From a2a4f957b770898a0f87f15aa01bdfc632baff86 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Wed, 7 Jan 2026 09:59:10 +0900 Subject: [PATCH 001/528] =?UTF-8?q?feat:=20Polly=20TTS=20=EC=9D=8C?= =?UTF-8?q?=EC=84=B1=20=EC=BA=90=EC=8B=B1=20=EB=B0=8F=20DynamoDB=20?= =?UTF-8?q?=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 주요 변경사항 ### 음성 캐싱 기능 - 메시지별 음성 S3 키 저장 (maleVoiceKey, femaleVoiceKey) - 캐시 히트 시 Polly 호출 없이 S3 URL 반환 - API 요청 변경: text → messageId, roomId ### DynamoDB 최적화 - GSI2 추가: messageId로 직접 조회 (풀스캔 방지) - findByUserId 페이지네이션 추가 (OOM 방지) - updateLastMessageAt: UpdateExpression으로 N+1 해결 ### 보안 강화 - 채팅방 비밀번호 BCrypt 해싱 ### 성능 개선 - DynamoDbClient Singleton 패턴 (Cold Start 최적화) Closes #9 --- chatting/ChatFunction/build.gradle | 3 + .../chatting/handler/ChatMessageHandler.java | 9 +- .../chatting/handler/ChatRoomHandler.java | 11 +- .../chatting/handler/ChatVoiceHandler.java | 61 +++++++++- .../chatting/model/ChatMessage.java | 18 +++ .../repository/ChatMessageRepository.java | 59 +++++---- .../repository/ChatRoomRepository.java | 37 ++++-- .../chatting/service/ChatMessageService.java | 6 +- .../chatting/service/PollyService.java | 115 ++++++++++++++---- chatting/template.yaml | 14 +++ 10 files changed, 261 insertions(+), 72 deletions(-) diff --git a/chatting/ChatFunction/build.gradle b/chatting/ChatFunction/build.gradle index a2a01f69..5ea65dba 100644 --- a/chatting/ChatFunction/build.gradle +++ b/chatting/ChatFunction/build.gradle @@ -31,6 +31,9 @@ dependencies { // JSON Processing implementation 'com.google.code.gson:gson:2.10.1' + // Password Hashing + implementation 'org.mindrot:jbcrypt:0.4' + // Logging implementation 'com.amazonaws:aws-lambda-java-log4j2:1.6.0' implementation 'org.apache.logging.log4j:log4j-api:2.22.1' diff --git a/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatMessageHandler.java b/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatMessageHandler.java index d82fe950..fb700dae 100644 --- a/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatMessageHandler.java +++ b/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatMessageHandler.java @@ -77,6 +77,8 @@ private APIGatewayProxyResponseEvent handlePost(APIGatewayProxyRequestEvent requ .sk("MSG#" + now + "#" + messageId) .gsi1pk("USER#" + userId) .gsi1sk("MSG#" + now) + .gsi2pk("MSG#" + messageId) // GSI2: messageId로 직접 조회용 + .gsi2sk("ROOM#" + roomId) .messageId(messageId) .roomId(roomId) .userId(userId) @@ -87,11 +89,8 @@ private APIGatewayProxyResponseEvent handlePost(APIGatewayProxyRequestEvent requ ChatMessage savedMessage = chatMessageService.saveMessage(message); - // 채팅방 lastMessageAt 업데이트 - chatRoomRepository.findById(roomId).ifPresent(room -> { - room.setLastMessageAt(now); - chatRoomRepository.save(room); - }); + // 채팅방 lastMessageAt 업데이트 (UpdateExpression으로 1회 호출) + chatRoomRepository.updateLastMessageAt(roomId, now); logger.info("Message sent: {} in room: {}", messageId, roomId); return createResponse(201, ApiResponse.success("Message sent", savedMessage)); diff --git a/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatRoomHandler.java b/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatRoomHandler.java index 3f7c0460..d1414843 100644 --- a/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatRoomHandler.java +++ b/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatRoomHandler.java @@ -9,6 +9,7 @@ import com.mzc.secondproject.serverless.chatting.dto.ApiResponse; import com.mzc.secondproject.serverless.chatting.model.ChatRoom; import com.mzc.secondproject.serverless.chatting.repository.ChatRoomRepository; +import org.mindrot.jbcrypt.BCrypt; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -108,7 +109,7 @@ private APIGatewayProxyResponseEvent createRoom(APIGatewayProxyRequestEvent requ .currentMembers(1) // 방장 포함 .maxMembers(maxMembers) .isPrivate(isPrivate) - .password(isPrivate ? password : null) + .password(isPrivate && password != null ? BCrypt.hashpw(password, BCrypt.gensalt()) : null) .createdBy(createdBy) .createdAt(now) .lastMessageAt(now) @@ -203,9 +204,11 @@ private APIGatewayProxyResponseEvent joinRoom(APIGatewayProxyRequestEvent reques ChatRoom room = optRoom.get(); - // 비밀번호 확인 - if (room.getIsPrivate() && !room.getPassword().equals(password)) { - return createResponse(403, ApiResponse.error("Invalid password")); + // 비밀번호 확인 (BCrypt 해시 검증) + if (room.getIsPrivate()) { + if (password == null || room.getPassword() == null || !BCrypt.checkpw(password, room.getPassword())) { + return createResponse(403, ApiResponse.error("Invalid password")); + } } // 인원 확인 diff --git a/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatVoiceHandler.java b/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatVoiceHandler.java index 2d97c11b..90f711cd 100644 --- a/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatVoiceHandler.java +++ b/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatVoiceHandler.java @@ -7,11 +7,15 @@ import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.mzc.secondproject.serverless.chatting.dto.ApiResponse; +import com.mzc.secondproject.serverless.chatting.model.ChatMessage; +import com.mzc.secondproject.serverless.chatting.repository.ChatMessageRepository; import com.mzc.secondproject.serverless.chatting.service.PollyService; +import com.mzc.secondproject.serverless.chatting.service.PollyService.VoiceSynthesisResult; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.Map; +import java.util.Optional; public class ChatVoiceHandler implements RequestHandler { @@ -19,9 +23,11 @@ public class ChatVoiceHandler implements RequestHandler requestBody = gson.fromJson(body, Map.class); - String text = requestBody.get("text"); + String messageId = requestBody.get("messageId"); + String roomId = requestBody.get("roomId"); String voice = requestBody.getOrDefault("voice", "FEMALE"); - if (text == null || text.isEmpty()) { - return createResponse(400, ApiResponse.error("text is required")); + if (messageId == null || messageId.isEmpty()) { + return createResponse(400, ApiResponse.error("messageId is required")); } + if (roomId == null || roomId.isEmpty()) { + return createResponse(400, ApiResponse.error("roomId is required")); + } + + // 메시지 조회 + Optional messageOpt = messageRepository.findByRoomIdAndMessageId(roomId, messageId); + if (messageOpt.isEmpty()) { + return createResponse(404, ApiResponse.error("Message not found")); + } + + ChatMessage message = messageOpt.get(); + boolean isMale = "MALE".equalsIgnoreCase(voice); + + // 캐시된 음성 키 확인 + String cachedKey = isMale ? message.getMaleVoiceKey() : message.getFemaleVoiceKey(); - String audioUrl = pollyService.synthesizeSpeech(text, voice); + String audioUrl; + boolean cached; + + if (cachedKey != null && !cachedKey.isEmpty()) { + // 캐시 히트: DynamoDB에 키가 있으면 S3에서 URL 생성 + logger.info("DB cache hit for message: {}, voice: {}", messageId, voice); + audioUrl = pollyService.getPresignedUrl(cachedKey); + cached = true; + } else { + // 캐시 미스: Polly 변환 → S3 저장 → DynamoDB 업데이트 + VoiceSynthesisResult result = pollyService.synthesizeSpeechForMessage( + messageId, message.getContent(), voice); + + // DynamoDB에 S3 키 저장 + if (isMale) { + message.setMaleVoiceKey(result.getS3Key()); + } else { + message.setFemaleVoiceKey(result.getS3Key()); + } + messageRepository.save(message); + + audioUrl = result.getAudioUrl(); + cached = result.isCached(); + } - return createResponse(200, ApiResponse.success("Speech synthesized", Map.of("audioUrl", audioUrl))); + return createResponse(200, ApiResponse.success( + cached ? "Speech retrieved from cache" : "Speech synthesized", + Map.of( + "audioUrl", audioUrl, + "cached", cached + ) + )); } catch (Exception e) { logger.error("Error synthesizing speech", e); diff --git a/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/model/ChatMessage.java b/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/model/ChatMessage.java index b7a1a21a..d0a2726e 100644 --- a/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/model/ChatMessage.java +++ b/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/model/ChatMessage.java @@ -22,6 +22,8 @@ public class ChatMessage { private String sk; // MSG#{timestamp}#{messageId} private String gsi1pk; // USER#{userId} private String gsi1sk; // MSG#{timestamp} + private String gsi2pk; // MSG#{messageId} - messageId로 직접 조회용 + private String gsi2sk; // ROOM#{roomId} private String messageId; private String roomId; @@ -31,6 +33,10 @@ public class ChatMessage { private String createdAt; private Long ttl; + // 음성 캐시용 S3 키 (voice/{messageId}_{voice}.mp3) + private String maleVoiceKey; + private String femaleVoiceKey; + @DynamoDbPartitionKey @DynamoDbAttribute("PK") public String getPk() { @@ -54,4 +60,16 @@ public String getGsi1pk() { public String getGsi1sk() { return gsi1sk; } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI2") + @DynamoDbAttribute("GSI2PK") + public String getGsi2pk() { + return gsi2pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI2") + @DynamoDbAttribute("GSI2SK") + public String getGsi2sk() { + return gsi2sk; + } } diff --git a/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/repository/ChatMessageRepository.java b/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/repository/ChatMessageRepository.java index 5b679862..9bd2ba31 100644 --- a/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/repository/ChatMessageRepository.java +++ b/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/repository/ChatMessageRepository.java @@ -23,16 +23,16 @@ public class ChatMessageRepository { private static final Logger logger = LoggerFactory.getLogger(ChatMessageRepository.class); - private final DynamoDbEnhancedClient enhancedClient; + // Singleton 패턴으로 Cold Start 최적화 + private static final DynamoDbClient dynamoDbClient = DynamoDbClient.builder().build(); + private static final DynamoDbEnhancedClient enhancedClient = DynamoDbEnhancedClient.builder() + .dynamoDbClient(dynamoDbClient) + .build(); + private static final String tableName = System.getenv("CHAT_TABLE_NAME"); + private final DynamoDbTable table; public ChatMessageRepository() { - DynamoDbClient dynamoDbClient = DynamoDbClient.builder().build(); - this.enhancedClient = DynamoDbEnhancedClient.builder() - .dynamoDbClient(dynamoDbClient) - .build(); - - String tableName = System.getenv("CHAT_TABLE_NAME"); this.table = enhancedClient.table(tableName, TableSchema.fromBean(ChatMessage.class)); } @@ -43,13 +43,18 @@ public ChatMessage save(ChatMessage message) { } public Optional findByRoomIdAndMessageId(String roomId, String messageId) { - Key key = Key.builder() - .partitionValue("ROOM#" + roomId) - .sortValue("MSG#" + messageId) - .build(); + // GSI2를 사용하여 messageId로 직접 조회 (풀스캔 방지) + QueryConditional queryConditional = QueryConditional + .keyEqualTo(Key.builder() + .partitionValue("MSG#" + messageId) + .sortValue("ROOM#" + roomId) + .build()); - ChatMessage message = table.getItem(key); - return Optional.ofNullable(message); + return table.index("GSI2") + .query(queryConditional) + .stream() + .flatMap(page -> page.items().stream()) + .findFirst(); } /** @@ -127,20 +132,32 @@ private Map decodeCursor(String cursor) { } } - public List findByUserId(String userId) { + /** + * 사용자별 메시지 조회 - 페이지네이션 지원 (OOM 방지) + */ + public MessagePage findByUserIdWithPagination(String userId, int limit, String cursor) { QueryConditional queryConditional = QueryConditional .keyEqualTo(Key.builder().partitionValue("USER#" + userId).build()); - QueryEnhancedRequest request = QueryEnhancedRequest.builder() + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() .queryConditional(queryConditional) .scanIndexForward(false) // 최신순 - .build(); + .limit(limit); - return table.index("GSI1") - .query(request) - .stream() - .flatMap(page -> page.items().stream()) - .toList(); + if (cursor != null && !cursor.isEmpty()) { + Map exclusiveStartKey = decodeCursor(cursor); + if (exclusiveStartKey != null) { + requestBuilder.exclusiveStartKey(exclusiveStartKey); + } + } + + Page page = table.index("GSI1") + .query(requestBuilder.build()) + .iterator() + .next(); + + String nextCursor = encodeCursor(page.lastEvaluatedKey()); + return new MessagePage(page.items(), nextCursor); } /** diff --git a/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/repository/ChatRoomRepository.java b/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/repository/ChatRoomRepository.java index 110f6f69..4b5cef43 100644 --- a/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/repository/ChatRoomRepository.java +++ b/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/repository/ChatRoomRepository.java @@ -13,6 +13,7 @@ import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; import software.amazon.awssdk.services.dynamodb.DynamoDbClient; import software.amazon.awssdk.services.dynamodb.model.AttributeValue; +import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest; import java.util.Base64; import java.util.HashMap; @@ -24,16 +25,16 @@ public class ChatRoomRepository { private static final Logger logger = LoggerFactory.getLogger(ChatRoomRepository.class); - private final DynamoDbEnhancedClient enhancedClient; + // Singleton 패턴으로 Cold Start 최적화 + private static final DynamoDbClient dynamoDbClient = DynamoDbClient.builder().build(); + private static final DynamoDbEnhancedClient enhancedClient = DynamoDbEnhancedClient.builder() + .dynamoDbClient(dynamoDbClient) + .build(); + private static final String tableName = System.getenv("CHAT_TABLE_NAME"); + private final DynamoDbTable table; public ChatRoomRepository() { - DynamoDbClient dynamoDbClient = DynamoDbClient.builder().build(); - this.enhancedClient = DynamoDbEnhancedClient.builder() - .dynamoDbClient(dynamoDbClient) - .build(); - - String tableName = System.getenv("CHAT_TABLE_NAME"); this.table = enhancedClient.table(tableName, TableSchema.fromBean(ChatRoom.class)); } @@ -166,6 +167,28 @@ public void delete(String roomId) { logger.info("Deleted room: {}", roomId); } + /** + * 채팅방 lastMessageAt 업데이트 (N+1 방지 - UpdateExpression 사용) + */ + public void updateLastMessageAt(String roomId, String timestamp) { + Map key = new HashMap<>(); + key.put("PK", AttributeValue.builder().s("ROOM#" + roomId).build()); + key.put("SK", AttributeValue.builder().s("METADATA").build()); + + Map expressionValues = new HashMap<>(); + expressionValues.put(":ts", AttributeValue.builder().s(timestamp).build()); + + UpdateItemRequest updateRequest = UpdateItemRequest.builder() + .tableName(tableName) + .key(key) + .updateExpression("SET lastMessageAt = :ts") + .expressionAttributeValues(expressionValues) + .build(); + + dynamoDbClient.updateItem(updateRequest); + logger.info("Updated lastMessageAt for room: {}", roomId); + } + /** * 페이지네이션 결과 클래스 */ diff --git a/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/service/ChatMessageService.java b/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/service/ChatMessageService.java index cc983176..a7511066 100644 --- a/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/service/ChatMessageService.java +++ b/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/service/ChatMessageService.java @@ -33,8 +33,8 @@ public ChatMessageRepository.MessagePage getMessagesByRoomWithPagination(String return repository.findByRoomIdWithPagination(roomId, limit, cursor); } - public List getMessagesByUser(String userId) { - logger.info("Getting messages for user: {}", userId); - return repository.findByUserId(userId); + public ChatMessageRepository.MessagePage getMessagesByUserWithPagination(String userId, int limit, String cursor) { + logger.info("Getting messages for user: {} with limit: {}", userId, limit); + return repository.findByUserIdWithPagination(userId, limit, cursor); } } diff --git a/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/service/PollyService.java b/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/service/PollyService.java index b11e2134..57adbd98 100644 --- a/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/service/PollyService.java +++ b/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/service/PollyService.java @@ -14,10 +14,12 @@ import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest; import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.HeadObjectRequest; +import software.amazon.awssdk.services.s3.model.NoSuchKeyException; + import java.io.ByteArrayOutputStream; import java.io.InputStream; import java.time.Duration; -import java.util.UUID; public class PollyService { @@ -35,13 +37,65 @@ public PollyService() { this.bucketName = System.getenv("CHAT_BUCKET_NAME"); } - public String synthesizeSpeech(String text) { - return synthesizeSpeech(text, "FEMALE"); + /** + * 메시지 ID 기반으로 음성 합성 (캐시 지원) + * S3에 파일이 있으면 바로 URL 반환, 없으면 Polly 변환 후 저장 + */ + public VoiceSynthesisResult synthesizeSpeechForMessage(String messageId, String text, String voice) { + String s3Key = generateS3Key(messageId, voice); + + // 캐시 확인: S3에 이미 존재하는지 체크 + if (existsInS3(s3Key)) { + logger.info("Cache hit: {}", s3Key); + String presignedUrl = getPresignedUrl(s3Key); + return new VoiceSynthesisResult(s3Key, presignedUrl, true); + } + + // 캐시 미스: Polly 변환 후 S3 저장 + logger.info("Cache miss: synthesizing and saving to {}", s3Key); + synthesizeAndSave(text, voice, s3Key); + String presignedUrl = getPresignedUrl(s3Key); + return new VoiceSynthesisResult(s3Key, presignedUrl, false); + } + + /** + * S3 키로 Pre-signed URL 생성 + */ + public String getPresignedUrl(String s3Key) { + GetObjectRequest getObjectRequest = GetObjectRequest.builder() + .bucket(bucketName) + .key(s3Key) + .build(); + + GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() + .signatureDuration(Duration.ofHours(1)) + .getObjectRequest(getObjectRequest) + .build(); + + PresignedGetObjectRequest presignedRequest = s3Presigner.presignGetObject(presignRequest); + return presignedRequest.url().toString(); + } + + /** + * S3에 파일 존재 여부 확인 + */ + public boolean existsInS3(String s3Key) { + try { + s3Client.headObject(HeadObjectRequest.builder() + .bucket(bucketName) + .key(s3Key) + .build()); + return true; + } catch (NoSuchKeyException e) { + return false; + } } - public String synthesizeSpeech(String text, String voice) { + /** + * Polly로 음성 변환 후 지정된 S3 키로 저장 + */ + private void synthesizeAndSave(String text, String voice, String s3Key) { VoiceId voiceId = resolveVoiceId(voice); - logger.info("Synthesizing speech with voice: {}", voiceId); try { SynthesizeSpeechRequest request = SynthesizeSpeechRequest.builder() @@ -53,7 +107,6 @@ public String synthesizeSpeech(String text, String voice) { InputStream audioStream = pollyClient.synthesizeSpeech(request); - // InputStream을 byte[]로 변환 ByteArrayOutputStream buffer = new ByteArrayOutputStream(); byte[] data = new byte[4096]; int bytesRead; @@ -62,41 +115,49 @@ public String synthesizeSpeech(String text, String voice) { } byte[] audioBytes = buffer.toByteArray(); - // S3에 저장 - String audioKey = "voice/" + UUID.randomUUID() + ".mp3"; - s3Client.putObject( PutObjectRequest.builder() .bucket(bucketName) - .key(audioKey) + .key(s3Key) .contentType("audio/mpeg") .build(), RequestBody.fromBytes(audioBytes) ); - // Pre-signed URL 생성 (1시간 유효) - GetObjectRequest getObjectRequest = GetObjectRequest.builder() - .bucket(bucketName) - .key(audioKey) - .build(); - - GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() - .signatureDuration(Duration.ofHours(1)) - .getObjectRequest(getObjectRequest) - .build(); - - PresignedGetObjectRequest presignedRequest = s3Presigner.presignGetObject(presignRequest); - String presignedUrl = presignedRequest.url().toString(); - - logger.info("Generated pre-signed URL for audio: {}", audioKey); - return presignedUrl; - + logger.info("Saved audio to S3: {}", s3Key); } catch (Exception e) { logger.error("Error synthesizing speech", e); throw new RuntimeException("Failed to synthesize speech", e); } } + /** + * 메시지 ID와 음성 타입으로 S3 키 생성 + */ + public String generateS3Key(String messageId, String voice) { + String voiceSuffix = "MALE".equalsIgnoreCase(voice) ? "male" : "female"; + return "voice/" + messageId + "_" + voiceSuffix + ".mp3"; + } + + /** + * 음성 합성 결과 + */ + public static class VoiceSynthesisResult { + private final String s3Key; + private final String audioUrl; + private final boolean cached; + + public VoiceSynthesisResult(String s3Key, String audioUrl, boolean cached) { + this.s3Key = s3Key; + this.audioUrl = audioUrl; + this.cached = cached; + } + + public String getS3Key() { return s3Key; } + public String getAudioUrl() { return audioUrl; } + public boolean isCached() { return cached; } + } + private VoiceId resolveVoiceId(String voice) { if ("MALE".equalsIgnoreCase(voice)) { return VoiceId.MATTHEW; // 미국 영어 남성 (Neural 지원) diff --git a/chatting/template.yaml b/chatting/template.yaml index 66baddd2..1f10da75 100644 --- a/chatting/template.yaml +++ b/chatting/template.yaml @@ -158,6 +158,8 @@ Resources: SnapStart: ApplyOn: PublishedVersions Policies: + - DynamoDBCrudPolicy: + TableName: !Ref ChatTable - S3CrudPolicy: BucketName: group2-englishstudy - Statement: @@ -206,6 +208,10 @@ Resources: AttributeType: S - AttributeName: GSI1SK AttributeType: S + - AttributeName: GSI2PK + AttributeType: S + - AttributeName: GSI2SK + AttributeType: S KeySchema: - AttributeName: PK KeyType: HASH @@ -220,6 +226,14 @@ Resources: KeyType: RANGE Projection: ProjectionType: ALL + - IndexName: GSI2 + KeySchema: + - AttributeName: GSI2PK + KeyType: HASH + - AttributeName: GSI2SK + KeyType: RANGE + Projection: + ProjectionType: ALL TimeToLiveSpecification: AttributeName: ttl Enabled: true From 3b28e1cbfdbb2c944d10d5ef4cebb2e5098a287b Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Wed, 7 Jan 2026 10:06:49 +0900 Subject: [PATCH 002/528] =?UTF-8?q?chore:=20Lambda=20=ED=95=A8=EC=88=98?= =?UTF-8?q?=EB=AA=85=EC=97=90=20chat-=20prefix=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 도메인 구분을 위해 함수명 변경: - room-handler → chat-room-handler - message-handler → chat-message-handler - ai-handler → chat-ai-handler - voice-handler → chat-voice-handler --- chatting/template.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/chatting/template.yaml b/chatting/template.yaml index 1f10da75..e9779779 100644 --- a/chatting/template.yaml +++ b/chatting/template.yaml @@ -24,7 +24,7 @@ Resources: ChatRoomFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-room-handler + FunctionName: group2-englishstudy-chat-room-handler CodeUri: ChatFunction Handler: com.mzc.secondproject.serverless.chatting.handler.ChatRoomHandler::handleRequest Description: Handle chat room CRUD operations @@ -75,7 +75,7 @@ Resources: ChatMessageFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-message-handler + FunctionName: group2-englishstudy-chat-message-handler CodeUri: ChatFunction Handler: com.mzc.secondproject.serverless.chatting.handler.ChatMessageHandler::handleRequest Description: Handle chat messages @@ -122,7 +122,7 @@ Resources: ChatAIFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-ai-handler + FunctionName: group2-englishstudy-chat-ai-handler CodeUri: ChatFunction Handler: com.mzc.secondproject.serverless.chatting.handler.ChatAIHandler::handleRequest Description: Generate AI responses using Bedrock @@ -151,7 +151,7 @@ Resources: ChatVoiceFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-voice-handler + FunctionName: group2-englishstudy-chat-voice-handler CodeUri: ChatFunction Handler: com.mzc.secondproject.serverless.chatting.handler.ChatVoiceHandler::handleRequest Description: Convert text to speech using Polly From 4e5005d11c2a6ff308cd0991130089b3a383a3b0 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Wed, 7 Jan 2026 12:29:21 +0900 Subject: [PATCH 003/528] =?UTF-8?q?feat:=20=EB=8B=A8=EC=96=B4=20=EC=95=94?= =?UTF-8?q?=EA=B8=B0=20=EC=84=9C=EB=B9=84=EC=8A=A4=20(vocabulary)=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DynamoDB Single Table Design (Word, UserWord, DailyStudy, TestResult) - 6개 Lambda Handler (Word, UserWord, DailyStudy, Test, Stats, Voice) - Spaced Repetition 알고리즘 적용 - Polly TTS 음성 캐싱 (S3 + Pre-signed URL) - 일일 학습 55개 단어 (50개 신규 + 5개 복습) - 시험 기능 및 통계 대시보드 --- vocabulary/VocabFunction/build.gradle | 60 ++++ vocabulary/VocabFunction/gradlew | 234 +++++++++++++ vocabulary/VocabFunction/gradlew.bat | 89 +++++ .../vocabulary/dto/ApiResponse.java | 33 ++ .../vocabulary/handler/DailyStudyHandler.java | 243 ++++++++++++++ .../vocabulary/handler/StatsHandler.java | 179 ++++++++++ .../vocabulary/handler/TestHandler.java | 240 +++++++++++++ .../vocabulary/handler/UserWordHandler.java | 223 +++++++++++++ .../vocabulary/handler/VoiceHandler.java | 123 +++++++ .../vocabulary/handler/WordHandler.java | 234 +++++++++++++ .../vocabulary/model/DailyStudy.java | 74 +++++ .../vocabulary/model/TestResult.java | 74 +++++ .../serverless/vocabulary/model/UserWord.java | 88 +++++ .../serverless/vocabulary/model/Word.java | 83 +++++ .../repository/DailyStudyRepository.java | 161 +++++++++ .../repository/TestResultRepository.java | 137 ++++++++ .../repository/UserWordRepository.java | 193 +++++++++++ .../vocabulary/repository/WordRepository.java | 170 ++++++++++ .../vocabulary/service/PollyService.java | 160 +++++++++ .../src/main/resources/log4j2.xml | 17 + vocabulary/template.yaml | 314 ++++++++++++++++++ 21 files changed, 3129 insertions(+) create mode 100644 vocabulary/VocabFunction/build.gradle create mode 100755 vocabulary/VocabFunction/gradlew create mode 100644 vocabulary/VocabFunction/gradlew.bat create mode 100644 vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/dto/ApiResponse.java create mode 100644 vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/DailyStudyHandler.java create mode 100644 vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/StatsHandler.java create mode 100644 vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/TestHandler.java create mode 100644 vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/UserWordHandler.java create mode 100644 vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/VoiceHandler.java create mode 100644 vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/WordHandler.java create mode 100644 vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/DailyStudy.java create mode 100644 vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/TestResult.java create mode 100644 vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/UserWord.java create mode 100644 vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/Word.java create mode 100644 vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/DailyStudyRepository.java create mode 100644 vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/TestResultRepository.java create mode 100644 vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/UserWordRepository.java create mode 100644 vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/WordRepository.java create mode 100644 vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/service/PollyService.java create mode 100644 vocabulary/VocabFunction/src/main/resources/log4j2.xml create mode 100644 vocabulary/template.yaml diff --git a/vocabulary/VocabFunction/build.gradle b/vocabulary/VocabFunction/build.gradle new file mode 100644 index 00000000..2dcb4f2b --- /dev/null +++ b/vocabulary/VocabFunction/build.gradle @@ -0,0 +1,60 @@ +plugins { + id 'java' +} + +group = 'com.mzc.secondproject.serverless' +version = '1.0.0' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +repositories { + mavenCentral() +} + +dependencies { + // AWS Lambda Core + implementation 'com.amazonaws:aws-lambda-java-core:1.2.3' + implementation 'com.amazonaws:aws-lambda-java-events:3.11.4' + + // AWS SDK v2 + implementation platform('software.amazon.awssdk:bom:2.24.0') + implementation 'software.amazon.awssdk:dynamodb' + implementation 'software.amazon.awssdk:dynamodb-enhanced' + implementation 'software.amazon.awssdk:polly' + implementation 'software.amazon.awssdk:s3' + + // JSON Processing + implementation 'com.google.code.gson:gson:2.10.1' + + // Logging + implementation 'com.amazonaws:aws-lambda-java-log4j2:1.6.0' + implementation 'org.apache.logging.log4j:log4j-api:2.22.1' + implementation 'org.apache.logging.log4j:log4j-core:2.22.1' + implementation 'org.apache.logging.log4j:log4j-slf4j2-impl:2.22.1' + + // Lombok + compileOnly 'org.projectlombok:lombok:1.18.30' + annotationProcessor 'org.projectlombok:lombok:1.18.30' + + // Testing + testImplementation 'org.junit.jupiter:junit-jupiter:5.10.1' + testImplementation 'org.mockito:mockito-core:5.8.0' +} + +test { + useJUnitPlatform() +} + +task buildZip(type: Zip) { + from compileJava + from processResources + into('lib') { + from configurations.runtimeClasspath + } +} + +build.dependsOn buildZip diff --git a/vocabulary/VocabFunction/gradlew b/vocabulary/VocabFunction/gradlew new file mode 100755 index 00000000..1b6c7873 --- /dev/null +++ b/vocabulary/VocabFunction/gradlew @@ -0,0 +1,234 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/vocabulary/VocabFunction/gradlew.bat b/vocabulary/VocabFunction/gradlew.bat new file mode 100644 index 00000000..107acd32 --- /dev/null +++ b/vocabulary/VocabFunction/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/dto/ApiResponse.java b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/dto/ApiResponse.java new file mode 100644 index 00000000..acd76c92 --- /dev/null +++ b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/dto/ApiResponse.java @@ -0,0 +1,33 @@ +package com.mzc.secondproject.serverless.vocabulary.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ApiResponse { + + private boolean success; + private String message; + private T data; + private String error; + + public static ApiResponse success(String message, T data) { + return ApiResponse.builder() + .success(true) + .message(message) + .data(data) + .build(); + } + + public static ApiResponse error(String errorMessage) { + return ApiResponse.builder() + .success(false) + .error(errorMessage) + .build(); + } +} diff --git a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/DailyStudyHandler.java b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/DailyStudyHandler.java new file mode 100644 index 00000000..aeabd3cd --- /dev/null +++ b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/DailyStudyHandler.java @@ -0,0 +1,243 @@ +package com.mzc.secondproject.serverless.vocabulary.handler; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.mzc.secondproject.serverless.vocabulary.dto.ApiResponse; +import com.mzc.secondproject.serverless.vocabulary.model.DailyStudy; +import com.mzc.secondproject.serverless.vocabulary.model.UserWord; +import com.mzc.secondproject.serverless.vocabulary.model.Word; +import com.mzc.secondproject.serverless.vocabulary.repository.DailyStudyRepository; +import com.mzc.secondproject.serverless.vocabulary.repository.UserWordRepository; +import com.mzc.secondproject.serverless.vocabulary.repository.WordRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +public class DailyStudyHandler implements RequestHandler { + + private static final Logger logger = LoggerFactory.getLogger(DailyStudyHandler.class); + private static final Gson gson = new GsonBuilder().setPrettyPrinting().create(); + + private static final int NEW_WORDS_COUNT = 50; + private static final int REVIEW_WORDS_COUNT = 5; + + private final DailyStudyRepository dailyStudyRepository; + private final UserWordRepository userWordRepository; + private final WordRepository wordRepository; + + public DailyStudyHandler() { + this.dailyStudyRepository = new DailyStudyRepository(); + this.userWordRepository = new UserWordRepository(); + this.wordRepository = new WordRepository(); + } + + @Override + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { + String httpMethod = request.getHttpMethod(); + String path = request.getPath(); + + logger.info("Received request: {} {}", httpMethod, path); + + try { + // GET /vocab/daily/{userId} - 오늘의 학습 단어 + if ("GET".equals(httpMethod) && !path.contains("/learned")) { + return getDailyWords(request); + } + + // POST /vocab/daily/{userId}/words/{wordId}/learned - 학습 완료 + if ("POST".equals(httpMethod) && path.endsWith("/learned")) { + return markWordLearned(request); + } + + return createResponse(404, ApiResponse.error("Not found")); + + } catch (Exception e) { + logger.error("Error handling request", e); + return createResponse(500, ApiResponse.error("Internal server error: " + e.getMessage())); + } + } + + private APIGatewayProxyResponseEvent getDailyWords(APIGatewayProxyRequestEvent request) { + Map pathParams = request.getPathParameters(); + String userId = pathParams != null ? pathParams.get("userId") : null; + + if (userId == null) { + return createResponse(400, ApiResponse.error("userId is required")); + } + + String today = LocalDate.now().toString(); + + // 오늘의 학습 데이터 조회 + Optional optDailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today); + + DailyStudy dailyStudy; + if (optDailyStudy.isPresent()) { + dailyStudy = optDailyStudy.get(); + } else { + // 새로운 일일 학습 생성 + dailyStudy = createDailyStudy(userId, today); + } + + // 단어 상세 정보 조회 + List newWords = getWordDetails(dailyStudy.getNewWordIds()); + List reviewWords = getWordDetails(dailyStudy.getReviewWordIds()); + + Map result = new HashMap<>(); + result.put("dailyStudy", dailyStudy); + result.put("newWords", newWords); + result.put("reviewWords", reviewWords); + result.put("progress", calculateProgress(dailyStudy)); + + return createResponse(200, ApiResponse.success("Daily words retrieved", result)); + } + + private DailyStudy createDailyStudy(String userId, String date) { + String now = Instant.now().toString(); + + // 복습 대상 단어 조회 (5개) + UserWordRepository.UserWordPage reviewPage = userWordRepository.findReviewDueWords(userId, date, REVIEW_WORDS_COUNT, null); + List reviewWordIds = reviewPage.getUserWords().stream() + .map(UserWord::getWordId) + .collect(Collectors.toList()); + + // 신규 단어 조회 (50개) - 아직 학습하지 않은 단어 + List newWordIds = getNewWordsForUser(userId, NEW_WORDS_COUNT); + + DailyStudy dailyStudy = DailyStudy.builder() + .pk("DAILY#" + userId) + .sk("DATE#" + date) + .gsi1pk("DAILY#ALL") + .gsi1sk("DATE#" + date) + .userId(userId) + .date(date) + .newWordIds(newWordIds) + .reviewWordIds(reviewWordIds) + .learnedWordIds(new ArrayList<>()) + .totalWords(newWordIds.size() + reviewWordIds.size()) + .learnedCount(0) + .isCompleted(false) + .createdAt(now) + .updatedAt(now) + .build(); + + dailyStudyRepository.save(dailyStudy); + logger.info("Created daily study for user: {}, date: {}", userId, date); + + return dailyStudy; + } + + private List getNewWordsForUser(String userId, int count) { + // 사용자가 학습한 단어 목록 + UserWordRepository.UserWordPage userWordPage = userWordRepository.findByUserIdWithPagination(userId, 1000, null); + List learnedWordIds = userWordPage.getUserWords().stream() + .map(UserWord::getWordId) + .collect(Collectors.toList()); + + // 전체 단어에서 학습하지 않은 단어 선택 + List newWordIds = new ArrayList<>(); + String[] levels = {"BEGINNER", "INTERMEDIATE", "ADVANCED"}; + + for (String level : levels) { + if (newWordIds.size() >= count) break; + + WordRepository.WordPage wordPage = wordRepository.findByLevelWithPagination(level, count * 2, null); + for (Word word : wordPage.getWords()) { + if (!learnedWordIds.contains(word.getWordId()) && !newWordIds.contains(word.getWordId())) { + newWordIds.add(word.getWordId()); + if (newWordIds.size() >= count) break; + } + } + } + + return newWordIds; + } + + private List getWordDetails(List wordIds) { + if (wordIds == null || wordIds.isEmpty()) { + return new ArrayList<>(); + } + + List words = new ArrayList<>(); + for (String wordId : wordIds) { + wordRepository.findById(wordId).ifPresent(words::add); + } + return words; + } + + private Map calculateProgress(DailyStudy dailyStudy) { + Map progress = new HashMap<>(); + int total = dailyStudy.getTotalWords(); + int learned = dailyStudy.getLearnedCount(); + + progress.put("total", total); + progress.put("learned", learned); + progress.put("remaining", total - learned); + progress.put("percentage", total > 0 ? (learned * 100.0 / total) : 0); + progress.put("isCompleted", dailyStudy.getIsCompleted()); + + return progress; + } + + private APIGatewayProxyResponseEvent markWordLearned(APIGatewayProxyRequestEvent request) { + Map pathParams = request.getPathParameters(); + String userId = pathParams != null ? pathParams.get("userId") : null; + String wordId = pathParams != null ? pathParams.get("wordId") : null; + + if (userId == null || wordId == null) { + return createResponse(400, ApiResponse.error("userId and wordId are required")); + } + + String today = LocalDate.now().toString(); + + Optional optDailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today); + if (optDailyStudy.isEmpty()) { + return createResponse(404, ApiResponse.error("Daily study not found")); + } + + DailyStudy dailyStudy = optDailyStudy.get(); + + // 이미 학습 완료된 단어인지 확인 + if (dailyStudy.getLearnedWordIds() != null && dailyStudy.getLearnedWordIds().contains(wordId)) { + return createResponse(200, ApiResponse.success("Already marked as learned", dailyStudy)); + } + + // 학습 완료 처리 + dailyStudyRepository.addLearnedWord(userId, today, wordId); + + // 업데이트된 데이터 조회 + DailyStudy updatedDailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today).orElse(dailyStudy); + + // 완료 여부 확인 + if (updatedDailyStudy.getLearnedCount() >= updatedDailyStudy.getTotalWords()) { + updatedDailyStudy.setIsCompleted(true); + dailyStudyRepository.save(updatedDailyStudy); + } + + logger.info("Marked word as learned: userId={}, wordId={}", userId, wordId); + return createResponse(200, ApiResponse.success("Word marked as learned", calculateProgress(updatedDailyStudy))); + } + + private APIGatewayProxyResponseEvent createResponse(int statusCode, Object body) { + return new APIGatewayProxyResponseEvent() + .withStatusCode(statusCode) + .withHeaders(Map.of( + "Content-Type", "application/json", + "Access-Control-Allow-Origin", "*", + "Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS", + "Access-Control-Allow-Headers", "Content-Type,Authorization" + )) + .withBody(gson.toJson(body)); + } +} diff --git a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/StatsHandler.java b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/StatsHandler.java new file mode 100644 index 00000000..2e680e22 --- /dev/null +++ b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/StatsHandler.java @@ -0,0 +1,179 @@ +package com.mzc.secondproject.serverless.vocabulary.handler; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.mzc.secondproject.serverless.vocabulary.dto.ApiResponse; +import com.mzc.secondproject.serverless.vocabulary.model.DailyStudy; +import com.mzc.secondproject.serverless.vocabulary.model.TestResult; +import com.mzc.secondproject.serverless.vocabulary.model.UserWord; +import com.mzc.secondproject.serverless.vocabulary.repository.DailyStudyRepository; +import com.mzc.secondproject.serverless.vocabulary.repository.TestResultRepository; +import com.mzc.secondproject.serverless.vocabulary.repository.UserWordRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class StatsHandler implements RequestHandler { + + private static final Logger logger = LoggerFactory.getLogger(StatsHandler.class); + private static final Gson gson = new GsonBuilder().setPrettyPrinting().create(); + + private final UserWordRepository userWordRepository; + private final DailyStudyRepository dailyStudyRepository; + private final TestResultRepository testResultRepository; + + public StatsHandler() { + this.userWordRepository = new UserWordRepository(); + this.dailyStudyRepository = new DailyStudyRepository(); + this.testResultRepository = new TestResultRepository(); + } + + @Override + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { + String httpMethod = request.getHttpMethod(); + String path = request.getPath(); + + logger.info("Received request: {} {}", httpMethod, path); + + try { + // GET /vocab/stats/{userId} - 전체 통계 + if ("GET".equals(httpMethod) && !path.endsWith("/daily")) { + return getOverallStats(request); + } + + // GET /vocab/stats/{userId}/daily - 일별 통계 + if ("GET".equals(httpMethod) && path.endsWith("/daily")) { + return getDailyStats(request); + } + + return createResponse(404, ApiResponse.error("Not found")); + + } catch (Exception e) { + logger.error("Error handling request", e); + return createResponse(500, ApiResponse.error("Internal server error: " + e.getMessage())); + } + } + + private APIGatewayProxyResponseEvent getOverallStats(APIGatewayProxyRequestEvent request) { + Map pathParams = request.getPathParameters(); + String userId = pathParams != null ? pathParams.get("userId") : null; + + if (userId == null) { + return createResponse(400, ApiResponse.error("userId is required")); + } + + // 단어 학습 상태별 통계 + Map wordStatusCounts = new HashMap<>(); + wordStatusCounts.put("NEW", 0); + wordStatusCounts.put("LEARNING", 0); + wordStatusCounts.put("REVIEWING", 0); + wordStatusCounts.put("MASTERED", 0); + + int totalCorrect = 0; + int totalIncorrect = 0; + + // 사용자 단어 통계 조회 + String cursor = null; + do { + UserWordRepository.UserWordPage page = userWordRepository.findByUserIdWithPagination(userId, 100, cursor); + for (UserWord userWord : page.getUserWords()) { + String status = userWord.getStatus(); + wordStatusCounts.merge(status, 1, Integer::sum); + totalCorrect += userWord.getCorrectCount() != null ? userWord.getCorrectCount() : 0; + totalIncorrect += userWord.getIncorrectCount() != null ? userWord.getIncorrectCount() : 0; + } + cursor = page.getNextCursor(); + } while (cursor != null); + + int totalWords = wordStatusCounts.values().stream().mapToInt(Integer::intValue).sum(); + + // 시험 통계 + TestResultRepository.TestResultPage testPage = testResultRepository.findByUserIdWithPagination(userId, 100, null); + List testResults = testPage.getTestResults(); + + double avgSuccessRate = testResults.stream() + .mapToDouble(TestResult::getSuccessRate) + .average() + .orElse(0.0); + + // 학습 일수 + DailyStudyRepository.DailyStudyPage dailyPage = dailyStudyRepository.findByUserIdWithPagination(userId, 365, null); + int studyDays = dailyPage.getDailyStudies().size(); + int completedDays = (int) dailyPage.getDailyStudies().stream() + .filter(d -> Boolean.TRUE.equals(d.getIsCompleted())) + .count(); + + Map stats = new HashMap<>(); + stats.put("totalWords", totalWords); + stats.put("wordStatusCounts", wordStatusCounts); + stats.put("totalCorrect", totalCorrect); + stats.put("totalIncorrect", totalIncorrect); + stats.put("accuracy", totalCorrect + totalIncorrect > 0 + ? (totalCorrect * 100.0 / (totalCorrect + totalIncorrect)) : 0); + stats.put("testCount", testResults.size()); + stats.put("avgSuccessRate", avgSuccessRate); + stats.put("studyDays", studyDays); + stats.put("completedDays", completedDays); + stats.put("completionRate", studyDays > 0 ? (completedDays * 100.0 / studyDays) : 0); + + return createResponse(200, ApiResponse.success("Stats retrieved", stats)); + } + + private APIGatewayProxyResponseEvent getDailyStats(APIGatewayProxyRequestEvent request) { + Map pathParams = request.getPathParameters(); + Map queryParams = request.getQueryStringParameters(); + + String userId = pathParams != null ? pathParams.get("userId") : null; + String cursor = queryParams != null ? queryParams.get("cursor") : null; + + if (userId == null) { + return createResponse(400, ApiResponse.error("userId is required")); + } + + int limit = 30; // 최근 30일 + if (queryParams != null && queryParams.get("limit") != null) { + limit = Math.min(Integer.parseInt(queryParams.get("limit")), 90); + } + + DailyStudyRepository.DailyStudyPage dailyPage = dailyStudyRepository.findByUserIdWithPagination(userId, limit, cursor); + + List> dailyStats = dailyPage.getDailyStudies().stream() + .map(daily -> { + Map stat = new HashMap<>(); + stat.put("date", daily.getDate()); + stat.put("totalWords", daily.getTotalWords()); + stat.put("learnedCount", daily.getLearnedCount()); + stat.put("isCompleted", daily.getIsCompleted()); + stat.put("progress", daily.getTotalWords() > 0 + ? (daily.getLearnedCount() * 100.0 / daily.getTotalWords()) : 0); + return stat; + }) + .toList(); + + Map result = new HashMap<>(); + result.put("dailyStats", dailyStats); + result.put("nextCursor", dailyPage.getNextCursor()); + result.put("hasMore", dailyPage.hasMore()); + + return createResponse(200, ApiResponse.success("Daily stats retrieved", result)); + } + + private APIGatewayProxyResponseEvent createResponse(int statusCode, Object body) { + return new APIGatewayProxyResponseEvent() + .withStatusCode(statusCode) + .withHeaders(Map.of( + "Content-Type", "application/json", + "Access-Control-Allow-Origin", "*", + "Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS", + "Access-Control-Allow-Headers", "Content-Type,Authorization" + )) + .withBody(gson.toJson(body)); + } +} diff --git a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/TestHandler.java b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/TestHandler.java new file mode 100644 index 00000000..ad70efab --- /dev/null +++ b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/TestHandler.java @@ -0,0 +1,240 @@ +package com.mzc.secondproject.serverless.vocabulary.handler; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.mzc.secondproject.serverless.vocabulary.dto.ApiResponse; +import com.mzc.secondproject.serverless.vocabulary.model.DailyStudy; +import com.mzc.secondproject.serverless.vocabulary.model.TestResult; +import com.mzc.secondproject.serverless.vocabulary.model.Word; +import com.mzc.secondproject.serverless.vocabulary.repository.DailyStudyRepository; +import com.mzc.secondproject.serverless.vocabulary.repository.TestResultRepository; +import com.mzc.secondproject.serverless.vocabulary.repository.WordRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +public class TestHandler implements RequestHandler { + + private static final Logger logger = LoggerFactory.getLogger(TestHandler.class); + private static final Gson gson = new GsonBuilder().setPrettyPrinting().create(); + + private final TestResultRepository testResultRepository; + private final DailyStudyRepository dailyStudyRepository; + private final WordRepository wordRepository; + + public TestHandler() { + this.testResultRepository = new TestResultRepository(); + this.dailyStudyRepository = new DailyStudyRepository(); + this.wordRepository = new WordRepository(); + } + + @Override + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { + String httpMethod = request.getHttpMethod(); + String path = request.getPath(); + + logger.info("Received request: {} {}", httpMethod, path); + + try { + // POST /vocab/test/{userId}/start - 시험 시작 + if ("POST".equals(httpMethod) && path.endsWith("/start")) { + return startTest(request); + } + + // POST /vocab/test/{userId}/submit - 답안 제출 + if ("POST".equals(httpMethod) && path.endsWith("/submit")) { + return submitAnswer(request); + } + + // GET /vocab/test/{userId}/results - 시험 결과 조회 + if ("GET".equals(httpMethod) && path.endsWith("/results")) { + return getTestResults(request); + } + + return createResponse(404, ApiResponse.error("Not found")); + + } catch (Exception e) { + logger.error("Error handling request", e); + return createResponse(500, ApiResponse.error("Internal server error: " + e.getMessage())); + } + } + + private APIGatewayProxyResponseEvent startTest(APIGatewayProxyRequestEvent request) { + Map pathParams = request.getPathParameters(); + String userId = pathParams != null ? pathParams.get("userId") : null; + + if (userId == null) { + return createResponse(400, ApiResponse.error("userId is required")); + } + + String body = request.getBody(); + Map requestBody = gson.fromJson(body, Map.class); + String testType = (String) requestBody.getOrDefault("testType", "DAILY"); + + String today = LocalDate.now().toString(); + + // 오늘 학습한 단어 기반 시험 + Optional optDailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today); + if (optDailyStudy.isEmpty()) { + return createResponse(404, ApiResponse.error("No daily study found for today")); + } + + DailyStudy dailyStudy = optDailyStudy.get(); + List allWordIds = new ArrayList<>(); + if (dailyStudy.getNewWordIds() != null) allWordIds.addAll(dailyStudy.getNewWordIds()); + if (dailyStudy.getReviewWordIds() != null) allWordIds.addAll(dailyStudy.getReviewWordIds()); + + if (allWordIds.isEmpty()) { + return createResponse(400, ApiResponse.error("No words to test")); + } + + // 시험 문제 생성 + List> questions = new ArrayList<>(); + for (String wordId : allWordIds) { + Optional optWord = wordRepository.findById(wordId); + if (optWord.isPresent()) { + Word word = optWord.get(); + Map question = new HashMap<>(); + question.put("wordId", word.getWordId()); + question.put("english", word.getEnglish()); + question.put("example", word.getExample()); + questions.add(question); + } + } + + String testId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + + Map result = new HashMap<>(); + result.put("testId", testId); + result.put("testType", testType); + result.put("questions", questions); + result.put("totalQuestions", questions.size()); + result.put("startedAt", now); + + logger.info("Started test: userId={}, testId={}, questions={}", userId, testId, questions.size()); + return createResponse(200, ApiResponse.success("Test started", result)); + } + + @SuppressWarnings("unchecked") + private APIGatewayProxyResponseEvent submitAnswer(APIGatewayProxyRequestEvent request) { + Map pathParams = request.getPathParameters(); + String userId = pathParams != null ? pathParams.get("userId") : null; + + if (userId == null) { + return createResponse(400, ApiResponse.error("userId is required")); + } + + String body = request.getBody(); + Map requestBody = gson.fromJson(body, Map.class); + + String testId = (String) requestBody.get("testId"); + String testType = (String) requestBody.getOrDefault("testType", "DAILY"); + List> answers = (List>) requestBody.get("answers"); + + if (testId == null || answers == null) { + return createResponse(400, ApiResponse.error("testId and answers are required")); + } + + String now = Instant.now().toString(); + String today = LocalDate.now().toString(); + + int correctCount = 0; + int incorrectCount = 0; + List incorrectWordIds = new ArrayList<>(); + + for (Map answer : answers) { + String wordId = (String) answer.get("wordId"); + String userAnswer = (String) answer.get("answer"); + + Optional optWord = wordRepository.findById(wordId); + if (optWord.isPresent()) { + Word word = optWord.get(); + // 대소문자 무시, 공백 제거 후 비교 + boolean isCorrect = word.getKorean().trim().equalsIgnoreCase(userAnswer.trim()); + + if (isCorrect) { + correctCount++; + } else { + incorrectCount++; + incorrectWordIds.add(wordId); + } + } + } + + int totalQuestions = answers.size(); + double successRate = totalQuestions > 0 ? (correctCount * 100.0 / totalQuestions) : 0; + + TestResult testResult = TestResult.builder() + .pk("TEST#" + userId) + .sk("RESULT#" + now) + .gsi1pk("TEST#ALL") + .gsi1sk("DATE#" + today) + .testId(testId) + .userId(userId) + .testType(testType) + .totalQuestions(totalQuestions) + .correctAnswers(correctCount) + .incorrectAnswers(incorrectCount) + .successRate(successRate) + .incorrectWordIds(incorrectWordIds) + .startedAt((String) requestBody.get("startedAt")) + .completedAt(now) + .build(); + + testResultRepository.save(testResult); + + logger.info("Test submitted: userId={}, testId={}, successRate={}%", userId, testId, successRate); + return createResponse(200, ApiResponse.success("Test submitted", testResult)); + } + + private APIGatewayProxyResponseEvent getTestResults(APIGatewayProxyRequestEvent request) { + Map pathParams = request.getPathParameters(); + Map queryParams = request.getQueryStringParameters(); + + String userId = pathParams != null ? pathParams.get("userId") : null; + String cursor = queryParams != null ? queryParams.get("cursor") : null; + + if (userId == null) { + return createResponse(400, ApiResponse.error("userId is required")); + } + + int limit = 10; + if (queryParams != null && queryParams.get("limit") != null) { + limit = Math.min(Integer.parseInt(queryParams.get("limit")), 50); + } + + TestResultRepository.TestResultPage resultPage = testResultRepository.findByUserIdWithPagination(userId, limit, cursor); + + Map result = new HashMap<>(); + result.put("testResults", resultPage.getTestResults()); + result.put("nextCursor", resultPage.getNextCursor()); + result.put("hasMore", resultPage.hasMore()); + + return createResponse(200, ApiResponse.success("Test results retrieved", result)); + } + + private APIGatewayProxyResponseEvent createResponse(int statusCode, Object body) { + return new APIGatewayProxyResponseEvent() + .withStatusCode(statusCode) + .withHeaders(Map.of( + "Content-Type", "application/json", + "Access-Control-Allow-Origin", "*", + "Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS", + "Access-Control-Allow-Headers", "Content-Type,Authorization" + )) + .withBody(gson.toJson(body)); + } +} diff --git a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/UserWordHandler.java b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/UserWordHandler.java new file mode 100644 index 00000000..c4af3cc5 --- /dev/null +++ b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/UserWordHandler.java @@ -0,0 +1,223 @@ +package com.mzc.secondproject.serverless.vocabulary.handler; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.mzc.secondproject.serverless.vocabulary.dto.ApiResponse; +import com.mzc.secondproject.serverless.vocabulary.model.UserWord; +import com.mzc.secondproject.serverless.vocabulary.repository.UserWordRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +public class UserWordHandler implements RequestHandler { + + private static final Logger logger = LoggerFactory.getLogger(UserWordHandler.class); + private static final Gson gson = new GsonBuilder().setPrettyPrinting().create(); + + private final UserWordRepository userWordRepository; + + public UserWordHandler() { + this.userWordRepository = new UserWordRepository(); + } + + @Override + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { + String httpMethod = request.getHttpMethod(); + String path = request.getPath(); + + logger.info("Received request: {} {}", httpMethod, path); + + try { + // GET /vocab/users/{userId}/words - 사용자 단어 목록 + if ("GET".equals(httpMethod) && path.endsWith("/words")) { + return getUserWords(request); + } + + // GET /vocab/users/{userId}/words/{wordId} - 사용자 단어 상세 + if ("GET".equals(httpMethod) && path.contains("/words/")) { + return getUserWord(request); + } + + // PUT /vocab/users/{userId}/words/{wordId} - 학습 상태 업데이트 + if ("PUT".equals(httpMethod) && path.contains("/words/")) { + return updateUserWord(request); + } + + return createResponse(404, ApiResponse.error("Not found")); + + } catch (Exception e) { + logger.error("Error handling request", e); + return createResponse(500, ApiResponse.error("Internal server error: " + e.getMessage())); + } + } + + private APIGatewayProxyResponseEvent getUserWords(APIGatewayProxyRequestEvent request) { + Map pathParams = request.getPathParameters(); + Map queryParams = request.getQueryStringParameters(); + + String userId = pathParams != null ? pathParams.get("userId") : null; + String status = queryParams != null ? queryParams.get("status") : null; + String cursor = queryParams != null ? queryParams.get("cursor") : null; + + if (userId == null) { + return createResponse(400, ApiResponse.error("userId is required")); + } + + int limit = 20; + if (queryParams != null && queryParams.get("limit") != null) { + limit = Math.min(Integer.parseInt(queryParams.get("limit")), 50); + } + + UserWordRepository.UserWordPage userWordPage; + if (status != null && !status.isEmpty()) { + userWordPage = userWordRepository.findByUserIdAndStatus(userId, status, limit, cursor); + } else { + userWordPage = userWordRepository.findByUserIdWithPagination(userId, limit, cursor); + } + + Map result = new HashMap<>(); + result.put("userWords", userWordPage.getUserWords()); + result.put("nextCursor", userWordPage.getNextCursor()); + result.put("hasMore", userWordPage.hasMore()); + + return createResponse(200, ApiResponse.success("User words retrieved", result)); + } + + private APIGatewayProxyResponseEvent getUserWord(APIGatewayProxyRequestEvent request) { + Map pathParams = request.getPathParameters(); + String userId = pathParams != null ? pathParams.get("userId") : null; + String wordId = pathParams != null ? pathParams.get("wordId") : null; + + if (userId == null || wordId == null) { + return createResponse(400, ApiResponse.error("userId and wordId are required")); + } + + Optional optUserWord = userWordRepository.findByUserIdAndWordId(userId, wordId); + if (optUserWord.isEmpty()) { + return createResponse(404, ApiResponse.error("UserWord not found")); + } + + return createResponse(200, ApiResponse.success("UserWord retrieved", optUserWord.get())); + } + + private APIGatewayProxyResponseEvent updateUserWord(APIGatewayProxyRequestEvent request) { + Map pathParams = request.getPathParameters(); + String userId = pathParams != null ? pathParams.get("userId") : null; + String wordId = pathParams != null ? pathParams.get("wordId") : null; + + if (userId == null || wordId == null) { + return createResponse(400, ApiResponse.error("userId and wordId are required")); + } + + String body = request.getBody(); + Map requestBody = gson.fromJson(body, Map.class); + + // 정답/오답 여부 + Boolean isCorrect = (Boolean) requestBody.get("isCorrect"); + if (isCorrect == null) { + return createResponse(400, ApiResponse.error("isCorrect is required")); + } + + Optional optUserWord = userWordRepository.findByUserIdAndWordId(userId, wordId); + UserWord userWord; + String now = Instant.now().toString(); + + if (optUserWord.isEmpty()) { + // 새로운 UserWord 생성 + userWord = UserWord.builder() + .pk("USER#" + userId) + .sk("WORD#" + wordId) + .gsi1pk("USER#" + userId + "#REVIEW") + .gsi2pk("USER#" + userId + "#STATUS") + .userId(userId) + .wordId(wordId) + .status("NEW") + .interval(1) + .easeFactor(2.5) + .repetitions(0) + .correctCount(0) + .incorrectCount(0) + .createdAt(now) + .build(); + } else { + userWord = optUserWord.get(); + } + + // Spaced Repetition 알고리즘 적용 + applySpacedRepetition(userWord, isCorrect); + userWord.setUpdatedAt(now); + userWord.setLastReviewedAt(now); + + // GSI 업데이트 + userWord.setGsi1sk("DATE#" + userWord.getNextReviewAt()); + userWord.setGsi2sk("STATUS#" + userWord.getStatus()); + + userWordRepository.save(userWord); + + logger.info("Updated user word: userId={}, wordId={}, isCorrect={}", userId, wordId, isCorrect); + return createResponse(200, ApiResponse.success("UserWord updated", userWord)); + } + + /** + * SM-2 Spaced Repetition 알고리즘 적용 + */ + private void applySpacedRepetition(UserWord userWord, boolean isCorrect) { + if (isCorrect) { + userWord.setCorrectCount(userWord.getCorrectCount() + 1); + userWord.setRepetitions(userWord.getRepetitions() + 1); + + // 간격 계산 + if (userWord.getRepetitions() == 1) { + userWord.setInterval(1); + } else if (userWord.getRepetitions() == 2) { + userWord.setInterval(6); + } else { + int newInterval = (int) Math.round(userWord.getInterval() * userWord.getEaseFactor()); + userWord.setInterval(newInterval); + } + + // 상태 업데이트 + if (userWord.getRepetitions() >= 5) { + userWord.setStatus("MASTERED"); + } else if (userWord.getRepetitions() >= 2) { + userWord.setStatus("REVIEWING"); + } else { + userWord.setStatus("LEARNING"); + } + } else { + userWord.setIncorrectCount(userWord.getIncorrectCount() + 1); + userWord.setRepetitions(0); + userWord.setInterval(1); + userWord.setStatus("LEARNING"); + + // easeFactor 감소 (최소 1.3) + double newEaseFactor = userWord.getEaseFactor() - 0.2; + userWord.setEaseFactor(Math.max(1.3, newEaseFactor)); + } + + // 다음 복습일 계산 + LocalDate nextReview = LocalDate.now().plusDays(userWord.getInterval()); + userWord.setNextReviewAt(nextReview.toString()); + } + + private APIGatewayProxyResponseEvent createResponse(int statusCode, Object body) { + return new APIGatewayProxyResponseEvent() + .withStatusCode(statusCode) + .withHeaders(Map.of( + "Content-Type", "application/json", + "Access-Control-Allow-Origin", "*", + "Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS", + "Access-Control-Allow-Headers", "Content-Type,Authorization" + )) + .withBody(gson.toJson(body)); + } +} diff --git a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/VoiceHandler.java b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/VoiceHandler.java new file mode 100644 index 00000000..c49d4fef --- /dev/null +++ b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/VoiceHandler.java @@ -0,0 +1,123 @@ +package com.mzc.secondproject.serverless.vocabulary.handler; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.mzc.secondproject.serverless.vocabulary.dto.ApiResponse; +import com.mzc.secondproject.serverless.vocabulary.model.Word; +import com.mzc.secondproject.serverless.vocabulary.repository.WordRepository; +import com.mzc.secondproject.serverless.vocabulary.service.PollyService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +public class VoiceHandler implements RequestHandler { + + private static final Logger logger = LoggerFactory.getLogger(VoiceHandler.class); + private static final Gson gson = new GsonBuilder().setPrettyPrinting().create(); + + private final WordRepository wordRepository; + private final PollyService pollyService; + + public VoiceHandler() { + this.wordRepository = new WordRepository(); + this.pollyService = new PollyService(); + } + + @Override + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { + String httpMethod = request.getHttpMethod(); + String path = request.getPath(); + + logger.info("Received request: {} {}", httpMethod, path); + + try { + // POST /vocab/voice/synthesize - 음성 합성 + if ("POST".equals(httpMethod) && path.endsWith("/synthesize")) { + return synthesizeSpeech(request); + } + + return createResponse(404, ApiResponse.error("Not found")); + + } catch (Exception e) { + logger.error("Error handling request", e); + return createResponse(500, ApiResponse.error("Internal server error: " + e.getMessage())); + } + } + + private APIGatewayProxyResponseEvent synthesizeSpeech(APIGatewayProxyRequestEvent request) { + String body = request.getBody(); + Map requestBody = gson.fromJson(body, Map.class); + + String wordId = (String) requestBody.get("wordId"); + String voice = (String) requestBody.getOrDefault("voice", "FEMALE"); + + if (wordId == null || wordId.isEmpty()) { + return createResponse(400, ApiResponse.error("wordId is required")); + } + + // 단어 조회 + Optional optWord = wordRepository.findById(wordId); + if (optWord.isEmpty()) { + return createResponse(404, ApiResponse.error("Word not found")); + } + + Word word = optWord.get(); + boolean isMale = "MALE".equalsIgnoreCase(voice); + + // 캐시 확인: DynamoDB에 저장된 S3 키 확인 + String cachedKey = isMale ? word.getMaleVoiceKey() : word.getFemaleVoiceKey(); + String audioUrl; + boolean cached = false; + + if (cachedKey != null && !cachedKey.isEmpty()) { + // DB에 캐시 키가 있으면 Pre-signed URL 생성 + audioUrl = pollyService.getPresignedUrl(cachedKey); + cached = true; + logger.info("Cache hit from DB: wordId={}, voice={}", wordId, voice); + } else { + // 캐시 미스: Polly 변환 후 S3 저장 + PollyService.VoiceSynthesisResult result = pollyService.synthesizeSpeechForWord( + wordId, word.getEnglish(), voice); + + audioUrl = result.getAudioUrl(); + cached = result.isCached(); + + // DynamoDB에 S3 키 저장 + if (isMale) { + word.setMaleVoiceKey(result.getS3Key()); + } else { + word.setFemaleVoiceKey(result.getS3Key()); + } + wordRepository.save(word); + logger.info("Saved voice cache to DB: wordId={}, voice={}", wordId, voice); + } + + Map responseData = new HashMap<>(); + responseData.put("wordId", wordId); + responseData.put("english", word.getEnglish()); + responseData.put("voice", voice); + responseData.put("audioUrl", audioUrl); + responseData.put("cached", cached); + + return createResponse(200, ApiResponse.success("Speech synthesized", responseData)); + } + + private APIGatewayProxyResponseEvent createResponse(int statusCode, Object body) { + return new APIGatewayProxyResponseEvent() + .withStatusCode(statusCode) + .withHeaders(Map.of( + "Content-Type", "application/json", + "Access-Control-Allow-Origin", "*", + "Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS", + "Access-Control-Allow-Headers", "Content-Type,Authorization" + )) + .withBody(gson.toJson(body)); + } +} diff --git a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/WordHandler.java b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/WordHandler.java new file mode 100644 index 00000000..2d591f32 --- /dev/null +++ b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/WordHandler.java @@ -0,0 +1,234 @@ +package com.mzc.secondproject.serverless.vocabulary.handler; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.mzc.secondproject.serverless.vocabulary.dto.ApiResponse; +import com.mzc.secondproject.serverless.vocabulary.model.Word; +import com.mzc.secondproject.serverless.vocabulary.repository.WordRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +public class WordHandler implements RequestHandler { + + private static final Logger logger = LoggerFactory.getLogger(WordHandler.class); + private static final Gson gson = new GsonBuilder().setPrettyPrinting().create(); + + private final WordRepository wordRepository; + + public WordHandler() { + this.wordRepository = new WordRepository(); + } + + @Override + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { + String httpMethod = request.getHttpMethod(); + String path = request.getPath(); + + logger.info("Received request: {} {}", httpMethod, path); + + try { + // POST /vocab/words - 단어 생성 + if ("POST".equals(httpMethod) && path.endsWith("/words")) { + return createWord(request); + } + + // GET /vocab/words - 단어 목록 조회 + if ("GET".equals(httpMethod) && path.endsWith("/words")) { + return getWords(request); + } + + // GET /vocab/words/{wordId} - 단어 상세 조회 + if ("GET".equals(httpMethod) && path.contains("/words/")) { + return getWord(request); + } + + // PUT /vocab/words/{wordId} - 단어 수정 + if ("PUT".equals(httpMethod) && path.contains("/words/")) { + return updateWord(request); + } + + // DELETE /vocab/words/{wordId} - 단어 삭제 + if ("DELETE".equals(httpMethod) && path.contains("/words/")) { + return deleteWord(request); + } + + return createResponse(404, ApiResponse.error("Not found")); + + } catch (Exception e) { + logger.error("Error handling request", e); + return createResponse(500, ApiResponse.error("Internal server error: " + e.getMessage())); + } + } + + private APIGatewayProxyResponseEvent createWord(APIGatewayProxyRequestEvent request) { + String body = request.getBody(); + Map requestBody = gson.fromJson(body, Map.class); + + String english = (String) requestBody.get("english"); + String korean = (String) requestBody.get("korean"); + String example = (String) requestBody.get("example"); + String level = (String) requestBody.getOrDefault("level", "BEGINNER"); + String category = (String) requestBody.getOrDefault("category", "DAILY"); + + if (english == null || english.isEmpty()) { + return createResponse(400, ApiResponse.error("english is required")); + } + if (korean == null || korean.isEmpty()) { + return createResponse(400, ApiResponse.error("korean is required")); + } + + String wordId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + + Word word = Word.builder() + .pk("WORD#" + wordId) + .sk("METADATA") + .gsi1pk("LEVEL#" + level) + .gsi1sk("WORD#" + wordId) + .gsi2pk("CATEGORY#" + category) + .gsi2sk("WORD#" + wordId) + .wordId(wordId) + .english(english) + .korean(korean) + .example(example) + .level(level) + .category(category) + .createdAt(now) + .build(); + + wordRepository.save(word); + + logger.info("Created word: {}", wordId); + return createResponse(201, ApiResponse.success("Word created", word)); + } + + private APIGatewayProxyResponseEvent getWords(APIGatewayProxyRequestEvent request) { + Map queryParams = request.getQueryStringParameters(); + + String level = queryParams != null ? queryParams.get("level") : null; + String category = queryParams != null ? queryParams.get("category") : null; + String cursor = queryParams != null ? queryParams.get("cursor") : null; + + int limit = 20; + if (queryParams != null && queryParams.get("limit") != null) { + limit = Math.min(Integer.parseInt(queryParams.get("limit")), 50); + } + + WordRepository.WordPage wordPage; + if (level != null && !level.isEmpty()) { + wordPage = wordRepository.findByLevelWithPagination(level, limit, cursor); + } else if (category != null && !category.isEmpty()) { + wordPage = wordRepository.findByCategoryWithPagination(category, limit, cursor); + } else { + // 기본: BEGINNER 레벨 + wordPage = wordRepository.findByLevelWithPagination("BEGINNER", limit, cursor); + } + + Map result = new HashMap<>(); + result.put("words", wordPage.getWords()); + result.put("nextCursor", wordPage.getNextCursor()); + result.put("hasMore", wordPage.hasMore()); + + return createResponse(200, ApiResponse.success("Words retrieved", result)); + } + + private APIGatewayProxyResponseEvent getWord(APIGatewayProxyRequestEvent request) { + Map pathParams = request.getPathParameters(); + String wordId = pathParams != null ? pathParams.get("wordId") : null; + + if (wordId == null) { + return createResponse(400, ApiResponse.error("wordId is required")); + } + + Optional optWord = wordRepository.findById(wordId); + if (optWord.isEmpty()) { + return createResponse(404, ApiResponse.error("Word not found")); + } + + return createResponse(200, ApiResponse.success("Word retrieved", optWord.get())); + } + + private APIGatewayProxyResponseEvent updateWord(APIGatewayProxyRequestEvent request) { + Map pathParams = request.getPathParameters(); + String wordId = pathParams != null ? pathParams.get("wordId") : null; + + if (wordId == null) { + return createResponse(400, ApiResponse.error("wordId is required")); + } + + Optional optWord = wordRepository.findById(wordId); + if (optWord.isEmpty()) { + return createResponse(404, ApiResponse.error("Word not found")); + } + + Word word = optWord.get(); + String body = request.getBody(); + Map requestBody = gson.fromJson(body, Map.class); + + if (requestBody.containsKey("english")) { + word.setEnglish((String) requestBody.get("english")); + } + if (requestBody.containsKey("korean")) { + word.setKorean((String) requestBody.get("korean")); + } + if (requestBody.containsKey("example")) { + word.setExample((String) requestBody.get("example")); + } + if (requestBody.containsKey("level")) { + String newLevel = (String) requestBody.get("level"); + word.setLevel(newLevel); + word.setGsi1pk("LEVEL#" + newLevel); + } + if (requestBody.containsKey("category")) { + String newCategory = (String) requestBody.get("category"); + word.setCategory(newCategory); + word.setGsi2pk("CATEGORY#" + newCategory); + } + + wordRepository.save(word); + + logger.info("Updated word: {}", wordId); + return createResponse(200, ApiResponse.success("Word updated", word)); + } + + private APIGatewayProxyResponseEvent deleteWord(APIGatewayProxyRequestEvent request) { + Map pathParams = request.getPathParameters(); + String wordId = pathParams != null ? pathParams.get("wordId") : null; + + if (wordId == null) { + return createResponse(400, ApiResponse.error("wordId is required")); + } + + Optional optWord = wordRepository.findById(wordId); + if (optWord.isEmpty()) { + return createResponse(404, ApiResponse.error("Word not found")); + } + + wordRepository.delete(wordId); + + logger.info("Deleted word: {}", wordId); + return createResponse(200, ApiResponse.success("Word deleted", null)); + } + + private APIGatewayProxyResponseEvent createResponse(int statusCode, Object body) { + return new APIGatewayProxyResponseEvent() + .withStatusCode(statusCode) + .withHeaders(Map.of( + "Content-Type", "application/json", + "Access-Control-Allow-Origin", "*", + "Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS", + "Access-Control-Allow-Headers", "Content-Type,Authorization" + )) + .withBody(gson.toJson(body)); + } +} diff --git a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/DailyStudy.java b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/DailyStudy.java new file mode 100644 index 00000000..c6c51520 --- /dev/null +++ b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/DailyStudy.java @@ -0,0 +1,74 @@ +package com.mzc.secondproject.serverless.vocabulary.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbAttribute; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondaryPartitionKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondarySortKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSortKey; + +import java.util.List; + +/** + * 일일 학습 정보 + * PK: DAILY#{userId} + * SK: DATE#{date} + * GSI1: DAILY#ALL / DATE#{date} - 전체 일일 학습 조회 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DynamoDbBean +public class DailyStudy { + + private String pk; // DAILY#{userId} + private String sk; // DATE#{date} + private String gsi1pk; // DAILY#ALL + private String gsi1sk; // DATE#{date} + + private String userId; + private String date; // yyyy-MM-dd + + // 학습 단어 목록 (55개: 50개 신규 + 5개 복습) + private List newWordIds; // 신규 단어 ID 목록 (50개) + private List reviewWordIds; // 복습 단어 ID 목록 (5개) + private List learnedWordIds; // 학습 완료 단어 ID 목록 + + // 진행 상태 + private Integer totalWords; // 총 단어 수 (55) + private Integer learnedCount; // 학습 완료 수 + private Boolean isCompleted; // 일일 학습 완료 여부 + + private String createdAt; + private String updatedAt; + private Long ttl; + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { + return pk; + } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { + return sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1PK") + public String getGsi1pk() { + return gsi1pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1SK") + public String getGsi1sk() { + return gsi1sk; + } +} diff --git a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/TestResult.java b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/TestResult.java new file mode 100644 index 00000000..1b0da164 --- /dev/null +++ b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/TestResult.java @@ -0,0 +1,74 @@ +package com.mzc.secondproject.serverless.vocabulary.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbAttribute; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondaryPartitionKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondarySortKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSortKey; + +import java.util.List; + +/** + * 시험 결과 + * PK: TEST#{userId} + * SK: RESULT#{timestamp} + * GSI1: TEST#ALL / DATE#{date} - 전체 시험 결과 조회 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DynamoDbBean +public class TestResult { + + private String pk; // TEST#{userId} + private String sk; // RESULT#{timestamp} + private String gsi1pk; // TEST#ALL + private String gsi1sk; // DATE#{date} + + private String testId; + private String userId; + private String testType; // DAILY, WEEKLY, CUSTOM + + // 시험 결과 + private Integer totalQuestions; + private Integer correctAnswers; + private Integer incorrectAnswers; + private Double successRate; // 성공률 (%) + + // 오답 단어 목록 + private List incorrectWordIds; + + private String startedAt; + private String completedAt; + private Long ttl; + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { + return pk; + } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { + return sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1PK") + public String getGsi1pk() { + return gsi1pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1SK") + public String getGsi1sk() { + return gsi1sk; + } +} diff --git a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/UserWord.java b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/UserWord.java new file mode 100644 index 00000000..0abb5e19 --- /dev/null +++ b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/UserWord.java @@ -0,0 +1,88 @@ +package com.mzc.secondproject.serverless.vocabulary.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbAttribute; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondaryPartitionKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondarySortKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSortKey; + +/** + * 사용자별 단어 학습 상태 (Spaced Repetition) + * PK: USER#{userId} + * SK: WORD#{wordId} + * GSI1: USER#{userId}#REVIEW / DATE#{nextReviewAt} - 복습 예정 조회 + * GSI2: USER#{userId}#STATUS / STATUS#{status} - 상태별 조회 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DynamoDbBean +public class UserWord { + + private String pk; // USER#{userId} + private String sk; // WORD#{wordId} + private String gsi1pk; // USER#{userId}#REVIEW + private String gsi1sk; // DATE#{nextReviewAt} + private String gsi2pk; // USER#{userId}#STATUS + private String gsi2sk; // STATUS#{status} + + private String userId; + private String wordId; + private String status; // NEW, LEARNING, REVIEWING, MASTERED + + // Spaced Repetition 알고리즘 필드 + private Integer interval; // 복습 간격 (일) + private Double easeFactor; // 난이도 계수 (2.5 기본) + private Integer repetitions; // 연속 정답 횟수 + private String nextReviewAt; // 다음 복습 예정일 + private String lastReviewedAt; // 마지막 복습일 + + // 학습 통계 + private Integer correctCount; // 정답 횟수 + private Integer incorrectCount; // 오답 횟수 + private String createdAt; + private String updatedAt; + private Long ttl; + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { + return pk; + } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { + return sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1PK") + public String getGsi1pk() { + return gsi1pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1SK") + public String getGsi1sk() { + return gsi1sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI2") + @DynamoDbAttribute("GSI2PK") + public String getGsi2pk() { + return gsi2pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI2") + @DynamoDbAttribute("GSI2SK") + public String getGsi2sk() { + return gsi2sk; + } +} diff --git a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/Word.java b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/Word.java new file mode 100644 index 00000000..ce116685 --- /dev/null +++ b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/Word.java @@ -0,0 +1,83 @@ +package com.mzc.secondproject.serverless.vocabulary.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbAttribute; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondaryPartitionKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondarySortKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSortKey; + +/** + * 단어 정보 모델 + * PK: WORD#{wordId} + * SK: METADATA + * GSI1: LEVEL#{level} / WORD#{wordId} - 난이도별 조회 + * GSI2: CATEGORY#{category} / WORD#{wordId} - 카테고리별 조회 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DynamoDbBean +public class Word { + + private String pk; // WORD#{wordId} + private String sk; // METADATA + private String gsi1pk; // LEVEL#{level} + private String gsi1sk; // WORD#{wordId} + private String gsi2pk; // CATEGORY#{category} + private String gsi2sk; // WORD#{wordId} + + private String wordId; + private String english; // 영어 단어 + private String korean; // 한국어 뜻 + private String example; // 예문 + private String level; // BEGINNER, INTERMEDIATE, ADVANCED + private String category; // DAILY, BUSINESS, ACADEMIC, etc. + private String createdAt; + private Long ttl; + + // 음성 캐시용 S3 키 (vocab/voice/{wordId}_{voice}.mp3) + private String maleVoiceKey; + private String femaleVoiceKey; + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { + return pk; + } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { + return sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1PK") + public String getGsi1pk() { + return gsi1pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1SK") + public String getGsi1sk() { + return gsi1sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI2") + @DynamoDbAttribute("GSI2PK") + public String getGsi2pk() { + return gsi2pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI2") + @DynamoDbAttribute("GSI2SK") + public String getGsi2sk() { + return gsi2sk; + } +} diff --git a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/DailyStudyRepository.java b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/DailyStudyRepository.java new file mode 100644 index 00000000..c255dd70 --- /dev/null +++ b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/DailyStudyRepository.java @@ -0,0 +1,161 @@ +package com.mzc.secondproject.serverless.vocabulary.repository; + +import com.mzc.secondproject.serverless.vocabulary.model.DailyStudy; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; +import software.amazon.awssdk.enhanced.dynamodb.Key; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.model.Page; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; +import software.amazon.awssdk.services.dynamodb.DynamoDbClient; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; +import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest; + +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +public class DailyStudyRepository { + + private static final Logger logger = LoggerFactory.getLogger(DailyStudyRepository.class); + + // Singleton 패턴으로 Cold Start 최적화 + private static final DynamoDbClient dynamoDbClient = DynamoDbClient.builder().build(); + private static final DynamoDbEnhancedClient enhancedClient = DynamoDbEnhancedClient.builder() + .dynamoDbClient(dynamoDbClient) + .build(); + private static final String tableName = System.getenv("VOCAB_TABLE_NAME"); + + private final DynamoDbTable table; + + public DailyStudyRepository() { + this.table = enhancedClient.table(tableName, TableSchema.fromBean(DailyStudy.class)); + } + + public DailyStudy save(DailyStudy dailyStudy) { + logger.info("Saving daily study: userId={}, date={}", dailyStudy.getUserId(), dailyStudy.getDate()); + table.putItem(dailyStudy); + return dailyStudy; + } + + public Optional findByUserIdAndDate(String userId, String date) { + Key key = Key.builder() + .partitionValue("DAILY#" + userId) + .sortValue("DATE#" + date) + .build(); + + DailyStudy dailyStudy = table.getItem(key); + return Optional.ofNullable(dailyStudy); + } + + /** + * 사용자의 일일 학습 기록 조회 - 최신순, 페이지네이션 + */ + public DailyStudyPage findByUserIdWithPagination(String userId, int limit, String cursor) { + QueryConditional queryConditional = QueryConditional + .sortBeginsWith(Key.builder() + .partitionValue("DAILY#" + userId) + .sortValue("DATE#") + .build()); + + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .scanIndexForward(false) // 최신순 + .limit(limit); + + if (cursor != null && !cursor.isEmpty()) { + Map exclusiveStartKey = decodeCursor(cursor); + if (exclusiveStartKey != null) { + requestBuilder.exclusiveStartKey(exclusiveStartKey); + } + } + + Page page = table.query(requestBuilder.build()).iterator().next(); + String nextCursor = encodeCursor(page.lastEvaluatedKey()); + + return new DailyStudyPage(page.items(), nextCursor); + } + + /** + * 학습 완료 단어 추가 (UpdateExpression 사용 - N+1 방지) + */ + public void addLearnedWord(String userId, String date, String wordId) { + Map key = new HashMap<>(); + key.put("PK", AttributeValue.builder().s("DAILY#" + userId).build()); + key.put("SK", AttributeValue.builder().s("DATE#" + date).build()); + + Map expressionValues = new HashMap<>(); + expressionValues.put(":wordId", AttributeValue.builder().ss(wordId).build()); + expressionValues.put(":one", AttributeValue.builder().n("1").build()); + + UpdateItemRequest updateRequest = UpdateItemRequest.builder() + .tableName(tableName) + .key(key) + .updateExpression("ADD learnedWordIds :wordId, learnedCount :one") + .expressionAttributeValues(expressionValues) + .build(); + + dynamoDbClient.updateItem(updateRequest); + logger.info("Added learned word: userId={}, date={}, wordId={}", userId, date, wordId); + } + + private String encodeCursor(Map lastEvaluatedKey) { + if (lastEvaluatedKey == null || lastEvaluatedKey.isEmpty()) { + return null; + } + + StringBuilder sb = new StringBuilder(); + for (Map.Entry entry : lastEvaluatedKey.entrySet()) { + if (sb.length() > 0) sb.append("|"); + sb.append(entry.getKey()).append("=").append(entry.getValue().s()); + } + + return Base64.getUrlEncoder().encodeToString(sb.toString().getBytes()); + } + + private Map decodeCursor(String cursor) { + try { + String decoded = new String(Base64.getUrlDecoder().decode(cursor)); + Map result = new HashMap<>(); + + for (String pair : decoded.split("\\|")) { + String[] kv = pair.split("=", 2); + if (kv.length == 2) { + result.put(kv[0], AttributeValue.builder().s(kv[1]).build()); + } + } + + return result.isEmpty() ? null : result; + } catch (Exception e) { + logger.error("Failed to decode cursor: {}", cursor, e); + return null; + } + } + + public static class DailyStudyPage { + private final List dailyStudies; + private final String nextCursor; + + public DailyStudyPage(List dailyStudies, String nextCursor) { + this.dailyStudies = dailyStudies; + this.nextCursor = nextCursor; + } + + public List getDailyStudies() { + return dailyStudies; + } + + public String getNextCursor() { + return nextCursor; + } + + public boolean hasMore() { + return nextCursor != null; + } + } +} diff --git a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/TestResultRepository.java b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/TestResultRepository.java new file mode 100644 index 00000000..6224f2bc --- /dev/null +++ b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/TestResultRepository.java @@ -0,0 +1,137 @@ +package com.mzc.secondproject.serverless.vocabulary.repository; + +import com.mzc.secondproject.serverless.vocabulary.model.TestResult; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; +import software.amazon.awssdk.enhanced.dynamodb.Key; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.model.Page; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; +import software.amazon.awssdk.services.dynamodb.DynamoDbClient; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; + +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +public class TestResultRepository { + + private static final Logger logger = LoggerFactory.getLogger(TestResultRepository.class); + + // Singleton 패턴으로 Cold Start 최적화 + private static final DynamoDbClient dynamoDbClient = DynamoDbClient.builder().build(); + private static final DynamoDbEnhancedClient enhancedClient = DynamoDbEnhancedClient.builder() + .dynamoDbClient(dynamoDbClient) + .build(); + private static final String tableName = System.getenv("VOCAB_TABLE_NAME"); + + private final DynamoDbTable table; + + public TestResultRepository() { + this.table = enhancedClient.table(tableName, TableSchema.fromBean(TestResult.class)); + } + + public TestResult save(TestResult testResult) { + logger.info("Saving test result: userId={}, testId={}", testResult.getUserId(), testResult.getTestId()); + table.putItem(testResult); + return testResult; + } + + public Optional findByUserIdAndTestId(String userId, String timestamp) { + Key key = Key.builder() + .partitionValue("TEST#" + userId) + .sortValue("RESULT#" + timestamp) + .build(); + + TestResult testResult = table.getItem(key); + return Optional.ofNullable(testResult); + } + + /** + * 사용자의 시험 결과 조회 - 최신순, 페이지네이션 + */ + public TestResultPage findByUserIdWithPagination(String userId, int limit, String cursor) { + QueryConditional queryConditional = QueryConditional + .sortBeginsWith(Key.builder() + .partitionValue("TEST#" + userId) + .sortValue("RESULT#") + .build()); + + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .scanIndexForward(false) // 최신순 + .limit(limit); + + if (cursor != null && !cursor.isEmpty()) { + Map exclusiveStartKey = decodeCursor(cursor); + if (exclusiveStartKey != null) { + requestBuilder.exclusiveStartKey(exclusiveStartKey); + } + } + + Page page = table.query(requestBuilder.build()).iterator().next(); + String nextCursor = encodeCursor(page.lastEvaluatedKey()); + + return new TestResultPage(page.items(), nextCursor); + } + + private String encodeCursor(Map lastEvaluatedKey) { + if (lastEvaluatedKey == null || lastEvaluatedKey.isEmpty()) { + return null; + } + + StringBuilder sb = new StringBuilder(); + for (Map.Entry entry : lastEvaluatedKey.entrySet()) { + if (sb.length() > 0) sb.append("|"); + sb.append(entry.getKey()).append("=").append(entry.getValue().s()); + } + + return Base64.getUrlEncoder().encodeToString(sb.toString().getBytes()); + } + + private Map decodeCursor(String cursor) { + try { + String decoded = new String(Base64.getUrlDecoder().decode(cursor)); + Map result = new HashMap<>(); + + for (String pair : decoded.split("\\|")) { + String[] kv = pair.split("=", 2); + if (kv.length == 2) { + result.put(kv[0], AttributeValue.builder().s(kv[1]).build()); + } + } + + return result.isEmpty() ? null : result; + } catch (Exception e) { + logger.error("Failed to decode cursor: {}", cursor, e); + return null; + } + } + + public static class TestResultPage { + private final List testResults; + private final String nextCursor; + + public TestResultPage(List testResults, String nextCursor) { + this.testResults = testResults; + this.nextCursor = nextCursor; + } + + public List getTestResults() { + return testResults; + } + + public String getNextCursor() { + return nextCursor; + } + + public boolean hasMore() { + return nextCursor != null; + } + } +} diff --git a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/UserWordRepository.java b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/UserWordRepository.java new file mode 100644 index 00000000..7214b500 --- /dev/null +++ b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/UserWordRepository.java @@ -0,0 +1,193 @@ +package com.mzc.secondproject.serverless.vocabulary.repository; + +import com.mzc.secondproject.serverless.vocabulary.model.UserWord; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbIndex; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; +import software.amazon.awssdk.enhanced.dynamodb.Key; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.model.Page; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; +import software.amazon.awssdk.services.dynamodb.DynamoDbClient; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; + +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +public class UserWordRepository { + + private static final Logger logger = LoggerFactory.getLogger(UserWordRepository.class); + + // Singleton 패턴으로 Cold Start 최적화 + private static final DynamoDbClient dynamoDbClient = DynamoDbClient.builder().build(); + private static final DynamoDbEnhancedClient enhancedClient = DynamoDbEnhancedClient.builder() + .dynamoDbClient(dynamoDbClient) + .build(); + private static final String tableName = System.getenv("VOCAB_TABLE_NAME"); + + private final DynamoDbTable table; + + public UserWordRepository() { + this.table = enhancedClient.table(tableName, TableSchema.fromBean(UserWord.class)); + } + + public UserWord save(UserWord userWord) { + logger.info("Saving user word: userId={}, wordId={}", userWord.getUserId(), userWord.getWordId()); + table.putItem(userWord); + return userWord; + } + + public Optional findByUserIdAndWordId(String userId, String wordId) { + Key key = Key.builder() + .partitionValue("USER#" + userId) + .sortValue("WORD#" + wordId) + .build(); + + UserWord userWord = table.getItem(key); + return Optional.ofNullable(userWord); + } + + /** + * 사용자의 모든 단어 학습 상태 조회 - 페이지네이션 + */ + public UserWordPage findByUserIdWithPagination(String userId, int limit, String cursor) { + QueryConditional queryConditional = QueryConditional + .sortBeginsWith(Key.builder() + .partitionValue("USER#" + userId) + .sortValue("WORD#") + .build()); + + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .limit(limit); + + if (cursor != null && !cursor.isEmpty()) { + Map exclusiveStartKey = decodeCursor(cursor); + if (exclusiveStartKey != null) { + requestBuilder.exclusiveStartKey(exclusiveStartKey); + } + } + + Page page = table.query(requestBuilder.build()).iterator().next(); + String nextCursor = encodeCursor(page.lastEvaluatedKey()); + + return new UserWordPage(page.items(), nextCursor); + } + + /** + * 복습 예정 단어 조회 (오늘 이전 날짜) - 페이지네이션 + */ + public UserWordPage findReviewDueWords(String userId, String todayDate, int limit, String cursor) { + QueryConditional queryConditional = QueryConditional + .sortLessThanOrEqualTo(Key.builder() + .partitionValue("USER#" + userId + "#REVIEW") + .sortValue("DATE#" + todayDate) + .build()); + + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .limit(limit); + + if (cursor != null && !cursor.isEmpty()) { + Map exclusiveStartKey = decodeCursor(cursor); + if (exclusiveStartKey != null) { + requestBuilder.exclusiveStartKey(exclusiveStartKey); + } + } + + DynamoDbIndex gsi1 = table.index("GSI1"); + Page page = gsi1.query(requestBuilder.build()).iterator().next(); + String nextCursor = encodeCursor(page.lastEvaluatedKey()); + + return new UserWordPage(page.items(), nextCursor); + } + + /** + * 상태별 단어 조회 - 페이지네이션 + */ + public UserWordPage findByUserIdAndStatus(String userId, String status, int limit, String cursor) { + QueryConditional queryConditional = QueryConditional + .keyEqualTo(Key.builder() + .partitionValue("USER#" + userId + "#STATUS") + .sortValue("STATUS#" + status) + .build()); + + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .limit(limit); + + if (cursor != null && !cursor.isEmpty()) { + Map exclusiveStartKey = decodeCursor(cursor); + if (exclusiveStartKey != null) { + requestBuilder.exclusiveStartKey(exclusiveStartKey); + } + } + + DynamoDbIndex gsi2 = table.index("GSI2"); + Page page = gsi2.query(requestBuilder.build()).iterator().next(); + String nextCursor = encodeCursor(page.lastEvaluatedKey()); + + return new UserWordPage(page.items(), nextCursor); + } + + private String encodeCursor(Map lastEvaluatedKey) { + if (lastEvaluatedKey == null || lastEvaluatedKey.isEmpty()) { + return null; + } + + StringBuilder sb = new StringBuilder(); + for (Map.Entry entry : lastEvaluatedKey.entrySet()) { + if (sb.length() > 0) sb.append("|"); + sb.append(entry.getKey()).append("=").append(entry.getValue().s()); + } + + return Base64.getUrlEncoder().encodeToString(sb.toString().getBytes()); + } + + private Map decodeCursor(String cursor) { + try { + String decoded = new String(Base64.getUrlDecoder().decode(cursor)); + Map result = new HashMap<>(); + + for (String pair : decoded.split("\\|")) { + String[] kv = pair.split("=", 2); + if (kv.length == 2) { + result.put(kv[0], AttributeValue.builder().s(kv[1]).build()); + } + } + + return result.isEmpty() ? null : result; + } catch (Exception e) { + logger.error("Failed to decode cursor: {}", cursor, e); + return null; + } + } + + public static class UserWordPage { + private final List userWords; + private final String nextCursor; + + public UserWordPage(List userWords, String nextCursor) { + this.userWords = userWords; + this.nextCursor = nextCursor; + } + + public List getUserWords() { + return userWords; + } + + public String getNextCursor() { + return nextCursor; + } + + public boolean hasMore() { + return nextCursor != null; + } + } +} diff --git a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/WordRepository.java b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/WordRepository.java new file mode 100644 index 00000000..0acf0c01 --- /dev/null +++ b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/WordRepository.java @@ -0,0 +1,170 @@ +package com.mzc.secondproject.serverless.vocabulary.repository; + +import com.mzc.secondproject.serverless.vocabulary.model.Word; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbIndex; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; +import software.amazon.awssdk.enhanced.dynamodb.Key; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.model.Page; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; +import software.amazon.awssdk.services.dynamodb.DynamoDbClient; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; + +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +public class WordRepository { + + private static final Logger logger = LoggerFactory.getLogger(WordRepository.class); + + // Singleton 패턴으로 Cold Start 최적화 + private static final DynamoDbClient dynamoDbClient = DynamoDbClient.builder().build(); + private static final DynamoDbEnhancedClient enhancedClient = DynamoDbEnhancedClient.builder() + .dynamoDbClient(dynamoDbClient) + .build(); + private static final String tableName = System.getenv("VOCAB_TABLE_NAME"); + + private final DynamoDbTable table; + + public WordRepository() { + this.table = enhancedClient.table(tableName, TableSchema.fromBean(Word.class)); + } + + public Word save(Word word) { + logger.info("Saving word to DynamoDB: {}", word.getWordId()); + table.putItem(word); + return word; + } + + public Optional findById(String wordId) { + Key key = Key.builder() + .partitionValue("WORD#" + wordId) + .sortValue("METADATA") + .build(); + + Word word = table.getItem(key); + return Optional.ofNullable(word); + } + + public void delete(String wordId) { + Key key = Key.builder() + .partitionValue("WORD#" + wordId) + .sortValue("METADATA") + .build(); + + table.deleteItem(key); + logger.info("Deleted word: {}", wordId); + } + + /** + * 난이도별 단어 조회 - 페이지네이션 + */ + public WordPage findByLevelWithPagination(String level, int limit, String cursor) { + QueryConditional queryConditional = QueryConditional + .keyEqualTo(Key.builder().partitionValue("LEVEL#" + level).build()); + + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .limit(limit); + + if (cursor != null && !cursor.isEmpty()) { + Map exclusiveStartKey = decodeCursor(cursor); + if (exclusiveStartKey != null) { + requestBuilder.exclusiveStartKey(exclusiveStartKey); + } + } + + DynamoDbIndex gsi1 = table.index("GSI1"); + Page page = gsi1.query(requestBuilder.build()).iterator().next(); + String nextCursor = encodeCursor(page.lastEvaluatedKey()); + + return new WordPage(page.items(), nextCursor); + } + + /** + * 카테고리별 단어 조회 - 페이지네이션 + */ + public WordPage findByCategoryWithPagination(String category, int limit, String cursor) { + QueryConditional queryConditional = QueryConditional + .keyEqualTo(Key.builder().partitionValue("CATEGORY#" + category).build()); + + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .limit(limit); + + if (cursor != null && !cursor.isEmpty()) { + Map exclusiveStartKey = decodeCursor(cursor); + if (exclusiveStartKey != null) { + requestBuilder.exclusiveStartKey(exclusiveStartKey); + } + } + + DynamoDbIndex gsi2 = table.index("GSI2"); + Page page = gsi2.query(requestBuilder.build()).iterator().next(); + String nextCursor = encodeCursor(page.lastEvaluatedKey()); + + return new WordPage(page.items(), nextCursor); + } + + private String encodeCursor(Map lastEvaluatedKey) { + if (lastEvaluatedKey == null || lastEvaluatedKey.isEmpty()) { + return null; + } + + StringBuilder sb = new StringBuilder(); + for (Map.Entry entry : lastEvaluatedKey.entrySet()) { + if (sb.length() > 0) sb.append("|"); + sb.append(entry.getKey()).append("=").append(entry.getValue().s()); + } + + return Base64.getUrlEncoder().encodeToString(sb.toString().getBytes()); + } + + private Map decodeCursor(String cursor) { + try { + String decoded = new String(Base64.getUrlDecoder().decode(cursor)); + Map result = new HashMap<>(); + + for (String pair : decoded.split("\\|")) { + String[] kv = pair.split("=", 2); + if (kv.length == 2) { + result.put(kv[0], AttributeValue.builder().s(kv[1]).build()); + } + } + + return result.isEmpty() ? null : result; + } catch (Exception e) { + logger.error("Failed to decode cursor: {}", cursor, e); + return null; + } + } + + public static class WordPage { + private final List words; + private final String nextCursor; + + public WordPage(List words, String nextCursor) { + this.words = words; + this.nextCursor = nextCursor; + } + + public List getWords() { + return words; + } + + public String getNextCursor() { + return nextCursor; + } + + public boolean hasMore() { + return nextCursor != null; + } + } +} diff --git a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/service/PollyService.java b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/service/PollyService.java new file mode 100644 index 00000000..5e7cb5c1 --- /dev/null +++ b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/service/PollyService.java @@ -0,0 +1,160 @@ +package com.mzc.secondproject.serverless.vocabulary.service; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.polly.PollyClient; +import software.amazon.awssdk.services.polly.model.OutputFormat; +import software.amazon.awssdk.services.polly.model.SynthesizeSpeechRequest; +import software.amazon.awssdk.services.polly.model.VoiceId; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; +import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.HeadObjectRequest; +import software.amazon.awssdk.services.s3.model.NoSuchKeyException; + +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.time.Duration; + +public class PollyService { + + private static final Logger logger = LoggerFactory.getLogger(PollyService.class); + + // Singleton 패턴으로 Cold Start 최적화 + private static final PollyClient pollyClient = PollyClient.builder().build(); + private static final S3Client s3Client = S3Client.builder().build(); + private static final S3Presigner s3Presigner = S3Presigner.builder().build(); + private static final String bucketName = System.getenv("VOCAB_BUCKET_NAME"); + + /** + * 단어 ID 기반으로 음성 합성 (캐시 지원) + * S3에 파일이 있으면 바로 URL 반환, 없으면 Polly 변환 후 저장 + */ + public VoiceSynthesisResult synthesizeSpeechForWord(String wordId, String text, String voice) { + String s3Key = generateS3Key(wordId, voice); + + // 캐시 확인: S3에 이미 존재하는지 체크 + if (existsInS3(s3Key)) { + logger.info("Cache hit: {}", s3Key); + String presignedUrl = getPresignedUrl(s3Key); + return new VoiceSynthesisResult(s3Key, presignedUrl, true); + } + + // 캐시 미스: Polly 변환 후 S3 저장 + logger.info("Cache miss: synthesizing and saving to {}", s3Key); + synthesizeAndSave(text, voice, s3Key); + String presignedUrl = getPresignedUrl(s3Key); + return new VoiceSynthesisResult(s3Key, presignedUrl, false); + } + + /** + * S3 키로 Pre-signed URL 생성 + */ + public String getPresignedUrl(String s3Key) { + GetObjectRequest getObjectRequest = GetObjectRequest.builder() + .bucket(bucketName) + .key(s3Key) + .build(); + + GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() + .signatureDuration(Duration.ofHours(1)) + .getObjectRequest(getObjectRequest) + .build(); + + PresignedGetObjectRequest presignedRequest = s3Presigner.presignGetObject(presignRequest); + return presignedRequest.url().toString(); + } + + /** + * S3에 파일 존재 여부 확인 + */ + public boolean existsInS3(String s3Key) { + try { + s3Client.headObject(HeadObjectRequest.builder() + .bucket(bucketName) + .key(s3Key) + .build()); + return true; + } catch (NoSuchKeyException e) { + return false; + } + } + + /** + * Polly로 음성 변환 후 지정된 S3 키로 저장 + */ + private void synthesizeAndSave(String text, String voice, String s3Key) { + VoiceId voiceId = resolveVoiceId(voice); + + try { + SynthesizeSpeechRequest request = SynthesizeSpeechRequest.builder() + .text(text) + .voiceId(voiceId) + .engine("neural") + .outputFormat(OutputFormat.MP3) + .build(); + + InputStream audioStream = pollyClient.synthesizeSpeech(request); + + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + byte[] data = new byte[4096]; + int bytesRead; + while ((bytesRead = audioStream.read(data, 0, data.length)) != -1) { + buffer.write(data, 0, bytesRead); + } + byte[] audioBytes = buffer.toByteArray(); + + s3Client.putObject( + PutObjectRequest.builder() + .bucket(bucketName) + .key(s3Key) + .contentType("audio/mpeg") + .build(), + RequestBody.fromBytes(audioBytes) + ); + + logger.info("Saved audio to S3: {}", s3Key); + } catch (Exception e) { + logger.error("Error synthesizing speech", e); + throw new RuntimeException("Failed to synthesize speech", e); + } + } + + /** + * 단어 ID와 음성 타입으로 S3 키 생성 + */ + public String generateS3Key(String wordId, String voice) { + String voiceSuffix = "MALE".equalsIgnoreCase(voice) ? "male" : "female"; + return "vocab/voice/" + wordId + "_" + voiceSuffix + ".mp3"; + } + + /** + * 음성 합성 결과 + */ + public static class VoiceSynthesisResult { + private final String s3Key; + private final String audioUrl; + private final boolean cached; + + public VoiceSynthesisResult(String s3Key, String audioUrl, boolean cached) { + this.s3Key = s3Key; + this.audioUrl = audioUrl; + this.cached = cached; + } + + public String getS3Key() { return s3Key; } + public String getAudioUrl() { return audioUrl; } + public boolean isCached() { return cached; } + } + + private VoiceId resolveVoiceId(String voice) { + if ("MALE".equalsIgnoreCase(voice)) { + return VoiceId.MATTHEW; // 미국 영어 남성 (Neural 지원) + } + return VoiceId.JOANNA; // 미국 영어 여성 (Neural 지원, 기본값) + } +} diff --git a/vocabulary/VocabFunction/src/main/resources/log4j2.xml b/vocabulary/VocabFunction/src/main/resources/log4j2.xml new file mode 100644 index 00000000..69a29404 --- /dev/null +++ b/vocabulary/VocabFunction/src/main/resources/log4j2.xml @@ -0,0 +1,17 @@ + + + + + + %d{yyyy-MM-dd HH:mm:ss} %X{AWSRequestId} %-5p %c{1} - %m%n + + + + + + + + + + + diff --git a/vocabulary/template.yaml b/vocabulary/template.yaml new file mode 100644 index 00000000..4970f7d8 --- /dev/null +++ b/vocabulary/template.yaml @@ -0,0 +1,314 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: Group2 English Study - Vocabulary Domain + +Globals: + Function: + Timeout: 30 + MemorySize: 512 + Runtime: java21 + Architectures: + - x86_64 + Environment: + Variables: + VOCAB_TABLE_NAME: !Ref VocabTable + VOCAB_BUCKET_NAME: group2-englishstudy + AWS_REGION_NAME: !Ref AWS::Region + +Resources: + ############################################# + # Lambda Functions + ############################################# + + # 단어 관리 함수 + WordFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: group2-englishstudy-vocab-word-handler + CodeUri: VocabFunction + Handler: com.mzc.secondproject.serverless.vocabulary.handler.WordHandler::handleRequest + Description: Handle word CRUD operations + SnapStart: + ApplyOn: PublishedVersions + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref VocabTable + Events: + CreateWord: + Type: Api + Properties: + RestApiId: !Ref VocabApi + Path: /vocab/words + Method: POST + GetWords: + Type: Api + Properties: + RestApiId: !Ref VocabApi + Path: /vocab/words + Method: GET + GetWord: + Type: Api + Properties: + RestApiId: !Ref VocabApi + Path: /vocab/words/{wordId} + Method: GET + UpdateWord: + Type: Api + Properties: + RestApiId: !Ref VocabApi + Path: /vocab/words/{wordId} + Method: PUT + DeleteWord: + Type: Api + Properties: + RestApiId: !Ref VocabApi + Path: /vocab/words/{wordId} + Method: DELETE + + # 사용자 단어 학습 상태 관리 함수 + UserWordFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: group2-englishstudy-vocab-userword-handler + CodeUri: VocabFunction + Handler: com.mzc.secondproject.serverless.vocabulary.handler.UserWordHandler::handleRequest + Description: Handle user word learning status + SnapStart: + ApplyOn: PublishedVersions + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref VocabTable + Events: + GetUserWords: + Type: Api + Properties: + RestApiId: !Ref VocabApi + Path: /vocab/users/{userId}/words + Method: GET + GetUserWord: + Type: Api + Properties: + RestApiId: !Ref VocabApi + Path: /vocab/users/{userId}/words/{wordId} + Method: GET + UpdateUserWord: + Type: Api + Properties: + RestApiId: !Ref VocabApi + Path: /vocab/users/{userId}/words/{wordId} + Method: PUT + + # 일일 학습 관리 함수 (55개 단어 할당) + DailyStudyFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: group2-englishstudy-vocab-daily-handler + CodeUri: VocabFunction + Handler: com.mzc.secondproject.serverless.vocabulary.handler.DailyStudyHandler::handleRequest + Description: Handle daily study word assignment + SnapStart: + ApplyOn: PublishedVersions + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref VocabTable + Events: + GetDailyWords: + Type: Api + Properties: + RestApiId: !Ref VocabApi + Path: /vocab/daily/{userId} + Method: GET + MarkWordLearned: + Type: Api + Properties: + RestApiId: !Ref VocabApi + Path: /vocab/daily/{userId}/words/{wordId}/learned + Method: POST + + # 시험 기능 함수 + TestFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: group2-englishstudy-vocab-test-handler + CodeUri: VocabFunction + Handler: com.mzc.secondproject.serverless.vocabulary.handler.TestHandler::handleRequest + Description: Handle vocabulary tests + SnapStart: + ApplyOn: PublishedVersions + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref VocabTable + Events: + StartTest: + Type: Api + Properties: + RestApiId: !Ref VocabApi + Path: /vocab/test/{userId}/start + Method: POST + SubmitAnswer: + Type: Api + Properties: + RestApiId: !Ref VocabApi + Path: /vocab/test/{userId}/submit + Method: POST + GetTestResult: + Type: Api + Properties: + RestApiId: !Ref VocabApi + Path: /vocab/test/{userId}/results + Method: GET + + # 통계 함수 + StatsFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: group2-englishstudy-vocab-stats-handler + CodeUri: VocabFunction + Handler: com.mzc.secondproject.serverless.vocabulary.handler.StatsHandler::handleRequest + Description: Handle user learning statistics + SnapStart: + ApplyOn: PublishedVersions + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref VocabTable + Events: + GetStats: + Type: Api + Properties: + RestApiId: !Ref VocabApi + Path: /vocab/stats/{userId} + Method: GET + GetDailyStats: + Type: Api + Properties: + RestApiId: !Ref VocabApi + Path: /vocab/stats/{userId}/daily + Method: GET + + # 음성 변환 함수 (Polly) + VoiceFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: group2-englishstudy-vocab-voice-handler + CodeUri: VocabFunction + Handler: com.mzc.secondproject.serverless.vocabulary.handler.VoiceHandler::handleRequest + Description: Convert word to speech using Polly + SnapStart: + ApplyOn: PublishedVersions + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref VocabTable + - S3CrudPolicy: + BucketName: group2-englishstudy + - Statement: + - Effect: Allow + Action: + - polly:SynthesizeSpeech + - polly:DescribeVoices + Resource: "*" + Events: + TextToSpeech: + Type: Api + Properties: + RestApiId: !Ref VocabApi + Path: /vocab/voice/synthesize + Method: POST + + ############################################# + # API Gateway + ############################################# + + VocabApi: + Type: AWS::Serverless::Api + Properties: + Name: group2-englishstudy-vocab-api + StageName: dev + Cors: + AllowMethods: "'GET,POST,PUT,DELETE,OPTIONS'" + AllowHeaders: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" + AllowOrigin: "'*'" + + ############################################# + # DynamoDB + ############################################# + + VocabTable: + Type: AWS::DynamoDB::Table + Properties: + TableName: group2-englishstudy-vocab + BillingMode: PAY_PER_REQUEST + AttributeDefinitions: + - AttributeName: PK + AttributeType: S + - AttributeName: SK + AttributeType: S + - AttributeName: GSI1PK + AttributeType: S + - AttributeName: GSI1SK + AttributeType: S + - AttributeName: GSI2PK + AttributeType: S + - AttributeName: GSI2SK + AttributeType: S + KeySchema: + - AttributeName: PK + KeyType: HASH + - AttributeName: SK + KeyType: RANGE + GlobalSecondaryIndexes: + - IndexName: GSI1 + KeySchema: + - AttributeName: GSI1PK + KeyType: HASH + - AttributeName: GSI1SK + KeyType: RANGE + Projection: + ProjectionType: ALL + - IndexName: GSI2 + KeySchema: + - AttributeName: GSI2PK + KeyType: HASH + - AttributeName: GSI2SK + KeyType: RANGE + Projection: + ProjectionType: ALL + TimeToLiveSpecification: + AttributeName: ttl + Enabled: true + +############################################# +# Outputs +############################################# + +Outputs: + VocabApiUrl: + Description: API Gateway endpoint URL + Value: !Sub 'https://${VocabApi}.execute-api.${AWS::Region}.amazonaws.com/dev/' + + VocabTableName: + Description: DynamoDB Table Name + Value: !Ref VocabTable + + WordFunctionArn: + Description: Word Lambda Function ARN + Value: !GetAtt WordFunction.Arn + + UserWordFunctionArn: + Description: UserWord Lambda Function ARN + Value: !GetAtt UserWordFunction.Arn + + DailyStudyFunctionArn: + Description: DailyStudy Lambda Function ARN + Value: !GetAtt DailyStudyFunction.Arn + + TestFunctionArn: + Description: Test Lambda Function ARN + Value: !GetAtt TestFunction.Arn + + StatsFunctionArn: + Description: Stats Lambda Function ARN + Value: !GetAtt StatsFunction.Arn + + VoiceFunctionArn: + Description: Voice Lambda Function ARN + Value: !GetAtt VoiceFunction.Arn From 17886e8d0d7210579294d41d1e65851cf001312f Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Wed, 7 Jan 2026 14:06:23 +0900 Subject: [PATCH 004/528] =?UTF-8?q?feat:=20=EB=8B=A8=EC=96=B4=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EC=B6=94=EA=B0=80=20=EA=B8=B0=EB=8A=A5=20=EB=B0=8F?= =?UTF-8?q?=20API=20Gateway=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 단어 일괄 등록 API (POST /vocab/words/batch) - 단어 검색 API (GET /vocab/words/search) - 사용자 단어 태그 API (북마크, 즐겨찾기, 난이도) - 약점 분석 API (카테고리/레벨별 정확도 분석) - Chat + Vocab API Gateway 통합 (단일 엔드포인트) - 통합 빌드/배포 스크립트 (deploy.sh) - 시드 데이터 69개 단어 추가 - 프론트엔드용 API 명세서 작성 --- chatting/seed-data.sh | 2 +- chatting/template.yaml | 345 +++++++-- deploy.sh | 146 ++++ docs/ReadMe.md | 1 + docs/VOCAB_API_SPEC.md | 678 ++++++++++++++++++ .../vocabulary/handler/StatsHandler.java | 167 ++++- .../vocabulary/handler/UserWordHandler.java | 71 ++ .../vocabulary/handler/WordHandler.java | 105 ++- .../serverless/vocabulary/model/UserWord.java | 5 + .../vocabulary/repository/WordRepository.java | 50 ++ vocabulary/seed-data/words.json | 73 ++ vocabulary/template.yaml | 73 +- 12 files changed, 1630 insertions(+), 86 deletions(-) create mode 100755 deploy.sh create mode 100644 docs/VOCAB_API_SPEC.md create mode 100644 vocabulary/seed-data/words.json diff --git a/chatting/seed-data.sh b/chatting/seed-data.sh index d1684464..b1e584e3 100755 --- a/chatting/seed-data.sh +++ b/chatting/seed-data.sh @@ -1,6 +1,6 @@ #!/bin/bash -API_URL="https://ha3vg3u73g.execute-api.ap-northeast-2.amazonaws.com/dev" +API_URL="https://gc8l9ijhzc.execute-api.ap-northeast-2.amazonaws.com/dev" echo "=== 채팅방 15개 생성 ===" LEVELS=("beginner" "intermediate" "advanced") diff --git a/chatting/template.yaml b/chatting/template.yaml index e9779779..21c887f5 100644 --- a/chatting/template.yaml +++ b/chatting/template.yaml @@ -1,6 +1,6 @@ AWSTemplateFormatVersion: '2010-09-09' Transform: AWS::Serverless-2016-10-31 -Description: Group2 English Study - Chatting Domain +Description: Group2 English Study - Unified API (Chatting + Vocabulary) Globals: Function: @@ -12,15 +12,30 @@ Globals: Environment: Variables: CHAT_TABLE_NAME: !Ref ChatTable + VOCAB_TABLE_NAME: !Ref VocabTable CHAT_BUCKET_NAME: group2-englishstudy + VOCAB_BUCKET_NAME: group2-englishstudy AWS_REGION_NAME: !Ref AWS::Region Resources: ############################################# - # Lambda Functions + # API Gateway (Unified) + ############################################# + + MainApi: + Type: AWS::Serverless::Api + Properties: + Name: group2-englishstudy-api + StageName: dev + Cors: + AllowMethods: "'GET,POST,PUT,DELETE,OPTIONS'" + AllowHeaders: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" + AllowOrigin: "'*'" + + ############################################# + # Chatting Lambda Functions ############################################# - # 채팅방 관리 함수 ChatRoomFunction: Type: AWS::Serverless::Function Properties: @@ -37,41 +52,40 @@ Resources: CreateRoom: Type: Api Properties: - RestApiId: !Ref ChatApi + RestApiId: !Ref MainApi Path: /chat/rooms Method: POST GetRooms: Type: Api Properties: - RestApiId: !Ref ChatApi + RestApiId: !Ref MainApi Path: /chat/rooms Method: GET GetRoom: Type: Api Properties: - RestApiId: !Ref ChatApi + RestApiId: !Ref MainApi Path: /chat/rooms/{roomId} Method: GET DeleteRoom: Type: Api Properties: - RestApiId: !Ref ChatApi + RestApiId: !Ref MainApi Path: /chat/rooms/{roomId} Method: DELETE JoinRoom: Type: Api Properties: - RestApiId: !Ref ChatApi + RestApiId: !Ref MainApi Path: /chat/rooms/{roomId}/join Method: POST LeaveRoom: Type: Api Properties: - RestApiId: !Ref ChatApi + RestApiId: !Ref MainApi Path: /chat/rooms/{roomId}/leave Method: POST - # 채팅 메시지 처리 함수 ChatMessageFunction: Type: AWS::Serverless::Function Properties: @@ -102,23 +116,22 @@ Resources: SendMessage: Type: Api Properties: - RestApiId: !Ref ChatApi + RestApiId: !Ref MainApi Path: /chat/rooms/{roomId}/messages Method: POST GetMessages: Type: Api Properties: - RestApiId: !Ref ChatApi + RestApiId: !Ref MainApi Path: /chat/rooms/{roomId}/messages Method: GET GetMessage: Type: Api Properties: - RestApiId: !Ref ChatApi + RestApiId: !Ref MainApi Path: /chat/rooms/{roomId}/messages/{messageId} Method: GET - # AI 응답 생성 함수 (Bedrock) ChatAIFunction: Type: AWS::Serverless::Function Properties: @@ -143,11 +156,10 @@ Resources: GenerateAI: Type: Api Properties: - RestApiId: !Ref ChatApi + RestApiId: !Ref MainApi Path: /chat/ai/generate Method: POST - # 음성 변환 함수 (Polly) ChatVoiceFunction: Type: AWS::Serverless::Function Properties: @@ -172,26 +184,228 @@ Resources: TextToSpeech: Type: Api Properties: - RestApiId: !Ref ChatApi + RestApiId: !Ref MainApi Path: /chat/voice/synthesize Method: POST ############################################# - # API Gateway + # Vocabulary Lambda Functions ############################################# - ChatApi: - Type: AWS::Serverless::Api + WordFunction: + Type: AWS::Serverless::Function Properties: - Name: group2-englishstudy-chat-api - StageName: dev - Cors: - AllowMethods: "'GET,POST,PUT,DELETE,OPTIONS'" - AllowHeaders: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" - AllowOrigin: "'*'" + FunctionName: group2-englishstudy-vocab-word-handler + CodeUri: ../vocabulary/VocabFunction + Handler: com.mzc.secondproject.serverless.vocabulary.handler.WordHandler::handleRequest + Description: Handle word CRUD operations + SnapStart: + ApplyOn: PublishedVersions + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref VocabTable + Events: + CreateWord: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/words + Method: POST + BatchCreateWords: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/words/batch + Method: POST + SearchWords: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/words/search + Method: GET + GetWords: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/words + Method: GET + GetWord: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/words/{wordId} + Method: GET + UpdateWord: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/words/{wordId} + Method: PUT + DeleteWord: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/words/{wordId} + Method: DELETE + + UserWordFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: group2-englishstudy-vocab-userword-handler + CodeUri: ../vocabulary/VocabFunction + Handler: com.mzc.secondproject.serverless.vocabulary.handler.UserWordHandler::handleRequest + Description: Handle user word learning status + SnapStart: + ApplyOn: PublishedVersions + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref VocabTable + Events: + GetUserWords: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/users/{userId}/words + Method: GET + GetUserWord: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/users/{userId}/words/{wordId} + Method: GET + UpdateUserWord: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/users/{userId}/words/{wordId} + Method: PUT + UpdateUserWordTag: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/users/{userId}/words/{wordId}/tag + Method: PUT + + DailyStudyFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: group2-englishstudy-vocab-daily-handler + CodeUri: ../vocabulary/VocabFunction + Handler: com.mzc.secondproject.serverless.vocabulary.handler.DailyStudyHandler::handleRequest + Description: Handle daily study word assignment + SnapStart: + ApplyOn: PublishedVersions + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref VocabTable + Events: + GetDailyWords: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/daily/{userId} + Method: GET + MarkWordLearned: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/daily/{userId}/words/{wordId}/learned + Method: POST + + TestFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: group2-englishstudy-vocab-test-handler + CodeUri: ../vocabulary/VocabFunction + Handler: com.mzc.secondproject.serverless.vocabulary.handler.TestHandler::handleRequest + Description: Handle vocabulary tests + SnapStart: + ApplyOn: PublishedVersions + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref VocabTable + Events: + StartTest: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/test/{userId}/start + Method: POST + SubmitAnswer: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/test/{userId}/submit + Method: POST + GetTestResult: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/test/{userId}/results + Method: GET + + StatsFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: group2-englishstudy-vocab-stats-handler + CodeUri: ../vocabulary/VocabFunction + Handler: com.mzc.secondproject.serverless.vocabulary.handler.StatsHandler::handleRequest + Description: Handle user learning statistics + SnapStart: + ApplyOn: PublishedVersions + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref VocabTable + Events: + GetStats: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/stats/{userId} + Method: GET + GetDailyStats: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/stats/{userId}/daily + Method: GET + GetWeaknessAnalysis: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/stats/{userId}/weakness + Method: GET + + VocabVoiceFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: group2-englishstudy-vocab-voice-handler + CodeUri: ../vocabulary/VocabFunction + Handler: com.mzc.secondproject.serverless.vocabulary.handler.VoiceHandler::handleRequest + Description: Convert word to speech using Polly + SnapStart: + ApplyOn: PublishedVersions + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref VocabTable + - S3CrudPolicy: + BucketName: group2-englishstudy + - Statement: + - Effect: Allow + Action: + - polly:SynthesizeSpeech + - polly:DescribeVoices + Resource: "*" + Events: + TextToSpeech: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/voice/synthesize + Method: POST ############################################# - # DynamoDB + # DynamoDB Tables ############################################# ChatTable: @@ -238,40 +452,67 @@ Resources: AttributeName: ttl Enabled: true - ############################################# - # S3 Bucket (기존 버킷 사용) - ############################################# - # 기존 버킷 group2-englishstudy 사용 - 버킷 생성 제거 + VocabTable: + Type: AWS::DynamoDB::Table + Properties: + TableName: group2-englishstudy-vocab + BillingMode: PAY_PER_REQUEST + AttributeDefinitions: + - AttributeName: PK + AttributeType: S + - AttributeName: SK + AttributeType: S + - AttributeName: GSI1PK + AttributeType: S + - AttributeName: GSI1SK + AttributeType: S + - AttributeName: GSI2PK + AttributeType: S + - AttributeName: GSI2SK + AttributeType: S + KeySchema: + - AttributeName: PK + KeyType: HASH + - AttributeName: SK + KeyType: RANGE + GlobalSecondaryIndexes: + - IndexName: GSI1 + KeySchema: + - AttributeName: GSI1PK + KeyType: HASH + - AttributeName: GSI1SK + KeyType: RANGE + Projection: + ProjectionType: ALL + - IndexName: GSI2 + KeySchema: + - AttributeName: GSI2PK + KeyType: HASH + - AttributeName: GSI2SK + KeyType: RANGE + Projection: + ProjectionType: ALL + TimeToLiveSpecification: + AttributeName: ttl + Enabled: true ############################################# # Outputs ############################################# Outputs: - ChatApiUrl: - Description: API Gateway endpoint URL - Value: !Sub 'https://${ChatApi}.execute-api.${AWS::Region}.amazonaws.com/dev/' + ApiUrl: + Description: Unified API Gateway endpoint URL + Value: !Sub 'https://${MainApi}.execute-api.${AWS::Region}.amazonaws.com/dev/' ChatTableName: - Description: DynamoDB Table Name + Description: Chat DynamoDB Table Name Value: !Ref ChatTable - ChatBucketName: + VocabTableName: + Description: Vocab DynamoDB Table Name + Value: !Ref VocabTable + + BucketName: Description: S3 Bucket Name Value: group2-englishstudy - - ChatRoomFunctionArn: - Description: Chat Room Lambda Function ARN - Value: !GetAtt ChatRoomFunction.Arn - - ChatMessageFunctionArn: - Description: Chat Message Lambda Function ARN - Value: !GetAtt ChatMessageFunction.Arn - - ChatAIFunctionArn: - Description: Chat AI Lambda Function ARN - Value: !GetAtt ChatAIFunction.Arn - - ChatVoiceFunctionArn: - Description: Chat Voice Lambda Function ARN - Value: !GetAtt ChatVoiceFunction.Arn diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 00000000..9df6b9ac --- /dev/null +++ b/deploy.sh @@ -0,0 +1,146 @@ +#!/bin/bash + +# Group2 English Study - 통합 빌드 & 배포 스크립트 +# 사용법: ./deploy.sh [build|deploy|all] + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CHATTING_DIR="$SCRIPT_DIR/chatting" + +# 색상 정의 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +log_info() { + echo -e "${GREEN}[INFO]${NC} $1" +} + +log_warn() { + echo -e "${YELLOW}[WARN]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +build() { + log_info "==========================================" + log_info "빌드 시작: Chat + Vocab 통합" + log_info "==========================================" + + cd "$CHATTING_DIR" + sam build + + log_info "빌드 완료!" +} + +deploy() { + log_info "==========================================" + log_info "배포 시작: group2-englishstudy-chatting" + log_info "==========================================" + + cd "$CHATTING_DIR" + sam deploy --no-confirm-changeset + + # API URL 출력 + log_info "==========================================" + log_info "배포 완료!" + API_URL=$(aws cloudformation describe-stacks \ + --stack-name group2-englishstudy-chatting \ + --profile mzc \ + --region ap-northeast-2 \ + --query 'Stacks[0].Outputs[?OutputKey==`ApiUrl`].OutputValue' \ + --output text 2>/dev/null) + + if [ -n "$API_URL" ]; then + log_info "API URL: $API_URL" + fi + log_info "==========================================" +} + +validate() { + log_info "템플릿 검증 중..." + cd "$CHATTING_DIR" + sam validate + log_info "템플릿 검증 완료!" +} + +status() { + log_info "스택 상태 확인 중..." + aws cloudformation describe-stacks \ + --stack-name group2-englishstudy-chatting \ + --profile mzc \ + --region ap-northeast-2 \ + --query 'Stacks[0].{Status:StackStatus,LastUpdated:LastUpdatedTime}' \ + --output table 2>/dev/null || log_warn "스택이 존재하지 않습니다." +} + +delete() { + log_warn "==========================================" + log_warn "스택 삭제: group2-englishstudy-chatting" + log_warn "==========================================" + read -p "정말 삭제하시겠습니까? (y/N): " confirm + if [ "$confirm" = "y" ] || [ "$confirm" = "Y" ]; then + aws cloudformation delete-stack \ + --stack-name group2-englishstudy-chatting \ + --profile mzc \ + --region ap-northeast-2 + log_info "삭제 요청 완료. 'aws cloudformation wait stack-delete-complete'로 완료 대기 가능" + else + log_info "삭제 취소됨" + fi +} + +show_help() { + echo "Group2 English Study - 빌드 & 배포 스크립트" + echo "" + echo "사용법: $0 [command]" + echo "" + echo "Commands:" + echo " build SAM 빌드만 실행" + echo " deploy SAM 배포만 실행" + echo " all 빌드 + 배포 (기본값)" + echo " validate 템플릿 검증" + echo " status 스택 상태 확인" + echo " delete 스택 삭제" + echo " help 도움말 표시" + echo "" + echo "예시:" + echo " $0 all # 빌드 후 배포" + echo " $0 build # 빌드만" + echo " $0 deploy # 배포만" +} + +# 메인 로직 +case "${1:-all}" in + build) + build + ;; + deploy) + deploy + ;; + all) + build + deploy + ;; + validate) + validate + ;; + status) + status + ;; + delete) + delete + ;; + help|--help|-h) + show_help + ;; + *) + log_error "알 수 없는 명령: $1" + show_help + exit 1 + ;; +esac diff --git a/docs/ReadMe.md b/docs/ReadMe.md index e69de29b..8b137891 100644 --- a/docs/ReadMe.md +++ b/docs/ReadMe.md @@ -0,0 +1 @@ + diff --git a/docs/VOCAB_API_SPEC.md b/docs/VOCAB_API_SPEC.md new file mode 100644 index 00000000..a972647a --- /dev/null +++ b/docs/VOCAB_API_SPEC.md @@ -0,0 +1,678 @@ +# 단어 암기 서비스 API 명세서 + +## 개요 +영어 단어 암기 학습을 위한 백엔드 API입니다. +- **Base URL**: `https://gc8l9ijhzc.execute-api.ap-northeast-2.amazonaws.com/dev` +- **Content-Type**: `application/json` + +--- + +## 핵심 기능 + +| 기능 | 설명 | +|------|------| +| 일일 학습 | 매일 55개 단어 (새 단어 50개 + 복습 5개) | +| Spaced Repetition | SM-2 알고리즘 기반 최적 복습 주기 | +| 시험 | 학습한 단어 테스트 및 성적 기록 | +| TTS | Polly 기반 발음 듣기 (남성/여성) | +| 약점 분석 | 틀린 단어, 카테고리별 정확도 분석 | + +--- + +## 1. 단어 관리 API + +### 1.1 단어 목록 조회 +``` +GET /vocab/words +``` + +**Query Parameters** +| 파라미터 | 타입 | 필수 | 설명 | +|---------|------|------|------| +| level | string | N | `BEGINNER`, `INTERMEDIATE`, `ADVANCED` | +| category | string | N | `DAILY`, `BUSINESS`, `ACADEMIC` | +| limit | number | N | 페이지 크기 (기본: 20, 최대: 50) | +| cursor | string | N | 페이지네이션 커서 | + +**Response** +```json +{ + "success": true, + "message": "Words retrieved", + "data": { + "words": [ + { + "wordId": "uuid", + "english": "apple", + "korean": "사과", + "example": "I eat an apple every day.", + "level": "BEGINNER", + "category": "DAILY" + } + ], + "nextCursor": "base64-encoded-cursor", + "hasMore": true + } +} +``` + +--- + +### 1.2 단어 검색 +``` +GET /vocab/words/search +``` + +**Query Parameters** +| 파라미터 | 타입 | 필수 | 설명 | +|---------|------|------|------| +| q | string | Y | 검색어 (영어/한국어 모두 가능) | +| limit | number | N | 결과 개수 (기본: 20) | +| cursor | string | N | 페이지네이션 커서 | + +**Response** +```json +{ + "success": true, + "message": "Search completed", + "data": { + "words": [...], + "query": "apple", + "nextCursor": null, + "hasMore": false + } +} +``` + +--- + +### 1.3 단어 상세 조회 +``` +GET /vocab/words/{wordId} +``` + +**Response** +```json +{ + "success": true, + "message": "Word retrieved", + "data": { + "wordId": "uuid", + "english": "apple", + "korean": "사과", + "example": "I eat an apple every day.", + "level": "BEGINNER", + "category": "DAILY", + "createdAt": "2024-01-07T12:00:00Z" + } +} +``` + +--- + +## 2. 일일 학습 API + +### 2.1 오늘의 학습 단어 조회 +``` +GET /vocab/daily/{userId} +``` + +**설명**: 오늘 학습할 55개 단어를 반환합니다. +- 새 단어 50개 (아직 학습하지 않은 단어) +- 복습 단어 5개 (Spaced Repetition 기반) + +**Response** +```json +{ + "success": true, + "message": "Daily words retrieved", + "data": { + "date": "2024-01-07", + "userId": "user123", + "totalWords": 55, + "learnedCount": 10, + "isCompleted": false, + "newWords": [ + { + "wordId": "uuid", + "english": "apple", + "korean": "사과", + "example": "I eat an apple every day." + } + ], + "reviewWords": [ + { + "wordId": "uuid", + "english": "book", + "korean": "책", + "lastReviewedAt": "2024-01-05", + "correctCount": 3, + "incorrectCount": 1 + } + ] + } +} +``` + +--- + +### 2.2 단어 학습 완료 표시 +``` +POST /vocab/daily/{userId}/words/{wordId}/learned +``` + +**Request Body** +```json +{ + "isCorrect": true +} +``` + +**Response** +```json +{ + "success": true, + "message": "Word marked as learned", + "data": { + "wordId": "uuid", + "status": "LEARNING", + "nextReviewAt": "2024-01-08" + } +} +``` + +--- + +## 3. 사용자 단어 학습 상태 API + +### 3.1 학습 상태 조회 +``` +GET /vocab/users/{userId}/words +``` + +**Query Parameters** +| 파라미터 | 타입 | 필수 | 설명 | +|---------|------|------|------| +| status | string | N | `NEW`, `LEARNING`, `REVIEWING`, `MASTERED` | +| limit | number | N | 페이지 크기 (기본: 20) | +| cursor | string | N | 페이지네이션 커서 | + +**Response** +```json +{ + "success": true, + "message": "User words retrieved", + "data": { + "userWords": [ + { + "wordId": "uuid", + "userId": "user123", + "status": "LEARNING", + "correctCount": 5, + "incorrectCount": 2, + "interval": 6, + "nextReviewAt": "2024-01-13", + "lastReviewedAt": "2024-01-07", + "bookmarked": true, + "favorite": false, + "difficulty": "HARD" + } + ], + "nextCursor": null, + "hasMore": false + } +} +``` + +**학습 상태 설명** +| 상태 | 설명 | +|------|------| +| `NEW` | 아직 학습하지 않음 | +| `LEARNING` | 학습 중 (1-2회 정답) | +| `REVIEWING` | 복습 단계 (2-4회 정답) | +| `MASTERED` | 완전 암기 (5회 이상 연속 정답) | + +--- + +### 3.2 학습 결과 업데이트 (정답/오답) +``` +PUT /vocab/users/{userId}/words/{wordId} +``` + +**Request Body** +```json +{ + "isCorrect": true +} +``` + +**Response** +```json +{ + "success": true, + "message": "UserWord updated", + "data": { + "wordId": "uuid", + "status": "REVIEWING", + "interval": 6, + "easeFactor": 2.5, + "repetitions": 3, + "nextReviewAt": "2024-01-13", + "correctCount": 6, + "incorrectCount": 2 + } +} +``` + +**Spaced Repetition 알고리즘 (SM-2)** +- 정답 시: `interval` 증가 (1일 → 6일 → 이전간격 × easeFactor) +- 오답 시: `interval = 1`, `easeFactor` 감소 (최소 1.3) +- 5회 연속 정답 시 `MASTERED` 상태 + +--- + +### 3.3 단어 태그 변경 (북마크/즐겨찾기/난이도) +``` +PUT /vocab/users/{userId}/words/{wordId}/tag +``` + +**Request Body** +```json +{ + "bookmarked": true, + "favorite": false, + "difficulty": "HARD" +} +``` + +| 필드 | 타입 | 설명 | +|------|------|------| +| bookmarked | boolean | 북마크 여부 | +| favorite | boolean | 즐겨찾기 여부 | +| difficulty | string | `EASY`, `NORMAL`, `HARD` | + +**Response** +```json +{ + "success": true, + "message": "Tag updated", + "data": { + "wordId": "uuid", + "bookmarked": true, + "favorite": false, + "difficulty": "HARD" + } +} +``` + +--- + +## 4. 시험 API + +### 4.1 시험 시작 +``` +POST /vocab/test/{userId}/start +``` + +**Request Body** +```json +{ + "wordCount": 20, + "level": "BEGINNER", + "type": "KOREAN_TO_ENGLISH" +} +``` + +| 필드 | 타입 | 필수 | 설명 | +|------|------|------|------| +| wordCount | number | N | 문제 수 (기본: 20) | +| level | string | N | 출제 레벨 필터 | +| type | string | N | `KOREAN_TO_ENGLISH`, `ENGLISH_TO_KOREAN` | + +**Response** +```json +{ + "success": true, + "message": "Test started", + "data": { + "testId": "uuid", + "startedAt": "2024-01-07T12:00:00Z", + "wordCount": 20, + "questions": [ + { + "questionId": 1, + "wordId": "uuid", + "question": "사과", + "options": ["apple", "banana", "orange", "grape"], + "type": "KOREAN_TO_ENGLISH" + } + ] + } +} +``` + +--- + +### 4.2 답안 제출 +``` +POST /vocab/test/{userId}/submit +``` + +**Request Body** +```json +{ + "testId": "uuid", + "answers": [ + {"questionId": 1, "wordId": "uuid", "answer": "apple"}, + {"questionId": 2, "wordId": "uuid", "answer": "book"} + ] +} +``` + +**Response** +```json +{ + "success": true, + "message": "Test submitted", + "data": { + "testId": "uuid", + "totalQuestions": 20, + "correctCount": 18, + "incorrectCount": 2, + "successRate": 90.0, + "results": [ + { + "questionId": 1, + "wordId": "uuid", + "isCorrect": true, + "userAnswer": "apple", + "correctAnswer": "apple" + }, + { + "questionId": 5, + "wordId": "uuid", + "isCorrect": false, + "userAnswer": "banana", + "correctAnswer": "apple" + } + ], + "completedAt": "2024-01-07T12:15:00Z" + } +} +``` + +--- + +### 4.3 시험 결과 조회 +``` +GET /vocab/test/{userId}/results +``` + +**Query Parameters** +| 파라미터 | 타입 | 필수 | 설명 | +|---------|------|------|------| +| limit | number | N | 결과 개수 (기본: 20) | +| cursor | string | N | 페이지네이션 커서 | + +**Response** +```json +{ + "success": true, + "message": "Test results retrieved", + "data": { + "testResults": [ + { + "testId": "uuid", + "totalQuestions": 20, + "correctCount": 18, + "successRate": 90.0, + "completedAt": "2024-01-07T12:15:00Z" + } + ], + "nextCursor": null, + "hasMore": false + } +} +``` + +--- + +## 5. 통계 API + +### 5.1 전체 학습 통계 +``` +GET /vocab/stats/{userId} +``` + +**Response** +```json +{ + "success": true, + "message": "Stats retrieved", + "data": { + "totalWords": 150, + "wordStatusCounts": { + "NEW": 50, + "LEARNING": 40, + "REVIEWING": 35, + "MASTERED": 25 + }, + "totalCorrect": 500, + "totalIncorrect": 100, + "accuracy": 83.3, + "testCount": 15, + "avgSuccessRate": 85.5, + "studyDays": 30, + "completedDays": 25, + "completionRate": 83.3 + } +} +``` + +--- + +### 5.2 일별 학습 통계 +``` +GET /vocab/stats/{userId}/daily +``` + +**Query Parameters** +| 파라미터 | 타입 | 필수 | 설명 | +|---------|------|------|------| +| limit | number | N | 조회 일수 (기본: 30, 최대: 90) | + +**Response** +```json +{ + "success": true, + "message": "Daily stats retrieved", + "data": { + "dailyStats": [ + { + "date": "2024-01-07", + "totalWords": 55, + "learnedCount": 55, + "isCompleted": true, + "progress": 100.0 + }, + { + "date": "2024-01-06", + "totalWords": 55, + "learnedCount": 40, + "isCompleted": false, + "progress": 72.7 + } + ], + "nextCursor": null, + "hasMore": false + } +} +``` + +--- + +### 5.3 약점 분석 +``` +GET /vocab/stats/{userId}/weakness +``` + +**Response** +```json +{ + "success": true, + "message": "Weakness analysis completed", + "data": { + "weakestWords": [ + { + "wordId": "uuid", + "english": "hypothesis", + "korean": "가설", + "level": "ADVANCED", + "category": "ACADEMIC", + "incorrectCount": 5, + "correctCount": 2, + "accuracy": 28.6, + "status": "LEARNING" + } + ], + "categoryAnalysis": { + "DAILY": { + "totalCorrect": 200, + "totalIncorrect": 30, + "wordCount": 50, + "accuracy": 87.0 + }, + "BUSINESS": { + "totalCorrect": 150, + "totalIncorrect": 50, + "wordCount": 40, + "accuracy": 75.0 + }, + "ACADEMIC": { + "totalCorrect": 80, + "totalIncorrect": 40, + "wordCount": 30, + "accuracy": 66.7 + } + }, + "levelAnalysis": { + "BEGINNER": {"accuracy": 90.0, "wordCount": 50}, + "INTERMEDIATE": {"accuracy": 78.0, "wordCount": 40}, + "ADVANCED": {"accuracy": 65.0, "wordCount": 30} + }, + "suggestions": [ + "ACADEMIC 카테고리의 정확도가 66.7%로 가장 낮습니다. 집중 학습을 권장합니다.", + "ADVANCED 레벨의 정확도가 65.0%입니다. 이 레벨의 단어들을 더 복습해보세요.", + "자주 틀리는 단어 10개가 있습니다. 북마크하여 집중 복습하세요." + ] + } +} +``` + +--- + +## 6. 음성 API (TTS) + +### 6.1 단어 발음 듣기 +``` +POST /vocab/voice/synthesize +``` + +**Request Body** +```json +{ + "wordId": "uuid", + "text": "apple", + "voice": "FEMALE" +} +``` + +| 필드 | 타입 | 필수 | 설명 | +|------|------|------|------| +| wordId | string | Y | 단어 ID (캐시 키로 사용) | +| text | string | Y | 발음할 텍스트 | +| voice | string | N | `MALE` (Matthew), `FEMALE` (Joanna, 기본값) | + +**Response** +```json +{ + "success": true, + "message": "Speech synthesized", + "data": { + "audioUrl": "https://s3.ap-northeast-2.amazonaws.com/...", + "s3Key": "vocab/voice/uuid_female.mp3", + "cached": true + } +} +``` + +**참고**: +- `audioUrl`은 1시간 동안 유효한 Pre-signed URL입니다. +- 동일 단어+음성 조합은 S3에 캐시되어 재사용됩니다. + +--- + +## 에러 응답 형식 + +```json +{ + "success": false, + "error": "에러 메시지" +} +``` + +**HTTP 상태 코드** +| 코드 | 설명 | +|------|------| +| 200 | 성공 | +| 201 | 생성 성공 | +| 400 | 잘못된 요청 (필수 파라미터 누락 등) | +| 404 | 리소스 없음 | +| 500 | 서버 에러 | + +--- + +## 프론트엔드 구현 가이드 + +### 추천 화면 구성 + +1. **메인 대시보드** + - 오늘의 학습 진행률 (GET /vocab/daily/{userId}) + - 전체 통계 요약 (GET /vocab/stats/{userId}) + +2. **일일 학습 화면** + - 플래시카드 UI + - 정답/오답 버튼 → PUT /vocab/users/{userId}/words/{wordId} + - TTS 발음 듣기 버튼 → POST /vocab/voice/synthesize + +3. **시험 화면** + - 시험 시작 → POST /vocab/test/{userId}/start + - 4지선다 문제 표시 + - 답안 제출 → POST /vocab/test/{userId}/submit + - 결과 화면 (정답/오답 표시) + +4. **단어장 화면** + - 전체 단어 목록 (GET /vocab/words) + - 검색 기능 (GET /vocab/words/search) + - 북마크/즐겨찾기 토글 (PUT /vocab/users/{userId}/words/{wordId}/tag) + +5. **통계/분석 화면** + - 학습 달력 (일별 완료 여부) + - 약점 분석 차트 (GET /vocab/stats/{userId}/weakness) + - 레벨/카테고리별 정확도 그래프 + +### 상태 관리 추천 +- userId는 로그인 후 전역 상태로 관리 +- 일일 학습 단어 목록은 세션 캐시 +- 북마크/즐겨찾기는 낙관적 업데이트 + +--- + +## 테스트 데이터 + +현재 등록된 시드 데이터: +- **BEGINNER + DAILY**: 25개 (apple, book, cat, ...) +- **INTERMEDIATE + BUSINESS**: 25개 (achieve, benefit, ...) +- **ADVANCED + ACADEMIC**: 19개 (abstract, hypothesis, ...) + +총 **69개 단어** 등록됨 diff --git a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/StatsHandler.java b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/StatsHandler.java index 2e680e22..d5ec6b54 100644 --- a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/StatsHandler.java +++ b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/StatsHandler.java @@ -10,15 +10,20 @@ import com.mzc.secondproject.serverless.vocabulary.model.DailyStudy; import com.mzc.secondproject.serverless.vocabulary.model.TestResult; import com.mzc.secondproject.serverless.vocabulary.model.UserWord; +import com.mzc.secondproject.serverless.vocabulary.model.Word; import com.mzc.secondproject.serverless.vocabulary.repository.DailyStudyRepository; import com.mzc.secondproject.serverless.vocabulary.repository.TestResultRepository; import com.mzc.secondproject.serverless.vocabulary.repository.UserWordRepository; +import com.mzc.secondproject.serverless.vocabulary.repository.WordRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.util.ArrayList; +import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; public class StatsHandler implements RequestHandler { @@ -28,11 +33,13 @@ public class StatsHandler implements RequestHandler pathParams = request.getPathParameters(); + String userId = pathParams != null ? pathParams.get("userId") : null; + + if (userId == null) { + return createResponse(400, ApiResponse.error("userId is required")); + } + + // 사용자의 모든 학습 단어 조회 + List allUserWords = new ArrayList<>(); + String cursor = null; + do { + UserWordRepository.UserWordPage page = userWordRepository.findByUserIdWithPagination(userId, 100, cursor); + allUserWords.addAll(page.getUserWords()); + cursor = page.getNextCursor(); + } while (cursor != null); + + if (allUserWords.isEmpty()) { + Map emptyResult = new HashMap<>(); + emptyResult.put("weakestWords", List.of()); + emptyResult.put("categoryAnalysis", Map.of()); + emptyResult.put("levelAnalysis", Map.of()); + emptyResult.put("suggestions", List.of()); + return createResponse(200, ApiResponse.success("No learning data", emptyResult)); + } + + // 1. 가장 많이 틀린 단어 Top 10 + List> weakestWords = allUserWords.stream() + .filter(uw -> uw.getIncorrectCount() != null && uw.getIncorrectCount() > 0) + .sorted(Comparator.comparingInt(UserWord::getIncorrectCount).reversed()) + .limit(10) + .map(uw -> { + Map wordInfo = new HashMap<>(); + wordInfo.put("wordId", uw.getWordId()); + wordInfo.put("incorrectCount", uw.getIncorrectCount()); + wordInfo.put("correctCount", uw.getCorrectCount()); + wordInfo.put("status", uw.getStatus()); + + // 단어 상세 정보 조회 + wordRepository.findById(uw.getWordId()).ifPresent(word -> { + wordInfo.put("english", word.getEnglish()); + wordInfo.put("korean", word.getKorean()); + wordInfo.put("level", word.getLevel()); + wordInfo.put("category", word.getCategory()); + }); + + int total = (uw.getCorrectCount() != null ? uw.getCorrectCount() : 0) + + (uw.getIncorrectCount() != null ? uw.getIncorrectCount() : 0); + wordInfo.put("accuracy", total > 0 ? + (uw.getCorrectCount() != null ? uw.getCorrectCount() * 100.0 / total : 0) : 0); + + return wordInfo; + }) + .collect(Collectors.toList()); + + // 2. 카테고리별 정확도 분석 + Map> categoryAnalysis = new HashMap<>(); + // 3. 레벨별 정확도 분석 + Map> levelAnalysis = new HashMap<>(); + + for (UserWord uw : allUserWords) { + // 단어 정보 조회 + wordRepository.findById(uw.getWordId()).ifPresent(word -> { + String category = word.getCategory(); + String level = word.getLevel(); + + int correct = uw.getCorrectCount() != null ? uw.getCorrectCount() : 0; + int incorrect = uw.getIncorrectCount() != null ? uw.getIncorrectCount() : 0; + + // 카테고리별 집계 + categoryAnalysis.computeIfAbsent(category, k -> { + Map stats = new HashMap<>(); + stats.put("totalCorrect", 0); + stats.put("totalIncorrect", 0); + stats.put("wordCount", 0); + return stats; + }); + Map catStats = categoryAnalysis.get(category); + catStats.put("totalCorrect", (Integer) catStats.get("totalCorrect") + correct); + catStats.put("totalIncorrect", (Integer) catStats.get("totalIncorrect") + incorrect); + catStats.put("wordCount", (Integer) catStats.get("wordCount") + 1); + + // 레벨별 집계 + levelAnalysis.computeIfAbsent(level, k -> { + Map stats = new HashMap<>(); + stats.put("totalCorrect", 0); + stats.put("totalIncorrect", 0); + stats.put("wordCount", 0); + return stats; + }); + Map lvlStats = levelAnalysis.get(level); + lvlStats.put("totalCorrect", (Integer) lvlStats.get("totalCorrect") + correct); + lvlStats.put("totalIncorrect", (Integer) lvlStats.get("totalIncorrect") + incorrect); + lvlStats.put("wordCount", (Integer) lvlStats.get("wordCount") + 1); + }); + } + + // 정확도 계산 + categoryAnalysis.values().forEach(stats -> { + int correct = (Integer) stats.get("totalCorrect"); + int incorrect = (Integer) stats.get("totalIncorrect"); + int total = correct + incorrect; + stats.put("accuracy", total > 0 ? (correct * 100.0 / total) : 0); + }); + + levelAnalysis.values().forEach(stats -> { + int correct = (Integer) stats.get("totalCorrect"); + int incorrect = (Integer) stats.get("totalIncorrect"); + int total = correct + incorrect; + stats.put("accuracy", total > 0 ? (correct * 100.0 / total) : 0); + }); + + // 4. 학습 제안 생성 + List suggestions = new ArrayList<>(); + + // 가장 약한 카테고리 찾기 + categoryAnalysis.entrySet().stream() + .filter(e -> (Integer) e.getValue().get("wordCount") >= 3) // 최소 3개 이상 학습한 카테고리만 + .min(Comparator.comparingDouble(e -> (Double) e.getValue().get("accuracy"))) + .ifPresent(e -> suggestions.add( + String.format("%s 카테고리의 정확도가 %.1f%%로 가장 낮습니다. 집중 학습을 권장합니다.", + e.getKey(), e.getValue().get("accuracy")))); + + // 가장 약한 레벨 찾기 + levelAnalysis.entrySet().stream() + .filter(e -> (Integer) e.getValue().get("wordCount") >= 3) + .min(Comparator.comparingDouble(e -> (Double) e.getValue().get("accuracy"))) + .ifPresent(e -> suggestions.add( + String.format("%s 레벨의 정확도가 %.1f%%입니다. 이 레벨의 단어들을 더 복습해보세요.", + e.getKey(), e.getValue().get("accuracy")))); + + // 많이 틀린 단어가 있는 경우 + if (!weakestWords.isEmpty()) { + suggestions.add(String.format("자주 틀리는 단어 %d개가 있습니다. 북마크하여 집중 복습하세요.", + weakestWords.size())); + } + + Map result = new HashMap<>(); + result.put("weakestWords", weakestWords); + result.put("categoryAnalysis", categoryAnalysis); + result.put("levelAnalysis", levelAnalysis); + result.put("suggestions", suggestions); + + return createResponse(200, ApiResponse.success("Weakness analysis completed", result)); + } + private APIGatewayProxyResponseEvent createResponse(int statusCode, Object body) { return new APIGatewayProxyResponseEvent() .withStatusCode(statusCode) diff --git a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/UserWordHandler.java b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/UserWordHandler.java index c4af3cc5..1df390e3 100644 --- a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/UserWordHandler.java +++ b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/UserWordHandler.java @@ -47,6 +47,11 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent re return getUserWord(request); } + // PUT /vocab/users/{userId}/words/{wordId}/tag - 태그 변경 + if ("PUT".equals(httpMethod) && path.endsWith("/tag")) { + return updateUserWordTag(request); + } + // PUT /vocab/users/{userId}/words/{wordId} - 학습 상태 업데이트 if ("PUT".equals(httpMethod) && path.contains("/words/")) { return updateUserWord(request); @@ -167,6 +172,72 @@ private APIGatewayProxyResponseEvent updateUserWord(APIGatewayProxyRequestEvent return createResponse(200, ApiResponse.success("UserWord updated", userWord)); } + /** + * 사용자 단어 태그 변경 (북마크, 즐겨찾기, 난이도) + */ + private APIGatewayProxyResponseEvent updateUserWordTag(APIGatewayProxyRequestEvent request) { + Map pathParams = request.getPathParameters(); + String userId = pathParams != null ? pathParams.get("userId") : null; + String wordId = pathParams != null ? pathParams.get("wordId") : null; + + if (userId == null || wordId == null) { + return createResponse(400, ApiResponse.error("userId and wordId are required")); + } + + String body = request.getBody(); + Map requestBody = gson.fromJson(body, Map.class); + + Optional optUserWord = userWordRepository.findByUserIdAndWordId(userId, wordId); + UserWord userWord; + String now = Instant.now().toString(); + + if (optUserWord.isEmpty()) { + // 새로운 UserWord 생성 (태그만 설정) + userWord = UserWord.builder() + .pk("USER#" + userId) + .sk("WORD#" + wordId) + .gsi1pk("USER#" + userId + "#REVIEW") + .gsi2pk("USER#" + userId + "#STATUS") + .gsi2sk("STATUS#NEW") + .userId(userId) + .wordId(wordId) + .status("NEW") + .interval(1) + .easeFactor(2.5) + .repetitions(0) + .correctCount(0) + .incorrectCount(0) + .bookmarked(false) + .favorite(false) + .createdAt(now) + .build(); + } else { + userWord = optUserWord.get(); + } + + // 태그 업데이트 + if (requestBody.containsKey("bookmarked")) { + userWord.setBookmarked((Boolean) requestBody.get("bookmarked")); + } + if (requestBody.containsKey("favorite")) { + userWord.setFavorite((Boolean) requestBody.get("favorite")); + } + if (requestBody.containsKey("difficulty")) { + String difficulty = (String) requestBody.get("difficulty"); + if (difficulty != null && (difficulty.equals("EASY") || difficulty.equals("NORMAL") || difficulty.equals("HARD"))) { + userWord.setDifficulty(difficulty); + } else if (difficulty != null) { + return createResponse(400, ApiResponse.error("difficulty must be EASY, NORMAL, or HARD")); + } + } + + userWord.setUpdatedAt(now); + userWordRepository.save(userWord); + + logger.info("Updated user word tag: userId={}, wordId={}", userId, wordId); + return createResponse(200, ApiResponse.success("Tag updated", userWord)); + } + /** * SM-2 Spaced Repetition 알고리즘 적용 */ diff --git a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/WordHandler.java b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/WordHandler.java index 2d591f32..e05352e5 100644 --- a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/WordHandler.java +++ b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/WordHandler.java @@ -13,7 +13,9 @@ import org.slf4j.LoggerFactory; import java.time.Instant; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Optional; import java.util.UUID; @@ -37,6 +39,16 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent re logger.info("Received request: {} {}", httpMethod, path); try { + // POST /vocab/words/batch - 단어 일괄 등록 + if ("POST".equals(httpMethod) && path.endsWith("/batch")) { + return createWordsBatch(request); + } + + // GET /vocab/words/search - 단어 검색 + if ("GET".equals(httpMethod) && path.endsWith("/search")) { + return searchWords(request); + } + // POST /vocab/words - 단어 생성 if ("POST".equals(httpMethod) && path.endsWith("/words")) { return createWord(request); @@ -48,7 +60,7 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent re } // GET /vocab/words/{wordId} - 단어 상세 조회 - if ("GET".equals(httpMethod) && path.contains("/words/")) { + if ("GET".equals(httpMethod) && path.contains("/words/") && !path.contains("/search") && !path.contains("/batch")) { return getWord(request); } @@ -220,6 +232,97 @@ private APIGatewayProxyResponseEvent deleteWord(APIGatewayProxyRequestEvent requ return createResponse(200, ApiResponse.success("Word deleted", null)); } + @SuppressWarnings("unchecked") + private APIGatewayProxyResponseEvent createWordsBatch(APIGatewayProxyRequestEvent request) { + String body = request.getBody(); + Map requestBody = gson.fromJson(body, Map.class); + + List> wordsList = (List>) requestBody.get("words"); + if (wordsList == null || wordsList.isEmpty()) { + return createResponse(400, ApiResponse.error("words array is required")); + } + + String now = Instant.now().toString(); + List createdWords = new ArrayList<>(); + int successCount = 0; + int failCount = 0; + + for (Map wordData : wordsList) { + try { + String english = (String) wordData.get("english"); + String korean = (String) wordData.get("korean"); + String example = (String) wordData.get("example"); + String level = (String) wordData.getOrDefault("level", "BEGINNER"); + String category = (String) wordData.getOrDefault("category", "DAILY"); + + if (english == null || korean == null) { + failCount++; + continue; + } + + String wordId = UUID.randomUUID().toString(); + + Word word = Word.builder() + .pk("WORD#" + wordId) + .sk("METADATA") + .gsi1pk("LEVEL#" + level) + .gsi1sk("WORD#" + wordId) + .gsi2pk("CATEGORY#" + category) + .gsi2sk("WORD#" + wordId) + .wordId(wordId) + .english(english) + .korean(korean) + .example(example) + .level(level) + .category(category) + .createdAt(now) + .build(); + + wordRepository.save(word); + createdWords.add(word); + successCount++; + } catch (Exception e) { + logger.error("Failed to create word", e); + failCount++; + } + } + + Map result = new HashMap<>(); + result.put("successCount", successCount); + result.put("failCount", failCount); + result.put("totalRequested", wordsList.size()); + + logger.info("Batch created {} words, failed {}", successCount, failCount); + return createResponse(201, ApiResponse.success("Batch completed", result)); + } + + private APIGatewayProxyResponseEvent searchWords(APIGatewayProxyRequestEvent request) { + Map queryParams = request.getQueryStringParameters(); + + String query = queryParams != null ? queryParams.get("q") : null; + String cursor = queryParams != null ? queryParams.get("cursor") : null; + + if (query == null || query.isEmpty()) { + return createResponse(400, ApiResponse.error("q (query) parameter is required")); + } + + int limit = 20; + if (queryParams.get("limit") != null) { + limit = Math.min(Integer.parseInt(queryParams.get("limit")), 50); + } + + // 영어/한국어 모두 검색 + WordRepository.WordPage wordPage = wordRepository.searchByKeyword(query, limit, cursor); + + Map result = new HashMap<>(); + result.put("words", wordPage.getWords()); + result.put("query", query); + result.put("nextCursor", wordPage.getNextCursor()); + result.put("hasMore", wordPage.hasMore()); + + return createResponse(200, ApiResponse.success("Search completed", result)); + } + private APIGatewayProxyResponseEvent createResponse(int statusCode, Object body) { return new APIGatewayProxyResponseEvent() .withStatusCode(statusCode) diff --git a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/UserWord.java b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/UserWord.java index 0abb5e19..c9b22db1 100644 --- a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/UserWord.java +++ b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/UserWord.java @@ -50,6 +50,11 @@ public class UserWord { private String updatedAt; private Long ttl; + // 사용자 태그 + private Boolean bookmarked; // 북마크 여부 + private Boolean favorite; // 즐겨찾기 여부 + private String difficulty; // 사용자 지정 난이도 (EASY, NORMAL, HARD) + @DynamoDbPartitionKey @DynamoDbAttribute("PK") public String getPk() { diff --git a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/WordRepository.java b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/WordRepository.java index 0acf0c01..d0ad28f0 100644 --- a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/WordRepository.java +++ b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/WordRepository.java @@ -11,9 +11,13 @@ import software.amazon.awssdk.enhanced.dynamodb.model.Page; import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; +import software.amazon.awssdk.enhanced.dynamodb.model.ScanEnhancedRequest; +import software.amazon.awssdk.enhanced.dynamodb.Expression; import software.amazon.awssdk.services.dynamodb.DynamoDbClient; import software.amazon.awssdk.services.dynamodb.model.AttributeValue; +import java.util.ArrayList; + import java.util.Base64; import java.util.HashMap; import java.util.List; @@ -146,6 +150,52 @@ private Map decodeCursor(String cursor) { } } + /** + * 키워드로 단어 검색 (영어/한국어 contains) + * 참고: Scan은 비용이 높으므로 데이터가 많아지면 OpenSearch 도입 권장 + */ + public WordPage searchByKeyword(String keyword, int limit, String cursor) { + String lowerKeyword = keyword.toLowerCase(); + + // Filter: PK가 WORD#로 시작하고, english 또는 korean에 keyword 포함 + Expression filterExpression = Expression.builder() + .expression("begins_with(PK, :pk) AND (contains(#eng, :keyword) OR contains(korean, :keyword))") + .putExpressionName("#eng", "english") + .putExpressionValue(":pk", AttributeValue.builder().s("WORD#").build()) + .putExpressionValue(":keyword", AttributeValue.builder().s(lowerKeyword).build()) + .build(); + + ScanEnhancedRequest.Builder requestBuilder = ScanEnhancedRequest.builder() + .filterExpression(filterExpression) + .limit(limit * 3); // filter 적용되므로 넉넉히 + + if (cursor != null && !cursor.isEmpty()) { + Map exclusiveStartKey = decodeCursor(cursor); + if (exclusiveStartKey != null) { + requestBuilder.exclusiveStartKey(exclusiveStartKey); + } + } + + List results = new ArrayList<>(); + Map lastKey = null; + + for (Page page : table.scan(requestBuilder.build())) { + for (Word word : page.items()) { + // 대소문자 무시 검색 + if (word.getEnglish().toLowerCase().contains(lowerKeyword) || + word.getKorean().contains(keyword)) { + results.add(word); + if (results.size() >= limit) break; + } + } + lastKey = page.lastEvaluatedKey(); + if (results.size() >= limit) break; + } + + String nextCursor = results.size() >= limit ? encodeCursor(lastKey) : null; + return new WordPage(results, nextCursor); + } + public static class WordPage { private final List words; private final String nextCursor; diff --git a/vocabulary/seed-data/words.json b/vocabulary/seed-data/words.json new file mode 100644 index 00000000..fe07ba2a --- /dev/null +++ b/vocabulary/seed-data/words.json @@ -0,0 +1,73 @@ +{ + "words": [ + {"english": "apple", "korean": "사과", "example": "I eat an apple every day.", "level": "BEGINNER", "category": "DAILY"}, + {"english": "book", "korean": "책", "example": "She reads a book before bed.", "level": "BEGINNER", "category": "DAILY"}, + {"english": "cat", "korean": "고양이", "example": "The cat is sleeping on the sofa.", "level": "BEGINNER", "category": "DAILY"}, + {"english": "dog", "korean": "개", "example": "My dog loves to play fetch.", "level": "BEGINNER", "category": "DAILY"}, + {"english": "eat", "korean": "먹다", "example": "We eat dinner at 7 PM.", "level": "BEGINNER", "category": "DAILY"}, + {"english": "family", "korean": "가족", "example": "My family lives in Seoul.", "level": "BEGINNER", "category": "DAILY"}, + {"english": "good", "korean": "좋은", "example": "This is a good idea.", "level": "BEGINNER", "category": "DAILY"}, + {"english": "happy", "korean": "행복한", "example": "She looks very happy today.", "level": "BEGINNER", "category": "DAILY"}, + {"english": "house", "korean": "집", "example": "They bought a new house.", "level": "BEGINNER", "category": "DAILY"}, + {"english": "important", "korean": "중요한", "example": "This meeting is very important.", "level": "BEGINNER", "category": "DAILY"}, + {"english": "job", "korean": "직업", "example": "He got a new job last month.", "level": "BEGINNER", "category": "DAILY"}, + {"english": "kind", "korean": "친절한", "example": "She is always kind to everyone.", "level": "BEGINNER", "category": "DAILY"}, + {"english": "learn", "korean": "배우다", "example": "I want to learn English.", "level": "BEGINNER", "category": "DAILY"}, + {"english": "money", "korean": "돈", "example": "He saved a lot of money.", "level": "BEGINNER", "category": "DAILY"}, + {"english": "name", "korean": "이름", "example": "What is your name?", "level": "BEGINNER", "category": "DAILY"}, + {"english": "open", "korean": "열다", "example": "Please open the window.", "level": "BEGINNER", "category": "DAILY"}, + {"english": "people", "korean": "사람들", "example": "Many people came to the party.", "level": "BEGINNER", "category": "DAILY"}, + {"english": "question", "korean": "질문", "example": "Do you have any questions?", "level": "BEGINNER", "category": "DAILY"}, + {"english": "run", "korean": "달리다", "example": "He runs every morning.", "level": "BEGINNER", "category": "DAILY"}, + {"english": "school", "korean": "학교", "example": "The school starts at 9 AM.", "level": "BEGINNER", "category": "DAILY"}, + {"english": "time", "korean": "시간", "example": "What time is it now?", "level": "BEGINNER", "category": "DAILY"}, + {"english": "understand", "korean": "이해하다", "example": "I understand your concern.", "level": "BEGINNER", "category": "DAILY"}, + {"english": "very", "korean": "매우", "example": "This coffee is very hot.", "level": "BEGINNER", "category": "DAILY"}, + {"english": "water", "korean": "물", "example": "Please give me some water.", "level": "BEGINNER", "category": "DAILY"}, + {"english": "year", "korean": "년", "example": "I lived here for three years.", "level": "BEGINNER", "category": "DAILY"}, + {"english": "achieve", "korean": "달성하다", "example": "She achieved her goal.", "level": "INTERMEDIATE", "category": "BUSINESS"}, + {"english": "benefit", "korean": "이익, 혜택", "example": "What are the benefits of this plan?", "level": "INTERMEDIATE", "category": "BUSINESS"}, + {"english": "challenge", "korean": "도전", "example": "This project is a big challenge.", "level": "INTERMEDIATE", "category": "BUSINESS"}, + {"english": "decision", "korean": "결정", "example": "We need to make a decision today.", "level": "INTERMEDIATE", "category": "BUSINESS"}, + {"english": "efficient", "korean": "효율적인", "example": "This method is more efficient.", "level": "INTERMEDIATE", "category": "BUSINESS"}, + {"english": "flexible", "korean": "유연한", "example": "We need a flexible schedule.", "level": "INTERMEDIATE", "category": "BUSINESS"}, + {"english": "growth", "korean": "성장", "example": "The company showed rapid growth.", "level": "INTERMEDIATE", "category": "BUSINESS"}, + {"english": "implement", "korean": "실행하다", "example": "We will implement the new policy.", "level": "INTERMEDIATE", "category": "BUSINESS"}, + {"english": "invest", "korean": "투자하다", "example": "They decided to invest in technology.", "level": "INTERMEDIATE", "category": "BUSINESS"}, + {"english": "leadership", "korean": "리더십", "example": "Good leadership is essential.", "level": "INTERMEDIATE", "category": "BUSINESS"}, + {"english": "maintain", "korean": "유지하다", "example": "We need to maintain quality.", "level": "INTERMEDIATE", "category": "BUSINESS"}, + {"english": "negotiate", "korean": "협상하다", "example": "Let's negotiate the terms.", "level": "INTERMEDIATE", "category": "BUSINESS"}, + {"english": "opportunity", "korean": "기회", "example": "This is a great opportunity.", "level": "INTERMEDIATE", "category": "BUSINESS"}, + {"english": "performance", "korean": "성과", "example": "His performance was excellent.", "level": "INTERMEDIATE", "category": "BUSINESS"}, + {"english": "quality", "korean": "품질", "example": "Quality is our top priority.", "level": "INTERMEDIATE", "category": "BUSINESS"}, + {"english": "revenue", "korean": "수익", "example": "Revenue increased by 20%.", "level": "INTERMEDIATE", "category": "BUSINESS"}, + {"english": "strategy", "korean": "전략", "example": "We need a new marketing strategy.", "level": "INTERMEDIATE", "category": "BUSINESS"}, + {"english": "target", "korean": "목표", "example": "We reached our sales target.", "level": "INTERMEDIATE", "category": "BUSINESS"}, + {"english": "utilize", "korean": "활용하다", "example": "We should utilize all resources.", "level": "INTERMEDIATE", "category": "BUSINESS"}, + {"english": "valuable", "korean": "가치 있는", "example": "Your feedback is valuable.", "level": "INTERMEDIATE", "category": "BUSINESS"}, + {"english": "workflow", "korean": "워크플로우", "example": "Improve your workflow efficiency.", "level": "INTERMEDIATE", "category": "BUSINESS"}, + {"english": "yield", "korean": "산출하다", "example": "This investment will yield profit.", "level": "INTERMEDIATE", "category": "BUSINESS"}, + {"english": "zone", "korean": "구역", "example": "This is a no-parking zone.", "level": "INTERMEDIATE", "category": "BUSINESS"}, + {"english": "adjacent", "korean": "인접한", "example": "The two buildings are adjacent.", "level": "INTERMEDIATE", "category": "BUSINESS"}, + {"english": "abstract", "korean": "추상적인", "example": "The concept is too abstract.", "level": "ADVANCED", "category": "ACADEMIC"}, + {"english": "comprehensive", "korean": "포괄적인", "example": "We need a comprehensive analysis.", "level": "ADVANCED", "category": "ACADEMIC"}, + {"english": "contemporary", "korean": "현대의", "example": "Contemporary art is fascinating.", "level": "ADVANCED", "category": "ACADEMIC"}, + {"english": "differentiate", "korean": "구별하다", "example": "Can you differentiate between them?", "level": "ADVANCED", "category": "ACADEMIC"}, + {"english": "empirical", "korean": "경험적인", "example": "We need empirical evidence.", "level": "ADVANCED", "category": "ACADEMIC"}, + {"english": "fundamental", "korean": "근본적인", "example": "This is a fundamental problem.", "level": "ADVANCED", "category": "ACADEMIC"}, + {"english": "hypothesis", "korean": "가설", "example": "The hypothesis was proven correct.", "level": "ADVANCED", "category": "ACADEMIC"}, + {"english": "indigenous", "korean": "토착의", "example": "Indigenous plants are protected.", "level": "ADVANCED", "category": "ACADEMIC"}, + {"english": "jurisdiction", "korean": "관할권", "example": "This is outside our jurisdiction.", "level": "ADVANCED", "category": "ACADEMIC"}, + {"english": "methodology", "korean": "방법론", "example": "What methodology did you use?", "level": "ADVANCED", "category": "ACADEMIC"}, + {"english": "phenomenon", "korean": "현상", "example": "This is a natural phenomenon.", "level": "ADVANCED", "category": "ACADEMIC"}, + {"english": "paradigm", "korean": "패러다임", "example": "We need a paradigm shift.", "level": "ADVANCED", "category": "ACADEMIC"}, + {"english": "qualitative", "korean": "질적인", "example": "This is a qualitative study.", "level": "ADVANCED", "category": "ACADEMIC"}, + {"english": "quantitative", "korean": "양적인", "example": "We need quantitative data.", "level": "ADVANCED", "category": "ACADEMIC"}, + {"english": "synthesis", "korean": "합성, 종합", "example": "This requires a synthesis of ideas.", "level": "ADVANCED", "category": "ACADEMIC"}, + {"english": "theoretical", "korean": "이론적인", "example": "This is a theoretical framework.", "level": "ADVANCED", "category": "ACADEMIC"}, + {"english": "unprecedented", "korean": "전례 없는", "example": "This is an unprecedented situation.", "level": "ADVANCED", "category": "ACADEMIC"}, + {"english": "validity", "korean": "타당성", "example": "We must check the validity.", "level": "ADVANCED", "category": "ACADEMIC"}, + {"english": "variable", "korean": "변수", "example": "Control all variables carefully.", "level": "ADVANCED", "category": "ACADEMIC"}, + {"english": "ambiguous", "korean": "애매한", "example": "The statement is ambiguous.", "level": "ADVANCED", "category": "ACADEMIC"} + ] +} diff --git a/vocabulary/template.yaml b/vocabulary/template.yaml index 4970f7d8..dc5aa9af 100644 --- a/vocabulary/template.yaml +++ b/vocabulary/template.yaml @@ -37,31 +37,43 @@ Resources: CreateWord: Type: Api Properties: - RestApiId: !Ref VocabApi + RestApiId: !ImportValue group2-englishstudy-api-id Path: /vocab/words Method: POST + BatchCreateWords: + Type: Api + Properties: + RestApiId: !ImportValue group2-englishstudy-api-id + Path: /vocab/words/batch + Method: POST + SearchWords: + Type: Api + Properties: + RestApiId: !ImportValue group2-englishstudy-api-id + Path: /vocab/words/search + Method: GET GetWords: Type: Api Properties: - RestApiId: !Ref VocabApi + RestApiId: !ImportValue group2-englishstudy-api-id Path: /vocab/words Method: GET GetWord: Type: Api Properties: - RestApiId: !Ref VocabApi + RestApiId: !ImportValue group2-englishstudy-api-id Path: /vocab/words/{wordId} Method: GET UpdateWord: Type: Api Properties: - RestApiId: !Ref VocabApi + RestApiId: !ImportValue group2-englishstudy-api-id Path: /vocab/words/{wordId} Method: PUT DeleteWord: Type: Api Properties: - RestApiId: !Ref VocabApi + RestApiId: !ImportValue group2-englishstudy-api-id Path: /vocab/words/{wordId} Method: DELETE @@ -82,21 +94,27 @@ Resources: GetUserWords: Type: Api Properties: - RestApiId: !Ref VocabApi + RestApiId: !ImportValue group2-englishstudy-api-id Path: /vocab/users/{userId}/words Method: GET GetUserWord: Type: Api Properties: - RestApiId: !Ref VocabApi + RestApiId: !ImportValue group2-englishstudy-api-id Path: /vocab/users/{userId}/words/{wordId} Method: GET UpdateUserWord: Type: Api Properties: - RestApiId: !Ref VocabApi + RestApiId: !ImportValue group2-englishstudy-api-id Path: /vocab/users/{userId}/words/{wordId} Method: PUT + UpdateUserWordTag: + Type: Api + Properties: + RestApiId: !ImportValue group2-englishstudy-api-id + Path: /vocab/users/{userId}/words/{wordId}/tag + Method: PUT # 일일 학습 관리 함수 (55개 단어 할당) DailyStudyFunction: @@ -115,13 +133,13 @@ Resources: GetDailyWords: Type: Api Properties: - RestApiId: !Ref VocabApi + RestApiId: !ImportValue group2-englishstudy-api-id Path: /vocab/daily/{userId} Method: GET MarkWordLearned: Type: Api Properties: - RestApiId: !Ref VocabApi + RestApiId: !ImportValue group2-englishstudy-api-id Path: /vocab/daily/{userId}/words/{wordId}/learned Method: POST @@ -142,19 +160,19 @@ Resources: StartTest: Type: Api Properties: - RestApiId: !Ref VocabApi + RestApiId: !ImportValue group2-englishstudy-api-id Path: /vocab/test/{userId}/start Method: POST SubmitAnswer: Type: Api Properties: - RestApiId: !Ref VocabApi + RestApiId: !ImportValue group2-englishstudy-api-id Path: /vocab/test/{userId}/submit Method: POST GetTestResult: Type: Api Properties: - RestApiId: !Ref VocabApi + RestApiId: !ImportValue group2-englishstudy-api-id Path: /vocab/test/{userId}/results Method: GET @@ -175,15 +193,21 @@ Resources: GetStats: Type: Api Properties: - RestApiId: !Ref VocabApi + RestApiId: !ImportValue group2-englishstudy-api-id Path: /vocab/stats/{userId} Method: GET GetDailyStats: Type: Api Properties: - RestApiId: !Ref VocabApi + RestApiId: !ImportValue group2-englishstudy-api-id Path: /vocab/stats/{userId}/daily Method: GET + GetWeaknessAnalysis: + Type: Api + Properties: + RestApiId: !ImportValue group2-englishstudy-api-id + Path: /vocab/stats/{userId}/weakness + Method: GET # 음성 변환 함수 (Polly) VoiceFunction: @@ -210,23 +234,14 @@ Resources: TextToSpeech: Type: Api Properties: - RestApiId: !Ref VocabApi + RestApiId: !ImportValue group2-englishstudy-api-id Path: /vocab/voice/synthesize Method: POST ############################################# - # API Gateway + # API Gateway - chatting 스택에서 공유 API Gateway 참조 ############################################# - - VocabApi: - Type: AWS::Serverless::Api - Properties: - Name: group2-englishstudy-vocab-api - StageName: dev - Cors: - AllowMethods: "'GET,POST,PUT,DELETE,OPTIONS'" - AllowHeaders: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" - AllowOrigin: "'*'" + # VocabApi는 chatting 스택의 ChatApi를 ImportValue로 참조 ############################################# # DynamoDB @@ -282,8 +297,8 @@ Resources: Outputs: VocabApiUrl: - Description: API Gateway endpoint URL - Value: !Sub 'https://${VocabApi}.execute-api.${AWS::Region}.amazonaws.com/dev/' + Description: API Gateway endpoint URL (Shared with chatting) + Value: !ImportValue group2-englishstudy-api-url VocabTableName: Description: DynamoDB Table Name From 9f49cd0845644591dc9f7ab621913f368e6e8b4a Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Wed, 7 Jan 2026 14:23:10 +0900 Subject: [PATCH 005/528] =?UTF-8?q?chore:=20=EC=8B=9C=EB=93=9C=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EB=94=94=EB=A0=89=ED=86=A0=EB=A6=AC=20git?= =?UTF-8?q?ignore=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - vocabulary/seed-data/ 디렉토리 추적 제외 - 시드 데이터는 DynamoDB에 업로드 완료 (1,088개 단어) --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 5f70a5bc..f1597d4c 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,6 @@ samconfig.toml # Test *.test.ts coverage/ + +# Seed Data (already uploaded to DynamoDB) +vocabulary/seed-data/ From fffd4d0645e247d51c06563b58cfe14c2c8e9269 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Wed, 7 Jan 2026 14:30:21 +0900 Subject: [PATCH 006/528] =?UTF-8?q?perf:=20BatchGetItem=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9=EC=9C=BC=EB=A1=9C=20N+1=20=EB=AC=B8=EC=A0=9C=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WordRepository.findByIds() 메서드 추가 (최대 100개씩 분할 처리) - DailyStudyHandler.getWordDetails() BatchGetItem 적용 - TestHandler.startTest() BatchGetItem 적용 - TestHandler.submitAnswer() BatchGetItem 적용 및 Map 캐싱 변경 전: 55개 단어 조회 시 55번의 DynamoDB GetItem 호출 변경 후: 55개 단어 조회 시 1번의 DynamoDB BatchGetItem 호출 --- .../vocabulary/handler/DailyStudyHandler.java | 7 +-- .../vocabulary/handler/TestHandler.java | 34 ++++++++------ .../vocabulary/repository/WordRepository.java | 44 +++++++++++++++++++ 3 files changed, 66 insertions(+), 19 deletions(-) diff --git a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/DailyStudyHandler.java b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/DailyStudyHandler.java index aeabd3cd..1d1b3cec 100644 --- a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/DailyStudyHandler.java +++ b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/DailyStudyHandler.java @@ -169,11 +169,8 @@ private List getWordDetails(List wordIds) { return new ArrayList<>(); } - List words = new ArrayList<>(); - for (String wordId : wordIds) { - wordRepository.findById(wordId).ifPresent(words::add); - } - return words; + // BatchGetItem으로 한 번에 조회 (N+1 문제 해결) + return wordRepository.findByIds(wordIds); } private Map calculateProgress(DailyStudy dailyStudy) { diff --git a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/TestHandler.java b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/TestHandler.java index ad70efab..462bf951 100644 --- a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/TestHandler.java +++ b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/TestHandler.java @@ -100,18 +100,15 @@ private APIGatewayProxyResponseEvent startTest(APIGatewayProxyRequestEvent reque return createResponse(400, ApiResponse.error("No words to test")); } - // 시험 문제 생성 + // 시험 문제 생성 (BatchGetItem으로 한 번에 조회) + List words = wordRepository.findByIds(allWordIds); List> questions = new ArrayList<>(); - for (String wordId : allWordIds) { - Optional optWord = wordRepository.findById(wordId); - if (optWord.isPresent()) { - Word word = optWord.get(); - Map question = new HashMap<>(); - question.put("wordId", word.getWordId()); - question.put("english", word.getEnglish()); - question.put("example", word.getExample()); - questions.add(question); - } + for (Word word : words) { + Map question = new HashMap<>(); + question.put("wordId", word.getWordId()); + question.put("english", word.getEnglish()); + question.put("example", word.getExample()); + questions.add(question); } String testId = UUID.randomUUID().toString(); @@ -155,13 +152,22 @@ private APIGatewayProxyResponseEvent submitAnswer(APIGatewayProxyRequestEvent re int incorrectCount = 0; List incorrectWordIds = new ArrayList<>(); + // 모든 wordId를 추출하여 BatchGetItem으로 한 번에 조회 + List wordIds = answers.stream() + .map(a -> (String) a.get("wordId")) + .collect(java.util.stream.Collectors.toList()); + List words = wordRepository.findByIds(wordIds); + + // wordId -> Word 맵 생성 + Map wordMap = words.stream() + .collect(java.util.stream.Collectors.toMap(Word::getWordId, w -> w)); + for (Map answer : answers) { String wordId = (String) answer.get("wordId"); String userAnswer = (String) answer.get("answer"); - Optional optWord = wordRepository.findById(wordId); - if (optWord.isPresent()) { - Word word = optWord.get(); + Word word = wordMap.get(wordId); + if (word != null) { // 대소문자 무시, 공백 제거 후 비교 boolean isCorrect = word.getKorean().trim().equalsIgnoreCase(userAnswer.trim()); diff --git a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/WordRepository.java b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/WordRepository.java index d0ad28f0..715e40bf 100644 --- a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/WordRepository.java +++ b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/WordRepository.java @@ -8,9 +8,11 @@ import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; import software.amazon.awssdk.enhanced.dynamodb.Key; import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.model.BatchGetResultPageIterable; import software.amazon.awssdk.enhanced.dynamodb.model.Page; import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; +import software.amazon.awssdk.enhanced.dynamodb.model.ReadBatch; import software.amazon.awssdk.enhanced.dynamodb.model.ScanEnhancedRequest; import software.amazon.awssdk.enhanced.dynamodb.Expression; import software.amazon.awssdk.services.dynamodb.DynamoDbClient; @@ -57,6 +59,48 @@ public Optional findById(String wordId) { return Optional.ofNullable(word); } + /** + * 여러 단어를 한 번에 조회 (BatchGetItem) - N+1 문제 해결 + * DynamoDB BatchGetItem은 최대 100개까지 지원 + */ + public List findByIds(List wordIds) { + if (wordIds == null || wordIds.isEmpty()) { + return new ArrayList<>(); + } + + List results = new ArrayList<>(); + + // BatchGetItem은 최대 100개까지 지원하므로 분할 처리 + int batchSize = 100; + for (int i = 0; i < wordIds.size(); i += batchSize) { + List batch = wordIds.subList(i, Math.min(i + batchSize, wordIds.size())); + results.addAll(batchGetWords(batch)); + } + + return results; + } + + private List batchGetWords(List wordIds) { + ReadBatch.Builder readBatchBuilder = ReadBatch.builder(Word.class) + .mappedTableResource(table); + + for (String wordId : wordIds) { + Key key = Key.builder() + .partitionValue("WORD#" + wordId) + .sortValue("METADATA") + .build(); + readBatchBuilder.addGetItem(key); + } + + BatchGetResultPageIterable resultPages = enhancedClient.batchGetItem(r -> r.readBatches(readBatchBuilder.build())); + + List words = new ArrayList<>(); + resultPages.resultsForTable(table).forEach(words::add); + logger.info("BatchGetItem: requested={}, retrieved={}", wordIds.size(), words.size()); + + return words; + } + public void delete(String wordId) { Key key = Key.builder() .partitionValue("WORD#" + wordId) From 8ab640b09576125c05f40b5fc099d4dd0d5bc090 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Wed, 7 Jan 2026 15:18:52 +0900 Subject: [PATCH 007/528] =?UTF-8?q?feat:=20Daily=20Study=20=EB=A0=88?= =?UTF-8?q?=EB=B2=A8=20=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#44)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /vocab/daily/{userId}?level=BEGINNER 형태로 레벨 선택 가능 - 첫 생성 시 level 필수, 이후 호출 시 저장된 데이터 반환 - 해당 레벨에서 학습하지 않은 단어만 선택 - 레벨 유효성 검사 추가 (BEGINNER, INTERMEDIATE, ADVANCED) --- .../vocabulary/handler/DailyStudyHandler.java | 37 +++++++++++++------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/DailyStudyHandler.java b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/DailyStudyHandler.java index 1d1b3cec..e3f948fc 100644 --- a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/DailyStudyHandler.java +++ b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/DailyStudyHandler.java @@ -71,12 +71,16 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent re private APIGatewayProxyResponseEvent getDailyWords(APIGatewayProxyRequestEvent request) { Map pathParams = request.getPathParameters(); + Map queryParams = request.getQueryStringParameters(); String userId = pathParams != null ? pathParams.get("userId") : null; if (userId == null) { return createResponse(400, ApiResponse.error("userId is required")); } + // 레벨 파라미터 (첫 생성 시 필수) + String level = queryParams != null ? queryParams.get("level") : null; + String today = LocalDate.now().toString(); // 오늘의 학습 데이터 조회 @@ -86,8 +90,16 @@ private APIGatewayProxyResponseEvent getDailyWords(APIGatewayProxyRequestEvent r if (optDailyStudy.isPresent()) { dailyStudy = optDailyStudy.get(); } else { + // 첫 생성 시 레벨 필수 + if (level == null || level.isEmpty()) { + return createResponse(400, ApiResponse.error("level is required for first daily study (BEGINNER, INTERMEDIATE, ADVANCED)")); + } + // 레벨 유효성 검사 + if (!level.equals("BEGINNER") && !level.equals("INTERMEDIATE") && !level.equals("ADVANCED")) { + return createResponse(400, ApiResponse.error("Invalid level. Must be BEGINNER, INTERMEDIATE, or ADVANCED")); + } // 새로운 일일 학습 생성 - dailyStudy = createDailyStudy(userId, today); + dailyStudy = createDailyStudy(userId, today, level); } // 단어 상세 정보 조회 @@ -103,7 +115,7 @@ private APIGatewayProxyResponseEvent getDailyWords(APIGatewayProxyRequestEvent r return createResponse(200, ApiResponse.success("Daily words retrieved", result)); } - private DailyStudy createDailyStudy(String userId, String date) { + private DailyStudy createDailyStudy(String userId, String date, String level) { String now = Instant.now().toString(); // 복습 대상 단어 조회 (5개) @@ -112,8 +124,8 @@ private DailyStudy createDailyStudy(String userId, String date) { .map(UserWord::getWordId) .collect(Collectors.toList()); - // 신규 단어 조회 (50개) - 아직 학습하지 않은 단어 - List newWordIds = getNewWordsForUser(userId, NEW_WORDS_COUNT); + // 신규 단어 조회 (50개) - 해당 레벨에서 아직 학습하지 않은 단어 + List newWordIds = getNewWordsForUser(userId, level, NEW_WORDS_COUNT); DailyStudy dailyStudy = DailyStudy.builder() .pk("DAILY#" + userId) @@ -138,29 +150,30 @@ private DailyStudy createDailyStudy(String userId, String date) { return dailyStudy; } - private List getNewWordsForUser(String userId, int count) { + private List getNewWordsForUser(String userId, String level, int count) { // 사용자가 학습한 단어 목록 UserWordRepository.UserWordPage userWordPage = userWordRepository.findByUserIdWithPagination(userId, 1000, null); List learnedWordIds = userWordPage.getUserWords().stream() .map(UserWord::getWordId) .collect(Collectors.toList()); - // 전체 단어에서 학습하지 않은 단어 선택 + // 해당 레벨에서 학습하지 않은 단어 선택 List newWordIds = new ArrayList<>(); - String[] levels = {"BEGINNER", "INTERMEDIATE", "ADVANCED"}; + String lastEvaluatedKey = null; - for (String level : levels) { - if (newWordIds.size() >= count) break; - - WordRepository.WordPage wordPage = wordRepository.findByLevelWithPagination(level, count * 2, null); + // 페이지네이션으로 해당 레벨의 모든 단어 조회 + do { + WordRepository.WordPage wordPage = wordRepository.findByLevelWithPagination(level, count * 2, lastEvaluatedKey); for (Word word : wordPage.getWords()) { if (!learnedWordIds.contains(word.getWordId()) && !newWordIds.contains(word.getWordId())) { newWordIds.add(word.getWordId()); if (newWordIds.size() >= count) break; } } - } + lastEvaluatedKey = wordPage.getNextCursor(); + } while (newWordIds.size() < count && lastEvaluatedKey != null); + logger.info("Selected {} new words for user {} at level {}", newWordIds.size(), userId, level); return newWordIds; } From c4cdb2cdf27d1be4da30d9966f6c71415067f7bc Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Wed, 7 Jan 2026 15:21:50 +0900 Subject: [PATCH 008/528] =?UTF-8?q?feat:=20=EC=8B=9C=ED=97=98=204=EC=A7=80?= =?UTF-8?q?=EC=84=A0=EB=8B=A4=20=EC=98=B5=EC=85=98=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?(#45)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - startTest() 응답에 options 필드 추가 - 같은 레벨의 다른 단어들로 오답 3개 동적 생성 - 정답 포함 4개 옵션 랜덤 셔플 --- .../vocabulary/handler/TestHandler.java | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/TestHandler.java b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/TestHandler.java index 462bf951..52dc3a5d 100644 --- a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/TestHandler.java +++ b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/TestHandler.java @@ -19,11 +19,14 @@ import java.time.Instant; import java.time.LocalDate; import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Random; import java.util.UUID; +import java.util.stream.Collectors; public class TestHandler implements RequestHandler { @@ -102,12 +105,30 @@ private APIGatewayProxyResponseEvent startTest(APIGatewayProxyRequestEvent reque // 시험 문제 생성 (BatchGetItem으로 한 번에 조회) List words = wordRepository.findByIds(allWordIds); + + // 레벨별로 단어 그룹화하여 오답 후보 준비 + Map> wordsByLevel = words.stream() + .collect(Collectors.groupingBy(Word::getLevel)); + + // 각 레벨별 추가 오답 후보 단어 조회 (문제 단어 외의 다른 단어들) + Map> distractorsByLevel = new HashMap<>(); + for (String level : wordsByLevel.keySet()) { + List distractors = getDistractorsForLevel(level, allWordIds); + distractorsByLevel.put(level, distractors); + } + + Random random = new Random(); List> questions = new ArrayList<>(); for (Word word : words) { Map question = new HashMap<>(); question.put("wordId", word.getWordId()); question.put("english", word.getEnglish()); question.put("example", word.getExample()); + + // 4지선다 옵션 생성 + List options = generateOptions(word, wordsByLevel, distractorsByLevel, random); + question.put("options", options); + questions.add(question); } @@ -232,6 +253,60 @@ private APIGatewayProxyResponseEvent getTestResults(APIGatewayProxyRequestEvent return createResponse(200, ApiResponse.success("Test results retrieved", result)); } + /** + * 해당 레벨에서 오답 후보 단어들의 한국어 뜻 목록을 가져옴 + */ + private List getDistractorsForLevel(String level, List excludeWordIds) { + WordRepository.WordPage wordPage = wordRepository.findByLevelWithPagination(level, 50, null); + return wordPage.getWords().stream() + .filter(w -> !excludeWordIds.contains(w.getWordId())) + .map(Word::getKorean) + .collect(Collectors.toList()); + } + + /** + * 4지선다 옵션 생성 (정답 1개 + 오답 3개, 셔플됨) + */ + private List generateOptions(Word correctWord, Map> wordsByLevel, + Map> distractorsByLevel, Random random) { + List options = new ArrayList<>(); + String correctAnswer = correctWord.getKorean(); + options.add(correctAnswer); + + String level = correctWord.getLevel(); + + // 같은 레벨의 다른 문제 단어들에서 오답 후보 추출 + List sameLevelOptions = wordsByLevel.getOrDefault(level, new ArrayList<>()).stream() + .filter(w -> !w.getWordId().equals(correctWord.getWordId())) + .map(Word::getKorean) + .collect(Collectors.toList()); + + // 추가 오답 후보 (문제에 포함되지 않은 단어들) + List additionalDistractors = distractorsByLevel.getOrDefault(level, new ArrayList<>()); + + // 모든 오답 후보 합치기 + List allDistractors = new ArrayList<>(); + allDistractors.addAll(sameLevelOptions); + allDistractors.addAll(additionalDistractors); + + // 중복 및 정답 제거 + allDistractors = allDistractors.stream() + .filter(d -> !d.equals(correctAnswer)) + .distinct() + .collect(Collectors.toList()); + + // 랜덤하게 3개 선택 + Collections.shuffle(allDistractors, random); + int distractorCount = Math.min(3, allDistractors.size()); + for (int i = 0; i < distractorCount; i++) { + options.add(allDistractors.get(i)); + } + + // 옵션 셔플 + Collections.shuffle(options, random); + return options; + } + private APIGatewayProxyResponseEvent createResponse(int statusCode, Object body) { return new APIGatewayProxyResponseEvent() .withStatusCode(statusCode) From b59dfd7dbd224dfe829b11ed7ffaf14b7417869a Mon Sep 17 00:00:00 2001 From: DDING JOO Date: Wed, 7 Jan 2026 16:25:46 +0900 Subject: [PATCH 009/528] =?UTF-8?q?feat:=20Test=20Submit=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=EC=97=90=20results=20=EB=B0=B0=EC=97=B4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#49)=20(#50)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 문제별 상세 결과 포함 (isCorrect, english, userAnswer, correctAnswer) - 필드명 변경: correctAnswers → correctCount, incorrectAnswers → incorrectCount --- .../vocabulary/handler/TestHandler.java | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/TestHandler.java b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/TestHandler.java index 52dc3a5d..a5a5f744 100644 --- a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/TestHandler.java +++ b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/TestHandler.java @@ -172,6 +172,7 @@ private APIGatewayProxyResponseEvent submitAnswer(APIGatewayProxyRequestEvent re int correctCount = 0; int incorrectCount = 0; List incorrectWordIds = new ArrayList<>(); + List> results = new ArrayList<>(); // 모든 wordId를 추출하여 BatchGetItem으로 한 번에 조회 List wordIds = answers.stream() @@ -192,6 +193,15 @@ private APIGatewayProxyResponseEvent submitAnswer(APIGatewayProxyRequestEvent re // 대소문자 무시, 공백 제거 후 비교 boolean isCorrect = word.getKorean().trim().equalsIgnoreCase(userAnswer.trim()); + // 결과 상세 정보 추가 + Map resultItem = new HashMap<>(); + resultItem.put("wordId", wordId); + resultItem.put("english", word.getEnglish()); + resultItem.put("correctAnswer", word.getKorean()); + resultItem.put("userAnswer", userAnswer); + resultItem.put("isCorrect", isCorrect); + results.add(resultItem); + if (isCorrect) { correctCount++; } else { @@ -223,8 +233,18 @@ private APIGatewayProxyResponseEvent submitAnswer(APIGatewayProxyRequestEvent re testResultRepository.save(testResult); + // 응답 데이터 구성 (results 포함) + Map responseData = new HashMap<>(); + responseData.put("testId", testId); + responseData.put("testType", testType); + responseData.put("totalQuestions", totalQuestions); + responseData.put("correctCount", correctCount); + responseData.put("incorrectCount", incorrectCount); + responseData.put("successRate", successRate); + responseData.put("results", results); + logger.info("Test submitted: userId={}, testId={}, successRate={}%", userId, testId, successRate); - return createResponse(200, ApiResponse.success("Test submitted", testResult)); + return createResponse(200, ApiResponse.success("Test submitted", responseData)); } private APIGatewayProxyResponseEvent getTestResults(APIGatewayProxyRequestEvent request) { From 50e7ba957686fd3fc8ee9bd10543a162ab8f7945 Mon Sep 17 00:00:00 2001 From: DDING JOO Date: Wed, 7 Jan 2026 16:28:28 +0900 Subject: [PATCH 010/528] =?UTF-8?q?feat:=20=EB=82=98=EC=9D=98=20=EB=8B=A8?= =?UTF-8?q?=EC=96=B4=EC=9E=A5=20=ED=95=84=ED=84=B0=EB=A7=81=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80=20(#51)=20(#52)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /vocab/users/{userId}/words?bookmarked=true - 북마크 단어만 - GET /vocab/users/{userId}/words?incorrectOnly=true - 틀린 단어만 - FilterExpression 사용으로 GSI 추가 없이 비용 최적화 --- .../vocabulary/handler/UserWordHandler.java | 10 ++- .../repository/UserWordRepository.java | 67 +++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/UserWordHandler.java b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/UserWordHandler.java index 1df390e3..c1486640 100644 --- a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/UserWordHandler.java +++ b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/UserWordHandler.java @@ -72,6 +72,8 @@ private APIGatewayProxyResponseEvent getUserWords(APIGatewayProxyRequestEvent re String userId = pathParams != null ? pathParams.get("userId") : null; String status = queryParams != null ? queryParams.get("status") : null; String cursor = queryParams != null ? queryParams.get("cursor") : null; + String bookmarked = queryParams != null ? queryParams.get("bookmarked") : null; + String incorrectOnly = queryParams != null ? queryParams.get("incorrectOnly") : null; if (userId == null) { return createResponse(400, ApiResponse.error("userId is required")); @@ -83,7 +85,13 @@ private APIGatewayProxyResponseEvent getUserWords(APIGatewayProxyRequestEvent re } UserWordRepository.UserWordPage userWordPage; - if (status != null && !status.isEmpty()) { + + // 필터 우선순위: bookmarked > incorrectOnly > status > 전체 + if ("true".equalsIgnoreCase(bookmarked)) { + userWordPage = userWordRepository.findBookmarkedWords(userId, limit, cursor); + } else if ("true".equalsIgnoreCase(incorrectOnly)) { + userWordPage = userWordRepository.findIncorrectWords(userId, limit, cursor); + } else if (status != null && !status.isEmpty()) { userWordPage = userWordRepository.findByUserIdAndStatus(userId, status, limit, cursor); } else { userWordPage = userWordRepository.findByUserIdWithPagination(userId, limit, cursor); diff --git a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/UserWordRepository.java b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/UserWordRepository.java index 7214b500..72ed881a 100644 --- a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/UserWordRepository.java +++ b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/UserWordRepository.java @@ -12,6 +12,7 @@ import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; import software.amazon.awssdk.services.dynamodb.DynamoDbClient; +import software.amazon.awssdk.enhanced.dynamodb.Expression; import software.amazon.awssdk.services.dynamodb.model.AttributeValue; import java.util.Base64; @@ -108,6 +109,72 @@ public UserWordPage findReviewDueWords(String userId, String todayDate, int limi return new UserWordPage(page.items(), nextCursor); } + /** + * 북마크된 단어만 조회 - FilterExpression 사용 (GSI 추가 없이 비용 최적화) + */ + public UserWordPage findBookmarkedWords(String userId, int limit, String cursor) { + QueryConditional queryConditional = QueryConditional + .sortBeginsWith(Key.builder() + .partitionValue("USER#" + userId) + .sortValue("WORD#") + .build()); + + Expression filterExpression = Expression.builder() + .expression("bookmarked = :bookmarked") + .putExpressionValue(":bookmarked", AttributeValue.builder().bool(true).build()) + .build(); + + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .filterExpression(filterExpression) + .limit(limit); + + if (cursor != null && !cursor.isEmpty()) { + Map exclusiveStartKey = decodeCursor(cursor); + if (exclusiveStartKey != null) { + requestBuilder.exclusiveStartKey(exclusiveStartKey); + } + } + + Page page = table.query(requestBuilder.build()).iterator().next(); + String nextCursor = encodeCursor(page.lastEvaluatedKey()); + + return new UserWordPage(page.items(), nextCursor); + } + + /** + * 틀린 적 있는 단어만 조회 - FilterExpression 사용 (GSI 추가 없이 비용 최적화) + */ + public UserWordPage findIncorrectWords(String userId, int limit, String cursor) { + QueryConditional queryConditional = QueryConditional + .sortBeginsWith(Key.builder() + .partitionValue("USER#" + userId) + .sortValue("WORD#") + .build()); + + Expression filterExpression = Expression.builder() + .expression("incorrectCount > :zero") + .putExpressionValue(":zero", AttributeValue.builder().n("0").build()) + .build(); + + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .filterExpression(filterExpression) + .limit(limit); + + if (cursor != null && !cursor.isEmpty()) { + Map exclusiveStartKey = decodeCursor(cursor); + if (exclusiveStartKey != null) { + requestBuilder.exclusiveStartKey(exclusiveStartKey); + } + } + + Page page = table.query(requestBuilder.build()).iterator().next(); + String nextCursor = encodeCursor(page.lastEvaluatedKey()); + + return new UserWordPage(page.items(), nextCursor); + } + /** * 상태별 단어 조회 - 페이지네이션 */ From d257b708badfffbed89eb6521d96c4412aac4564 Mon Sep 17 00:00:00 2001 From: DDING JOO Date: Wed, 7 Jan 2026 16:39:21 +0900 Subject: [PATCH 011/528] =?UTF-8?q?feat:=20SNS/SQS=20=EB=B9=84=EB=8F=99?= =?UTF-8?q?=EA=B8=B0=20=ED=86=B5=EA=B3=84=20=EC=B2=98=EB=A6=AC=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#53)=20(#55)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 단어 관리 추가 기능 및 API Gateway 통합 - 단어 일괄 등록 API (POST /vocab/words/batch) - 단어 검색 API (GET /vocab/words/search) - 사용자 단어 태그 API (북마크, 즐겨찾기, 난이도) - 약점 분석 API (카테고리/레벨별 정확도 분석) - Chat + Vocab API Gateway 통합 (단일 엔드포인트) - 통합 빌드/배포 스크립트 (deploy.sh) - 시드 데이터 69개 단어 추가 - 프론트엔드용 API 명세서 작성 * chore: 시드 데이터 디렉토리 gitignore 추가 - vocabulary/seed-data/ 디렉토리 추적 제외 - 시드 데이터는 DynamoDB에 업로드 완료 (1,088개 단어) * perf: BatchGetItem 적용으로 N+1 문제 해결 - WordRepository.findByIds() 메서드 추가 (최대 100개씩 분할 처리) - DailyStudyHandler.getWordDetails() BatchGetItem 적용 - TestHandler.startTest() BatchGetItem 적용 - TestHandler.submitAnswer() BatchGetItem 적용 및 Map 캐싱 변경 전: 55개 단어 조회 시 55번의 DynamoDB GetItem 호출 변경 후: 55개 단어 조회 시 1번의 DynamoDB BatchGetItem 호출 * feat: Daily Study 레벨 파라미터 추가 (#44) - GET /vocab/daily/{userId}?level=BEGINNER 형태로 레벨 선택 가능 - 첫 생성 시 level 필수, 이후 호출 시 저장된 데이터 반환 - 해당 레벨에서 학습하지 않은 단어만 선택 - 레벨 유효성 검사 추가 (BEGINNER, INTERMEDIATE, ADVANCED) * feat: 시험 4지선다 옵션 생성 (#45) - startTest() 응답에 options 필드 추가 - 같은 레벨의 다른 단어들로 오답 3개 동적 생성 - 정답 포함 4개 옵션 랜덤 셔플 * feat: Test Submit 응답에 results 배열 추가 (#49) (#50) - 문제별 상세 결과 포함 (isCorrect, english, userAnswer, correctAnswer) - 필드명 변경: correctAnswers → correctCount, incorrectAnswers → incorrectCount * feat: 나의 단어장 필터링 기능 추가 (#51) (#52) - GET /vocab/users/{userId}/words?bookmarked=true - 북마크 단어만 - GET /vocab/users/{userId}/words?incorrectOnly=true - 틀린 단어만 - FilterExpression 사용으로 GSI 추가 없이 비용 최적화 * feat: SNS/SQS 비동기 통계 처리 구현 (#53) - TestResultTopic (SNS) 및 StatisticsQueue (SQS) 추가 - StatisticsDeadLetterQueue (DLQ) 추가로 실패 메시지 처리 - StatisticsHandler Lambda 생성 (SQS 이벤트 처리) - TestHandler에서 시험 결과 SNS 발행 - SM-2 Spaced Repetition 알고리즘으로 UserWord 통계 업데이트 - group2-englishstudy- 프리픽스 적용 --- .gitignore | 3 + chatting/seed-data.sh | 2 +- chatting/template.yaml | 422 +++++++++-- deploy.sh | 146 ++++ docs/ReadMe.md | 1 + docs/VOCAB_API_SPEC.md | 678 ++++++++++++++++++ vocabulary/VocabFunction/build.gradle | 1 + .../vocabulary/handler/DailyStudyHandler.java | 44 +- .../vocabulary/handler/StatisticsHandler.java | 160 +++++ .../vocabulary/handler/StatsHandler.java | 167 ++++- .../vocabulary/handler/TestHandler.java | 167 ++++- .../vocabulary/handler/UserWordHandler.java | 81 ++- .../vocabulary/handler/WordHandler.java | 105 ++- .../serverless/vocabulary/model/UserWord.java | 5 + .../repository/UserWordRepository.java | 67 ++ .../vocabulary/repository/WordRepository.java | 94 +++ vocabulary/seed-data/words.json | 73 ++ vocabulary/template.yaml | 73 +- 18 files changed, 2172 insertions(+), 117 deletions(-) create mode 100755 deploy.sh create mode 100644 docs/VOCAB_API_SPEC.md create mode 100644 vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/StatisticsHandler.java create mode 100644 vocabulary/seed-data/words.json diff --git a/.gitignore b/.gitignore index 5f70a5bc..f1597d4c 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,6 @@ samconfig.toml # Test *.test.ts coverage/ + +# Seed Data (already uploaded to DynamoDB) +vocabulary/seed-data/ diff --git a/chatting/seed-data.sh b/chatting/seed-data.sh index d1684464..b1e584e3 100755 --- a/chatting/seed-data.sh +++ b/chatting/seed-data.sh @@ -1,6 +1,6 @@ #!/bin/bash -API_URL="https://ha3vg3u73g.execute-api.ap-northeast-2.amazonaws.com/dev" +API_URL="https://gc8l9ijhzc.execute-api.ap-northeast-2.amazonaws.com/dev" echo "=== 채팅방 15개 생성 ===" LEVELS=("beginner" "intermediate" "advanced") diff --git a/chatting/template.yaml b/chatting/template.yaml index e9779779..de1f9452 100644 --- a/chatting/template.yaml +++ b/chatting/template.yaml @@ -1,6 +1,6 @@ AWSTemplateFormatVersion: '2010-09-09' Transform: AWS::Serverless-2016-10-31 -Description: Group2 English Study - Chatting Domain +Description: Group2 English Study - Unified API (Chatting + Vocabulary) Globals: Function: @@ -12,15 +12,30 @@ Globals: Environment: Variables: CHAT_TABLE_NAME: !Ref ChatTable + VOCAB_TABLE_NAME: !Ref VocabTable CHAT_BUCKET_NAME: group2-englishstudy + VOCAB_BUCKET_NAME: group2-englishstudy AWS_REGION_NAME: !Ref AWS::Region Resources: ############################################# - # Lambda Functions + # API Gateway (Unified) + ############################################# + + MainApi: + Type: AWS::Serverless::Api + Properties: + Name: group2-englishstudy-api + StageName: dev + Cors: + AllowMethods: "'GET,POST,PUT,DELETE,OPTIONS'" + AllowHeaders: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" + AllowOrigin: "'*'" + + ############################################# + # Chatting Lambda Functions ############################################# - # 채팅방 관리 함수 ChatRoomFunction: Type: AWS::Serverless::Function Properties: @@ -37,41 +52,40 @@ Resources: CreateRoom: Type: Api Properties: - RestApiId: !Ref ChatApi + RestApiId: !Ref MainApi Path: /chat/rooms Method: POST GetRooms: Type: Api Properties: - RestApiId: !Ref ChatApi + RestApiId: !Ref MainApi Path: /chat/rooms Method: GET GetRoom: Type: Api Properties: - RestApiId: !Ref ChatApi + RestApiId: !Ref MainApi Path: /chat/rooms/{roomId} Method: GET DeleteRoom: Type: Api Properties: - RestApiId: !Ref ChatApi + RestApiId: !Ref MainApi Path: /chat/rooms/{roomId} Method: DELETE JoinRoom: Type: Api Properties: - RestApiId: !Ref ChatApi + RestApiId: !Ref MainApi Path: /chat/rooms/{roomId}/join Method: POST LeaveRoom: Type: Api Properties: - RestApiId: !Ref ChatApi + RestApiId: !Ref MainApi Path: /chat/rooms/{roomId}/leave Method: POST - # 채팅 메시지 처리 함수 ChatMessageFunction: Type: AWS::Serverless::Function Properties: @@ -102,23 +116,22 @@ Resources: SendMessage: Type: Api Properties: - RestApiId: !Ref ChatApi + RestApiId: !Ref MainApi Path: /chat/rooms/{roomId}/messages Method: POST GetMessages: Type: Api Properties: - RestApiId: !Ref ChatApi + RestApiId: !Ref MainApi Path: /chat/rooms/{roomId}/messages Method: GET GetMessage: Type: Api Properties: - RestApiId: !Ref ChatApi + RestApiId: !Ref MainApi Path: /chat/rooms/{roomId}/messages/{messageId} Method: GET - # AI 응답 생성 함수 (Bedrock) ChatAIFunction: Type: AWS::Serverless::Function Properties: @@ -143,11 +156,10 @@ Resources: GenerateAI: Type: Api Properties: - RestApiId: !Ref ChatApi + RestApiId: !Ref MainApi Path: /chat/ai/generate Method: POST - # 음성 변환 함수 (Polly) ChatVoiceFunction: Type: AWS::Serverless::Function Properties: @@ -172,26 +184,233 @@ Resources: TextToSpeech: Type: Api Properties: - RestApiId: !Ref ChatApi + RestApiId: !Ref MainApi Path: /chat/voice/synthesize Method: POST ############################################# - # API Gateway + # Vocabulary Lambda Functions ############################################# - ChatApi: - Type: AWS::Serverless::Api + WordFunction: + Type: AWS::Serverless::Function Properties: - Name: group2-englishstudy-chat-api - StageName: dev - Cors: - AllowMethods: "'GET,POST,PUT,DELETE,OPTIONS'" - AllowHeaders: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" - AllowOrigin: "'*'" + FunctionName: group2-englishstudy-vocab-word-handler + CodeUri: ../vocabulary/VocabFunction + Handler: com.mzc.secondproject.serverless.vocabulary.handler.WordHandler::handleRequest + Description: Handle word CRUD operations + SnapStart: + ApplyOn: PublishedVersions + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref VocabTable + Events: + CreateWord: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/words + Method: POST + BatchCreateWords: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/words/batch + Method: POST + SearchWords: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/words/search + Method: GET + GetWords: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/words + Method: GET + GetWord: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/words/{wordId} + Method: GET + UpdateWord: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/words/{wordId} + Method: PUT + DeleteWord: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/words/{wordId} + Method: DELETE + + UserWordFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: group2-englishstudy-vocab-userword-handler + CodeUri: ../vocabulary/VocabFunction + Handler: com.mzc.secondproject.serverless.vocabulary.handler.UserWordHandler::handleRequest + Description: Handle user word learning status + SnapStart: + ApplyOn: PublishedVersions + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref VocabTable + Events: + GetUserWords: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/users/{userId}/words + Method: GET + GetUserWord: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/users/{userId}/words/{wordId} + Method: GET + UpdateUserWord: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/users/{userId}/words/{wordId} + Method: PUT + UpdateUserWordTag: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/users/{userId}/words/{wordId}/tag + Method: PUT + + DailyStudyFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: group2-englishstudy-vocab-daily-handler + CodeUri: ../vocabulary/VocabFunction + Handler: com.mzc.secondproject.serverless.vocabulary.handler.DailyStudyHandler::handleRequest + Description: Handle daily study word assignment + SnapStart: + ApplyOn: PublishedVersions + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref VocabTable + Events: + GetDailyWords: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/daily/{userId} + Method: GET + MarkWordLearned: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/daily/{userId}/words/{wordId}/learned + Method: POST + + TestFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: group2-englishstudy-vocab-test-handler + CodeUri: ../vocabulary/VocabFunction + Handler: com.mzc.secondproject.serverless.vocabulary.handler.TestHandler::handleRequest + Description: Handle vocabulary tests + SnapStart: + ApplyOn: PublishedVersions + Environment: + Variables: + TEST_RESULT_TOPIC_ARN: !Ref TestResultTopic + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref VocabTable + - SNSPublishMessagePolicy: + TopicName: !GetAtt TestResultTopic.TopicName + Events: + StartTest: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/test/{userId}/start + Method: POST + SubmitAnswer: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/test/{userId}/submit + Method: POST + GetTestResult: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/test/{userId}/results + Method: GET + + StatsFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: group2-englishstudy-vocab-stats-handler + CodeUri: ../vocabulary/VocabFunction + Handler: com.mzc.secondproject.serverless.vocabulary.handler.StatsHandler::handleRequest + Description: Handle user learning statistics + SnapStart: + ApplyOn: PublishedVersions + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref VocabTable + Events: + GetStats: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/stats/{userId} + Method: GET + GetDailyStats: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/stats/{userId}/daily + Method: GET + GetWeaknessAnalysis: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/stats/{userId}/weakness + Method: GET + + VocabVoiceFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: group2-englishstudy-vocab-voice-handler + CodeUri: ../vocabulary/VocabFunction + Handler: com.mzc.secondproject.serverless.vocabulary.handler.VoiceHandler::handleRequest + Description: Convert word to speech using Polly + SnapStart: + ApplyOn: PublishedVersions + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref VocabTable + - S3CrudPolicy: + BucketName: group2-englishstudy + - Statement: + - Effect: Allow + Action: + - polly:SynthesizeSpeech + - polly:DescribeVoices + Resource: "*" + Events: + TextToSpeech: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/voice/synthesize + Method: POST ############################################# - # DynamoDB + # DynamoDB Tables ############################################# ChatTable: @@ -238,40 +457,143 @@ Resources: AttributeName: ttl Enabled: true + VocabTable: + Type: AWS::DynamoDB::Table + Properties: + TableName: group2-englishstudy-vocab + BillingMode: PAY_PER_REQUEST + AttributeDefinitions: + - AttributeName: PK + AttributeType: S + - AttributeName: SK + AttributeType: S + - AttributeName: GSI1PK + AttributeType: S + - AttributeName: GSI1SK + AttributeType: S + - AttributeName: GSI2PK + AttributeType: S + - AttributeName: GSI2SK + AttributeType: S + KeySchema: + - AttributeName: PK + KeyType: HASH + - AttributeName: SK + KeyType: RANGE + GlobalSecondaryIndexes: + - IndexName: GSI1 + KeySchema: + - AttributeName: GSI1PK + KeyType: HASH + - AttributeName: GSI1SK + KeyType: RANGE + Projection: + ProjectionType: ALL + - IndexName: GSI2 + KeySchema: + - AttributeName: GSI2PK + KeyType: HASH + - AttributeName: GSI2SK + KeyType: RANGE + Projection: + ProjectionType: ALL + TimeToLiveSpecification: + AttributeName: ttl + Enabled: true + ############################################# - # S3 Bucket (기존 버킷 사용) + # SNS / SQS for Async Statistics Processing ############################################# - # 기존 버킷 group2-englishstudy 사용 - 버킷 생성 제거 + + # SNS Topic - 시험 결과 이벤트 발행 + TestResultTopic: + Type: AWS::SNS::Topic + Properties: + TopicName: group2-englishstudy-test-result-topic + + # SQS Dead Letter Queue - 실패한 메시지 보관 + StatisticsDeadLetterQueue: + Type: AWS::SQS::Queue + Properties: + QueueName: group2-englishstudy-statistics-dlq + MessageRetentionPeriod: 1209600 # 14일 + + # SQS Queue - 통계 처리용 + StatisticsQueue: + Type: AWS::SQS::Queue + Properties: + QueueName: group2-englishstudy-statistics-queue + VisibilityTimeout: 60 + RedrivePolicy: + deadLetterTargetArn: !GetAtt StatisticsDeadLetterQueue.Arn + maxReceiveCount: 3 + + # SQS Queue Policy - SNS에서 메시지 수신 허용 + StatisticsQueuePolicy: + Type: AWS::SQS::QueuePolicy + Properties: + Queues: + - !Ref StatisticsQueue + PolicyDocument: + Statement: + - Effect: Allow + Principal: + Service: sns.amazonaws.com + Action: sqs:SendMessage + Resource: !GetAtt StatisticsQueue.Arn + Condition: + ArnEquals: + aws:SourceArn: !Ref TestResultTopic + + # SNS → SQS 구독 + StatisticsQueueSubscription: + Type: AWS::SNS::Subscription + Properties: + Protocol: sqs + TopicArn: !Ref TestResultTopic + Endpoint: !GetAtt StatisticsQueue.Arn + RawMessageDelivery: true + + # Statistics Processor Lambda - SQS에서 메시지 소비하여 통계 업데이트 + StatisticsProcessorFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: group2-englishstudy-statistics-processor + CodeUri: ../vocabulary/VocabFunction + Handler: com.mzc.secondproject.serverless.vocabulary.handler.StatisticsHandler::handleRequest + Description: Process test results and update user word statistics + Timeout: 60 + SnapStart: + ApplyOn: PublishedVersions + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref VocabTable + - SQSPollerPolicy: + QueueName: !GetAtt StatisticsQueue.QueueName + Events: + SQSEvent: + Type: SQS + Properties: + Queue: !GetAtt StatisticsQueue.Arn + BatchSize: 10 ############################################# # Outputs ############################################# Outputs: - ChatApiUrl: - Description: API Gateway endpoint URL - Value: !Sub 'https://${ChatApi}.execute-api.${AWS::Region}.amazonaws.com/dev/' + ApiUrl: + Description: Unified API Gateway endpoint URL + Value: !Sub 'https://${MainApi}.execute-api.${AWS::Region}.amazonaws.com/dev/' ChatTableName: - Description: DynamoDB Table Name + Description: Chat DynamoDB Table Name Value: !Ref ChatTable - ChatBucketName: + VocabTableName: + Description: Vocab DynamoDB Table Name + Value: !Ref VocabTable + + BucketName: Description: S3 Bucket Name Value: group2-englishstudy - - ChatRoomFunctionArn: - Description: Chat Room Lambda Function ARN - Value: !GetAtt ChatRoomFunction.Arn - - ChatMessageFunctionArn: - Description: Chat Message Lambda Function ARN - Value: !GetAtt ChatMessageFunction.Arn - - ChatAIFunctionArn: - Description: Chat AI Lambda Function ARN - Value: !GetAtt ChatAIFunction.Arn - - ChatVoiceFunctionArn: - Description: Chat Voice Lambda Function ARN - Value: !GetAtt ChatVoiceFunction.Arn diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 00000000..9df6b9ac --- /dev/null +++ b/deploy.sh @@ -0,0 +1,146 @@ +#!/bin/bash + +# Group2 English Study - 통합 빌드 & 배포 스크립트 +# 사용법: ./deploy.sh [build|deploy|all] + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CHATTING_DIR="$SCRIPT_DIR/chatting" + +# 색상 정의 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +log_info() { + echo -e "${GREEN}[INFO]${NC} $1" +} + +log_warn() { + echo -e "${YELLOW}[WARN]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +build() { + log_info "==========================================" + log_info "빌드 시작: Chat + Vocab 통합" + log_info "==========================================" + + cd "$CHATTING_DIR" + sam build + + log_info "빌드 완료!" +} + +deploy() { + log_info "==========================================" + log_info "배포 시작: group2-englishstudy-chatting" + log_info "==========================================" + + cd "$CHATTING_DIR" + sam deploy --no-confirm-changeset + + # API URL 출력 + log_info "==========================================" + log_info "배포 완료!" + API_URL=$(aws cloudformation describe-stacks \ + --stack-name group2-englishstudy-chatting \ + --profile mzc \ + --region ap-northeast-2 \ + --query 'Stacks[0].Outputs[?OutputKey==`ApiUrl`].OutputValue' \ + --output text 2>/dev/null) + + if [ -n "$API_URL" ]; then + log_info "API URL: $API_URL" + fi + log_info "==========================================" +} + +validate() { + log_info "템플릿 검증 중..." + cd "$CHATTING_DIR" + sam validate + log_info "템플릿 검증 완료!" +} + +status() { + log_info "스택 상태 확인 중..." + aws cloudformation describe-stacks \ + --stack-name group2-englishstudy-chatting \ + --profile mzc \ + --region ap-northeast-2 \ + --query 'Stacks[0].{Status:StackStatus,LastUpdated:LastUpdatedTime}' \ + --output table 2>/dev/null || log_warn "스택이 존재하지 않습니다." +} + +delete() { + log_warn "==========================================" + log_warn "스택 삭제: group2-englishstudy-chatting" + log_warn "==========================================" + read -p "정말 삭제하시겠습니까? (y/N): " confirm + if [ "$confirm" = "y" ] || [ "$confirm" = "Y" ]; then + aws cloudformation delete-stack \ + --stack-name group2-englishstudy-chatting \ + --profile mzc \ + --region ap-northeast-2 + log_info "삭제 요청 완료. 'aws cloudformation wait stack-delete-complete'로 완료 대기 가능" + else + log_info "삭제 취소됨" + fi +} + +show_help() { + echo "Group2 English Study - 빌드 & 배포 스크립트" + echo "" + echo "사용법: $0 [command]" + echo "" + echo "Commands:" + echo " build SAM 빌드만 실행" + echo " deploy SAM 배포만 실행" + echo " all 빌드 + 배포 (기본값)" + echo " validate 템플릿 검증" + echo " status 스택 상태 확인" + echo " delete 스택 삭제" + echo " help 도움말 표시" + echo "" + echo "예시:" + echo " $0 all # 빌드 후 배포" + echo " $0 build # 빌드만" + echo " $0 deploy # 배포만" +} + +# 메인 로직 +case "${1:-all}" in + build) + build + ;; + deploy) + deploy + ;; + all) + build + deploy + ;; + validate) + validate + ;; + status) + status + ;; + delete) + delete + ;; + help|--help|-h) + show_help + ;; + *) + log_error "알 수 없는 명령: $1" + show_help + exit 1 + ;; +esac diff --git a/docs/ReadMe.md b/docs/ReadMe.md index e69de29b..8b137891 100644 --- a/docs/ReadMe.md +++ b/docs/ReadMe.md @@ -0,0 +1 @@ + diff --git a/docs/VOCAB_API_SPEC.md b/docs/VOCAB_API_SPEC.md new file mode 100644 index 00000000..a972647a --- /dev/null +++ b/docs/VOCAB_API_SPEC.md @@ -0,0 +1,678 @@ +# 단어 암기 서비스 API 명세서 + +## 개요 +영어 단어 암기 학습을 위한 백엔드 API입니다. +- **Base URL**: `https://gc8l9ijhzc.execute-api.ap-northeast-2.amazonaws.com/dev` +- **Content-Type**: `application/json` + +--- + +## 핵심 기능 + +| 기능 | 설명 | +|------|------| +| 일일 학습 | 매일 55개 단어 (새 단어 50개 + 복습 5개) | +| Spaced Repetition | SM-2 알고리즘 기반 최적 복습 주기 | +| 시험 | 학습한 단어 테스트 및 성적 기록 | +| TTS | Polly 기반 발음 듣기 (남성/여성) | +| 약점 분석 | 틀린 단어, 카테고리별 정확도 분석 | + +--- + +## 1. 단어 관리 API + +### 1.1 단어 목록 조회 +``` +GET /vocab/words +``` + +**Query Parameters** +| 파라미터 | 타입 | 필수 | 설명 | +|---------|------|------|------| +| level | string | N | `BEGINNER`, `INTERMEDIATE`, `ADVANCED` | +| category | string | N | `DAILY`, `BUSINESS`, `ACADEMIC` | +| limit | number | N | 페이지 크기 (기본: 20, 최대: 50) | +| cursor | string | N | 페이지네이션 커서 | + +**Response** +```json +{ + "success": true, + "message": "Words retrieved", + "data": { + "words": [ + { + "wordId": "uuid", + "english": "apple", + "korean": "사과", + "example": "I eat an apple every day.", + "level": "BEGINNER", + "category": "DAILY" + } + ], + "nextCursor": "base64-encoded-cursor", + "hasMore": true + } +} +``` + +--- + +### 1.2 단어 검색 +``` +GET /vocab/words/search +``` + +**Query Parameters** +| 파라미터 | 타입 | 필수 | 설명 | +|---------|------|------|------| +| q | string | Y | 검색어 (영어/한국어 모두 가능) | +| limit | number | N | 결과 개수 (기본: 20) | +| cursor | string | N | 페이지네이션 커서 | + +**Response** +```json +{ + "success": true, + "message": "Search completed", + "data": { + "words": [...], + "query": "apple", + "nextCursor": null, + "hasMore": false + } +} +``` + +--- + +### 1.3 단어 상세 조회 +``` +GET /vocab/words/{wordId} +``` + +**Response** +```json +{ + "success": true, + "message": "Word retrieved", + "data": { + "wordId": "uuid", + "english": "apple", + "korean": "사과", + "example": "I eat an apple every day.", + "level": "BEGINNER", + "category": "DAILY", + "createdAt": "2024-01-07T12:00:00Z" + } +} +``` + +--- + +## 2. 일일 학습 API + +### 2.1 오늘의 학습 단어 조회 +``` +GET /vocab/daily/{userId} +``` + +**설명**: 오늘 학습할 55개 단어를 반환합니다. +- 새 단어 50개 (아직 학습하지 않은 단어) +- 복습 단어 5개 (Spaced Repetition 기반) + +**Response** +```json +{ + "success": true, + "message": "Daily words retrieved", + "data": { + "date": "2024-01-07", + "userId": "user123", + "totalWords": 55, + "learnedCount": 10, + "isCompleted": false, + "newWords": [ + { + "wordId": "uuid", + "english": "apple", + "korean": "사과", + "example": "I eat an apple every day." + } + ], + "reviewWords": [ + { + "wordId": "uuid", + "english": "book", + "korean": "책", + "lastReviewedAt": "2024-01-05", + "correctCount": 3, + "incorrectCount": 1 + } + ] + } +} +``` + +--- + +### 2.2 단어 학습 완료 표시 +``` +POST /vocab/daily/{userId}/words/{wordId}/learned +``` + +**Request Body** +```json +{ + "isCorrect": true +} +``` + +**Response** +```json +{ + "success": true, + "message": "Word marked as learned", + "data": { + "wordId": "uuid", + "status": "LEARNING", + "nextReviewAt": "2024-01-08" + } +} +``` + +--- + +## 3. 사용자 단어 학습 상태 API + +### 3.1 학습 상태 조회 +``` +GET /vocab/users/{userId}/words +``` + +**Query Parameters** +| 파라미터 | 타입 | 필수 | 설명 | +|---------|------|------|------| +| status | string | N | `NEW`, `LEARNING`, `REVIEWING`, `MASTERED` | +| limit | number | N | 페이지 크기 (기본: 20) | +| cursor | string | N | 페이지네이션 커서 | + +**Response** +```json +{ + "success": true, + "message": "User words retrieved", + "data": { + "userWords": [ + { + "wordId": "uuid", + "userId": "user123", + "status": "LEARNING", + "correctCount": 5, + "incorrectCount": 2, + "interval": 6, + "nextReviewAt": "2024-01-13", + "lastReviewedAt": "2024-01-07", + "bookmarked": true, + "favorite": false, + "difficulty": "HARD" + } + ], + "nextCursor": null, + "hasMore": false + } +} +``` + +**학습 상태 설명** +| 상태 | 설명 | +|------|------| +| `NEW` | 아직 학습하지 않음 | +| `LEARNING` | 학습 중 (1-2회 정답) | +| `REVIEWING` | 복습 단계 (2-4회 정답) | +| `MASTERED` | 완전 암기 (5회 이상 연속 정답) | + +--- + +### 3.2 학습 결과 업데이트 (정답/오답) +``` +PUT /vocab/users/{userId}/words/{wordId} +``` + +**Request Body** +```json +{ + "isCorrect": true +} +``` + +**Response** +```json +{ + "success": true, + "message": "UserWord updated", + "data": { + "wordId": "uuid", + "status": "REVIEWING", + "interval": 6, + "easeFactor": 2.5, + "repetitions": 3, + "nextReviewAt": "2024-01-13", + "correctCount": 6, + "incorrectCount": 2 + } +} +``` + +**Spaced Repetition 알고리즘 (SM-2)** +- 정답 시: `interval` 증가 (1일 → 6일 → 이전간격 × easeFactor) +- 오답 시: `interval = 1`, `easeFactor` 감소 (최소 1.3) +- 5회 연속 정답 시 `MASTERED` 상태 + +--- + +### 3.3 단어 태그 변경 (북마크/즐겨찾기/난이도) +``` +PUT /vocab/users/{userId}/words/{wordId}/tag +``` + +**Request Body** +```json +{ + "bookmarked": true, + "favorite": false, + "difficulty": "HARD" +} +``` + +| 필드 | 타입 | 설명 | +|------|------|------| +| bookmarked | boolean | 북마크 여부 | +| favorite | boolean | 즐겨찾기 여부 | +| difficulty | string | `EASY`, `NORMAL`, `HARD` | + +**Response** +```json +{ + "success": true, + "message": "Tag updated", + "data": { + "wordId": "uuid", + "bookmarked": true, + "favorite": false, + "difficulty": "HARD" + } +} +``` + +--- + +## 4. 시험 API + +### 4.1 시험 시작 +``` +POST /vocab/test/{userId}/start +``` + +**Request Body** +```json +{ + "wordCount": 20, + "level": "BEGINNER", + "type": "KOREAN_TO_ENGLISH" +} +``` + +| 필드 | 타입 | 필수 | 설명 | +|------|------|------|------| +| wordCount | number | N | 문제 수 (기본: 20) | +| level | string | N | 출제 레벨 필터 | +| type | string | N | `KOREAN_TO_ENGLISH`, `ENGLISH_TO_KOREAN` | + +**Response** +```json +{ + "success": true, + "message": "Test started", + "data": { + "testId": "uuid", + "startedAt": "2024-01-07T12:00:00Z", + "wordCount": 20, + "questions": [ + { + "questionId": 1, + "wordId": "uuid", + "question": "사과", + "options": ["apple", "banana", "orange", "grape"], + "type": "KOREAN_TO_ENGLISH" + } + ] + } +} +``` + +--- + +### 4.2 답안 제출 +``` +POST /vocab/test/{userId}/submit +``` + +**Request Body** +```json +{ + "testId": "uuid", + "answers": [ + {"questionId": 1, "wordId": "uuid", "answer": "apple"}, + {"questionId": 2, "wordId": "uuid", "answer": "book"} + ] +} +``` + +**Response** +```json +{ + "success": true, + "message": "Test submitted", + "data": { + "testId": "uuid", + "totalQuestions": 20, + "correctCount": 18, + "incorrectCount": 2, + "successRate": 90.0, + "results": [ + { + "questionId": 1, + "wordId": "uuid", + "isCorrect": true, + "userAnswer": "apple", + "correctAnswer": "apple" + }, + { + "questionId": 5, + "wordId": "uuid", + "isCorrect": false, + "userAnswer": "banana", + "correctAnswer": "apple" + } + ], + "completedAt": "2024-01-07T12:15:00Z" + } +} +``` + +--- + +### 4.3 시험 결과 조회 +``` +GET /vocab/test/{userId}/results +``` + +**Query Parameters** +| 파라미터 | 타입 | 필수 | 설명 | +|---------|------|------|------| +| limit | number | N | 결과 개수 (기본: 20) | +| cursor | string | N | 페이지네이션 커서 | + +**Response** +```json +{ + "success": true, + "message": "Test results retrieved", + "data": { + "testResults": [ + { + "testId": "uuid", + "totalQuestions": 20, + "correctCount": 18, + "successRate": 90.0, + "completedAt": "2024-01-07T12:15:00Z" + } + ], + "nextCursor": null, + "hasMore": false + } +} +``` + +--- + +## 5. 통계 API + +### 5.1 전체 학습 통계 +``` +GET /vocab/stats/{userId} +``` + +**Response** +```json +{ + "success": true, + "message": "Stats retrieved", + "data": { + "totalWords": 150, + "wordStatusCounts": { + "NEW": 50, + "LEARNING": 40, + "REVIEWING": 35, + "MASTERED": 25 + }, + "totalCorrect": 500, + "totalIncorrect": 100, + "accuracy": 83.3, + "testCount": 15, + "avgSuccessRate": 85.5, + "studyDays": 30, + "completedDays": 25, + "completionRate": 83.3 + } +} +``` + +--- + +### 5.2 일별 학습 통계 +``` +GET /vocab/stats/{userId}/daily +``` + +**Query Parameters** +| 파라미터 | 타입 | 필수 | 설명 | +|---------|------|------|------| +| limit | number | N | 조회 일수 (기본: 30, 최대: 90) | + +**Response** +```json +{ + "success": true, + "message": "Daily stats retrieved", + "data": { + "dailyStats": [ + { + "date": "2024-01-07", + "totalWords": 55, + "learnedCount": 55, + "isCompleted": true, + "progress": 100.0 + }, + { + "date": "2024-01-06", + "totalWords": 55, + "learnedCount": 40, + "isCompleted": false, + "progress": 72.7 + } + ], + "nextCursor": null, + "hasMore": false + } +} +``` + +--- + +### 5.3 약점 분석 +``` +GET /vocab/stats/{userId}/weakness +``` + +**Response** +```json +{ + "success": true, + "message": "Weakness analysis completed", + "data": { + "weakestWords": [ + { + "wordId": "uuid", + "english": "hypothesis", + "korean": "가설", + "level": "ADVANCED", + "category": "ACADEMIC", + "incorrectCount": 5, + "correctCount": 2, + "accuracy": 28.6, + "status": "LEARNING" + } + ], + "categoryAnalysis": { + "DAILY": { + "totalCorrect": 200, + "totalIncorrect": 30, + "wordCount": 50, + "accuracy": 87.0 + }, + "BUSINESS": { + "totalCorrect": 150, + "totalIncorrect": 50, + "wordCount": 40, + "accuracy": 75.0 + }, + "ACADEMIC": { + "totalCorrect": 80, + "totalIncorrect": 40, + "wordCount": 30, + "accuracy": 66.7 + } + }, + "levelAnalysis": { + "BEGINNER": {"accuracy": 90.0, "wordCount": 50}, + "INTERMEDIATE": {"accuracy": 78.0, "wordCount": 40}, + "ADVANCED": {"accuracy": 65.0, "wordCount": 30} + }, + "suggestions": [ + "ACADEMIC 카테고리의 정확도가 66.7%로 가장 낮습니다. 집중 학습을 권장합니다.", + "ADVANCED 레벨의 정확도가 65.0%입니다. 이 레벨의 단어들을 더 복습해보세요.", + "자주 틀리는 단어 10개가 있습니다. 북마크하여 집중 복습하세요." + ] + } +} +``` + +--- + +## 6. 음성 API (TTS) + +### 6.1 단어 발음 듣기 +``` +POST /vocab/voice/synthesize +``` + +**Request Body** +```json +{ + "wordId": "uuid", + "text": "apple", + "voice": "FEMALE" +} +``` + +| 필드 | 타입 | 필수 | 설명 | +|------|------|------|------| +| wordId | string | Y | 단어 ID (캐시 키로 사용) | +| text | string | Y | 발음할 텍스트 | +| voice | string | N | `MALE` (Matthew), `FEMALE` (Joanna, 기본값) | + +**Response** +```json +{ + "success": true, + "message": "Speech synthesized", + "data": { + "audioUrl": "https://s3.ap-northeast-2.amazonaws.com/...", + "s3Key": "vocab/voice/uuid_female.mp3", + "cached": true + } +} +``` + +**참고**: +- `audioUrl`은 1시간 동안 유효한 Pre-signed URL입니다. +- 동일 단어+음성 조합은 S3에 캐시되어 재사용됩니다. + +--- + +## 에러 응답 형식 + +```json +{ + "success": false, + "error": "에러 메시지" +} +``` + +**HTTP 상태 코드** +| 코드 | 설명 | +|------|------| +| 200 | 성공 | +| 201 | 생성 성공 | +| 400 | 잘못된 요청 (필수 파라미터 누락 등) | +| 404 | 리소스 없음 | +| 500 | 서버 에러 | + +--- + +## 프론트엔드 구현 가이드 + +### 추천 화면 구성 + +1. **메인 대시보드** + - 오늘의 학습 진행률 (GET /vocab/daily/{userId}) + - 전체 통계 요약 (GET /vocab/stats/{userId}) + +2. **일일 학습 화면** + - 플래시카드 UI + - 정답/오답 버튼 → PUT /vocab/users/{userId}/words/{wordId} + - TTS 발음 듣기 버튼 → POST /vocab/voice/synthesize + +3. **시험 화면** + - 시험 시작 → POST /vocab/test/{userId}/start + - 4지선다 문제 표시 + - 답안 제출 → POST /vocab/test/{userId}/submit + - 결과 화면 (정답/오답 표시) + +4. **단어장 화면** + - 전체 단어 목록 (GET /vocab/words) + - 검색 기능 (GET /vocab/words/search) + - 북마크/즐겨찾기 토글 (PUT /vocab/users/{userId}/words/{wordId}/tag) + +5. **통계/분석 화면** + - 학습 달력 (일별 완료 여부) + - 약점 분석 차트 (GET /vocab/stats/{userId}/weakness) + - 레벨/카테고리별 정확도 그래프 + +### 상태 관리 추천 +- userId는 로그인 후 전역 상태로 관리 +- 일일 학습 단어 목록은 세션 캐시 +- 북마크/즐겨찾기는 낙관적 업데이트 + +--- + +## 테스트 데이터 + +현재 등록된 시드 데이터: +- **BEGINNER + DAILY**: 25개 (apple, book, cat, ...) +- **INTERMEDIATE + BUSINESS**: 25개 (achieve, benefit, ...) +- **ADVANCED + ACADEMIC**: 19개 (abstract, hypothesis, ...) + +총 **69개 단어** 등록됨 diff --git a/vocabulary/VocabFunction/build.gradle b/vocabulary/VocabFunction/build.gradle index 2dcb4f2b..bb558447 100644 --- a/vocabulary/VocabFunction/build.gradle +++ b/vocabulary/VocabFunction/build.gradle @@ -26,6 +26,7 @@ dependencies { implementation 'software.amazon.awssdk:dynamodb-enhanced' implementation 'software.amazon.awssdk:polly' implementation 'software.amazon.awssdk:s3' + implementation 'software.amazon.awssdk:sns' // JSON Processing implementation 'com.google.code.gson:gson:2.10.1' diff --git a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/DailyStudyHandler.java b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/DailyStudyHandler.java index aeabd3cd..e3f948fc 100644 --- a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/DailyStudyHandler.java +++ b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/DailyStudyHandler.java @@ -71,12 +71,16 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent re private APIGatewayProxyResponseEvent getDailyWords(APIGatewayProxyRequestEvent request) { Map pathParams = request.getPathParameters(); + Map queryParams = request.getQueryStringParameters(); String userId = pathParams != null ? pathParams.get("userId") : null; if (userId == null) { return createResponse(400, ApiResponse.error("userId is required")); } + // 레벨 파라미터 (첫 생성 시 필수) + String level = queryParams != null ? queryParams.get("level") : null; + String today = LocalDate.now().toString(); // 오늘의 학습 데이터 조회 @@ -86,8 +90,16 @@ private APIGatewayProxyResponseEvent getDailyWords(APIGatewayProxyRequestEvent r if (optDailyStudy.isPresent()) { dailyStudy = optDailyStudy.get(); } else { + // 첫 생성 시 레벨 필수 + if (level == null || level.isEmpty()) { + return createResponse(400, ApiResponse.error("level is required for first daily study (BEGINNER, INTERMEDIATE, ADVANCED)")); + } + // 레벨 유효성 검사 + if (!level.equals("BEGINNER") && !level.equals("INTERMEDIATE") && !level.equals("ADVANCED")) { + return createResponse(400, ApiResponse.error("Invalid level. Must be BEGINNER, INTERMEDIATE, or ADVANCED")); + } // 새로운 일일 학습 생성 - dailyStudy = createDailyStudy(userId, today); + dailyStudy = createDailyStudy(userId, today, level); } // 단어 상세 정보 조회 @@ -103,7 +115,7 @@ private APIGatewayProxyResponseEvent getDailyWords(APIGatewayProxyRequestEvent r return createResponse(200, ApiResponse.success("Daily words retrieved", result)); } - private DailyStudy createDailyStudy(String userId, String date) { + private DailyStudy createDailyStudy(String userId, String date, String level) { String now = Instant.now().toString(); // 복습 대상 단어 조회 (5개) @@ -112,8 +124,8 @@ private DailyStudy createDailyStudy(String userId, String date) { .map(UserWord::getWordId) .collect(Collectors.toList()); - // 신규 단어 조회 (50개) - 아직 학습하지 않은 단어 - List newWordIds = getNewWordsForUser(userId, NEW_WORDS_COUNT); + // 신규 단어 조회 (50개) - 해당 레벨에서 아직 학습하지 않은 단어 + List newWordIds = getNewWordsForUser(userId, level, NEW_WORDS_COUNT); DailyStudy dailyStudy = DailyStudy.builder() .pk("DAILY#" + userId) @@ -138,29 +150,30 @@ private DailyStudy createDailyStudy(String userId, String date) { return dailyStudy; } - private List getNewWordsForUser(String userId, int count) { + private List getNewWordsForUser(String userId, String level, int count) { // 사용자가 학습한 단어 목록 UserWordRepository.UserWordPage userWordPage = userWordRepository.findByUserIdWithPagination(userId, 1000, null); List learnedWordIds = userWordPage.getUserWords().stream() .map(UserWord::getWordId) .collect(Collectors.toList()); - // 전체 단어에서 학습하지 않은 단어 선택 + // 해당 레벨에서 학습하지 않은 단어 선택 List newWordIds = new ArrayList<>(); - String[] levels = {"BEGINNER", "INTERMEDIATE", "ADVANCED"}; + String lastEvaluatedKey = null; - for (String level : levels) { - if (newWordIds.size() >= count) break; - - WordRepository.WordPage wordPage = wordRepository.findByLevelWithPagination(level, count * 2, null); + // 페이지네이션으로 해당 레벨의 모든 단어 조회 + do { + WordRepository.WordPage wordPage = wordRepository.findByLevelWithPagination(level, count * 2, lastEvaluatedKey); for (Word word : wordPage.getWords()) { if (!learnedWordIds.contains(word.getWordId()) && !newWordIds.contains(word.getWordId())) { newWordIds.add(word.getWordId()); if (newWordIds.size() >= count) break; } } - } + lastEvaluatedKey = wordPage.getNextCursor(); + } while (newWordIds.size() < count && lastEvaluatedKey != null); + logger.info("Selected {} new words for user {} at level {}", newWordIds.size(), userId, level); return newWordIds; } @@ -169,11 +182,8 @@ private List getWordDetails(List wordIds) { return new ArrayList<>(); } - List words = new ArrayList<>(); - for (String wordId : wordIds) { - wordRepository.findById(wordId).ifPresent(words::add); - } - return words; + // BatchGetItem으로 한 번에 조회 (N+1 문제 해결) + return wordRepository.findByIds(wordIds); } private Map calculateProgress(DailyStudy dailyStudy) { diff --git a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/StatisticsHandler.java b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/StatisticsHandler.java new file mode 100644 index 00000000..5f09ffa5 --- /dev/null +++ b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/StatisticsHandler.java @@ -0,0 +1,160 @@ +package com.mzc.secondproject.serverless.vocabulary.handler; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.events.SQSEvent; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.mzc.secondproject.serverless.vocabulary.model.UserWord; +import com.mzc.secondproject.serverless.vocabulary.repository.UserWordRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * SQS에서 시험 결과 메시지를 받아 UserWord 통계를 업데이트하는 Lambda + * SNS → SQS → Statistics Lambda 패턴 + */ +public class StatisticsHandler implements RequestHandler { + + private static final Logger logger = LoggerFactory.getLogger(StatisticsHandler.class); + private static final Gson gson = new GsonBuilder().create(); + + private final UserWordRepository userWordRepository; + + public StatisticsHandler() { + this.userWordRepository = new UserWordRepository(); + } + + @Override + public Void handleRequest(SQSEvent event, Context context) { + logger.info("Received {} messages from SQS", event.getRecords().size()); + + for (SQSEvent.SQSMessage message : event.getRecords()) { + try { + processMessage(message); + } catch (Exception e) { + logger.error("Failed to process message: {}", message.getMessageId(), e); + // 실패한 메시지는 DLQ로 이동됨 (SQS 설정에 의해) + throw new RuntimeException("Failed to process message", e); + } + } + + return null; + } + + @SuppressWarnings("unchecked") + private void processMessage(SQSEvent.SQSMessage message) { + String body = message.getBody(); + logger.info("Processing message: {}", body); + + Map testResult = gson.fromJson(body, Map.class); + + String userId = (String) testResult.get("userId"); + List> results = (List>) testResult.get("results"); + + if (userId == null || results == null) { + logger.warn("Invalid message format: userId or results is null"); + return; + } + + String now = Instant.now().toString(); + + for (Map result : results) { + String wordId = (String) result.get("wordId"); + Boolean isCorrect = (Boolean) result.get("isCorrect"); + + if (wordId == null || isCorrect == null) { + continue; + } + + updateUserWordStatistics(userId, wordId, isCorrect, now); + } + + logger.info("Successfully processed test result for user: {}, {} words updated", userId, results.size()); + } + + private void updateUserWordStatistics(String userId, String wordId, boolean isCorrect, String now) { + Optional optUserWord = userWordRepository.findByUserIdAndWordId(userId, wordId); + UserWord userWord; + + if (optUserWord.isEmpty()) { + // 새로운 UserWord 생성 + userWord = UserWord.builder() + .pk("USER#" + userId) + .sk("WORD#" + wordId) + .gsi1pk("USER#" + userId + "#REVIEW") + .gsi2pk("USER#" + userId + "#STATUS") + .userId(userId) + .wordId(wordId) + .status("NEW") + .interval(1) + .easeFactor(2.5) + .repetitions(0) + .correctCount(0) + .incorrectCount(0) + .createdAt(now) + .build(); + } else { + userWord = optUserWord.get(); + } + + // Spaced Repetition 알고리즘 적용 + applySpacedRepetition(userWord, isCorrect); + userWord.setUpdatedAt(now); + userWord.setLastReviewedAt(now); + + // GSI 업데이트 + userWord.setGsi1sk("DATE#" + userWord.getNextReviewAt()); + userWord.setGsi2sk("STATUS#" + userWord.getStatus()); + + userWordRepository.save(userWord); + } + + /** + * SM-2 Spaced Repetition 알고리즘 적용 + */ + private void applySpacedRepetition(UserWord userWord, boolean isCorrect) { + if (isCorrect) { + userWord.setCorrectCount(userWord.getCorrectCount() + 1); + userWord.setRepetitions(userWord.getRepetitions() + 1); + + // 간격 계산 + if (userWord.getRepetitions() == 1) { + userWord.setInterval(1); + } else if (userWord.getRepetitions() == 2) { + userWord.setInterval(6); + } else { + int newInterval = (int) Math.round(userWord.getInterval() * userWord.getEaseFactor()); + userWord.setInterval(newInterval); + } + + // 상태 업데이트 + if (userWord.getRepetitions() >= 5) { + userWord.setStatus("MASTERED"); + } else if (userWord.getRepetitions() >= 2) { + userWord.setStatus("REVIEWING"); + } else { + userWord.setStatus("LEARNING"); + } + } else { + userWord.setIncorrectCount(userWord.getIncorrectCount() + 1); + userWord.setRepetitions(0); + userWord.setInterval(1); + userWord.setStatus("LEARNING"); + + // easeFactor 감소 (최소 1.3) + double newEaseFactor = userWord.getEaseFactor() - 0.2; + userWord.setEaseFactor(Math.max(1.3, newEaseFactor)); + } + + // 다음 복습일 계산 + LocalDate nextReview = LocalDate.now().plusDays(userWord.getInterval()); + userWord.setNextReviewAt(nextReview.toString()); + } +} diff --git a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/StatsHandler.java b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/StatsHandler.java index 2e680e22..d5ec6b54 100644 --- a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/StatsHandler.java +++ b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/StatsHandler.java @@ -10,15 +10,20 @@ import com.mzc.secondproject.serverless.vocabulary.model.DailyStudy; import com.mzc.secondproject.serverless.vocabulary.model.TestResult; import com.mzc.secondproject.serverless.vocabulary.model.UserWord; +import com.mzc.secondproject.serverless.vocabulary.model.Word; import com.mzc.secondproject.serverless.vocabulary.repository.DailyStudyRepository; import com.mzc.secondproject.serverless.vocabulary.repository.TestResultRepository; import com.mzc.secondproject.serverless.vocabulary.repository.UserWordRepository; +import com.mzc.secondproject.serverless.vocabulary.repository.WordRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.util.ArrayList; +import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; public class StatsHandler implements RequestHandler { @@ -28,11 +33,13 @@ public class StatsHandler implements RequestHandler pathParams = request.getPathParameters(); + String userId = pathParams != null ? pathParams.get("userId") : null; + + if (userId == null) { + return createResponse(400, ApiResponse.error("userId is required")); + } + + // 사용자의 모든 학습 단어 조회 + List allUserWords = new ArrayList<>(); + String cursor = null; + do { + UserWordRepository.UserWordPage page = userWordRepository.findByUserIdWithPagination(userId, 100, cursor); + allUserWords.addAll(page.getUserWords()); + cursor = page.getNextCursor(); + } while (cursor != null); + + if (allUserWords.isEmpty()) { + Map emptyResult = new HashMap<>(); + emptyResult.put("weakestWords", List.of()); + emptyResult.put("categoryAnalysis", Map.of()); + emptyResult.put("levelAnalysis", Map.of()); + emptyResult.put("suggestions", List.of()); + return createResponse(200, ApiResponse.success("No learning data", emptyResult)); + } + + // 1. 가장 많이 틀린 단어 Top 10 + List> weakestWords = allUserWords.stream() + .filter(uw -> uw.getIncorrectCount() != null && uw.getIncorrectCount() > 0) + .sorted(Comparator.comparingInt(UserWord::getIncorrectCount).reversed()) + .limit(10) + .map(uw -> { + Map wordInfo = new HashMap<>(); + wordInfo.put("wordId", uw.getWordId()); + wordInfo.put("incorrectCount", uw.getIncorrectCount()); + wordInfo.put("correctCount", uw.getCorrectCount()); + wordInfo.put("status", uw.getStatus()); + + // 단어 상세 정보 조회 + wordRepository.findById(uw.getWordId()).ifPresent(word -> { + wordInfo.put("english", word.getEnglish()); + wordInfo.put("korean", word.getKorean()); + wordInfo.put("level", word.getLevel()); + wordInfo.put("category", word.getCategory()); + }); + + int total = (uw.getCorrectCount() != null ? uw.getCorrectCount() : 0) + + (uw.getIncorrectCount() != null ? uw.getIncorrectCount() : 0); + wordInfo.put("accuracy", total > 0 ? + (uw.getCorrectCount() != null ? uw.getCorrectCount() * 100.0 / total : 0) : 0); + + return wordInfo; + }) + .collect(Collectors.toList()); + + // 2. 카테고리별 정확도 분석 + Map> categoryAnalysis = new HashMap<>(); + // 3. 레벨별 정확도 분석 + Map> levelAnalysis = new HashMap<>(); + + for (UserWord uw : allUserWords) { + // 단어 정보 조회 + wordRepository.findById(uw.getWordId()).ifPresent(word -> { + String category = word.getCategory(); + String level = word.getLevel(); + + int correct = uw.getCorrectCount() != null ? uw.getCorrectCount() : 0; + int incorrect = uw.getIncorrectCount() != null ? uw.getIncorrectCount() : 0; + + // 카테고리별 집계 + categoryAnalysis.computeIfAbsent(category, k -> { + Map stats = new HashMap<>(); + stats.put("totalCorrect", 0); + stats.put("totalIncorrect", 0); + stats.put("wordCount", 0); + return stats; + }); + Map catStats = categoryAnalysis.get(category); + catStats.put("totalCorrect", (Integer) catStats.get("totalCorrect") + correct); + catStats.put("totalIncorrect", (Integer) catStats.get("totalIncorrect") + incorrect); + catStats.put("wordCount", (Integer) catStats.get("wordCount") + 1); + + // 레벨별 집계 + levelAnalysis.computeIfAbsent(level, k -> { + Map stats = new HashMap<>(); + stats.put("totalCorrect", 0); + stats.put("totalIncorrect", 0); + stats.put("wordCount", 0); + return stats; + }); + Map lvlStats = levelAnalysis.get(level); + lvlStats.put("totalCorrect", (Integer) lvlStats.get("totalCorrect") + correct); + lvlStats.put("totalIncorrect", (Integer) lvlStats.get("totalIncorrect") + incorrect); + lvlStats.put("wordCount", (Integer) lvlStats.get("wordCount") + 1); + }); + } + + // 정확도 계산 + categoryAnalysis.values().forEach(stats -> { + int correct = (Integer) stats.get("totalCorrect"); + int incorrect = (Integer) stats.get("totalIncorrect"); + int total = correct + incorrect; + stats.put("accuracy", total > 0 ? (correct * 100.0 / total) : 0); + }); + + levelAnalysis.values().forEach(stats -> { + int correct = (Integer) stats.get("totalCorrect"); + int incorrect = (Integer) stats.get("totalIncorrect"); + int total = correct + incorrect; + stats.put("accuracy", total > 0 ? (correct * 100.0 / total) : 0); + }); + + // 4. 학습 제안 생성 + List suggestions = new ArrayList<>(); + + // 가장 약한 카테고리 찾기 + categoryAnalysis.entrySet().stream() + .filter(e -> (Integer) e.getValue().get("wordCount") >= 3) // 최소 3개 이상 학습한 카테고리만 + .min(Comparator.comparingDouble(e -> (Double) e.getValue().get("accuracy"))) + .ifPresent(e -> suggestions.add( + String.format("%s 카테고리의 정확도가 %.1f%%로 가장 낮습니다. 집중 학습을 권장합니다.", + e.getKey(), e.getValue().get("accuracy")))); + + // 가장 약한 레벨 찾기 + levelAnalysis.entrySet().stream() + .filter(e -> (Integer) e.getValue().get("wordCount") >= 3) + .min(Comparator.comparingDouble(e -> (Double) e.getValue().get("accuracy"))) + .ifPresent(e -> suggestions.add( + String.format("%s 레벨의 정확도가 %.1f%%입니다. 이 레벨의 단어들을 더 복습해보세요.", + e.getKey(), e.getValue().get("accuracy")))); + + // 많이 틀린 단어가 있는 경우 + if (!weakestWords.isEmpty()) { + suggestions.add(String.format("자주 틀리는 단어 %d개가 있습니다. 북마크하여 집중 복습하세요.", + weakestWords.size())); + } + + Map result = new HashMap<>(); + result.put("weakestWords", weakestWords); + result.put("categoryAnalysis", categoryAnalysis); + result.put("levelAnalysis", levelAnalysis); + result.put("suggestions", suggestions); + + return createResponse(200, ApiResponse.success("Weakness analysis completed", result)); + } + private APIGatewayProxyResponseEvent createResponse(int statusCode, Object body) { return new APIGatewayProxyResponseEvent() .withStatusCode(statusCode) diff --git a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/TestHandler.java b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/TestHandler.java index ad70efab..9e27a220 100644 --- a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/TestHandler.java +++ b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/TestHandler.java @@ -15,20 +15,27 @@ import com.mzc.secondproject.serverless.vocabulary.repository.WordRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import software.amazon.awssdk.services.sns.SnsClient; +import software.amazon.awssdk.services.sns.model.PublishRequest; import java.time.Instant; import java.time.LocalDate; import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Random; import java.util.UUID; +import java.util.stream.Collectors; public class TestHandler implements RequestHandler { private static final Logger logger = LoggerFactory.getLogger(TestHandler.class); private static final Gson gson = new GsonBuilder().setPrettyPrinting().create(); + private static final SnsClient snsClient = SnsClient.builder().build(); + private static final String TEST_RESULT_TOPIC_ARN = System.getenv("TEST_RESULT_TOPIC_ARN"); private final TestResultRepository testResultRepository; private final DailyStudyRepository dailyStudyRepository; @@ -100,18 +107,33 @@ private APIGatewayProxyResponseEvent startTest(APIGatewayProxyRequestEvent reque return createResponse(400, ApiResponse.error("No words to test")); } - // 시험 문제 생성 + // 시험 문제 생성 (BatchGetItem으로 한 번에 조회) + List words = wordRepository.findByIds(allWordIds); + + // 레벨별로 단어 그룹화하여 오답 후보 준비 + Map> wordsByLevel = words.stream() + .collect(Collectors.groupingBy(Word::getLevel)); + + // 각 레벨별 추가 오답 후보 단어 조회 (문제 단어 외의 다른 단어들) + Map> distractorsByLevel = new HashMap<>(); + for (String level : wordsByLevel.keySet()) { + List distractors = getDistractorsForLevel(level, allWordIds); + distractorsByLevel.put(level, distractors); + } + + Random random = new Random(); List> questions = new ArrayList<>(); - for (String wordId : allWordIds) { - Optional optWord = wordRepository.findById(wordId); - if (optWord.isPresent()) { - Word word = optWord.get(); - Map question = new HashMap<>(); - question.put("wordId", word.getWordId()); - question.put("english", word.getEnglish()); - question.put("example", word.getExample()); - questions.add(question); - } + for (Word word : words) { + Map question = new HashMap<>(); + question.put("wordId", word.getWordId()); + question.put("english", word.getEnglish()); + question.put("example", word.getExample()); + + // 4지선다 옵션 생성 + List options = generateOptions(word, wordsByLevel, distractorsByLevel, random); + question.put("options", options); + + questions.add(question); } String testId = UUID.randomUUID().toString(); @@ -154,17 +176,36 @@ private APIGatewayProxyResponseEvent submitAnswer(APIGatewayProxyRequestEvent re int correctCount = 0; int incorrectCount = 0; List incorrectWordIds = new ArrayList<>(); + List> results = new ArrayList<>(); + + // 모든 wordId를 추출하여 BatchGetItem으로 한 번에 조회 + List wordIds = answers.stream() + .map(a -> (String) a.get("wordId")) + .collect(java.util.stream.Collectors.toList()); + List words = wordRepository.findByIds(wordIds); + + // wordId -> Word 맵 생성 + Map wordMap = words.stream() + .collect(java.util.stream.Collectors.toMap(Word::getWordId, w -> w)); for (Map answer : answers) { String wordId = (String) answer.get("wordId"); String userAnswer = (String) answer.get("answer"); - Optional optWord = wordRepository.findById(wordId); - if (optWord.isPresent()) { - Word word = optWord.get(); + Word word = wordMap.get(wordId); + if (word != null) { // 대소문자 무시, 공백 제거 후 비교 boolean isCorrect = word.getKorean().trim().equalsIgnoreCase(userAnswer.trim()); + // 결과 상세 정보 추가 + Map resultItem = new HashMap<>(); + resultItem.put("wordId", wordId); + resultItem.put("english", word.getEnglish()); + resultItem.put("correctAnswer", word.getKorean()); + resultItem.put("userAnswer", userAnswer); + resultItem.put("isCorrect", isCorrect); + results.add(resultItem); + if (isCorrect) { correctCount++; } else { @@ -196,8 +237,21 @@ private APIGatewayProxyResponseEvent submitAnswer(APIGatewayProxyRequestEvent re testResultRepository.save(testResult); + // SNS로 시험 결과 발행 (비동기 통계 처리용) + publishTestResultToSns(userId, results); + + // 응답 데이터 구성 (results 포함) + Map responseData = new HashMap<>(); + responseData.put("testId", testId); + responseData.put("testType", testType); + responseData.put("totalQuestions", totalQuestions); + responseData.put("correctCount", correctCount); + responseData.put("incorrectCount", incorrectCount); + responseData.put("successRate", successRate); + responseData.put("results", results); + logger.info("Test submitted: userId={}, testId={}, successRate={}%", userId, testId, successRate); - return createResponse(200, ApiResponse.success("Test submitted", testResult)); + return createResponse(200, ApiResponse.success("Test submitted", responseData)); } private APIGatewayProxyResponseEvent getTestResults(APIGatewayProxyRequestEvent request) { @@ -226,6 +280,89 @@ private APIGatewayProxyResponseEvent getTestResults(APIGatewayProxyRequestEvent return createResponse(200, ApiResponse.success("Test results retrieved", result)); } + /** + * 해당 레벨에서 오답 후보 단어들의 한국어 뜻 목록을 가져옴 + */ + private List getDistractorsForLevel(String level, List excludeWordIds) { + WordRepository.WordPage wordPage = wordRepository.findByLevelWithPagination(level, 50, null); + return wordPage.getWords().stream() + .filter(w -> !excludeWordIds.contains(w.getWordId())) + .map(Word::getKorean) + .collect(Collectors.toList()); + } + + /** + * 4지선다 옵션 생성 (정답 1개 + 오답 3개, 셔플됨) + */ + private List generateOptions(Word correctWord, Map> wordsByLevel, + Map> distractorsByLevel, Random random) { + List options = new ArrayList<>(); + String correctAnswer = correctWord.getKorean(); + options.add(correctAnswer); + + String level = correctWord.getLevel(); + + // 같은 레벨의 다른 문제 단어들에서 오답 후보 추출 + List sameLevelOptions = wordsByLevel.getOrDefault(level, new ArrayList<>()).stream() + .filter(w -> !w.getWordId().equals(correctWord.getWordId())) + .map(Word::getKorean) + .collect(Collectors.toList()); + + // 추가 오답 후보 (문제에 포함되지 않은 단어들) + List additionalDistractors = distractorsByLevel.getOrDefault(level, new ArrayList<>()); + + // 모든 오답 후보 합치기 + List allDistractors = new ArrayList<>(); + allDistractors.addAll(sameLevelOptions); + allDistractors.addAll(additionalDistractors); + + // 중복 및 정답 제거 + allDistractors = allDistractors.stream() + .filter(d -> !d.equals(correctAnswer)) + .distinct() + .collect(Collectors.toList()); + + // 랜덤하게 3개 선택 + Collections.shuffle(allDistractors, random); + int distractorCount = Math.min(3, allDistractors.size()); + for (int i = 0; i < distractorCount; i++) { + options.add(allDistractors.get(i)); + } + + // 옵션 셔플 + Collections.shuffle(options, random); + return options; + } + + /** + * SNS로 시험 결과 발행 (비동기 통계 처리용) + */ + private void publishTestResultToSns(String userId, List> results) { + if (TEST_RESULT_TOPIC_ARN == null || TEST_RESULT_TOPIC_ARN.isEmpty()) { + logger.warn("TEST_RESULT_TOPIC_ARN is not configured, skipping SNS publish"); + return; + } + + try { + Map message = new HashMap<>(); + message.put("userId", userId); + message.put("results", results); + + String messageJson = gson.toJson(message); + + PublishRequest publishRequest = PublishRequest.builder() + .topicArn(TEST_RESULT_TOPIC_ARN) + .message(messageJson) + .build(); + + snsClient.publish(publishRequest); + logger.info("Published test result to SNS for user: {}", userId); + } catch (Exception e) { + // SNS 발행 실패해도 API 응답에는 영향 없음 (fire-and-forget) + logger.error("Failed to publish test result to SNS for user: {}", userId, e); + } + } + private APIGatewayProxyResponseEvent createResponse(int statusCode, Object body) { return new APIGatewayProxyResponseEvent() .withStatusCode(statusCode) diff --git a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/UserWordHandler.java b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/UserWordHandler.java index c4af3cc5..c1486640 100644 --- a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/UserWordHandler.java +++ b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/UserWordHandler.java @@ -47,6 +47,11 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent re return getUserWord(request); } + // PUT /vocab/users/{userId}/words/{wordId}/tag - 태그 변경 + if ("PUT".equals(httpMethod) && path.endsWith("/tag")) { + return updateUserWordTag(request); + } + // PUT /vocab/users/{userId}/words/{wordId} - 학습 상태 업데이트 if ("PUT".equals(httpMethod) && path.contains("/words/")) { return updateUserWord(request); @@ -67,6 +72,8 @@ private APIGatewayProxyResponseEvent getUserWords(APIGatewayProxyRequestEvent re String userId = pathParams != null ? pathParams.get("userId") : null; String status = queryParams != null ? queryParams.get("status") : null; String cursor = queryParams != null ? queryParams.get("cursor") : null; + String bookmarked = queryParams != null ? queryParams.get("bookmarked") : null; + String incorrectOnly = queryParams != null ? queryParams.get("incorrectOnly") : null; if (userId == null) { return createResponse(400, ApiResponse.error("userId is required")); @@ -78,7 +85,13 @@ private APIGatewayProxyResponseEvent getUserWords(APIGatewayProxyRequestEvent re } UserWordRepository.UserWordPage userWordPage; - if (status != null && !status.isEmpty()) { + + // 필터 우선순위: bookmarked > incorrectOnly > status > 전체 + if ("true".equalsIgnoreCase(bookmarked)) { + userWordPage = userWordRepository.findBookmarkedWords(userId, limit, cursor); + } else if ("true".equalsIgnoreCase(incorrectOnly)) { + userWordPage = userWordRepository.findIncorrectWords(userId, limit, cursor); + } else if (status != null && !status.isEmpty()) { userWordPage = userWordRepository.findByUserIdAndStatus(userId, status, limit, cursor); } else { userWordPage = userWordRepository.findByUserIdWithPagination(userId, limit, cursor); @@ -167,6 +180,72 @@ private APIGatewayProxyResponseEvent updateUserWord(APIGatewayProxyRequestEvent return createResponse(200, ApiResponse.success("UserWord updated", userWord)); } + /** + * 사용자 단어 태그 변경 (북마크, 즐겨찾기, 난이도) + */ + private APIGatewayProxyResponseEvent updateUserWordTag(APIGatewayProxyRequestEvent request) { + Map pathParams = request.getPathParameters(); + String userId = pathParams != null ? pathParams.get("userId") : null; + String wordId = pathParams != null ? pathParams.get("wordId") : null; + + if (userId == null || wordId == null) { + return createResponse(400, ApiResponse.error("userId and wordId are required")); + } + + String body = request.getBody(); + Map requestBody = gson.fromJson(body, Map.class); + + Optional optUserWord = userWordRepository.findByUserIdAndWordId(userId, wordId); + UserWord userWord; + String now = Instant.now().toString(); + + if (optUserWord.isEmpty()) { + // 새로운 UserWord 생성 (태그만 설정) + userWord = UserWord.builder() + .pk("USER#" + userId) + .sk("WORD#" + wordId) + .gsi1pk("USER#" + userId + "#REVIEW") + .gsi2pk("USER#" + userId + "#STATUS") + .gsi2sk("STATUS#NEW") + .userId(userId) + .wordId(wordId) + .status("NEW") + .interval(1) + .easeFactor(2.5) + .repetitions(0) + .correctCount(0) + .incorrectCount(0) + .bookmarked(false) + .favorite(false) + .createdAt(now) + .build(); + } else { + userWord = optUserWord.get(); + } + + // 태그 업데이트 + if (requestBody.containsKey("bookmarked")) { + userWord.setBookmarked((Boolean) requestBody.get("bookmarked")); + } + if (requestBody.containsKey("favorite")) { + userWord.setFavorite((Boolean) requestBody.get("favorite")); + } + if (requestBody.containsKey("difficulty")) { + String difficulty = (String) requestBody.get("difficulty"); + if (difficulty != null && (difficulty.equals("EASY") || difficulty.equals("NORMAL") || difficulty.equals("HARD"))) { + userWord.setDifficulty(difficulty); + } else if (difficulty != null) { + return createResponse(400, ApiResponse.error("difficulty must be EASY, NORMAL, or HARD")); + } + } + + userWord.setUpdatedAt(now); + userWordRepository.save(userWord); + + logger.info("Updated user word tag: userId={}, wordId={}", userId, wordId); + return createResponse(200, ApiResponse.success("Tag updated", userWord)); + } + /** * SM-2 Spaced Repetition 알고리즘 적용 */ diff --git a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/WordHandler.java b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/WordHandler.java index 2d591f32..e05352e5 100644 --- a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/WordHandler.java +++ b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/WordHandler.java @@ -13,7 +13,9 @@ import org.slf4j.LoggerFactory; import java.time.Instant; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Optional; import java.util.UUID; @@ -37,6 +39,16 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent re logger.info("Received request: {} {}", httpMethod, path); try { + // POST /vocab/words/batch - 단어 일괄 등록 + if ("POST".equals(httpMethod) && path.endsWith("/batch")) { + return createWordsBatch(request); + } + + // GET /vocab/words/search - 단어 검색 + if ("GET".equals(httpMethod) && path.endsWith("/search")) { + return searchWords(request); + } + // POST /vocab/words - 단어 생성 if ("POST".equals(httpMethod) && path.endsWith("/words")) { return createWord(request); @@ -48,7 +60,7 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent re } // GET /vocab/words/{wordId} - 단어 상세 조회 - if ("GET".equals(httpMethod) && path.contains("/words/")) { + if ("GET".equals(httpMethod) && path.contains("/words/") && !path.contains("/search") && !path.contains("/batch")) { return getWord(request); } @@ -220,6 +232,97 @@ private APIGatewayProxyResponseEvent deleteWord(APIGatewayProxyRequestEvent requ return createResponse(200, ApiResponse.success("Word deleted", null)); } + @SuppressWarnings("unchecked") + private APIGatewayProxyResponseEvent createWordsBatch(APIGatewayProxyRequestEvent request) { + String body = request.getBody(); + Map requestBody = gson.fromJson(body, Map.class); + + List> wordsList = (List>) requestBody.get("words"); + if (wordsList == null || wordsList.isEmpty()) { + return createResponse(400, ApiResponse.error("words array is required")); + } + + String now = Instant.now().toString(); + List createdWords = new ArrayList<>(); + int successCount = 0; + int failCount = 0; + + for (Map wordData : wordsList) { + try { + String english = (String) wordData.get("english"); + String korean = (String) wordData.get("korean"); + String example = (String) wordData.get("example"); + String level = (String) wordData.getOrDefault("level", "BEGINNER"); + String category = (String) wordData.getOrDefault("category", "DAILY"); + + if (english == null || korean == null) { + failCount++; + continue; + } + + String wordId = UUID.randomUUID().toString(); + + Word word = Word.builder() + .pk("WORD#" + wordId) + .sk("METADATA") + .gsi1pk("LEVEL#" + level) + .gsi1sk("WORD#" + wordId) + .gsi2pk("CATEGORY#" + category) + .gsi2sk("WORD#" + wordId) + .wordId(wordId) + .english(english) + .korean(korean) + .example(example) + .level(level) + .category(category) + .createdAt(now) + .build(); + + wordRepository.save(word); + createdWords.add(word); + successCount++; + } catch (Exception e) { + logger.error("Failed to create word", e); + failCount++; + } + } + + Map result = new HashMap<>(); + result.put("successCount", successCount); + result.put("failCount", failCount); + result.put("totalRequested", wordsList.size()); + + logger.info("Batch created {} words, failed {}", successCount, failCount); + return createResponse(201, ApiResponse.success("Batch completed", result)); + } + + private APIGatewayProxyResponseEvent searchWords(APIGatewayProxyRequestEvent request) { + Map queryParams = request.getQueryStringParameters(); + + String query = queryParams != null ? queryParams.get("q") : null; + String cursor = queryParams != null ? queryParams.get("cursor") : null; + + if (query == null || query.isEmpty()) { + return createResponse(400, ApiResponse.error("q (query) parameter is required")); + } + + int limit = 20; + if (queryParams.get("limit") != null) { + limit = Math.min(Integer.parseInt(queryParams.get("limit")), 50); + } + + // 영어/한국어 모두 검색 + WordRepository.WordPage wordPage = wordRepository.searchByKeyword(query, limit, cursor); + + Map result = new HashMap<>(); + result.put("words", wordPage.getWords()); + result.put("query", query); + result.put("nextCursor", wordPage.getNextCursor()); + result.put("hasMore", wordPage.hasMore()); + + return createResponse(200, ApiResponse.success("Search completed", result)); + } + private APIGatewayProxyResponseEvent createResponse(int statusCode, Object body) { return new APIGatewayProxyResponseEvent() .withStatusCode(statusCode) diff --git a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/UserWord.java b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/UserWord.java index 0abb5e19..c9b22db1 100644 --- a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/UserWord.java +++ b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/UserWord.java @@ -50,6 +50,11 @@ public class UserWord { private String updatedAt; private Long ttl; + // 사용자 태그 + private Boolean bookmarked; // 북마크 여부 + private Boolean favorite; // 즐겨찾기 여부 + private String difficulty; // 사용자 지정 난이도 (EASY, NORMAL, HARD) + @DynamoDbPartitionKey @DynamoDbAttribute("PK") public String getPk() { diff --git a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/UserWordRepository.java b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/UserWordRepository.java index 7214b500..72ed881a 100644 --- a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/UserWordRepository.java +++ b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/UserWordRepository.java @@ -12,6 +12,7 @@ import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; import software.amazon.awssdk.services.dynamodb.DynamoDbClient; +import software.amazon.awssdk.enhanced.dynamodb.Expression; import software.amazon.awssdk.services.dynamodb.model.AttributeValue; import java.util.Base64; @@ -108,6 +109,72 @@ public UserWordPage findReviewDueWords(String userId, String todayDate, int limi return new UserWordPage(page.items(), nextCursor); } + /** + * 북마크된 단어만 조회 - FilterExpression 사용 (GSI 추가 없이 비용 최적화) + */ + public UserWordPage findBookmarkedWords(String userId, int limit, String cursor) { + QueryConditional queryConditional = QueryConditional + .sortBeginsWith(Key.builder() + .partitionValue("USER#" + userId) + .sortValue("WORD#") + .build()); + + Expression filterExpression = Expression.builder() + .expression("bookmarked = :bookmarked") + .putExpressionValue(":bookmarked", AttributeValue.builder().bool(true).build()) + .build(); + + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .filterExpression(filterExpression) + .limit(limit); + + if (cursor != null && !cursor.isEmpty()) { + Map exclusiveStartKey = decodeCursor(cursor); + if (exclusiveStartKey != null) { + requestBuilder.exclusiveStartKey(exclusiveStartKey); + } + } + + Page page = table.query(requestBuilder.build()).iterator().next(); + String nextCursor = encodeCursor(page.lastEvaluatedKey()); + + return new UserWordPage(page.items(), nextCursor); + } + + /** + * 틀린 적 있는 단어만 조회 - FilterExpression 사용 (GSI 추가 없이 비용 최적화) + */ + public UserWordPage findIncorrectWords(String userId, int limit, String cursor) { + QueryConditional queryConditional = QueryConditional + .sortBeginsWith(Key.builder() + .partitionValue("USER#" + userId) + .sortValue("WORD#") + .build()); + + Expression filterExpression = Expression.builder() + .expression("incorrectCount > :zero") + .putExpressionValue(":zero", AttributeValue.builder().n("0").build()) + .build(); + + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .filterExpression(filterExpression) + .limit(limit); + + if (cursor != null && !cursor.isEmpty()) { + Map exclusiveStartKey = decodeCursor(cursor); + if (exclusiveStartKey != null) { + requestBuilder.exclusiveStartKey(exclusiveStartKey); + } + } + + Page page = table.query(requestBuilder.build()).iterator().next(); + String nextCursor = encodeCursor(page.lastEvaluatedKey()); + + return new UserWordPage(page.items(), nextCursor); + } + /** * 상태별 단어 조회 - 페이지네이션 */ diff --git a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/WordRepository.java b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/WordRepository.java index 0acf0c01..715e40bf 100644 --- a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/WordRepository.java +++ b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/WordRepository.java @@ -8,12 +8,18 @@ import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; import software.amazon.awssdk.enhanced.dynamodb.Key; import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.model.BatchGetResultPageIterable; import software.amazon.awssdk.enhanced.dynamodb.model.Page; import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; +import software.amazon.awssdk.enhanced.dynamodb.model.ReadBatch; +import software.amazon.awssdk.enhanced.dynamodb.model.ScanEnhancedRequest; +import software.amazon.awssdk.enhanced.dynamodb.Expression; import software.amazon.awssdk.services.dynamodb.DynamoDbClient; import software.amazon.awssdk.services.dynamodb.model.AttributeValue; +import java.util.ArrayList; + import java.util.Base64; import java.util.HashMap; import java.util.List; @@ -53,6 +59,48 @@ public Optional findById(String wordId) { return Optional.ofNullable(word); } + /** + * 여러 단어를 한 번에 조회 (BatchGetItem) - N+1 문제 해결 + * DynamoDB BatchGetItem은 최대 100개까지 지원 + */ + public List findByIds(List wordIds) { + if (wordIds == null || wordIds.isEmpty()) { + return new ArrayList<>(); + } + + List results = new ArrayList<>(); + + // BatchGetItem은 최대 100개까지 지원하므로 분할 처리 + int batchSize = 100; + for (int i = 0; i < wordIds.size(); i += batchSize) { + List batch = wordIds.subList(i, Math.min(i + batchSize, wordIds.size())); + results.addAll(batchGetWords(batch)); + } + + return results; + } + + private List batchGetWords(List wordIds) { + ReadBatch.Builder readBatchBuilder = ReadBatch.builder(Word.class) + .mappedTableResource(table); + + for (String wordId : wordIds) { + Key key = Key.builder() + .partitionValue("WORD#" + wordId) + .sortValue("METADATA") + .build(); + readBatchBuilder.addGetItem(key); + } + + BatchGetResultPageIterable resultPages = enhancedClient.batchGetItem(r -> r.readBatches(readBatchBuilder.build())); + + List words = new ArrayList<>(); + resultPages.resultsForTable(table).forEach(words::add); + logger.info("BatchGetItem: requested={}, retrieved={}", wordIds.size(), words.size()); + + return words; + } + public void delete(String wordId) { Key key = Key.builder() .partitionValue("WORD#" + wordId) @@ -146,6 +194,52 @@ private Map decodeCursor(String cursor) { } } + /** + * 키워드로 단어 검색 (영어/한국어 contains) + * 참고: Scan은 비용이 높으므로 데이터가 많아지면 OpenSearch 도입 권장 + */ + public WordPage searchByKeyword(String keyword, int limit, String cursor) { + String lowerKeyword = keyword.toLowerCase(); + + // Filter: PK가 WORD#로 시작하고, english 또는 korean에 keyword 포함 + Expression filterExpression = Expression.builder() + .expression("begins_with(PK, :pk) AND (contains(#eng, :keyword) OR contains(korean, :keyword))") + .putExpressionName("#eng", "english") + .putExpressionValue(":pk", AttributeValue.builder().s("WORD#").build()) + .putExpressionValue(":keyword", AttributeValue.builder().s(lowerKeyword).build()) + .build(); + + ScanEnhancedRequest.Builder requestBuilder = ScanEnhancedRequest.builder() + .filterExpression(filterExpression) + .limit(limit * 3); // filter 적용되므로 넉넉히 + + if (cursor != null && !cursor.isEmpty()) { + Map exclusiveStartKey = decodeCursor(cursor); + if (exclusiveStartKey != null) { + requestBuilder.exclusiveStartKey(exclusiveStartKey); + } + } + + List results = new ArrayList<>(); + Map lastKey = null; + + for (Page page : table.scan(requestBuilder.build())) { + for (Word word : page.items()) { + // 대소문자 무시 검색 + if (word.getEnglish().toLowerCase().contains(lowerKeyword) || + word.getKorean().contains(keyword)) { + results.add(word); + if (results.size() >= limit) break; + } + } + lastKey = page.lastEvaluatedKey(); + if (results.size() >= limit) break; + } + + String nextCursor = results.size() >= limit ? encodeCursor(lastKey) : null; + return new WordPage(results, nextCursor); + } + public static class WordPage { private final List words; private final String nextCursor; diff --git a/vocabulary/seed-data/words.json b/vocabulary/seed-data/words.json new file mode 100644 index 00000000..fe07ba2a --- /dev/null +++ b/vocabulary/seed-data/words.json @@ -0,0 +1,73 @@ +{ + "words": [ + {"english": "apple", "korean": "사과", "example": "I eat an apple every day.", "level": "BEGINNER", "category": "DAILY"}, + {"english": "book", "korean": "책", "example": "She reads a book before bed.", "level": "BEGINNER", "category": "DAILY"}, + {"english": "cat", "korean": "고양이", "example": "The cat is sleeping on the sofa.", "level": "BEGINNER", "category": "DAILY"}, + {"english": "dog", "korean": "개", "example": "My dog loves to play fetch.", "level": "BEGINNER", "category": "DAILY"}, + {"english": "eat", "korean": "먹다", "example": "We eat dinner at 7 PM.", "level": "BEGINNER", "category": "DAILY"}, + {"english": "family", "korean": "가족", "example": "My family lives in Seoul.", "level": "BEGINNER", "category": "DAILY"}, + {"english": "good", "korean": "좋은", "example": "This is a good idea.", "level": "BEGINNER", "category": "DAILY"}, + {"english": "happy", "korean": "행복한", "example": "She looks very happy today.", "level": "BEGINNER", "category": "DAILY"}, + {"english": "house", "korean": "집", "example": "They bought a new house.", "level": "BEGINNER", "category": "DAILY"}, + {"english": "important", "korean": "중요한", "example": "This meeting is very important.", "level": "BEGINNER", "category": "DAILY"}, + {"english": "job", "korean": "직업", "example": "He got a new job last month.", "level": "BEGINNER", "category": "DAILY"}, + {"english": "kind", "korean": "친절한", "example": "She is always kind to everyone.", "level": "BEGINNER", "category": "DAILY"}, + {"english": "learn", "korean": "배우다", "example": "I want to learn English.", "level": "BEGINNER", "category": "DAILY"}, + {"english": "money", "korean": "돈", "example": "He saved a lot of money.", "level": "BEGINNER", "category": "DAILY"}, + {"english": "name", "korean": "이름", "example": "What is your name?", "level": "BEGINNER", "category": "DAILY"}, + {"english": "open", "korean": "열다", "example": "Please open the window.", "level": "BEGINNER", "category": "DAILY"}, + {"english": "people", "korean": "사람들", "example": "Many people came to the party.", "level": "BEGINNER", "category": "DAILY"}, + {"english": "question", "korean": "질문", "example": "Do you have any questions?", "level": "BEGINNER", "category": "DAILY"}, + {"english": "run", "korean": "달리다", "example": "He runs every morning.", "level": "BEGINNER", "category": "DAILY"}, + {"english": "school", "korean": "학교", "example": "The school starts at 9 AM.", "level": "BEGINNER", "category": "DAILY"}, + {"english": "time", "korean": "시간", "example": "What time is it now?", "level": "BEGINNER", "category": "DAILY"}, + {"english": "understand", "korean": "이해하다", "example": "I understand your concern.", "level": "BEGINNER", "category": "DAILY"}, + {"english": "very", "korean": "매우", "example": "This coffee is very hot.", "level": "BEGINNER", "category": "DAILY"}, + {"english": "water", "korean": "물", "example": "Please give me some water.", "level": "BEGINNER", "category": "DAILY"}, + {"english": "year", "korean": "년", "example": "I lived here for three years.", "level": "BEGINNER", "category": "DAILY"}, + {"english": "achieve", "korean": "달성하다", "example": "She achieved her goal.", "level": "INTERMEDIATE", "category": "BUSINESS"}, + {"english": "benefit", "korean": "이익, 혜택", "example": "What are the benefits of this plan?", "level": "INTERMEDIATE", "category": "BUSINESS"}, + {"english": "challenge", "korean": "도전", "example": "This project is a big challenge.", "level": "INTERMEDIATE", "category": "BUSINESS"}, + {"english": "decision", "korean": "결정", "example": "We need to make a decision today.", "level": "INTERMEDIATE", "category": "BUSINESS"}, + {"english": "efficient", "korean": "효율적인", "example": "This method is more efficient.", "level": "INTERMEDIATE", "category": "BUSINESS"}, + {"english": "flexible", "korean": "유연한", "example": "We need a flexible schedule.", "level": "INTERMEDIATE", "category": "BUSINESS"}, + {"english": "growth", "korean": "성장", "example": "The company showed rapid growth.", "level": "INTERMEDIATE", "category": "BUSINESS"}, + {"english": "implement", "korean": "실행하다", "example": "We will implement the new policy.", "level": "INTERMEDIATE", "category": "BUSINESS"}, + {"english": "invest", "korean": "투자하다", "example": "They decided to invest in technology.", "level": "INTERMEDIATE", "category": "BUSINESS"}, + {"english": "leadership", "korean": "리더십", "example": "Good leadership is essential.", "level": "INTERMEDIATE", "category": "BUSINESS"}, + {"english": "maintain", "korean": "유지하다", "example": "We need to maintain quality.", "level": "INTERMEDIATE", "category": "BUSINESS"}, + {"english": "negotiate", "korean": "협상하다", "example": "Let's negotiate the terms.", "level": "INTERMEDIATE", "category": "BUSINESS"}, + {"english": "opportunity", "korean": "기회", "example": "This is a great opportunity.", "level": "INTERMEDIATE", "category": "BUSINESS"}, + {"english": "performance", "korean": "성과", "example": "His performance was excellent.", "level": "INTERMEDIATE", "category": "BUSINESS"}, + {"english": "quality", "korean": "품질", "example": "Quality is our top priority.", "level": "INTERMEDIATE", "category": "BUSINESS"}, + {"english": "revenue", "korean": "수익", "example": "Revenue increased by 20%.", "level": "INTERMEDIATE", "category": "BUSINESS"}, + {"english": "strategy", "korean": "전략", "example": "We need a new marketing strategy.", "level": "INTERMEDIATE", "category": "BUSINESS"}, + {"english": "target", "korean": "목표", "example": "We reached our sales target.", "level": "INTERMEDIATE", "category": "BUSINESS"}, + {"english": "utilize", "korean": "활용하다", "example": "We should utilize all resources.", "level": "INTERMEDIATE", "category": "BUSINESS"}, + {"english": "valuable", "korean": "가치 있는", "example": "Your feedback is valuable.", "level": "INTERMEDIATE", "category": "BUSINESS"}, + {"english": "workflow", "korean": "워크플로우", "example": "Improve your workflow efficiency.", "level": "INTERMEDIATE", "category": "BUSINESS"}, + {"english": "yield", "korean": "산출하다", "example": "This investment will yield profit.", "level": "INTERMEDIATE", "category": "BUSINESS"}, + {"english": "zone", "korean": "구역", "example": "This is a no-parking zone.", "level": "INTERMEDIATE", "category": "BUSINESS"}, + {"english": "adjacent", "korean": "인접한", "example": "The two buildings are adjacent.", "level": "INTERMEDIATE", "category": "BUSINESS"}, + {"english": "abstract", "korean": "추상적인", "example": "The concept is too abstract.", "level": "ADVANCED", "category": "ACADEMIC"}, + {"english": "comprehensive", "korean": "포괄적인", "example": "We need a comprehensive analysis.", "level": "ADVANCED", "category": "ACADEMIC"}, + {"english": "contemporary", "korean": "현대의", "example": "Contemporary art is fascinating.", "level": "ADVANCED", "category": "ACADEMIC"}, + {"english": "differentiate", "korean": "구별하다", "example": "Can you differentiate between them?", "level": "ADVANCED", "category": "ACADEMIC"}, + {"english": "empirical", "korean": "경험적인", "example": "We need empirical evidence.", "level": "ADVANCED", "category": "ACADEMIC"}, + {"english": "fundamental", "korean": "근본적인", "example": "This is a fundamental problem.", "level": "ADVANCED", "category": "ACADEMIC"}, + {"english": "hypothesis", "korean": "가설", "example": "The hypothesis was proven correct.", "level": "ADVANCED", "category": "ACADEMIC"}, + {"english": "indigenous", "korean": "토착의", "example": "Indigenous plants are protected.", "level": "ADVANCED", "category": "ACADEMIC"}, + {"english": "jurisdiction", "korean": "관할권", "example": "This is outside our jurisdiction.", "level": "ADVANCED", "category": "ACADEMIC"}, + {"english": "methodology", "korean": "방법론", "example": "What methodology did you use?", "level": "ADVANCED", "category": "ACADEMIC"}, + {"english": "phenomenon", "korean": "현상", "example": "This is a natural phenomenon.", "level": "ADVANCED", "category": "ACADEMIC"}, + {"english": "paradigm", "korean": "패러다임", "example": "We need a paradigm shift.", "level": "ADVANCED", "category": "ACADEMIC"}, + {"english": "qualitative", "korean": "질적인", "example": "This is a qualitative study.", "level": "ADVANCED", "category": "ACADEMIC"}, + {"english": "quantitative", "korean": "양적인", "example": "We need quantitative data.", "level": "ADVANCED", "category": "ACADEMIC"}, + {"english": "synthesis", "korean": "합성, 종합", "example": "This requires a synthesis of ideas.", "level": "ADVANCED", "category": "ACADEMIC"}, + {"english": "theoretical", "korean": "이론적인", "example": "This is a theoretical framework.", "level": "ADVANCED", "category": "ACADEMIC"}, + {"english": "unprecedented", "korean": "전례 없는", "example": "This is an unprecedented situation.", "level": "ADVANCED", "category": "ACADEMIC"}, + {"english": "validity", "korean": "타당성", "example": "We must check the validity.", "level": "ADVANCED", "category": "ACADEMIC"}, + {"english": "variable", "korean": "변수", "example": "Control all variables carefully.", "level": "ADVANCED", "category": "ACADEMIC"}, + {"english": "ambiguous", "korean": "애매한", "example": "The statement is ambiguous.", "level": "ADVANCED", "category": "ACADEMIC"} + ] +} diff --git a/vocabulary/template.yaml b/vocabulary/template.yaml index 4970f7d8..dc5aa9af 100644 --- a/vocabulary/template.yaml +++ b/vocabulary/template.yaml @@ -37,31 +37,43 @@ Resources: CreateWord: Type: Api Properties: - RestApiId: !Ref VocabApi + RestApiId: !ImportValue group2-englishstudy-api-id Path: /vocab/words Method: POST + BatchCreateWords: + Type: Api + Properties: + RestApiId: !ImportValue group2-englishstudy-api-id + Path: /vocab/words/batch + Method: POST + SearchWords: + Type: Api + Properties: + RestApiId: !ImportValue group2-englishstudy-api-id + Path: /vocab/words/search + Method: GET GetWords: Type: Api Properties: - RestApiId: !Ref VocabApi + RestApiId: !ImportValue group2-englishstudy-api-id Path: /vocab/words Method: GET GetWord: Type: Api Properties: - RestApiId: !Ref VocabApi + RestApiId: !ImportValue group2-englishstudy-api-id Path: /vocab/words/{wordId} Method: GET UpdateWord: Type: Api Properties: - RestApiId: !Ref VocabApi + RestApiId: !ImportValue group2-englishstudy-api-id Path: /vocab/words/{wordId} Method: PUT DeleteWord: Type: Api Properties: - RestApiId: !Ref VocabApi + RestApiId: !ImportValue group2-englishstudy-api-id Path: /vocab/words/{wordId} Method: DELETE @@ -82,21 +94,27 @@ Resources: GetUserWords: Type: Api Properties: - RestApiId: !Ref VocabApi + RestApiId: !ImportValue group2-englishstudy-api-id Path: /vocab/users/{userId}/words Method: GET GetUserWord: Type: Api Properties: - RestApiId: !Ref VocabApi + RestApiId: !ImportValue group2-englishstudy-api-id Path: /vocab/users/{userId}/words/{wordId} Method: GET UpdateUserWord: Type: Api Properties: - RestApiId: !Ref VocabApi + RestApiId: !ImportValue group2-englishstudy-api-id Path: /vocab/users/{userId}/words/{wordId} Method: PUT + UpdateUserWordTag: + Type: Api + Properties: + RestApiId: !ImportValue group2-englishstudy-api-id + Path: /vocab/users/{userId}/words/{wordId}/tag + Method: PUT # 일일 학습 관리 함수 (55개 단어 할당) DailyStudyFunction: @@ -115,13 +133,13 @@ Resources: GetDailyWords: Type: Api Properties: - RestApiId: !Ref VocabApi + RestApiId: !ImportValue group2-englishstudy-api-id Path: /vocab/daily/{userId} Method: GET MarkWordLearned: Type: Api Properties: - RestApiId: !Ref VocabApi + RestApiId: !ImportValue group2-englishstudy-api-id Path: /vocab/daily/{userId}/words/{wordId}/learned Method: POST @@ -142,19 +160,19 @@ Resources: StartTest: Type: Api Properties: - RestApiId: !Ref VocabApi + RestApiId: !ImportValue group2-englishstudy-api-id Path: /vocab/test/{userId}/start Method: POST SubmitAnswer: Type: Api Properties: - RestApiId: !Ref VocabApi + RestApiId: !ImportValue group2-englishstudy-api-id Path: /vocab/test/{userId}/submit Method: POST GetTestResult: Type: Api Properties: - RestApiId: !Ref VocabApi + RestApiId: !ImportValue group2-englishstudy-api-id Path: /vocab/test/{userId}/results Method: GET @@ -175,15 +193,21 @@ Resources: GetStats: Type: Api Properties: - RestApiId: !Ref VocabApi + RestApiId: !ImportValue group2-englishstudy-api-id Path: /vocab/stats/{userId} Method: GET GetDailyStats: Type: Api Properties: - RestApiId: !Ref VocabApi + RestApiId: !ImportValue group2-englishstudy-api-id Path: /vocab/stats/{userId}/daily Method: GET + GetWeaknessAnalysis: + Type: Api + Properties: + RestApiId: !ImportValue group2-englishstudy-api-id + Path: /vocab/stats/{userId}/weakness + Method: GET # 음성 변환 함수 (Polly) VoiceFunction: @@ -210,23 +234,14 @@ Resources: TextToSpeech: Type: Api Properties: - RestApiId: !Ref VocabApi + RestApiId: !ImportValue group2-englishstudy-api-id Path: /vocab/voice/synthesize Method: POST ############################################# - # API Gateway + # API Gateway - chatting 스택에서 공유 API Gateway 참조 ############################################# - - VocabApi: - Type: AWS::Serverless::Api - Properties: - Name: group2-englishstudy-vocab-api - StageName: dev - Cors: - AllowMethods: "'GET,POST,PUT,DELETE,OPTIONS'" - AllowHeaders: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" - AllowOrigin: "'*'" + # VocabApi는 chatting 스택의 ChatApi를 ImportValue로 참조 ############################################# # DynamoDB @@ -282,8 +297,8 @@ Resources: Outputs: VocabApiUrl: - Description: API Gateway endpoint URL - Value: !Sub 'https://${VocabApi}.execute-api.${AWS::Region}.amazonaws.com/dev/' + Description: API Gateway endpoint URL (Shared with chatting) + Value: !ImportValue group2-englishstudy-api-url VocabTableName: Description: DynamoDB Table Name From 31e807a1c192d3ea269530462424768b8d9f437b Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Wed, 7 Jan 2026 16:52:47 +0900 Subject: [PATCH 012/528] =?UTF-8?q?feat:=20UserWord=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=20=EC=8B=9C=20Word=20=EC=A0=95=EB=B3=B4=20=ED=8F=AC=ED=95=A8?= =?UTF-8?q?=20=EB=B0=98=ED=99=98=20(#56)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - enrichWithWordInfo() 메서드 추가 - BatchGetItem으로 Word 정보 한 번에 조회 (N+1 방지) - 응답에 english, korean, level, category, example 포함 --- .../vocabulary/handler/UserWordHandler.java | 71 ++++++++++++++++++- 1 file changed, 70 insertions(+), 1 deletion(-) diff --git a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/UserWordHandler.java b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/UserWordHandler.java index c1486640..3ba1b86c 100644 --- a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/UserWordHandler.java +++ b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/UserWordHandler.java @@ -8,15 +8,20 @@ import com.google.gson.GsonBuilder; import com.mzc.secondproject.serverless.vocabulary.dto.ApiResponse; import com.mzc.secondproject.serverless.vocabulary.model.UserWord; +import com.mzc.secondproject.serverless.vocabulary.model.Word; import com.mzc.secondproject.serverless.vocabulary.repository.UserWordRepository; +import com.mzc.secondproject.serverless.vocabulary.repository.WordRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.time.Instant; import java.time.LocalDate; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.stream.Collectors; public class UserWordHandler implements RequestHandler { @@ -24,9 +29,11 @@ public class UserWordHandler implements RequestHandler userWords = userWordPage.getUserWords(); + List> enrichedUserWords = enrichWithWordInfo(userWords); + Map result = new HashMap<>(); - result.put("userWords", userWordPage.getUserWords()); + result.put("userWords", enrichedUserWords); result.put("nextCursor", userWordPage.getNextCursor()); result.put("hasMore", userWordPage.hasMore()); @@ -288,6 +299,64 @@ private void applySpacedRepetition(UserWord userWord, boolean isCorrect) { userWord.setNextReviewAt(nextReview.toString()); } + /** + * UserWord 목록에 Word 정보(english, korean, level 등)를 조인 + * BatchGetItem으로 한 번에 조회하여 N+1 문제 방지 + */ + private List> enrichWithWordInfo(List userWords) { + if (userWords == null || userWords.isEmpty()) { + return new ArrayList<>(); + } + + // wordId 목록 추출 + List wordIds = userWords.stream() + .map(UserWord::getWordId) + .collect(Collectors.toList()); + + // BatchGetItem으로 Word 정보 한 번에 조회 + List words = wordRepository.findByIds(wordIds); + + // wordId -> Word 맵 생성 + Map wordMap = words.stream() + .collect(Collectors.toMap(Word::getWordId, w -> w, (w1, w2) -> w1)); + + // UserWord + Word 정보 합치기 + List> enrichedList = new ArrayList<>(); + for (UserWord userWord : userWords) { + Map enriched = new HashMap<>(); + + // UserWord 정보 + enriched.put("wordId", userWord.getWordId()); + enriched.put("userId", userWord.getUserId()); + enriched.put("status", userWord.getStatus()); + enriched.put("correctCount", userWord.getCorrectCount()); + enriched.put("incorrectCount", userWord.getIncorrectCount()); + enriched.put("bookmarked", userWord.getBookmarked()); + enriched.put("favorite", userWord.getFavorite()); + enriched.put("difficulty", userWord.getDifficulty()); + enriched.put("nextReviewAt", userWord.getNextReviewAt()); + enriched.put("lastReviewedAt", userWord.getLastReviewedAt()); + enriched.put("repetitions", userWord.getRepetitions()); + enriched.put("interval", userWord.getInterval()); + + // Word 정보 추가 + Word word = wordMap.get(userWord.getWordId()); + if (word != null) { + enriched.put("english", word.getEnglish()); + enriched.put("korean", word.getKorean()); + enriched.put("level", word.getLevel()); + enriched.put("category", word.getCategory()); + enriched.put("example", word.getExample()); + enriched.put("maleVoiceKey", word.getMaleVoiceKey()); + enriched.put("femaleVoiceKey", word.getFemaleVoiceKey()); + } + + enrichedList.add(enriched); + } + + return enrichedList; + } + private APIGatewayProxyResponseEvent createResponse(int statusCode, Object body) { return new APIGatewayProxyResponseEvent() .withStatusCode(statusCode) From e0a442c4a24f1b5fd39fc9c9b89960db82487b21 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Wed, 7 Jan 2026 17:03:39 +0900 Subject: [PATCH 013/528] =?UTF-8?q?refactor:=20=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=20=ED=86=B5=ED=95=A9=20-=20chatting=20+=20vocabulary?= =?UTF-8?q?=20=E2=86=92=20ServerlessFunction=20(#58)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ServerlessFunction 단일 프로젝트로 통합 - chatting, vocabulary 코드를 하나의 Java 프로젝트에 병합 - build.gradle 통합 (모든 의존성 포함) - template.yaml을 루트로 이동 및 CodeUri 수정 - 기존 스택(group2-englishstudy-chatting) 유지 구조: ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/ ├── chatting/ │ ├── handler/ │ ├── model/ │ ├── repository/ │ └── service/ └── vocabulary/ ├── handler/ ├── model/ └── repository/ --- ServerlessFunction/build.gradle | 65 ++ ServerlessFunction/gradlew | 248 ++++++++ ServerlessFunction/gradlew.bat | 93 +++ .../serverless/chatting/dto/ApiResponse.java | 33 + .../chatting/handler/ChatAIHandler.java | 60 ++ .../chatting/handler/ChatMessageHandler.java | 152 +++++ .../chatting/handler/ChatRoomHandler.java | 320 ++++++++++ .../chatting/handler/ChatVoiceHandler.java | 117 ++++ .../chatting/model/ChatMessage.java | 75 +++ .../serverless/chatting/model/ChatRoom.java | 65 ++ .../repository/ChatMessageRepository.java | 187 ++++++ .../repository/ChatRoomRepository.java | 216 +++++++ .../chatting/service/BedrockService.java | 67 ++ .../chatting/service/ChatMessageService.java | 40 ++ .../chatting/service/PollyService.java | 167 +++++ .../vocabulary/dto/ApiResponse.java | 33 + .../vocabulary/handler/DailyStudyHandler.java | 253 ++++++++ .../vocabulary/handler/StatisticsHandler.java | 160 +++++ .../vocabulary/handler/StatsHandler.java | 340 ++++++++++ .../vocabulary/handler/TestHandler.java | 377 +++++++++++ .../vocabulary/handler/UserWordHandler.java | 371 +++++++++++ .../vocabulary/handler/VoiceHandler.java | 123 ++++ .../vocabulary/handler/WordHandler.java | 337 ++++++++++ .../vocabulary/model/DailyStudy.java | 74 +++ .../vocabulary/model/TestResult.java | 74 +++ .../serverless/vocabulary/model/UserWord.java | 93 +++ .../serverless/vocabulary/model/Word.java | 83 +++ .../repository/DailyStudyRepository.java | 161 +++++ .../repository/TestResultRepository.java | 137 ++++ .../repository/UserWordRepository.java | 260 ++++++++ .../vocabulary/repository/WordRepository.java | 264 ++++++++ .../vocabulary/service/PollyService.java | 160 +++++ .../src/main/resources/log4j2.xml | 17 + template.yaml | 599 ++++++++++++++++++ 34 files changed, 5821 insertions(+) create mode 100644 ServerlessFunction/build.gradle create mode 100755 ServerlessFunction/gradlew create mode 100644 ServerlessFunction/gradlew.bat create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/dto/ApiResponse.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatAIHandler.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatMessageHandler.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatRoomHandler.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatVoiceHandler.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/model/ChatMessage.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/model/ChatRoom.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/repository/ChatMessageRepository.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/repository/ChatRoomRepository.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/service/BedrockService.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/service/ChatMessageService.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/service/PollyService.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/dto/ApiResponse.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/DailyStudyHandler.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/StatisticsHandler.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/StatsHandler.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/TestHandler.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/UserWordHandler.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/VoiceHandler.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/WordHandler.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/DailyStudy.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/TestResult.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/UserWord.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/Word.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/DailyStudyRepository.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/TestResultRepository.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/UserWordRepository.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/WordRepository.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/service/PollyService.java create mode 100644 ServerlessFunction/src/main/resources/log4j2.xml create mode 100644 template.yaml diff --git a/ServerlessFunction/build.gradle b/ServerlessFunction/build.gradle new file mode 100644 index 00000000..0307538c --- /dev/null +++ b/ServerlessFunction/build.gradle @@ -0,0 +1,65 @@ +plugins { + id 'java' +} + +group = 'com.mzc.secondproject.serverless' +version = '1.0.0' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +repositories { + mavenCentral() +} + +dependencies { + // AWS Lambda Core + implementation 'com.amazonaws:aws-lambda-java-core:1.2.3' + implementation 'com.amazonaws:aws-lambda-java-events:3.11.4' + + // AWS SDK v2 + implementation platform('software.amazon.awssdk:bom:2.24.0') + implementation 'software.amazon.awssdk:dynamodb' + implementation 'software.amazon.awssdk:dynamodb-enhanced' + implementation 'software.amazon.awssdk:s3' + implementation 'software.amazon.awssdk:polly' + implementation 'software.amazon.awssdk:sns' + implementation 'software.amazon.awssdk:bedrockruntime' + + // JSON Processing + implementation 'com.google.code.gson:gson:2.10.1' + + // Password Hashing + implementation 'org.mindrot:jbcrypt:0.4' + + // Logging + implementation 'com.amazonaws:aws-lambda-java-log4j2:1.6.0' + implementation 'org.apache.logging.log4j:log4j-api:2.22.1' + implementation 'org.apache.logging.log4j:log4j-core:2.22.1' + implementation 'org.apache.logging.log4j:log4j-slf4j2-impl:2.22.1' + + // Lombok + compileOnly 'org.projectlombok:lombok:1.18.30' + annotationProcessor 'org.projectlombok:lombok:1.18.30' + + // Testing + testImplementation 'org.junit.jupiter:junit-jupiter:5.10.1' + testImplementation 'org.mockito:mockito-core:5.8.0' +} + +test { + useJUnitPlatform() +} + +task buildZip(type: Zip) { + from compileJava + from processResources + into('lib') { + from configurations.runtimeClasspath + } +} + +build.dependsOn buildZip diff --git a/ServerlessFunction/gradlew b/ServerlessFunction/gradlew new file mode 100755 index 00000000..adff685a --- /dev/null +++ b/ServerlessFunction/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/ServerlessFunction/gradlew.bat b/ServerlessFunction/gradlew.bat new file mode 100644 index 00000000..c4bdd3ab --- /dev/null +++ b/ServerlessFunction/gradlew.bat @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/dto/ApiResponse.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/dto/ApiResponse.java new file mode 100644 index 00000000..2d34f585 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/dto/ApiResponse.java @@ -0,0 +1,33 @@ +package com.mzc.secondproject.serverless.chatting.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ApiResponse { + + private boolean success; + private String message; + private T data; + private String error; + + public static ApiResponse success(String message, T data) { + return ApiResponse.builder() + .success(true) + .message(message) + .data(data) + .build(); + } + + public static ApiResponse error(String errorMessage) { + return ApiResponse.builder() + .success(false) + .error(errorMessage) + .build(); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatAIHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatAIHandler.java new file mode 100644 index 00000000..8396b285 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatAIHandler.java @@ -0,0 +1,60 @@ +package com.mzc.secondproject.serverless.chatting.handler; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.mzc.secondproject.serverless.chatting.dto.ApiResponse; +import com.mzc.secondproject.serverless.chatting.service.BedrockService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Map; + +public class ChatAIHandler implements RequestHandler { + + private static final Logger logger = LoggerFactory.getLogger(ChatAIHandler.class); + private static final Gson gson = new GsonBuilder().setPrettyPrinting().create(); + + private final BedrockService bedrockService; + + public ChatAIHandler() { + this.bedrockService = new BedrockService(); + } + + @Override + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { + logger.info("Received AI generation request"); + + try { + if (!"POST".equals(request.getHttpMethod())) { + return createResponse(405, ApiResponse.error("Method not allowed")); + } + + String body = request.getBody(); + // TODO: Parse request and generate AI response using Bedrock + + String aiResponse = bedrockService.generateResponse("Hello, how can I help you?"); + + return createResponse(200, ApiResponse.success("AI response generated", Map.of("response", aiResponse))); + + } catch (Exception e) { + logger.error("Error generating AI response", e); + return createResponse(500, ApiResponse.error("Internal server error: " + e.getMessage())); + } + } + + private APIGatewayProxyResponseEvent createResponse(int statusCode, Object body) { + return new APIGatewayProxyResponseEvent() + .withStatusCode(statusCode) + .withHeaders(Map.of( + "Content-Type", "application/json", + "Access-Control-Allow-Origin", "*", + "Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS", + "Access-Control-Allow-Headers", "Content-Type,Authorization" + )) + .withBody(gson.toJson(body)); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatMessageHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatMessageHandler.java new file mode 100644 index 00000000..fb700dae --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatMessageHandler.java @@ -0,0 +1,152 @@ +package com.mzc.secondproject.serverless.chatting.handler; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.mzc.secondproject.serverless.chatting.dto.ApiResponse; +import com.mzc.secondproject.serverless.chatting.model.ChatMessage; +import com.mzc.secondproject.serverless.chatting.model.ChatRoom; +import com.mzc.secondproject.serverless.chatting.repository.ChatMessageRepository; +import com.mzc.secondproject.serverless.chatting.repository.ChatRoomRepository; +import com.mzc.secondproject.serverless.chatting.service.ChatMessageService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +public class ChatMessageHandler implements RequestHandler { + + private static final Logger logger = LoggerFactory.getLogger(ChatMessageHandler.class); + private static final Gson gson = new GsonBuilder().setPrettyPrinting().create(); + + private final ChatMessageService chatMessageService; + private final ChatRoomRepository chatRoomRepository; + + public ChatMessageHandler() { + this.chatMessageService = new ChatMessageService(); + this.chatRoomRepository = new ChatRoomRepository(); + } + + @Override + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { + logger.info("Received request: {} {}", request.getHttpMethod(), request.getPath()); + + try { + return switch (request.getHttpMethod()) { + case "POST" -> handlePost(request); + case "GET" -> handleGet(request); + default -> createResponse(405, ApiResponse.error("Method not allowed")); + }; + } catch (Exception e) { + logger.error("Error processing request", e); + return createResponse(500, ApiResponse.error("Internal server error: " + e.getMessage())); + } + } + + private APIGatewayProxyResponseEvent handlePost(APIGatewayProxyRequestEvent request) { + Map pathParams = request.getPathParameters(); + String roomId = pathParams != null ? pathParams.get("roomId") : null; + + if (roomId == null) { + return createResponse(400, ApiResponse.error("roomId is required")); + } + + String body = request.getBody(); + Map requestBody = gson.fromJson(body, Map.class); + + String userId = (String) requestBody.get("userId"); + String content = (String) requestBody.get("content"); + String messageType = (String) requestBody.getOrDefault("messageType", "TEXT"); + + if (userId == null || content == null) { + return createResponse(400, ApiResponse.error("userId and content are required")); + } + + String messageId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + + ChatMessage message = ChatMessage.builder() + .pk("ROOM#" + roomId) + .sk("MSG#" + now + "#" + messageId) + .gsi1pk("USER#" + userId) + .gsi1sk("MSG#" + now) + .gsi2pk("MSG#" + messageId) // GSI2: messageId로 직접 조회용 + .gsi2sk("ROOM#" + roomId) + .messageId(messageId) + .roomId(roomId) + .userId(userId) + .content(content) + .messageType(messageType) + .createdAt(now) + .build(); + + ChatMessage savedMessage = chatMessageService.saveMessage(message); + + // 채팅방 lastMessageAt 업데이트 (UpdateExpression으로 1회 호출) + chatRoomRepository.updateLastMessageAt(roomId, now); + + logger.info("Message sent: {} in room: {}", messageId, roomId); + return createResponse(201, ApiResponse.success("Message sent", savedMessage)); + } + + private APIGatewayProxyResponseEvent handleGet(APIGatewayProxyRequestEvent request) { + Map pathParams = request.getPathParameters(); + Map queryParams = request.getQueryStringParameters(); + + String roomId = pathParams != null ? pathParams.get("roomId") : null; + String messageId = pathParams != null ? pathParams.get("messageId") : null; + + if (roomId == null) { + return createResponse(400, ApiResponse.error("roomId is required")); + } + + if (messageId != null) { + // Get single message + Optional message = chatMessageService.getMessage(roomId, messageId); + if (message.isEmpty()) { + return createResponse(404, ApiResponse.error("Message not found")); + } + return createResponse(200, ApiResponse.success("Message retrieved", message.get())); + } + + // 페이지네이션 파라미터 + int limit = 20; // 기본값 + String cursor = null; + + if (queryParams != null) { + if (queryParams.get("limit") != null) { + limit = Math.min(Integer.parseInt(queryParams.get("limit")), 50); // 최대 50 + } + cursor = queryParams.get("cursor"); + } + + // 메시지 목록 조회 (최신순, 페이지네이션) + ChatMessageRepository.MessagePage messagePage = chatMessageService.getMessagesByRoomWithPagination(roomId, limit, cursor); + + Map result = new HashMap<>(); + result.put("messages", messagePage.getMessages()); + result.put("nextCursor", messagePage.getNextCursor()); + result.put("hasMore", messagePage.hasMore()); + + return createResponse(200, ApiResponse.success("Messages retrieved", result)); + } + + private APIGatewayProxyResponseEvent createResponse(int statusCode, Object body) { + return new APIGatewayProxyResponseEvent() + .withStatusCode(statusCode) + .withHeaders(Map.of( + "Content-Type", "application/json", + "Access-Control-Allow-Origin", "*", + "Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS", + "Access-Control-Allow-Headers", "Content-Type,Authorization" + )) + .withBody(gson.toJson(body)); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatRoomHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatRoomHandler.java new file mode 100644 index 00000000..d1414843 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatRoomHandler.java @@ -0,0 +1,320 @@ +package com.mzc.secondproject.serverless.chatting.handler; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.mzc.secondproject.serverless.chatting.dto.ApiResponse; +import com.mzc.secondproject.serverless.chatting.model.ChatRoom; +import com.mzc.secondproject.serverless.chatting.repository.ChatRoomRepository; +import org.mindrot.jbcrypt.BCrypt; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +public class ChatRoomHandler implements RequestHandler { + + private static final Logger logger = LoggerFactory.getLogger(ChatRoomHandler.class); + private static final Gson gson = new GsonBuilder().setPrettyPrinting().create(); + + private final ChatRoomRepository roomRepository; + + public ChatRoomHandler() { + this.roomRepository = new ChatRoomRepository(); + } + + @Override + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { + String httpMethod = request.getHttpMethod(); + String path = request.getPath(); + + logger.info("Received request: {} {}", httpMethod, path); + + try { + // POST /chat/rooms - 방 생성 + if ("POST".equals(httpMethod) && path.endsWith("/rooms")) { + return createRoom(request); + } + + // GET /chat/rooms - 방 목록 조회 + if ("GET".equals(httpMethod) && path.endsWith("/rooms")) { + return getRooms(request); + } + + // GET /chat/rooms/{roomId} - 방 상세 조회 + if ("GET".equals(httpMethod) && path.contains("/rooms/") && !path.contains("/join")) { + return getRoom(request); + } + + // POST /chat/rooms/{roomId}/join - 방 입장 + if ("POST".equals(httpMethod) && path.endsWith("/join")) { + return joinRoom(request); + } + + // POST /chat/rooms/{roomId}/leave - 방 퇴장 + if ("POST".equals(httpMethod) && path.endsWith("/leave")) { + return leaveRoom(request); + } + + // DELETE /chat/rooms/{roomId} - 방 삭제 + if ("DELETE".equals(httpMethod) && path.contains("/rooms/")) { + return deleteRoom(request); + } + + return createResponse(404, ApiResponse.error("Not found")); + + } catch (Exception e) { + logger.error("Error handling request", e); + return createResponse(500, ApiResponse.error("Internal server error: " + e.getMessage())); + } + } + + private APIGatewayProxyResponseEvent createRoom(APIGatewayProxyRequestEvent request) { + String body = request.getBody(); + Map requestBody = gson.fromJson(body, Map.class); + + String name = (String) requestBody.get("name"); + String description = (String) requestBody.get("description"); + String level = (String) requestBody.getOrDefault("level", "beginner"); + Integer maxMembers = ((Double) requestBody.getOrDefault("maxMembers", 6.0)).intValue(); + Boolean isPrivate = (Boolean) requestBody.getOrDefault("isPrivate", false); + String password = (String) requestBody.get("password"); + String createdBy = (String) requestBody.get("createdBy"); + + if (name == null || name.isEmpty()) { + return createResponse(400, ApiResponse.error("name is required")); + } + + String roomId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + + ChatRoom room = ChatRoom.builder() + .pk("ROOM#" + roomId) + .sk("METADATA") + .gsi1pk("ROOMS") + .gsi1sk(level + "#" + now) + .roomId(roomId) + .name(name) + .description(description) + .level(level) + .currentMembers(1) // 방장 포함 + .maxMembers(maxMembers) + .isPrivate(isPrivate) + .password(isPrivate && password != null ? BCrypt.hashpw(password, BCrypt.gensalt()) : null) + .createdBy(createdBy) + .createdAt(now) + .lastMessageAt(now) + .memberIds(new ArrayList<>(List.of(createdBy))) + .build(); + + roomRepository.save(room); + + // 비밀번호는 응답에서 제외 + room.setPassword(null); + + logger.info("Created room: {}", roomId); + return createResponse(201, ApiResponse.success("Room created", room)); + } + + private APIGatewayProxyResponseEvent getRooms(APIGatewayProxyRequestEvent request) { + Map queryParams = request.getQueryStringParameters(); + + String level = queryParams != null ? queryParams.get("level") : null; + String userId = queryParams != null ? queryParams.get("userId") : null; + String joined = queryParams != null ? queryParams.get("joined") : null; + String cursor = queryParams != null ? queryParams.get("cursor") : null; + + int limit = 10; // 기본값 + if (queryParams != null && queryParams.get("limit") != null) { + limit = Math.min(Integer.parseInt(queryParams.get("limit")), 20); // 최대 20 + } + + ChatRoomRepository.RoomPage roomPage; + if (level != null && !level.isEmpty()) { + roomPage = roomRepository.findByLevelWithPagination(level, limit, cursor); + } else { + roomPage = roomRepository.findAllWithPagination(limit, cursor); + } + + List rooms = roomPage.getRooms(); + + // "참여중" 필터 - userId가 memberIds에 포함된 방만 + if ("true".equals(joined) && userId != null) { + rooms = rooms.stream() + .filter(room -> room.getMemberIds() != null && room.getMemberIds().contains(userId)) + .toList(); + } + + // 비밀번호 제외 + rooms.forEach(room -> room.setPassword(null)); + + Map result = new HashMap<>(); + result.put("rooms", rooms); + result.put("nextCursor", roomPage.getNextCursor()); + result.put("hasMore", roomPage.hasMore()); + + return createResponse(200, ApiResponse.success("Rooms retrieved", result)); + } + + private APIGatewayProxyResponseEvent getRoom(APIGatewayProxyRequestEvent request) { + Map pathParams = request.getPathParameters(); + String roomId = pathParams != null ? pathParams.get("roomId") : null; + + if (roomId == null) { + return createResponse(400, ApiResponse.error("roomId is required")); + } + + Optional optRoom = roomRepository.findById(roomId); + if (optRoom.isEmpty()) { + return createResponse(404, ApiResponse.error("Room not found")); + } + + ChatRoom room = optRoom.get(); + room.setPassword(null); + + return createResponse(200, ApiResponse.success("Room retrieved", room)); + } + + private APIGatewayProxyResponseEvent joinRoom(APIGatewayProxyRequestEvent request) { + Map pathParams = request.getPathParameters(); + String roomId = pathParams != null ? pathParams.get("roomId") : null; + + String body = request.getBody(); + Map requestBody = gson.fromJson(body, Map.class); + String userId = requestBody.get("userId"); + String password = requestBody.get("password"); + + if (roomId == null || userId == null) { + return createResponse(400, ApiResponse.error("roomId and userId are required")); + } + + Optional optRoom = roomRepository.findById(roomId); + if (optRoom.isEmpty()) { + return createResponse(404, ApiResponse.error("Room not found")); + } + + ChatRoom room = optRoom.get(); + + // 비밀번호 확인 (BCrypt 해시 검증) + if (room.getIsPrivate()) { + if (password == null || room.getPassword() == null || !BCrypt.checkpw(password, room.getPassword())) { + return createResponse(403, ApiResponse.error("Invalid password")); + } + } + + // 인원 확인 + if (room.getCurrentMembers() >= room.getMaxMembers()) { + return createResponse(400, ApiResponse.error("Room is full")); + } + + // 이미 참여 중인지 확인 + if (room.getMemberIds() != null && room.getMemberIds().contains(userId)) { + room.setPassword(null); + return createResponse(200, ApiResponse.success("Already joined", room)); + } + + // 멤버 추가 + if (room.getMemberIds() == null) { + room.setMemberIds(new ArrayList<>()); + } + room.getMemberIds().add(userId); + room.setCurrentMembers(room.getCurrentMembers() + 1); + + roomRepository.save(room); + room.setPassword(null); + + logger.info("User {} joined room {}", userId, roomId); + return createResponse(200, ApiResponse.success("Joined room", room)); + } + + private APIGatewayProxyResponseEvent leaveRoom(APIGatewayProxyRequestEvent request) { + Map pathParams = request.getPathParameters(); + String roomId = pathParams != null ? pathParams.get("roomId") : null; + + String body = request.getBody(); + Map requestBody = gson.fromJson(body, Map.class); + String userId = requestBody.get("userId"); + + if (roomId == null || userId == null) { + return createResponse(400, ApiResponse.error("roomId and userId are required")); + } + + Optional optRoom = roomRepository.findById(roomId); + if (optRoom.isEmpty()) { + return createResponse(404, ApiResponse.error("Room not found")); + } + + ChatRoom room = optRoom.get(); + + // 멤버에서 제거 + if (room.getMemberIds() != null) { + room.getMemberIds().remove(userId); + room.setCurrentMembers(Math.max(0, room.getCurrentMembers() - 1)); + } + + // 방장이 나가거나 인원이 0이면 방 삭제 + if (userId.equals(room.getCreatedBy()) || room.getCurrentMembers() <= 0) { + roomRepository.delete(roomId); + return createResponse(200, ApiResponse.success("Room deleted", null)); + } + + roomRepository.save(room); + room.setPassword(null); + + logger.info("User {} left room {}", userId, roomId); + return createResponse(200, ApiResponse.success("Left room", room)); + } + + private APIGatewayProxyResponseEvent deleteRoom(APIGatewayProxyRequestEvent request) { + Map pathParams = request.getPathParameters(); + Map queryParams = request.getQueryStringParameters(); + + String roomId = pathParams != null ? pathParams.get("roomId") : null; + String userId = queryParams != null ? queryParams.get("userId") : null; + + if (roomId == null) { + return createResponse(400, ApiResponse.error("roomId is required")); + } + + if (userId == null) { + return createResponse(400, ApiResponse.error("userId is required")); + } + + // 방장 확인 + Optional optRoom = roomRepository.findById(roomId); + if (optRoom.isEmpty()) { + return createResponse(404, ApiResponse.error("Room not found")); + } + + ChatRoom room = optRoom.get(); + if (!userId.equals(room.getCreatedBy())) { + return createResponse(403, ApiResponse.error("Only the room owner can delete the room")); + } + + roomRepository.delete(roomId); + logger.info("Deleted room: {} by owner: {}", roomId, userId); + + return createResponse(200, ApiResponse.success("Room deleted", null)); + } + + private APIGatewayProxyResponseEvent createResponse(int statusCode, Object body) { + return new APIGatewayProxyResponseEvent() + .withStatusCode(statusCode) + .withHeaders(Map.of( + "Content-Type", "application/json", + "Access-Control-Allow-Origin", "*", + "Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS", + "Access-Control-Allow-Headers", "Content-Type,Authorization" + )) + .withBody(gson.toJson(body)); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatVoiceHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatVoiceHandler.java new file mode 100644 index 00000000..90f711cd --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatVoiceHandler.java @@ -0,0 +1,117 @@ +package com.mzc.secondproject.serverless.chatting.handler; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.mzc.secondproject.serverless.chatting.dto.ApiResponse; +import com.mzc.secondproject.serverless.chatting.model.ChatMessage; +import com.mzc.secondproject.serverless.chatting.repository.ChatMessageRepository; +import com.mzc.secondproject.serverless.chatting.service.PollyService; +import com.mzc.secondproject.serverless.chatting.service.PollyService.VoiceSynthesisResult; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Map; +import java.util.Optional; + +public class ChatVoiceHandler implements RequestHandler { + + private static final Logger logger = LoggerFactory.getLogger(ChatVoiceHandler.class); + private static final Gson gson = new GsonBuilder().setPrettyPrinting().create(); + + private final PollyService pollyService; + private final ChatMessageRepository messageRepository; + + public ChatVoiceHandler() { + this.pollyService = new PollyService(); + this.messageRepository = new ChatMessageRepository(); + } + + @Override + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { + logger.info("Received voice synthesis request"); + + try { + if (!"POST".equals(request.getHttpMethod())) { + return createResponse(405, ApiResponse.error("Method not allowed")); + } + + String body = request.getBody(); + Map requestBody = gson.fromJson(body, Map.class); + String messageId = requestBody.get("messageId"); + String roomId = requestBody.get("roomId"); + String voice = requestBody.getOrDefault("voice", "FEMALE"); + + if (messageId == null || messageId.isEmpty()) { + return createResponse(400, ApiResponse.error("messageId is required")); + } + if (roomId == null || roomId.isEmpty()) { + return createResponse(400, ApiResponse.error("roomId is required")); + } + + // 메시지 조회 + Optional messageOpt = messageRepository.findByRoomIdAndMessageId(roomId, messageId); + if (messageOpt.isEmpty()) { + return createResponse(404, ApiResponse.error("Message not found")); + } + + ChatMessage message = messageOpt.get(); + boolean isMale = "MALE".equalsIgnoreCase(voice); + + // 캐시된 음성 키 확인 + String cachedKey = isMale ? message.getMaleVoiceKey() : message.getFemaleVoiceKey(); + + String audioUrl; + boolean cached; + + if (cachedKey != null && !cachedKey.isEmpty()) { + // 캐시 히트: DynamoDB에 키가 있으면 S3에서 URL 생성 + logger.info("DB cache hit for message: {}, voice: {}", messageId, voice); + audioUrl = pollyService.getPresignedUrl(cachedKey); + cached = true; + } else { + // 캐시 미스: Polly 변환 → S3 저장 → DynamoDB 업데이트 + VoiceSynthesisResult result = pollyService.synthesizeSpeechForMessage( + messageId, message.getContent(), voice); + + // DynamoDB에 S3 키 저장 + if (isMale) { + message.setMaleVoiceKey(result.getS3Key()); + } else { + message.setFemaleVoiceKey(result.getS3Key()); + } + messageRepository.save(message); + + audioUrl = result.getAudioUrl(); + cached = result.isCached(); + } + + return createResponse(200, ApiResponse.success( + cached ? "Speech retrieved from cache" : "Speech synthesized", + Map.of( + "audioUrl", audioUrl, + "cached", cached + ) + )); + + } catch (Exception e) { + logger.error("Error synthesizing speech", e); + return createResponse(500, ApiResponse.error("Internal server error: " + e.getMessage())); + } + } + + private APIGatewayProxyResponseEvent createResponse(int statusCode, Object body) { + return new APIGatewayProxyResponseEvent() + .withStatusCode(statusCode) + .withHeaders(Map.of( + "Content-Type", "application/json", + "Access-Control-Allow-Origin", "*", + "Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS", + "Access-Control-Allow-Headers", "Content-Type,Authorization" + )) + .withBody(gson.toJson(body)); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/model/ChatMessage.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/model/ChatMessage.java new file mode 100644 index 00000000..d0a2726e --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/model/ChatMessage.java @@ -0,0 +1,75 @@ +package com.mzc.secondproject.serverless.chatting.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbAttribute; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondaryPartitionKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondarySortKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSortKey; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DynamoDbBean +public class ChatMessage { + + private String pk; // ROOM#{roomId} + private String sk; // MSG#{timestamp}#{messageId} + private String gsi1pk; // USER#{userId} + private String gsi1sk; // MSG#{timestamp} + private String gsi2pk; // MSG#{messageId} - messageId로 직접 조회용 + private String gsi2sk; // ROOM#{roomId} + + private String messageId; + private String roomId; + private String userId; + private String content; + private String messageType; // TEXT, IMAGE, VOICE, AI_RESPONSE + private String createdAt; + private Long ttl; + + // 음성 캐시용 S3 키 (voice/{messageId}_{voice}.mp3) + private String maleVoiceKey; + private String femaleVoiceKey; + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { + return pk; + } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { + return sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1PK") + public String getGsi1pk() { + return gsi1pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1SK") + public String getGsi1sk() { + return gsi1sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI2") + @DynamoDbAttribute("GSI2PK") + public String getGsi2pk() { + return gsi2pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI2") + @DynamoDbAttribute("GSI2SK") + public String getGsi2sk() { + return gsi2sk; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/model/ChatRoom.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/model/ChatRoom.java new file mode 100644 index 00000000..cfa20cc9 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/model/ChatRoom.java @@ -0,0 +1,65 @@ +package com.mzc.secondproject.serverless.chatting.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbAttribute; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondaryPartitionKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondarySortKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSortKey; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DynamoDbBean +public class ChatRoom { + + private String pk; // ROOM#{roomId} + private String sk; // METADATA + private String gsi1pk; // ROOMS + private String gsi1sk; // {level}#{createdAt} + + private String roomId; + private String name; + private String description; + private String level; // beginner, intermediate, advanced + private Integer currentMembers; + private Integer maxMembers; + private Boolean isPrivate; + private String password; // 비밀방 비밀번호 (해시) + private String createdBy; // 방장 userId + private String createdAt; + private String lastMessageAt; + private List memberIds; // 참여 멤버 목록 + private Long ttl; + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { + return pk; + } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { + return sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1PK") + public String getGsi1pk() { + return gsi1pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1SK") + public String getGsi1sk() { + return gsi1sk; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/repository/ChatMessageRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/repository/ChatMessageRepository.java new file mode 100644 index 00000000..9bd2ba31 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/repository/ChatMessageRepository.java @@ -0,0 +1,187 @@ +package com.mzc.secondproject.serverless.chatting.repository; + +import com.mzc.secondproject.serverless.chatting.model.ChatMessage; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; +import software.amazon.awssdk.enhanced.dynamodb.Key; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.model.Page; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; +import software.amazon.awssdk.services.dynamodb.DynamoDbClient; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; + +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +public class ChatMessageRepository { + + private static final Logger logger = LoggerFactory.getLogger(ChatMessageRepository.class); + + // Singleton 패턴으로 Cold Start 최적화 + private static final DynamoDbClient dynamoDbClient = DynamoDbClient.builder().build(); + private static final DynamoDbEnhancedClient enhancedClient = DynamoDbEnhancedClient.builder() + .dynamoDbClient(dynamoDbClient) + .build(); + private static final String tableName = System.getenv("CHAT_TABLE_NAME"); + + private final DynamoDbTable table; + + public ChatMessageRepository() { + this.table = enhancedClient.table(tableName, TableSchema.fromBean(ChatMessage.class)); + } + + public ChatMessage save(ChatMessage message) { + logger.info("Saving message to DynamoDB: {}", message.getMessageId()); + table.putItem(message); + return message; + } + + public Optional findByRoomIdAndMessageId(String roomId, String messageId) { + // GSI2를 사용하여 messageId로 직접 조회 (풀스캔 방지) + QueryConditional queryConditional = QueryConditional + .keyEqualTo(Key.builder() + .partitionValue("MSG#" + messageId) + .sortValue("ROOM#" + roomId) + .build()); + + return table.index("GSI2") + .query(queryConditional) + .stream() + .flatMap(page -> page.items().stream()) + .findFirst(); + } + + /** + * 채팅방 메시지 조회 - 최신순, 페이지네이션 지원 + * @param roomId 채팅방 ID + * @param limit 조회 개수 (기본 20) + * @param cursor Base64 인코딩된 커서 (무한스크롤용) + * @return 메시지 목록과 다음 페이지 커서 + */ + public MessagePage findByRoomIdWithPagination(String roomId, int limit, String cursor) { + QueryConditional queryConditional = QueryConditional + .sortBeginsWith(Key.builder() + .partitionValue("ROOM#" + roomId) + .sortValue("MSG#") + .build()); + + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .scanIndexForward(false) // 최신순 (역순) + .limit(limit); + + // 커서 기반 페이지네이션 (Base64 디코딩) + if (cursor != null && !cursor.isEmpty()) { + Map exclusiveStartKey = decodeCursor(cursor); + if (exclusiveStartKey != null) { + requestBuilder.exclusiveStartKey(exclusiveStartKey); + } + } + + Page page = table.query(requestBuilder.build()).iterator().next(); + List messages = page.items(); + + // 다음 페이지 커서 (Base64 인코딩) + String nextCursor = encodeCursor(page.lastEvaluatedKey()); + + return new MessagePage(messages, nextCursor); + } + + /** + * 커서 인코딩 (lastEvaluatedKey -> Base64) + */ + private String encodeCursor(Map lastEvaluatedKey) { + if (lastEvaluatedKey == null || lastEvaluatedKey.isEmpty()) { + return null; + } + + StringBuilder sb = new StringBuilder(); + for (Map.Entry entry : lastEvaluatedKey.entrySet()) { + if (sb.length() > 0) sb.append("|"); + sb.append(entry.getKey()).append("=").append(entry.getValue().s()); + } + + return Base64.getUrlEncoder().encodeToString(sb.toString().getBytes()); + } + + /** + * 커서 디코딩 (Base64 -> exclusiveStartKey) + */ + private Map decodeCursor(String cursor) { + try { + String decoded = new String(Base64.getUrlDecoder().decode(cursor)); + Map result = new HashMap<>(); + + for (String pair : decoded.split("\\|")) { + String[] kv = pair.split("=", 2); + if (kv.length == 2) { + result.put(kv[0], AttributeValue.builder().s(kv[1]).build()); + } + } + + return result.isEmpty() ? null : result; + } catch (Exception e) { + logger.error("Failed to decode cursor: {}", cursor, e); + return null; + } + } + + /** + * 사용자별 메시지 조회 - 페이지네이션 지원 (OOM 방지) + */ + public MessagePage findByUserIdWithPagination(String userId, int limit, String cursor) { + QueryConditional queryConditional = QueryConditional + .keyEqualTo(Key.builder().partitionValue("USER#" + userId).build()); + + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .scanIndexForward(false) // 최신순 + .limit(limit); + + if (cursor != null && !cursor.isEmpty()) { + Map exclusiveStartKey = decodeCursor(cursor); + if (exclusiveStartKey != null) { + requestBuilder.exclusiveStartKey(exclusiveStartKey); + } + } + + Page page = table.index("GSI1") + .query(requestBuilder.build()) + .iterator() + .next(); + + String nextCursor = encodeCursor(page.lastEvaluatedKey()); + return new MessagePage(page.items(), nextCursor); + } + + /** + * 페이지네이션 결과 클래스 + */ + public static class MessagePage { + private final List messages; + private final String nextCursor; + + public MessagePage(List messages, String nextCursor) { + this.messages = messages; + this.nextCursor = nextCursor; + } + + public List getMessages() { + return messages; + } + + public String getNextCursor() { + return nextCursor; + } + + public boolean hasMore() { + return nextCursor != null; + } + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/repository/ChatRoomRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/repository/ChatRoomRepository.java new file mode 100644 index 00000000..4b5cef43 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/repository/ChatRoomRepository.java @@ -0,0 +1,216 @@ +package com.mzc.secondproject.serverless.chatting.repository; + +import com.mzc.secondproject.serverless.chatting.model.ChatRoom; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbIndex; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; +import software.amazon.awssdk.enhanced.dynamodb.Key; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.model.Page; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; +import software.amazon.awssdk.services.dynamodb.DynamoDbClient; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; +import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest; + +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +public class ChatRoomRepository { + + private static final Logger logger = LoggerFactory.getLogger(ChatRoomRepository.class); + + // Singleton 패턴으로 Cold Start 최적화 + private static final DynamoDbClient dynamoDbClient = DynamoDbClient.builder().build(); + private static final DynamoDbEnhancedClient enhancedClient = DynamoDbEnhancedClient.builder() + .dynamoDbClient(dynamoDbClient) + .build(); + private static final String tableName = System.getenv("CHAT_TABLE_NAME"); + + private final DynamoDbTable table; + + public ChatRoomRepository() { + this.table = enhancedClient.table(tableName, TableSchema.fromBean(ChatRoom.class)); + } + + public ChatRoom save(ChatRoom room) { + logger.info("Saving room to DynamoDB: {}", room.getRoomId()); + table.putItem(room); + return room; + } + + public Optional findById(String roomId) { + Key key = Key.builder() + .partitionValue("ROOM#" + roomId) + .sortValue("METADATA") + .build(); + + ChatRoom room = table.getItem(key); + return Optional.ofNullable(room); + } + + /** + * 채팅방 목록 조회 - 최신순, 페이지네이션 지원 + * @param limit 조회 개수 (기본 10) + * @param cursor Base64 인코딩된 커서 (무한스크롤용) + * @return 채팅방 목록과 다음 페이지 커서 + */ + public RoomPage findAllWithPagination(int limit, String cursor) { + QueryConditional queryConditional = QueryConditional + .keyEqualTo(Key.builder().partitionValue("ROOMS").build()); + + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .scanIndexForward(false) // 최신순 (역순) + .limit(limit); + + // 커서 기반 페이지네이션 (Base64 디코딩) + if (cursor != null && !cursor.isEmpty()) { + Map exclusiveStartKey = decodeCursor(cursor); + if (exclusiveStartKey != null) { + requestBuilder.exclusiveStartKey(exclusiveStartKey); + } + } + + DynamoDbIndex gsi1 = table.index("GSI1"); + Page page = gsi1.query(requestBuilder.build()).iterator().next(); + List rooms = page.items(); + + // 다음 페이지 커서 (Base64 인코딩) + String nextCursor = encodeCursor(page.lastEvaluatedKey()); + + return new RoomPage(rooms, nextCursor); + } + + /** + * 레벨별 채팅방 조회 - 최신순, 페이지네이션 지원 + */ + public RoomPage findByLevelWithPagination(String level, int limit, String cursor) { + QueryConditional queryConditional = QueryConditional + .sortBeginsWith(Key.builder() + .partitionValue("ROOMS") + .sortValue(level + "#") + .build()); + + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .scanIndexForward(false) // 최신순 + .limit(limit); + + if (cursor != null && !cursor.isEmpty()) { + Map exclusiveStartKey = decodeCursor(cursor); + if (exclusiveStartKey != null) { + requestBuilder.exclusiveStartKey(exclusiveStartKey); + } + } + + DynamoDbIndex gsi1 = table.index("GSI1"); + Page page = gsi1.query(requestBuilder.build()).iterator().next(); + List rooms = page.items(); + + String nextCursor = encodeCursor(page.lastEvaluatedKey()); + + return new RoomPage(rooms, nextCursor); + } + + /** + * 커서 인코딩 (lastEvaluatedKey -> Base64) + */ + private String encodeCursor(Map lastEvaluatedKey) { + if (lastEvaluatedKey == null || lastEvaluatedKey.isEmpty()) { + return null; + } + + StringBuilder sb = new StringBuilder(); + for (Map.Entry entry : lastEvaluatedKey.entrySet()) { + if (sb.length() > 0) sb.append("|"); + sb.append(entry.getKey()).append("=").append(entry.getValue().s()); + } + + return Base64.getUrlEncoder().encodeToString(sb.toString().getBytes()); + } + + /** + * 커서 디코딩 (Base64 -> exclusiveStartKey) + */ + private Map decodeCursor(String cursor) { + try { + String decoded = new String(Base64.getUrlDecoder().decode(cursor)); + Map result = new HashMap<>(); + + for (String pair : decoded.split("\\|")) { + String[] kv = pair.split("=", 2); + if (kv.length == 2) { + result.put(kv[0], AttributeValue.builder().s(kv[1]).build()); + } + } + + return result.isEmpty() ? null : result; + } catch (Exception e) { + logger.error("Failed to decode cursor: {}", cursor, e); + return null; + } + } + + public void delete(String roomId) { + Key key = Key.builder() + .partitionValue("ROOM#" + roomId) + .sortValue("METADATA") + .build(); + + table.deleteItem(key); + logger.info("Deleted room: {}", roomId); + } + + /** + * 채팅방 lastMessageAt 업데이트 (N+1 방지 - UpdateExpression 사용) + */ + public void updateLastMessageAt(String roomId, String timestamp) { + Map key = new HashMap<>(); + key.put("PK", AttributeValue.builder().s("ROOM#" + roomId).build()); + key.put("SK", AttributeValue.builder().s("METADATA").build()); + + Map expressionValues = new HashMap<>(); + expressionValues.put(":ts", AttributeValue.builder().s(timestamp).build()); + + UpdateItemRequest updateRequest = UpdateItemRequest.builder() + .tableName(tableName) + .key(key) + .updateExpression("SET lastMessageAt = :ts") + .expressionAttributeValues(expressionValues) + .build(); + + dynamoDbClient.updateItem(updateRequest); + logger.info("Updated lastMessageAt for room: {}", roomId); + } + + /** + * 페이지네이션 결과 클래스 + */ + public static class RoomPage { + private final List rooms; + private final String nextCursor; + + public RoomPage(List rooms, String nextCursor) { + this.rooms = rooms; + this.nextCursor = nextCursor; + } + + public List getRooms() { + return rooms; + } + + public String getNextCursor() { + return nextCursor; + } + + public boolean hasMore() { + return nextCursor != null; + } + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/service/BedrockService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/service/BedrockService.java new file mode 100644 index 00000000..433a8117 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/service/BedrockService.java @@ -0,0 +1,67 @@ +package com.mzc.secondproject.serverless.chatting.service; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.core.SdkBytes; +import software.amazon.awssdk.services.bedrockruntime.BedrockRuntimeClient; +import software.amazon.awssdk.services.bedrockruntime.model.InvokeModelRequest; +import software.amazon.awssdk.services.bedrockruntime.model.InvokeModelResponse; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + +public class BedrockService { + + private static final Logger logger = LoggerFactory.getLogger(BedrockService.class); + private static final Gson gson = new Gson(); + + // Claude 3 Sonnet 모델 ID + private static final String MODEL_ID = "anthropic.claude-3-sonnet-20240229-v1:0"; + + private final BedrockRuntimeClient bedrockClient; + + public BedrockService() { + this.bedrockClient = BedrockRuntimeClient.builder().build(); + } + + public String generateResponse(String prompt) { + logger.info("Generating AI response for prompt"); + + try { + // Claude 3 Messages API 형식 + JsonObject requestBody = new JsonObject(); + requestBody.addProperty("anthropic_version", "bedrock-2023-05-31"); + requestBody.addProperty("max_tokens", 1024); + + JsonArray messages = new JsonArray(); + JsonObject userMessage = new JsonObject(); + userMessage.addProperty("role", "user"); + userMessage.addProperty("content", prompt); + messages.add(userMessage); + + requestBody.add("messages", messages); + + InvokeModelRequest request = InvokeModelRequest.builder() + .modelId(MODEL_ID) + .contentType("application/json") + .accept("application/json") + .body(SdkBytes.fromUtf8String(gson.toJson(requestBody))) + .build(); + + InvokeModelResponse response = bedrockClient.invokeModel(request); + + String responseBody = response.body().asUtf8String(); + JsonObject jsonResponse = gson.fromJson(responseBody, JsonObject.class); + + // Claude 3 응답에서 텍스트 추출 + return jsonResponse.getAsJsonArray("content") + .get(0).getAsJsonObject() + .get("text").getAsString(); + + } catch (Exception e) { + logger.error("Error calling Bedrock", e); + throw new RuntimeException("Failed to generate AI response", e); + } + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/service/ChatMessageService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/service/ChatMessageService.java new file mode 100644 index 00000000..a7511066 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/service/ChatMessageService.java @@ -0,0 +1,40 @@ +package com.mzc.secondproject.serverless.chatting.service; + +import com.mzc.secondproject.serverless.chatting.model.ChatMessage; +import com.mzc.secondproject.serverless.chatting.repository.ChatMessageRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; +import java.util.Optional; + +public class ChatMessageService { + + private static final Logger logger = LoggerFactory.getLogger(ChatMessageService.class); + + private final ChatMessageRepository repository; + + public ChatMessageService() { + this.repository = new ChatMessageRepository(); + } + + public ChatMessage saveMessage(ChatMessage message) { + logger.info("Saving message: {}", message.getMessageId()); + return repository.save(message); + } + + public Optional getMessage(String roomId, String messageId) { + logger.info("Getting message: {} from room: {}", messageId, roomId); + return repository.findByRoomIdAndMessageId(roomId, messageId); + } + + public ChatMessageRepository.MessagePage getMessagesByRoomWithPagination(String roomId, int limit, String cursor) { + logger.info("Getting messages for room: {} with limit: {}", roomId, limit); + return repository.findByRoomIdWithPagination(roomId, limit, cursor); + } + + public ChatMessageRepository.MessagePage getMessagesByUserWithPagination(String userId, int limit, String cursor) { + logger.info("Getting messages for user: {} with limit: {}", userId, limit); + return repository.findByUserIdWithPagination(userId, limit, cursor); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/service/PollyService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/service/PollyService.java new file mode 100644 index 00000000..57adbd98 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/service/PollyService.java @@ -0,0 +1,167 @@ +package com.mzc.secondproject.serverless.chatting.service; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.polly.PollyClient; +import software.amazon.awssdk.services.polly.model.OutputFormat; +import software.amazon.awssdk.services.polly.model.SynthesizeSpeechRequest; +import software.amazon.awssdk.services.polly.model.VoiceId; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; +import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; + +import software.amazon.awssdk.services.s3.model.HeadObjectRequest; +import software.amazon.awssdk.services.s3.model.NoSuchKeyException; + +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.time.Duration; + +public class PollyService { + + private static final Logger logger = LoggerFactory.getLogger(PollyService.class); + + private final PollyClient pollyClient; + private final S3Client s3Client; + private final S3Presigner s3Presigner; + private final String bucketName; + + public PollyService() { + this.pollyClient = PollyClient.builder().build(); + this.s3Client = S3Client.builder().build(); + this.s3Presigner = S3Presigner.builder().build(); + this.bucketName = System.getenv("CHAT_BUCKET_NAME"); + } + + /** + * 메시지 ID 기반으로 음성 합성 (캐시 지원) + * S3에 파일이 있으면 바로 URL 반환, 없으면 Polly 변환 후 저장 + */ + public VoiceSynthesisResult synthesizeSpeechForMessage(String messageId, String text, String voice) { + String s3Key = generateS3Key(messageId, voice); + + // 캐시 확인: S3에 이미 존재하는지 체크 + if (existsInS3(s3Key)) { + logger.info("Cache hit: {}", s3Key); + String presignedUrl = getPresignedUrl(s3Key); + return new VoiceSynthesisResult(s3Key, presignedUrl, true); + } + + // 캐시 미스: Polly 변환 후 S3 저장 + logger.info("Cache miss: synthesizing and saving to {}", s3Key); + synthesizeAndSave(text, voice, s3Key); + String presignedUrl = getPresignedUrl(s3Key); + return new VoiceSynthesisResult(s3Key, presignedUrl, false); + } + + /** + * S3 키로 Pre-signed URL 생성 + */ + public String getPresignedUrl(String s3Key) { + GetObjectRequest getObjectRequest = GetObjectRequest.builder() + .bucket(bucketName) + .key(s3Key) + .build(); + + GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() + .signatureDuration(Duration.ofHours(1)) + .getObjectRequest(getObjectRequest) + .build(); + + PresignedGetObjectRequest presignedRequest = s3Presigner.presignGetObject(presignRequest); + return presignedRequest.url().toString(); + } + + /** + * S3에 파일 존재 여부 확인 + */ + public boolean existsInS3(String s3Key) { + try { + s3Client.headObject(HeadObjectRequest.builder() + .bucket(bucketName) + .key(s3Key) + .build()); + return true; + } catch (NoSuchKeyException e) { + return false; + } + } + + /** + * Polly로 음성 변환 후 지정된 S3 키로 저장 + */ + private void synthesizeAndSave(String text, String voice, String s3Key) { + VoiceId voiceId = resolveVoiceId(voice); + + try { + SynthesizeSpeechRequest request = SynthesizeSpeechRequest.builder() + .text(text) + .voiceId(voiceId) + .engine("neural") + .outputFormat(OutputFormat.MP3) + .build(); + + InputStream audioStream = pollyClient.synthesizeSpeech(request); + + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + byte[] data = new byte[4096]; + int bytesRead; + while ((bytesRead = audioStream.read(data, 0, data.length)) != -1) { + buffer.write(data, 0, bytesRead); + } + byte[] audioBytes = buffer.toByteArray(); + + s3Client.putObject( + PutObjectRequest.builder() + .bucket(bucketName) + .key(s3Key) + .contentType("audio/mpeg") + .build(), + RequestBody.fromBytes(audioBytes) + ); + + logger.info("Saved audio to S3: {}", s3Key); + } catch (Exception e) { + logger.error("Error synthesizing speech", e); + throw new RuntimeException("Failed to synthesize speech", e); + } + } + + /** + * 메시지 ID와 음성 타입으로 S3 키 생성 + */ + public String generateS3Key(String messageId, String voice) { + String voiceSuffix = "MALE".equalsIgnoreCase(voice) ? "male" : "female"; + return "voice/" + messageId + "_" + voiceSuffix + ".mp3"; + } + + /** + * 음성 합성 결과 + */ + public static class VoiceSynthesisResult { + private final String s3Key; + private final String audioUrl; + private final boolean cached; + + public VoiceSynthesisResult(String s3Key, String audioUrl, boolean cached) { + this.s3Key = s3Key; + this.audioUrl = audioUrl; + this.cached = cached; + } + + public String getS3Key() { return s3Key; } + public String getAudioUrl() { return audioUrl; } + public boolean isCached() { return cached; } + } + + private VoiceId resolveVoiceId(String voice) { + if ("MALE".equalsIgnoreCase(voice)) { + return VoiceId.MATTHEW; // 미국 영어 남성 (Neural 지원) + } + return VoiceId.JOANNA; // 미국 영어 여성 (Neural 지원, 기본값) + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/dto/ApiResponse.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/dto/ApiResponse.java new file mode 100644 index 00000000..acd76c92 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/dto/ApiResponse.java @@ -0,0 +1,33 @@ +package com.mzc.secondproject.serverless.vocabulary.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ApiResponse { + + private boolean success; + private String message; + private T data; + private String error; + + public static ApiResponse success(String message, T data) { + return ApiResponse.builder() + .success(true) + .message(message) + .data(data) + .build(); + } + + public static ApiResponse error(String errorMessage) { + return ApiResponse.builder() + .success(false) + .error(errorMessage) + .build(); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/DailyStudyHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/DailyStudyHandler.java new file mode 100644 index 00000000..e3f948fc --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/DailyStudyHandler.java @@ -0,0 +1,253 @@ +package com.mzc.secondproject.serverless.vocabulary.handler; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.mzc.secondproject.serverless.vocabulary.dto.ApiResponse; +import com.mzc.secondproject.serverless.vocabulary.model.DailyStudy; +import com.mzc.secondproject.serverless.vocabulary.model.UserWord; +import com.mzc.secondproject.serverless.vocabulary.model.Word; +import com.mzc.secondproject.serverless.vocabulary.repository.DailyStudyRepository; +import com.mzc.secondproject.serverless.vocabulary.repository.UserWordRepository; +import com.mzc.secondproject.serverless.vocabulary.repository.WordRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +public class DailyStudyHandler implements RequestHandler { + + private static final Logger logger = LoggerFactory.getLogger(DailyStudyHandler.class); + private static final Gson gson = new GsonBuilder().setPrettyPrinting().create(); + + private static final int NEW_WORDS_COUNT = 50; + private static final int REVIEW_WORDS_COUNT = 5; + + private final DailyStudyRepository dailyStudyRepository; + private final UserWordRepository userWordRepository; + private final WordRepository wordRepository; + + public DailyStudyHandler() { + this.dailyStudyRepository = new DailyStudyRepository(); + this.userWordRepository = new UserWordRepository(); + this.wordRepository = new WordRepository(); + } + + @Override + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { + String httpMethod = request.getHttpMethod(); + String path = request.getPath(); + + logger.info("Received request: {} {}", httpMethod, path); + + try { + // GET /vocab/daily/{userId} - 오늘의 학습 단어 + if ("GET".equals(httpMethod) && !path.contains("/learned")) { + return getDailyWords(request); + } + + // POST /vocab/daily/{userId}/words/{wordId}/learned - 학습 완료 + if ("POST".equals(httpMethod) && path.endsWith("/learned")) { + return markWordLearned(request); + } + + return createResponse(404, ApiResponse.error("Not found")); + + } catch (Exception e) { + logger.error("Error handling request", e); + return createResponse(500, ApiResponse.error("Internal server error: " + e.getMessage())); + } + } + + private APIGatewayProxyResponseEvent getDailyWords(APIGatewayProxyRequestEvent request) { + Map pathParams = request.getPathParameters(); + Map queryParams = request.getQueryStringParameters(); + String userId = pathParams != null ? pathParams.get("userId") : null; + + if (userId == null) { + return createResponse(400, ApiResponse.error("userId is required")); + } + + // 레벨 파라미터 (첫 생성 시 필수) + String level = queryParams != null ? queryParams.get("level") : null; + + String today = LocalDate.now().toString(); + + // 오늘의 학습 데이터 조회 + Optional optDailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today); + + DailyStudy dailyStudy; + if (optDailyStudy.isPresent()) { + dailyStudy = optDailyStudy.get(); + } else { + // 첫 생성 시 레벨 필수 + if (level == null || level.isEmpty()) { + return createResponse(400, ApiResponse.error("level is required for first daily study (BEGINNER, INTERMEDIATE, ADVANCED)")); + } + // 레벨 유효성 검사 + if (!level.equals("BEGINNER") && !level.equals("INTERMEDIATE") && !level.equals("ADVANCED")) { + return createResponse(400, ApiResponse.error("Invalid level. Must be BEGINNER, INTERMEDIATE, or ADVANCED")); + } + // 새로운 일일 학습 생성 + dailyStudy = createDailyStudy(userId, today, level); + } + + // 단어 상세 정보 조회 + List newWords = getWordDetails(dailyStudy.getNewWordIds()); + List reviewWords = getWordDetails(dailyStudy.getReviewWordIds()); + + Map result = new HashMap<>(); + result.put("dailyStudy", dailyStudy); + result.put("newWords", newWords); + result.put("reviewWords", reviewWords); + result.put("progress", calculateProgress(dailyStudy)); + + return createResponse(200, ApiResponse.success("Daily words retrieved", result)); + } + + private DailyStudy createDailyStudy(String userId, String date, String level) { + String now = Instant.now().toString(); + + // 복습 대상 단어 조회 (5개) + UserWordRepository.UserWordPage reviewPage = userWordRepository.findReviewDueWords(userId, date, REVIEW_WORDS_COUNT, null); + List reviewWordIds = reviewPage.getUserWords().stream() + .map(UserWord::getWordId) + .collect(Collectors.toList()); + + // 신규 단어 조회 (50개) - 해당 레벨에서 아직 학습하지 않은 단어 + List newWordIds = getNewWordsForUser(userId, level, NEW_WORDS_COUNT); + + DailyStudy dailyStudy = DailyStudy.builder() + .pk("DAILY#" + userId) + .sk("DATE#" + date) + .gsi1pk("DAILY#ALL") + .gsi1sk("DATE#" + date) + .userId(userId) + .date(date) + .newWordIds(newWordIds) + .reviewWordIds(reviewWordIds) + .learnedWordIds(new ArrayList<>()) + .totalWords(newWordIds.size() + reviewWordIds.size()) + .learnedCount(0) + .isCompleted(false) + .createdAt(now) + .updatedAt(now) + .build(); + + dailyStudyRepository.save(dailyStudy); + logger.info("Created daily study for user: {}, date: {}", userId, date); + + return dailyStudy; + } + + private List getNewWordsForUser(String userId, String level, int count) { + // 사용자가 학습한 단어 목록 + UserWordRepository.UserWordPage userWordPage = userWordRepository.findByUserIdWithPagination(userId, 1000, null); + List learnedWordIds = userWordPage.getUserWords().stream() + .map(UserWord::getWordId) + .collect(Collectors.toList()); + + // 해당 레벨에서 학습하지 않은 단어 선택 + List newWordIds = new ArrayList<>(); + String lastEvaluatedKey = null; + + // 페이지네이션으로 해당 레벨의 모든 단어 조회 + do { + WordRepository.WordPage wordPage = wordRepository.findByLevelWithPagination(level, count * 2, lastEvaluatedKey); + for (Word word : wordPage.getWords()) { + if (!learnedWordIds.contains(word.getWordId()) && !newWordIds.contains(word.getWordId())) { + newWordIds.add(word.getWordId()); + if (newWordIds.size() >= count) break; + } + } + lastEvaluatedKey = wordPage.getNextCursor(); + } while (newWordIds.size() < count && lastEvaluatedKey != null); + + logger.info("Selected {} new words for user {} at level {}", newWordIds.size(), userId, level); + return newWordIds; + } + + private List getWordDetails(List wordIds) { + if (wordIds == null || wordIds.isEmpty()) { + return new ArrayList<>(); + } + + // BatchGetItem으로 한 번에 조회 (N+1 문제 해결) + return wordRepository.findByIds(wordIds); + } + + private Map calculateProgress(DailyStudy dailyStudy) { + Map progress = new HashMap<>(); + int total = dailyStudy.getTotalWords(); + int learned = dailyStudy.getLearnedCount(); + + progress.put("total", total); + progress.put("learned", learned); + progress.put("remaining", total - learned); + progress.put("percentage", total > 0 ? (learned * 100.0 / total) : 0); + progress.put("isCompleted", dailyStudy.getIsCompleted()); + + return progress; + } + + private APIGatewayProxyResponseEvent markWordLearned(APIGatewayProxyRequestEvent request) { + Map pathParams = request.getPathParameters(); + String userId = pathParams != null ? pathParams.get("userId") : null; + String wordId = pathParams != null ? pathParams.get("wordId") : null; + + if (userId == null || wordId == null) { + return createResponse(400, ApiResponse.error("userId and wordId are required")); + } + + String today = LocalDate.now().toString(); + + Optional optDailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today); + if (optDailyStudy.isEmpty()) { + return createResponse(404, ApiResponse.error("Daily study not found")); + } + + DailyStudy dailyStudy = optDailyStudy.get(); + + // 이미 학습 완료된 단어인지 확인 + if (dailyStudy.getLearnedWordIds() != null && dailyStudy.getLearnedWordIds().contains(wordId)) { + return createResponse(200, ApiResponse.success("Already marked as learned", dailyStudy)); + } + + // 학습 완료 처리 + dailyStudyRepository.addLearnedWord(userId, today, wordId); + + // 업데이트된 데이터 조회 + DailyStudy updatedDailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today).orElse(dailyStudy); + + // 완료 여부 확인 + if (updatedDailyStudy.getLearnedCount() >= updatedDailyStudy.getTotalWords()) { + updatedDailyStudy.setIsCompleted(true); + dailyStudyRepository.save(updatedDailyStudy); + } + + logger.info("Marked word as learned: userId={}, wordId={}", userId, wordId); + return createResponse(200, ApiResponse.success("Word marked as learned", calculateProgress(updatedDailyStudy))); + } + + private APIGatewayProxyResponseEvent createResponse(int statusCode, Object body) { + return new APIGatewayProxyResponseEvent() + .withStatusCode(statusCode) + .withHeaders(Map.of( + "Content-Type", "application/json", + "Access-Control-Allow-Origin", "*", + "Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS", + "Access-Control-Allow-Headers", "Content-Type,Authorization" + )) + .withBody(gson.toJson(body)); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/StatisticsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/StatisticsHandler.java new file mode 100644 index 00000000..5f09ffa5 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/StatisticsHandler.java @@ -0,0 +1,160 @@ +package com.mzc.secondproject.serverless.vocabulary.handler; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.events.SQSEvent; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.mzc.secondproject.serverless.vocabulary.model.UserWord; +import com.mzc.secondproject.serverless.vocabulary.repository.UserWordRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * SQS에서 시험 결과 메시지를 받아 UserWord 통계를 업데이트하는 Lambda + * SNS → SQS → Statistics Lambda 패턴 + */ +public class StatisticsHandler implements RequestHandler { + + private static final Logger logger = LoggerFactory.getLogger(StatisticsHandler.class); + private static final Gson gson = new GsonBuilder().create(); + + private final UserWordRepository userWordRepository; + + public StatisticsHandler() { + this.userWordRepository = new UserWordRepository(); + } + + @Override + public Void handleRequest(SQSEvent event, Context context) { + logger.info("Received {} messages from SQS", event.getRecords().size()); + + for (SQSEvent.SQSMessage message : event.getRecords()) { + try { + processMessage(message); + } catch (Exception e) { + logger.error("Failed to process message: {}", message.getMessageId(), e); + // 실패한 메시지는 DLQ로 이동됨 (SQS 설정에 의해) + throw new RuntimeException("Failed to process message", e); + } + } + + return null; + } + + @SuppressWarnings("unchecked") + private void processMessage(SQSEvent.SQSMessage message) { + String body = message.getBody(); + logger.info("Processing message: {}", body); + + Map testResult = gson.fromJson(body, Map.class); + + String userId = (String) testResult.get("userId"); + List> results = (List>) testResult.get("results"); + + if (userId == null || results == null) { + logger.warn("Invalid message format: userId or results is null"); + return; + } + + String now = Instant.now().toString(); + + for (Map result : results) { + String wordId = (String) result.get("wordId"); + Boolean isCorrect = (Boolean) result.get("isCorrect"); + + if (wordId == null || isCorrect == null) { + continue; + } + + updateUserWordStatistics(userId, wordId, isCorrect, now); + } + + logger.info("Successfully processed test result for user: {}, {} words updated", userId, results.size()); + } + + private void updateUserWordStatistics(String userId, String wordId, boolean isCorrect, String now) { + Optional optUserWord = userWordRepository.findByUserIdAndWordId(userId, wordId); + UserWord userWord; + + if (optUserWord.isEmpty()) { + // 새로운 UserWord 생성 + userWord = UserWord.builder() + .pk("USER#" + userId) + .sk("WORD#" + wordId) + .gsi1pk("USER#" + userId + "#REVIEW") + .gsi2pk("USER#" + userId + "#STATUS") + .userId(userId) + .wordId(wordId) + .status("NEW") + .interval(1) + .easeFactor(2.5) + .repetitions(0) + .correctCount(0) + .incorrectCount(0) + .createdAt(now) + .build(); + } else { + userWord = optUserWord.get(); + } + + // Spaced Repetition 알고리즘 적용 + applySpacedRepetition(userWord, isCorrect); + userWord.setUpdatedAt(now); + userWord.setLastReviewedAt(now); + + // GSI 업데이트 + userWord.setGsi1sk("DATE#" + userWord.getNextReviewAt()); + userWord.setGsi2sk("STATUS#" + userWord.getStatus()); + + userWordRepository.save(userWord); + } + + /** + * SM-2 Spaced Repetition 알고리즘 적용 + */ + private void applySpacedRepetition(UserWord userWord, boolean isCorrect) { + if (isCorrect) { + userWord.setCorrectCount(userWord.getCorrectCount() + 1); + userWord.setRepetitions(userWord.getRepetitions() + 1); + + // 간격 계산 + if (userWord.getRepetitions() == 1) { + userWord.setInterval(1); + } else if (userWord.getRepetitions() == 2) { + userWord.setInterval(6); + } else { + int newInterval = (int) Math.round(userWord.getInterval() * userWord.getEaseFactor()); + userWord.setInterval(newInterval); + } + + // 상태 업데이트 + if (userWord.getRepetitions() >= 5) { + userWord.setStatus("MASTERED"); + } else if (userWord.getRepetitions() >= 2) { + userWord.setStatus("REVIEWING"); + } else { + userWord.setStatus("LEARNING"); + } + } else { + userWord.setIncorrectCount(userWord.getIncorrectCount() + 1); + userWord.setRepetitions(0); + userWord.setInterval(1); + userWord.setStatus("LEARNING"); + + // easeFactor 감소 (최소 1.3) + double newEaseFactor = userWord.getEaseFactor() - 0.2; + userWord.setEaseFactor(Math.max(1.3, newEaseFactor)); + } + + // 다음 복습일 계산 + LocalDate nextReview = LocalDate.now().plusDays(userWord.getInterval()); + userWord.setNextReviewAt(nextReview.toString()); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/StatsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/StatsHandler.java new file mode 100644 index 00000000..d5ec6b54 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/StatsHandler.java @@ -0,0 +1,340 @@ +package com.mzc.secondproject.serverless.vocabulary.handler; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.mzc.secondproject.serverless.vocabulary.dto.ApiResponse; +import com.mzc.secondproject.serverless.vocabulary.model.DailyStudy; +import com.mzc.secondproject.serverless.vocabulary.model.TestResult; +import com.mzc.secondproject.serverless.vocabulary.model.UserWord; +import com.mzc.secondproject.serverless.vocabulary.model.Word; +import com.mzc.secondproject.serverless.vocabulary.repository.DailyStudyRepository; +import com.mzc.secondproject.serverless.vocabulary.repository.TestResultRepository; +import com.mzc.secondproject.serverless.vocabulary.repository.UserWordRepository; +import com.mzc.secondproject.serverless.vocabulary.repository.WordRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +public class StatsHandler implements RequestHandler { + + private static final Logger logger = LoggerFactory.getLogger(StatsHandler.class); + private static final Gson gson = new GsonBuilder().setPrettyPrinting().create(); + + private final UserWordRepository userWordRepository; + private final DailyStudyRepository dailyStudyRepository; + private final TestResultRepository testResultRepository; + private final WordRepository wordRepository; + + public StatsHandler() { + this.userWordRepository = new UserWordRepository(); + this.dailyStudyRepository = new DailyStudyRepository(); + this.testResultRepository = new TestResultRepository(); + this.wordRepository = new WordRepository(); + } + + @Override + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { + String httpMethod = request.getHttpMethod(); + String path = request.getPath(); + + logger.info("Received request: {} {}", httpMethod, path); + + try { + // GET /vocab/stats/{userId}/weakness - 약점 분석 + if ("GET".equals(httpMethod) && path.endsWith("/weakness")) { + return getWeaknessAnalysis(request); + } + + // GET /vocab/stats/{userId}/daily - 일별 통계 + if ("GET".equals(httpMethod) && path.endsWith("/daily")) { + return getDailyStats(request); + } + + // GET /vocab/stats/{userId} - 전체 통계 + if ("GET".equals(httpMethod)) { + return getOverallStats(request); + } + + return createResponse(404, ApiResponse.error("Not found")); + + } catch (Exception e) { + logger.error("Error handling request", e); + return createResponse(500, ApiResponse.error("Internal server error: " + e.getMessage())); + } + } + + private APIGatewayProxyResponseEvent getOverallStats(APIGatewayProxyRequestEvent request) { + Map pathParams = request.getPathParameters(); + String userId = pathParams != null ? pathParams.get("userId") : null; + + if (userId == null) { + return createResponse(400, ApiResponse.error("userId is required")); + } + + // 단어 학습 상태별 통계 + Map wordStatusCounts = new HashMap<>(); + wordStatusCounts.put("NEW", 0); + wordStatusCounts.put("LEARNING", 0); + wordStatusCounts.put("REVIEWING", 0); + wordStatusCounts.put("MASTERED", 0); + + int totalCorrect = 0; + int totalIncorrect = 0; + + // 사용자 단어 통계 조회 + String cursor = null; + do { + UserWordRepository.UserWordPage page = userWordRepository.findByUserIdWithPagination(userId, 100, cursor); + for (UserWord userWord : page.getUserWords()) { + String status = userWord.getStatus(); + wordStatusCounts.merge(status, 1, Integer::sum); + totalCorrect += userWord.getCorrectCount() != null ? userWord.getCorrectCount() : 0; + totalIncorrect += userWord.getIncorrectCount() != null ? userWord.getIncorrectCount() : 0; + } + cursor = page.getNextCursor(); + } while (cursor != null); + + int totalWords = wordStatusCounts.values().stream().mapToInt(Integer::intValue).sum(); + + // 시험 통계 + TestResultRepository.TestResultPage testPage = testResultRepository.findByUserIdWithPagination(userId, 100, null); + List testResults = testPage.getTestResults(); + + double avgSuccessRate = testResults.stream() + .mapToDouble(TestResult::getSuccessRate) + .average() + .orElse(0.0); + + // 학습 일수 + DailyStudyRepository.DailyStudyPage dailyPage = dailyStudyRepository.findByUserIdWithPagination(userId, 365, null); + int studyDays = dailyPage.getDailyStudies().size(); + int completedDays = (int) dailyPage.getDailyStudies().stream() + .filter(d -> Boolean.TRUE.equals(d.getIsCompleted())) + .count(); + + Map stats = new HashMap<>(); + stats.put("totalWords", totalWords); + stats.put("wordStatusCounts", wordStatusCounts); + stats.put("totalCorrect", totalCorrect); + stats.put("totalIncorrect", totalIncorrect); + stats.put("accuracy", totalCorrect + totalIncorrect > 0 + ? (totalCorrect * 100.0 / (totalCorrect + totalIncorrect)) : 0); + stats.put("testCount", testResults.size()); + stats.put("avgSuccessRate", avgSuccessRate); + stats.put("studyDays", studyDays); + stats.put("completedDays", completedDays); + stats.put("completionRate", studyDays > 0 ? (completedDays * 100.0 / studyDays) : 0); + + return createResponse(200, ApiResponse.success("Stats retrieved", stats)); + } + + private APIGatewayProxyResponseEvent getDailyStats(APIGatewayProxyRequestEvent request) { + Map pathParams = request.getPathParameters(); + Map queryParams = request.getQueryStringParameters(); + + String userId = pathParams != null ? pathParams.get("userId") : null; + String cursor = queryParams != null ? queryParams.get("cursor") : null; + + if (userId == null) { + return createResponse(400, ApiResponse.error("userId is required")); + } + + int limit = 30; // 최근 30일 + if (queryParams != null && queryParams.get("limit") != null) { + limit = Math.min(Integer.parseInt(queryParams.get("limit")), 90); + } + + DailyStudyRepository.DailyStudyPage dailyPage = dailyStudyRepository.findByUserIdWithPagination(userId, limit, cursor); + + List> dailyStats = dailyPage.getDailyStudies().stream() + .map(daily -> { + Map stat = new HashMap<>(); + stat.put("date", daily.getDate()); + stat.put("totalWords", daily.getTotalWords()); + stat.put("learnedCount", daily.getLearnedCount()); + stat.put("isCompleted", daily.getIsCompleted()); + stat.put("progress", daily.getTotalWords() > 0 + ? (daily.getLearnedCount() * 100.0 / daily.getTotalWords()) : 0); + return stat; + }) + .toList(); + + Map result = new HashMap<>(); + result.put("dailyStats", dailyStats); + result.put("nextCursor", dailyPage.getNextCursor()); + result.put("hasMore", dailyPage.hasMore()); + + return createResponse(200, ApiResponse.success("Daily stats retrieved", result)); + } + + /** + * 약점 분석 - 틀린 횟수가 많은 단어, 카테고리/레벨별 정확도 분석 + */ + private APIGatewayProxyResponseEvent getWeaknessAnalysis(APIGatewayProxyRequestEvent request) { + Map pathParams = request.getPathParameters(); + String userId = pathParams != null ? pathParams.get("userId") : null; + + if (userId == null) { + return createResponse(400, ApiResponse.error("userId is required")); + } + + // 사용자의 모든 학습 단어 조회 + List allUserWords = new ArrayList<>(); + String cursor = null; + do { + UserWordRepository.UserWordPage page = userWordRepository.findByUserIdWithPagination(userId, 100, cursor); + allUserWords.addAll(page.getUserWords()); + cursor = page.getNextCursor(); + } while (cursor != null); + + if (allUserWords.isEmpty()) { + Map emptyResult = new HashMap<>(); + emptyResult.put("weakestWords", List.of()); + emptyResult.put("categoryAnalysis", Map.of()); + emptyResult.put("levelAnalysis", Map.of()); + emptyResult.put("suggestions", List.of()); + return createResponse(200, ApiResponse.success("No learning data", emptyResult)); + } + + // 1. 가장 많이 틀린 단어 Top 10 + List> weakestWords = allUserWords.stream() + .filter(uw -> uw.getIncorrectCount() != null && uw.getIncorrectCount() > 0) + .sorted(Comparator.comparingInt(UserWord::getIncorrectCount).reversed()) + .limit(10) + .map(uw -> { + Map wordInfo = new HashMap<>(); + wordInfo.put("wordId", uw.getWordId()); + wordInfo.put("incorrectCount", uw.getIncorrectCount()); + wordInfo.put("correctCount", uw.getCorrectCount()); + wordInfo.put("status", uw.getStatus()); + + // 단어 상세 정보 조회 + wordRepository.findById(uw.getWordId()).ifPresent(word -> { + wordInfo.put("english", word.getEnglish()); + wordInfo.put("korean", word.getKorean()); + wordInfo.put("level", word.getLevel()); + wordInfo.put("category", word.getCategory()); + }); + + int total = (uw.getCorrectCount() != null ? uw.getCorrectCount() : 0) + + (uw.getIncorrectCount() != null ? uw.getIncorrectCount() : 0); + wordInfo.put("accuracy", total > 0 ? + (uw.getCorrectCount() != null ? uw.getCorrectCount() * 100.0 / total : 0) : 0); + + return wordInfo; + }) + .collect(Collectors.toList()); + + // 2. 카테고리별 정확도 분석 + Map> categoryAnalysis = new HashMap<>(); + // 3. 레벨별 정확도 분석 + Map> levelAnalysis = new HashMap<>(); + + for (UserWord uw : allUserWords) { + // 단어 정보 조회 + wordRepository.findById(uw.getWordId()).ifPresent(word -> { + String category = word.getCategory(); + String level = word.getLevel(); + + int correct = uw.getCorrectCount() != null ? uw.getCorrectCount() : 0; + int incorrect = uw.getIncorrectCount() != null ? uw.getIncorrectCount() : 0; + + // 카테고리별 집계 + categoryAnalysis.computeIfAbsent(category, k -> { + Map stats = new HashMap<>(); + stats.put("totalCorrect", 0); + stats.put("totalIncorrect", 0); + stats.put("wordCount", 0); + return stats; + }); + Map catStats = categoryAnalysis.get(category); + catStats.put("totalCorrect", (Integer) catStats.get("totalCorrect") + correct); + catStats.put("totalIncorrect", (Integer) catStats.get("totalIncorrect") + incorrect); + catStats.put("wordCount", (Integer) catStats.get("wordCount") + 1); + + // 레벨별 집계 + levelAnalysis.computeIfAbsent(level, k -> { + Map stats = new HashMap<>(); + stats.put("totalCorrect", 0); + stats.put("totalIncorrect", 0); + stats.put("wordCount", 0); + return stats; + }); + Map lvlStats = levelAnalysis.get(level); + lvlStats.put("totalCorrect", (Integer) lvlStats.get("totalCorrect") + correct); + lvlStats.put("totalIncorrect", (Integer) lvlStats.get("totalIncorrect") + incorrect); + lvlStats.put("wordCount", (Integer) lvlStats.get("wordCount") + 1); + }); + } + + // 정확도 계산 + categoryAnalysis.values().forEach(stats -> { + int correct = (Integer) stats.get("totalCorrect"); + int incorrect = (Integer) stats.get("totalIncorrect"); + int total = correct + incorrect; + stats.put("accuracy", total > 0 ? (correct * 100.0 / total) : 0); + }); + + levelAnalysis.values().forEach(stats -> { + int correct = (Integer) stats.get("totalCorrect"); + int incorrect = (Integer) stats.get("totalIncorrect"); + int total = correct + incorrect; + stats.put("accuracy", total > 0 ? (correct * 100.0 / total) : 0); + }); + + // 4. 학습 제안 생성 + List suggestions = new ArrayList<>(); + + // 가장 약한 카테고리 찾기 + categoryAnalysis.entrySet().stream() + .filter(e -> (Integer) e.getValue().get("wordCount") >= 3) // 최소 3개 이상 학습한 카테고리만 + .min(Comparator.comparingDouble(e -> (Double) e.getValue().get("accuracy"))) + .ifPresent(e -> suggestions.add( + String.format("%s 카테고리의 정확도가 %.1f%%로 가장 낮습니다. 집중 학습을 권장합니다.", + e.getKey(), e.getValue().get("accuracy")))); + + // 가장 약한 레벨 찾기 + levelAnalysis.entrySet().stream() + .filter(e -> (Integer) e.getValue().get("wordCount") >= 3) + .min(Comparator.comparingDouble(e -> (Double) e.getValue().get("accuracy"))) + .ifPresent(e -> suggestions.add( + String.format("%s 레벨의 정확도가 %.1f%%입니다. 이 레벨의 단어들을 더 복습해보세요.", + e.getKey(), e.getValue().get("accuracy")))); + + // 많이 틀린 단어가 있는 경우 + if (!weakestWords.isEmpty()) { + suggestions.add(String.format("자주 틀리는 단어 %d개가 있습니다. 북마크하여 집중 복습하세요.", + weakestWords.size())); + } + + Map result = new HashMap<>(); + result.put("weakestWords", weakestWords); + result.put("categoryAnalysis", categoryAnalysis); + result.put("levelAnalysis", levelAnalysis); + result.put("suggestions", suggestions); + + return createResponse(200, ApiResponse.success("Weakness analysis completed", result)); + } + + private APIGatewayProxyResponseEvent createResponse(int statusCode, Object body) { + return new APIGatewayProxyResponseEvent() + .withStatusCode(statusCode) + .withHeaders(Map.of( + "Content-Type", "application/json", + "Access-Control-Allow-Origin", "*", + "Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS", + "Access-Control-Allow-Headers", "Content-Type,Authorization" + )) + .withBody(gson.toJson(body)); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/TestHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/TestHandler.java new file mode 100644 index 00000000..9e27a220 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/TestHandler.java @@ -0,0 +1,377 @@ +package com.mzc.secondproject.serverless.vocabulary.handler; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.mzc.secondproject.serverless.vocabulary.dto.ApiResponse; +import com.mzc.secondproject.serverless.vocabulary.model.DailyStudy; +import com.mzc.secondproject.serverless.vocabulary.model.TestResult; +import com.mzc.secondproject.serverless.vocabulary.model.Word; +import com.mzc.secondproject.serverless.vocabulary.repository.DailyStudyRepository; +import com.mzc.secondproject.serverless.vocabulary.repository.TestResultRepository; +import com.mzc.secondproject.serverless.vocabulary.repository.WordRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.services.sns.SnsClient; +import software.amazon.awssdk.services.sns.model.PublishRequest; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Random; +import java.util.UUID; +import java.util.stream.Collectors; + +public class TestHandler implements RequestHandler { + + private static final Logger logger = LoggerFactory.getLogger(TestHandler.class); + private static final Gson gson = new GsonBuilder().setPrettyPrinting().create(); + private static final SnsClient snsClient = SnsClient.builder().build(); + private static final String TEST_RESULT_TOPIC_ARN = System.getenv("TEST_RESULT_TOPIC_ARN"); + + private final TestResultRepository testResultRepository; + private final DailyStudyRepository dailyStudyRepository; + private final WordRepository wordRepository; + + public TestHandler() { + this.testResultRepository = new TestResultRepository(); + this.dailyStudyRepository = new DailyStudyRepository(); + this.wordRepository = new WordRepository(); + } + + @Override + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { + String httpMethod = request.getHttpMethod(); + String path = request.getPath(); + + logger.info("Received request: {} {}", httpMethod, path); + + try { + // POST /vocab/test/{userId}/start - 시험 시작 + if ("POST".equals(httpMethod) && path.endsWith("/start")) { + return startTest(request); + } + + // POST /vocab/test/{userId}/submit - 답안 제출 + if ("POST".equals(httpMethod) && path.endsWith("/submit")) { + return submitAnswer(request); + } + + // GET /vocab/test/{userId}/results - 시험 결과 조회 + if ("GET".equals(httpMethod) && path.endsWith("/results")) { + return getTestResults(request); + } + + return createResponse(404, ApiResponse.error("Not found")); + + } catch (Exception e) { + logger.error("Error handling request", e); + return createResponse(500, ApiResponse.error("Internal server error: " + e.getMessage())); + } + } + + private APIGatewayProxyResponseEvent startTest(APIGatewayProxyRequestEvent request) { + Map pathParams = request.getPathParameters(); + String userId = pathParams != null ? pathParams.get("userId") : null; + + if (userId == null) { + return createResponse(400, ApiResponse.error("userId is required")); + } + + String body = request.getBody(); + Map requestBody = gson.fromJson(body, Map.class); + String testType = (String) requestBody.getOrDefault("testType", "DAILY"); + + String today = LocalDate.now().toString(); + + // 오늘 학습한 단어 기반 시험 + Optional optDailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today); + if (optDailyStudy.isEmpty()) { + return createResponse(404, ApiResponse.error("No daily study found for today")); + } + + DailyStudy dailyStudy = optDailyStudy.get(); + List allWordIds = new ArrayList<>(); + if (dailyStudy.getNewWordIds() != null) allWordIds.addAll(dailyStudy.getNewWordIds()); + if (dailyStudy.getReviewWordIds() != null) allWordIds.addAll(dailyStudy.getReviewWordIds()); + + if (allWordIds.isEmpty()) { + return createResponse(400, ApiResponse.error("No words to test")); + } + + // 시험 문제 생성 (BatchGetItem으로 한 번에 조회) + List words = wordRepository.findByIds(allWordIds); + + // 레벨별로 단어 그룹화하여 오답 후보 준비 + Map> wordsByLevel = words.stream() + .collect(Collectors.groupingBy(Word::getLevel)); + + // 각 레벨별 추가 오답 후보 단어 조회 (문제 단어 외의 다른 단어들) + Map> distractorsByLevel = new HashMap<>(); + for (String level : wordsByLevel.keySet()) { + List distractors = getDistractorsForLevel(level, allWordIds); + distractorsByLevel.put(level, distractors); + } + + Random random = new Random(); + List> questions = new ArrayList<>(); + for (Word word : words) { + Map question = new HashMap<>(); + question.put("wordId", word.getWordId()); + question.put("english", word.getEnglish()); + question.put("example", word.getExample()); + + // 4지선다 옵션 생성 + List options = generateOptions(word, wordsByLevel, distractorsByLevel, random); + question.put("options", options); + + questions.add(question); + } + + String testId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + + Map result = new HashMap<>(); + result.put("testId", testId); + result.put("testType", testType); + result.put("questions", questions); + result.put("totalQuestions", questions.size()); + result.put("startedAt", now); + + logger.info("Started test: userId={}, testId={}, questions={}", userId, testId, questions.size()); + return createResponse(200, ApiResponse.success("Test started", result)); + } + + @SuppressWarnings("unchecked") + private APIGatewayProxyResponseEvent submitAnswer(APIGatewayProxyRequestEvent request) { + Map pathParams = request.getPathParameters(); + String userId = pathParams != null ? pathParams.get("userId") : null; + + if (userId == null) { + return createResponse(400, ApiResponse.error("userId is required")); + } + + String body = request.getBody(); + Map requestBody = gson.fromJson(body, Map.class); + + String testId = (String) requestBody.get("testId"); + String testType = (String) requestBody.getOrDefault("testType", "DAILY"); + List> answers = (List>) requestBody.get("answers"); + + if (testId == null || answers == null) { + return createResponse(400, ApiResponse.error("testId and answers are required")); + } + + String now = Instant.now().toString(); + String today = LocalDate.now().toString(); + + int correctCount = 0; + int incorrectCount = 0; + List incorrectWordIds = new ArrayList<>(); + List> results = new ArrayList<>(); + + // 모든 wordId를 추출하여 BatchGetItem으로 한 번에 조회 + List wordIds = answers.stream() + .map(a -> (String) a.get("wordId")) + .collect(java.util.stream.Collectors.toList()); + List words = wordRepository.findByIds(wordIds); + + // wordId -> Word 맵 생성 + Map wordMap = words.stream() + .collect(java.util.stream.Collectors.toMap(Word::getWordId, w -> w)); + + for (Map answer : answers) { + String wordId = (String) answer.get("wordId"); + String userAnswer = (String) answer.get("answer"); + + Word word = wordMap.get(wordId); + if (word != null) { + // 대소문자 무시, 공백 제거 후 비교 + boolean isCorrect = word.getKorean().trim().equalsIgnoreCase(userAnswer.trim()); + + // 결과 상세 정보 추가 + Map resultItem = new HashMap<>(); + resultItem.put("wordId", wordId); + resultItem.put("english", word.getEnglish()); + resultItem.put("correctAnswer", word.getKorean()); + resultItem.put("userAnswer", userAnswer); + resultItem.put("isCorrect", isCorrect); + results.add(resultItem); + + if (isCorrect) { + correctCount++; + } else { + incorrectCount++; + incorrectWordIds.add(wordId); + } + } + } + + int totalQuestions = answers.size(); + double successRate = totalQuestions > 0 ? (correctCount * 100.0 / totalQuestions) : 0; + + TestResult testResult = TestResult.builder() + .pk("TEST#" + userId) + .sk("RESULT#" + now) + .gsi1pk("TEST#ALL") + .gsi1sk("DATE#" + today) + .testId(testId) + .userId(userId) + .testType(testType) + .totalQuestions(totalQuestions) + .correctAnswers(correctCount) + .incorrectAnswers(incorrectCount) + .successRate(successRate) + .incorrectWordIds(incorrectWordIds) + .startedAt((String) requestBody.get("startedAt")) + .completedAt(now) + .build(); + + testResultRepository.save(testResult); + + // SNS로 시험 결과 발행 (비동기 통계 처리용) + publishTestResultToSns(userId, results); + + // 응답 데이터 구성 (results 포함) + Map responseData = new HashMap<>(); + responseData.put("testId", testId); + responseData.put("testType", testType); + responseData.put("totalQuestions", totalQuestions); + responseData.put("correctCount", correctCount); + responseData.put("incorrectCount", incorrectCount); + responseData.put("successRate", successRate); + responseData.put("results", results); + + logger.info("Test submitted: userId={}, testId={}, successRate={}%", userId, testId, successRate); + return createResponse(200, ApiResponse.success("Test submitted", responseData)); + } + + private APIGatewayProxyResponseEvent getTestResults(APIGatewayProxyRequestEvent request) { + Map pathParams = request.getPathParameters(); + Map queryParams = request.getQueryStringParameters(); + + String userId = pathParams != null ? pathParams.get("userId") : null; + String cursor = queryParams != null ? queryParams.get("cursor") : null; + + if (userId == null) { + return createResponse(400, ApiResponse.error("userId is required")); + } + + int limit = 10; + if (queryParams != null && queryParams.get("limit") != null) { + limit = Math.min(Integer.parseInt(queryParams.get("limit")), 50); + } + + TestResultRepository.TestResultPage resultPage = testResultRepository.findByUserIdWithPagination(userId, limit, cursor); + + Map result = new HashMap<>(); + result.put("testResults", resultPage.getTestResults()); + result.put("nextCursor", resultPage.getNextCursor()); + result.put("hasMore", resultPage.hasMore()); + + return createResponse(200, ApiResponse.success("Test results retrieved", result)); + } + + /** + * 해당 레벨에서 오답 후보 단어들의 한국어 뜻 목록을 가져옴 + */ + private List getDistractorsForLevel(String level, List excludeWordIds) { + WordRepository.WordPage wordPage = wordRepository.findByLevelWithPagination(level, 50, null); + return wordPage.getWords().stream() + .filter(w -> !excludeWordIds.contains(w.getWordId())) + .map(Word::getKorean) + .collect(Collectors.toList()); + } + + /** + * 4지선다 옵션 생성 (정답 1개 + 오답 3개, 셔플됨) + */ + private List generateOptions(Word correctWord, Map> wordsByLevel, + Map> distractorsByLevel, Random random) { + List options = new ArrayList<>(); + String correctAnswer = correctWord.getKorean(); + options.add(correctAnswer); + + String level = correctWord.getLevel(); + + // 같은 레벨의 다른 문제 단어들에서 오답 후보 추출 + List sameLevelOptions = wordsByLevel.getOrDefault(level, new ArrayList<>()).stream() + .filter(w -> !w.getWordId().equals(correctWord.getWordId())) + .map(Word::getKorean) + .collect(Collectors.toList()); + + // 추가 오답 후보 (문제에 포함되지 않은 단어들) + List additionalDistractors = distractorsByLevel.getOrDefault(level, new ArrayList<>()); + + // 모든 오답 후보 합치기 + List allDistractors = new ArrayList<>(); + allDistractors.addAll(sameLevelOptions); + allDistractors.addAll(additionalDistractors); + + // 중복 및 정답 제거 + allDistractors = allDistractors.stream() + .filter(d -> !d.equals(correctAnswer)) + .distinct() + .collect(Collectors.toList()); + + // 랜덤하게 3개 선택 + Collections.shuffle(allDistractors, random); + int distractorCount = Math.min(3, allDistractors.size()); + for (int i = 0; i < distractorCount; i++) { + options.add(allDistractors.get(i)); + } + + // 옵션 셔플 + Collections.shuffle(options, random); + return options; + } + + /** + * SNS로 시험 결과 발행 (비동기 통계 처리용) + */ + private void publishTestResultToSns(String userId, List> results) { + if (TEST_RESULT_TOPIC_ARN == null || TEST_RESULT_TOPIC_ARN.isEmpty()) { + logger.warn("TEST_RESULT_TOPIC_ARN is not configured, skipping SNS publish"); + return; + } + + try { + Map message = new HashMap<>(); + message.put("userId", userId); + message.put("results", results); + + String messageJson = gson.toJson(message); + + PublishRequest publishRequest = PublishRequest.builder() + .topicArn(TEST_RESULT_TOPIC_ARN) + .message(messageJson) + .build(); + + snsClient.publish(publishRequest); + logger.info("Published test result to SNS for user: {}", userId); + } catch (Exception e) { + // SNS 발행 실패해도 API 응답에는 영향 없음 (fire-and-forget) + logger.error("Failed to publish test result to SNS for user: {}", userId, e); + } + } + + private APIGatewayProxyResponseEvent createResponse(int statusCode, Object body) { + return new APIGatewayProxyResponseEvent() + .withStatusCode(statusCode) + .withHeaders(Map.of( + "Content-Type", "application/json", + "Access-Control-Allow-Origin", "*", + "Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS", + "Access-Control-Allow-Headers", "Content-Type,Authorization" + )) + .withBody(gson.toJson(body)); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/UserWordHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/UserWordHandler.java new file mode 100644 index 00000000..3ba1b86c --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/UserWordHandler.java @@ -0,0 +1,371 @@ +package com.mzc.secondproject.serverless.vocabulary.handler; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.mzc.secondproject.serverless.vocabulary.dto.ApiResponse; +import com.mzc.secondproject.serverless.vocabulary.model.UserWord; +import com.mzc.secondproject.serverless.vocabulary.model.Word; +import com.mzc.secondproject.serverless.vocabulary.repository.UserWordRepository; +import com.mzc.secondproject.serverless.vocabulary.repository.WordRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +public class UserWordHandler implements RequestHandler { + + private static final Logger logger = LoggerFactory.getLogger(UserWordHandler.class); + private static final Gson gson = new GsonBuilder().setPrettyPrinting().create(); + + private final UserWordRepository userWordRepository; + private final WordRepository wordRepository; + + public UserWordHandler() { + this.userWordRepository = new UserWordRepository(); + this.wordRepository = new WordRepository(); + } + + @Override + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { + String httpMethod = request.getHttpMethod(); + String path = request.getPath(); + + logger.info("Received request: {} {}", httpMethod, path); + + try { + // GET /vocab/users/{userId}/words - 사용자 단어 목록 + if ("GET".equals(httpMethod) && path.endsWith("/words")) { + return getUserWords(request); + } + + // GET /vocab/users/{userId}/words/{wordId} - 사용자 단어 상세 + if ("GET".equals(httpMethod) && path.contains("/words/")) { + return getUserWord(request); + } + + // PUT /vocab/users/{userId}/words/{wordId}/tag - 태그 변경 + if ("PUT".equals(httpMethod) && path.endsWith("/tag")) { + return updateUserWordTag(request); + } + + // PUT /vocab/users/{userId}/words/{wordId} - 학습 상태 업데이트 + if ("PUT".equals(httpMethod) && path.contains("/words/")) { + return updateUserWord(request); + } + + return createResponse(404, ApiResponse.error("Not found")); + + } catch (Exception e) { + logger.error("Error handling request", e); + return createResponse(500, ApiResponse.error("Internal server error: " + e.getMessage())); + } + } + + private APIGatewayProxyResponseEvent getUserWords(APIGatewayProxyRequestEvent request) { + Map pathParams = request.getPathParameters(); + Map queryParams = request.getQueryStringParameters(); + + String userId = pathParams != null ? pathParams.get("userId") : null; + String status = queryParams != null ? queryParams.get("status") : null; + String cursor = queryParams != null ? queryParams.get("cursor") : null; + String bookmarked = queryParams != null ? queryParams.get("bookmarked") : null; + String incorrectOnly = queryParams != null ? queryParams.get("incorrectOnly") : null; + + if (userId == null) { + return createResponse(400, ApiResponse.error("userId is required")); + } + + int limit = 20; + if (queryParams != null && queryParams.get("limit") != null) { + limit = Math.min(Integer.parseInt(queryParams.get("limit")), 50); + } + + UserWordRepository.UserWordPage userWordPage; + + // 필터 우선순위: bookmarked > incorrectOnly > status > 전체 + if ("true".equalsIgnoreCase(bookmarked)) { + userWordPage = userWordRepository.findBookmarkedWords(userId, limit, cursor); + } else if ("true".equalsIgnoreCase(incorrectOnly)) { + userWordPage = userWordRepository.findIncorrectWords(userId, limit, cursor); + } else if (status != null && !status.isEmpty()) { + userWordPage = userWordRepository.findByUserIdAndStatus(userId, status, limit, cursor); + } else { + userWordPage = userWordRepository.findByUserIdWithPagination(userId, limit, cursor); + } + + // Word 정보 조인 (BatchGetItem) + List userWords = userWordPage.getUserWords(); + List> enrichedUserWords = enrichWithWordInfo(userWords); + + Map result = new HashMap<>(); + result.put("userWords", enrichedUserWords); + result.put("nextCursor", userWordPage.getNextCursor()); + result.put("hasMore", userWordPage.hasMore()); + + return createResponse(200, ApiResponse.success("User words retrieved", result)); + } + + private APIGatewayProxyResponseEvent getUserWord(APIGatewayProxyRequestEvent request) { + Map pathParams = request.getPathParameters(); + String userId = pathParams != null ? pathParams.get("userId") : null; + String wordId = pathParams != null ? pathParams.get("wordId") : null; + + if (userId == null || wordId == null) { + return createResponse(400, ApiResponse.error("userId and wordId are required")); + } + + Optional optUserWord = userWordRepository.findByUserIdAndWordId(userId, wordId); + if (optUserWord.isEmpty()) { + return createResponse(404, ApiResponse.error("UserWord not found")); + } + + return createResponse(200, ApiResponse.success("UserWord retrieved", optUserWord.get())); + } + + private APIGatewayProxyResponseEvent updateUserWord(APIGatewayProxyRequestEvent request) { + Map pathParams = request.getPathParameters(); + String userId = pathParams != null ? pathParams.get("userId") : null; + String wordId = pathParams != null ? pathParams.get("wordId") : null; + + if (userId == null || wordId == null) { + return createResponse(400, ApiResponse.error("userId and wordId are required")); + } + + String body = request.getBody(); + Map requestBody = gson.fromJson(body, Map.class); + + // 정답/오답 여부 + Boolean isCorrect = (Boolean) requestBody.get("isCorrect"); + if (isCorrect == null) { + return createResponse(400, ApiResponse.error("isCorrect is required")); + } + + Optional optUserWord = userWordRepository.findByUserIdAndWordId(userId, wordId); + UserWord userWord; + String now = Instant.now().toString(); + + if (optUserWord.isEmpty()) { + // 새로운 UserWord 생성 + userWord = UserWord.builder() + .pk("USER#" + userId) + .sk("WORD#" + wordId) + .gsi1pk("USER#" + userId + "#REVIEW") + .gsi2pk("USER#" + userId + "#STATUS") + .userId(userId) + .wordId(wordId) + .status("NEW") + .interval(1) + .easeFactor(2.5) + .repetitions(0) + .correctCount(0) + .incorrectCount(0) + .createdAt(now) + .build(); + } else { + userWord = optUserWord.get(); + } + + // Spaced Repetition 알고리즘 적용 + applySpacedRepetition(userWord, isCorrect); + userWord.setUpdatedAt(now); + userWord.setLastReviewedAt(now); + + // GSI 업데이트 + userWord.setGsi1sk("DATE#" + userWord.getNextReviewAt()); + userWord.setGsi2sk("STATUS#" + userWord.getStatus()); + + userWordRepository.save(userWord); + + logger.info("Updated user word: userId={}, wordId={}, isCorrect={}", userId, wordId, isCorrect); + return createResponse(200, ApiResponse.success("UserWord updated", userWord)); + } + + /** + * 사용자 단어 태그 변경 (북마크, 즐겨찾기, 난이도) + */ + private APIGatewayProxyResponseEvent updateUserWordTag(APIGatewayProxyRequestEvent request) { + Map pathParams = request.getPathParameters(); + String userId = pathParams != null ? pathParams.get("userId") : null; + String wordId = pathParams != null ? pathParams.get("wordId") : null; + + if (userId == null || wordId == null) { + return createResponse(400, ApiResponse.error("userId and wordId are required")); + } + + String body = request.getBody(); + Map requestBody = gson.fromJson(body, Map.class); + + Optional optUserWord = userWordRepository.findByUserIdAndWordId(userId, wordId); + UserWord userWord; + String now = Instant.now().toString(); + + if (optUserWord.isEmpty()) { + // 새로운 UserWord 생성 (태그만 설정) + userWord = UserWord.builder() + .pk("USER#" + userId) + .sk("WORD#" + wordId) + .gsi1pk("USER#" + userId + "#REVIEW") + .gsi2pk("USER#" + userId + "#STATUS") + .gsi2sk("STATUS#NEW") + .userId(userId) + .wordId(wordId) + .status("NEW") + .interval(1) + .easeFactor(2.5) + .repetitions(0) + .correctCount(0) + .incorrectCount(0) + .bookmarked(false) + .favorite(false) + .createdAt(now) + .build(); + } else { + userWord = optUserWord.get(); + } + + // 태그 업데이트 + if (requestBody.containsKey("bookmarked")) { + userWord.setBookmarked((Boolean) requestBody.get("bookmarked")); + } + if (requestBody.containsKey("favorite")) { + userWord.setFavorite((Boolean) requestBody.get("favorite")); + } + if (requestBody.containsKey("difficulty")) { + String difficulty = (String) requestBody.get("difficulty"); + if (difficulty != null && (difficulty.equals("EASY") || difficulty.equals("NORMAL") || difficulty.equals("HARD"))) { + userWord.setDifficulty(difficulty); + } else if (difficulty != null) { + return createResponse(400, ApiResponse.error("difficulty must be EASY, NORMAL, or HARD")); + } + } + + userWord.setUpdatedAt(now); + userWordRepository.save(userWord); + + logger.info("Updated user word tag: userId={}, wordId={}", userId, wordId); + return createResponse(200, ApiResponse.success("Tag updated", userWord)); + } + + /** + * SM-2 Spaced Repetition 알고리즘 적용 + */ + private void applySpacedRepetition(UserWord userWord, boolean isCorrect) { + if (isCorrect) { + userWord.setCorrectCount(userWord.getCorrectCount() + 1); + userWord.setRepetitions(userWord.getRepetitions() + 1); + + // 간격 계산 + if (userWord.getRepetitions() == 1) { + userWord.setInterval(1); + } else if (userWord.getRepetitions() == 2) { + userWord.setInterval(6); + } else { + int newInterval = (int) Math.round(userWord.getInterval() * userWord.getEaseFactor()); + userWord.setInterval(newInterval); + } + + // 상태 업데이트 + if (userWord.getRepetitions() >= 5) { + userWord.setStatus("MASTERED"); + } else if (userWord.getRepetitions() >= 2) { + userWord.setStatus("REVIEWING"); + } else { + userWord.setStatus("LEARNING"); + } + } else { + userWord.setIncorrectCount(userWord.getIncorrectCount() + 1); + userWord.setRepetitions(0); + userWord.setInterval(1); + userWord.setStatus("LEARNING"); + + // easeFactor 감소 (최소 1.3) + double newEaseFactor = userWord.getEaseFactor() - 0.2; + userWord.setEaseFactor(Math.max(1.3, newEaseFactor)); + } + + // 다음 복습일 계산 + LocalDate nextReview = LocalDate.now().plusDays(userWord.getInterval()); + userWord.setNextReviewAt(nextReview.toString()); + } + + /** + * UserWord 목록에 Word 정보(english, korean, level 등)를 조인 + * BatchGetItem으로 한 번에 조회하여 N+1 문제 방지 + */ + private List> enrichWithWordInfo(List userWords) { + if (userWords == null || userWords.isEmpty()) { + return new ArrayList<>(); + } + + // wordId 목록 추출 + List wordIds = userWords.stream() + .map(UserWord::getWordId) + .collect(Collectors.toList()); + + // BatchGetItem으로 Word 정보 한 번에 조회 + List words = wordRepository.findByIds(wordIds); + + // wordId -> Word 맵 생성 + Map wordMap = words.stream() + .collect(Collectors.toMap(Word::getWordId, w -> w, (w1, w2) -> w1)); + + // UserWord + Word 정보 합치기 + List> enrichedList = new ArrayList<>(); + for (UserWord userWord : userWords) { + Map enriched = new HashMap<>(); + + // UserWord 정보 + enriched.put("wordId", userWord.getWordId()); + enriched.put("userId", userWord.getUserId()); + enriched.put("status", userWord.getStatus()); + enriched.put("correctCount", userWord.getCorrectCount()); + enriched.put("incorrectCount", userWord.getIncorrectCount()); + enriched.put("bookmarked", userWord.getBookmarked()); + enriched.put("favorite", userWord.getFavorite()); + enriched.put("difficulty", userWord.getDifficulty()); + enriched.put("nextReviewAt", userWord.getNextReviewAt()); + enriched.put("lastReviewedAt", userWord.getLastReviewedAt()); + enriched.put("repetitions", userWord.getRepetitions()); + enriched.put("interval", userWord.getInterval()); + + // Word 정보 추가 + Word word = wordMap.get(userWord.getWordId()); + if (word != null) { + enriched.put("english", word.getEnglish()); + enriched.put("korean", word.getKorean()); + enriched.put("level", word.getLevel()); + enriched.put("category", word.getCategory()); + enriched.put("example", word.getExample()); + enriched.put("maleVoiceKey", word.getMaleVoiceKey()); + enriched.put("femaleVoiceKey", word.getFemaleVoiceKey()); + } + + enrichedList.add(enriched); + } + + return enrichedList; + } + + private APIGatewayProxyResponseEvent createResponse(int statusCode, Object body) { + return new APIGatewayProxyResponseEvent() + .withStatusCode(statusCode) + .withHeaders(Map.of( + "Content-Type", "application/json", + "Access-Control-Allow-Origin", "*", + "Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS", + "Access-Control-Allow-Headers", "Content-Type,Authorization" + )) + .withBody(gson.toJson(body)); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/VoiceHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/VoiceHandler.java new file mode 100644 index 00000000..c49d4fef --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/VoiceHandler.java @@ -0,0 +1,123 @@ +package com.mzc.secondproject.serverless.vocabulary.handler; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.mzc.secondproject.serverless.vocabulary.dto.ApiResponse; +import com.mzc.secondproject.serverless.vocabulary.model.Word; +import com.mzc.secondproject.serverless.vocabulary.repository.WordRepository; +import com.mzc.secondproject.serverless.vocabulary.service.PollyService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +public class VoiceHandler implements RequestHandler { + + private static final Logger logger = LoggerFactory.getLogger(VoiceHandler.class); + private static final Gson gson = new GsonBuilder().setPrettyPrinting().create(); + + private final WordRepository wordRepository; + private final PollyService pollyService; + + public VoiceHandler() { + this.wordRepository = new WordRepository(); + this.pollyService = new PollyService(); + } + + @Override + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { + String httpMethod = request.getHttpMethod(); + String path = request.getPath(); + + logger.info("Received request: {} {}", httpMethod, path); + + try { + // POST /vocab/voice/synthesize - 음성 합성 + if ("POST".equals(httpMethod) && path.endsWith("/synthesize")) { + return synthesizeSpeech(request); + } + + return createResponse(404, ApiResponse.error("Not found")); + + } catch (Exception e) { + logger.error("Error handling request", e); + return createResponse(500, ApiResponse.error("Internal server error: " + e.getMessage())); + } + } + + private APIGatewayProxyResponseEvent synthesizeSpeech(APIGatewayProxyRequestEvent request) { + String body = request.getBody(); + Map requestBody = gson.fromJson(body, Map.class); + + String wordId = (String) requestBody.get("wordId"); + String voice = (String) requestBody.getOrDefault("voice", "FEMALE"); + + if (wordId == null || wordId.isEmpty()) { + return createResponse(400, ApiResponse.error("wordId is required")); + } + + // 단어 조회 + Optional optWord = wordRepository.findById(wordId); + if (optWord.isEmpty()) { + return createResponse(404, ApiResponse.error("Word not found")); + } + + Word word = optWord.get(); + boolean isMale = "MALE".equalsIgnoreCase(voice); + + // 캐시 확인: DynamoDB에 저장된 S3 키 확인 + String cachedKey = isMale ? word.getMaleVoiceKey() : word.getFemaleVoiceKey(); + String audioUrl; + boolean cached = false; + + if (cachedKey != null && !cachedKey.isEmpty()) { + // DB에 캐시 키가 있으면 Pre-signed URL 생성 + audioUrl = pollyService.getPresignedUrl(cachedKey); + cached = true; + logger.info("Cache hit from DB: wordId={}, voice={}", wordId, voice); + } else { + // 캐시 미스: Polly 변환 후 S3 저장 + PollyService.VoiceSynthesisResult result = pollyService.synthesizeSpeechForWord( + wordId, word.getEnglish(), voice); + + audioUrl = result.getAudioUrl(); + cached = result.isCached(); + + // DynamoDB에 S3 키 저장 + if (isMale) { + word.setMaleVoiceKey(result.getS3Key()); + } else { + word.setFemaleVoiceKey(result.getS3Key()); + } + wordRepository.save(word); + logger.info("Saved voice cache to DB: wordId={}, voice={}", wordId, voice); + } + + Map responseData = new HashMap<>(); + responseData.put("wordId", wordId); + responseData.put("english", word.getEnglish()); + responseData.put("voice", voice); + responseData.put("audioUrl", audioUrl); + responseData.put("cached", cached); + + return createResponse(200, ApiResponse.success("Speech synthesized", responseData)); + } + + private APIGatewayProxyResponseEvent createResponse(int statusCode, Object body) { + return new APIGatewayProxyResponseEvent() + .withStatusCode(statusCode) + .withHeaders(Map.of( + "Content-Type", "application/json", + "Access-Control-Allow-Origin", "*", + "Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS", + "Access-Control-Allow-Headers", "Content-Type,Authorization" + )) + .withBody(gson.toJson(body)); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/WordHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/WordHandler.java new file mode 100644 index 00000000..e05352e5 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/WordHandler.java @@ -0,0 +1,337 @@ +package com.mzc.secondproject.serverless.vocabulary.handler; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.mzc.secondproject.serverless.vocabulary.dto.ApiResponse; +import com.mzc.secondproject.serverless.vocabulary.model.Word; +import com.mzc.secondproject.serverless.vocabulary.repository.WordRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +public class WordHandler implements RequestHandler { + + private static final Logger logger = LoggerFactory.getLogger(WordHandler.class); + private static final Gson gson = new GsonBuilder().setPrettyPrinting().create(); + + private final WordRepository wordRepository; + + public WordHandler() { + this.wordRepository = new WordRepository(); + } + + @Override + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { + String httpMethod = request.getHttpMethod(); + String path = request.getPath(); + + logger.info("Received request: {} {}", httpMethod, path); + + try { + // POST /vocab/words/batch - 단어 일괄 등록 + if ("POST".equals(httpMethod) && path.endsWith("/batch")) { + return createWordsBatch(request); + } + + // GET /vocab/words/search - 단어 검색 + if ("GET".equals(httpMethod) && path.endsWith("/search")) { + return searchWords(request); + } + + // POST /vocab/words - 단어 생성 + if ("POST".equals(httpMethod) && path.endsWith("/words")) { + return createWord(request); + } + + // GET /vocab/words - 단어 목록 조회 + if ("GET".equals(httpMethod) && path.endsWith("/words")) { + return getWords(request); + } + + // GET /vocab/words/{wordId} - 단어 상세 조회 + if ("GET".equals(httpMethod) && path.contains("/words/") && !path.contains("/search") && !path.contains("/batch")) { + return getWord(request); + } + + // PUT /vocab/words/{wordId} - 단어 수정 + if ("PUT".equals(httpMethod) && path.contains("/words/")) { + return updateWord(request); + } + + // DELETE /vocab/words/{wordId} - 단어 삭제 + if ("DELETE".equals(httpMethod) && path.contains("/words/")) { + return deleteWord(request); + } + + return createResponse(404, ApiResponse.error("Not found")); + + } catch (Exception e) { + logger.error("Error handling request", e); + return createResponse(500, ApiResponse.error("Internal server error: " + e.getMessage())); + } + } + + private APIGatewayProxyResponseEvent createWord(APIGatewayProxyRequestEvent request) { + String body = request.getBody(); + Map requestBody = gson.fromJson(body, Map.class); + + String english = (String) requestBody.get("english"); + String korean = (String) requestBody.get("korean"); + String example = (String) requestBody.get("example"); + String level = (String) requestBody.getOrDefault("level", "BEGINNER"); + String category = (String) requestBody.getOrDefault("category", "DAILY"); + + if (english == null || english.isEmpty()) { + return createResponse(400, ApiResponse.error("english is required")); + } + if (korean == null || korean.isEmpty()) { + return createResponse(400, ApiResponse.error("korean is required")); + } + + String wordId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + + Word word = Word.builder() + .pk("WORD#" + wordId) + .sk("METADATA") + .gsi1pk("LEVEL#" + level) + .gsi1sk("WORD#" + wordId) + .gsi2pk("CATEGORY#" + category) + .gsi2sk("WORD#" + wordId) + .wordId(wordId) + .english(english) + .korean(korean) + .example(example) + .level(level) + .category(category) + .createdAt(now) + .build(); + + wordRepository.save(word); + + logger.info("Created word: {}", wordId); + return createResponse(201, ApiResponse.success("Word created", word)); + } + + private APIGatewayProxyResponseEvent getWords(APIGatewayProxyRequestEvent request) { + Map queryParams = request.getQueryStringParameters(); + + String level = queryParams != null ? queryParams.get("level") : null; + String category = queryParams != null ? queryParams.get("category") : null; + String cursor = queryParams != null ? queryParams.get("cursor") : null; + + int limit = 20; + if (queryParams != null && queryParams.get("limit") != null) { + limit = Math.min(Integer.parseInt(queryParams.get("limit")), 50); + } + + WordRepository.WordPage wordPage; + if (level != null && !level.isEmpty()) { + wordPage = wordRepository.findByLevelWithPagination(level, limit, cursor); + } else if (category != null && !category.isEmpty()) { + wordPage = wordRepository.findByCategoryWithPagination(category, limit, cursor); + } else { + // 기본: BEGINNER 레벨 + wordPage = wordRepository.findByLevelWithPagination("BEGINNER", limit, cursor); + } + + Map result = new HashMap<>(); + result.put("words", wordPage.getWords()); + result.put("nextCursor", wordPage.getNextCursor()); + result.put("hasMore", wordPage.hasMore()); + + return createResponse(200, ApiResponse.success("Words retrieved", result)); + } + + private APIGatewayProxyResponseEvent getWord(APIGatewayProxyRequestEvent request) { + Map pathParams = request.getPathParameters(); + String wordId = pathParams != null ? pathParams.get("wordId") : null; + + if (wordId == null) { + return createResponse(400, ApiResponse.error("wordId is required")); + } + + Optional optWord = wordRepository.findById(wordId); + if (optWord.isEmpty()) { + return createResponse(404, ApiResponse.error("Word not found")); + } + + return createResponse(200, ApiResponse.success("Word retrieved", optWord.get())); + } + + private APIGatewayProxyResponseEvent updateWord(APIGatewayProxyRequestEvent request) { + Map pathParams = request.getPathParameters(); + String wordId = pathParams != null ? pathParams.get("wordId") : null; + + if (wordId == null) { + return createResponse(400, ApiResponse.error("wordId is required")); + } + + Optional optWord = wordRepository.findById(wordId); + if (optWord.isEmpty()) { + return createResponse(404, ApiResponse.error("Word not found")); + } + + Word word = optWord.get(); + String body = request.getBody(); + Map requestBody = gson.fromJson(body, Map.class); + + if (requestBody.containsKey("english")) { + word.setEnglish((String) requestBody.get("english")); + } + if (requestBody.containsKey("korean")) { + word.setKorean((String) requestBody.get("korean")); + } + if (requestBody.containsKey("example")) { + word.setExample((String) requestBody.get("example")); + } + if (requestBody.containsKey("level")) { + String newLevel = (String) requestBody.get("level"); + word.setLevel(newLevel); + word.setGsi1pk("LEVEL#" + newLevel); + } + if (requestBody.containsKey("category")) { + String newCategory = (String) requestBody.get("category"); + word.setCategory(newCategory); + word.setGsi2pk("CATEGORY#" + newCategory); + } + + wordRepository.save(word); + + logger.info("Updated word: {}", wordId); + return createResponse(200, ApiResponse.success("Word updated", word)); + } + + private APIGatewayProxyResponseEvent deleteWord(APIGatewayProxyRequestEvent request) { + Map pathParams = request.getPathParameters(); + String wordId = pathParams != null ? pathParams.get("wordId") : null; + + if (wordId == null) { + return createResponse(400, ApiResponse.error("wordId is required")); + } + + Optional optWord = wordRepository.findById(wordId); + if (optWord.isEmpty()) { + return createResponse(404, ApiResponse.error("Word not found")); + } + + wordRepository.delete(wordId); + + logger.info("Deleted word: {}", wordId); + return createResponse(200, ApiResponse.success("Word deleted", null)); + } + + @SuppressWarnings("unchecked") + private APIGatewayProxyResponseEvent createWordsBatch(APIGatewayProxyRequestEvent request) { + String body = request.getBody(); + Map requestBody = gson.fromJson(body, Map.class); + + List> wordsList = (List>) requestBody.get("words"); + if (wordsList == null || wordsList.isEmpty()) { + return createResponse(400, ApiResponse.error("words array is required")); + } + + String now = Instant.now().toString(); + List createdWords = new ArrayList<>(); + int successCount = 0; + int failCount = 0; + + for (Map wordData : wordsList) { + try { + String english = (String) wordData.get("english"); + String korean = (String) wordData.get("korean"); + String example = (String) wordData.get("example"); + String level = (String) wordData.getOrDefault("level", "BEGINNER"); + String category = (String) wordData.getOrDefault("category", "DAILY"); + + if (english == null || korean == null) { + failCount++; + continue; + } + + String wordId = UUID.randomUUID().toString(); + + Word word = Word.builder() + .pk("WORD#" + wordId) + .sk("METADATA") + .gsi1pk("LEVEL#" + level) + .gsi1sk("WORD#" + wordId) + .gsi2pk("CATEGORY#" + category) + .gsi2sk("WORD#" + wordId) + .wordId(wordId) + .english(english) + .korean(korean) + .example(example) + .level(level) + .category(category) + .createdAt(now) + .build(); + + wordRepository.save(word); + createdWords.add(word); + successCount++; + } catch (Exception e) { + logger.error("Failed to create word", e); + failCount++; + } + } + + Map result = new HashMap<>(); + result.put("successCount", successCount); + result.put("failCount", failCount); + result.put("totalRequested", wordsList.size()); + + logger.info("Batch created {} words, failed {}", successCount, failCount); + return createResponse(201, ApiResponse.success("Batch completed", result)); + } + + private APIGatewayProxyResponseEvent searchWords(APIGatewayProxyRequestEvent request) { + Map queryParams = request.getQueryStringParameters(); + + String query = queryParams != null ? queryParams.get("q") : null; + String cursor = queryParams != null ? queryParams.get("cursor") : null; + + if (query == null || query.isEmpty()) { + return createResponse(400, ApiResponse.error("q (query) parameter is required")); + } + + int limit = 20; + if (queryParams.get("limit") != null) { + limit = Math.min(Integer.parseInt(queryParams.get("limit")), 50); + } + + // 영어/한국어 모두 검색 + WordRepository.WordPage wordPage = wordRepository.searchByKeyword(query, limit, cursor); + + Map result = new HashMap<>(); + result.put("words", wordPage.getWords()); + result.put("query", query); + result.put("nextCursor", wordPage.getNextCursor()); + result.put("hasMore", wordPage.hasMore()); + + return createResponse(200, ApiResponse.success("Search completed", result)); + } + + private APIGatewayProxyResponseEvent createResponse(int statusCode, Object body) { + return new APIGatewayProxyResponseEvent() + .withStatusCode(statusCode) + .withHeaders(Map.of( + "Content-Type", "application/json", + "Access-Control-Allow-Origin", "*", + "Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS", + "Access-Control-Allow-Headers", "Content-Type,Authorization" + )) + .withBody(gson.toJson(body)); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/DailyStudy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/DailyStudy.java new file mode 100644 index 00000000..c6c51520 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/DailyStudy.java @@ -0,0 +1,74 @@ +package com.mzc.secondproject.serverless.vocabulary.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbAttribute; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondaryPartitionKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondarySortKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSortKey; + +import java.util.List; + +/** + * 일일 학습 정보 + * PK: DAILY#{userId} + * SK: DATE#{date} + * GSI1: DAILY#ALL / DATE#{date} - 전체 일일 학습 조회 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DynamoDbBean +public class DailyStudy { + + private String pk; // DAILY#{userId} + private String sk; // DATE#{date} + private String gsi1pk; // DAILY#ALL + private String gsi1sk; // DATE#{date} + + private String userId; + private String date; // yyyy-MM-dd + + // 학습 단어 목록 (55개: 50개 신규 + 5개 복습) + private List newWordIds; // 신규 단어 ID 목록 (50개) + private List reviewWordIds; // 복습 단어 ID 목록 (5개) + private List learnedWordIds; // 학습 완료 단어 ID 목록 + + // 진행 상태 + private Integer totalWords; // 총 단어 수 (55) + private Integer learnedCount; // 학습 완료 수 + private Boolean isCompleted; // 일일 학습 완료 여부 + + private String createdAt; + private String updatedAt; + private Long ttl; + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { + return pk; + } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { + return sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1PK") + public String getGsi1pk() { + return gsi1pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1SK") + public String getGsi1sk() { + return gsi1sk; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/TestResult.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/TestResult.java new file mode 100644 index 00000000..1b0da164 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/TestResult.java @@ -0,0 +1,74 @@ +package com.mzc.secondproject.serverless.vocabulary.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbAttribute; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondaryPartitionKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondarySortKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSortKey; + +import java.util.List; + +/** + * 시험 결과 + * PK: TEST#{userId} + * SK: RESULT#{timestamp} + * GSI1: TEST#ALL / DATE#{date} - 전체 시험 결과 조회 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DynamoDbBean +public class TestResult { + + private String pk; // TEST#{userId} + private String sk; // RESULT#{timestamp} + private String gsi1pk; // TEST#ALL + private String gsi1sk; // DATE#{date} + + private String testId; + private String userId; + private String testType; // DAILY, WEEKLY, CUSTOM + + // 시험 결과 + private Integer totalQuestions; + private Integer correctAnswers; + private Integer incorrectAnswers; + private Double successRate; // 성공률 (%) + + // 오답 단어 목록 + private List incorrectWordIds; + + private String startedAt; + private String completedAt; + private Long ttl; + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { + return pk; + } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { + return sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1PK") + public String getGsi1pk() { + return gsi1pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1SK") + public String getGsi1sk() { + return gsi1sk; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/UserWord.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/UserWord.java new file mode 100644 index 00000000..c9b22db1 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/UserWord.java @@ -0,0 +1,93 @@ +package com.mzc.secondproject.serverless.vocabulary.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbAttribute; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondaryPartitionKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondarySortKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSortKey; + +/** + * 사용자별 단어 학습 상태 (Spaced Repetition) + * PK: USER#{userId} + * SK: WORD#{wordId} + * GSI1: USER#{userId}#REVIEW / DATE#{nextReviewAt} - 복습 예정 조회 + * GSI2: USER#{userId}#STATUS / STATUS#{status} - 상태별 조회 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DynamoDbBean +public class UserWord { + + private String pk; // USER#{userId} + private String sk; // WORD#{wordId} + private String gsi1pk; // USER#{userId}#REVIEW + private String gsi1sk; // DATE#{nextReviewAt} + private String gsi2pk; // USER#{userId}#STATUS + private String gsi2sk; // STATUS#{status} + + private String userId; + private String wordId; + private String status; // NEW, LEARNING, REVIEWING, MASTERED + + // Spaced Repetition 알고리즘 필드 + private Integer interval; // 복습 간격 (일) + private Double easeFactor; // 난이도 계수 (2.5 기본) + private Integer repetitions; // 연속 정답 횟수 + private String nextReviewAt; // 다음 복습 예정일 + private String lastReviewedAt; // 마지막 복습일 + + // 학습 통계 + private Integer correctCount; // 정답 횟수 + private Integer incorrectCount; // 오답 횟수 + private String createdAt; + private String updatedAt; + private Long ttl; + + // 사용자 태그 + private Boolean bookmarked; // 북마크 여부 + private Boolean favorite; // 즐겨찾기 여부 + private String difficulty; // 사용자 지정 난이도 (EASY, NORMAL, HARD) + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { + return pk; + } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { + return sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1PK") + public String getGsi1pk() { + return gsi1pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1SK") + public String getGsi1sk() { + return gsi1sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI2") + @DynamoDbAttribute("GSI2PK") + public String getGsi2pk() { + return gsi2pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI2") + @DynamoDbAttribute("GSI2SK") + public String getGsi2sk() { + return gsi2sk; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/Word.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/Word.java new file mode 100644 index 00000000..ce116685 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/Word.java @@ -0,0 +1,83 @@ +package com.mzc.secondproject.serverless.vocabulary.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbAttribute; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondaryPartitionKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondarySortKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSortKey; + +/** + * 단어 정보 모델 + * PK: WORD#{wordId} + * SK: METADATA + * GSI1: LEVEL#{level} / WORD#{wordId} - 난이도별 조회 + * GSI2: CATEGORY#{category} / WORD#{wordId} - 카테고리별 조회 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DynamoDbBean +public class Word { + + private String pk; // WORD#{wordId} + private String sk; // METADATA + private String gsi1pk; // LEVEL#{level} + private String gsi1sk; // WORD#{wordId} + private String gsi2pk; // CATEGORY#{category} + private String gsi2sk; // WORD#{wordId} + + private String wordId; + private String english; // 영어 단어 + private String korean; // 한국어 뜻 + private String example; // 예문 + private String level; // BEGINNER, INTERMEDIATE, ADVANCED + private String category; // DAILY, BUSINESS, ACADEMIC, etc. + private String createdAt; + private Long ttl; + + // 음성 캐시용 S3 키 (vocab/voice/{wordId}_{voice}.mp3) + private String maleVoiceKey; + private String femaleVoiceKey; + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { + return pk; + } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { + return sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1PK") + public String getGsi1pk() { + return gsi1pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1SK") + public String getGsi1sk() { + return gsi1sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI2") + @DynamoDbAttribute("GSI2PK") + public String getGsi2pk() { + return gsi2pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI2") + @DynamoDbAttribute("GSI2SK") + public String getGsi2sk() { + return gsi2sk; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/DailyStudyRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/DailyStudyRepository.java new file mode 100644 index 00000000..c255dd70 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/DailyStudyRepository.java @@ -0,0 +1,161 @@ +package com.mzc.secondproject.serverless.vocabulary.repository; + +import com.mzc.secondproject.serverless.vocabulary.model.DailyStudy; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; +import software.amazon.awssdk.enhanced.dynamodb.Key; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.model.Page; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; +import software.amazon.awssdk.services.dynamodb.DynamoDbClient; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; +import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest; + +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +public class DailyStudyRepository { + + private static final Logger logger = LoggerFactory.getLogger(DailyStudyRepository.class); + + // Singleton 패턴으로 Cold Start 최적화 + private static final DynamoDbClient dynamoDbClient = DynamoDbClient.builder().build(); + private static final DynamoDbEnhancedClient enhancedClient = DynamoDbEnhancedClient.builder() + .dynamoDbClient(dynamoDbClient) + .build(); + private static final String tableName = System.getenv("VOCAB_TABLE_NAME"); + + private final DynamoDbTable table; + + public DailyStudyRepository() { + this.table = enhancedClient.table(tableName, TableSchema.fromBean(DailyStudy.class)); + } + + public DailyStudy save(DailyStudy dailyStudy) { + logger.info("Saving daily study: userId={}, date={}", dailyStudy.getUserId(), dailyStudy.getDate()); + table.putItem(dailyStudy); + return dailyStudy; + } + + public Optional findByUserIdAndDate(String userId, String date) { + Key key = Key.builder() + .partitionValue("DAILY#" + userId) + .sortValue("DATE#" + date) + .build(); + + DailyStudy dailyStudy = table.getItem(key); + return Optional.ofNullable(dailyStudy); + } + + /** + * 사용자의 일일 학습 기록 조회 - 최신순, 페이지네이션 + */ + public DailyStudyPage findByUserIdWithPagination(String userId, int limit, String cursor) { + QueryConditional queryConditional = QueryConditional + .sortBeginsWith(Key.builder() + .partitionValue("DAILY#" + userId) + .sortValue("DATE#") + .build()); + + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .scanIndexForward(false) // 최신순 + .limit(limit); + + if (cursor != null && !cursor.isEmpty()) { + Map exclusiveStartKey = decodeCursor(cursor); + if (exclusiveStartKey != null) { + requestBuilder.exclusiveStartKey(exclusiveStartKey); + } + } + + Page page = table.query(requestBuilder.build()).iterator().next(); + String nextCursor = encodeCursor(page.lastEvaluatedKey()); + + return new DailyStudyPage(page.items(), nextCursor); + } + + /** + * 학습 완료 단어 추가 (UpdateExpression 사용 - N+1 방지) + */ + public void addLearnedWord(String userId, String date, String wordId) { + Map key = new HashMap<>(); + key.put("PK", AttributeValue.builder().s("DAILY#" + userId).build()); + key.put("SK", AttributeValue.builder().s("DATE#" + date).build()); + + Map expressionValues = new HashMap<>(); + expressionValues.put(":wordId", AttributeValue.builder().ss(wordId).build()); + expressionValues.put(":one", AttributeValue.builder().n("1").build()); + + UpdateItemRequest updateRequest = UpdateItemRequest.builder() + .tableName(tableName) + .key(key) + .updateExpression("ADD learnedWordIds :wordId, learnedCount :one") + .expressionAttributeValues(expressionValues) + .build(); + + dynamoDbClient.updateItem(updateRequest); + logger.info("Added learned word: userId={}, date={}, wordId={}", userId, date, wordId); + } + + private String encodeCursor(Map lastEvaluatedKey) { + if (lastEvaluatedKey == null || lastEvaluatedKey.isEmpty()) { + return null; + } + + StringBuilder sb = new StringBuilder(); + for (Map.Entry entry : lastEvaluatedKey.entrySet()) { + if (sb.length() > 0) sb.append("|"); + sb.append(entry.getKey()).append("=").append(entry.getValue().s()); + } + + return Base64.getUrlEncoder().encodeToString(sb.toString().getBytes()); + } + + private Map decodeCursor(String cursor) { + try { + String decoded = new String(Base64.getUrlDecoder().decode(cursor)); + Map result = new HashMap<>(); + + for (String pair : decoded.split("\\|")) { + String[] kv = pair.split("=", 2); + if (kv.length == 2) { + result.put(kv[0], AttributeValue.builder().s(kv[1]).build()); + } + } + + return result.isEmpty() ? null : result; + } catch (Exception e) { + logger.error("Failed to decode cursor: {}", cursor, e); + return null; + } + } + + public static class DailyStudyPage { + private final List dailyStudies; + private final String nextCursor; + + public DailyStudyPage(List dailyStudies, String nextCursor) { + this.dailyStudies = dailyStudies; + this.nextCursor = nextCursor; + } + + public List getDailyStudies() { + return dailyStudies; + } + + public String getNextCursor() { + return nextCursor; + } + + public boolean hasMore() { + return nextCursor != null; + } + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/TestResultRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/TestResultRepository.java new file mode 100644 index 00000000..6224f2bc --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/TestResultRepository.java @@ -0,0 +1,137 @@ +package com.mzc.secondproject.serverless.vocabulary.repository; + +import com.mzc.secondproject.serverless.vocabulary.model.TestResult; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; +import software.amazon.awssdk.enhanced.dynamodb.Key; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.model.Page; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; +import software.amazon.awssdk.services.dynamodb.DynamoDbClient; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; + +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +public class TestResultRepository { + + private static final Logger logger = LoggerFactory.getLogger(TestResultRepository.class); + + // Singleton 패턴으로 Cold Start 최적화 + private static final DynamoDbClient dynamoDbClient = DynamoDbClient.builder().build(); + private static final DynamoDbEnhancedClient enhancedClient = DynamoDbEnhancedClient.builder() + .dynamoDbClient(dynamoDbClient) + .build(); + private static final String tableName = System.getenv("VOCAB_TABLE_NAME"); + + private final DynamoDbTable table; + + public TestResultRepository() { + this.table = enhancedClient.table(tableName, TableSchema.fromBean(TestResult.class)); + } + + public TestResult save(TestResult testResult) { + logger.info("Saving test result: userId={}, testId={}", testResult.getUserId(), testResult.getTestId()); + table.putItem(testResult); + return testResult; + } + + public Optional findByUserIdAndTestId(String userId, String timestamp) { + Key key = Key.builder() + .partitionValue("TEST#" + userId) + .sortValue("RESULT#" + timestamp) + .build(); + + TestResult testResult = table.getItem(key); + return Optional.ofNullable(testResult); + } + + /** + * 사용자의 시험 결과 조회 - 최신순, 페이지네이션 + */ + public TestResultPage findByUserIdWithPagination(String userId, int limit, String cursor) { + QueryConditional queryConditional = QueryConditional + .sortBeginsWith(Key.builder() + .partitionValue("TEST#" + userId) + .sortValue("RESULT#") + .build()); + + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .scanIndexForward(false) // 최신순 + .limit(limit); + + if (cursor != null && !cursor.isEmpty()) { + Map exclusiveStartKey = decodeCursor(cursor); + if (exclusiveStartKey != null) { + requestBuilder.exclusiveStartKey(exclusiveStartKey); + } + } + + Page page = table.query(requestBuilder.build()).iterator().next(); + String nextCursor = encodeCursor(page.lastEvaluatedKey()); + + return new TestResultPage(page.items(), nextCursor); + } + + private String encodeCursor(Map lastEvaluatedKey) { + if (lastEvaluatedKey == null || lastEvaluatedKey.isEmpty()) { + return null; + } + + StringBuilder sb = new StringBuilder(); + for (Map.Entry entry : lastEvaluatedKey.entrySet()) { + if (sb.length() > 0) sb.append("|"); + sb.append(entry.getKey()).append("=").append(entry.getValue().s()); + } + + return Base64.getUrlEncoder().encodeToString(sb.toString().getBytes()); + } + + private Map decodeCursor(String cursor) { + try { + String decoded = new String(Base64.getUrlDecoder().decode(cursor)); + Map result = new HashMap<>(); + + for (String pair : decoded.split("\\|")) { + String[] kv = pair.split("=", 2); + if (kv.length == 2) { + result.put(kv[0], AttributeValue.builder().s(kv[1]).build()); + } + } + + return result.isEmpty() ? null : result; + } catch (Exception e) { + logger.error("Failed to decode cursor: {}", cursor, e); + return null; + } + } + + public static class TestResultPage { + private final List testResults; + private final String nextCursor; + + public TestResultPage(List testResults, String nextCursor) { + this.testResults = testResults; + this.nextCursor = nextCursor; + } + + public List getTestResults() { + return testResults; + } + + public String getNextCursor() { + return nextCursor; + } + + public boolean hasMore() { + return nextCursor != null; + } + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/UserWordRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/UserWordRepository.java new file mode 100644 index 00000000..72ed881a --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/UserWordRepository.java @@ -0,0 +1,260 @@ +package com.mzc.secondproject.serverless.vocabulary.repository; + +import com.mzc.secondproject.serverless.vocabulary.model.UserWord; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbIndex; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; +import software.amazon.awssdk.enhanced.dynamodb.Key; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.model.Page; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; +import software.amazon.awssdk.services.dynamodb.DynamoDbClient; +import software.amazon.awssdk.enhanced.dynamodb.Expression; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; + +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +public class UserWordRepository { + + private static final Logger logger = LoggerFactory.getLogger(UserWordRepository.class); + + // Singleton 패턴으로 Cold Start 최적화 + private static final DynamoDbClient dynamoDbClient = DynamoDbClient.builder().build(); + private static final DynamoDbEnhancedClient enhancedClient = DynamoDbEnhancedClient.builder() + .dynamoDbClient(dynamoDbClient) + .build(); + private static final String tableName = System.getenv("VOCAB_TABLE_NAME"); + + private final DynamoDbTable table; + + public UserWordRepository() { + this.table = enhancedClient.table(tableName, TableSchema.fromBean(UserWord.class)); + } + + public UserWord save(UserWord userWord) { + logger.info("Saving user word: userId={}, wordId={}", userWord.getUserId(), userWord.getWordId()); + table.putItem(userWord); + return userWord; + } + + public Optional findByUserIdAndWordId(String userId, String wordId) { + Key key = Key.builder() + .partitionValue("USER#" + userId) + .sortValue("WORD#" + wordId) + .build(); + + UserWord userWord = table.getItem(key); + return Optional.ofNullable(userWord); + } + + /** + * 사용자의 모든 단어 학습 상태 조회 - 페이지네이션 + */ + public UserWordPage findByUserIdWithPagination(String userId, int limit, String cursor) { + QueryConditional queryConditional = QueryConditional + .sortBeginsWith(Key.builder() + .partitionValue("USER#" + userId) + .sortValue("WORD#") + .build()); + + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .limit(limit); + + if (cursor != null && !cursor.isEmpty()) { + Map exclusiveStartKey = decodeCursor(cursor); + if (exclusiveStartKey != null) { + requestBuilder.exclusiveStartKey(exclusiveStartKey); + } + } + + Page page = table.query(requestBuilder.build()).iterator().next(); + String nextCursor = encodeCursor(page.lastEvaluatedKey()); + + return new UserWordPage(page.items(), nextCursor); + } + + /** + * 복습 예정 단어 조회 (오늘 이전 날짜) - 페이지네이션 + */ + public UserWordPage findReviewDueWords(String userId, String todayDate, int limit, String cursor) { + QueryConditional queryConditional = QueryConditional + .sortLessThanOrEqualTo(Key.builder() + .partitionValue("USER#" + userId + "#REVIEW") + .sortValue("DATE#" + todayDate) + .build()); + + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .limit(limit); + + if (cursor != null && !cursor.isEmpty()) { + Map exclusiveStartKey = decodeCursor(cursor); + if (exclusiveStartKey != null) { + requestBuilder.exclusiveStartKey(exclusiveStartKey); + } + } + + DynamoDbIndex gsi1 = table.index("GSI1"); + Page page = gsi1.query(requestBuilder.build()).iterator().next(); + String nextCursor = encodeCursor(page.lastEvaluatedKey()); + + return new UserWordPage(page.items(), nextCursor); + } + + /** + * 북마크된 단어만 조회 - FilterExpression 사용 (GSI 추가 없이 비용 최적화) + */ + public UserWordPage findBookmarkedWords(String userId, int limit, String cursor) { + QueryConditional queryConditional = QueryConditional + .sortBeginsWith(Key.builder() + .partitionValue("USER#" + userId) + .sortValue("WORD#") + .build()); + + Expression filterExpression = Expression.builder() + .expression("bookmarked = :bookmarked") + .putExpressionValue(":bookmarked", AttributeValue.builder().bool(true).build()) + .build(); + + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .filterExpression(filterExpression) + .limit(limit); + + if (cursor != null && !cursor.isEmpty()) { + Map exclusiveStartKey = decodeCursor(cursor); + if (exclusiveStartKey != null) { + requestBuilder.exclusiveStartKey(exclusiveStartKey); + } + } + + Page page = table.query(requestBuilder.build()).iterator().next(); + String nextCursor = encodeCursor(page.lastEvaluatedKey()); + + return new UserWordPage(page.items(), nextCursor); + } + + /** + * 틀린 적 있는 단어만 조회 - FilterExpression 사용 (GSI 추가 없이 비용 최적화) + */ + public UserWordPage findIncorrectWords(String userId, int limit, String cursor) { + QueryConditional queryConditional = QueryConditional + .sortBeginsWith(Key.builder() + .partitionValue("USER#" + userId) + .sortValue("WORD#") + .build()); + + Expression filterExpression = Expression.builder() + .expression("incorrectCount > :zero") + .putExpressionValue(":zero", AttributeValue.builder().n("0").build()) + .build(); + + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .filterExpression(filterExpression) + .limit(limit); + + if (cursor != null && !cursor.isEmpty()) { + Map exclusiveStartKey = decodeCursor(cursor); + if (exclusiveStartKey != null) { + requestBuilder.exclusiveStartKey(exclusiveStartKey); + } + } + + Page page = table.query(requestBuilder.build()).iterator().next(); + String nextCursor = encodeCursor(page.lastEvaluatedKey()); + + return new UserWordPage(page.items(), nextCursor); + } + + /** + * 상태별 단어 조회 - 페이지네이션 + */ + public UserWordPage findByUserIdAndStatus(String userId, String status, int limit, String cursor) { + QueryConditional queryConditional = QueryConditional + .keyEqualTo(Key.builder() + .partitionValue("USER#" + userId + "#STATUS") + .sortValue("STATUS#" + status) + .build()); + + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .limit(limit); + + if (cursor != null && !cursor.isEmpty()) { + Map exclusiveStartKey = decodeCursor(cursor); + if (exclusiveStartKey != null) { + requestBuilder.exclusiveStartKey(exclusiveStartKey); + } + } + + DynamoDbIndex gsi2 = table.index("GSI2"); + Page page = gsi2.query(requestBuilder.build()).iterator().next(); + String nextCursor = encodeCursor(page.lastEvaluatedKey()); + + return new UserWordPage(page.items(), nextCursor); + } + + private String encodeCursor(Map lastEvaluatedKey) { + if (lastEvaluatedKey == null || lastEvaluatedKey.isEmpty()) { + return null; + } + + StringBuilder sb = new StringBuilder(); + for (Map.Entry entry : lastEvaluatedKey.entrySet()) { + if (sb.length() > 0) sb.append("|"); + sb.append(entry.getKey()).append("=").append(entry.getValue().s()); + } + + return Base64.getUrlEncoder().encodeToString(sb.toString().getBytes()); + } + + private Map decodeCursor(String cursor) { + try { + String decoded = new String(Base64.getUrlDecoder().decode(cursor)); + Map result = new HashMap<>(); + + for (String pair : decoded.split("\\|")) { + String[] kv = pair.split("=", 2); + if (kv.length == 2) { + result.put(kv[0], AttributeValue.builder().s(kv[1]).build()); + } + } + + return result.isEmpty() ? null : result; + } catch (Exception e) { + logger.error("Failed to decode cursor: {}", cursor, e); + return null; + } + } + + public static class UserWordPage { + private final List userWords; + private final String nextCursor; + + public UserWordPage(List userWords, String nextCursor) { + this.userWords = userWords; + this.nextCursor = nextCursor; + } + + public List getUserWords() { + return userWords; + } + + public String getNextCursor() { + return nextCursor; + } + + public boolean hasMore() { + return nextCursor != null; + } + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/WordRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/WordRepository.java new file mode 100644 index 00000000..715e40bf --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/WordRepository.java @@ -0,0 +1,264 @@ +package com.mzc.secondproject.serverless.vocabulary.repository; + +import com.mzc.secondproject.serverless.vocabulary.model.Word; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbIndex; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; +import software.amazon.awssdk.enhanced.dynamodb.Key; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.model.BatchGetResultPageIterable; +import software.amazon.awssdk.enhanced.dynamodb.model.Page; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; +import software.amazon.awssdk.enhanced.dynamodb.model.ReadBatch; +import software.amazon.awssdk.enhanced.dynamodb.model.ScanEnhancedRequest; +import software.amazon.awssdk.enhanced.dynamodb.Expression; +import software.amazon.awssdk.services.dynamodb.DynamoDbClient; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; + +import java.util.ArrayList; + +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +public class WordRepository { + + private static final Logger logger = LoggerFactory.getLogger(WordRepository.class); + + // Singleton 패턴으로 Cold Start 최적화 + private static final DynamoDbClient dynamoDbClient = DynamoDbClient.builder().build(); + private static final DynamoDbEnhancedClient enhancedClient = DynamoDbEnhancedClient.builder() + .dynamoDbClient(dynamoDbClient) + .build(); + private static final String tableName = System.getenv("VOCAB_TABLE_NAME"); + + private final DynamoDbTable table; + + public WordRepository() { + this.table = enhancedClient.table(tableName, TableSchema.fromBean(Word.class)); + } + + public Word save(Word word) { + logger.info("Saving word to DynamoDB: {}", word.getWordId()); + table.putItem(word); + return word; + } + + public Optional findById(String wordId) { + Key key = Key.builder() + .partitionValue("WORD#" + wordId) + .sortValue("METADATA") + .build(); + + Word word = table.getItem(key); + return Optional.ofNullable(word); + } + + /** + * 여러 단어를 한 번에 조회 (BatchGetItem) - N+1 문제 해결 + * DynamoDB BatchGetItem은 최대 100개까지 지원 + */ + public List findByIds(List wordIds) { + if (wordIds == null || wordIds.isEmpty()) { + return new ArrayList<>(); + } + + List results = new ArrayList<>(); + + // BatchGetItem은 최대 100개까지 지원하므로 분할 처리 + int batchSize = 100; + for (int i = 0; i < wordIds.size(); i += batchSize) { + List batch = wordIds.subList(i, Math.min(i + batchSize, wordIds.size())); + results.addAll(batchGetWords(batch)); + } + + return results; + } + + private List batchGetWords(List wordIds) { + ReadBatch.Builder readBatchBuilder = ReadBatch.builder(Word.class) + .mappedTableResource(table); + + for (String wordId : wordIds) { + Key key = Key.builder() + .partitionValue("WORD#" + wordId) + .sortValue("METADATA") + .build(); + readBatchBuilder.addGetItem(key); + } + + BatchGetResultPageIterable resultPages = enhancedClient.batchGetItem(r -> r.readBatches(readBatchBuilder.build())); + + List words = new ArrayList<>(); + resultPages.resultsForTable(table).forEach(words::add); + logger.info("BatchGetItem: requested={}, retrieved={}", wordIds.size(), words.size()); + + return words; + } + + public void delete(String wordId) { + Key key = Key.builder() + .partitionValue("WORD#" + wordId) + .sortValue("METADATA") + .build(); + + table.deleteItem(key); + logger.info("Deleted word: {}", wordId); + } + + /** + * 난이도별 단어 조회 - 페이지네이션 + */ + public WordPage findByLevelWithPagination(String level, int limit, String cursor) { + QueryConditional queryConditional = QueryConditional + .keyEqualTo(Key.builder().partitionValue("LEVEL#" + level).build()); + + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .limit(limit); + + if (cursor != null && !cursor.isEmpty()) { + Map exclusiveStartKey = decodeCursor(cursor); + if (exclusiveStartKey != null) { + requestBuilder.exclusiveStartKey(exclusiveStartKey); + } + } + + DynamoDbIndex gsi1 = table.index("GSI1"); + Page page = gsi1.query(requestBuilder.build()).iterator().next(); + String nextCursor = encodeCursor(page.lastEvaluatedKey()); + + return new WordPage(page.items(), nextCursor); + } + + /** + * 카테고리별 단어 조회 - 페이지네이션 + */ + public WordPage findByCategoryWithPagination(String category, int limit, String cursor) { + QueryConditional queryConditional = QueryConditional + .keyEqualTo(Key.builder().partitionValue("CATEGORY#" + category).build()); + + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .limit(limit); + + if (cursor != null && !cursor.isEmpty()) { + Map exclusiveStartKey = decodeCursor(cursor); + if (exclusiveStartKey != null) { + requestBuilder.exclusiveStartKey(exclusiveStartKey); + } + } + + DynamoDbIndex gsi2 = table.index("GSI2"); + Page page = gsi2.query(requestBuilder.build()).iterator().next(); + String nextCursor = encodeCursor(page.lastEvaluatedKey()); + + return new WordPage(page.items(), nextCursor); + } + + private String encodeCursor(Map lastEvaluatedKey) { + if (lastEvaluatedKey == null || lastEvaluatedKey.isEmpty()) { + return null; + } + + StringBuilder sb = new StringBuilder(); + for (Map.Entry entry : lastEvaluatedKey.entrySet()) { + if (sb.length() > 0) sb.append("|"); + sb.append(entry.getKey()).append("=").append(entry.getValue().s()); + } + + return Base64.getUrlEncoder().encodeToString(sb.toString().getBytes()); + } + + private Map decodeCursor(String cursor) { + try { + String decoded = new String(Base64.getUrlDecoder().decode(cursor)); + Map result = new HashMap<>(); + + for (String pair : decoded.split("\\|")) { + String[] kv = pair.split("=", 2); + if (kv.length == 2) { + result.put(kv[0], AttributeValue.builder().s(kv[1]).build()); + } + } + + return result.isEmpty() ? null : result; + } catch (Exception e) { + logger.error("Failed to decode cursor: {}", cursor, e); + return null; + } + } + + /** + * 키워드로 단어 검색 (영어/한국어 contains) + * 참고: Scan은 비용이 높으므로 데이터가 많아지면 OpenSearch 도입 권장 + */ + public WordPage searchByKeyword(String keyword, int limit, String cursor) { + String lowerKeyword = keyword.toLowerCase(); + + // Filter: PK가 WORD#로 시작하고, english 또는 korean에 keyword 포함 + Expression filterExpression = Expression.builder() + .expression("begins_with(PK, :pk) AND (contains(#eng, :keyword) OR contains(korean, :keyword))") + .putExpressionName("#eng", "english") + .putExpressionValue(":pk", AttributeValue.builder().s("WORD#").build()) + .putExpressionValue(":keyword", AttributeValue.builder().s(lowerKeyword).build()) + .build(); + + ScanEnhancedRequest.Builder requestBuilder = ScanEnhancedRequest.builder() + .filterExpression(filterExpression) + .limit(limit * 3); // filter 적용되므로 넉넉히 + + if (cursor != null && !cursor.isEmpty()) { + Map exclusiveStartKey = decodeCursor(cursor); + if (exclusiveStartKey != null) { + requestBuilder.exclusiveStartKey(exclusiveStartKey); + } + } + + List results = new ArrayList<>(); + Map lastKey = null; + + for (Page page : table.scan(requestBuilder.build())) { + for (Word word : page.items()) { + // 대소문자 무시 검색 + if (word.getEnglish().toLowerCase().contains(lowerKeyword) || + word.getKorean().contains(keyword)) { + results.add(word); + if (results.size() >= limit) break; + } + } + lastKey = page.lastEvaluatedKey(); + if (results.size() >= limit) break; + } + + String nextCursor = results.size() >= limit ? encodeCursor(lastKey) : null; + return new WordPage(results, nextCursor); + } + + public static class WordPage { + private final List words; + private final String nextCursor; + + public WordPage(List words, String nextCursor) { + this.words = words; + this.nextCursor = nextCursor; + } + + public List getWords() { + return words; + } + + public String getNextCursor() { + return nextCursor; + } + + public boolean hasMore() { + return nextCursor != null; + } + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/service/PollyService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/service/PollyService.java new file mode 100644 index 00000000..5e7cb5c1 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/service/PollyService.java @@ -0,0 +1,160 @@ +package com.mzc.secondproject.serverless.vocabulary.service; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.polly.PollyClient; +import software.amazon.awssdk.services.polly.model.OutputFormat; +import software.amazon.awssdk.services.polly.model.SynthesizeSpeechRequest; +import software.amazon.awssdk.services.polly.model.VoiceId; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; +import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.HeadObjectRequest; +import software.amazon.awssdk.services.s3.model.NoSuchKeyException; + +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.time.Duration; + +public class PollyService { + + private static final Logger logger = LoggerFactory.getLogger(PollyService.class); + + // Singleton 패턴으로 Cold Start 최적화 + private static final PollyClient pollyClient = PollyClient.builder().build(); + private static final S3Client s3Client = S3Client.builder().build(); + private static final S3Presigner s3Presigner = S3Presigner.builder().build(); + private static final String bucketName = System.getenv("VOCAB_BUCKET_NAME"); + + /** + * 단어 ID 기반으로 음성 합성 (캐시 지원) + * S3에 파일이 있으면 바로 URL 반환, 없으면 Polly 변환 후 저장 + */ + public VoiceSynthesisResult synthesizeSpeechForWord(String wordId, String text, String voice) { + String s3Key = generateS3Key(wordId, voice); + + // 캐시 확인: S3에 이미 존재하는지 체크 + if (existsInS3(s3Key)) { + logger.info("Cache hit: {}", s3Key); + String presignedUrl = getPresignedUrl(s3Key); + return new VoiceSynthesisResult(s3Key, presignedUrl, true); + } + + // 캐시 미스: Polly 변환 후 S3 저장 + logger.info("Cache miss: synthesizing and saving to {}", s3Key); + synthesizeAndSave(text, voice, s3Key); + String presignedUrl = getPresignedUrl(s3Key); + return new VoiceSynthesisResult(s3Key, presignedUrl, false); + } + + /** + * S3 키로 Pre-signed URL 생성 + */ + public String getPresignedUrl(String s3Key) { + GetObjectRequest getObjectRequest = GetObjectRequest.builder() + .bucket(bucketName) + .key(s3Key) + .build(); + + GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() + .signatureDuration(Duration.ofHours(1)) + .getObjectRequest(getObjectRequest) + .build(); + + PresignedGetObjectRequest presignedRequest = s3Presigner.presignGetObject(presignRequest); + return presignedRequest.url().toString(); + } + + /** + * S3에 파일 존재 여부 확인 + */ + public boolean existsInS3(String s3Key) { + try { + s3Client.headObject(HeadObjectRequest.builder() + .bucket(bucketName) + .key(s3Key) + .build()); + return true; + } catch (NoSuchKeyException e) { + return false; + } + } + + /** + * Polly로 음성 변환 후 지정된 S3 키로 저장 + */ + private void synthesizeAndSave(String text, String voice, String s3Key) { + VoiceId voiceId = resolveVoiceId(voice); + + try { + SynthesizeSpeechRequest request = SynthesizeSpeechRequest.builder() + .text(text) + .voiceId(voiceId) + .engine("neural") + .outputFormat(OutputFormat.MP3) + .build(); + + InputStream audioStream = pollyClient.synthesizeSpeech(request); + + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + byte[] data = new byte[4096]; + int bytesRead; + while ((bytesRead = audioStream.read(data, 0, data.length)) != -1) { + buffer.write(data, 0, bytesRead); + } + byte[] audioBytes = buffer.toByteArray(); + + s3Client.putObject( + PutObjectRequest.builder() + .bucket(bucketName) + .key(s3Key) + .contentType("audio/mpeg") + .build(), + RequestBody.fromBytes(audioBytes) + ); + + logger.info("Saved audio to S3: {}", s3Key); + } catch (Exception e) { + logger.error("Error synthesizing speech", e); + throw new RuntimeException("Failed to synthesize speech", e); + } + } + + /** + * 단어 ID와 음성 타입으로 S3 키 생성 + */ + public String generateS3Key(String wordId, String voice) { + String voiceSuffix = "MALE".equalsIgnoreCase(voice) ? "male" : "female"; + return "vocab/voice/" + wordId + "_" + voiceSuffix + ".mp3"; + } + + /** + * 음성 합성 결과 + */ + public static class VoiceSynthesisResult { + private final String s3Key; + private final String audioUrl; + private final boolean cached; + + public VoiceSynthesisResult(String s3Key, String audioUrl, boolean cached) { + this.s3Key = s3Key; + this.audioUrl = audioUrl; + this.cached = cached; + } + + public String getS3Key() { return s3Key; } + public String getAudioUrl() { return audioUrl; } + public boolean isCached() { return cached; } + } + + private VoiceId resolveVoiceId(String voice) { + if ("MALE".equalsIgnoreCase(voice)) { + return VoiceId.MATTHEW; // 미국 영어 남성 (Neural 지원) + } + return VoiceId.JOANNA; // 미국 영어 여성 (Neural 지원, 기본값) + } +} diff --git a/ServerlessFunction/src/main/resources/log4j2.xml b/ServerlessFunction/src/main/resources/log4j2.xml new file mode 100644 index 00000000..69a29404 --- /dev/null +++ b/ServerlessFunction/src/main/resources/log4j2.xml @@ -0,0 +1,17 @@ + + + + + + %d{yyyy-MM-dd HH:mm:ss} %X{AWSRequestId} %-5p %c{1} - %m%n + + + + + + + + + + + diff --git a/template.yaml b/template.yaml new file mode 100644 index 00000000..0aac4a05 --- /dev/null +++ b/template.yaml @@ -0,0 +1,599 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: Group2 English Study - Unified API (Chatting + Vocabulary) + +Globals: + Function: + Timeout: 30 + MemorySize: 512 + Runtime: java21 + Architectures: + - x86_64 + Environment: + Variables: + CHAT_TABLE_NAME: !Ref ChatTable + VOCAB_TABLE_NAME: !Ref VocabTable + CHAT_BUCKET_NAME: group2-englishstudy + VOCAB_BUCKET_NAME: group2-englishstudy + AWS_REGION_NAME: !Ref AWS::Region + +Resources: + ############################################# + # API Gateway (Unified) + ############################################# + + MainApi: + Type: AWS::Serverless::Api + Properties: + Name: group2-englishstudy-api + StageName: dev + Cors: + AllowMethods: "'GET,POST,PUT,DELETE,OPTIONS'" + AllowHeaders: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" + AllowOrigin: "'*'" + + ############################################# + # Chatting Lambda Functions + ############################################# + + ChatRoomFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: group2-englishstudy-chat-room-handler + CodeUri: ServerlessFunction + Handler: com.mzc.secondproject.serverless.chatting.handler.ChatRoomHandler::handleRequest + Description: Handle chat room CRUD operations + SnapStart: + ApplyOn: PublishedVersions + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref ChatTable + Events: + CreateRoom: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /chat/rooms + Method: POST + GetRooms: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /chat/rooms + Method: GET + GetRoom: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /chat/rooms/{roomId} + Method: GET + DeleteRoom: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /chat/rooms/{roomId} + Method: DELETE + JoinRoom: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /chat/rooms/{roomId}/join + Method: POST + LeaveRoom: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /chat/rooms/{roomId}/leave + Method: POST + + ChatMessageFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: group2-englishstudy-chat-message-handler + CodeUri: ServerlessFunction + Handler: com.mzc.secondproject.serverless.chatting.handler.ChatMessageHandler::handleRequest + Description: Handle chat messages + SnapStart: + ApplyOn: PublishedVersions + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref ChatTable + - S3CrudPolicy: + BucketName: group2-englishstudy + - Statement: + - Effect: Allow + Action: + - bedrock:InvokeModel + - bedrock:InvokeModelWithResponseStream + Resource: "*" + - Statement: + - Effect: Allow + Action: + - polly:SynthesizeSpeech + - polly:DescribeVoices + Resource: "*" + Events: + SendMessage: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /chat/rooms/{roomId}/messages + Method: POST + GetMessages: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /chat/rooms/{roomId}/messages + Method: GET + GetMessage: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /chat/rooms/{roomId}/messages/{messageId} + Method: GET + + ChatAIFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: group2-englishstudy-chat-ai-handler + CodeUri: ServerlessFunction + Handler: com.mzc.secondproject.serverless.chatting.handler.ChatAIHandler::handleRequest + Description: Generate AI responses using Bedrock + Timeout: 60 + MemorySize: 1024 + SnapStart: + ApplyOn: PublishedVersions + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref ChatTable + - Statement: + - Effect: Allow + Action: + - bedrock:InvokeModel + - bedrock:InvokeModelWithResponseStream + Resource: "*" + Events: + GenerateAI: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /chat/ai/generate + Method: POST + + ChatVoiceFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: group2-englishstudy-chat-voice-handler + CodeUri: ServerlessFunction + Handler: com.mzc.secondproject.serverless.chatting.handler.ChatVoiceHandler::handleRequest + Description: Convert text to speech using Polly + SnapStart: + ApplyOn: PublishedVersions + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref ChatTable + - S3CrudPolicy: + BucketName: group2-englishstudy + - Statement: + - Effect: Allow + Action: + - polly:SynthesizeSpeech + - polly:DescribeVoices + Resource: "*" + Events: + TextToSpeech: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /chat/voice/synthesize + Method: POST + + ############################################# + # Vocabulary Lambda Functions + ############################################# + + WordFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: group2-englishstudy-vocab-word-handler + CodeUri: ServerlessFunction + Handler: com.mzc.secondproject.serverless.vocabulary.handler.WordHandler::handleRequest + Description: Handle word CRUD operations + SnapStart: + ApplyOn: PublishedVersions + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref VocabTable + Events: + CreateWord: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/words + Method: POST + BatchCreateWords: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/words/batch + Method: POST + SearchWords: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/words/search + Method: GET + GetWords: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/words + Method: GET + GetWord: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/words/{wordId} + Method: GET + UpdateWord: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/words/{wordId} + Method: PUT + DeleteWord: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/words/{wordId} + Method: DELETE + + UserWordFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: group2-englishstudy-vocab-userword-handler + CodeUri: ServerlessFunction + Handler: com.mzc.secondproject.serverless.vocabulary.handler.UserWordHandler::handleRequest + Description: Handle user word learning status + SnapStart: + ApplyOn: PublishedVersions + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref VocabTable + Events: + GetUserWords: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/users/{userId}/words + Method: GET + GetUserWord: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/users/{userId}/words/{wordId} + Method: GET + UpdateUserWord: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/users/{userId}/words/{wordId} + Method: PUT + UpdateUserWordTag: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/users/{userId}/words/{wordId}/tag + Method: PUT + + DailyStudyFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: group2-englishstudy-vocab-daily-handler + CodeUri: ServerlessFunction + Handler: com.mzc.secondproject.serverless.vocabulary.handler.DailyStudyHandler::handleRequest + Description: Handle daily study word assignment + SnapStart: + ApplyOn: PublishedVersions + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref VocabTable + Events: + GetDailyWords: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/daily/{userId} + Method: GET + MarkWordLearned: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/daily/{userId}/words/{wordId}/learned + Method: POST + + TestFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: group2-englishstudy-vocab-test-handler + CodeUri: ServerlessFunction + Handler: com.mzc.secondproject.serverless.vocabulary.handler.TestHandler::handleRequest + Description: Handle vocabulary tests + SnapStart: + ApplyOn: PublishedVersions + Environment: + Variables: + TEST_RESULT_TOPIC_ARN: !Ref TestResultTopic + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref VocabTable + - SNSPublishMessagePolicy: + TopicName: !GetAtt TestResultTopic.TopicName + Events: + StartTest: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/test/{userId}/start + Method: POST + SubmitAnswer: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/test/{userId}/submit + Method: POST + GetTestResult: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/test/{userId}/results + Method: GET + + StatsFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: group2-englishstudy-vocab-stats-handler + CodeUri: ServerlessFunction + Handler: com.mzc.secondproject.serverless.vocabulary.handler.StatsHandler::handleRequest + Description: Handle user learning statistics + SnapStart: + ApplyOn: PublishedVersions + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref VocabTable + Events: + GetStats: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/stats/{userId} + Method: GET + GetDailyStats: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/stats/{userId}/daily + Method: GET + GetWeaknessAnalysis: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/stats/{userId}/weakness + Method: GET + + VocabVoiceFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: group2-englishstudy-vocab-voice-handler + CodeUri: ServerlessFunction + Handler: com.mzc.secondproject.serverless.vocabulary.handler.VoiceHandler::handleRequest + Description: Convert word to speech using Polly + SnapStart: + ApplyOn: PublishedVersions + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref VocabTable + - S3CrudPolicy: + BucketName: group2-englishstudy + - Statement: + - Effect: Allow + Action: + - polly:SynthesizeSpeech + - polly:DescribeVoices + Resource: "*" + Events: + TextToSpeech: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/voice/synthesize + Method: POST + + ############################################# + # DynamoDB Tables + ############################################# + + ChatTable: + Type: AWS::DynamoDB::Table + Properties: + TableName: group2-englishstudy-chat + BillingMode: PAY_PER_REQUEST + AttributeDefinitions: + - AttributeName: PK + AttributeType: S + - AttributeName: SK + AttributeType: S + - AttributeName: GSI1PK + AttributeType: S + - AttributeName: GSI1SK + AttributeType: S + - AttributeName: GSI2PK + AttributeType: S + - AttributeName: GSI2SK + AttributeType: S + KeySchema: + - AttributeName: PK + KeyType: HASH + - AttributeName: SK + KeyType: RANGE + GlobalSecondaryIndexes: + - IndexName: GSI1 + KeySchema: + - AttributeName: GSI1PK + KeyType: HASH + - AttributeName: GSI1SK + KeyType: RANGE + Projection: + ProjectionType: ALL + - IndexName: GSI2 + KeySchema: + - AttributeName: GSI2PK + KeyType: HASH + - AttributeName: GSI2SK + KeyType: RANGE + Projection: + ProjectionType: ALL + TimeToLiveSpecification: + AttributeName: ttl + Enabled: true + + VocabTable: + Type: AWS::DynamoDB::Table + Properties: + TableName: group2-englishstudy-vocab + BillingMode: PAY_PER_REQUEST + AttributeDefinitions: + - AttributeName: PK + AttributeType: S + - AttributeName: SK + AttributeType: S + - AttributeName: GSI1PK + AttributeType: S + - AttributeName: GSI1SK + AttributeType: S + - AttributeName: GSI2PK + AttributeType: S + - AttributeName: GSI2SK + AttributeType: S + KeySchema: + - AttributeName: PK + KeyType: HASH + - AttributeName: SK + KeyType: RANGE + GlobalSecondaryIndexes: + - IndexName: GSI1 + KeySchema: + - AttributeName: GSI1PK + KeyType: HASH + - AttributeName: GSI1SK + KeyType: RANGE + Projection: + ProjectionType: ALL + - IndexName: GSI2 + KeySchema: + - AttributeName: GSI2PK + KeyType: HASH + - AttributeName: GSI2SK + KeyType: RANGE + Projection: + ProjectionType: ALL + TimeToLiveSpecification: + AttributeName: ttl + Enabled: true + + ############################################# + # SNS / SQS for Async Statistics Processing + ############################################# + + # SNS Topic - 시험 결과 이벤트 발행 + TestResultTopic: + Type: AWS::SNS::Topic + Properties: + TopicName: group2-englishstudy-test-result-topic + + # SQS Dead Letter Queue - 실패한 메시지 보관 + StatisticsDeadLetterQueue: + Type: AWS::SQS::Queue + Properties: + QueueName: group2-englishstudy-statistics-dlq + MessageRetentionPeriod: 1209600 # 14일 + + # SQS Queue - 통계 처리용 + StatisticsQueue: + Type: AWS::SQS::Queue + Properties: + QueueName: group2-englishstudy-statistics-queue + VisibilityTimeout: 60 + RedrivePolicy: + deadLetterTargetArn: !GetAtt StatisticsDeadLetterQueue.Arn + maxReceiveCount: 3 + + # SQS Queue Policy - SNS에서 메시지 수신 허용 + StatisticsQueuePolicy: + Type: AWS::SQS::QueuePolicy + Properties: + Queues: + - !Ref StatisticsQueue + PolicyDocument: + Statement: + - Effect: Allow + Principal: + Service: sns.amazonaws.com + Action: sqs:SendMessage + Resource: !GetAtt StatisticsQueue.Arn + Condition: + ArnEquals: + aws:SourceArn: !Ref TestResultTopic + + # SNS → SQS 구독 + StatisticsQueueSubscription: + Type: AWS::SNS::Subscription + Properties: + Protocol: sqs + TopicArn: !Ref TestResultTopic + Endpoint: !GetAtt StatisticsQueue.Arn + RawMessageDelivery: true + + # Statistics Processor Lambda - SQS에서 메시지 소비하여 통계 업데이트 + StatisticsProcessorFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: group2-englishstudy-statistics-processor + CodeUri: ServerlessFunction + Handler: com.mzc.secondproject.serverless.vocabulary.handler.StatisticsHandler::handleRequest + Description: Process test results and update user word statistics + Timeout: 60 + SnapStart: + ApplyOn: PublishedVersions + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref VocabTable + - SQSPollerPolicy: + QueueName: !GetAtt StatisticsQueue.QueueName + Events: + SQSEvent: + Type: SQS + Properties: + Queue: !GetAtt StatisticsQueue.Arn + BatchSize: 10 + +############################################# +# Outputs +############################################# + +Outputs: + ApiUrl: + Description: Unified API Gateway endpoint URL + Value: !Sub 'https://${MainApi}.execute-api.${AWS::Region}.amazonaws.com/dev/' + + ChatTableName: + Description: Chat DynamoDB Table Name + Value: !Ref ChatTable + + VocabTableName: + Description: Vocab DynamoDB Table Name + Value: !Ref VocabTable + + BucketName: + Description: S3 Bucket Name + Value: group2-englishstudy From 748106ab625533f5ba5dca6f92e3271e01bebd80 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Wed, 7 Jan 2026 17:05:28 +0900 Subject: [PATCH 014/528] =?UTF-8?q?chore:=20=EA=B8=B0=EC=A1=B4=20chatting/?= =?UTF-8?q?,=20vocabulary/=20=ED=8F=B4=EB=8D=94=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- chatting/.gitignore | 28 - chatting/ChatFunction/build.gradle | 64 -- chatting/ChatFunction/gradlew | 248 -------- chatting/ChatFunction/gradlew.bat | 93 --- chatting/ChatFunction/settings.gradle | 1 - .../serverless/chatting/dto/ApiResponse.java | 33 - .../chatting/handler/ChatAIHandler.java | 60 -- .../chatting/handler/ChatMessageHandler.java | 152 ----- .../chatting/handler/ChatRoomHandler.java | 320 ---------- .../chatting/handler/ChatVoiceHandler.java | 117 ---- .../chatting/model/ChatMessage.java | 75 --- .../serverless/chatting/model/ChatRoom.java | 65 -- .../repository/ChatMessageRepository.java | 187 ------ .../repository/ChatRoomRepository.java | 216 ------- .../chatting/service/BedrockService.java | 67 -- .../chatting/service/ChatMessageService.java | 40 -- .../chatting/service/PollyService.java | 167 ----- .../src/main/resources/log4j2.xml | 17 - chatting/events/ai-generate.json | 8 - chatting/events/get-messages.json | 10 - chatting/events/post-message.json | 8 - chatting/events/voice-synthesize.json | 8 - chatting/seed-data.sh | 61 -- chatting/template.yaml | 599 ------------------ vocabulary/VocabFunction/build.gradle | 61 -- vocabulary/VocabFunction/gradlew | 234 ------- vocabulary/VocabFunction/gradlew.bat | 89 --- .../vocabulary/dto/ApiResponse.java | 33 - .../vocabulary/handler/DailyStudyHandler.java | 253 -------- .../vocabulary/handler/StatisticsHandler.java | 160 ----- .../vocabulary/handler/StatsHandler.java | 340 ---------- .../vocabulary/handler/TestHandler.java | 377 ----------- .../vocabulary/handler/UserWordHandler.java | 371 ----------- .../vocabulary/handler/VoiceHandler.java | 123 ---- .../vocabulary/handler/WordHandler.java | 337 ---------- .../vocabulary/model/DailyStudy.java | 74 --- .../vocabulary/model/TestResult.java | 74 --- .../serverless/vocabulary/model/UserWord.java | 93 --- .../serverless/vocabulary/model/Word.java | 83 --- .../repository/DailyStudyRepository.java | 161 ----- .../repository/TestResultRepository.java | 137 ---- .../repository/UserWordRepository.java | 260 -------- .../vocabulary/repository/WordRepository.java | 264 -------- .../vocabulary/service/PollyService.java | 160 ----- .../src/main/resources/log4j2.xml | 17 - vocabulary/seed-data/words.json | 73 --- vocabulary/template.yaml | 329 ---------- 47 files changed, 6747 deletions(-) delete mode 100644 chatting/.gitignore delete mode 100644 chatting/ChatFunction/build.gradle delete mode 100755 chatting/ChatFunction/gradlew delete mode 100644 chatting/ChatFunction/gradlew.bat delete mode 100644 chatting/ChatFunction/settings.gradle delete mode 100644 chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/dto/ApiResponse.java delete mode 100644 chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatAIHandler.java delete mode 100644 chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatMessageHandler.java delete mode 100644 chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatRoomHandler.java delete mode 100644 chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatVoiceHandler.java delete mode 100644 chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/model/ChatMessage.java delete mode 100644 chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/model/ChatRoom.java delete mode 100644 chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/repository/ChatMessageRepository.java delete mode 100644 chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/repository/ChatRoomRepository.java delete mode 100644 chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/service/BedrockService.java delete mode 100644 chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/service/ChatMessageService.java delete mode 100644 chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/service/PollyService.java delete mode 100644 chatting/ChatFunction/src/main/resources/log4j2.xml delete mode 100644 chatting/events/ai-generate.json delete mode 100644 chatting/events/get-messages.json delete mode 100644 chatting/events/post-message.json delete mode 100644 chatting/events/voice-synthesize.json delete mode 100755 chatting/seed-data.sh delete mode 100644 chatting/template.yaml delete mode 100644 vocabulary/VocabFunction/build.gradle delete mode 100755 vocabulary/VocabFunction/gradlew delete mode 100644 vocabulary/VocabFunction/gradlew.bat delete mode 100644 vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/dto/ApiResponse.java delete mode 100644 vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/DailyStudyHandler.java delete mode 100644 vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/StatisticsHandler.java delete mode 100644 vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/StatsHandler.java delete mode 100644 vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/TestHandler.java delete mode 100644 vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/UserWordHandler.java delete mode 100644 vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/VoiceHandler.java delete mode 100644 vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/WordHandler.java delete mode 100644 vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/DailyStudy.java delete mode 100644 vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/TestResult.java delete mode 100644 vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/UserWord.java delete mode 100644 vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/Word.java delete mode 100644 vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/DailyStudyRepository.java delete mode 100644 vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/TestResultRepository.java delete mode 100644 vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/UserWordRepository.java delete mode 100644 vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/WordRepository.java delete mode 100644 vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/service/PollyService.java delete mode 100644 vocabulary/VocabFunction/src/main/resources/log4j2.xml delete mode 100644 vocabulary/seed-data/words.json delete mode 100644 vocabulary/template.yaml diff --git a/chatting/.gitignore b/chatting/.gitignore deleted file mode 100644 index 733b3f5c..00000000 --- a/chatting/.gitignore +++ /dev/null @@ -1,28 +0,0 @@ -# Gradle -.gradle/ -build/ -!gradle/wrapper/gradle-wrapper.jar - -# IDE -.idea/ -*.iml -*.ipr -*.iws -.project -.classpath -.settings/ - -# SAM -.aws-sam/ -samconfig.toml.bak - -# Local testing -env.json - -# OS -.DS_Store -Thumbs.db - -# Logs -*.log -*.mp3 diff --git a/chatting/ChatFunction/build.gradle b/chatting/ChatFunction/build.gradle deleted file mode 100644 index 5ea65dba..00000000 --- a/chatting/ChatFunction/build.gradle +++ /dev/null @@ -1,64 +0,0 @@ -plugins { - id 'java' -} - -group = 'com.mzc.secondproject.serverless' -version = '1.0.0' - -java { - toolchain { - languageVersion = JavaLanguageVersion.of(21) - } -} - -repositories { - mavenCentral() -} - -dependencies { - // AWS Lambda Core - implementation 'com.amazonaws:aws-lambda-java-core:1.2.3' - implementation 'com.amazonaws:aws-lambda-java-events:3.11.4' - - // AWS SDK v2 - implementation platform('software.amazon.awssdk:bom:2.24.0') - implementation 'software.amazon.awssdk:dynamodb' - implementation 'software.amazon.awssdk:dynamodb-enhanced' - implementation 'software.amazon.awssdk:s3' - implementation 'software.amazon.awssdk:polly' - implementation 'software.amazon.awssdk:bedrockruntime' - - // JSON Processing - implementation 'com.google.code.gson:gson:2.10.1' - - // Password Hashing - implementation 'org.mindrot:jbcrypt:0.4' - - // Logging - implementation 'com.amazonaws:aws-lambda-java-log4j2:1.6.0' - implementation 'org.apache.logging.log4j:log4j-api:2.22.1' - implementation 'org.apache.logging.log4j:log4j-core:2.22.1' - implementation 'org.apache.logging.log4j:log4j-slf4j2-impl:2.22.1' - - // Lombok - compileOnly 'org.projectlombok:lombok:1.18.30' - annotationProcessor 'org.projectlombok:lombok:1.18.30' - - // Testing - testImplementation 'org.junit.jupiter:junit-jupiter:5.10.1' - testImplementation 'org.mockito:mockito-core:5.8.0' -} - -test { - useJUnitPlatform() -} - -task buildZip(type: Zip) { - from compileJava - from processResources - into('lib') { - from configurations.runtimeClasspath - } -} - -build.dependsOn buildZip diff --git a/chatting/ChatFunction/gradlew b/chatting/ChatFunction/gradlew deleted file mode 100755 index adff685a..00000000 --- a/chatting/ChatFunction/gradlew +++ /dev/null @@ -1,248 +0,0 @@ -#!/bin/sh - -# -# Copyright © 2015 the original authors. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -# SPDX-License-Identifier: Apache-2.0 -# - -############################################################################## -# -# Gradle start up script for POSIX generated by Gradle. -# -# Important for running: -# -# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is -# noncompliant, but you have some other compliant shell such as ksh or -# bash, then to run this script, type that shell name before the whole -# command line, like: -# -# ksh Gradle -# -# Busybox and similar reduced shells will NOT work, because this script -# requires all of these POSIX shell features: -# * functions; -# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», -# «${var#prefix}», «${var%suffix}», and «$( cmd )»; -# * compound commands having a testable exit status, especially «case»; -# * various built-in commands including «command», «set», and «ulimit». -# -# Important for patching: -# -# (2) This script targets any POSIX shell, so it avoids extensions provided -# by Bash, Ksh, etc; in particular arrays are avoided. -# -# The "traditional" practice of packing multiple parameters into a -# space-separated string is a well documented source of bugs and security -# problems, so this is (mostly) avoided, by progressively accumulating -# options in "$@", and eventually passing that to Java. -# -# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, -# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; -# see the in-line comments for details. -# -# There are tweaks for specific operating systems such as AIX, CygWin, -# Darwin, MinGW, and NonStop. -# -# (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt -# within the Gradle project. -# -# You can find Gradle at https://github.com/gradle/gradle/. -# -############################################################################## - -# Attempt to set APP_HOME - -# Resolve links: $0 may be a link -app_path=$0 - -# Need this for daisy-chained symlinks. -while - APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path - [ -h "$app_path" ] -do - ls=$( ls -ld "$app_path" ) - link=${ls#*' -> '} - case $link in #( - /*) app_path=$link ;; #( - *) app_path=$APP_HOME$link ;; - esac -done - -# This is normally unused -# shellcheck disable=SC2034 -APP_BASE_NAME=${0##*/} -# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit - -# Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD=maximum - -warn () { - echo "$*" -} >&2 - -die () { - echo - echo "$*" - echo - exit 1 -} >&2 - -# OS specific support (must be 'true' or 'false'). -cygwin=false -msys=false -darwin=false -nonstop=false -case "$( uname )" in #( - CYGWIN* ) cygwin=true ;; #( - Darwin* ) darwin=true ;; #( - MSYS* | MINGW* ) msys=true ;; #( - NONSTOP* ) nonstop=true ;; -esac - - - -# Determine the Java command to use to start the JVM. -if [ -n "$JAVA_HOME" ] ; then - if [ -x "$JAVA_HOME/jre/sh/java" ] ; then - # IBM's JDK on AIX uses strange locations for the executables - JAVACMD=$JAVA_HOME/jre/sh/java - else - JAVACMD=$JAVA_HOME/bin/java - fi - if [ ! -x "$JAVACMD" ] ; then - die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -else - JAVACMD=java - if ! command -v java >/dev/null 2>&1 - then - die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -fi - -# Increase the maximum file descriptors if we can. -if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then - case $MAX_FD in #( - max*) - # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 - MAX_FD=$( ulimit -H -n ) || - warn "Could not query maximum file descriptor limit" - esac - case $MAX_FD in #( - '' | soft) :;; #( - *) - # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 - ulimit -n "$MAX_FD" || - warn "Could not set maximum file descriptor limit to $MAX_FD" - esac -fi - -# Collect all arguments for the java command, stacking in reverse order: -# * args from the command line -# * the main class name -# * -classpath -# * -D...appname settings -# * --module-path (only if needed) -# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. - -# For Cygwin or MSYS, switch paths to Windows format before running java -if "$cygwin" || "$msys" ; then - APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - - JAVACMD=$( cygpath --unix "$JAVACMD" ) - - # Now convert the arguments - kludge to limit ourselves to /bin/sh - for arg do - if - case $arg in #( - -*) false ;; # don't mess with options #( - /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath - [ -e "$t" ] ;; #( - *) false ;; - esac - then - arg=$( cygpath --path --ignore --mixed "$arg" ) - fi - # Roll the args list around exactly as many times as the number of - # args, so each arg winds up back in the position where it started, but - # possibly modified. - # - # NB: a `for` loop captures its iteration list before it begins, so - # changing the positional parameters here affects neither the number of - # iterations, nor the values presented in `arg`. - shift # remove old arg - set -- "$@" "$arg" # push replacement arg - done -fi - - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' - -# Collect all arguments for the java command: -# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, -# and any embedded shellness will be escaped. -# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be -# treated as '${Hostname}' itself on the command line. - -set -- \ - "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ - "$@" - -# Stop when "xargs" is not available. -if ! command -v xargs >/dev/null 2>&1 -then - die "xargs is not available" -fi - -# Use "xargs" to parse quoted args. -# -# With -n1 it outputs one arg per line, with the quotes and backslashes removed. -# -# In Bash we could simply go: -# -# readarray ARGS < <( xargs -n1 <<<"$var" ) && -# set -- "${ARGS[@]}" "$@" -# -# but POSIX shell has neither arrays nor command substitution, so instead we -# post-process each arg (as a line of input to sed) to backslash-escape any -# character that might be a shell metacharacter, then use eval to reverse -# that process (while maintaining the separation between arguments), and wrap -# the whole thing up as a single "set" statement. -# -# This will of course break if any of these variables contains a newline or -# an unmatched quote. -# - -eval "set -- $( - printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | - xargs -n1 | - sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | - tr '\n' ' ' - )" '"$@"' - -exec "$JAVACMD" "$@" diff --git a/chatting/ChatFunction/gradlew.bat b/chatting/ChatFunction/gradlew.bat deleted file mode 100644 index c4bdd3ab..00000000 --- a/chatting/ChatFunction/gradlew.bat +++ /dev/null @@ -1,93 +0,0 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem -@rem SPDX-License-Identifier: Apache-2.0 -@rem - -@if "%DEBUG%"=="" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%"=="" set DIRNAME=. -@rem This is normally unused -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if %ERRORLEVEL% equ 0 goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:execute -@rem Setup the command line - - - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* - -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega diff --git a/chatting/ChatFunction/settings.gradle b/chatting/ChatFunction/settings.gradle deleted file mode 100644 index aa3a6d7d..00000000 --- a/chatting/ChatFunction/settings.gradle +++ /dev/null @@ -1 +0,0 @@ -rootProject.name = 'chatting-function' diff --git a/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/dto/ApiResponse.java b/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/dto/ApiResponse.java deleted file mode 100644 index 2d34f585..00000000 --- a/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/dto/ApiResponse.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.mzc.secondproject.serverless.chatting.dto; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class ApiResponse { - - private boolean success; - private String message; - private T data; - private String error; - - public static ApiResponse success(String message, T data) { - return ApiResponse.builder() - .success(true) - .message(message) - .data(data) - .build(); - } - - public static ApiResponse error(String errorMessage) { - return ApiResponse.builder() - .success(false) - .error(errorMessage) - .build(); - } -} diff --git a/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatAIHandler.java b/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatAIHandler.java deleted file mode 100644 index 8396b285..00000000 --- a/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatAIHandler.java +++ /dev/null @@ -1,60 +0,0 @@ -package com.mzc.secondproject.serverless.chatting.handler; - -import com.amazonaws.services.lambda.runtime.Context; -import com.amazonaws.services.lambda.runtime.RequestHandler; -import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; -import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; -import com.mzc.secondproject.serverless.chatting.dto.ApiResponse; -import com.mzc.secondproject.serverless.chatting.service.BedrockService; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.Map; - -public class ChatAIHandler implements RequestHandler { - - private static final Logger logger = LoggerFactory.getLogger(ChatAIHandler.class); - private static final Gson gson = new GsonBuilder().setPrettyPrinting().create(); - - private final BedrockService bedrockService; - - public ChatAIHandler() { - this.bedrockService = new BedrockService(); - } - - @Override - public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { - logger.info("Received AI generation request"); - - try { - if (!"POST".equals(request.getHttpMethod())) { - return createResponse(405, ApiResponse.error("Method not allowed")); - } - - String body = request.getBody(); - // TODO: Parse request and generate AI response using Bedrock - - String aiResponse = bedrockService.generateResponse("Hello, how can I help you?"); - - return createResponse(200, ApiResponse.success("AI response generated", Map.of("response", aiResponse))); - - } catch (Exception e) { - logger.error("Error generating AI response", e); - return createResponse(500, ApiResponse.error("Internal server error: " + e.getMessage())); - } - } - - private APIGatewayProxyResponseEvent createResponse(int statusCode, Object body) { - return new APIGatewayProxyResponseEvent() - .withStatusCode(statusCode) - .withHeaders(Map.of( - "Content-Type", "application/json", - "Access-Control-Allow-Origin", "*", - "Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS", - "Access-Control-Allow-Headers", "Content-Type,Authorization" - )) - .withBody(gson.toJson(body)); - } -} diff --git a/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatMessageHandler.java b/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatMessageHandler.java deleted file mode 100644 index fb700dae..00000000 --- a/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatMessageHandler.java +++ /dev/null @@ -1,152 +0,0 @@ -package com.mzc.secondproject.serverless.chatting.handler; - -import com.amazonaws.services.lambda.runtime.Context; -import com.amazonaws.services.lambda.runtime.RequestHandler; -import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; -import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; -import com.mzc.secondproject.serverless.chatting.dto.ApiResponse; -import com.mzc.secondproject.serverless.chatting.model.ChatMessage; -import com.mzc.secondproject.serverless.chatting.model.ChatRoom; -import com.mzc.secondproject.serverless.chatting.repository.ChatMessageRepository; -import com.mzc.secondproject.serverless.chatting.repository.ChatRoomRepository; -import com.mzc.secondproject.serverless.chatting.service.ChatMessageService; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.time.Instant; -import java.util.HashMap; -import java.util.Map; -import java.util.Optional; -import java.util.UUID; - -public class ChatMessageHandler implements RequestHandler { - - private static final Logger logger = LoggerFactory.getLogger(ChatMessageHandler.class); - private static final Gson gson = new GsonBuilder().setPrettyPrinting().create(); - - private final ChatMessageService chatMessageService; - private final ChatRoomRepository chatRoomRepository; - - public ChatMessageHandler() { - this.chatMessageService = new ChatMessageService(); - this.chatRoomRepository = new ChatRoomRepository(); - } - - @Override - public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { - logger.info("Received request: {} {}", request.getHttpMethod(), request.getPath()); - - try { - return switch (request.getHttpMethod()) { - case "POST" -> handlePost(request); - case "GET" -> handleGet(request); - default -> createResponse(405, ApiResponse.error("Method not allowed")); - }; - } catch (Exception e) { - logger.error("Error processing request", e); - return createResponse(500, ApiResponse.error("Internal server error: " + e.getMessage())); - } - } - - private APIGatewayProxyResponseEvent handlePost(APIGatewayProxyRequestEvent request) { - Map pathParams = request.getPathParameters(); - String roomId = pathParams != null ? pathParams.get("roomId") : null; - - if (roomId == null) { - return createResponse(400, ApiResponse.error("roomId is required")); - } - - String body = request.getBody(); - Map requestBody = gson.fromJson(body, Map.class); - - String userId = (String) requestBody.get("userId"); - String content = (String) requestBody.get("content"); - String messageType = (String) requestBody.getOrDefault("messageType", "TEXT"); - - if (userId == null || content == null) { - return createResponse(400, ApiResponse.error("userId and content are required")); - } - - String messageId = UUID.randomUUID().toString(); - String now = Instant.now().toString(); - - ChatMessage message = ChatMessage.builder() - .pk("ROOM#" + roomId) - .sk("MSG#" + now + "#" + messageId) - .gsi1pk("USER#" + userId) - .gsi1sk("MSG#" + now) - .gsi2pk("MSG#" + messageId) // GSI2: messageId로 직접 조회용 - .gsi2sk("ROOM#" + roomId) - .messageId(messageId) - .roomId(roomId) - .userId(userId) - .content(content) - .messageType(messageType) - .createdAt(now) - .build(); - - ChatMessage savedMessage = chatMessageService.saveMessage(message); - - // 채팅방 lastMessageAt 업데이트 (UpdateExpression으로 1회 호출) - chatRoomRepository.updateLastMessageAt(roomId, now); - - logger.info("Message sent: {} in room: {}", messageId, roomId); - return createResponse(201, ApiResponse.success("Message sent", savedMessage)); - } - - private APIGatewayProxyResponseEvent handleGet(APIGatewayProxyRequestEvent request) { - Map pathParams = request.getPathParameters(); - Map queryParams = request.getQueryStringParameters(); - - String roomId = pathParams != null ? pathParams.get("roomId") : null; - String messageId = pathParams != null ? pathParams.get("messageId") : null; - - if (roomId == null) { - return createResponse(400, ApiResponse.error("roomId is required")); - } - - if (messageId != null) { - // Get single message - Optional message = chatMessageService.getMessage(roomId, messageId); - if (message.isEmpty()) { - return createResponse(404, ApiResponse.error("Message not found")); - } - return createResponse(200, ApiResponse.success("Message retrieved", message.get())); - } - - // 페이지네이션 파라미터 - int limit = 20; // 기본값 - String cursor = null; - - if (queryParams != null) { - if (queryParams.get("limit") != null) { - limit = Math.min(Integer.parseInt(queryParams.get("limit")), 50); // 최대 50 - } - cursor = queryParams.get("cursor"); - } - - // 메시지 목록 조회 (최신순, 페이지네이션) - ChatMessageRepository.MessagePage messagePage = chatMessageService.getMessagesByRoomWithPagination(roomId, limit, cursor); - - Map result = new HashMap<>(); - result.put("messages", messagePage.getMessages()); - result.put("nextCursor", messagePage.getNextCursor()); - result.put("hasMore", messagePage.hasMore()); - - return createResponse(200, ApiResponse.success("Messages retrieved", result)); - } - - private APIGatewayProxyResponseEvent createResponse(int statusCode, Object body) { - return new APIGatewayProxyResponseEvent() - .withStatusCode(statusCode) - .withHeaders(Map.of( - "Content-Type", "application/json", - "Access-Control-Allow-Origin", "*", - "Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS", - "Access-Control-Allow-Headers", "Content-Type,Authorization" - )) - .withBody(gson.toJson(body)); - } -} diff --git a/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatRoomHandler.java b/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatRoomHandler.java deleted file mode 100644 index d1414843..00000000 --- a/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatRoomHandler.java +++ /dev/null @@ -1,320 +0,0 @@ -package com.mzc.secondproject.serverless.chatting.handler; - -import com.amazonaws.services.lambda.runtime.Context; -import com.amazonaws.services.lambda.runtime.RequestHandler; -import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; -import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; -import com.mzc.secondproject.serverless.chatting.dto.ApiResponse; -import com.mzc.secondproject.serverless.chatting.model.ChatRoom; -import com.mzc.secondproject.serverless.chatting.repository.ChatRoomRepository; -import org.mindrot.jbcrypt.BCrypt; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.time.Instant; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.UUID; - -public class ChatRoomHandler implements RequestHandler { - - private static final Logger logger = LoggerFactory.getLogger(ChatRoomHandler.class); - private static final Gson gson = new GsonBuilder().setPrettyPrinting().create(); - - private final ChatRoomRepository roomRepository; - - public ChatRoomHandler() { - this.roomRepository = new ChatRoomRepository(); - } - - @Override - public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { - String httpMethod = request.getHttpMethod(); - String path = request.getPath(); - - logger.info("Received request: {} {}", httpMethod, path); - - try { - // POST /chat/rooms - 방 생성 - if ("POST".equals(httpMethod) && path.endsWith("/rooms")) { - return createRoom(request); - } - - // GET /chat/rooms - 방 목록 조회 - if ("GET".equals(httpMethod) && path.endsWith("/rooms")) { - return getRooms(request); - } - - // GET /chat/rooms/{roomId} - 방 상세 조회 - if ("GET".equals(httpMethod) && path.contains("/rooms/") && !path.contains("/join")) { - return getRoom(request); - } - - // POST /chat/rooms/{roomId}/join - 방 입장 - if ("POST".equals(httpMethod) && path.endsWith("/join")) { - return joinRoom(request); - } - - // POST /chat/rooms/{roomId}/leave - 방 퇴장 - if ("POST".equals(httpMethod) && path.endsWith("/leave")) { - return leaveRoom(request); - } - - // DELETE /chat/rooms/{roomId} - 방 삭제 - if ("DELETE".equals(httpMethod) && path.contains("/rooms/")) { - return deleteRoom(request); - } - - return createResponse(404, ApiResponse.error("Not found")); - - } catch (Exception e) { - logger.error("Error handling request", e); - return createResponse(500, ApiResponse.error("Internal server error: " + e.getMessage())); - } - } - - private APIGatewayProxyResponseEvent createRoom(APIGatewayProxyRequestEvent request) { - String body = request.getBody(); - Map requestBody = gson.fromJson(body, Map.class); - - String name = (String) requestBody.get("name"); - String description = (String) requestBody.get("description"); - String level = (String) requestBody.getOrDefault("level", "beginner"); - Integer maxMembers = ((Double) requestBody.getOrDefault("maxMembers", 6.0)).intValue(); - Boolean isPrivate = (Boolean) requestBody.getOrDefault("isPrivate", false); - String password = (String) requestBody.get("password"); - String createdBy = (String) requestBody.get("createdBy"); - - if (name == null || name.isEmpty()) { - return createResponse(400, ApiResponse.error("name is required")); - } - - String roomId = UUID.randomUUID().toString(); - String now = Instant.now().toString(); - - ChatRoom room = ChatRoom.builder() - .pk("ROOM#" + roomId) - .sk("METADATA") - .gsi1pk("ROOMS") - .gsi1sk(level + "#" + now) - .roomId(roomId) - .name(name) - .description(description) - .level(level) - .currentMembers(1) // 방장 포함 - .maxMembers(maxMembers) - .isPrivate(isPrivate) - .password(isPrivate && password != null ? BCrypt.hashpw(password, BCrypt.gensalt()) : null) - .createdBy(createdBy) - .createdAt(now) - .lastMessageAt(now) - .memberIds(new ArrayList<>(List.of(createdBy))) - .build(); - - roomRepository.save(room); - - // 비밀번호는 응답에서 제외 - room.setPassword(null); - - logger.info("Created room: {}", roomId); - return createResponse(201, ApiResponse.success("Room created", room)); - } - - private APIGatewayProxyResponseEvent getRooms(APIGatewayProxyRequestEvent request) { - Map queryParams = request.getQueryStringParameters(); - - String level = queryParams != null ? queryParams.get("level") : null; - String userId = queryParams != null ? queryParams.get("userId") : null; - String joined = queryParams != null ? queryParams.get("joined") : null; - String cursor = queryParams != null ? queryParams.get("cursor") : null; - - int limit = 10; // 기본값 - if (queryParams != null && queryParams.get("limit") != null) { - limit = Math.min(Integer.parseInt(queryParams.get("limit")), 20); // 최대 20 - } - - ChatRoomRepository.RoomPage roomPage; - if (level != null && !level.isEmpty()) { - roomPage = roomRepository.findByLevelWithPagination(level, limit, cursor); - } else { - roomPage = roomRepository.findAllWithPagination(limit, cursor); - } - - List rooms = roomPage.getRooms(); - - // "참여중" 필터 - userId가 memberIds에 포함된 방만 - if ("true".equals(joined) && userId != null) { - rooms = rooms.stream() - .filter(room -> room.getMemberIds() != null && room.getMemberIds().contains(userId)) - .toList(); - } - - // 비밀번호 제외 - rooms.forEach(room -> room.setPassword(null)); - - Map result = new HashMap<>(); - result.put("rooms", rooms); - result.put("nextCursor", roomPage.getNextCursor()); - result.put("hasMore", roomPage.hasMore()); - - return createResponse(200, ApiResponse.success("Rooms retrieved", result)); - } - - private APIGatewayProxyResponseEvent getRoom(APIGatewayProxyRequestEvent request) { - Map pathParams = request.getPathParameters(); - String roomId = pathParams != null ? pathParams.get("roomId") : null; - - if (roomId == null) { - return createResponse(400, ApiResponse.error("roomId is required")); - } - - Optional optRoom = roomRepository.findById(roomId); - if (optRoom.isEmpty()) { - return createResponse(404, ApiResponse.error("Room not found")); - } - - ChatRoom room = optRoom.get(); - room.setPassword(null); - - return createResponse(200, ApiResponse.success("Room retrieved", room)); - } - - private APIGatewayProxyResponseEvent joinRoom(APIGatewayProxyRequestEvent request) { - Map pathParams = request.getPathParameters(); - String roomId = pathParams != null ? pathParams.get("roomId") : null; - - String body = request.getBody(); - Map requestBody = gson.fromJson(body, Map.class); - String userId = requestBody.get("userId"); - String password = requestBody.get("password"); - - if (roomId == null || userId == null) { - return createResponse(400, ApiResponse.error("roomId and userId are required")); - } - - Optional optRoom = roomRepository.findById(roomId); - if (optRoom.isEmpty()) { - return createResponse(404, ApiResponse.error("Room not found")); - } - - ChatRoom room = optRoom.get(); - - // 비밀번호 확인 (BCrypt 해시 검증) - if (room.getIsPrivate()) { - if (password == null || room.getPassword() == null || !BCrypt.checkpw(password, room.getPassword())) { - return createResponse(403, ApiResponse.error("Invalid password")); - } - } - - // 인원 확인 - if (room.getCurrentMembers() >= room.getMaxMembers()) { - return createResponse(400, ApiResponse.error("Room is full")); - } - - // 이미 참여 중인지 확인 - if (room.getMemberIds() != null && room.getMemberIds().contains(userId)) { - room.setPassword(null); - return createResponse(200, ApiResponse.success("Already joined", room)); - } - - // 멤버 추가 - if (room.getMemberIds() == null) { - room.setMemberIds(new ArrayList<>()); - } - room.getMemberIds().add(userId); - room.setCurrentMembers(room.getCurrentMembers() + 1); - - roomRepository.save(room); - room.setPassword(null); - - logger.info("User {} joined room {}", userId, roomId); - return createResponse(200, ApiResponse.success("Joined room", room)); - } - - private APIGatewayProxyResponseEvent leaveRoom(APIGatewayProxyRequestEvent request) { - Map pathParams = request.getPathParameters(); - String roomId = pathParams != null ? pathParams.get("roomId") : null; - - String body = request.getBody(); - Map requestBody = gson.fromJson(body, Map.class); - String userId = requestBody.get("userId"); - - if (roomId == null || userId == null) { - return createResponse(400, ApiResponse.error("roomId and userId are required")); - } - - Optional optRoom = roomRepository.findById(roomId); - if (optRoom.isEmpty()) { - return createResponse(404, ApiResponse.error("Room not found")); - } - - ChatRoom room = optRoom.get(); - - // 멤버에서 제거 - if (room.getMemberIds() != null) { - room.getMemberIds().remove(userId); - room.setCurrentMembers(Math.max(0, room.getCurrentMembers() - 1)); - } - - // 방장이 나가거나 인원이 0이면 방 삭제 - if (userId.equals(room.getCreatedBy()) || room.getCurrentMembers() <= 0) { - roomRepository.delete(roomId); - return createResponse(200, ApiResponse.success("Room deleted", null)); - } - - roomRepository.save(room); - room.setPassword(null); - - logger.info("User {} left room {}", userId, roomId); - return createResponse(200, ApiResponse.success("Left room", room)); - } - - private APIGatewayProxyResponseEvent deleteRoom(APIGatewayProxyRequestEvent request) { - Map pathParams = request.getPathParameters(); - Map queryParams = request.getQueryStringParameters(); - - String roomId = pathParams != null ? pathParams.get("roomId") : null; - String userId = queryParams != null ? queryParams.get("userId") : null; - - if (roomId == null) { - return createResponse(400, ApiResponse.error("roomId is required")); - } - - if (userId == null) { - return createResponse(400, ApiResponse.error("userId is required")); - } - - // 방장 확인 - Optional optRoom = roomRepository.findById(roomId); - if (optRoom.isEmpty()) { - return createResponse(404, ApiResponse.error("Room not found")); - } - - ChatRoom room = optRoom.get(); - if (!userId.equals(room.getCreatedBy())) { - return createResponse(403, ApiResponse.error("Only the room owner can delete the room")); - } - - roomRepository.delete(roomId); - logger.info("Deleted room: {} by owner: {}", roomId, userId); - - return createResponse(200, ApiResponse.success("Room deleted", null)); - } - - private APIGatewayProxyResponseEvent createResponse(int statusCode, Object body) { - return new APIGatewayProxyResponseEvent() - .withStatusCode(statusCode) - .withHeaders(Map.of( - "Content-Type", "application/json", - "Access-Control-Allow-Origin", "*", - "Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS", - "Access-Control-Allow-Headers", "Content-Type,Authorization" - )) - .withBody(gson.toJson(body)); - } -} diff --git a/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatVoiceHandler.java b/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatVoiceHandler.java deleted file mode 100644 index 90f711cd..00000000 --- a/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatVoiceHandler.java +++ /dev/null @@ -1,117 +0,0 @@ -package com.mzc.secondproject.serverless.chatting.handler; - -import com.amazonaws.services.lambda.runtime.Context; -import com.amazonaws.services.lambda.runtime.RequestHandler; -import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; -import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; -import com.mzc.secondproject.serverless.chatting.dto.ApiResponse; -import com.mzc.secondproject.serverless.chatting.model.ChatMessage; -import com.mzc.secondproject.serverless.chatting.repository.ChatMessageRepository; -import com.mzc.secondproject.serverless.chatting.service.PollyService; -import com.mzc.secondproject.serverless.chatting.service.PollyService.VoiceSynthesisResult; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.Map; -import java.util.Optional; - -public class ChatVoiceHandler implements RequestHandler { - - private static final Logger logger = LoggerFactory.getLogger(ChatVoiceHandler.class); - private static final Gson gson = new GsonBuilder().setPrettyPrinting().create(); - - private final PollyService pollyService; - private final ChatMessageRepository messageRepository; - - public ChatVoiceHandler() { - this.pollyService = new PollyService(); - this.messageRepository = new ChatMessageRepository(); - } - - @Override - public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { - logger.info("Received voice synthesis request"); - - try { - if (!"POST".equals(request.getHttpMethod())) { - return createResponse(405, ApiResponse.error("Method not allowed")); - } - - String body = request.getBody(); - Map requestBody = gson.fromJson(body, Map.class); - String messageId = requestBody.get("messageId"); - String roomId = requestBody.get("roomId"); - String voice = requestBody.getOrDefault("voice", "FEMALE"); - - if (messageId == null || messageId.isEmpty()) { - return createResponse(400, ApiResponse.error("messageId is required")); - } - if (roomId == null || roomId.isEmpty()) { - return createResponse(400, ApiResponse.error("roomId is required")); - } - - // 메시지 조회 - Optional messageOpt = messageRepository.findByRoomIdAndMessageId(roomId, messageId); - if (messageOpt.isEmpty()) { - return createResponse(404, ApiResponse.error("Message not found")); - } - - ChatMessage message = messageOpt.get(); - boolean isMale = "MALE".equalsIgnoreCase(voice); - - // 캐시된 음성 키 확인 - String cachedKey = isMale ? message.getMaleVoiceKey() : message.getFemaleVoiceKey(); - - String audioUrl; - boolean cached; - - if (cachedKey != null && !cachedKey.isEmpty()) { - // 캐시 히트: DynamoDB에 키가 있으면 S3에서 URL 생성 - logger.info("DB cache hit for message: {}, voice: {}", messageId, voice); - audioUrl = pollyService.getPresignedUrl(cachedKey); - cached = true; - } else { - // 캐시 미스: Polly 변환 → S3 저장 → DynamoDB 업데이트 - VoiceSynthesisResult result = pollyService.synthesizeSpeechForMessage( - messageId, message.getContent(), voice); - - // DynamoDB에 S3 키 저장 - if (isMale) { - message.setMaleVoiceKey(result.getS3Key()); - } else { - message.setFemaleVoiceKey(result.getS3Key()); - } - messageRepository.save(message); - - audioUrl = result.getAudioUrl(); - cached = result.isCached(); - } - - return createResponse(200, ApiResponse.success( - cached ? "Speech retrieved from cache" : "Speech synthesized", - Map.of( - "audioUrl", audioUrl, - "cached", cached - ) - )); - - } catch (Exception e) { - logger.error("Error synthesizing speech", e); - return createResponse(500, ApiResponse.error("Internal server error: " + e.getMessage())); - } - } - - private APIGatewayProxyResponseEvent createResponse(int statusCode, Object body) { - return new APIGatewayProxyResponseEvent() - .withStatusCode(statusCode) - .withHeaders(Map.of( - "Content-Type", "application/json", - "Access-Control-Allow-Origin", "*", - "Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS", - "Access-Control-Allow-Headers", "Content-Type,Authorization" - )) - .withBody(gson.toJson(body)); - } -} diff --git a/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/model/ChatMessage.java b/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/model/ChatMessage.java deleted file mode 100644 index d0a2726e..00000000 --- a/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/model/ChatMessage.java +++ /dev/null @@ -1,75 +0,0 @@ -package com.mzc.secondproject.serverless.chatting.model; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbAttribute; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondaryPartitionKey; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondarySortKey; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSortKey; - -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -@DynamoDbBean -public class ChatMessage { - - private String pk; // ROOM#{roomId} - private String sk; // MSG#{timestamp}#{messageId} - private String gsi1pk; // USER#{userId} - private String gsi1sk; // MSG#{timestamp} - private String gsi2pk; // MSG#{messageId} - messageId로 직접 조회용 - private String gsi2sk; // ROOM#{roomId} - - private String messageId; - private String roomId; - private String userId; - private String content; - private String messageType; // TEXT, IMAGE, VOICE, AI_RESPONSE - private String createdAt; - private Long ttl; - - // 음성 캐시용 S3 키 (voice/{messageId}_{voice}.mp3) - private String maleVoiceKey; - private String femaleVoiceKey; - - @DynamoDbPartitionKey - @DynamoDbAttribute("PK") - public String getPk() { - return pk; - } - - @DynamoDbSortKey - @DynamoDbAttribute("SK") - public String getSk() { - return sk; - } - - @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") - @DynamoDbAttribute("GSI1PK") - public String getGsi1pk() { - return gsi1pk; - } - - @DynamoDbSecondarySortKey(indexNames = "GSI1") - @DynamoDbAttribute("GSI1SK") - public String getGsi1sk() { - return gsi1sk; - } - - @DynamoDbSecondaryPartitionKey(indexNames = "GSI2") - @DynamoDbAttribute("GSI2PK") - public String getGsi2pk() { - return gsi2pk; - } - - @DynamoDbSecondarySortKey(indexNames = "GSI2") - @DynamoDbAttribute("GSI2SK") - public String getGsi2sk() { - return gsi2sk; - } -} diff --git a/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/model/ChatRoom.java b/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/model/ChatRoom.java deleted file mode 100644 index cfa20cc9..00000000 --- a/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/model/ChatRoom.java +++ /dev/null @@ -1,65 +0,0 @@ -package com.mzc.secondproject.serverless.chatting.model; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbAttribute; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondaryPartitionKey; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondarySortKey; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSortKey; - -import java.util.List; - -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -@DynamoDbBean -public class ChatRoom { - - private String pk; // ROOM#{roomId} - private String sk; // METADATA - private String gsi1pk; // ROOMS - private String gsi1sk; // {level}#{createdAt} - - private String roomId; - private String name; - private String description; - private String level; // beginner, intermediate, advanced - private Integer currentMembers; - private Integer maxMembers; - private Boolean isPrivate; - private String password; // 비밀방 비밀번호 (해시) - private String createdBy; // 방장 userId - private String createdAt; - private String lastMessageAt; - private List memberIds; // 참여 멤버 목록 - private Long ttl; - - @DynamoDbPartitionKey - @DynamoDbAttribute("PK") - public String getPk() { - return pk; - } - - @DynamoDbSortKey - @DynamoDbAttribute("SK") - public String getSk() { - return sk; - } - - @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") - @DynamoDbAttribute("GSI1PK") - public String getGsi1pk() { - return gsi1pk; - } - - @DynamoDbSecondarySortKey(indexNames = "GSI1") - @DynamoDbAttribute("GSI1SK") - public String getGsi1sk() { - return gsi1sk; - } -} diff --git a/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/repository/ChatMessageRepository.java b/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/repository/ChatMessageRepository.java deleted file mode 100644 index 9bd2ba31..00000000 --- a/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/repository/ChatMessageRepository.java +++ /dev/null @@ -1,187 +0,0 @@ -package com.mzc.secondproject.serverless.chatting.repository; - -import com.mzc.secondproject.serverless.chatting.model.ChatMessage; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; -import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; -import software.amazon.awssdk.enhanced.dynamodb.Key; -import software.amazon.awssdk.enhanced.dynamodb.TableSchema; -import software.amazon.awssdk.enhanced.dynamodb.model.Page; -import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; -import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; -import software.amazon.awssdk.services.dynamodb.DynamoDbClient; -import software.amazon.awssdk.services.dynamodb.model.AttributeValue; - -import java.util.Base64; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; - -public class ChatMessageRepository { - - private static final Logger logger = LoggerFactory.getLogger(ChatMessageRepository.class); - - // Singleton 패턴으로 Cold Start 최적화 - private static final DynamoDbClient dynamoDbClient = DynamoDbClient.builder().build(); - private static final DynamoDbEnhancedClient enhancedClient = DynamoDbEnhancedClient.builder() - .dynamoDbClient(dynamoDbClient) - .build(); - private static final String tableName = System.getenv("CHAT_TABLE_NAME"); - - private final DynamoDbTable table; - - public ChatMessageRepository() { - this.table = enhancedClient.table(tableName, TableSchema.fromBean(ChatMessage.class)); - } - - public ChatMessage save(ChatMessage message) { - logger.info("Saving message to DynamoDB: {}", message.getMessageId()); - table.putItem(message); - return message; - } - - public Optional findByRoomIdAndMessageId(String roomId, String messageId) { - // GSI2를 사용하여 messageId로 직접 조회 (풀스캔 방지) - QueryConditional queryConditional = QueryConditional - .keyEqualTo(Key.builder() - .partitionValue("MSG#" + messageId) - .sortValue("ROOM#" + roomId) - .build()); - - return table.index("GSI2") - .query(queryConditional) - .stream() - .flatMap(page -> page.items().stream()) - .findFirst(); - } - - /** - * 채팅방 메시지 조회 - 최신순, 페이지네이션 지원 - * @param roomId 채팅방 ID - * @param limit 조회 개수 (기본 20) - * @param cursor Base64 인코딩된 커서 (무한스크롤용) - * @return 메시지 목록과 다음 페이지 커서 - */ - public MessagePage findByRoomIdWithPagination(String roomId, int limit, String cursor) { - QueryConditional queryConditional = QueryConditional - .sortBeginsWith(Key.builder() - .partitionValue("ROOM#" + roomId) - .sortValue("MSG#") - .build()); - - QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() - .queryConditional(queryConditional) - .scanIndexForward(false) // 최신순 (역순) - .limit(limit); - - // 커서 기반 페이지네이션 (Base64 디코딩) - if (cursor != null && !cursor.isEmpty()) { - Map exclusiveStartKey = decodeCursor(cursor); - if (exclusiveStartKey != null) { - requestBuilder.exclusiveStartKey(exclusiveStartKey); - } - } - - Page page = table.query(requestBuilder.build()).iterator().next(); - List messages = page.items(); - - // 다음 페이지 커서 (Base64 인코딩) - String nextCursor = encodeCursor(page.lastEvaluatedKey()); - - return new MessagePage(messages, nextCursor); - } - - /** - * 커서 인코딩 (lastEvaluatedKey -> Base64) - */ - private String encodeCursor(Map lastEvaluatedKey) { - if (lastEvaluatedKey == null || lastEvaluatedKey.isEmpty()) { - return null; - } - - StringBuilder sb = new StringBuilder(); - for (Map.Entry entry : lastEvaluatedKey.entrySet()) { - if (sb.length() > 0) sb.append("|"); - sb.append(entry.getKey()).append("=").append(entry.getValue().s()); - } - - return Base64.getUrlEncoder().encodeToString(sb.toString().getBytes()); - } - - /** - * 커서 디코딩 (Base64 -> exclusiveStartKey) - */ - private Map decodeCursor(String cursor) { - try { - String decoded = new String(Base64.getUrlDecoder().decode(cursor)); - Map result = new HashMap<>(); - - for (String pair : decoded.split("\\|")) { - String[] kv = pair.split("=", 2); - if (kv.length == 2) { - result.put(kv[0], AttributeValue.builder().s(kv[1]).build()); - } - } - - return result.isEmpty() ? null : result; - } catch (Exception e) { - logger.error("Failed to decode cursor: {}", cursor, e); - return null; - } - } - - /** - * 사용자별 메시지 조회 - 페이지네이션 지원 (OOM 방지) - */ - public MessagePage findByUserIdWithPagination(String userId, int limit, String cursor) { - QueryConditional queryConditional = QueryConditional - .keyEqualTo(Key.builder().partitionValue("USER#" + userId).build()); - - QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() - .queryConditional(queryConditional) - .scanIndexForward(false) // 최신순 - .limit(limit); - - if (cursor != null && !cursor.isEmpty()) { - Map exclusiveStartKey = decodeCursor(cursor); - if (exclusiveStartKey != null) { - requestBuilder.exclusiveStartKey(exclusiveStartKey); - } - } - - Page page = table.index("GSI1") - .query(requestBuilder.build()) - .iterator() - .next(); - - String nextCursor = encodeCursor(page.lastEvaluatedKey()); - return new MessagePage(page.items(), nextCursor); - } - - /** - * 페이지네이션 결과 클래스 - */ - public static class MessagePage { - private final List messages; - private final String nextCursor; - - public MessagePage(List messages, String nextCursor) { - this.messages = messages; - this.nextCursor = nextCursor; - } - - public List getMessages() { - return messages; - } - - public String getNextCursor() { - return nextCursor; - } - - public boolean hasMore() { - return nextCursor != null; - } - } -} diff --git a/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/repository/ChatRoomRepository.java b/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/repository/ChatRoomRepository.java deleted file mode 100644 index 4b5cef43..00000000 --- a/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/repository/ChatRoomRepository.java +++ /dev/null @@ -1,216 +0,0 @@ -package com.mzc.secondproject.serverless.chatting.repository; - -import com.mzc.secondproject.serverless.chatting.model.ChatRoom; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; -import software.amazon.awssdk.enhanced.dynamodb.DynamoDbIndex; -import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; -import software.amazon.awssdk.enhanced.dynamodb.Key; -import software.amazon.awssdk.enhanced.dynamodb.TableSchema; -import software.amazon.awssdk.enhanced.dynamodb.model.Page; -import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; -import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; -import software.amazon.awssdk.services.dynamodb.DynamoDbClient; -import software.amazon.awssdk.services.dynamodb.model.AttributeValue; -import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest; - -import java.util.Base64; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; - -public class ChatRoomRepository { - - private static final Logger logger = LoggerFactory.getLogger(ChatRoomRepository.class); - - // Singleton 패턴으로 Cold Start 최적화 - private static final DynamoDbClient dynamoDbClient = DynamoDbClient.builder().build(); - private static final DynamoDbEnhancedClient enhancedClient = DynamoDbEnhancedClient.builder() - .dynamoDbClient(dynamoDbClient) - .build(); - private static final String tableName = System.getenv("CHAT_TABLE_NAME"); - - private final DynamoDbTable table; - - public ChatRoomRepository() { - this.table = enhancedClient.table(tableName, TableSchema.fromBean(ChatRoom.class)); - } - - public ChatRoom save(ChatRoom room) { - logger.info("Saving room to DynamoDB: {}", room.getRoomId()); - table.putItem(room); - return room; - } - - public Optional findById(String roomId) { - Key key = Key.builder() - .partitionValue("ROOM#" + roomId) - .sortValue("METADATA") - .build(); - - ChatRoom room = table.getItem(key); - return Optional.ofNullable(room); - } - - /** - * 채팅방 목록 조회 - 최신순, 페이지네이션 지원 - * @param limit 조회 개수 (기본 10) - * @param cursor Base64 인코딩된 커서 (무한스크롤용) - * @return 채팅방 목록과 다음 페이지 커서 - */ - public RoomPage findAllWithPagination(int limit, String cursor) { - QueryConditional queryConditional = QueryConditional - .keyEqualTo(Key.builder().partitionValue("ROOMS").build()); - - QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() - .queryConditional(queryConditional) - .scanIndexForward(false) // 최신순 (역순) - .limit(limit); - - // 커서 기반 페이지네이션 (Base64 디코딩) - if (cursor != null && !cursor.isEmpty()) { - Map exclusiveStartKey = decodeCursor(cursor); - if (exclusiveStartKey != null) { - requestBuilder.exclusiveStartKey(exclusiveStartKey); - } - } - - DynamoDbIndex gsi1 = table.index("GSI1"); - Page page = gsi1.query(requestBuilder.build()).iterator().next(); - List rooms = page.items(); - - // 다음 페이지 커서 (Base64 인코딩) - String nextCursor = encodeCursor(page.lastEvaluatedKey()); - - return new RoomPage(rooms, nextCursor); - } - - /** - * 레벨별 채팅방 조회 - 최신순, 페이지네이션 지원 - */ - public RoomPage findByLevelWithPagination(String level, int limit, String cursor) { - QueryConditional queryConditional = QueryConditional - .sortBeginsWith(Key.builder() - .partitionValue("ROOMS") - .sortValue(level + "#") - .build()); - - QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() - .queryConditional(queryConditional) - .scanIndexForward(false) // 최신순 - .limit(limit); - - if (cursor != null && !cursor.isEmpty()) { - Map exclusiveStartKey = decodeCursor(cursor); - if (exclusiveStartKey != null) { - requestBuilder.exclusiveStartKey(exclusiveStartKey); - } - } - - DynamoDbIndex gsi1 = table.index("GSI1"); - Page page = gsi1.query(requestBuilder.build()).iterator().next(); - List rooms = page.items(); - - String nextCursor = encodeCursor(page.lastEvaluatedKey()); - - return new RoomPage(rooms, nextCursor); - } - - /** - * 커서 인코딩 (lastEvaluatedKey -> Base64) - */ - private String encodeCursor(Map lastEvaluatedKey) { - if (lastEvaluatedKey == null || lastEvaluatedKey.isEmpty()) { - return null; - } - - StringBuilder sb = new StringBuilder(); - for (Map.Entry entry : lastEvaluatedKey.entrySet()) { - if (sb.length() > 0) sb.append("|"); - sb.append(entry.getKey()).append("=").append(entry.getValue().s()); - } - - return Base64.getUrlEncoder().encodeToString(sb.toString().getBytes()); - } - - /** - * 커서 디코딩 (Base64 -> exclusiveStartKey) - */ - private Map decodeCursor(String cursor) { - try { - String decoded = new String(Base64.getUrlDecoder().decode(cursor)); - Map result = new HashMap<>(); - - for (String pair : decoded.split("\\|")) { - String[] kv = pair.split("=", 2); - if (kv.length == 2) { - result.put(kv[0], AttributeValue.builder().s(kv[1]).build()); - } - } - - return result.isEmpty() ? null : result; - } catch (Exception e) { - logger.error("Failed to decode cursor: {}", cursor, e); - return null; - } - } - - public void delete(String roomId) { - Key key = Key.builder() - .partitionValue("ROOM#" + roomId) - .sortValue("METADATA") - .build(); - - table.deleteItem(key); - logger.info("Deleted room: {}", roomId); - } - - /** - * 채팅방 lastMessageAt 업데이트 (N+1 방지 - UpdateExpression 사용) - */ - public void updateLastMessageAt(String roomId, String timestamp) { - Map key = new HashMap<>(); - key.put("PK", AttributeValue.builder().s("ROOM#" + roomId).build()); - key.put("SK", AttributeValue.builder().s("METADATA").build()); - - Map expressionValues = new HashMap<>(); - expressionValues.put(":ts", AttributeValue.builder().s(timestamp).build()); - - UpdateItemRequest updateRequest = UpdateItemRequest.builder() - .tableName(tableName) - .key(key) - .updateExpression("SET lastMessageAt = :ts") - .expressionAttributeValues(expressionValues) - .build(); - - dynamoDbClient.updateItem(updateRequest); - logger.info("Updated lastMessageAt for room: {}", roomId); - } - - /** - * 페이지네이션 결과 클래스 - */ - public static class RoomPage { - private final List rooms; - private final String nextCursor; - - public RoomPage(List rooms, String nextCursor) { - this.rooms = rooms; - this.nextCursor = nextCursor; - } - - public List getRooms() { - return rooms; - } - - public String getNextCursor() { - return nextCursor; - } - - public boolean hasMore() { - return nextCursor != null; - } - } -} diff --git a/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/service/BedrockService.java b/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/service/BedrockService.java deleted file mode 100644 index 433a8117..00000000 --- a/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/service/BedrockService.java +++ /dev/null @@ -1,67 +0,0 @@ -package com.mzc.secondproject.serverless.chatting.service; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import software.amazon.awssdk.core.SdkBytes; -import software.amazon.awssdk.services.bedrockruntime.BedrockRuntimeClient; -import software.amazon.awssdk.services.bedrockruntime.model.InvokeModelRequest; -import software.amazon.awssdk.services.bedrockruntime.model.InvokeModelResponse; - -import com.google.gson.Gson; -import com.google.gson.JsonArray; -import com.google.gson.JsonObject; - -public class BedrockService { - - private static final Logger logger = LoggerFactory.getLogger(BedrockService.class); - private static final Gson gson = new Gson(); - - // Claude 3 Sonnet 모델 ID - private static final String MODEL_ID = "anthropic.claude-3-sonnet-20240229-v1:0"; - - private final BedrockRuntimeClient bedrockClient; - - public BedrockService() { - this.bedrockClient = BedrockRuntimeClient.builder().build(); - } - - public String generateResponse(String prompt) { - logger.info("Generating AI response for prompt"); - - try { - // Claude 3 Messages API 형식 - JsonObject requestBody = new JsonObject(); - requestBody.addProperty("anthropic_version", "bedrock-2023-05-31"); - requestBody.addProperty("max_tokens", 1024); - - JsonArray messages = new JsonArray(); - JsonObject userMessage = new JsonObject(); - userMessage.addProperty("role", "user"); - userMessage.addProperty("content", prompt); - messages.add(userMessage); - - requestBody.add("messages", messages); - - InvokeModelRequest request = InvokeModelRequest.builder() - .modelId(MODEL_ID) - .contentType("application/json") - .accept("application/json") - .body(SdkBytes.fromUtf8String(gson.toJson(requestBody))) - .build(); - - InvokeModelResponse response = bedrockClient.invokeModel(request); - - String responseBody = response.body().asUtf8String(); - JsonObject jsonResponse = gson.fromJson(responseBody, JsonObject.class); - - // Claude 3 응답에서 텍스트 추출 - return jsonResponse.getAsJsonArray("content") - .get(0).getAsJsonObject() - .get("text").getAsString(); - - } catch (Exception e) { - logger.error("Error calling Bedrock", e); - throw new RuntimeException("Failed to generate AI response", e); - } - } -} diff --git a/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/service/ChatMessageService.java b/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/service/ChatMessageService.java deleted file mode 100644 index a7511066..00000000 --- a/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/service/ChatMessageService.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.mzc.secondproject.serverless.chatting.service; - -import com.mzc.secondproject.serverless.chatting.model.ChatMessage; -import com.mzc.secondproject.serverless.chatting.repository.ChatMessageRepository; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.List; -import java.util.Optional; - -public class ChatMessageService { - - private static final Logger logger = LoggerFactory.getLogger(ChatMessageService.class); - - private final ChatMessageRepository repository; - - public ChatMessageService() { - this.repository = new ChatMessageRepository(); - } - - public ChatMessage saveMessage(ChatMessage message) { - logger.info("Saving message: {}", message.getMessageId()); - return repository.save(message); - } - - public Optional getMessage(String roomId, String messageId) { - logger.info("Getting message: {} from room: {}", messageId, roomId); - return repository.findByRoomIdAndMessageId(roomId, messageId); - } - - public ChatMessageRepository.MessagePage getMessagesByRoomWithPagination(String roomId, int limit, String cursor) { - logger.info("Getting messages for room: {} with limit: {}", roomId, limit); - return repository.findByRoomIdWithPagination(roomId, limit, cursor); - } - - public ChatMessageRepository.MessagePage getMessagesByUserWithPagination(String userId, int limit, String cursor) { - logger.info("Getting messages for user: {} with limit: {}", userId, limit); - return repository.findByUserIdWithPagination(userId, limit, cursor); - } -} diff --git a/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/service/PollyService.java b/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/service/PollyService.java deleted file mode 100644 index 57adbd98..00000000 --- a/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/service/PollyService.java +++ /dev/null @@ -1,167 +0,0 @@ -package com.mzc.secondproject.serverless.chatting.service; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import software.amazon.awssdk.core.sync.RequestBody; -import software.amazon.awssdk.services.polly.PollyClient; -import software.amazon.awssdk.services.polly.model.OutputFormat; -import software.amazon.awssdk.services.polly.model.SynthesizeSpeechRequest; -import software.amazon.awssdk.services.polly.model.VoiceId; -import software.amazon.awssdk.services.s3.S3Client; -import software.amazon.awssdk.services.s3.model.PutObjectRequest; -import software.amazon.awssdk.services.s3.presigner.S3Presigner; -import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; -import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest; -import software.amazon.awssdk.services.s3.model.GetObjectRequest; - -import software.amazon.awssdk.services.s3.model.HeadObjectRequest; -import software.amazon.awssdk.services.s3.model.NoSuchKeyException; - -import java.io.ByteArrayOutputStream; -import java.io.InputStream; -import java.time.Duration; - -public class PollyService { - - private static final Logger logger = LoggerFactory.getLogger(PollyService.class); - - private final PollyClient pollyClient; - private final S3Client s3Client; - private final S3Presigner s3Presigner; - private final String bucketName; - - public PollyService() { - this.pollyClient = PollyClient.builder().build(); - this.s3Client = S3Client.builder().build(); - this.s3Presigner = S3Presigner.builder().build(); - this.bucketName = System.getenv("CHAT_BUCKET_NAME"); - } - - /** - * 메시지 ID 기반으로 음성 합성 (캐시 지원) - * S3에 파일이 있으면 바로 URL 반환, 없으면 Polly 변환 후 저장 - */ - public VoiceSynthesisResult synthesizeSpeechForMessage(String messageId, String text, String voice) { - String s3Key = generateS3Key(messageId, voice); - - // 캐시 확인: S3에 이미 존재하는지 체크 - if (existsInS3(s3Key)) { - logger.info("Cache hit: {}", s3Key); - String presignedUrl = getPresignedUrl(s3Key); - return new VoiceSynthesisResult(s3Key, presignedUrl, true); - } - - // 캐시 미스: Polly 변환 후 S3 저장 - logger.info("Cache miss: synthesizing and saving to {}", s3Key); - synthesizeAndSave(text, voice, s3Key); - String presignedUrl = getPresignedUrl(s3Key); - return new VoiceSynthesisResult(s3Key, presignedUrl, false); - } - - /** - * S3 키로 Pre-signed URL 생성 - */ - public String getPresignedUrl(String s3Key) { - GetObjectRequest getObjectRequest = GetObjectRequest.builder() - .bucket(bucketName) - .key(s3Key) - .build(); - - GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() - .signatureDuration(Duration.ofHours(1)) - .getObjectRequest(getObjectRequest) - .build(); - - PresignedGetObjectRequest presignedRequest = s3Presigner.presignGetObject(presignRequest); - return presignedRequest.url().toString(); - } - - /** - * S3에 파일 존재 여부 확인 - */ - public boolean existsInS3(String s3Key) { - try { - s3Client.headObject(HeadObjectRequest.builder() - .bucket(bucketName) - .key(s3Key) - .build()); - return true; - } catch (NoSuchKeyException e) { - return false; - } - } - - /** - * Polly로 음성 변환 후 지정된 S3 키로 저장 - */ - private void synthesizeAndSave(String text, String voice, String s3Key) { - VoiceId voiceId = resolveVoiceId(voice); - - try { - SynthesizeSpeechRequest request = SynthesizeSpeechRequest.builder() - .text(text) - .voiceId(voiceId) - .engine("neural") - .outputFormat(OutputFormat.MP3) - .build(); - - InputStream audioStream = pollyClient.synthesizeSpeech(request); - - ByteArrayOutputStream buffer = new ByteArrayOutputStream(); - byte[] data = new byte[4096]; - int bytesRead; - while ((bytesRead = audioStream.read(data, 0, data.length)) != -1) { - buffer.write(data, 0, bytesRead); - } - byte[] audioBytes = buffer.toByteArray(); - - s3Client.putObject( - PutObjectRequest.builder() - .bucket(bucketName) - .key(s3Key) - .contentType("audio/mpeg") - .build(), - RequestBody.fromBytes(audioBytes) - ); - - logger.info("Saved audio to S3: {}", s3Key); - } catch (Exception e) { - logger.error("Error synthesizing speech", e); - throw new RuntimeException("Failed to synthesize speech", e); - } - } - - /** - * 메시지 ID와 음성 타입으로 S3 키 생성 - */ - public String generateS3Key(String messageId, String voice) { - String voiceSuffix = "MALE".equalsIgnoreCase(voice) ? "male" : "female"; - return "voice/" + messageId + "_" + voiceSuffix + ".mp3"; - } - - /** - * 음성 합성 결과 - */ - public static class VoiceSynthesisResult { - private final String s3Key; - private final String audioUrl; - private final boolean cached; - - public VoiceSynthesisResult(String s3Key, String audioUrl, boolean cached) { - this.s3Key = s3Key; - this.audioUrl = audioUrl; - this.cached = cached; - } - - public String getS3Key() { return s3Key; } - public String getAudioUrl() { return audioUrl; } - public boolean isCached() { return cached; } - } - - private VoiceId resolveVoiceId(String voice) { - if ("MALE".equalsIgnoreCase(voice)) { - return VoiceId.MATTHEW; // 미국 영어 남성 (Neural 지원) - } - return VoiceId.JOANNA; // 미국 영어 여성 (Neural 지원, 기본값) - } -} diff --git a/chatting/ChatFunction/src/main/resources/log4j2.xml b/chatting/ChatFunction/src/main/resources/log4j2.xml deleted file mode 100644 index 69a29404..00000000 --- a/chatting/ChatFunction/src/main/resources/log4j2.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - %d{yyyy-MM-dd HH:mm:ss} %X{AWSRequestId} %-5p %c{1} - %m%n - - - - - - - - - - - diff --git a/chatting/events/ai-generate.json b/chatting/events/ai-generate.json deleted file mode 100644 index b5072a1f..00000000 --- a/chatting/events/ai-generate.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "httpMethod": "POST", - "path": "/chat/ai/generate", - "headers": { - "Content-Type": "application/json" - }, - "body": "{\"prompt\": \"안녕하세요, 오늘 날씨가 어떤가요?\"}" -} diff --git a/chatting/events/get-messages.json b/chatting/events/get-messages.json deleted file mode 100644 index 82f25021..00000000 --- a/chatting/events/get-messages.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "httpMethod": "GET", - "path": "/chat/messages", - "headers": { - "Content-Type": "application/json" - }, - "queryStringParameters": { - "roomId": "room-123" - } -} diff --git a/chatting/events/post-message.json b/chatting/events/post-message.json deleted file mode 100644 index 18a3230e..00000000 --- a/chatting/events/post-message.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "httpMethod": "POST", - "path": "/chat/messages", - "headers": { - "Content-Type": "application/json" - }, - "body": "{\"roomId\": \"room-123\", \"userId\": \"user-456\", \"content\": \"Hello, World!\", \"messageType\": \"TEXT\"}" -} diff --git a/chatting/events/voice-synthesize.json b/chatting/events/voice-synthesize.json deleted file mode 100644 index acc75167..00000000 --- a/chatting/events/voice-synthesize.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "httpMethod": "POST", - "path": "/chat/voice/synthesize", - "headers": { - "Content-Type": "application/json" - }, - "body": "{\"text\": \"안녕하세요, 반갑습니다.\"}" -} diff --git a/chatting/seed-data.sh b/chatting/seed-data.sh deleted file mode 100755 index b1e584e3..00000000 --- a/chatting/seed-data.sh +++ /dev/null @@ -1,61 +0,0 @@ -#!/bin/bash - -API_URL="https://gc8l9ijhzc.execute-api.ap-northeast-2.amazonaws.com/dev" - -echo "=== 채팅방 15개 생성 ===" -LEVELS=("beginner" "intermediate" "advanced") - -for i in {1..15}; do - LEVEL=${LEVELS[$((i % 3))]} - echo "Creating room $i (level: $LEVEL)..." - - curl -s -X POST "$API_URL/chat/rooms" \ - -H "Content-Type: application/json" \ - -d "{\"name\": \"영어 스터디방 $i\", \"description\": \"함께 영어 공부해요 $i\", \"level\": \"$LEVEL\", \"maxMembers\": $((4 + i % 5)), \"createdBy\": \"user$((i % 5 + 1))\"}" | jq -r '.data.roomId' - - sleep 0.3 -done - -echo "" -echo "=== 첫 번째 방에 메시지 30개 생성 ===" - -# 첫 번째 방 ID 가져오기 -ROOM_ID=$(curl -s "$API_URL/chat/rooms?limit=1" | jq -r '.data.rooms[0].roomId') -echo "Room ID: $ROOM_ID" - -MESSAGES=( - "Hello everyone!" - "Hi! Nice to meet you!" - "How are you doing today?" - "I'm fine, thank you!" - "What are you studying?" - "I'm learning English conversation." - "That's great!" - "Can you help me with pronunciation?" - "Sure, I'd love to help!" - "Let's practice together." -) - -for i in {1..30}; do - USER_ID="user$((i % 5 + 1))" - MSG_IDX=$((i % 10)) - MESSAGE="${MESSAGES[$MSG_IDX]} (Message #$i)" - - echo "Sending message $i from $USER_ID..." - - curl -s -X POST "$API_URL/chat/rooms/$ROOM_ID/messages" \ - -H "Content-Type: application/json" \ - -d "{\"userId\": \"$USER_ID\", \"content\": \"$MESSAGE\"}" | jq -r '.data.messageId' - - sleep 0.2 -done - -echo "" -echo "=== 완료! ===" -echo "" -echo "테스트 명령어:" -echo "# 채팅방 목록 (페이지네이션)" -echo "curl '$API_URL/chat/rooms?limit=5'" -echo "" -echo "# 메시지 목록 (페이지네이션)" -echo "curl '$API_URL/chat/rooms/$ROOM_ID/messages?limit=10'" diff --git a/chatting/template.yaml b/chatting/template.yaml deleted file mode 100644 index de1f9452..00000000 --- a/chatting/template.yaml +++ /dev/null @@ -1,599 +0,0 @@ -AWSTemplateFormatVersion: '2010-09-09' -Transform: AWS::Serverless-2016-10-31 -Description: Group2 English Study - Unified API (Chatting + Vocabulary) - -Globals: - Function: - Timeout: 30 - MemorySize: 512 - Runtime: java21 - Architectures: - - x86_64 - Environment: - Variables: - CHAT_TABLE_NAME: !Ref ChatTable - VOCAB_TABLE_NAME: !Ref VocabTable - CHAT_BUCKET_NAME: group2-englishstudy - VOCAB_BUCKET_NAME: group2-englishstudy - AWS_REGION_NAME: !Ref AWS::Region - -Resources: - ############################################# - # API Gateway (Unified) - ############################################# - - MainApi: - Type: AWS::Serverless::Api - Properties: - Name: group2-englishstudy-api - StageName: dev - Cors: - AllowMethods: "'GET,POST,PUT,DELETE,OPTIONS'" - AllowHeaders: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" - AllowOrigin: "'*'" - - ############################################# - # Chatting Lambda Functions - ############################################# - - ChatRoomFunction: - Type: AWS::Serverless::Function - Properties: - FunctionName: group2-englishstudy-chat-room-handler - CodeUri: ChatFunction - Handler: com.mzc.secondproject.serverless.chatting.handler.ChatRoomHandler::handleRequest - Description: Handle chat room CRUD operations - SnapStart: - ApplyOn: PublishedVersions - Policies: - - DynamoDBCrudPolicy: - TableName: !Ref ChatTable - Events: - CreateRoom: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /chat/rooms - Method: POST - GetRooms: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /chat/rooms - Method: GET - GetRoom: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /chat/rooms/{roomId} - Method: GET - DeleteRoom: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /chat/rooms/{roomId} - Method: DELETE - JoinRoom: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /chat/rooms/{roomId}/join - Method: POST - LeaveRoom: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /chat/rooms/{roomId}/leave - Method: POST - - ChatMessageFunction: - Type: AWS::Serverless::Function - Properties: - FunctionName: group2-englishstudy-chat-message-handler - CodeUri: ChatFunction - Handler: com.mzc.secondproject.serverless.chatting.handler.ChatMessageHandler::handleRequest - Description: Handle chat messages - SnapStart: - ApplyOn: PublishedVersions - Policies: - - DynamoDBCrudPolicy: - TableName: !Ref ChatTable - - S3CrudPolicy: - BucketName: group2-englishstudy - - Statement: - - Effect: Allow - Action: - - bedrock:InvokeModel - - bedrock:InvokeModelWithResponseStream - Resource: "*" - - Statement: - - Effect: Allow - Action: - - polly:SynthesizeSpeech - - polly:DescribeVoices - Resource: "*" - Events: - SendMessage: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /chat/rooms/{roomId}/messages - Method: POST - GetMessages: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /chat/rooms/{roomId}/messages - Method: GET - GetMessage: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /chat/rooms/{roomId}/messages/{messageId} - Method: GET - - ChatAIFunction: - Type: AWS::Serverless::Function - Properties: - FunctionName: group2-englishstudy-chat-ai-handler - CodeUri: ChatFunction - Handler: com.mzc.secondproject.serverless.chatting.handler.ChatAIHandler::handleRequest - Description: Generate AI responses using Bedrock - Timeout: 60 - MemorySize: 1024 - SnapStart: - ApplyOn: PublishedVersions - Policies: - - DynamoDBCrudPolicy: - TableName: !Ref ChatTable - - Statement: - - Effect: Allow - Action: - - bedrock:InvokeModel - - bedrock:InvokeModelWithResponseStream - Resource: "*" - Events: - GenerateAI: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /chat/ai/generate - Method: POST - - ChatVoiceFunction: - Type: AWS::Serverless::Function - Properties: - FunctionName: group2-englishstudy-chat-voice-handler - CodeUri: ChatFunction - Handler: com.mzc.secondproject.serverless.chatting.handler.ChatVoiceHandler::handleRequest - Description: Convert text to speech using Polly - SnapStart: - ApplyOn: PublishedVersions - Policies: - - DynamoDBCrudPolicy: - TableName: !Ref ChatTable - - S3CrudPolicy: - BucketName: group2-englishstudy - - Statement: - - Effect: Allow - Action: - - polly:SynthesizeSpeech - - polly:DescribeVoices - Resource: "*" - Events: - TextToSpeech: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /chat/voice/synthesize - Method: POST - - ############################################# - # Vocabulary Lambda Functions - ############################################# - - WordFunction: - Type: AWS::Serverless::Function - Properties: - FunctionName: group2-englishstudy-vocab-word-handler - CodeUri: ../vocabulary/VocabFunction - Handler: com.mzc.secondproject.serverless.vocabulary.handler.WordHandler::handleRequest - Description: Handle word CRUD operations - SnapStart: - ApplyOn: PublishedVersions - Policies: - - DynamoDBCrudPolicy: - TableName: !Ref VocabTable - Events: - CreateWord: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /vocab/words - Method: POST - BatchCreateWords: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /vocab/words/batch - Method: POST - SearchWords: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /vocab/words/search - Method: GET - GetWords: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /vocab/words - Method: GET - GetWord: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /vocab/words/{wordId} - Method: GET - UpdateWord: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /vocab/words/{wordId} - Method: PUT - DeleteWord: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /vocab/words/{wordId} - Method: DELETE - - UserWordFunction: - Type: AWS::Serverless::Function - Properties: - FunctionName: group2-englishstudy-vocab-userword-handler - CodeUri: ../vocabulary/VocabFunction - Handler: com.mzc.secondproject.serverless.vocabulary.handler.UserWordHandler::handleRequest - Description: Handle user word learning status - SnapStart: - ApplyOn: PublishedVersions - Policies: - - DynamoDBCrudPolicy: - TableName: !Ref VocabTable - Events: - GetUserWords: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /vocab/users/{userId}/words - Method: GET - GetUserWord: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /vocab/users/{userId}/words/{wordId} - Method: GET - UpdateUserWord: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /vocab/users/{userId}/words/{wordId} - Method: PUT - UpdateUserWordTag: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /vocab/users/{userId}/words/{wordId}/tag - Method: PUT - - DailyStudyFunction: - Type: AWS::Serverless::Function - Properties: - FunctionName: group2-englishstudy-vocab-daily-handler - CodeUri: ../vocabulary/VocabFunction - Handler: com.mzc.secondproject.serverless.vocabulary.handler.DailyStudyHandler::handleRequest - Description: Handle daily study word assignment - SnapStart: - ApplyOn: PublishedVersions - Policies: - - DynamoDBCrudPolicy: - TableName: !Ref VocabTable - Events: - GetDailyWords: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /vocab/daily/{userId} - Method: GET - MarkWordLearned: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /vocab/daily/{userId}/words/{wordId}/learned - Method: POST - - TestFunction: - Type: AWS::Serverless::Function - Properties: - FunctionName: group2-englishstudy-vocab-test-handler - CodeUri: ../vocabulary/VocabFunction - Handler: com.mzc.secondproject.serverless.vocabulary.handler.TestHandler::handleRequest - Description: Handle vocabulary tests - SnapStart: - ApplyOn: PublishedVersions - Environment: - Variables: - TEST_RESULT_TOPIC_ARN: !Ref TestResultTopic - Policies: - - DynamoDBCrudPolicy: - TableName: !Ref VocabTable - - SNSPublishMessagePolicy: - TopicName: !GetAtt TestResultTopic.TopicName - Events: - StartTest: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /vocab/test/{userId}/start - Method: POST - SubmitAnswer: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /vocab/test/{userId}/submit - Method: POST - GetTestResult: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /vocab/test/{userId}/results - Method: GET - - StatsFunction: - Type: AWS::Serverless::Function - Properties: - FunctionName: group2-englishstudy-vocab-stats-handler - CodeUri: ../vocabulary/VocabFunction - Handler: com.mzc.secondproject.serverless.vocabulary.handler.StatsHandler::handleRequest - Description: Handle user learning statistics - SnapStart: - ApplyOn: PublishedVersions - Policies: - - DynamoDBCrudPolicy: - TableName: !Ref VocabTable - Events: - GetStats: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /vocab/stats/{userId} - Method: GET - GetDailyStats: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /vocab/stats/{userId}/daily - Method: GET - GetWeaknessAnalysis: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /vocab/stats/{userId}/weakness - Method: GET - - VocabVoiceFunction: - Type: AWS::Serverless::Function - Properties: - FunctionName: group2-englishstudy-vocab-voice-handler - CodeUri: ../vocabulary/VocabFunction - Handler: com.mzc.secondproject.serverless.vocabulary.handler.VoiceHandler::handleRequest - Description: Convert word to speech using Polly - SnapStart: - ApplyOn: PublishedVersions - Policies: - - DynamoDBCrudPolicy: - TableName: !Ref VocabTable - - S3CrudPolicy: - BucketName: group2-englishstudy - - Statement: - - Effect: Allow - Action: - - polly:SynthesizeSpeech - - polly:DescribeVoices - Resource: "*" - Events: - TextToSpeech: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /vocab/voice/synthesize - Method: POST - - ############################################# - # DynamoDB Tables - ############################################# - - ChatTable: - Type: AWS::DynamoDB::Table - Properties: - TableName: group2-englishstudy-chat - BillingMode: PAY_PER_REQUEST - AttributeDefinitions: - - AttributeName: PK - AttributeType: S - - AttributeName: SK - AttributeType: S - - AttributeName: GSI1PK - AttributeType: S - - AttributeName: GSI1SK - AttributeType: S - - AttributeName: GSI2PK - AttributeType: S - - AttributeName: GSI2SK - AttributeType: S - KeySchema: - - AttributeName: PK - KeyType: HASH - - AttributeName: SK - KeyType: RANGE - GlobalSecondaryIndexes: - - IndexName: GSI1 - KeySchema: - - AttributeName: GSI1PK - KeyType: HASH - - AttributeName: GSI1SK - KeyType: RANGE - Projection: - ProjectionType: ALL - - IndexName: GSI2 - KeySchema: - - AttributeName: GSI2PK - KeyType: HASH - - AttributeName: GSI2SK - KeyType: RANGE - Projection: - ProjectionType: ALL - TimeToLiveSpecification: - AttributeName: ttl - Enabled: true - - VocabTable: - Type: AWS::DynamoDB::Table - Properties: - TableName: group2-englishstudy-vocab - BillingMode: PAY_PER_REQUEST - AttributeDefinitions: - - AttributeName: PK - AttributeType: S - - AttributeName: SK - AttributeType: S - - AttributeName: GSI1PK - AttributeType: S - - AttributeName: GSI1SK - AttributeType: S - - AttributeName: GSI2PK - AttributeType: S - - AttributeName: GSI2SK - AttributeType: S - KeySchema: - - AttributeName: PK - KeyType: HASH - - AttributeName: SK - KeyType: RANGE - GlobalSecondaryIndexes: - - IndexName: GSI1 - KeySchema: - - AttributeName: GSI1PK - KeyType: HASH - - AttributeName: GSI1SK - KeyType: RANGE - Projection: - ProjectionType: ALL - - IndexName: GSI2 - KeySchema: - - AttributeName: GSI2PK - KeyType: HASH - - AttributeName: GSI2SK - KeyType: RANGE - Projection: - ProjectionType: ALL - TimeToLiveSpecification: - AttributeName: ttl - Enabled: true - - ############################################# - # SNS / SQS for Async Statistics Processing - ############################################# - - # SNS Topic - 시험 결과 이벤트 발행 - TestResultTopic: - Type: AWS::SNS::Topic - Properties: - TopicName: group2-englishstudy-test-result-topic - - # SQS Dead Letter Queue - 실패한 메시지 보관 - StatisticsDeadLetterQueue: - Type: AWS::SQS::Queue - Properties: - QueueName: group2-englishstudy-statistics-dlq - MessageRetentionPeriod: 1209600 # 14일 - - # SQS Queue - 통계 처리용 - StatisticsQueue: - Type: AWS::SQS::Queue - Properties: - QueueName: group2-englishstudy-statistics-queue - VisibilityTimeout: 60 - RedrivePolicy: - deadLetterTargetArn: !GetAtt StatisticsDeadLetterQueue.Arn - maxReceiveCount: 3 - - # SQS Queue Policy - SNS에서 메시지 수신 허용 - StatisticsQueuePolicy: - Type: AWS::SQS::QueuePolicy - Properties: - Queues: - - !Ref StatisticsQueue - PolicyDocument: - Statement: - - Effect: Allow - Principal: - Service: sns.amazonaws.com - Action: sqs:SendMessage - Resource: !GetAtt StatisticsQueue.Arn - Condition: - ArnEquals: - aws:SourceArn: !Ref TestResultTopic - - # SNS → SQS 구독 - StatisticsQueueSubscription: - Type: AWS::SNS::Subscription - Properties: - Protocol: sqs - TopicArn: !Ref TestResultTopic - Endpoint: !GetAtt StatisticsQueue.Arn - RawMessageDelivery: true - - # Statistics Processor Lambda - SQS에서 메시지 소비하여 통계 업데이트 - StatisticsProcessorFunction: - Type: AWS::Serverless::Function - Properties: - FunctionName: group2-englishstudy-statistics-processor - CodeUri: ../vocabulary/VocabFunction - Handler: com.mzc.secondproject.serverless.vocabulary.handler.StatisticsHandler::handleRequest - Description: Process test results and update user word statistics - Timeout: 60 - SnapStart: - ApplyOn: PublishedVersions - Policies: - - DynamoDBCrudPolicy: - TableName: !Ref VocabTable - - SQSPollerPolicy: - QueueName: !GetAtt StatisticsQueue.QueueName - Events: - SQSEvent: - Type: SQS - Properties: - Queue: !GetAtt StatisticsQueue.Arn - BatchSize: 10 - -############################################# -# Outputs -############################################# - -Outputs: - ApiUrl: - Description: Unified API Gateway endpoint URL - Value: !Sub 'https://${MainApi}.execute-api.${AWS::Region}.amazonaws.com/dev/' - - ChatTableName: - Description: Chat DynamoDB Table Name - Value: !Ref ChatTable - - VocabTableName: - Description: Vocab DynamoDB Table Name - Value: !Ref VocabTable - - BucketName: - Description: S3 Bucket Name - Value: group2-englishstudy diff --git a/vocabulary/VocabFunction/build.gradle b/vocabulary/VocabFunction/build.gradle deleted file mode 100644 index bb558447..00000000 --- a/vocabulary/VocabFunction/build.gradle +++ /dev/null @@ -1,61 +0,0 @@ -plugins { - id 'java' -} - -group = 'com.mzc.secondproject.serverless' -version = '1.0.0' - -java { - toolchain { - languageVersion = JavaLanguageVersion.of(21) - } -} - -repositories { - mavenCentral() -} - -dependencies { - // AWS Lambda Core - implementation 'com.amazonaws:aws-lambda-java-core:1.2.3' - implementation 'com.amazonaws:aws-lambda-java-events:3.11.4' - - // AWS SDK v2 - implementation platform('software.amazon.awssdk:bom:2.24.0') - implementation 'software.amazon.awssdk:dynamodb' - implementation 'software.amazon.awssdk:dynamodb-enhanced' - implementation 'software.amazon.awssdk:polly' - implementation 'software.amazon.awssdk:s3' - implementation 'software.amazon.awssdk:sns' - - // JSON Processing - implementation 'com.google.code.gson:gson:2.10.1' - - // Logging - implementation 'com.amazonaws:aws-lambda-java-log4j2:1.6.0' - implementation 'org.apache.logging.log4j:log4j-api:2.22.1' - implementation 'org.apache.logging.log4j:log4j-core:2.22.1' - implementation 'org.apache.logging.log4j:log4j-slf4j2-impl:2.22.1' - - // Lombok - compileOnly 'org.projectlombok:lombok:1.18.30' - annotationProcessor 'org.projectlombok:lombok:1.18.30' - - // Testing - testImplementation 'org.junit.jupiter:junit-jupiter:5.10.1' - testImplementation 'org.mockito:mockito-core:5.8.0' -} - -test { - useJUnitPlatform() -} - -task buildZip(type: Zip) { - from compileJava - from processResources - into('lib') { - from configurations.runtimeClasspath - } -} - -build.dependsOn buildZip diff --git a/vocabulary/VocabFunction/gradlew b/vocabulary/VocabFunction/gradlew deleted file mode 100755 index 1b6c7873..00000000 --- a/vocabulary/VocabFunction/gradlew +++ /dev/null @@ -1,234 +0,0 @@ -#!/bin/sh - -# -# Copyright © 2015-2021 the original authors. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -############################################################################## -# -# Gradle start up script for POSIX generated by Gradle. -# -# Important for running: -# -# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is -# noncompliant, but you have some other compliant shell such as ksh or -# bash, then to run this script, type that shell name before the whole -# command line, like: -# -# ksh Gradle -# -# Busybox and similar reduced shells will NOT work, because this script -# requires all of these POSIX shell features: -# * functions; -# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», -# «${var#prefix}», «${var%suffix}», and «$( cmd )»; -# * compound commands having a testable exit status, especially «case»; -# * various built-in commands including «command», «set», and «ulimit». -# -# Important for patching: -# -# (2) This script targets any POSIX shell, so it avoids extensions provided -# by Bash, Ksh, etc; in particular arrays are avoided. -# -# The "traditional" practice of packing multiple parameters into a -# space-separated string is a well documented source of bugs and security -# problems, so this is (mostly) avoided, by progressively accumulating -# options in "$@", and eventually passing that to Java. -# -# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, -# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; -# see the in-line comments for details. -# -# There are tweaks for specific operating systems such as AIX, CygWin, -# Darwin, MinGW, and NonStop. -# -# (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt -# within the Gradle project. -# -# You can find Gradle at https://github.com/gradle/gradle/. -# -############################################################################## - -# Attempt to set APP_HOME - -# Resolve links: $0 may be a link -app_path=$0 - -# Need this for daisy-chained symlinks. -while - APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path - [ -h "$app_path" ] -do - ls=$( ls -ld "$app_path" ) - link=${ls#*' -> '} - case $link in #( - /*) app_path=$link ;; #( - *) app_path=$APP_HOME$link ;; - esac -done - -APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit - -APP_NAME="Gradle" -APP_BASE_NAME=${0##*/} - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' - -# Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD=maximum - -warn () { - echo "$*" -} >&2 - -die () { - echo - echo "$*" - echo - exit 1 -} >&2 - -# OS specific support (must be 'true' or 'false'). -cygwin=false -msys=false -darwin=false -nonstop=false -case "$( uname )" in #( - CYGWIN* ) cygwin=true ;; #( - Darwin* ) darwin=true ;; #( - MSYS* | MINGW* ) msys=true ;; #( - NONSTOP* ) nonstop=true ;; -esac - -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar - - -# Determine the Java command to use to start the JVM. -if [ -n "$JAVA_HOME" ] ; then - if [ -x "$JAVA_HOME/jre/sh/java" ] ; then - # IBM's JDK on AIX uses strange locations for the executables - JAVACMD=$JAVA_HOME/jre/sh/java - else - JAVACMD=$JAVA_HOME/bin/java - fi - if [ ! -x "$JAVACMD" ] ; then - die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -else - JAVACMD=java - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." -fi - -# Increase the maximum file descriptors if we can. -if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then - case $MAX_FD in #( - max*) - MAX_FD=$( ulimit -H -n ) || - warn "Could not query maximum file descriptor limit" - esac - case $MAX_FD in #( - '' | soft) :;; #( - *) - ulimit -n "$MAX_FD" || - warn "Could not set maximum file descriptor limit to $MAX_FD" - esac -fi - -# Collect all arguments for the java command, stacking in reverse order: -# * args from the command line -# * the main class name -# * -classpath -# * -D...appname settings -# * --module-path (only if needed) -# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. - -# For Cygwin or MSYS, switch paths to Windows format before running java -if "$cygwin" || "$msys" ; then - APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) - - JAVACMD=$( cygpath --unix "$JAVACMD" ) - - # Now convert the arguments - kludge to limit ourselves to /bin/sh - for arg do - if - case $arg in #( - -*) false ;; # don't mess with options #( - /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath - [ -e "$t" ] ;; #( - *) false ;; - esac - then - arg=$( cygpath --path --ignore --mixed "$arg" ) - fi - # Roll the args list around exactly as many times as the number of - # args, so each arg winds up back in the position where it started, but - # possibly modified. - # - # NB: a `for` loop captures its iteration list before it begins, so - # changing the positional parameters here affects neither the number of - # iterations, nor the values presented in `arg`. - shift # remove old arg - set -- "$@" "$arg" # push replacement arg - done -fi - -# Collect all arguments for the java command; -# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of -# shell script including quotes and variable substitutions, so put them in -# double quotes to make sure that they get re-expanded; and -# * put everything else in single quotes, so that it's not re-expanded. - -set -- \ - "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ - "$@" - -# Use "xargs" to parse quoted args. -# -# With -n1 it outputs one arg per line, with the quotes and backslashes removed. -# -# In Bash we could simply go: -# -# readarray ARGS < <( xargs -n1 <<<"$var" ) && -# set -- "${ARGS[@]}" "$@" -# -# but POSIX shell has neither arrays nor command substitution, so instead we -# post-process each arg (as a line of input to sed) to backslash-escape any -# character that might be a shell metacharacter, then use eval to reverse -# that process (while maintaining the separation between arguments), and wrap -# the whole thing up as a single "set" statement. -# -# This will of course break if any of these variables contains a newline or -# an unmatched quote. -# - -eval "set -- $( - printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | - xargs -n1 | - sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | - tr '\n' ' ' - )" '"$@"' - -exec "$JAVACMD" "$@" diff --git a/vocabulary/VocabFunction/gradlew.bat b/vocabulary/VocabFunction/gradlew.bat deleted file mode 100644 index 107acd32..00000000 --- a/vocabulary/VocabFunction/gradlew.bat +++ /dev/null @@ -1,89 +0,0 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem - -@if "%DEBUG%" == "" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute - -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:execute -@rem Setup the command line - -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* - -:end -@rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega diff --git a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/dto/ApiResponse.java b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/dto/ApiResponse.java deleted file mode 100644 index acd76c92..00000000 --- a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/dto/ApiResponse.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.mzc.secondproject.serverless.vocabulary.dto; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class ApiResponse { - - private boolean success; - private String message; - private T data; - private String error; - - public static ApiResponse success(String message, T data) { - return ApiResponse.builder() - .success(true) - .message(message) - .data(data) - .build(); - } - - public static ApiResponse error(String errorMessage) { - return ApiResponse.builder() - .success(false) - .error(errorMessage) - .build(); - } -} diff --git a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/DailyStudyHandler.java b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/DailyStudyHandler.java deleted file mode 100644 index e3f948fc..00000000 --- a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/DailyStudyHandler.java +++ /dev/null @@ -1,253 +0,0 @@ -package com.mzc.secondproject.serverless.vocabulary.handler; - -import com.amazonaws.services.lambda.runtime.Context; -import com.amazonaws.services.lambda.runtime.RequestHandler; -import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; -import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; -import com.mzc.secondproject.serverless.vocabulary.dto.ApiResponse; -import com.mzc.secondproject.serverless.vocabulary.model.DailyStudy; -import com.mzc.secondproject.serverless.vocabulary.model.UserWord; -import com.mzc.secondproject.serverless.vocabulary.model.Word; -import com.mzc.secondproject.serverless.vocabulary.repository.DailyStudyRepository; -import com.mzc.secondproject.serverless.vocabulary.repository.UserWordRepository; -import com.mzc.secondproject.serverless.vocabulary.repository.WordRepository; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.time.Instant; -import java.time.LocalDate; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.stream.Collectors; - -public class DailyStudyHandler implements RequestHandler { - - private static final Logger logger = LoggerFactory.getLogger(DailyStudyHandler.class); - private static final Gson gson = new GsonBuilder().setPrettyPrinting().create(); - - private static final int NEW_WORDS_COUNT = 50; - private static final int REVIEW_WORDS_COUNT = 5; - - private final DailyStudyRepository dailyStudyRepository; - private final UserWordRepository userWordRepository; - private final WordRepository wordRepository; - - public DailyStudyHandler() { - this.dailyStudyRepository = new DailyStudyRepository(); - this.userWordRepository = new UserWordRepository(); - this.wordRepository = new WordRepository(); - } - - @Override - public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { - String httpMethod = request.getHttpMethod(); - String path = request.getPath(); - - logger.info("Received request: {} {}", httpMethod, path); - - try { - // GET /vocab/daily/{userId} - 오늘의 학습 단어 - if ("GET".equals(httpMethod) && !path.contains("/learned")) { - return getDailyWords(request); - } - - // POST /vocab/daily/{userId}/words/{wordId}/learned - 학습 완료 - if ("POST".equals(httpMethod) && path.endsWith("/learned")) { - return markWordLearned(request); - } - - return createResponse(404, ApiResponse.error("Not found")); - - } catch (Exception e) { - logger.error("Error handling request", e); - return createResponse(500, ApiResponse.error("Internal server error: " + e.getMessage())); - } - } - - private APIGatewayProxyResponseEvent getDailyWords(APIGatewayProxyRequestEvent request) { - Map pathParams = request.getPathParameters(); - Map queryParams = request.getQueryStringParameters(); - String userId = pathParams != null ? pathParams.get("userId") : null; - - if (userId == null) { - return createResponse(400, ApiResponse.error("userId is required")); - } - - // 레벨 파라미터 (첫 생성 시 필수) - String level = queryParams != null ? queryParams.get("level") : null; - - String today = LocalDate.now().toString(); - - // 오늘의 학습 데이터 조회 - Optional optDailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today); - - DailyStudy dailyStudy; - if (optDailyStudy.isPresent()) { - dailyStudy = optDailyStudy.get(); - } else { - // 첫 생성 시 레벨 필수 - if (level == null || level.isEmpty()) { - return createResponse(400, ApiResponse.error("level is required for first daily study (BEGINNER, INTERMEDIATE, ADVANCED)")); - } - // 레벨 유효성 검사 - if (!level.equals("BEGINNER") && !level.equals("INTERMEDIATE") && !level.equals("ADVANCED")) { - return createResponse(400, ApiResponse.error("Invalid level. Must be BEGINNER, INTERMEDIATE, or ADVANCED")); - } - // 새로운 일일 학습 생성 - dailyStudy = createDailyStudy(userId, today, level); - } - - // 단어 상세 정보 조회 - List newWords = getWordDetails(dailyStudy.getNewWordIds()); - List reviewWords = getWordDetails(dailyStudy.getReviewWordIds()); - - Map result = new HashMap<>(); - result.put("dailyStudy", dailyStudy); - result.put("newWords", newWords); - result.put("reviewWords", reviewWords); - result.put("progress", calculateProgress(dailyStudy)); - - return createResponse(200, ApiResponse.success("Daily words retrieved", result)); - } - - private DailyStudy createDailyStudy(String userId, String date, String level) { - String now = Instant.now().toString(); - - // 복습 대상 단어 조회 (5개) - UserWordRepository.UserWordPage reviewPage = userWordRepository.findReviewDueWords(userId, date, REVIEW_WORDS_COUNT, null); - List reviewWordIds = reviewPage.getUserWords().stream() - .map(UserWord::getWordId) - .collect(Collectors.toList()); - - // 신규 단어 조회 (50개) - 해당 레벨에서 아직 학습하지 않은 단어 - List newWordIds = getNewWordsForUser(userId, level, NEW_WORDS_COUNT); - - DailyStudy dailyStudy = DailyStudy.builder() - .pk("DAILY#" + userId) - .sk("DATE#" + date) - .gsi1pk("DAILY#ALL") - .gsi1sk("DATE#" + date) - .userId(userId) - .date(date) - .newWordIds(newWordIds) - .reviewWordIds(reviewWordIds) - .learnedWordIds(new ArrayList<>()) - .totalWords(newWordIds.size() + reviewWordIds.size()) - .learnedCount(0) - .isCompleted(false) - .createdAt(now) - .updatedAt(now) - .build(); - - dailyStudyRepository.save(dailyStudy); - logger.info("Created daily study for user: {}, date: {}", userId, date); - - return dailyStudy; - } - - private List getNewWordsForUser(String userId, String level, int count) { - // 사용자가 학습한 단어 목록 - UserWordRepository.UserWordPage userWordPage = userWordRepository.findByUserIdWithPagination(userId, 1000, null); - List learnedWordIds = userWordPage.getUserWords().stream() - .map(UserWord::getWordId) - .collect(Collectors.toList()); - - // 해당 레벨에서 학습하지 않은 단어 선택 - List newWordIds = new ArrayList<>(); - String lastEvaluatedKey = null; - - // 페이지네이션으로 해당 레벨의 모든 단어 조회 - do { - WordRepository.WordPage wordPage = wordRepository.findByLevelWithPagination(level, count * 2, lastEvaluatedKey); - for (Word word : wordPage.getWords()) { - if (!learnedWordIds.contains(word.getWordId()) && !newWordIds.contains(word.getWordId())) { - newWordIds.add(word.getWordId()); - if (newWordIds.size() >= count) break; - } - } - lastEvaluatedKey = wordPage.getNextCursor(); - } while (newWordIds.size() < count && lastEvaluatedKey != null); - - logger.info("Selected {} new words for user {} at level {}", newWordIds.size(), userId, level); - return newWordIds; - } - - private List getWordDetails(List wordIds) { - if (wordIds == null || wordIds.isEmpty()) { - return new ArrayList<>(); - } - - // BatchGetItem으로 한 번에 조회 (N+1 문제 해결) - return wordRepository.findByIds(wordIds); - } - - private Map calculateProgress(DailyStudy dailyStudy) { - Map progress = new HashMap<>(); - int total = dailyStudy.getTotalWords(); - int learned = dailyStudy.getLearnedCount(); - - progress.put("total", total); - progress.put("learned", learned); - progress.put("remaining", total - learned); - progress.put("percentage", total > 0 ? (learned * 100.0 / total) : 0); - progress.put("isCompleted", dailyStudy.getIsCompleted()); - - return progress; - } - - private APIGatewayProxyResponseEvent markWordLearned(APIGatewayProxyRequestEvent request) { - Map pathParams = request.getPathParameters(); - String userId = pathParams != null ? pathParams.get("userId") : null; - String wordId = pathParams != null ? pathParams.get("wordId") : null; - - if (userId == null || wordId == null) { - return createResponse(400, ApiResponse.error("userId and wordId are required")); - } - - String today = LocalDate.now().toString(); - - Optional optDailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today); - if (optDailyStudy.isEmpty()) { - return createResponse(404, ApiResponse.error("Daily study not found")); - } - - DailyStudy dailyStudy = optDailyStudy.get(); - - // 이미 학습 완료된 단어인지 확인 - if (dailyStudy.getLearnedWordIds() != null && dailyStudy.getLearnedWordIds().contains(wordId)) { - return createResponse(200, ApiResponse.success("Already marked as learned", dailyStudy)); - } - - // 학습 완료 처리 - dailyStudyRepository.addLearnedWord(userId, today, wordId); - - // 업데이트된 데이터 조회 - DailyStudy updatedDailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today).orElse(dailyStudy); - - // 완료 여부 확인 - if (updatedDailyStudy.getLearnedCount() >= updatedDailyStudy.getTotalWords()) { - updatedDailyStudy.setIsCompleted(true); - dailyStudyRepository.save(updatedDailyStudy); - } - - logger.info("Marked word as learned: userId={}, wordId={}", userId, wordId); - return createResponse(200, ApiResponse.success("Word marked as learned", calculateProgress(updatedDailyStudy))); - } - - private APIGatewayProxyResponseEvent createResponse(int statusCode, Object body) { - return new APIGatewayProxyResponseEvent() - .withStatusCode(statusCode) - .withHeaders(Map.of( - "Content-Type", "application/json", - "Access-Control-Allow-Origin", "*", - "Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS", - "Access-Control-Allow-Headers", "Content-Type,Authorization" - )) - .withBody(gson.toJson(body)); - } -} diff --git a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/StatisticsHandler.java b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/StatisticsHandler.java deleted file mode 100644 index 5f09ffa5..00000000 --- a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/StatisticsHandler.java +++ /dev/null @@ -1,160 +0,0 @@ -package com.mzc.secondproject.serverless.vocabulary.handler; - -import com.amazonaws.services.lambda.runtime.Context; -import com.amazonaws.services.lambda.runtime.RequestHandler; -import com.amazonaws.services.lambda.runtime.events.SQSEvent; -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; -import com.mzc.secondproject.serverless.vocabulary.model.UserWord; -import com.mzc.secondproject.serverless.vocabulary.repository.UserWordRepository; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.time.Instant; -import java.time.LocalDate; -import java.util.List; -import java.util.Map; -import java.util.Optional; - -/** - * SQS에서 시험 결과 메시지를 받아 UserWord 통계를 업데이트하는 Lambda - * SNS → SQS → Statistics Lambda 패턴 - */ -public class StatisticsHandler implements RequestHandler { - - private static final Logger logger = LoggerFactory.getLogger(StatisticsHandler.class); - private static final Gson gson = new GsonBuilder().create(); - - private final UserWordRepository userWordRepository; - - public StatisticsHandler() { - this.userWordRepository = new UserWordRepository(); - } - - @Override - public Void handleRequest(SQSEvent event, Context context) { - logger.info("Received {} messages from SQS", event.getRecords().size()); - - for (SQSEvent.SQSMessage message : event.getRecords()) { - try { - processMessage(message); - } catch (Exception e) { - logger.error("Failed to process message: {}", message.getMessageId(), e); - // 실패한 메시지는 DLQ로 이동됨 (SQS 설정에 의해) - throw new RuntimeException("Failed to process message", e); - } - } - - return null; - } - - @SuppressWarnings("unchecked") - private void processMessage(SQSEvent.SQSMessage message) { - String body = message.getBody(); - logger.info("Processing message: {}", body); - - Map testResult = gson.fromJson(body, Map.class); - - String userId = (String) testResult.get("userId"); - List> results = (List>) testResult.get("results"); - - if (userId == null || results == null) { - logger.warn("Invalid message format: userId or results is null"); - return; - } - - String now = Instant.now().toString(); - - for (Map result : results) { - String wordId = (String) result.get("wordId"); - Boolean isCorrect = (Boolean) result.get("isCorrect"); - - if (wordId == null || isCorrect == null) { - continue; - } - - updateUserWordStatistics(userId, wordId, isCorrect, now); - } - - logger.info("Successfully processed test result for user: {}, {} words updated", userId, results.size()); - } - - private void updateUserWordStatistics(String userId, String wordId, boolean isCorrect, String now) { - Optional optUserWord = userWordRepository.findByUserIdAndWordId(userId, wordId); - UserWord userWord; - - if (optUserWord.isEmpty()) { - // 새로운 UserWord 생성 - userWord = UserWord.builder() - .pk("USER#" + userId) - .sk("WORD#" + wordId) - .gsi1pk("USER#" + userId + "#REVIEW") - .gsi2pk("USER#" + userId + "#STATUS") - .userId(userId) - .wordId(wordId) - .status("NEW") - .interval(1) - .easeFactor(2.5) - .repetitions(0) - .correctCount(0) - .incorrectCount(0) - .createdAt(now) - .build(); - } else { - userWord = optUserWord.get(); - } - - // Spaced Repetition 알고리즘 적용 - applySpacedRepetition(userWord, isCorrect); - userWord.setUpdatedAt(now); - userWord.setLastReviewedAt(now); - - // GSI 업데이트 - userWord.setGsi1sk("DATE#" + userWord.getNextReviewAt()); - userWord.setGsi2sk("STATUS#" + userWord.getStatus()); - - userWordRepository.save(userWord); - } - - /** - * SM-2 Spaced Repetition 알고리즘 적용 - */ - private void applySpacedRepetition(UserWord userWord, boolean isCorrect) { - if (isCorrect) { - userWord.setCorrectCount(userWord.getCorrectCount() + 1); - userWord.setRepetitions(userWord.getRepetitions() + 1); - - // 간격 계산 - if (userWord.getRepetitions() == 1) { - userWord.setInterval(1); - } else if (userWord.getRepetitions() == 2) { - userWord.setInterval(6); - } else { - int newInterval = (int) Math.round(userWord.getInterval() * userWord.getEaseFactor()); - userWord.setInterval(newInterval); - } - - // 상태 업데이트 - if (userWord.getRepetitions() >= 5) { - userWord.setStatus("MASTERED"); - } else if (userWord.getRepetitions() >= 2) { - userWord.setStatus("REVIEWING"); - } else { - userWord.setStatus("LEARNING"); - } - } else { - userWord.setIncorrectCount(userWord.getIncorrectCount() + 1); - userWord.setRepetitions(0); - userWord.setInterval(1); - userWord.setStatus("LEARNING"); - - // easeFactor 감소 (최소 1.3) - double newEaseFactor = userWord.getEaseFactor() - 0.2; - userWord.setEaseFactor(Math.max(1.3, newEaseFactor)); - } - - // 다음 복습일 계산 - LocalDate nextReview = LocalDate.now().plusDays(userWord.getInterval()); - userWord.setNextReviewAt(nextReview.toString()); - } -} diff --git a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/StatsHandler.java b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/StatsHandler.java deleted file mode 100644 index d5ec6b54..00000000 --- a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/StatsHandler.java +++ /dev/null @@ -1,340 +0,0 @@ -package com.mzc.secondproject.serverless.vocabulary.handler; - -import com.amazonaws.services.lambda.runtime.Context; -import com.amazonaws.services.lambda.runtime.RequestHandler; -import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; -import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; -import com.mzc.secondproject.serverless.vocabulary.dto.ApiResponse; -import com.mzc.secondproject.serverless.vocabulary.model.DailyStudy; -import com.mzc.secondproject.serverless.vocabulary.model.TestResult; -import com.mzc.secondproject.serverless.vocabulary.model.UserWord; -import com.mzc.secondproject.serverless.vocabulary.model.Word; -import com.mzc.secondproject.serverless.vocabulary.repository.DailyStudyRepository; -import com.mzc.secondproject.serverless.vocabulary.repository.TestResultRepository; -import com.mzc.secondproject.serverless.vocabulary.repository.UserWordRepository; -import com.mzc.secondproject.serverless.vocabulary.repository.WordRepository; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.ArrayList; -import java.util.Comparator; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - -public class StatsHandler implements RequestHandler { - - private static final Logger logger = LoggerFactory.getLogger(StatsHandler.class); - private static final Gson gson = new GsonBuilder().setPrettyPrinting().create(); - - private final UserWordRepository userWordRepository; - private final DailyStudyRepository dailyStudyRepository; - private final TestResultRepository testResultRepository; - private final WordRepository wordRepository; - - public StatsHandler() { - this.userWordRepository = new UserWordRepository(); - this.dailyStudyRepository = new DailyStudyRepository(); - this.testResultRepository = new TestResultRepository(); - this.wordRepository = new WordRepository(); - } - - @Override - public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { - String httpMethod = request.getHttpMethod(); - String path = request.getPath(); - - logger.info("Received request: {} {}", httpMethod, path); - - try { - // GET /vocab/stats/{userId}/weakness - 약점 분석 - if ("GET".equals(httpMethod) && path.endsWith("/weakness")) { - return getWeaknessAnalysis(request); - } - - // GET /vocab/stats/{userId}/daily - 일별 통계 - if ("GET".equals(httpMethod) && path.endsWith("/daily")) { - return getDailyStats(request); - } - - // GET /vocab/stats/{userId} - 전체 통계 - if ("GET".equals(httpMethod)) { - return getOverallStats(request); - } - - return createResponse(404, ApiResponse.error("Not found")); - - } catch (Exception e) { - logger.error("Error handling request", e); - return createResponse(500, ApiResponse.error("Internal server error: " + e.getMessage())); - } - } - - private APIGatewayProxyResponseEvent getOverallStats(APIGatewayProxyRequestEvent request) { - Map pathParams = request.getPathParameters(); - String userId = pathParams != null ? pathParams.get("userId") : null; - - if (userId == null) { - return createResponse(400, ApiResponse.error("userId is required")); - } - - // 단어 학습 상태별 통계 - Map wordStatusCounts = new HashMap<>(); - wordStatusCounts.put("NEW", 0); - wordStatusCounts.put("LEARNING", 0); - wordStatusCounts.put("REVIEWING", 0); - wordStatusCounts.put("MASTERED", 0); - - int totalCorrect = 0; - int totalIncorrect = 0; - - // 사용자 단어 통계 조회 - String cursor = null; - do { - UserWordRepository.UserWordPage page = userWordRepository.findByUserIdWithPagination(userId, 100, cursor); - for (UserWord userWord : page.getUserWords()) { - String status = userWord.getStatus(); - wordStatusCounts.merge(status, 1, Integer::sum); - totalCorrect += userWord.getCorrectCount() != null ? userWord.getCorrectCount() : 0; - totalIncorrect += userWord.getIncorrectCount() != null ? userWord.getIncorrectCount() : 0; - } - cursor = page.getNextCursor(); - } while (cursor != null); - - int totalWords = wordStatusCounts.values().stream().mapToInt(Integer::intValue).sum(); - - // 시험 통계 - TestResultRepository.TestResultPage testPage = testResultRepository.findByUserIdWithPagination(userId, 100, null); - List testResults = testPage.getTestResults(); - - double avgSuccessRate = testResults.stream() - .mapToDouble(TestResult::getSuccessRate) - .average() - .orElse(0.0); - - // 학습 일수 - DailyStudyRepository.DailyStudyPage dailyPage = dailyStudyRepository.findByUserIdWithPagination(userId, 365, null); - int studyDays = dailyPage.getDailyStudies().size(); - int completedDays = (int) dailyPage.getDailyStudies().stream() - .filter(d -> Boolean.TRUE.equals(d.getIsCompleted())) - .count(); - - Map stats = new HashMap<>(); - stats.put("totalWords", totalWords); - stats.put("wordStatusCounts", wordStatusCounts); - stats.put("totalCorrect", totalCorrect); - stats.put("totalIncorrect", totalIncorrect); - stats.put("accuracy", totalCorrect + totalIncorrect > 0 - ? (totalCorrect * 100.0 / (totalCorrect + totalIncorrect)) : 0); - stats.put("testCount", testResults.size()); - stats.put("avgSuccessRate", avgSuccessRate); - stats.put("studyDays", studyDays); - stats.put("completedDays", completedDays); - stats.put("completionRate", studyDays > 0 ? (completedDays * 100.0 / studyDays) : 0); - - return createResponse(200, ApiResponse.success("Stats retrieved", stats)); - } - - private APIGatewayProxyResponseEvent getDailyStats(APIGatewayProxyRequestEvent request) { - Map pathParams = request.getPathParameters(); - Map queryParams = request.getQueryStringParameters(); - - String userId = pathParams != null ? pathParams.get("userId") : null; - String cursor = queryParams != null ? queryParams.get("cursor") : null; - - if (userId == null) { - return createResponse(400, ApiResponse.error("userId is required")); - } - - int limit = 30; // 최근 30일 - if (queryParams != null && queryParams.get("limit") != null) { - limit = Math.min(Integer.parseInt(queryParams.get("limit")), 90); - } - - DailyStudyRepository.DailyStudyPage dailyPage = dailyStudyRepository.findByUserIdWithPagination(userId, limit, cursor); - - List> dailyStats = dailyPage.getDailyStudies().stream() - .map(daily -> { - Map stat = new HashMap<>(); - stat.put("date", daily.getDate()); - stat.put("totalWords", daily.getTotalWords()); - stat.put("learnedCount", daily.getLearnedCount()); - stat.put("isCompleted", daily.getIsCompleted()); - stat.put("progress", daily.getTotalWords() > 0 - ? (daily.getLearnedCount() * 100.0 / daily.getTotalWords()) : 0); - return stat; - }) - .toList(); - - Map result = new HashMap<>(); - result.put("dailyStats", dailyStats); - result.put("nextCursor", dailyPage.getNextCursor()); - result.put("hasMore", dailyPage.hasMore()); - - return createResponse(200, ApiResponse.success("Daily stats retrieved", result)); - } - - /** - * 약점 분석 - 틀린 횟수가 많은 단어, 카테고리/레벨별 정확도 분석 - */ - private APIGatewayProxyResponseEvent getWeaknessAnalysis(APIGatewayProxyRequestEvent request) { - Map pathParams = request.getPathParameters(); - String userId = pathParams != null ? pathParams.get("userId") : null; - - if (userId == null) { - return createResponse(400, ApiResponse.error("userId is required")); - } - - // 사용자의 모든 학습 단어 조회 - List allUserWords = new ArrayList<>(); - String cursor = null; - do { - UserWordRepository.UserWordPage page = userWordRepository.findByUserIdWithPagination(userId, 100, cursor); - allUserWords.addAll(page.getUserWords()); - cursor = page.getNextCursor(); - } while (cursor != null); - - if (allUserWords.isEmpty()) { - Map emptyResult = new HashMap<>(); - emptyResult.put("weakestWords", List.of()); - emptyResult.put("categoryAnalysis", Map.of()); - emptyResult.put("levelAnalysis", Map.of()); - emptyResult.put("suggestions", List.of()); - return createResponse(200, ApiResponse.success("No learning data", emptyResult)); - } - - // 1. 가장 많이 틀린 단어 Top 10 - List> weakestWords = allUserWords.stream() - .filter(uw -> uw.getIncorrectCount() != null && uw.getIncorrectCount() > 0) - .sorted(Comparator.comparingInt(UserWord::getIncorrectCount).reversed()) - .limit(10) - .map(uw -> { - Map wordInfo = new HashMap<>(); - wordInfo.put("wordId", uw.getWordId()); - wordInfo.put("incorrectCount", uw.getIncorrectCount()); - wordInfo.put("correctCount", uw.getCorrectCount()); - wordInfo.put("status", uw.getStatus()); - - // 단어 상세 정보 조회 - wordRepository.findById(uw.getWordId()).ifPresent(word -> { - wordInfo.put("english", word.getEnglish()); - wordInfo.put("korean", word.getKorean()); - wordInfo.put("level", word.getLevel()); - wordInfo.put("category", word.getCategory()); - }); - - int total = (uw.getCorrectCount() != null ? uw.getCorrectCount() : 0) + - (uw.getIncorrectCount() != null ? uw.getIncorrectCount() : 0); - wordInfo.put("accuracy", total > 0 ? - (uw.getCorrectCount() != null ? uw.getCorrectCount() * 100.0 / total : 0) : 0); - - return wordInfo; - }) - .collect(Collectors.toList()); - - // 2. 카테고리별 정확도 분석 - Map> categoryAnalysis = new HashMap<>(); - // 3. 레벨별 정확도 분석 - Map> levelAnalysis = new HashMap<>(); - - for (UserWord uw : allUserWords) { - // 단어 정보 조회 - wordRepository.findById(uw.getWordId()).ifPresent(word -> { - String category = word.getCategory(); - String level = word.getLevel(); - - int correct = uw.getCorrectCount() != null ? uw.getCorrectCount() : 0; - int incorrect = uw.getIncorrectCount() != null ? uw.getIncorrectCount() : 0; - - // 카테고리별 집계 - categoryAnalysis.computeIfAbsent(category, k -> { - Map stats = new HashMap<>(); - stats.put("totalCorrect", 0); - stats.put("totalIncorrect", 0); - stats.put("wordCount", 0); - return stats; - }); - Map catStats = categoryAnalysis.get(category); - catStats.put("totalCorrect", (Integer) catStats.get("totalCorrect") + correct); - catStats.put("totalIncorrect", (Integer) catStats.get("totalIncorrect") + incorrect); - catStats.put("wordCount", (Integer) catStats.get("wordCount") + 1); - - // 레벨별 집계 - levelAnalysis.computeIfAbsent(level, k -> { - Map stats = new HashMap<>(); - stats.put("totalCorrect", 0); - stats.put("totalIncorrect", 0); - stats.put("wordCount", 0); - return stats; - }); - Map lvlStats = levelAnalysis.get(level); - lvlStats.put("totalCorrect", (Integer) lvlStats.get("totalCorrect") + correct); - lvlStats.put("totalIncorrect", (Integer) lvlStats.get("totalIncorrect") + incorrect); - lvlStats.put("wordCount", (Integer) lvlStats.get("wordCount") + 1); - }); - } - - // 정확도 계산 - categoryAnalysis.values().forEach(stats -> { - int correct = (Integer) stats.get("totalCorrect"); - int incorrect = (Integer) stats.get("totalIncorrect"); - int total = correct + incorrect; - stats.put("accuracy", total > 0 ? (correct * 100.0 / total) : 0); - }); - - levelAnalysis.values().forEach(stats -> { - int correct = (Integer) stats.get("totalCorrect"); - int incorrect = (Integer) stats.get("totalIncorrect"); - int total = correct + incorrect; - stats.put("accuracy", total > 0 ? (correct * 100.0 / total) : 0); - }); - - // 4. 학습 제안 생성 - List suggestions = new ArrayList<>(); - - // 가장 약한 카테고리 찾기 - categoryAnalysis.entrySet().stream() - .filter(e -> (Integer) e.getValue().get("wordCount") >= 3) // 최소 3개 이상 학습한 카테고리만 - .min(Comparator.comparingDouble(e -> (Double) e.getValue().get("accuracy"))) - .ifPresent(e -> suggestions.add( - String.format("%s 카테고리의 정확도가 %.1f%%로 가장 낮습니다. 집중 학습을 권장합니다.", - e.getKey(), e.getValue().get("accuracy")))); - - // 가장 약한 레벨 찾기 - levelAnalysis.entrySet().stream() - .filter(e -> (Integer) e.getValue().get("wordCount") >= 3) - .min(Comparator.comparingDouble(e -> (Double) e.getValue().get("accuracy"))) - .ifPresent(e -> suggestions.add( - String.format("%s 레벨의 정확도가 %.1f%%입니다. 이 레벨의 단어들을 더 복습해보세요.", - e.getKey(), e.getValue().get("accuracy")))); - - // 많이 틀린 단어가 있는 경우 - if (!weakestWords.isEmpty()) { - suggestions.add(String.format("자주 틀리는 단어 %d개가 있습니다. 북마크하여 집중 복습하세요.", - weakestWords.size())); - } - - Map result = new HashMap<>(); - result.put("weakestWords", weakestWords); - result.put("categoryAnalysis", categoryAnalysis); - result.put("levelAnalysis", levelAnalysis); - result.put("suggestions", suggestions); - - return createResponse(200, ApiResponse.success("Weakness analysis completed", result)); - } - - private APIGatewayProxyResponseEvent createResponse(int statusCode, Object body) { - return new APIGatewayProxyResponseEvent() - .withStatusCode(statusCode) - .withHeaders(Map.of( - "Content-Type", "application/json", - "Access-Control-Allow-Origin", "*", - "Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS", - "Access-Control-Allow-Headers", "Content-Type,Authorization" - )) - .withBody(gson.toJson(body)); - } -} diff --git a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/TestHandler.java b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/TestHandler.java deleted file mode 100644 index 9e27a220..00000000 --- a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/TestHandler.java +++ /dev/null @@ -1,377 +0,0 @@ -package com.mzc.secondproject.serverless.vocabulary.handler; - -import com.amazonaws.services.lambda.runtime.Context; -import com.amazonaws.services.lambda.runtime.RequestHandler; -import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; -import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; -import com.mzc.secondproject.serverless.vocabulary.dto.ApiResponse; -import com.mzc.secondproject.serverless.vocabulary.model.DailyStudy; -import com.mzc.secondproject.serverless.vocabulary.model.TestResult; -import com.mzc.secondproject.serverless.vocabulary.model.Word; -import com.mzc.secondproject.serverless.vocabulary.repository.DailyStudyRepository; -import com.mzc.secondproject.serverless.vocabulary.repository.TestResultRepository; -import com.mzc.secondproject.serverless.vocabulary.repository.WordRepository; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import software.amazon.awssdk.services.sns.SnsClient; -import software.amazon.awssdk.services.sns.model.PublishRequest; - -import java.time.Instant; -import java.time.LocalDate; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Random; -import java.util.UUID; -import java.util.stream.Collectors; - -public class TestHandler implements RequestHandler { - - private static final Logger logger = LoggerFactory.getLogger(TestHandler.class); - private static final Gson gson = new GsonBuilder().setPrettyPrinting().create(); - private static final SnsClient snsClient = SnsClient.builder().build(); - private static final String TEST_RESULT_TOPIC_ARN = System.getenv("TEST_RESULT_TOPIC_ARN"); - - private final TestResultRepository testResultRepository; - private final DailyStudyRepository dailyStudyRepository; - private final WordRepository wordRepository; - - public TestHandler() { - this.testResultRepository = new TestResultRepository(); - this.dailyStudyRepository = new DailyStudyRepository(); - this.wordRepository = new WordRepository(); - } - - @Override - public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { - String httpMethod = request.getHttpMethod(); - String path = request.getPath(); - - logger.info("Received request: {} {}", httpMethod, path); - - try { - // POST /vocab/test/{userId}/start - 시험 시작 - if ("POST".equals(httpMethod) && path.endsWith("/start")) { - return startTest(request); - } - - // POST /vocab/test/{userId}/submit - 답안 제출 - if ("POST".equals(httpMethod) && path.endsWith("/submit")) { - return submitAnswer(request); - } - - // GET /vocab/test/{userId}/results - 시험 결과 조회 - if ("GET".equals(httpMethod) && path.endsWith("/results")) { - return getTestResults(request); - } - - return createResponse(404, ApiResponse.error("Not found")); - - } catch (Exception e) { - logger.error("Error handling request", e); - return createResponse(500, ApiResponse.error("Internal server error: " + e.getMessage())); - } - } - - private APIGatewayProxyResponseEvent startTest(APIGatewayProxyRequestEvent request) { - Map pathParams = request.getPathParameters(); - String userId = pathParams != null ? pathParams.get("userId") : null; - - if (userId == null) { - return createResponse(400, ApiResponse.error("userId is required")); - } - - String body = request.getBody(); - Map requestBody = gson.fromJson(body, Map.class); - String testType = (String) requestBody.getOrDefault("testType", "DAILY"); - - String today = LocalDate.now().toString(); - - // 오늘 학습한 단어 기반 시험 - Optional optDailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today); - if (optDailyStudy.isEmpty()) { - return createResponse(404, ApiResponse.error("No daily study found for today")); - } - - DailyStudy dailyStudy = optDailyStudy.get(); - List allWordIds = new ArrayList<>(); - if (dailyStudy.getNewWordIds() != null) allWordIds.addAll(dailyStudy.getNewWordIds()); - if (dailyStudy.getReviewWordIds() != null) allWordIds.addAll(dailyStudy.getReviewWordIds()); - - if (allWordIds.isEmpty()) { - return createResponse(400, ApiResponse.error("No words to test")); - } - - // 시험 문제 생성 (BatchGetItem으로 한 번에 조회) - List words = wordRepository.findByIds(allWordIds); - - // 레벨별로 단어 그룹화하여 오답 후보 준비 - Map> wordsByLevel = words.stream() - .collect(Collectors.groupingBy(Word::getLevel)); - - // 각 레벨별 추가 오답 후보 단어 조회 (문제 단어 외의 다른 단어들) - Map> distractorsByLevel = new HashMap<>(); - for (String level : wordsByLevel.keySet()) { - List distractors = getDistractorsForLevel(level, allWordIds); - distractorsByLevel.put(level, distractors); - } - - Random random = new Random(); - List> questions = new ArrayList<>(); - for (Word word : words) { - Map question = new HashMap<>(); - question.put("wordId", word.getWordId()); - question.put("english", word.getEnglish()); - question.put("example", word.getExample()); - - // 4지선다 옵션 생성 - List options = generateOptions(word, wordsByLevel, distractorsByLevel, random); - question.put("options", options); - - questions.add(question); - } - - String testId = UUID.randomUUID().toString(); - String now = Instant.now().toString(); - - Map result = new HashMap<>(); - result.put("testId", testId); - result.put("testType", testType); - result.put("questions", questions); - result.put("totalQuestions", questions.size()); - result.put("startedAt", now); - - logger.info("Started test: userId={}, testId={}, questions={}", userId, testId, questions.size()); - return createResponse(200, ApiResponse.success("Test started", result)); - } - - @SuppressWarnings("unchecked") - private APIGatewayProxyResponseEvent submitAnswer(APIGatewayProxyRequestEvent request) { - Map pathParams = request.getPathParameters(); - String userId = pathParams != null ? pathParams.get("userId") : null; - - if (userId == null) { - return createResponse(400, ApiResponse.error("userId is required")); - } - - String body = request.getBody(); - Map requestBody = gson.fromJson(body, Map.class); - - String testId = (String) requestBody.get("testId"); - String testType = (String) requestBody.getOrDefault("testType", "DAILY"); - List> answers = (List>) requestBody.get("answers"); - - if (testId == null || answers == null) { - return createResponse(400, ApiResponse.error("testId and answers are required")); - } - - String now = Instant.now().toString(); - String today = LocalDate.now().toString(); - - int correctCount = 0; - int incorrectCount = 0; - List incorrectWordIds = new ArrayList<>(); - List> results = new ArrayList<>(); - - // 모든 wordId를 추출하여 BatchGetItem으로 한 번에 조회 - List wordIds = answers.stream() - .map(a -> (String) a.get("wordId")) - .collect(java.util.stream.Collectors.toList()); - List words = wordRepository.findByIds(wordIds); - - // wordId -> Word 맵 생성 - Map wordMap = words.stream() - .collect(java.util.stream.Collectors.toMap(Word::getWordId, w -> w)); - - for (Map answer : answers) { - String wordId = (String) answer.get("wordId"); - String userAnswer = (String) answer.get("answer"); - - Word word = wordMap.get(wordId); - if (word != null) { - // 대소문자 무시, 공백 제거 후 비교 - boolean isCorrect = word.getKorean().trim().equalsIgnoreCase(userAnswer.trim()); - - // 결과 상세 정보 추가 - Map resultItem = new HashMap<>(); - resultItem.put("wordId", wordId); - resultItem.put("english", word.getEnglish()); - resultItem.put("correctAnswer", word.getKorean()); - resultItem.put("userAnswer", userAnswer); - resultItem.put("isCorrect", isCorrect); - results.add(resultItem); - - if (isCorrect) { - correctCount++; - } else { - incorrectCount++; - incorrectWordIds.add(wordId); - } - } - } - - int totalQuestions = answers.size(); - double successRate = totalQuestions > 0 ? (correctCount * 100.0 / totalQuestions) : 0; - - TestResult testResult = TestResult.builder() - .pk("TEST#" + userId) - .sk("RESULT#" + now) - .gsi1pk("TEST#ALL") - .gsi1sk("DATE#" + today) - .testId(testId) - .userId(userId) - .testType(testType) - .totalQuestions(totalQuestions) - .correctAnswers(correctCount) - .incorrectAnswers(incorrectCount) - .successRate(successRate) - .incorrectWordIds(incorrectWordIds) - .startedAt((String) requestBody.get("startedAt")) - .completedAt(now) - .build(); - - testResultRepository.save(testResult); - - // SNS로 시험 결과 발행 (비동기 통계 처리용) - publishTestResultToSns(userId, results); - - // 응답 데이터 구성 (results 포함) - Map responseData = new HashMap<>(); - responseData.put("testId", testId); - responseData.put("testType", testType); - responseData.put("totalQuestions", totalQuestions); - responseData.put("correctCount", correctCount); - responseData.put("incorrectCount", incorrectCount); - responseData.put("successRate", successRate); - responseData.put("results", results); - - logger.info("Test submitted: userId={}, testId={}, successRate={}%", userId, testId, successRate); - return createResponse(200, ApiResponse.success("Test submitted", responseData)); - } - - private APIGatewayProxyResponseEvent getTestResults(APIGatewayProxyRequestEvent request) { - Map pathParams = request.getPathParameters(); - Map queryParams = request.getQueryStringParameters(); - - String userId = pathParams != null ? pathParams.get("userId") : null; - String cursor = queryParams != null ? queryParams.get("cursor") : null; - - if (userId == null) { - return createResponse(400, ApiResponse.error("userId is required")); - } - - int limit = 10; - if (queryParams != null && queryParams.get("limit") != null) { - limit = Math.min(Integer.parseInt(queryParams.get("limit")), 50); - } - - TestResultRepository.TestResultPage resultPage = testResultRepository.findByUserIdWithPagination(userId, limit, cursor); - - Map result = new HashMap<>(); - result.put("testResults", resultPage.getTestResults()); - result.put("nextCursor", resultPage.getNextCursor()); - result.put("hasMore", resultPage.hasMore()); - - return createResponse(200, ApiResponse.success("Test results retrieved", result)); - } - - /** - * 해당 레벨에서 오답 후보 단어들의 한국어 뜻 목록을 가져옴 - */ - private List getDistractorsForLevel(String level, List excludeWordIds) { - WordRepository.WordPage wordPage = wordRepository.findByLevelWithPagination(level, 50, null); - return wordPage.getWords().stream() - .filter(w -> !excludeWordIds.contains(w.getWordId())) - .map(Word::getKorean) - .collect(Collectors.toList()); - } - - /** - * 4지선다 옵션 생성 (정답 1개 + 오답 3개, 셔플됨) - */ - private List generateOptions(Word correctWord, Map> wordsByLevel, - Map> distractorsByLevel, Random random) { - List options = new ArrayList<>(); - String correctAnswer = correctWord.getKorean(); - options.add(correctAnswer); - - String level = correctWord.getLevel(); - - // 같은 레벨의 다른 문제 단어들에서 오답 후보 추출 - List sameLevelOptions = wordsByLevel.getOrDefault(level, new ArrayList<>()).stream() - .filter(w -> !w.getWordId().equals(correctWord.getWordId())) - .map(Word::getKorean) - .collect(Collectors.toList()); - - // 추가 오답 후보 (문제에 포함되지 않은 단어들) - List additionalDistractors = distractorsByLevel.getOrDefault(level, new ArrayList<>()); - - // 모든 오답 후보 합치기 - List allDistractors = new ArrayList<>(); - allDistractors.addAll(sameLevelOptions); - allDistractors.addAll(additionalDistractors); - - // 중복 및 정답 제거 - allDistractors = allDistractors.stream() - .filter(d -> !d.equals(correctAnswer)) - .distinct() - .collect(Collectors.toList()); - - // 랜덤하게 3개 선택 - Collections.shuffle(allDistractors, random); - int distractorCount = Math.min(3, allDistractors.size()); - for (int i = 0; i < distractorCount; i++) { - options.add(allDistractors.get(i)); - } - - // 옵션 셔플 - Collections.shuffle(options, random); - return options; - } - - /** - * SNS로 시험 결과 발행 (비동기 통계 처리용) - */ - private void publishTestResultToSns(String userId, List> results) { - if (TEST_RESULT_TOPIC_ARN == null || TEST_RESULT_TOPIC_ARN.isEmpty()) { - logger.warn("TEST_RESULT_TOPIC_ARN is not configured, skipping SNS publish"); - return; - } - - try { - Map message = new HashMap<>(); - message.put("userId", userId); - message.put("results", results); - - String messageJson = gson.toJson(message); - - PublishRequest publishRequest = PublishRequest.builder() - .topicArn(TEST_RESULT_TOPIC_ARN) - .message(messageJson) - .build(); - - snsClient.publish(publishRequest); - logger.info("Published test result to SNS for user: {}", userId); - } catch (Exception e) { - // SNS 발행 실패해도 API 응답에는 영향 없음 (fire-and-forget) - logger.error("Failed to publish test result to SNS for user: {}", userId, e); - } - } - - private APIGatewayProxyResponseEvent createResponse(int statusCode, Object body) { - return new APIGatewayProxyResponseEvent() - .withStatusCode(statusCode) - .withHeaders(Map.of( - "Content-Type", "application/json", - "Access-Control-Allow-Origin", "*", - "Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS", - "Access-Control-Allow-Headers", "Content-Type,Authorization" - )) - .withBody(gson.toJson(body)); - } -} diff --git a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/UserWordHandler.java b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/UserWordHandler.java deleted file mode 100644 index 3ba1b86c..00000000 --- a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/UserWordHandler.java +++ /dev/null @@ -1,371 +0,0 @@ -package com.mzc.secondproject.serverless.vocabulary.handler; - -import com.amazonaws.services.lambda.runtime.Context; -import com.amazonaws.services.lambda.runtime.RequestHandler; -import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; -import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; -import com.mzc.secondproject.serverless.vocabulary.dto.ApiResponse; -import com.mzc.secondproject.serverless.vocabulary.model.UserWord; -import com.mzc.secondproject.serverless.vocabulary.model.Word; -import com.mzc.secondproject.serverless.vocabulary.repository.UserWordRepository; -import com.mzc.secondproject.serverless.vocabulary.repository.WordRepository; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.time.Instant; -import java.time.LocalDate; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.stream.Collectors; - -public class UserWordHandler implements RequestHandler { - - private static final Logger logger = LoggerFactory.getLogger(UserWordHandler.class); - private static final Gson gson = new GsonBuilder().setPrettyPrinting().create(); - - private final UserWordRepository userWordRepository; - private final WordRepository wordRepository; - - public UserWordHandler() { - this.userWordRepository = new UserWordRepository(); - this.wordRepository = new WordRepository(); - } - - @Override - public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { - String httpMethod = request.getHttpMethod(); - String path = request.getPath(); - - logger.info("Received request: {} {}", httpMethod, path); - - try { - // GET /vocab/users/{userId}/words - 사용자 단어 목록 - if ("GET".equals(httpMethod) && path.endsWith("/words")) { - return getUserWords(request); - } - - // GET /vocab/users/{userId}/words/{wordId} - 사용자 단어 상세 - if ("GET".equals(httpMethod) && path.contains("/words/")) { - return getUserWord(request); - } - - // PUT /vocab/users/{userId}/words/{wordId}/tag - 태그 변경 - if ("PUT".equals(httpMethod) && path.endsWith("/tag")) { - return updateUserWordTag(request); - } - - // PUT /vocab/users/{userId}/words/{wordId} - 학습 상태 업데이트 - if ("PUT".equals(httpMethod) && path.contains("/words/")) { - return updateUserWord(request); - } - - return createResponse(404, ApiResponse.error("Not found")); - - } catch (Exception e) { - logger.error("Error handling request", e); - return createResponse(500, ApiResponse.error("Internal server error: " + e.getMessage())); - } - } - - private APIGatewayProxyResponseEvent getUserWords(APIGatewayProxyRequestEvent request) { - Map pathParams = request.getPathParameters(); - Map queryParams = request.getQueryStringParameters(); - - String userId = pathParams != null ? pathParams.get("userId") : null; - String status = queryParams != null ? queryParams.get("status") : null; - String cursor = queryParams != null ? queryParams.get("cursor") : null; - String bookmarked = queryParams != null ? queryParams.get("bookmarked") : null; - String incorrectOnly = queryParams != null ? queryParams.get("incorrectOnly") : null; - - if (userId == null) { - return createResponse(400, ApiResponse.error("userId is required")); - } - - int limit = 20; - if (queryParams != null && queryParams.get("limit") != null) { - limit = Math.min(Integer.parseInt(queryParams.get("limit")), 50); - } - - UserWordRepository.UserWordPage userWordPage; - - // 필터 우선순위: bookmarked > incorrectOnly > status > 전체 - if ("true".equalsIgnoreCase(bookmarked)) { - userWordPage = userWordRepository.findBookmarkedWords(userId, limit, cursor); - } else if ("true".equalsIgnoreCase(incorrectOnly)) { - userWordPage = userWordRepository.findIncorrectWords(userId, limit, cursor); - } else if (status != null && !status.isEmpty()) { - userWordPage = userWordRepository.findByUserIdAndStatus(userId, status, limit, cursor); - } else { - userWordPage = userWordRepository.findByUserIdWithPagination(userId, limit, cursor); - } - - // Word 정보 조인 (BatchGetItem) - List userWords = userWordPage.getUserWords(); - List> enrichedUserWords = enrichWithWordInfo(userWords); - - Map result = new HashMap<>(); - result.put("userWords", enrichedUserWords); - result.put("nextCursor", userWordPage.getNextCursor()); - result.put("hasMore", userWordPage.hasMore()); - - return createResponse(200, ApiResponse.success("User words retrieved", result)); - } - - private APIGatewayProxyResponseEvent getUserWord(APIGatewayProxyRequestEvent request) { - Map pathParams = request.getPathParameters(); - String userId = pathParams != null ? pathParams.get("userId") : null; - String wordId = pathParams != null ? pathParams.get("wordId") : null; - - if (userId == null || wordId == null) { - return createResponse(400, ApiResponse.error("userId and wordId are required")); - } - - Optional optUserWord = userWordRepository.findByUserIdAndWordId(userId, wordId); - if (optUserWord.isEmpty()) { - return createResponse(404, ApiResponse.error("UserWord not found")); - } - - return createResponse(200, ApiResponse.success("UserWord retrieved", optUserWord.get())); - } - - private APIGatewayProxyResponseEvent updateUserWord(APIGatewayProxyRequestEvent request) { - Map pathParams = request.getPathParameters(); - String userId = pathParams != null ? pathParams.get("userId") : null; - String wordId = pathParams != null ? pathParams.get("wordId") : null; - - if (userId == null || wordId == null) { - return createResponse(400, ApiResponse.error("userId and wordId are required")); - } - - String body = request.getBody(); - Map requestBody = gson.fromJson(body, Map.class); - - // 정답/오답 여부 - Boolean isCorrect = (Boolean) requestBody.get("isCorrect"); - if (isCorrect == null) { - return createResponse(400, ApiResponse.error("isCorrect is required")); - } - - Optional optUserWord = userWordRepository.findByUserIdAndWordId(userId, wordId); - UserWord userWord; - String now = Instant.now().toString(); - - if (optUserWord.isEmpty()) { - // 새로운 UserWord 생성 - userWord = UserWord.builder() - .pk("USER#" + userId) - .sk("WORD#" + wordId) - .gsi1pk("USER#" + userId + "#REVIEW") - .gsi2pk("USER#" + userId + "#STATUS") - .userId(userId) - .wordId(wordId) - .status("NEW") - .interval(1) - .easeFactor(2.5) - .repetitions(0) - .correctCount(0) - .incorrectCount(0) - .createdAt(now) - .build(); - } else { - userWord = optUserWord.get(); - } - - // Spaced Repetition 알고리즘 적용 - applySpacedRepetition(userWord, isCorrect); - userWord.setUpdatedAt(now); - userWord.setLastReviewedAt(now); - - // GSI 업데이트 - userWord.setGsi1sk("DATE#" + userWord.getNextReviewAt()); - userWord.setGsi2sk("STATUS#" + userWord.getStatus()); - - userWordRepository.save(userWord); - - logger.info("Updated user word: userId={}, wordId={}, isCorrect={}", userId, wordId, isCorrect); - return createResponse(200, ApiResponse.success("UserWord updated", userWord)); - } - - /** - * 사용자 단어 태그 변경 (북마크, 즐겨찾기, 난이도) - */ - private APIGatewayProxyResponseEvent updateUserWordTag(APIGatewayProxyRequestEvent request) { - Map pathParams = request.getPathParameters(); - String userId = pathParams != null ? pathParams.get("userId") : null; - String wordId = pathParams != null ? pathParams.get("wordId") : null; - - if (userId == null || wordId == null) { - return createResponse(400, ApiResponse.error("userId and wordId are required")); - } - - String body = request.getBody(); - Map requestBody = gson.fromJson(body, Map.class); - - Optional optUserWord = userWordRepository.findByUserIdAndWordId(userId, wordId); - UserWord userWord; - String now = Instant.now().toString(); - - if (optUserWord.isEmpty()) { - // 새로운 UserWord 생성 (태그만 설정) - userWord = UserWord.builder() - .pk("USER#" + userId) - .sk("WORD#" + wordId) - .gsi1pk("USER#" + userId + "#REVIEW") - .gsi2pk("USER#" + userId + "#STATUS") - .gsi2sk("STATUS#NEW") - .userId(userId) - .wordId(wordId) - .status("NEW") - .interval(1) - .easeFactor(2.5) - .repetitions(0) - .correctCount(0) - .incorrectCount(0) - .bookmarked(false) - .favorite(false) - .createdAt(now) - .build(); - } else { - userWord = optUserWord.get(); - } - - // 태그 업데이트 - if (requestBody.containsKey("bookmarked")) { - userWord.setBookmarked((Boolean) requestBody.get("bookmarked")); - } - if (requestBody.containsKey("favorite")) { - userWord.setFavorite((Boolean) requestBody.get("favorite")); - } - if (requestBody.containsKey("difficulty")) { - String difficulty = (String) requestBody.get("difficulty"); - if (difficulty != null && (difficulty.equals("EASY") || difficulty.equals("NORMAL") || difficulty.equals("HARD"))) { - userWord.setDifficulty(difficulty); - } else if (difficulty != null) { - return createResponse(400, ApiResponse.error("difficulty must be EASY, NORMAL, or HARD")); - } - } - - userWord.setUpdatedAt(now); - userWordRepository.save(userWord); - - logger.info("Updated user word tag: userId={}, wordId={}", userId, wordId); - return createResponse(200, ApiResponse.success("Tag updated", userWord)); - } - - /** - * SM-2 Spaced Repetition 알고리즘 적용 - */ - private void applySpacedRepetition(UserWord userWord, boolean isCorrect) { - if (isCorrect) { - userWord.setCorrectCount(userWord.getCorrectCount() + 1); - userWord.setRepetitions(userWord.getRepetitions() + 1); - - // 간격 계산 - if (userWord.getRepetitions() == 1) { - userWord.setInterval(1); - } else if (userWord.getRepetitions() == 2) { - userWord.setInterval(6); - } else { - int newInterval = (int) Math.round(userWord.getInterval() * userWord.getEaseFactor()); - userWord.setInterval(newInterval); - } - - // 상태 업데이트 - if (userWord.getRepetitions() >= 5) { - userWord.setStatus("MASTERED"); - } else if (userWord.getRepetitions() >= 2) { - userWord.setStatus("REVIEWING"); - } else { - userWord.setStatus("LEARNING"); - } - } else { - userWord.setIncorrectCount(userWord.getIncorrectCount() + 1); - userWord.setRepetitions(0); - userWord.setInterval(1); - userWord.setStatus("LEARNING"); - - // easeFactor 감소 (최소 1.3) - double newEaseFactor = userWord.getEaseFactor() - 0.2; - userWord.setEaseFactor(Math.max(1.3, newEaseFactor)); - } - - // 다음 복습일 계산 - LocalDate nextReview = LocalDate.now().plusDays(userWord.getInterval()); - userWord.setNextReviewAt(nextReview.toString()); - } - - /** - * UserWord 목록에 Word 정보(english, korean, level 등)를 조인 - * BatchGetItem으로 한 번에 조회하여 N+1 문제 방지 - */ - private List> enrichWithWordInfo(List userWords) { - if (userWords == null || userWords.isEmpty()) { - return new ArrayList<>(); - } - - // wordId 목록 추출 - List wordIds = userWords.stream() - .map(UserWord::getWordId) - .collect(Collectors.toList()); - - // BatchGetItem으로 Word 정보 한 번에 조회 - List words = wordRepository.findByIds(wordIds); - - // wordId -> Word 맵 생성 - Map wordMap = words.stream() - .collect(Collectors.toMap(Word::getWordId, w -> w, (w1, w2) -> w1)); - - // UserWord + Word 정보 합치기 - List> enrichedList = new ArrayList<>(); - for (UserWord userWord : userWords) { - Map enriched = new HashMap<>(); - - // UserWord 정보 - enriched.put("wordId", userWord.getWordId()); - enriched.put("userId", userWord.getUserId()); - enriched.put("status", userWord.getStatus()); - enriched.put("correctCount", userWord.getCorrectCount()); - enriched.put("incorrectCount", userWord.getIncorrectCount()); - enriched.put("bookmarked", userWord.getBookmarked()); - enriched.put("favorite", userWord.getFavorite()); - enriched.put("difficulty", userWord.getDifficulty()); - enriched.put("nextReviewAt", userWord.getNextReviewAt()); - enriched.put("lastReviewedAt", userWord.getLastReviewedAt()); - enriched.put("repetitions", userWord.getRepetitions()); - enriched.put("interval", userWord.getInterval()); - - // Word 정보 추가 - Word word = wordMap.get(userWord.getWordId()); - if (word != null) { - enriched.put("english", word.getEnglish()); - enriched.put("korean", word.getKorean()); - enriched.put("level", word.getLevel()); - enriched.put("category", word.getCategory()); - enriched.put("example", word.getExample()); - enriched.put("maleVoiceKey", word.getMaleVoiceKey()); - enriched.put("femaleVoiceKey", word.getFemaleVoiceKey()); - } - - enrichedList.add(enriched); - } - - return enrichedList; - } - - private APIGatewayProxyResponseEvent createResponse(int statusCode, Object body) { - return new APIGatewayProxyResponseEvent() - .withStatusCode(statusCode) - .withHeaders(Map.of( - "Content-Type", "application/json", - "Access-Control-Allow-Origin", "*", - "Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS", - "Access-Control-Allow-Headers", "Content-Type,Authorization" - )) - .withBody(gson.toJson(body)); - } -} diff --git a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/VoiceHandler.java b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/VoiceHandler.java deleted file mode 100644 index c49d4fef..00000000 --- a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/VoiceHandler.java +++ /dev/null @@ -1,123 +0,0 @@ -package com.mzc.secondproject.serverless.vocabulary.handler; - -import com.amazonaws.services.lambda.runtime.Context; -import com.amazonaws.services.lambda.runtime.RequestHandler; -import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; -import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; -import com.mzc.secondproject.serverless.vocabulary.dto.ApiResponse; -import com.mzc.secondproject.serverless.vocabulary.model.Word; -import com.mzc.secondproject.serverless.vocabulary.repository.WordRepository; -import com.mzc.secondproject.serverless.vocabulary.service.PollyService; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.HashMap; -import java.util.Map; -import java.util.Optional; - -public class VoiceHandler implements RequestHandler { - - private static final Logger logger = LoggerFactory.getLogger(VoiceHandler.class); - private static final Gson gson = new GsonBuilder().setPrettyPrinting().create(); - - private final WordRepository wordRepository; - private final PollyService pollyService; - - public VoiceHandler() { - this.wordRepository = new WordRepository(); - this.pollyService = new PollyService(); - } - - @Override - public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { - String httpMethod = request.getHttpMethod(); - String path = request.getPath(); - - logger.info("Received request: {} {}", httpMethod, path); - - try { - // POST /vocab/voice/synthesize - 음성 합성 - if ("POST".equals(httpMethod) && path.endsWith("/synthesize")) { - return synthesizeSpeech(request); - } - - return createResponse(404, ApiResponse.error("Not found")); - - } catch (Exception e) { - logger.error("Error handling request", e); - return createResponse(500, ApiResponse.error("Internal server error: " + e.getMessage())); - } - } - - private APIGatewayProxyResponseEvent synthesizeSpeech(APIGatewayProxyRequestEvent request) { - String body = request.getBody(); - Map requestBody = gson.fromJson(body, Map.class); - - String wordId = (String) requestBody.get("wordId"); - String voice = (String) requestBody.getOrDefault("voice", "FEMALE"); - - if (wordId == null || wordId.isEmpty()) { - return createResponse(400, ApiResponse.error("wordId is required")); - } - - // 단어 조회 - Optional optWord = wordRepository.findById(wordId); - if (optWord.isEmpty()) { - return createResponse(404, ApiResponse.error("Word not found")); - } - - Word word = optWord.get(); - boolean isMale = "MALE".equalsIgnoreCase(voice); - - // 캐시 확인: DynamoDB에 저장된 S3 키 확인 - String cachedKey = isMale ? word.getMaleVoiceKey() : word.getFemaleVoiceKey(); - String audioUrl; - boolean cached = false; - - if (cachedKey != null && !cachedKey.isEmpty()) { - // DB에 캐시 키가 있으면 Pre-signed URL 생성 - audioUrl = pollyService.getPresignedUrl(cachedKey); - cached = true; - logger.info("Cache hit from DB: wordId={}, voice={}", wordId, voice); - } else { - // 캐시 미스: Polly 변환 후 S3 저장 - PollyService.VoiceSynthesisResult result = pollyService.synthesizeSpeechForWord( - wordId, word.getEnglish(), voice); - - audioUrl = result.getAudioUrl(); - cached = result.isCached(); - - // DynamoDB에 S3 키 저장 - if (isMale) { - word.setMaleVoiceKey(result.getS3Key()); - } else { - word.setFemaleVoiceKey(result.getS3Key()); - } - wordRepository.save(word); - logger.info("Saved voice cache to DB: wordId={}, voice={}", wordId, voice); - } - - Map responseData = new HashMap<>(); - responseData.put("wordId", wordId); - responseData.put("english", word.getEnglish()); - responseData.put("voice", voice); - responseData.put("audioUrl", audioUrl); - responseData.put("cached", cached); - - return createResponse(200, ApiResponse.success("Speech synthesized", responseData)); - } - - private APIGatewayProxyResponseEvent createResponse(int statusCode, Object body) { - return new APIGatewayProxyResponseEvent() - .withStatusCode(statusCode) - .withHeaders(Map.of( - "Content-Type", "application/json", - "Access-Control-Allow-Origin", "*", - "Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS", - "Access-Control-Allow-Headers", "Content-Type,Authorization" - )) - .withBody(gson.toJson(body)); - } -} diff --git a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/WordHandler.java b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/WordHandler.java deleted file mode 100644 index e05352e5..00000000 --- a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/WordHandler.java +++ /dev/null @@ -1,337 +0,0 @@ -package com.mzc.secondproject.serverless.vocabulary.handler; - -import com.amazonaws.services.lambda.runtime.Context; -import com.amazonaws.services.lambda.runtime.RequestHandler; -import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; -import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; -import com.mzc.secondproject.serverless.vocabulary.dto.ApiResponse; -import com.mzc.secondproject.serverless.vocabulary.model.Word; -import com.mzc.secondproject.serverless.vocabulary.repository.WordRepository; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.time.Instant; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.UUID; - -public class WordHandler implements RequestHandler { - - private static final Logger logger = LoggerFactory.getLogger(WordHandler.class); - private static final Gson gson = new GsonBuilder().setPrettyPrinting().create(); - - private final WordRepository wordRepository; - - public WordHandler() { - this.wordRepository = new WordRepository(); - } - - @Override - public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { - String httpMethod = request.getHttpMethod(); - String path = request.getPath(); - - logger.info("Received request: {} {}", httpMethod, path); - - try { - // POST /vocab/words/batch - 단어 일괄 등록 - if ("POST".equals(httpMethod) && path.endsWith("/batch")) { - return createWordsBatch(request); - } - - // GET /vocab/words/search - 단어 검색 - if ("GET".equals(httpMethod) && path.endsWith("/search")) { - return searchWords(request); - } - - // POST /vocab/words - 단어 생성 - if ("POST".equals(httpMethod) && path.endsWith("/words")) { - return createWord(request); - } - - // GET /vocab/words - 단어 목록 조회 - if ("GET".equals(httpMethod) && path.endsWith("/words")) { - return getWords(request); - } - - // GET /vocab/words/{wordId} - 단어 상세 조회 - if ("GET".equals(httpMethod) && path.contains("/words/") && !path.contains("/search") && !path.contains("/batch")) { - return getWord(request); - } - - // PUT /vocab/words/{wordId} - 단어 수정 - if ("PUT".equals(httpMethod) && path.contains("/words/")) { - return updateWord(request); - } - - // DELETE /vocab/words/{wordId} - 단어 삭제 - if ("DELETE".equals(httpMethod) && path.contains("/words/")) { - return deleteWord(request); - } - - return createResponse(404, ApiResponse.error("Not found")); - - } catch (Exception e) { - logger.error("Error handling request", e); - return createResponse(500, ApiResponse.error("Internal server error: " + e.getMessage())); - } - } - - private APIGatewayProxyResponseEvent createWord(APIGatewayProxyRequestEvent request) { - String body = request.getBody(); - Map requestBody = gson.fromJson(body, Map.class); - - String english = (String) requestBody.get("english"); - String korean = (String) requestBody.get("korean"); - String example = (String) requestBody.get("example"); - String level = (String) requestBody.getOrDefault("level", "BEGINNER"); - String category = (String) requestBody.getOrDefault("category", "DAILY"); - - if (english == null || english.isEmpty()) { - return createResponse(400, ApiResponse.error("english is required")); - } - if (korean == null || korean.isEmpty()) { - return createResponse(400, ApiResponse.error("korean is required")); - } - - String wordId = UUID.randomUUID().toString(); - String now = Instant.now().toString(); - - Word word = Word.builder() - .pk("WORD#" + wordId) - .sk("METADATA") - .gsi1pk("LEVEL#" + level) - .gsi1sk("WORD#" + wordId) - .gsi2pk("CATEGORY#" + category) - .gsi2sk("WORD#" + wordId) - .wordId(wordId) - .english(english) - .korean(korean) - .example(example) - .level(level) - .category(category) - .createdAt(now) - .build(); - - wordRepository.save(word); - - logger.info("Created word: {}", wordId); - return createResponse(201, ApiResponse.success("Word created", word)); - } - - private APIGatewayProxyResponseEvent getWords(APIGatewayProxyRequestEvent request) { - Map queryParams = request.getQueryStringParameters(); - - String level = queryParams != null ? queryParams.get("level") : null; - String category = queryParams != null ? queryParams.get("category") : null; - String cursor = queryParams != null ? queryParams.get("cursor") : null; - - int limit = 20; - if (queryParams != null && queryParams.get("limit") != null) { - limit = Math.min(Integer.parseInt(queryParams.get("limit")), 50); - } - - WordRepository.WordPage wordPage; - if (level != null && !level.isEmpty()) { - wordPage = wordRepository.findByLevelWithPagination(level, limit, cursor); - } else if (category != null && !category.isEmpty()) { - wordPage = wordRepository.findByCategoryWithPagination(category, limit, cursor); - } else { - // 기본: BEGINNER 레벨 - wordPage = wordRepository.findByLevelWithPagination("BEGINNER", limit, cursor); - } - - Map result = new HashMap<>(); - result.put("words", wordPage.getWords()); - result.put("nextCursor", wordPage.getNextCursor()); - result.put("hasMore", wordPage.hasMore()); - - return createResponse(200, ApiResponse.success("Words retrieved", result)); - } - - private APIGatewayProxyResponseEvent getWord(APIGatewayProxyRequestEvent request) { - Map pathParams = request.getPathParameters(); - String wordId = pathParams != null ? pathParams.get("wordId") : null; - - if (wordId == null) { - return createResponse(400, ApiResponse.error("wordId is required")); - } - - Optional optWord = wordRepository.findById(wordId); - if (optWord.isEmpty()) { - return createResponse(404, ApiResponse.error("Word not found")); - } - - return createResponse(200, ApiResponse.success("Word retrieved", optWord.get())); - } - - private APIGatewayProxyResponseEvent updateWord(APIGatewayProxyRequestEvent request) { - Map pathParams = request.getPathParameters(); - String wordId = pathParams != null ? pathParams.get("wordId") : null; - - if (wordId == null) { - return createResponse(400, ApiResponse.error("wordId is required")); - } - - Optional optWord = wordRepository.findById(wordId); - if (optWord.isEmpty()) { - return createResponse(404, ApiResponse.error("Word not found")); - } - - Word word = optWord.get(); - String body = request.getBody(); - Map requestBody = gson.fromJson(body, Map.class); - - if (requestBody.containsKey("english")) { - word.setEnglish((String) requestBody.get("english")); - } - if (requestBody.containsKey("korean")) { - word.setKorean((String) requestBody.get("korean")); - } - if (requestBody.containsKey("example")) { - word.setExample((String) requestBody.get("example")); - } - if (requestBody.containsKey("level")) { - String newLevel = (String) requestBody.get("level"); - word.setLevel(newLevel); - word.setGsi1pk("LEVEL#" + newLevel); - } - if (requestBody.containsKey("category")) { - String newCategory = (String) requestBody.get("category"); - word.setCategory(newCategory); - word.setGsi2pk("CATEGORY#" + newCategory); - } - - wordRepository.save(word); - - logger.info("Updated word: {}", wordId); - return createResponse(200, ApiResponse.success("Word updated", word)); - } - - private APIGatewayProxyResponseEvent deleteWord(APIGatewayProxyRequestEvent request) { - Map pathParams = request.getPathParameters(); - String wordId = pathParams != null ? pathParams.get("wordId") : null; - - if (wordId == null) { - return createResponse(400, ApiResponse.error("wordId is required")); - } - - Optional optWord = wordRepository.findById(wordId); - if (optWord.isEmpty()) { - return createResponse(404, ApiResponse.error("Word not found")); - } - - wordRepository.delete(wordId); - - logger.info("Deleted word: {}", wordId); - return createResponse(200, ApiResponse.success("Word deleted", null)); - } - - @SuppressWarnings("unchecked") - private APIGatewayProxyResponseEvent createWordsBatch(APIGatewayProxyRequestEvent request) { - String body = request.getBody(); - Map requestBody = gson.fromJson(body, Map.class); - - List> wordsList = (List>) requestBody.get("words"); - if (wordsList == null || wordsList.isEmpty()) { - return createResponse(400, ApiResponse.error("words array is required")); - } - - String now = Instant.now().toString(); - List createdWords = new ArrayList<>(); - int successCount = 0; - int failCount = 0; - - for (Map wordData : wordsList) { - try { - String english = (String) wordData.get("english"); - String korean = (String) wordData.get("korean"); - String example = (String) wordData.get("example"); - String level = (String) wordData.getOrDefault("level", "BEGINNER"); - String category = (String) wordData.getOrDefault("category", "DAILY"); - - if (english == null || korean == null) { - failCount++; - continue; - } - - String wordId = UUID.randomUUID().toString(); - - Word word = Word.builder() - .pk("WORD#" + wordId) - .sk("METADATA") - .gsi1pk("LEVEL#" + level) - .gsi1sk("WORD#" + wordId) - .gsi2pk("CATEGORY#" + category) - .gsi2sk("WORD#" + wordId) - .wordId(wordId) - .english(english) - .korean(korean) - .example(example) - .level(level) - .category(category) - .createdAt(now) - .build(); - - wordRepository.save(word); - createdWords.add(word); - successCount++; - } catch (Exception e) { - logger.error("Failed to create word", e); - failCount++; - } - } - - Map result = new HashMap<>(); - result.put("successCount", successCount); - result.put("failCount", failCount); - result.put("totalRequested", wordsList.size()); - - logger.info("Batch created {} words, failed {}", successCount, failCount); - return createResponse(201, ApiResponse.success("Batch completed", result)); - } - - private APIGatewayProxyResponseEvent searchWords(APIGatewayProxyRequestEvent request) { - Map queryParams = request.getQueryStringParameters(); - - String query = queryParams != null ? queryParams.get("q") : null; - String cursor = queryParams != null ? queryParams.get("cursor") : null; - - if (query == null || query.isEmpty()) { - return createResponse(400, ApiResponse.error("q (query) parameter is required")); - } - - int limit = 20; - if (queryParams.get("limit") != null) { - limit = Math.min(Integer.parseInt(queryParams.get("limit")), 50); - } - - // 영어/한국어 모두 검색 - WordRepository.WordPage wordPage = wordRepository.searchByKeyword(query, limit, cursor); - - Map result = new HashMap<>(); - result.put("words", wordPage.getWords()); - result.put("query", query); - result.put("nextCursor", wordPage.getNextCursor()); - result.put("hasMore", wordPage.hasMore()); - - return createResponse(200, ApiResponse.success("Search completed", result)); - } - - private APIGatewayProxyResponseEvent createResponse(int statusCode, Object body) { - return new APIGatewayProxyResponseEvent() - .withStatusCode(statusCode) - .withHeaders(Map.of( - "Content-Type", "application/json", - "Access-Control-Allow-Origin", "*", - "Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS", - "Access-Control-Allow-Headers", "Content-Type,Authorization" - )) - .withBody(gson.toJson(body)); - } -} diff --git a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/DailyStudy.java b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/DailyStudy.java deleted file mode 100644 index c6c51520..00000000 --- a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/DailyStudy.java +++ /dev/null @@ -1,74 +0,0 @@ -package com.mzc.secondproject.serverless.vocabulary.model; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbAttribute; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondaryPartitionKey; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondarySortKey; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSortKey; - -import java.util.List; - -/** - * 일일 학습 정보 - * PK: DAILY#{userId} - * SK: DATE#{date} - * GSI1: DAILY#ALL / DATE#{date} - 전체 일일 학습 조회 - */ -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -@DynamoDbBean -public class DailyStudy { - - private String pk; // DAILY#{userId} - private String sk; // DATE#{date} - private String gsi1pk; // DAILY#ALL - private String gsi1sk; // DATE#{date} - - private String userId; - private String date; // yyyy-MM-dd - - // 학습 단어 목록 (55개: 50개 신규 + 5개 복습) - private List newWordIds; // 신규 단어 ID 목록 (50개) - private List reviewWordIds; // 복습 단어 ID 목록 (5개) - private List learnedWordIds; // 학습 완료 단어 ID 목록 - - // 진행 상태 - private Integer totalWords; // 총 단어 수 (55) - private Integer learnedCount; // 학습 완료 수 - private Boolean isCompleted; // 일일 학습 완료 여부 - - private String createdAt; - private String updatedAt; - private Long ttl; - - @DynamoDbPartitionKey - @DynamoDbAttribute("PK") - public String getPk() { - return pk; - } - - @DynamoDbSortKey - @DynamoDbAttribute("SK") - public String getSk() { - return sk; - } - - @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") - @DynamoDbAttribute("GSI1PK") - public String getGsi1pk() { - return gsi1pk; - } - - @DynamoDbSecondarySortKey(indexNames = "GSI1") - @DynamoDbAttribute("GSI1SK") - public String getGsi1sk() { - return gsi1sk; - } -} diff --git a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/TestResult.java b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/TestResult.java deleted file mode 100644 index 1b0da164..00000000 --- a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/TestResult.java +++ /dev/null @@ -1,74 +0,0 @@ -package com.mzc.secondproject.serverless.vocabulary.model; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbAttribute; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondaryPartitionKey; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondarySortKey; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSortKey; - -import java.util.List; - -/** - * 시험 결과 - * PK: TEST#{userId} - * SK: RESULT#{timestamp} - * GSI1: TEST#ALL / DATE#{date} - 전체 시험 결과 조회 - */ -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -@DynamoDbBean -public class TestResult { - - private String pk; // TEST#{userId} - private String sk; // RESULT#{timestamp} - private String gsi1pk; // TEST#ALL - private String gsi1sk; // DATE#{date} - - private String testId; - private String userId; - private String testType; // DAILY, WEEKLY, CUSTOM - - // 시험 결과 - private Integer totalQuestions; - private Integer correctAnswers; - private Integer incorrectAnswers; - private Double successRate; // 성공률 (%) - - // 오답 단어 목록 - private List incorrectWordIds; - - private String startedAt; - private String completedAt; - private Long ttl; - - @DynamoDbPartitionKey - @DynamoDbAttribute("PK") - public String getPk() { - return pk; - } - - @DynamoDbSortKey - @DynamoDbAttribute("SK") - public String getSk() { - return sk; - } - - @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") - @DynamoDbAttribute("GSI1PK") - public String getGsi1pk() { - return gsi1pk; - } - - @DynamoDbSecondarySortKey(indexNames = "GSI1") - @DynamoDbAttribute("GSI1SK") - public String getGsi1sk() { - return gsi1sk; - } -} diff --git a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/UserWord.java b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/UserWord.java deleted file mode 100644 index c9b22db1..00000000 --- a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/UserWord.java +++ /dev/null @@ -1,93 +0,0 @@ -package com.mzc.secondproject.serverless.vocabulary.model; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbAttribute; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondaryPartitionKey; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondarySortKey; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSortKey; - -/** - * 사용자별 단어 학습 상태 (Spaced Repetition) - * PK: USER#{userId} - * SK: WORD#{wordId} - * GSI1: USER#{userId}#REVIEW / DATE#{nextReviewAt} - 복습 예정 조회 - * GSI2: USER#{userId}#STATUS / STATUS#{status} - 상태별 조회 - */ -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -@DynamoDbBean -public class UserWord { - - private String pk; // USER#{userId} - private String sk; // WORD#{wordId} - private String gsi1pk; // USER#{userId}#REVIEW - private String gsi1sk; // DATE#{nextReviewAt} - private String gsi2pk; // USER#{userId}#STATUS - private String gsi2sk; // STATUS#{status} - - private String userId; - private String wordId; - private String status; // NEW, LEARNING, REVIEWING, MASTERED - - // Spaced Repetition 알고리즘 필드 - private Integer interval; // 복습 간격 (일) - private Double easeFactor; // 난이도 계수 (2.5 기본) - private Integer repetitions; // 연속 정답 횟수 - private String nextReviewAt; // 다음 복습 예정일 - private String lastReviewedAt; // 마지막 복습일 - - // 학습 통계 - private Integer correctCount; // 정답 횟수 - private Integer incorrectCount; // 오답 횟수 - private String createdAt; - private String updatedAt; - private Long ttl; - - // 사용자 태그 - private Boolean bookmarked; // 북마크 여부 - private Boolean favorite; // 즐겨찾기 여부 - private String difficulty; // 사용자 지정 난이도 (EASY, NORMAL, HARD) - - @DynamoDbPartitionKey - @DynamoDbAttribute("PK") - public String getPk() { - return pk; - } - - @DynamoDbSortKey - @DynamoDbAttribute("SK") - public String getSk() { - return sk; - } - - @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") - @DynamoDbAttribute("GSI1PK") - public String getGsi1pk() { - return gsi1pk; - } - - @DynamoDbSecondarySortKey(indexNames = "GSI1") - @DynamoDbAttribute("GSI1SK") - public String getGsi1sk() { - return gsi1sk; - } - - @DynamoDbSecondaryPartitionKey(indexNames = "GSI2") - @DynamoDbAttribute("GSI2PK") - public String getGsi2pk() { - return gsi2pk; - } - - @DynamoDbSecondarySortKey(indexNames = "GSI2") - @DynamoDbAttribute("GSI2SK") - public String getGsi2sk() { - return gsi2sk; - } -} diff --git a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/Word.java b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/Word.java deleted file mode 100644 index ce116685..00000000 --- a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/Word.java +++ /dev/null @@ -1,83 +0,0 @@ -package com.mzc.secondproject.serverless.vocabulary.model; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbAttribute; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondaryPartitionKey; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondarySortKey; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSortKey; - -/** - * 단어 정보 모델 - * PK: WORD#{wordId} - * SK: METADATA - * GSI1: LEVEL#{level} / WORD#{wordId} - 난이도별 조회 - * GSI2: CATEGORY#{category} / WORD#{wordId} - 카테고리별 조회 - */ -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -@DynamoDbBean -public class Word { - - private String pk; // WORD#{wordId} - private String sk; // METADATA - private String gsi1pk; // LEVEL#{level} - private String gsi1sk; // WORD#{wordId} - private String gsi2pk; // CATEGORY#{category} - private String gsi2sk; // WORD#{wordId} - - private String wordId; - private String english; // 영어 단어 - private String korean; // 한국어 뜻 - private String example; // 예문 - private String level; // BEGINNER, INTERMEDIATE, ADVANCED - private String category; // DAILY, BUSINESS, ACADEMIC, etc. - private String createdAt; - private Long ttl; - - // 음성 캐시용 S3 키 (vocab/voice/{wordId}_{voice}.mp3) - private String maleVoiceKey; - private String femaleVoiceKey; - - @DynamoDbPartitionKey - @DynamoDbAttribute("PK") - public String getPk() { - return pk; - } - - @DynamoDbSortKey - @DynamoDbAttribute("SK") - public String getSk() { - return sk; - } - - @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") - @DynamoDbAttribute("GSI1PK") - public String getGsi1pk() { - return gsi1pk; - } - - @DynamoDbSecondarySortKey(indexNames = "GSI1") - @DynamoDbAttribute("GSI1SK") - public String getGsi1sk() { - return gsi1sk; - } - - @DynamoDbSecondaryPartitionKey(indexNames = "GSI2") - @DynamoDbAttribute("GSI2PK") - public String getGsi2pk() { - return gsi2pk; - } - - @DynamoDbSecondarySortKey(indexNames = "GSI2") - @DynamoDbAttribute("GSI2SK") - public String getGsi2sk() { - return gsi2sk; - } -} diff --git a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/DailyStudyRepository.java b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/DailyStudyRepository.java deleted file mode 100644 index c255dd70..00000000 --- a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/DailyStudyRepository.java +++ /dev/null @@ -1,161 +0,0 @@ -package com.mzc.secondproject.serverless.vocabulary.repository; - -import com.mzc.secondproject.serverless.vocabulary.model.DailyStudy; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; -import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; -import software.amazon.awssdk.enhanced.dynamodb.Key; -import software.amazon.awssdk.enhanced.dynamodb.TableSchema; -import software.amazon.awssdk.enhanced.dynamodb.model.Page; -import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; -import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; -import software.amazon.awssdk.services.dynamodb.DynamoDbClient; -import software.amazon.awssdk.services.dynamodb.model.AttributeValue; -import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest; - -import java.util.Base64; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; - -public class DailyStudyRepository { - - private static final Logger logger = LoggerFactory.getLogger(DailyStudyRepository.class); - - // Singleton 패턴으로 Cold Start 최적화 - private static final DynamoDbClient dynamoDbClient = DynamoDbClient.builder().build(); - private static final DynamoDbEnhancedClient enhancedClient = DynamoDbEnhancedClient.builder() - .dynamoDbClient(dynamoDbClient) - .build(); - private static final String tableName = System.getenv("VOCAB_TABLE_NAME"); - - private final DynamoDbTable table; - - public DailyStudyRepository() { - this.table = enhancedClient.table(tableName, TableSchema.fromBean(DailyStudy.class)); - } - - public DailyStudy save(DailyStudy dailyStudy) { - logger.info("Saving daily study: userId={}, date={}", dailyStudy.getUserId(), dailyStudy.getDate()); - table.putItem(dailyStudy); - return dailyStudy; - } - - public Optional findByUserIdAndDate(String userId, String date) { - Key key = Key.builder() - .partitionValue("DAILY#" + userId) - .sortValue("DATE#" + date) - .build(); - - DailyStudy dailyStudy = table.getItem(key); - return Optional.ofNullable(dailyStudy); - } - - /** - * 사용자의 일일 학습 기록 조회 - 최신순, 페이지네이션 - */ - public DailyStudyPage findByUserIdWithPagination(String userId, int limit, String cursor) { - QueryConditional queryConditional = QueryConditional - .sortBeginsWith(Key.builder() - .partitionValue("DAILY#" + userId) - .sortValue("DATE#") - .build()); - - QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() - .queryConditional(queryConditional) - .scanIndexForward(false) // 최신순 - .limit(limit); - - if (cursor != null && !cursor.isEmpty()) { - Map exclusiveStartKey = decodeCursor(cursor); - if (exclusiveStartKey != null) { - requestBuilder.exclusiveStartKey(exclusiveStartKey); - } - } - - Page page = table.query(requestBuilder.build()).iterator().next(); - String nextCursor = encodeCursor(page.lastEvaluatedKey()); - - return new DailyStudyPage(page.items(), nextCursor); - } - - /** - * 학습 완료 단어 추가 (UpdateExpression 사용 - N+1 방지) - */ - public void addLearnedWord(String userId, String date, String wordId) { - Map key = new HashMap<>(); - key.put("PK", AttributeValue.builder().s("DAILY#" + userId).build()); - key.put("SK", AttributeValue.builder().s("DATE#" + date).build()); - - Map expressionValues = new HashMap<>(); - expressionValues.put(":wordId", AttributeValue.builder().ss(wordId).build()); - expressionValues.put(":one", AttributeValue.builder().n("1").build()); - - UpdateItemRequest updateRequest = UpdateItemRequest.builder() - .tableName(tableName) - .key(key) - .updateExpression("ADD learnedWordIds :wordId, learnedCount :one") - .expressionAttributeValues(expressionValues) - .build(); - - dynamoDbClient.updateItem(updateRequest); - logger.info("Added learned word: userId={}, date={}, wordId={}", userId, date, wordId); - } - - private String encodeCursor(Map lastEvaluatedKey) { - if (lastEvaluatedKey == null || lastEvaluatedKey.isEmpty()) { - return null; - } - - StringBuilder sb = new StringBuilder(); - for (Map.Entry entry : lastEvaluatedKey.entrySet()) { - if (sb.length() > 0) sb.append("|"); - sb.append(entry.getKey()).append("=").append(entry.getValue().s()); - } - - return Base64.getUrlEncoder().encodeToString(sb.toString().getBytes()); - } - - private Map decodeCursor(String cursor) { - try { - String decoded = new String(Base64.getUrlDecoder().decode(cursor)); - Map result = new HashMap<>(); - - for (String pair : decoded.split("\\|")) { - String[] kv = pair.split("=", 2); - if (kv.length == 2) { - result.put(kv[0], AttributeValue.builder().s(kv[1]).build()); - } - } - - return result.isEmpty() ? null : result; - } catch (Exception e) { - logger.error("Failed to decode cursor: {}", cursor, e); - return null; - } - } - - public static class DailyStudyPage { - private final List dailyStudies; - private final String nextCursor; - - public DailyStudyPage(List dailyStudies, String nextCursor) { - this.dailyStudies = dailyStudies; - this.nextCursor = nextCursor; - } - - public List getDailyStudies() { - return dailyStudies; - } - - public String getNextCursor() { - return nextCursor; - } - - public boolean hasMore() { - return nextCursor != null; - } - } -} diff --git a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/TestResultRepository.java b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/TestResultRepository.java deleted file mode 100644 index 6224f2bc..00000000 --- a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/TestResultRepository.java +++ /dev/null @@ -1,137 +0,0 @@ -package com.mzc.secondproject.serverless.vocabulary.repository; - -import com.mzc.secondproject.serverless.vocabulary.model.TestResult; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; -import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; -import software.amazon.awssdk.enhanced.dynamodb.Key; -import software.amazon.awssdk.enhanced.dynamodb.TableSchema; -import software.amazon.awssdk.enhanced.dynamodb.model.Page; -import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; -import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; -import software.amazon.awssdk.services.dynamodb.DynamoDbClient; -import software.amazon.awssdk.services.dynamodb.model.AttributeValue; - -import java.util.Base64; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; - -public class TestResultRepository { - - private static final Logger logger = LoggerFactory.getLogger(TestResultRepository.class); - - // Singleton 패턴으로 Cold Start 최적화 - private static final DynamoDbClient dynamoDbClient = DynamoDbClient.builder().build(); - private static final DynamoDbEnhancedClient enhancedClient = DynamoDbEnhancedClient.builder() - .dynamoDbClient(dynamoDbClient) - .build(); - private static final String tableName = System.getenv("VOCAB_TABLE_NAME"); - - private final DynamoDbTable table; - - public TestResultRepository() { - this.table = enhancedClient.table(tableName, TableSchema.fromBean(TestResult.class)); - } - - public TestResult save(TestResult testResult) { - logger.info("Saving test result: userId={}, testId={}", testResult.getUserId(), testResult.getTestId()); - table.putItem(testResult); - return testResult; - } - - public Optional findByUserIdAndTestId(String userId, String timestamp) { - Key key = Key.builder() - .partitionValue("TEST#" + userId) - .sortValue("RESULT#" + timestamp) - .build(); - - TestResult testResult = table.getItem(key); - return Optional.ofNullable(testResult); - } - - /** - * 사용자의 시험 결과 조회 - 최신순, 페이지네이션 - */ - public TestResultPage findByUserIdWithPagination(String userId, int limit, String cursor) { - QueryConditional queryConditional = QueryConditional - .sortBeginsWith(Key.builder() - .partitionValue("TEST#" + userId) - .sortValue("RESULT#") - .build()); - - QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() - .queryConditional(queryConditional) - .scanIndexForward(false) // 최신순 - .limit(limit); - - if (cursor != null && !cursor.isEmpty()) { - Map exclusiveStartKey = decodeCursor(cursor); - if (exclusiveStartKey != null) { - requestBuilder.exclusiveStartKey(exclusiveStartKey); - } - } - - Page page = table.query(requestBuilder.build()).iterator().next(); - String nextCursor = encodeCursor(page.lastEvaluatedKey()); - - return new TestResultPage(page.items(), nextCursor); - } - - private String encodeCursor(Map lastEvaluatedKey) { - if (lastEvaluatedKey == null || lastEvaluatedKey.isEmpty()) { - return null; - } - - StringBuilder sb = new StringBuilder(); - for (Map.Entry entry : lastEvaluatedKey.entrySet()) { - if (sb.length() > 0) sb.append("|"); - sb.append(entry.getKey()).append("=").append(entry.getValue().s()); - } - - return Base64.getUrlEncoder().encodeToString(sb.toString().getBytes()); - } - - private Map decodeCursor(String cursor) { - try { - String decoded = new String(Base64.getUrlDecoder().decode(cursor)); - Map result = new HashMap<>(); - - for (String pair : decoded.split("\\|")) { - String[] kv = pair.split("=", 2); - if (kv.length == 2) { - result.put(kv[0], AttributeValue.builder().s(kv[1]).build()); - } - } - - return result.isEmpty() ? null : result; - } catch (Exception e) { - logger.error("Failed to decode cursor: {}", cursor, e); - return null; - } - } - - public static class TestResultPage { - private final List testResults; - private final String nextCursor; - - public TestResultPage(List testResults, String nextCursor) { - this.testResults = testResults; - this.nextCursor = nextCursor; - } - - public List getTestResults() { - return testResults; - } - - public String getNextCursor() { - return nextCursor; - } - - public boolean hasMore() { - return nextCursor != null; - } - } -} diff --git a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/UserWordRepository.java b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/UserWordRepository.java deleted file mode 100644 index 72ed881a..00000000 --- a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/UserWordRepository.java +++ /dev/null @@ -1,260 +0,0 @@ -package com.mzc.secondproject.serverless.vocabulary.repository; - -import com.mzc.secondproject.serverless.vocabulary.model.UserWord; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; -import software.amazon.awssdk.enhanced.dynamodb.DynamoDbIndex; -import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; -import software.amazon.awssdk.enhanced.dynamodb.Key; -import software.amazon.awssdk.enhanced.dynamodb.TableSchema; -import software.amazon.awssdk.enhanced.dynamodb.model.Page; -import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; -import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; -import software.amazon.awssdk.services.dynamodb.DynamoDbClient; -import software.amazon.awssdk.enhanced.dynamodb.Expression; -import software.amazon.awssdk.services.dynamodb.model.AttributeValue; - -import java.util.Base64; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; - -public class UserWordRepository { - - private static final Logger logger = LoggerFactory.getLogger(UserWordRepository.class); - - // Singleton 패턴으로 Cold Start 최적화 - private static final DynamoDbClient dynamoDbClient = DynamoDbClient.builder().build(); - private static final DynamoDbEnhancedClient enhancedClient = DynamoDbEnhancedClient.builder() - .dynamoDbClient(dynamoDbClient) - .build(); - private static final String tableName = System.getenv("VOCAB_TABLE_NAME"); - - private final DynamoDbTable table; - - public UserWordRepository() { - this.table = enhancedClient.table(tableName, TableSchema.fromBean(UserWord.class)); - } - - public UserWord save(UserWord userWord) { - logger.info("Saving user word: userId={}, wordId={}", userWord.getUserId(), userWord.getWordId()); - table.putItem(userWord); - return userWord; - } - - public Optional findByUserIdAndWordId(String userId, String wordId) { - Key key = Key.builder() - .partitionValue("USER#" + userId) - .sortValue("WORD#" + wordId) - .build(); - - UserWord userWord = table.getItem(key); - return Optional.ofNullable(userWord); - } - - /** - * 사용자의 모든 단어 학습 상태 조회 - 페이지네이션 - */ - public UserWordPage findByUserIdWithPagination(String userId, int limit, String cursor) { - QueryConditional queryConditional = QueryConditional - .sortBeginsWith(Key.builder() - .partitionValue("USER#" + userId) - .sortValue("WORD#") - .build()); - - QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() - .queryConditional(queryConditional) - .limit(limit); - - if (cursor != null && !cursor.isEmpty()) { - Map exclusiveStartKey = decodeCursor(cursor); - if (exclusiveStartKey != null) { - requestBuilder.exclusiveStartKey(exclusiveStartKey); - } - } - - Page page = table.query(requestBuilder.build()).iterator().next(); - String nextCursor = encodeCursor(page.lastEvaluatedKey()); - - return new UserWordPage(page.items(), nextCursor); - } - - /** - * 복습 예정 단어 조회 (오늘 이전 날짜) - 페이지네이션 - */ - public UserWordPage findReviewDueWords(String userId, String todayDate, int limit, String cursor) { - QueryConditional queryConditional = QueryConditional - .sortLessThanOrEqualTo(Key.builder() - .partitionValue("USER#" + userId + "#REVIEW") - .sortValue("DATE#" + todayDate) - .build()); - - QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() - .queryConditional(queryConditional) - .limit(limit); - - if (cursor != null && !cursor.isEmpty()) { - Map exclusiveStartKey = decodeCursor(cursor); - if (exclusiveStartKey != null) { - requestBuilder.exclusiveStartKey(exclusiveStartKey); - } - } - - DynamoDbIndex gsi1 = table.index("GSI1"); - Page page = gsi1.query(requestBuilder.build()).iterator().next(); - String nextCursor = encodeCursor(page.lastEvaluatedKey()); - - return new UserWordPage(page.items(), nextCursor); - } - - /** - * 북마크된 단어만 조회 - FilterExpression 사용 (GSI 추가 없이 비용 최적화) - */ - public UserWordPage findBookmarkedWords(String userId, int limit, String cursor) { - QueryConditional queryConditional = QueryConditional - .sortBeginsWith(Key.builder() - .partitionValue("USER#" + userId) - .sortValue("WORD#") - .build()); - - Expression filterExpression = Expression.builder() - .expression("bookmarked = :bookmarked") - .putExpressionValue(":bookmarked", AttributeValue.builder().bool(true).build()) - .build(); - - QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() - .queryConditional(queryConditional) - .filterExpression(filterExpression) - .limit(limit); - - if (cursor != null && !cursor.isEmpty()) { - Map exclusiveStartKey = decodeCursor(cursor); - if (exclusiveStartKey != null) { - requestBuilder.exclusiveStartKey(exclusiveStartKey); - } - } - - Page page = table.query(requestBuilder.build()).iterator().next(); - String nextCursor = encodeCursor(page.lastEvaluatedKey()); - - return new UserWordPage(page.items(), nextCursor); - } - - /** - * 틀린 적 있는 단어만 조회 - FilterExpression 사용 (GSI 추가 없이 비용 최적화) - */ - public UserWordPage findIncorrectWords(String userId, int limit, String cursor) { - QueryConditional queryConditional = QueryConditional - .sortBeginsWith(Key.builder() - .partitionValue("USER#" + userId) - .sortValue("WORD#") - .build()); - - Expression filterExpression = Expression.builder() - .expression("incorrectCount > :zero") - .putExpressionValue(":zero", AttributeValue.builder().n("0").build()) - .build(); - - QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() - .queryConditional(queryConditional) - .filterExpression(filterExpression) - .limit(limit); - - if (cursor != null && !cursor.isEmpty()) { - Map exclusiveStartKey = decodeCursor(cursor); - if (exclusiveStartKey != null) { - requestBuilder.exclusiveStartKey(exclusiveStartKey); - } - } - - Page page = table.query(requestBuilder.build()).iterator().next(); - String nextCursor = encodeCursor(page.lastEvaluatedKey()); - - return new UserWordPage(page.items(), nextCursor); - } - - /** - * 상태별 단어 조회 - 페이지네이션 - */ - public UserWordPage findByUserIdAndStatus(String userId, String status, int limit, String cursor) { - QueryConditional queryConditional = QueryConditional - .keyEqualTo(Key.builder() - .partitionValue("USER#" + userId + "#STATUS") - .sortValue("STATUS#" + status) - .build()); - - QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() - .queryConditional(queryConditional) - .limit(limit); - - if (cursor != null && !cursor.isEmpty()) { - Map exclusiveStartKey = decodeCursor(cursor); - if (exclusiveStartKey != null) { - requestBuilder.exclusiveStartKey(exclusiveStartKey); - } - } - - DynamoDbIndex gsi2 = table.index("GSI2"); - Page page = gsi2.query(requestBuilder.build()).iterator().next(); - String nextCursor = encodeCursor(page.lastEvaluatedKey()); - - return new UserWordPage(page.items(), nextCursor); - } - - private String encodeCursor(Map lastEvaluatedKey) { - if (lastEvaluatedKey == null || lastEvaluatedKey.isEmpty()) { - return null; - } - - StringBuilder sb = new StringBuilder(); - for (Map.Entry entry : lastEvaluatedKey.entrySet()) { - if (sb.length() > 0) sb.append("|"); - sb.append(entry.getKey()).append("=").append(entry.getValue().s()); - } - - return Base64.getUrlEncoder().encodeToString(sb.toString().getBytes()); - } - - private Map decodeCursor(String cursor) { - try { - String decoded = new String(Base64.getUrlDecoder().decode(cursor)); - Map result = new HashMap<>(); - - for (String pair : decoded.split("\\|")) { - String[] kv = pair.split("=", 2); - if (kv.length == 2) { - result.put(kv[0], AttributeValue.builder().s(kv[1]).build()); - } - } - - return result.isEmpty() ? null : result; - } catch (Exception e) { - logger.error("Failed to decode cursor: {}", cursor, e); - return null; - } - } - - public static class UserWordPage { - private final List userWords; - private final String nextCursor; - - public UserWordPage(List userWords, String nextCursor) { - this.userWords = userWords; - this.nextCursor = nextCursor; - } - - public List getUserWords() { - return userWords; - } - - public String getNextCursor() { - return nextCursor; - } - - public boolean hasMore() { - return nextCursor != null; - } - } -} diff --git a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/WordRepository.java b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/WordRepository.java deleted file mode 100644 index 715e40bf..00000000 --- a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/WordRepository.java +++ /dev/null @@ -1,264 +0,0 @@ -package com.mzc.secondproject.serverless.vocabulary.repository; - -import com.mzc.secondproject.serverless.vocabulary.model.Word; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; -import software.amazon.awssdk.enhanced.dynamodb.DynamoDbIndex; -import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; -import software.amazon.awssdk.enhanced.dynamodb.Key; -import software.amazon.awssdk.enhanced.dynamodb.TableSchema; -import software.amazon.awssdk.enhanced.dynamodb.model.BatchGetResultPageIterable; -import software.amazon.awssdk.enhanced.dynamodb.model.Page; -import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; -import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; -import software.amazon.awssdk.enhanced.dynamodb.model.ReadBatch; -import software.amazon.awssdk.enhanced.dynamodb.model.ScanEnhancedRequest; -import software.amazon.awssdk.enhanced.dynamodb.Expression; -import software.amazon.awssdk.services.dynamodb.DynamoDbClient; -import software.amazon.awssdk.services.dynamodb.model.AttributeValue; - -import java.util.ArrayList; - -import java.util.Base64; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; - -public class WordRepository { - - private static final Logger logger = LoggerFactory.getLogger(WordRepository.class); - - // Singleton 패턴으로 Cold Start 최적화 - private static final DynamoDbClient dynamoDbClient = DynamoDbClient.builder().build(); - private static final DynamoDbEnhancedClient enhancedClient = DynamoDbEnhancedClient.builder() - .dynamoDbClient(dynamoDbClient) - .build(); - private static final String tableName = System.getenv("VOCAB_TABLE_NAME"); - - private final DynamoDbTable table; - - public WordRepository() { - this.table = enhancedClient.table(tableName, TableSchema.fromBean(Word.class)); - } - - public Word save(Word word) { - logger.info("Saving word to DynamoDB: {}", word.getWordId()); - table.putItem(word); - return word; - } - - public Optional findById(String wordId) { - Key key = Key.builder() - .partitionValue("WORD#" + wordId) - .sortValue("METADATA") - .build(); - - Word word = table.getItem(key); - return Optional.ofNullable(word); - } - - /** - * 여러 단어를 한 번에 조회 (BatchGetItem) - N+1 문제 해결 - * DynamoDB BatchGetItem은 최대 100개까지 지원 - */ - public List findByIds(List wordIds) { - if (wordIds == null || wordIds.isEmpty()) { - return new ArrayList<>(); - } - - List results = new ArrayList<>(); - - // BatchGetItem은 최대 100개까지 지원하므로 분할 처리 - int batchSize = 100; - for (int i = 0; i < wordIds.size(); i += batchSize) { - List batch = wordIds.subList(i, Math.min(i + batchSize, wordIds.size())); - results.addAll(batchGetWords(batch)); - } - - return results; - } - - private List batchGetWords(List wordIds) { - ReadBatch.Builder readBatchBuilder = ReadBatch.builder(Word.class) - .mappedTableResource(table); - - for (String wordId : wordIds) { - Key key = Key.builder() - .partitionValue("WORD#" + wordId) - .sortValue("METADATA") - .build(); - readBatchBuilder.addGetItem(key); - } - - BatchGetResultPageIterable resultPages = enhancedClient.batchGetItem(r -> r.readBatches(readBatchBuilder.build())); - - List words = new ArrayList<>(); - resultPages.resultsForTable(table).forEach(words::add); - logger.info("BatchGetItem: requested={}, retrieved={}", wordIds.size(), words.size()); - - return words; - } - - public void delete(String wordId) { - Key key = Key.builder() - .partitionValue("WORD#" + wordId) - .sortValue("METADATA") - .build(); - - table.deleteItem(key); - logger.info("Deleted word: {}", wordId); - } - - /** - * 난이도별 단어 조회 - 페이지네이션 - */ - public WordPage findByLevelWithPagination(String level, int limit, String cursor) { - QueryConditional queryConditional = QueryConditional - .keyEqualTo(Key.builder().partitionValue("LEVEL#" + level).build()); - - QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() - .queryConditional(queryConditional) - .limit(limit); - - if (cursor != null && !cursor.isEmpty()) { - Map exclusiveStartKey = decodeCursor(cursor); - if (exclusiveStartKey != null) { - requestBuilder.exclusiveStartKey(exclusiveStartKey); - } - } - - DynamoDbIndex gsi1 = table.index("GSI1"); - Page page = gsi1.query(requestBuilder.build()).iterator().next(); - String nextCursor = encodeCursor(page.lastEvaluatedKey()); - - return new WordPage(page.items(), nextCursor); - } - - /** - * 카테고리별 단어 조회 - 페이지네이션 - */ - public WordPage findByCategoryWithPagination(String category, int limit, String cursor) { - QueryConditional queryConditional = QueryConditional - .keyEqualTo(Key.builder().partitionValue("CATEGORY#" + category).build()); - - QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() - .queryConditional(queryConditional) - .limit(limit); - - if (cursor != null && !cursor.isEmpty()) { - Map exclusiveStartKey = decodeCursor(cursor); - if (exclusiveStartKey != null) { - requestBuilder.exclusiveStartKey(exclusiveStartKey); - } - } - - DynamoDbIndex gsi2 = table.index("GSI2"); - Page page = gsi2.query(requestBuilder.build()).iterator().next(); - String nextCursor = encodeCursor(page.lastEvaluatedKey()); - - return new WordPage(page.items(), nextCursor); - } - - private String encodeCursor(Map lastEvaluatedKey) { - if (lastEvaluatedKey == null || lastEvaluatedKey.isEmpty()) { - return null; - } - - StringBuilder sb = new StringBuilder(); - for (Map.Entry entry : lastEvaluatedKey.entrySet()) { - if (sb.length() > 0) sb.append("|"); - sb.append(entry.getKey()).append("=").append(entry.getValue().s()); - } - - return Base64.getUrlEncoder().encodeToString(sb.toString().getBytes()); - } - - private Map decodeCursor(String cursor) { - try { - String decoded = new String(Base64.getUrlDecoder().decode(cursor)); - Map result = new HashMap<>(); - - for (String pair : decoded.split("\\|")) { - String[] kv = pair.split("=", 2); - if (kv.length == 2) { - result.put(kv[0], AttributeValue.builder().s(kv[1]).build()); - } - } - - return result.isEmpty() ? null : result; - } catch (Exception e) { - logger.error("Failed to decode cursor: {}", cursor, e); - return null; - } - } - - /** - * 키워드로 단어 검색 (영어/한국어 contains) - * 참고: Scan은 비용이 높으므로 데이터가 많아지면 OpenSearch 도입 권장 - */ - public WordPage searchByKeyword(String keyword, int limit, String cursor) { - String lowerKeyword = keyword.toLowerCase(); - - // Filter: PK가 WORD#로 시작하고, english 또는 korean에 keyword 포함 - Expression filterExpression = Expression.builder() - .expression("begins_with(PK, :pk) AND (contains(#eng, :keyword) OR contains(korean, :keyword))") - .putExpressionName("#eng", "english") - .putExpressionValue(":pk", AttributeValue.builder().s("WORD#").build()) - .putExpressionValue(":keyword", AttributeValue.builder().s(lowerKeyword).build()) - .build(); - - ScanEnhancedRequest.Builder requestBuilder = ScanEnhancedRequest.builder() - .filterExpression(filterExpression) - .limit(limit * 3); // filter 적용되므로 넉넉히 - - if (cursor != null && !cursor.isEmpty()) { - Map exclusiveStartKey = decodeCursor(cursor); - if (exclusiveStartKey != null) { - requestBuilder.exclusiveStartKey(exclusiveStartKey); - } - } - - List results = new ArrayList<>(); - Map lastKey = null; - - for (Page page : table.scan(requestBuilder.build())) { - for (Word word : page.items()) { - // 대소문자 무시 검색 - if (word.getEnglish().toLowerCase().contains(lowerKeyword) || - word.getKorean().contains(keyword)) { - results.add(word); - if (results.size() >= limit) break; - } - } - lastKey = page.lastEvaluatedKey(); - if (results.size() >= limit) break; - } - - String nextCursor = results.size() >= limit ? encodeCursor(lastKey) : null; - return new WordPage(results, nextCursor); - } - - public static class WordPage { - private final List words; - private final String nextCursor; - - public WordPage(List words, String nextCursor) { - this.words = words; - this.nextCursor = nextCursor; - } - - public List getWords() { - return words; - } - - public String getNextCursor() { - return nextCursor; - } - - public boolean hasMore() { - return nextCursor != null; - } - } -} diff --git a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/service/PollyService.java b/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/service/PollyService.java deleted file mode 100644 index 5e7cb5c1..00000000 --- a/vocabulary/VocabFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/service/PollyService.java +++ /dev/null @@ -1,160 +0,0 @@ -package com.mzc.secondproject.serverless.vocabulary.service; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import software.amazon.awssdk.core.sync.RequestBody; -import software.amazon.awssdk.services.polly.PollyClient; -import software.amazon.awssdk.services.polly.model.OutputFormat; -import software.amazon.awssdk.services.polly.model.SynthesizeSpeechRequest; -import software.amazon.awssdk.services.polly.model.VoiceId; -import software.amazon.awssdk.services.s3.S3Client; -import software.amazon.awssdk.services.s3.model.PutObjectRequest; -import software.amazon.awssdk.services.s3.presigner.S3Presigner; -import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; -import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest; -import software.amazon.awssdk.services.s3.model.GetObjectRequest; -import software.amazon.awssdk.services.s3.model.HeadObjectRequest; -import software.amazon.awssdk.services.s3.model.NoSuchKeyException; - -import java.io.ByteArrayOutputStream; -import java.io.InputStream; -import java.time.Duration; - -public class PollyService { - - private static final Logger logger = LoggerFactory.getLogger(PollyService.class); - - // Singleton 패턴으로 Cold Start 최적화 - private static final PollyClient pollyClient = PollyClient.builder().build(); - private static final S3Client s3Client = S3Client.builder().build(); - private static final S3Presigner s3Presigner = S3Presigner.builder().build(); - private static final String bucketName = System.getenv("VOCAB_BUCKET_NAME"); - - /** - * 단어 ID 기반으로 음성 합성 (캐시 지원) - * S3에 파일이 있으면 바로 URL 반환, 없으면 Polly 변환 후 저장 - */ - public VoiceSynthesisResult synthesizeSpeechForWord(String wordId, String text, String voice) { - String s3Key = generateS3Key(wordId, voice); - - // 캐시 확인: S3에 이미 존재하는지 체크 - if (existsInS3(s3Key)) { - logger.info("Cache hit: {}", s3Key); - String presignedUrl = getPresignedUrl(s3Key); - return new VoiceSynthesisResult(s3Key, presignedUrl, true); - } - - // 캐시 미스: Polly 변환 후 S3 저장 - logger.info("Cache miss: synthesizing and saving to {}", s3Key); - synthesizeAndSave(text, voice, s3Key); - String presignedUrl = getPresignedUrl(s3Key); - return new VoiceSynthesisResult(s3Key, presignedUrl, false); - } - - /** - * S3 키로 Pre-signed URL 생성 - */ - public String getPresignedUrl(String s3Key) { - GetObjectRequest getObjectRequest = GetObjectRequest.builder() - .bucket(bucketName) - .key(s3Key) - .build(); - - GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() - .signatureDuration(Duration.ofHours(1)) - .getObjectRequest(getObjectRequest) - .build(); - - PresignedGetObjectRequest presignedRequest = s3Presigner.presignGetObject(presignRequest); - return presignedRequest.url().toString(); - } - - /** - * S3에 파일 존재 여부 확인 - */ - public boolean existsInS3(String s3Key) { - try { - s3Client.headObject(HeadObjectRequest.builder() - .bucket(bucketName) - .key(s3Key) - .build()); - return true; - } catch (NoSuchKeyException e) { - return false; - } - } - - /** - * Polly로 음성 변환 후 지정된 S3 키로 저장 - */ - private void synthesizeAndSave(String text, String voice, String s3Key) { - VoiceId voiceId = resolveVoiceId(voice); - - try { - SynthesizeSpeechRequest request = SynthesizeSpeechRequest.builder() - .text(text) - .voiceId(voiceId) - .engine("neural") - .outputFormat(OutputFormat.MP3) - .build(); - - InputStream audioStream = pollyClient.synthesizeSpeech(request); - - ByteArrayOutputStream buffer = new ByteArrayOutputStream(); - byte[] data = new byte[4096]; - int bytesRead; - while ((bytesRead = audioStream.read(data, 0, data.length)) != -1) { - buffer.write(data, 0, bytesRead); - } - byte[] audioBytes = buffer.toByteArray(); - - s3Client.putObject( - PutObjectRequest.builder() - .bucket(bucketName) - .key(s3Key) - .contentType("audio/mpeg") - .build(), - RequestBody.fromBytes(audioBytes) - ); - - logger.info("Saved audio to S3: {}", s3Key); - } catch (Exception e) { - logger.error("Error synthesizing speech", e); - throw new RuntimeException("Failed to synthesize speech", e); - } - } - - /** - * 단어 ID와 음성 타입으로 S3 키 생성 - */ - public String generateS3Key(String wordId, String voice) { - String voiceSuffix = "MALE".equalsIgnoreCase(voice) ? "male" : "female"; - return "vocab/voice/" + wordId + "_" + voiceSuffix + ".mp3"; - } - - /** - * 음성 합성 결과 - */ - public static class VoiceSynthesisResult { - private final String s3Key; - private final String audioUrl; - private final boolean cached; - - public VoiceSynthesisResult(String s3Key, String audioUrl, boolean cached) { - this.s3Key = s3Key; - this.audioUrl = audioUrl; - this.cached = cached; - } - - public String getS3Key() { return s3Key; } - public String getAudioUrl() { return audioUrl; } - public boolean isCached() { return cached; } - } - - private VoiceId resolveVoiceId(String voice) { - if ("MALE".equalsIgnoreCase(voice)) { - return VoiceId.MATTHEW; // 미국 영어 남성 (Neural 지원) - } - return VoiceId.JOANNA; // 미국 영어 여성 (Neural 지원, 기본값) - } -} diff --git a/vocabulary/VocabFunction/src/main/resources/log4j2.xml b/vocabulary/VocabFunction/src/main/resources/log4j2.xml deleted file mode 100644 index 69a29404..00000000 --- a/vocabulary/VocabFunction/src/main/resources/log4j2.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - %d{yyyy-MM-dd HH:mm:ss} %X{AWSRequestId} %-5p %c{1} - %m%n - - - - - - - - - - - diff --git a/vocabulary/seed-data/words.json b/vocabulary/seed-data/words.json deleted file mode 100644 index fe07ba2a..00000000 --- a/vocabulary/seed-data/words.json +++ /dev/null @@ -1,73 +0,0 @@ -{ - "words": [ - {"english": "apple", "korean": "사과", "example": "I eat an apple every day.", "level": "BEGINNER", "category": "DAILY"}, - {"english": "book", "korean": "책", "example": "She reads a book before bed.", "level": "BEGINNER", "category": "DAILY"}, - {"english": "cat", "korean": "고양이", "example": "The cat is sleeping on the sofa.", "level": "BEGINNER", "category": "DAILY"}, - {"english": "dog", "korean": "개", "example": "My dog loves to play fetch.", "level": "BEGINNER", "category": "DAILY"}, - {"english": "eat", "korean": "먹다", "example": "We eat dinner at 7 PM.", "level": "BEGINNER", "category": "DAILY"}, - {"english": "family", "korean": "가족", "example": "My family lives in Seoul.", "level": "BEGINNER", "category": "DAILY"}, - {"english": "good", "korean": "좋은", "example": "This is a good idea.", "level": "BEGINNER", "category": "DAILY"}, - {"english": "happy", "korean": "행복한", "example": "She looks very happy today.", "level": "BEGINNER", "category": "DAILY"}, - {"english": "house", "korean": "집", "example": "They bought a new house.", "level": "BEGINNER", "category": "DAILY"}, - {"english": "important", "korean": "중요한", "example": "This meeting is very important.", "level": "BEGINNER", "category": "DAILY"}, - {"english": "job", "korean": "직업", "example": "He got a new job last month.", "level": "BEGINNER", "category": "DAILY"}, - {"english": "kind", "korean": "친절한", "example": "She is always kind to everyone.", "level": "BEGINNER", "category": "DAILY"}, - {"english": "learn", "korean": "배우다", "example": "I want to learn English.", "level": "BEGINNER", "category": "DAILY"}, - {"english": "money", "korean": "돈", "example": "He saved a lot of money.", "level": "BEGINNER", "category": "DAILY"}, - {"english": "name", "korean": "이름", "example": "What is your name?", "level": "BEGINNER", "category": "DAILY"}, - {"english": "open", "korean": "열다", "example": "Please open the window.", "level": "BEGINNER", "category": "DAILY"}, - {"english": "people", "korean": "사람들", "example": "Many people came to the party.", "level": "BEGINNER", "category": "DAILY"}, - {"english": "question", "korean": "질문", "example": "Do you have any questions?", "level": "BEGINNER", "category": "DAILY"}, - {"english": "run", "korean": "달리다", "example": "He runs every morning.", "level": "BEGINNER", "category": "DAILY"}, - {"english": "school", "korean": "학교", "example": "The school starts at 9 AM.", "level": "BEGINNER", "category": "DAILY"}, - {"english": "time", "korean": "시간", "example": "What time is it now?", "level": "BEGINNER", "category": "DAILY"}, - {"english": "understand", "korean": "이해하다", "example": "I understand your concern.", "level": "BEGINNER", "category": "DAILY"}, - {"english": "very", "korean": "매우", "example": "This coffee is very hot.", "level": "BEGINNER", "category": "DAILY"}, - {"english": "water", "korean": "물", "example": "Please give me some water.", "level": "BEGINNER", "category": "DAILY"}, - {"english": "year", "korean": "년", "example": "I lived here for three years.", "level": "BEGINNER", "category": "DAILY"}, - {"english": "achieve", "korean": "달성하다", "example": "She achieved her goal.", "level": "INTERMEDIATE", "category": "BUSINESS"}, - {"english": "benefit", "korean": "이익, 혜택", "example": "What are the benefits of this plan?", "level": "INTERMEDIATE", "category": "BUSINESS"}, - {"english": "challenge", "korean": "도전", "example": "This project is a big challenge.", "level": "INTERMEDIATE", "category": "BUSINESS"}, - {"english": "decision", "korean": "결정", "example": "We need to make a decision today.", "level": "INTERMEDIATE", "category": "BUSINESS"}, - {"english": "efficient", "korean": "효율적인", "example": "This method is more efficient.", "level": "INTERMEDIATE", "category": "BUSINESS"}, - {"english": "flexible", "korean": "유연한", "example": "We need a flexible schedule.", "level": "INTERMEDIATE", "category": "BUSINESS"}, - {"english": "growth", "korean": "성장", "example": "The company showed rapid growth.", "level": "INTERMEDIATE", "category": "BUSINESS"}, - {"english": "implement", "korean": "실행하다", "example": "We will implement the new policy.", "level": "INTERMEDIATE", "category": "BUSINESS"}, - {"english": "invest", "korean": "투자하다", "example": "They decided to invest in technology.", "level": "INTERMEDIATE", "category": "BUSINESS"}, - {"english": "leadership", "korean": "리더십", "example": "Good leadership is essential.", "level": "INTERMEDIATE", "category": "BUSINESS"}, - {"english": "maintain", "korean": "유지하다", "example": "We need to maintain quality.", "level": "INTERMEDIATE", "category": "BUSINESS"}, - {"english": "negotiate", "korean": "협상하다", "example": "Let's negotiate the terms.", "level": "INTERMEDIATE", "category": "BUSINESS"}, - {"english": "opportunity", "korean": "기회", "example": "This is a great opportunity.", "level": "INTERMEDIATE", "category": "BUSINESS"}, - {"english": "performance", "korean": "성과", "example": "His performance was excellent.", "level": "INTERMEDIATE", "category": "BUSINESS"}, - {"english": "quality", "korean": "품질", "example": "Quality is our top priority.", "level": "INTERMEDIATE", "category": "BUSINESS"}, - {"english": "revenue", "korean": "수익", "example": "Revenue increased by 20%.", "level": "INTERMEDIATE", "category": "BUSINESS"}, - {"english": "strategy", "korean": "전략", "example": "We need a new marketing strategy.", "level": "INTERMEDIATE", "category": "BUSINESS"}, - {"english": "target", "korean": "목표", "example": "We reached our sales target.", "level": "INTERMEDIATE", "category": "BUSINESS"}, - {"english": "utilize", "korean": "활용하다", "example": "We should utilize all resources.", "level": "INTERMEDIATE", "category": "BUSINESS"}, - {"english": "valuable", "korean": "가치 있는", "example": "Your feedback is valuable.", "level": "INTERMEDIATE", "category": "BUSINESS"}, - {"english": "workflow", "korean": "워크플로우", "example": "Improve your workflow efficiency.", "level": "INTERMEDIATE", "category": "BUSINESS"}, - {"english": "yield", "korean": "산출하다", "example": "This investment will yield profit.", "level": "INTERMEDIATE", "category": "BUSINESS"}, - {"english": "zone", "korean": "구역", "example": "This is a no-parking zone.", "level": "INTERMEDIATE", "category": "BUSINESS"}, - {"english": "adjacent", "korean": "인접한", "example": "The two buildings are adjacent.", "level": "INTERMEDIATE", "category": "BUSINESS"}, - {"english": "abstract", "korean": "추상적인", "example": "The concept is too abstract.", "level": "ADVANCED", "category": "ACADEMIC"}, - {"english": "comprehensive", "korean": "포괄적인", "example": "We need a comprehensive analysis.", "level": "ADVANCED", "category": "ACADEMIC"}, - {"english": "contemporary", "korean": "현대의", "example": "Contemporary art is fascinating.", "level": "ADVANCED", "category": "ACADEMIC"}, - {"english": "differentiate", "korean": "구별하다", "example": "Can you differentiate between them?", "level": "ADVANCED", "category": "ACADEMIC"}, - {"english": "empirical", "korean": "경험적인", "example": "We need empirical evidence.", "level": "ADVANCED", "category": "ACADEMIC"}, - {"english": "fundamental", "korean": "근본적인", "example": "This is a fundamental problem.", "level": "ADVANCED", "category": "ACADEMIC"}, - {"english": "hypothesis", "korean": "가설", "example": "The hypothesis was proven correct.", "level": "ADVANCED", "category": "ACADEMIC"}, - {"english": "indigenous", "korean": "토착의", "example": "Indigenous plants are protected.", "level": "ADVANCED", "category": "ACADEMIC"}, - {"english": "jurisdiction", "korean": "관할권", "example": "This is outside our jurisdiction.", "level": "ADVANCED", "category": "ACADEMIC"}, - {"english": "methodology", "korean": "방법론", "example": "What methodology did you use?", "level": "ADVANCED", "category": "ACADEMIC"}, - {"english": "phenomenon", "korean": "현상", "example": "This is a natural phenomenon.", "level": "ADVANCED", "category": "ACADEMIC"}, - {"english": "paradigm", "korean": "패러다임", "example": "We need a paradigm shift.", "level": "ADVANCED", "category": "ACADEMIC"}, - {"english": "qualitative", "korean": "질적인", "example": "This is a qualitative study.", "level": "ADVANCED", "category": "ACADEMIC"}, - {"english": "quantitative", "korean": "양적인", "example": "We need quantitative data.", "level": "ADVANCED", "category": "ACADEMIC"}, - {"english": "synthesis", "korean": "합성, 종합", "example": "This requires a synthesis of ideas.", "level": "ADVANCED", "category": "ACADEMIC"}, - {"english": "theoretical", "korean": "이론적인", "example": "This is a theoretical framework.", "level": "ADVANCED", "category": "ACADEMIC"}, - {"english": "unprecedented", "korean": "전례 없는", "example": "This is an unprecedented situation.", "level": "ADVANCED", "category": "ACADEMIC"}, - {"english": "validity", "korean": "타당성", "example": "We must check the validity.", "level": "ADVANCED", "category": "ACADEMIC"}, - {"english": "variable", "korean": "변수", "example": "Control all variables carefully.", "level": "ADVANCED", "category": "ACADEMIC"}, - {"english": "ambiguous", "korean": "애매한", "example": "The statement is ambiguous.", "level": "ADVANCED", "category": "ACADEMIC"} - ] -} diff --git a/vocabulary/template.yaml b/vocabulary/template.yaml deleted file mode 100644 index dc5aa9af..00000000 --- a/vocabulary/template.yaml +++ /dev/null @@ -1,329 +0,0 @@ -AWSTemplateFormatVersion: '2010-09-09' -Transform: AWS::Serverless-2016-10-31 -Description: Group2 English Study - Vocabulary Domain - -Globals: - Function: - Timeout: 30 - MemorySize: 512 - Runtime: java21 - Architectures: - - x86_64 - Environment: - Variables: - VOCAB_TABLE_NAME: !Ref VocabTable - VOCAB_BUCKET_NAME: group2-englishstudy - AWS_REGION_NAME: !Ref AWS::Region - -Resources: - ############################################# - # Lambda Functions - ############################################# - - # 단어 관리 함수 - WordFunction: - Type: AWS::Serverless::Function - Properties: - FunctionName: group2-englishstudy-vocab-word-handler - CodeUri: VocabFunction - Handler: com.mzc.secondproject.serverless.vocabulary.handler.WordHandler::handleRequest - Description: Handle word CRUD operations - SnapStart: - ApplyOn: PublishedVersions - Policies: - - DynamoDBCrudPolicy: - TableName: !Ref VocabTable - Events: - CreateWord: - Type: Api - Properties: - RestApiId: !ImportValue group2-englishstudy-api-id - Path: /vocab/words - Method: POST - BatchCreateWords: - Type: Api - Properties: - RestApiId: !ImportValue group2-englishstudy-api-id - Path: /vocab/words/batch - Method: POST - SearchWords: - Type: Api - Properties: - RestApiId: !ImportValue group2-englishstudy-api-id - Path: /vocab/words/search - Method: GET - GetWords: - Type: Api - Properties: - RestApiId: !ImportValue group2-englishstudy-api-id - Path: /vocab/words - Method: GET - GetWord: - Type: Api - Properties: - RestApiId: !ImportValue group2-englishstudy-api-id - Path: /vocab/words/{wordId} - Method: GET - UpdateWord: - Type: Api - Properties: - RestApiId: !ImportValue group2-englishstudy-api-id - Path: /vocab/words/{wordId} - Method: PUT - DeleteWord: - Type: Api - Properties: - RestApiId: !ImportValue group2-englishstudy-api-id - Path: /vocab/words/{wordId} - Method: DELETE - - # 사용자 단어 학습 상태 관리 함수 - UserWordFunction: - Type: AWS::Serverless::Function - Properties: - FunctionName: group2-englishstudy-vocab-userword-handler - CodeUri: VocabFunction - Handler: com.mzc.secondproject.serverless.vocabulary.handler.UserWordHandler::handleRequest - Description: Handle user word learning status - SnapStart: - ApplyOn: PublishedVersions - Policies: - - DynamoDBCrudPolicy: - TableName: !Ref VocabTable - Events: - GetUserWords: - Type: Api - Properties: - RestApiId: !ImportValue group2-englishstudy-api-id - Path: /vocab/users/{userId}/words - Method: GET - GetUserWord: - Type: Api - Properties: - RestApiId: !ImportValue group2-englishstudy-api-id - Path: /vocab/users/{userId}/words/{wordId} - Method: GET - UpdateUserWord: - Type: Api - Properties: - RestApiId: !ImportValue group2-englishstudy-api-id - Path: /vocab/users/{userId}/words/{wordId} - Method: PUT - UpdateUserWordTag: - Type: Api - Properties: - RestApiId: !ImportValue group2-englishstudy-api-id - Path: /vocab/users/{userId}/words/{wordId}/tag - Method: PUT - - # 일일 학습 관리 함수 (55개 단어 할당) - DailyStudyFunction: - Type: AWS::Serverless::Function - Properties: - FunctionName: group2-englishstudy-vocab-daily-handler - CodeUri: VocabFunction - Handler: com.mzc.secondproject.serverless.vocabulary.handler.DailyStudyHandler::handleRequest - Description: Handle daily study word assignment - SnapStart: - ApplyOn: PublishedVersions - Policies: - - DynamoDBCrudPolicy: - TableName: !Ref VocabTable - Events: - GetDailyWords: - Type: Api - Properties: - RestApiId: !ImportValue group2-englishstudy-api-id - Path: /vocab/daily/{userId} - Method: GET - MarkWordLearned: - Type: Api - Properties: - RestApiId: !ImportValue group2-englishstudy-api-id - Path: /vocab/daily/{userId}/words/{wordId}/learned - Method: POST - - # 시험 기능 함수 - TestFunction: - Type: AWS::Serverless::Function - Properties: - FunctionName: group2-englishstudy-vocab-test-handler - CodeUri: VocabFunction - Handler: com.mzc.secondproject.serverless.vocabulary.handler.TestHandler::handleRequest - Description: Handle vocabulary tests - SnapStart: - ApplyOn: PublishedVersions - Policies: - - DynamoDBCrudPolicy: - TableName: !Ref VocabTable - Events: - StartTest: - Type: Api - Properties: - RestApiId: !ImportValue group2-englishstudy-api-id - Path: /vocab/test/{userId}/start - Method: POST - SubmitAnswer: - Type: Api - Properties: - RestApiId: !ImportValue group2-englishstudy-api-id - Path: /vocab/test/{userId}/submit - Method: POST - GetTestResult: - Type: Api - Properties: - RestApiId: !ImportValue group2-englishstudy-api-id - Path: /vocab/test/{userId}/results - Method: GET - - # 통계 함수 - StatsFunction: - Type: AWS::Serverless::Function - Properties: - FunctionName: group2-englishstudy-vocab-stats-handler - CodeUri: VocabFunction - Handler: com.mzc.secondproject.serverless.vocabulary.handler.StatsHandler::handleRequest - Description: Handle user learning statistics - SnapStart: - ApplyOn: PublishedVersions - Policies: - - DynamoDBCrudPolicy: - TableName: !Ref VocabTable - Events: - GetStats: - Type: Api - Properties: - RestApiId: !ImportValue group2-englishstudy-api-id - Path: /vocab/stats/{userId} - Method: GET - GetDailyStats: - Type: Api - Properties: - RestApiId: !ImportValue group2-englishstudy-api-id - Path: /vocab/stats/{userId}/daily - Method: GET - GetWeaknessAnalysis: - Type: Api - Properties: - RestApiId: !ImportValue group2-englishstudy-api-id - Path: /vocab/stats/{userId}/weakness - Method: GET - - # 음성 변환 함수 (Polly) - VoiceFunction: - Type: AWS::Serverless::Function - Properties: - FunctionName: group2-englishstudy-vocab-voice-handler - CodeUri: VocabFunction - Handler: com.mzc.secondproject.serverless.vocabulary.handler.VoiceHandler::handleRequest - Description: Convert word to speech using Polly - SnapStart: - ApplyOn: PublishedVersions - Policies: - - DynamoDBCrudPolicy: - TableName: !Ref VocabTable - - S3CrudPolicy: - BucketName: group2-englishstudy - - Statement: - - Effect: Allow - Action: - - polly:SynthesizeSpeech - - polly:DescribeVoices - Resource: "*" - Events: - TextToSpeech: - Type: Api - Properties: - RestApiId: !ImportValue group2-englishstudy-api-id - Path: /vocab/voice/synthesize - Method: POST - - ############################################# - # API Gateway - chatting 스택에서 공유 API Gateway 참조 - ############################################# - # VocabApi는 chatting 스택의 ChatApi를 ImportValue로 참조 - - ############################################# - # DynamoDB - ############################################# - - VocabTable: - Type: AWS::DynamoDB::Table - Properties: - TableName: group2-englishstudy-vocab - BillingMode: PAY_PER_REQUEST - AttributeDefinitions: - - AttributeName: PK - AttributeType: S - - AttributeName: SK - AttributeType: S - - AttributeName: GSI1PK - AttributeType: S - - AttributeName: GSI1SK - AttributeType: S - - AttributeName: GSI2PK - AttributeType: S - - AttributeName: GSI2SK - AttributeType: S - KeySchema: - - AttributeName: PK - KeyType: HASH - - AttributeName: SK - KeyType: RANGE - GlobalSecondaryIndexes: - - IndexName: GSI1 - KeySchema: - - AttributeName: GSI1PK - KeyType: HASH - - AttributeName: GSI1SK - KeyType: RANGE - Projection: - ProjectionType: ALL - - IndexName: GSI2 - KeySchema: - - AttributeName: GSI2PK - KeyType: HASH - - AttributeName: GSI2SK - KeyType: RANGE - Projection: - ProjectionType: ALL - TimeToLiveSpecification: - AttributeName: ttl - Enabled: true - -############################################# -# Outputs -############################################# - -Outputs: - VocabApiUrl: - Description: API Gateway endpoint URL (Shared with chatting) - Value: !ImportValue group2-englishstudy-api-url - - VocabTableName: - Description: DynamoDB Table Name - Value: !Ref VocabTable - - WordFunctionArn: - Description: Word Lambda Function ARN - Value: !GetAtt WordFunction.Arn - - UserWordFunctionArn: - Description: UserWord Lambda Function ARN - Value: !GetAtt UserWordFunction.Arn - - DailyStudyFunctionArn: - Description: DailyStudy Lambda Function ARN - Value: !GetAtt DailyStudyFunction.Arn - - TestFunctionArn: - Description: Test Lambda Function ARN - Value: !GetAtt TestFunction.Arn - - StatsFunctionArn: - Description: Stats Lambda Function ARN - Value: !GetAtt StatsFunction.Arn - - VoiceFunctionArn: - Description: Voice Lambda Function ARN - Value: !GetAtt VoiceFunction.Arn From 856d5534a4b9c5465d4f574578d6d22a09fe47c6 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Wed, 7 Jan 2026 17:08:32 +0900 Subject: [PATCH 015/528] =?UTF-8?q?refactor:=20=EA=B3=B5=ED=86=B5=20?= =?UTF-8?q?=EB=AA=A8=EB=93=88=20=EC=83=9D=EC=84=B1=20=EB=B0=8F=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=EC=BD=94=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - common/dto/ApiResponse: 통합 응답 DTO - common/service/PollyService: 통합 TTS 서비스 - common/util/AwsClients: AWS SDK 클라이언트 싱글톤 관리 - common/util/ResponseUtil: API Gateway 응답 유틸리티 - 기존 chatting/vocabulary의 중복 ApiResponse 삭제 --- .../chatting/handler/ChatAIHandler.java | 2 +- .../chatting/handler/ChatMessageHandler.java | 2 +- .../chatting/handler/ChatRoomHandler.java | 2 +- .../chatting/handler/ChatVoiceHandler.java | 2 +- .../{chatting => common}/dto/ApiResponse.java | 2 +- .../common/service/PollyService.java | 175 ++++++++++++++++++ .../serverless/common/util/AwsClients.java | 67 +++++++ .../serverless/common/util/ResponseUtil.java | 78 ++++++++ .../vocabulary/dto/ApiResponse.java | 33 ---- .../vocabulary/handler/DailyStudyHandler.java | 2 +- .../vocabulary/handler/StatsHandler.java | 2 +- .../vocabulary/handler/TestHandler.java | 2 +- .../vocabulary/handler/UserWordHandler.java | 2 +- .../vocabulary/handler/VoiceHandler.java | 2 +- .../vocabulary/handler/WordHandler.java | 2 +- 15 files changed, 331 insertions(+), 44 deletions(-) rename ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/{chatting => common}/dto/ApiResponse.java (93%) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/service/PollyService.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/AwsClients.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/ResponseUtil.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/dto/ApiResponse.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatAIHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatAIHandler.java index 8396b285..4993e550 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatAIHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatAIHandler.java @@ -6,7 +6,7 @@ import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; import com.google.gson.Gson; import com.google.gson.GsonBuilder; -import com.mzc.secondproject.serverless.chatting.dto.ApiResponse; +import com.mzc.secondproject.serverless.common.dto.ApiResponse; import com.mzc.secondproject.serverless.chatting.service.BedrockService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatMessageHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatMessageHandler.java index fb700dae..10179ee4 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatMessageHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatMessageHandler.java @@ -6,7 +6,7 @@ import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; import com.google.gson.Gson; import com.google.gson.GsonBuilder; -import com.mzc.secondproject.serverless.chatting.dto.ApiResponse; +import com.mzc.secondproject.serverless.common.dto.ApiResponse; import com.mzc.secondproject.serverless.chatting.model.ChatMessage; import com.mzc.secondproject.serverless.chatting.model.ChatRoom; import com.mzc.secondproject.serverless.chatting.repository.ChatMessageRepository; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatRoomHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatRoomHandler.java index d1414843..b01396be 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatRoomHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatRoomHandler.java @@ -6,7 +6,7 @@ import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; import com.google.gson.Gson; import com.google.gson.GsonBuilder; -import com.mzc.secondproject.serverless.chatting.dto.ApiResponse; +import com.mzc.secondproject.serverless.common.dto.ApiResponse; import com.mzc.secondproject.serverless.chatting.model.ChatRoom; import com.mzc.secondproject.serverless.chatting.repository.ChatRoomRepository; import org.mindrot.jbcrypt.BCrypt; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatVoiceHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatVoiceHandler.java index 90f711cd..45834560 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatVoiceHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatVoiceHandler.java @@ -6,7 +6,7 @@ import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; import com.google.gson.Gson; import com.google.gson.GsonBuilder; -import com.mzc.secondproject.serverless.chatting.dto.ApiResponse; +import com.mzc.secondproject.serverless.common.dto.ApiResponse; import com.mzc.secondproject.serverless.chatting.model.ChatMessage; import com.mzc.secondproject.serverless.chatting.repository.ChatMessageRepository; import com.mzc.secondproject.serverless.chatting.service.PollyService; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/dto/ApiResponse.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/ApiResponse.java similarity index 93% rename from ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/dto/ApiResponse.java rename to ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/ApiResponse.java index 2d34f585..3e1acd6b 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/dto/ApiResponse.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/ApiResponse.java @@ -1,4 +1,4 @@ -package com.mzc.secondproject.serverless.chatting.dto; +package com.mzc.secondproject.serverless.common.dto; import lombok.AllArgsConstructor; import lombok.Builder; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/service/PollyService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/service/PollyService.java new file mode 100644 index 00000000..6c26db6e --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/service/PollyService.java @@ -0,0 +1,175 @@ +package com.mzc.secondproject.serverless.common.service; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.polly.PollyClient; +import software.amazon.awssdk.services.polly.model.OutputFormat; +import software.amazon.awssdk.services.polly.model.SynthesizeSpeechRequest; +import software.amazon.awssdk.services.polly.model.VoiceId; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; +import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.HeadObjectRequest; +import software.amazon.awssdk.services.s3.model.NoSuchKeyException; + +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.time.Duration; + +/** + * AWS Polly를 사용한 TTS(Text-to-Speech) 공통 서비스 + * 음성 합성 결과를 S3에 캐싱하여 재사용 + */ +public class PollyService { + + private static final Logger logger = LoggerFactory.getLogger(PollyService.class); + + // Singleton 패턴으로 Cold Start 최적화 + private static final PollyClient pollyClient = PollyClient.builder().build(); + private static final S3Client s3Client = S3Client.builder().build(); + private static final S3Presigner s3Presigner = S3Presigner.builder().build(); + + private final String bucketName; + private final String s3KeyPrefix; + + /** + * @param bucketName S3 버킷 이름 + * @param s3KeyPrefix S3 키 prefix (예: "voice/", "vocab/voice/") + */ + public PollyService(String bucketName, String s3KeyPrefix) { + this.bucketName = bucketName; + this.s3KeyPrefix = s3KeyPrefix; + } + + /** + * ID 기반으로 음성 합성 (캐시 지원) + * S3에 파일이 있으면 바로 URL 반환, 없으면 Polly 변환 후 저장 + */ + public VoiceSynthesisResult synthesizeSpeech(String id, String text, String voice) { + String s3Key = generateS3Key(id, voice); + + // 캐시 확인: S3에 이미 존재하는지 체크 + if (existsInS3(s3Key)) { + logger.info("Cache hit: {}", s3Key); + String presignedUrl = getPresignedUrl(s3Key); + return new VoiceSynthesisResult(s3Key, presignedUrl, true); + } + + // 캐시 미스: Polly 변환 후 S3 저장 + logger.info("Cache miss: synthesizing and saving to {}", s3Key); + synthesizeAndSave(text, voice, s3Key); + String presignedUrl = getPresignedUrl(s3Key); + return new VoiceSynthesisResult(s3Key, presignedUrl, false); + } + + /** + * S3 키로 Pre-signed URL 생성 + */ + public String getPresignedUrl(String s3Key) { + GetObjectRequest getObjectRequest = GetObjectRequest.builder() + .bucket(bucketName) + .key(s3Key) + .build(); + + GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() + .signatureDuration(Duration.ofHours(1)) + .getObjectRequest(getObjectRequest) + .build(); + + PresignedGetObjectRequest presignedRequest = s3Presigner.presignGetObject(presignRequest); + return presignedRequest.url().toString(); + } + + /** + * S3에 파일 존재 여부 확인 + */ + public boolean existsInS3(String s3Key) { + try { + s3Client.headObject(HeadObjectRequest.builder() + .bucket(bucketName) + .key(s3Key) + .build()); + return true; + } catch (NoSuchKeyException e) { + return false; + } + } + + /** + * Polly로 음성 변환 후 지정된 S3 키로 저장 + */ + private void synthesizeAndSave(String text, String voice, String s3Key) { + VoiceId voiceId = resolveVoiceId(voice); + + try { + SynthesizeSpeechRequest request = SynthesizeSpeechRequest.builder() + .text(text) + .voiceId(voiceId) + .engine("neural") + .outputFormat(OutputFormat.MP3) + .build(); + + InputStream audioStream = pollyClient.synthesizeSpeech(request); + + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + byte[] data = new byte[4096]; + int bytesRead; + while ((bytesRead = audioStream.read(data, 0, data.length)) != -1) { + buffer.write(data, 0, bytesRead); + } + byte[] audioBytes = buffer.toByteArray(); + + s3Client.putObject( + PutObjectRequest.builder() + .bucket(bucketName) + .key(s3Key) + .contentType("audio/mpeg") + .build(), + RequestBody.fromBytes(audioBytes) + ); + + logger.info("Saved audio to S3: {}", s3Key); + } catch (Exception e) { + logger.error("Error synthesizing speech", e); + throw new RuntimeException("Failed to synthesize speech", e); + } + } + + /** + * ID와 음성 타입으로 S3 키 생성 + */ + public String generateS3Key(String id, String voice) { + String voiceSuffix = "MALE".equalsIgnoreCase(voice) ? "male" : "female"; + return s3KeyPrefix + id + "_" + voiceSuffix + ".mp3"; + } + + /** + * 음성 합성 결과 + */ + public static class VoiceSynthesisResult { + private final String s3Key; + private final String audioUrl; + private final boolean cached; + + public VoiceSynthesisResult(String s3Key, String audioUrl, boolean cached) { + this.s3Key = s3Key; + this.audioUrl = audioUrl; + this.cached = cached; + } + + public String getS3Key() { return s3Key; } + public String getAudioUrl() { return audioUrl; } + public boolean isCached() { return cached; } + } + + private VoiceId resolveVoiceId(String voice) { + if ("MALE".equalsIgnoreCase(voice)) { + return VoiceId.MATTHEW; // 미국 영어 남성 (Neural 지원) + } + return VoiceId.JOANNA; // 미국 영어 여성 (Neural 지원, 기본값) + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/AwsClients.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/AwsClients.java new file mode 100644 index 00000000..851342ba --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/AwsClients.java @@ -0,0 +1,67 @@ +package com.mzc.secondproject.serverless.common.util; + +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; +import software.amazon.awssdk.services.dynamodb.DynamoDbClient; +import software.amazon.awssdk.services.polly.PollyClient; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.sns.SnsClient; +import software.amazon.awssdk.services.bedrockruntime.BedrockRuntimeClient; + +/** + * AWS SDK 클라이언트 싱글톤 관리 + * Lambda Cold Start 최적화를 위해 static final로 선언 + */ +public final class AwsClients { + + private AwsClients() { + // 인스턴스화 방지 + } + + // DynamoDB + private static final DynamoDbClient DYNAMO_DB_CLIENT = DynamoDbClient.builder().build(); + private static final DynamoDbEnhancedClient DYNAMO_DB_ENHANCED_CLIENT = DynamoDbEnhancedClient.builder() + .dynamoDbClient(DYNAMO_DB_CLIENT) + .build(); + + // S3 + private static final S3Client S3_CLIENT = S3Client.builder().build(); + private static final S3Presigner S3_PRESIGNER = S3Presigner.builder().build(); + + // Polly + private static final PollyClient POLLY_CLIENT = PollyClient.builder().build(); + + // SNS + private static final SnsClient SNS_CLIENT = SnsClient.builder().build(); + + // Bedrock + private static final BedrockRuntimeClient BEDROCK_CLIENT = BedrockRuntimeClient.builder().build(); + + public static DynamoDbClient dynamoDb() { + return DYNAMO_DB_CLIENT; + } + + public static DynamoDbEnhancedClient dynamoDbEnhanced() { + return DYNAMO_DB_ENHANCED_CLIENT; + } + + public static S3Client s3() { + return S3_CLIENT; + } + + public static S3Presigner s3Presigner() { + return S3_PRESIGNER; + } + + public static PollyClient polly() { + return POLLY_CLIENT; + } + + public static SnsClient sns() { + return SNS_CLIENT; + } + + public static BedrockRuntimeClient bedrock() { + return BEDROCK_CLIENT; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/ResponseUtil.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/ResponseUtil.java new file mode 100644 index 00000000..a8afc407 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/ResponseUtil.java @@ -0,0 +1,78 @@ +package com.mzc.secondproject.serverless.common.util; + +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; + +import java.util.Map; + +/** + * API Gateway 응답 생성 유틸리티 + */ +public final class ResponseUtil { + + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + + private static final Map CORS_HEADERS = Map.of( + "Content-Type", "application/json", + "Access-Control-Allow-Origin", "*", + "Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS", + "Access-Control-Allow-Headers", "Content-Type,Authorization" + ); + + private ResponseUtil() { + // 인스턴스화 방지 + } + + /** + * JSON 응답 생성 + */ + public static APIGatewayProxyResponseEvent createResponse(int statusCode, Object body) { + return new APIGatewayProxyResponseEvent() + .withStatusCode(statusCode) + .withHeaders(CORS_HEADERS) + .withBody(GSON.toJson(body)); + } + + /** + * 200 OK 응답 + */ + public static APIGatewayProxyResponseEvent ok(Object body) { + return createResponse(200, body); + } + + /** + * 201 Created 응답 + */ + public static APIGatewayProxyResponseEvent created(Object body) { + return createResponse(201, body); + } + + /** + * 400 Bad Request 응답 + */ + public static APIGatewayProxyResponseEvent badRequest(Object body) { + return createResponse(400, body); + } + + /** + * 404 Not Found 응답 + */ + public static APIGatewayProxyResponseEvent notFound(Object body) { + return createResponse(404, body); + } + + /** + * 500 Internal Server Error 응답 + */ + public static APIGatewayProxyResponseEvent serverError(Object body) { + return createResponse(500, body); + } + + /** + * Gson 인스턴스 반환 (JSON 파싱용) + */ + public static Gson gson() { + return GSON; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/dto/ApiResponse.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/dto/ApiResponse.java deleted file mode 100644 index acd76c92..00000000 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/dto/ApiResponse.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.mzc.secondproject.serverless.vocabulary.dto; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class ApiResponse { - - private boolean success; - private String message; - private T data; - private String error; - - public static ApiResponse success(String message, T data) { - return ApiResponse.builder() - .success(true) - .message(message) - .data(data) - .build(); - } - - public static ApiResponse error(String errorMessage) { - return ApiResponse.builder() - .success(false) - .error(errorMessage) - .build(); - } -} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/DailyStudyHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/DailyStudyHandler.java index e3f948fc..8568f8ad 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/DailyStudyHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/DailyStudyHandler.java @@ -6,7 +6,7 @@ import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; import com.google.gson.Gson; import com.google.gson.GsonBuilder; -import com.mzc.secondproject.serverless.vocabulary.dto.ApiResponse; +import com.mzc.secondproject.serverless.common.dto.ApiResponse; import com.mzc.secondproject.serverless.vocabulary.model.DailyStudy; import com.mzc.secondproject.serverless.vocabulary.model.UserWord; import com.mzc.secondproject.serverless.vocabulary.model.Word; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/StatsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/StatsHandler.java index d5ec6b54..54709d74 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/StatsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/StatsHandler.java @@ -6,7 +6,7 @@ import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; import com.google.gson.Gson; import com.google.gson.GsonBuilder; -import com.mzc.secondproject.serverless.vocabulary.dto.ApiResponse; +import com.mzc.secondproject.serverless.common.dto.ApiResponse; import com.mzc.secondproject.serverless.vocabulary.model.DailyStudy; import com.mzc.secondproject.serverless.vocabulary.model.TestResult; import com.mzc.secondproject.serverless.vocabulary.model.UserWord; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/TestHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/TestHandler.java index 9e27a220..15d7e0db 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/TestHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/TestHandler.java @@ -6,7 +6,7 @@ import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; import com.google.gson.Gson; import com.google.gson.GsonBuilder; -import com.mzc.secondproject.serverless.vocabulary.dto.ApiResponse; +import com.mzc.secondproject.serverless.common.dto.ApiResponse; import com.mzc.secondproject.serverless.vocabulary.model.DailyStudy; import com.mzc.secondproject.serverless.vocabulary.model.TestResult; import com.mzc.secondproject.serverless.vocabulary.model.Word; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/UserWordHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/UserWordHandler.java index 3ba1b86c..1f7c86cf 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/UserWordHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/UserWordHandler.java @@ -6,7 +6,7 @@ import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; import com.google.gson.Gson; import com.google.gson.GsonBuilder; -import com.mzc.secondproject.serverless.vocabulary.dto.ApiResponse; +import com.mzc.secondproject.serverless.common.dto.ApiResponse; import com.mzc.secondproject.serverless.vocabulary.model.UserWord; import com.mzc.secondproject.serverless.vocabulary.model.Word; import com.mzc.secondproject.serverless.vocabulary.repository.UserWordRepository; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/VoiceHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/VoiceHandler.java index c49d4fef..9ef83b82 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/VoiceHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/VoiceHandler.java @@ -6,7 +6,7 @@ import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; import com.google.gson.Gson; import com.google.gson.GsonBuilder; -import com.mzc.secondproject.serverless.vocabulary.dto.ApiResponse; +import com.mzc.secondproject.serverless.common.dto.ApiResponse; import com.mzc.secondproject.serverless.vocabulary.model.Word; import com.mzc.secondproject.serverless.vocabulary.repository.WordRepository; import com.mzc.secondproject.serverless.vocabulary.service.PollyService; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/WordHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/WordHandler.java index e05352e5..b2d011fe 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/WordHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/WordHandler.java @@ -6,7 +6,7 @@ import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; import com.google.gson.Gson; import com.google.gson.GsonBuilder; -import com.mzc.secondproject.serverless.vocabulary.dto.ApiResponse; +import com.mzc.secondproject.serverless.common.dto.ApiResponse; import com.mzc.secondproject.serverless.vocabulary.model.Word; import com.mzc.secondproject.serverless.vocabulary.repository.WordRepository; import org.slf4j.Logger; From b297358fd75ea0889746d71ce6f439adb8203309 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Wed, 7 Jan 2026 17:31:16 +0900 Subject: [PATCH 016/528] =?UTF-8?q?refactor:=20domain=20=ED=8C=A8=ED=82=A4?= =?UTF-8?q?=EC=A7=80=20=EA=B5=AC=EC=A1=B0=EB=A1=9C=20=EB=A6=AC=ED=8C=A9?= =?UTF-8?q?=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - chatting/, vocabulary/ → domain/chatting/, domain/vocabulary/로 이동 - package 선언 및 import 문 업데이트 - template.yaml Handler 경로 수정 --- .../chatting/handler/ChatAIHandler.java | 4 +- .../chatting/handler/ChatMessageHandler.java | 12 +- .../chatting/handler/ChatRoomHandler.java | 6 +- .../chatting/handler/ChatVoiceHandler.java | 10 +- .../chatting/model/ChatMessage.java | 2 +- .../{ => domain}/chatting/model/ChatRoom.java | 2 +- .../repository/ChatMessageRepository.java | 4 +- .../repository/ChatRoomRepository.java | 4 +- .../chatting/service/BedrockService.java | 2 +- .../chatting/service/ChatMessageService.java | 6 +- .../chatting/service/PollyService.java | 2 +- .../vocabulary/handler/DailyStudyHandler.java | 14 +- .../vocabulary/handler/StatisticsHandler.java | 6 +- .../vocabulary/handler/StatsHandler.java | 18 +- .../vocabulary/handler/TestHandler.java | 14 +- .../vocabulary/handler/UserWordHandler.java | 10 +- .../vocabulary/handler/VoiceHandler.java | 8 +- .../vocabulary/handler/WordHandler.java | 6 +- .../vocabulary/model/DailyStudy.java | 2 +- .../vocabulary/model/TestResult.java | 2 +- .../vocabulary/model/UserWord.java | 2 +- .../{ => domain}/vocabulary/model/Word.java | 2 +- .../repository/DailyStudyRepository.java | 4 +- .../repository/TestResultRepository.java | 4 +- .../repository/UserWordRepository.java | 4 +- .../vocabulary/repository/WordRepository.java | 4 +- .../vocabulary/service/PollyService.java | 2 +- ServerlessFunction/template.yaml | 599 ++++++++++++++++++ deploy.sh | 146 ----- 29 files changed, 677 insertions(+), 224 deletions(-) rename ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/{ => domain}/chatting/handler/ChatAIHandler.java (94%) rename ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/{ => domain}/chatting/handler/ChatMessageHandler.java (92%) rename ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/{ => domain}/chatting/handler/ChatRoomHandler.java (98%) rename ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/{ => domain}/chatting/handler/ChatVoiceHandler.java (92%) rename ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/{ => domain}/chatting/model/ChatMessage.java (97%) rename ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/{ => domain}/chatting/model/ChatRoom.java (96%) rename ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/{ => domain}/chatting/repository/ChatMessageRepository.java (97%) rename ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/{ => domain}/chatting/repository/ChatRoomRepository.java (98%) rename ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/{ => domain}/chatting/service/BedrockService.java (97%) rename ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/{ => domain}/chatting/service/ChatMessageService.java (85%) rename ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/{ => domain}/chatting/service/PollyService.java (98%) rename ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/{ => domain}/vocabulary/handler/DailyStudyHandler.java (95%) rename ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/{ => domain}/vocabulary/handler/StatisticsHandler.java (96%) rename ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/{ => domain}/vocabulary/handler/StatsHandler.java (95%) rename ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/{ => domain}/vocabulary/handler/TestHandler.java (96%) rename ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/{ => domain}/vocabulary/handler/UserWordHandler.java (97%) rename ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/{ => domain}/vocabulary/handler/VoiceHandler.java (94%) rename ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/{ => domain}/vocabulary/handler/WordHandler.java (98%) rename ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/{ => domain}/vocabulary/model/DailyStudy.java (97%) rename ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/{ => domain}/vocabulary/model/TestResult.java (96%) rename ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/{ => domain}/vocabulary/model/UserWord.java (97%) rename ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/{ => domain}/vocabulary/model/Word.java (97%) rename ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/{ => domain}/vocabulary/repository/DailyStudyRepository.java (97%) rename ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/{ => domain}/vocabulary/repository/TestResultRepository.java (97%) rename ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/{ => domain}/vocabulary/repository/UserWordRepository.java (98%) rename ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/{ => domain}/vocabulary/repository/WordRepository.java (98%) rename ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/{ => domain}/vocabulary/service/PollyService.java (98%) create mode 100644 ServerlessFunction/template.yaml delete mode 100755 deploy.sh diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatAIHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatAIHandler.java similarity index 94% rename from ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatAIHandler.java rename to ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatAIHandler.java index 4993e550..646fa0e5 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatAIHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatAIHandler.java @@ -1,4 +1,4 @@ -package com.mzc.secondproject.serverless.chatting.handler; +package com.mzc.secondproject.serverless.domain.chatting.handler; import com.amazonaws.services.lambda.runtime.Context; import com.amazonaws.services.lambda.runtime.RequestHandler; @@ -7,7 +7,7 @@ import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.mzc.secondproject.serverless.common.dto.ApiResponse; -import com.mzc.secondproject.serverless.chatting.service.BedrockService; +import com.mzc.secondproject.serverless.domain.chatting.service.BedrockService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatMessageHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatMessageHandler.java similarity index 92% rename from ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatMessageHandler.java rename to ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatMessageHandler.java index 10179ee4..5a89bbc5 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatMessageHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatMessageHandler.java @@ -1,4 +1,4 @@ -package com.mzc.secondproject.serverless.chatting.handler; +package com.mzc.secondproject.serverless.domain.chatting.handler; import com.amazonaws.services.lambda.runtime.Context; import com.amazonaws.services.lambda.runtime.RequestHandler; @@ -7,11 +7,11 @@ import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.mzc.secondproject.serverless.common.dto.ApiResponse; -import com.mzc.secondproject.serverless.chatting.model.ChatMessage; -import com.mzc.secondproject.serverless.chatting.model.ChatRoom; -import com.mzc.secondproject.serverless.chatting.repository.ChatMessageRepository; -import com.mzc.secondproject.serverless.chatting.repository.ChatRoomRepository; -import com.mzc.secondproject.serverless.chatting.service.ChatMessageService; +import com.mzc.secondproject.serverless.domain.chatting.model.ChatMessage; +import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; +import com.mzc.secondproject.serverless.domain.chatting.repository.ChatMessageRepository; +import com.mzc.secondproject.serverless.domain.chatting.repository.ChatRoomRepository; +import com.mzc.secondproject.serverless.domain.chatting.service.ChatMessageService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatRoomHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java similarity index 98% rename from ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatRoomHandler.java rename to ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java index b01396be..c5d86e24 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatRoomHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java @@ -1,4 +1,4 @@ -package com.mzc.secondproject.serverless.chatting.handler; +package com.mzc.secondproject.serverless.domain.chatting.handler; import com.amazonaws.services.lambda.runtime.Context; import com.amazonaws.services.lambda.runtime.RequestHandler; @@ -7,8 +7,8 @@ import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.mzc.secondproject.serverless.common.dto.ApiResponse; -import com.mzc.secondproject.serverless.chatting.model.ChatRoom; -import com.mzc.secondproject.serverless.chatting.repository.ChatRoomRepository; +import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; +import com.mzc.secondproject.serverless.domain.chatting.repository.ChatRoomRepository; import org.mindrot.jbcrypt.BCrypt; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatVoiceHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatVoiceHandler.java similarity index 92% rename from ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatVoiceHandler.java rename to ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatVoiceHandler.java index 45834560..f0f0f80d 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatVoiceHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatVoiceHandler.java @@ -1,4 +1,4 @@ -package com.mzc.secondproject.serverless.chatting.handler; +package com.mzc.secondproject.serverless.domain.chatting.handler; import com.amazonaws.services.lambda.runtime.Context; import com.amazonaws.services.lambda.runtime.RequestHandler; @@ -7,10 +7,10 @@ import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.mzc.secondproject.serverless.common.dto.ApiResponse; -import com.mzc.secondproject.serverless.chatting.model.ChatMessage; -import com.mzc.secondproject.serverless.chatting.repository.ChatMessageRepository; -import com.mzc.secondproject.serverless.chatting.service.PollyService; -import com.mzc.secondproject.serverless.chatting.service.PollyService.VoiceSynthesisResult; +import com.mzc.secondproject.serverless.domain.chatting.model.ChatMessage; +import com.mzc.secondproject.serverless.domain.chatting.repository.ChatMessageRepository; +import com.mzc.secondproject.serverless.domain.chatting.service.PollyService; +import com.mzc.secondproject.serverless.domain.chatting.service.PollyService.VoiceSynthesisResult; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/model/ChatMessage.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/ChatMessage.java similarity index 97% rename from ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/model/ChatMessage.java rename to ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/ChatMessage.java index d0a2726e..059fe665 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/model/ChatMessage.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/ChatMessage.java @@ -1,4 +1,4 @@ -package com.mzc.secondproject.serverless.chatting.model; +package com.mzc.secondproject.serverless.domain.chatting.model; import lombok.AllArgsConstructor; import lombok.Builder; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/model/ChatRoom.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/ChatRoom.java similarity index 96% rename from ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/model/ChatRoom.java rename to ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/ChatRoom.java index cfa20cc9..81abd60b 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/model/ChatRoom.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/ChatRoom.java @@ -1,4 +1,4 @@ -package com.mzc.secondproject.serverless.chatting.model; +package com.mzc.secondproject.serverless.domain.chatting.model; import lombok.AllArgsConstructor; import lombok.Builder; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/repository/ChatMessageRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatMessageRepository.java similarity index 97% rename from ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/repository/ChatMessageRepository.java rename to ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatMessageRepository.java index 9bd2ba31..053c1dc3 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/repository/ChatMessageRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatMessageRepository.java @@ -1,6 +1,6 @@ -package com.mzc.secondproject.serverless.chatting.repository; +package com.mzc.secondproject.serverless.domain.chatting.repository; -import com.mzc.secondproject.serverless.chatting.model.ChatMessage; +import com.mzc.secondproject.serverless.domain.chatting.model.ChatMessage; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/repository/ChatRoomRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatRoomRepository.java similarity index 98% rename from ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/repository/ChatRoomRepository.java rename to ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatRoomRepository.java index 4b5cef43..eaa49764 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/repository/ChatRoomRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatRoomRepository.java @@ -1,6 +1,6 @@ -package com.mzc.secondproject.serverless.chatting.repository; +package com.mzc.secondproject.serverless.domain.chatting.repository; -import com.mzc.secondproject.serverless.chatting.model.ChatRoom; +import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/service/BedrockService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/BedrockService.java similarity index 97% rename from ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/service/BedrockService.java rename to ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/BedrockService.java index 433a8117..01a4688c 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/service/BedrockService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/BedrockService.java @@ -1,4 +1,4 @@ -package com.mzc.secondproject.serverless.chatting.service; +package com.mzc.secondproject.serverless.domain.chatting.service; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/service/ChatMessageService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatMessageService.java similarity index 85% rename from ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/service/ChatMessageService.java rename to ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatMessageService.java index a7511066..11bd7af4 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/service/ChatMessageService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatMessageService.java @@ -1,7 +1,7 @@ -package com.mzc.secondproject.serverless.chatting.service; +package com.mzc.secondproject.serverless.domain.chatting.service; -import com.mzc.secondproject.serverless.chatting.model.ChatMessage; -import com.mzc.secondproject.serverless.chatting.repository.ChatMessageRepository; +import com.mzc.secondproject.serverless.domain.chatting.model.ChatMessage; +import com.mzc.secondproject.serverless.domain.chatting.repository.ChatMessageRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/service/PollyService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/PollyService.java similarity index 98% rename from ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/service/PollyService.java rename to ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/PollyService.java index 57adbd98..8e353ef4 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/chatting/service/PollyService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/PollyService.java @@ -1,4 +1,4 @@ -package com.mzc.secondproject.serverless.chatting.service; +package com.mzc.secondproject.serverless.domain.chatting.service; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/DailyStudyHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/DailyStudyHandler.java similarity index 95% rename from ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/DailyStudyHandler.java rename to ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/DailyStudyHandler.java index 8568f8ad..8adc68b8 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/DailyStudyHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/DailyStudyHandler.java @@ -1,4 +1,4 @@ -package com.mzc.secondproject.serverless.vocabulary.handler; +package com.mzc.secondproject.serverless.domain.vocabulary.handler; import com.amazonaws.services.lambda.runtime.Context; import com.amazonaws.services.lambda.runtime.RequestHandler; @@ -7,12 +7,12 @@ import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.mzc.secondproject.serverless.common.dto.ApiResponse; -import com.mzc.secondproject.serverless.vocabulary.model.DailyStudy; -import com.mzc.secondproject.serverless.vocabulary.model.UserWord; -import com.mzc.secondproject.serverless.vocabulary.model.Word; -import com.mzc.secondproject.serverless.vocabulary.repository.DailyStudyRepository; -import com.mzc.secondproject.serverless.vocabulary.repository.UserWordRepository; -import com.mzc.secondproject.serverless.vocabulary.repository.WordRepository; +import com.mzc.secondproject.serverless.domain.vocabulary.model.DailyStudy; +import com.mzc.secondproject.serverless.domain.vocabulary.model.UserWord; +import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; +import com.mzc.secondproject.serverless.domain.vocabulary.repository.DailyStudyRepository; +import com.mzc.secondproject.serverless.domain.vocabulary.repository.UserWordRepository; +import com.mzc.secondproject.serverless.domain.vocabulary.repository.WordRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/StatisticsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatisticsHandler.java similarity index 96% rename from ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/StatisticsHandler.java rename to ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatisticsHandler.java index 5f09ffa5..d3f255a1 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/StatisticsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatisticsHandler.java @@ -1,12 +1,12 @@ -package com.mzc.secondproject.serverless.vocabulary.handler; +package com.mzc.secondproject.serverless.domain.vocabulary.handler; import com.amazonaws.services.lambda.runtime.Context; import com.amazonaws.services.lambda.runtime.RequestHandler; import com.amazonaws.services.lambda.runtime.events.SQSEvent; import com.google.gson.Gson; import com.google.gson.GsonBuilder; -import com.mzc.secondproject.serverless.vocabulary.model.UserWord; -import com.mzc.secondproject.serverless.vocabulary.repository.UserWordRepository; +import com.mzc.secondproject.serverless.domain.vocabulary.model.UserWord; +import com.mzc.secondproject.serverless.domain.vocabulary.repository.UserWordRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/StatsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatsHandler.java similarity index 95% rename from ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/StatsHandler.java rename to ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatsHandler.java index 54709d74..dc5758b9 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/StatsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatsHandler.java @@ -1,4 +1,4 @@ -package com.mzc.secondproject.serverless.vocabulary.handler; +package com.mzc.secondproject.serverless.domain.vocabulary.handler; import com.amazonaws.services.lambda.runtime.Context; import com.amazonaws.services.lambda.runtime.RequestHandler; @@ -7,14 +7,14 @@ import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.mzc.secondproject.serverless.common.dto.ApiResponse; -import com.mzc.secondproject.serverless.vocabulary.model.DailyStudy; -import com.mzc.secondproject.serverless.vocabulary.model.TestResult; -import com.mzc.secondproject.serverless.vocabulary.model.UserWord; -import com.mzc.secondproject.serverless.vocabulary.model.Word; -import com.mzc.secondproject.serverless.vocabulary.repository.DailyStudyRepository; -import com.mzc.secondproject.serverless.vocabulary.repository.TestResultRepository; -import com.mzc.secondproject.serverless.vocabulary.repository.UserWordRepository; -import com.mzc.secondproject.serverless.vocabulary.repository.WordRepository; +import com.mzc.secondproject.serverless.domain.vocabulary.model.DailyStudy; +import com.mzc.secondproject.serverless.domain.vocabulary.model.TestResult; +import com.mzc.secondproject.serverless.domain.vocabulary.model.UserWord; +import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; +import com.mzc.secondproject.serverless.domain.vocabulary.repository.DailyStudyRepository; +import com.mzc.secondproject.serverless.domain.vocabulary.repository.TestResultRepository; +import com.mzc.secondproject.serverless.domain.vocabulary.repository.UserWordRepository; +import com.mzc.secondproject.serverless.domain.vocabulary.repository.WordRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/TestHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/TestHandler.java similarity index 96% rename from ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/TestHandler.java rename to ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/TestHandler.java index 15d7e0db..6f0ccbb9 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/TestHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/TestHandler.java @@ -1,4 +1,4 @@ -package com.mzc.secondproject.serverless.vocabulary.handler; +package com.mzc.secondproject.serverless.domain.vocabulary.handler; import com.amazonaws.services.lambda.runtime.Context; import com.amazonaws.services.lambda.runtime.RequestHandler; @@ -7,12 +7,12 @@ import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.mzc.secondproject.serverless.common.dto.ApiResponse; -import com.mzc.secondproject.serverless.vocabulary.model.DailyStudy; -import com.mzc.secondproject.serverless.vocabulary.model.TestResult; -import com.mzc.secondproject.serverless.vocabulary.model.Word; -import com.mzc.secondproject.serverless.vocabulary.repository.DailyStudyRepository; -import com.mzc.secondproject.serverless.vocabulary.repository.TestResultRepository; -import com.mzc.secondproject.serverless.vocabulary.repository.WordRepository; +import com.mzc.secondproject.serverless.domain.vocabulary.model.DailyStudy; +import com.mzc.secondproject.serverless.domain.vocabulary.model.TestResult; +import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; +import com.mzc.secondproject.serverless.domain.vocabulary.repository.DailyStudyRepository; +import com.mzc.secondproject.serverless.domain.vocabulary.repository.TestResultRepository; +import com.mzc.secondproject.serverless.domain.vocabulary.repository.WordRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import software.amazon.awssdk.services.sns.SnsClient; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/UserWordHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java similarity index 97% rename from ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/UserWordHandler.java rename to ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java index 1f7c86cf..28bf53de 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/UserWordHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java @@ -1,4 +1,4 @@ -package com.mzc.secondproject.serverless.vocabulary.handler; +package com.mzc.secondproject.serverless.domain.vocabulary.handler; import com.amazonaws.services.lambda.runtime.Context; import com.amazonaws.services.lambda.runtime.RequestHandler; @@ -7,10 +7,10 @@ import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.mzc.secondproject.serverless.common.dto.ApiResponse; -import com.mzc.secondproject.serverless.vocabulary.model.UserWord; -import com.mzc.secondproject.serverless.vocabulary.model.Word; -import com.mzc.secondproject.serverless.vocabulary.repository.UserWordRepository; -import com.mzc.secondproject.serverless.vocabulary.repository.WordRepository; +import com.mzc.secondproject.serverless.domain.vocabulary.model.UserWord; +import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; +import com.mzc.secondproject.serverless.domain.vocabulary.repository.UserWordRepository; +import com.mzc.secondproject.serverless.domain.vocabulary.repository.WordRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/VoiceHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/VoiceHandler.java similarity index 94% rename from ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/VoiceHandler.java rename to ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/VoiceHandler.java index 9ef83b82..5936cd34 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/VoiceHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/VoiceHandler.java @@ -1,4 +1,4 @@ -package com.mzc.secondproject.serverless.vocabulary.handler; +package com.mzc.secondproject.serverless.domain.vocabulary.handler; import com.amazonaws.services.lambda.runtime.Context; import com.amazonaws.services.lambda.runtime.RequestHandler; @@ -7,9 +7,9 @@ import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.mzc.secondproject.serverless.common.dto.ApiResponse; -import com.mzc.secondproject.serverless.vocabulary.model.Word; -import com.mzc.secondproject.serverless.vocabulary.repository.WordRepository; -import com.mzc.secondproject.serverless.vocabulary.service.PollyService; +import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; +import com.mzc.secondproject.serverless.domain.vocabulary.repository.WordRepository; +import com.mzc.secondproject.serverless.domain.vocabulary.service.PollyService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/WordHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/WordHandler.java similarity index 98% rename from ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/WordHandler.java rename to ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/WordHandler.java index b2d011fe..f9919374 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/handler/WordHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/WordHandler.java @@ -1,4 +1,4 @@ -package com.mzc.secondproject.serverless.vocabulary.handler; +package com.mzc.secondproject.serverless.domain.vocabulary.handler; import com.amazonaws.services.lambda.runtime.Context; import com.amazonaws.services.lambda.runtime.RequestHandler; @@ -7,8 +7,8 @@ import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.mzc.secondproject.serverless.common.dto.ApiResponse; -import com.mzc.secondproject.serverless.vocabulary.model.Word; -import com.mzc.secondproject.serverless.vocabulary.repository.WordRepository; +import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; +import com.mzc.secondproject.serverless.domain.vocabulary.repository.WordRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/DailyStudy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/model/DailyStudy.java similarity index 97% rename from ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/DailyStudy.java rename to ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/model/DailyStudy.java index c6c51520..8a7fc48d 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/DailyStudy.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/model/DailyStudy.java @@ -1,4 +1,4 @@ -package com.mzc.secondproject.serverless.vocabulary.model; +package com.mzc.secondproject.serverless.domain.vocabulary.model; import lombok.AllArgsConstructor; import lombok.Builder; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/TestResult.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/model/TestResult.java similarity index 96% rename from ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/TestResult.java rename to ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/model/TestResult.java index 1b0da164..ecc9f02d 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/TestResult.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/model/TestResult.java @@ -1,4 +1,4 @@ -package com.mzc.secondproject.serverless.vocabulary.model; +package com.mzc.secondproject.serverless.domain.vocabulary.model; import lombok.AllArgsConstructor; import lombok.Builder; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/UserWord.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/model/UserWord.java similarity index 97% rename from ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/UserWord.java rename to ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/model/UserWord.java index c9b22db1..81638120 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/UserWord.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/model/UserWord.java @@ -1,4 +1,4 @@ -package com.mzc.secondproject.serverless.vocabulary.model; +package com.mzc.secondproject.serverless.domain.vocabulary.model; import lombok.AllArgsConstructor; import lombok.Builder; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/Word.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/model/Word.java similarity index 97% rename from ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/Word.java rename to ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/model/Word.java index ce116685..51eb8e30 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/model/Word.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/model/Word.java @@ -1,4 +1,4 @@ -package com.mzc.secondproject.serverless.vocabulary.model; +package com.mzc.secondproject.serverless.domain.vocabulary.model; import lombok.AllArgsConstructor; import lombok.Builder; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/DailyStudyRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/DailyStudyRepository.java similarity index 97% rename from ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/DailyStudyRepository.java rename to ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/DailyStudyRepository.java index c255dd70..49d4ed39 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/DailyStudyRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/DailyStudyRepository.java @@ -1,6 +1,6 @@ -package com.mzc.secondproject.serverless.vocabulary.repository; +package com.mzc.secondproject.serverless.domain.vocabulary.repository; -import com.mzc.secondproject.serverless.vocabulary.model.DailyStudy; +import com.mzc.secondproject.serverless.domain.vocabulary.model.DailyStudy; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/TestResultRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/TestResultRepository.java similarity index 97% rename from ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/TestResultRepository.java rename to ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/TestResultRepository.java index 6224f2bc..751f939d 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/TestResultRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/TestResultRepository.java @@ -1,6 +1,6 @@ -package com.mzc.secondproject.serverless.vocabulary.repository; +package com.mzc.secondproject.serverless.domain.vocabulary.repository; -import com.mzc.secondproject.serverless.vocabulary.model.TestResult; +import com.mzc.secondproject.serverless.domain.vocabulary.model.TestResult; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/UserWordRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/UserWordRepository.java similarity index 98% rename from ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/UserWordRepository.java rename to ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/UserWordRepository.java index 72ed881a..db461c7c 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/UserWordRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/UserWordRepository.java @@ -1,6 +1,6 @@ -package com.mzc.secondproject.serverless.vocabulary.repository; +package com.mzc.secondproject.serverless.domain.vocabulary.repository; -import com.mzc.secondproject.serverless.vocabulary.model.UserWord; +import com.mzc.secondproject.serverless.domain.vocabulary.model.UserWord; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/WordRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordRepository.java similarity index 98% rename from ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/WordRepository.java rename to ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordRepository.java index 715e40bf..29e783e3 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/repository/WordRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordRepository.java @@ -1,6 +1,6 @@ -package com.mzc.secondproject.serverless.vocabulary.repository; +package com.mzc.secondproject.serverless.domain.vocabulary.repository; -import com.mzc.secondproject.serverless.vocabulary.model.Word; +import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/service/PollyService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/PollyService.java similarity index 98% rename from ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/service/PollyService.java rename to ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/PollyService.java index 5e7cb5c1..a03437c8 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/vocabulary/service/PollyService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/PollyService.java @@ -1,4 +1,4 @@ -package com.mzc.secondproject.serverless.vocabulary.service; +package com.mzc.secondproject.serverless.domain.vocabulary.service; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml new file mode 100644 index 00000000..a60c6a80 --- /dev/null +++ b/ServerlessFunction/template.yaml @@ -0,0 +1,599 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: Group2 English Study - Unified API (Chatting + Vocabulary) + +Globals: + Function: + Timeout: 30 + MemorySize: 512 + Runtime: java21 + Architectures: + - x86_64 + Environment: + Variables: + CHAT_TABLE_NAME: !Ref ChatTable + VOCAB_TABLE_NAME: !Ref VocabTable + CHAT_BUCKET_NAME: group2-englishstudy + VOCAB_BUCKET_NAME: group2-englishstudy + AWS_REGION_NAME: !Ref AWS::Region + +Resources: + ############################################# + # API Gateway (Unified) + ############################################# + + MainApi: + Type: AWS::Serverless::Api + Properties: + Name: group2-englishstudy-api + StageName: dev + Cors: + AllowMethods: "'GET,POST,PUT,DELETE,OPTIONS'" + AllowHeaders: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" + AllowOrigin: "'*'" + + ############################################# + # Chatting Lambda Functions + ############################################# + + ChatRoomFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: group2-englishstudy-chat-room-handler + CodeUri: ServerlessFunction + Handler: com.mzc.secondproject.serverless.domain.chatting.handler.ChatRoomHandler::handleRequest + Description: Handle chat room CRUD operations + SnapStart: + ApplyOn: PublishedVersions + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref ChatTable + Events: + CreateRoom: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /chat/rooms + Method: POST + GetRooms: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /chat/rooms + Method: GET + GetRoom: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /chat/rooms/{roomId} + Method: GET + DeleteRoom: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /chat/rooms/{roomId} + Method: DELETE + JoinRoom: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /chat/rooms/{roomId}/join + Method: POST + LeaveRoom: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /chat/rooms/{roomId}/leave + Method: POST + + ChatMessageFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: group2-englishstudy-chat-message-handler + CodeUri: ServerlessFunction + Handler: com.mzc.secondproject.serverless.domain.chatting.handler.ChatMessageHandler::handleRequest + Description: Handle chat messages + SnapStart: + ApplyOn: PublishedVersions + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref ChatTable + - S3CrudPolicy: + BucketName: group2-englishstudy + - Statement: + - Effect: Allow + Action: + - bedrock:InvokeModel + - bedrock:InvokeModelWithResponseStream + Resource: "*" + - Statement: + - Effect: Allow + Action: + - polly:SynthesizeSpeech + - polly:DescribeVoices + Resource: "*" + Events: + SendMessage: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /chat/rooms/{roomId}/messages + Method: POST + GetMessages: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /chat/rooms/{roomId}/messages + Method: GET + GetMessage: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /chat/rooms/{roomId}/messages/{messageId} + Method: GET + + ChatAIFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: group2-englishstudy-chat-ai-handler + CodeUri: ServerlessFunction + Handler: com.mzc.secondproject.serverless.domain.chatting.handler.ChatAIHandler::handleRequest + Description: Generate AI responses using Bedrock + Timeout: 60 + MemorySize: 1024 + SnapStart: + ApplyOn: PublishedVersions + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref ChatTable + - Statement: + - Effect: Allow + Action: + - bedrock:InvokeModel + - bedrock:InvokeModelWithResponseStream + Resource: "*" + Events: + GenerateAI: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /chat/ai/generate + Method: POST + + ChatVoiceFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: group2-englishstudy-chat-voice-handler + CodeUri: ServerlessFunction + Handler: com.mzc.secondproject.serverless.domain.chatting.handler.ChatVoiceHandler::handleRequest + Description: Convert text to speech using Polly + SnapStart: + ApplyOn: PublishedVersions + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref ChatTable + - S3CrudPolicy: + BucketName: group2-englishstudy + - Statement: + - Effect: Allow + Action: + - polly:SynthesizeSpeech + - polly:DescribeVoices + Resource: "*" + Events: + TextToSpeech: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /chat/voice/synthesize + Method: POST + + ############################################# + # Vocabulary Lambda Functions + ############################################# + + WordFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: group2-englishstudy-vocab-word-handler + CodeUri: ServerlessFunction + Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.WordHandler::handleRequest + Description: Handle word CRUD operations + SnapStart: + ApplyOn: PublishedVersions + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref VocabTable + Events: + CreateWord: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/words + Method: POST + BatchCreateWords: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/words/batch + Method: POST + SearchWords: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/words/search + Method: GET + GetWords: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/words + Method: GET + GetWord: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/words/{wordId} + Method: GET + UpdateWord: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/words/{wordId} + Method: PUT + DeleteWord: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/words/{wordId} + Method: DELETE + + UserWordFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: group2-englishstudy-vocab-userword-handler + CodeUri: ServerlessFunction + Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.UserWordHandler::handleRequest + Description: Handle user word learning status + SnapStart: + ApplyOn: PublishedVersions + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref VocabTable + Events: + GetUserWords: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/users/{userId}/words + Method: GET + GetUserWord: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/users/{userId}/words/{wordId} + Method: GET + UpdateUserWord: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/users/{userId}/words/{wordId} + Method: PUT + UpdateUserWordTag: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/users/{userId}/words/{wordId}/tag + Method: PUT + + DailyStudyFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: group2-englishstudy-vocab-daily-handler + CodeUri: ServerlessFunction + Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.DailyStudyHandler::handleRequest + Description: Handle daily study word assignment + SnapStart: + ApplyOn: PublishedVersions + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref VocabTable + Events: + GetDailyWords: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/daily/{userId} + Method: GET + MarkWordLearned: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/daily/{userId}/words/{wordId}/learned + Method: POST + + TestFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: group2-englishstudy-vocab-test-handler + CodeUri: ServerlessFunction + Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.TestHandler::handleRequest + Description: Handle vocabulary tests + SnapStart: + ApplyOn: PublishedVersions + Environment: + Variables: + TEST_RESULT_TOPIC_ARN: !Ref TestResultTopic + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref VocabTable + - SNSPublishMessagePolicy: + TopicName: !GetAtt TestResultTopic.TopicName + Events: + StartTest: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/test/{userId}/start + Method: POST + SubmitAnswer: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/test/{userId}/submit + Method: POST + GetTestResult: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/test/{userId}/results + Method: GET + + StatsFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: group2-englishstudy-vocab-stats-handler + CodeUri: ServerlessFunction + Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.StatsHandler::handleRequest + Description: Handle user learning statistics + SnapStart: + ApplyOn: PublishedVersions + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref VocabTable + Events: + GetStats: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/stats/{userId} + Method: GET + GetDailyStats: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/stats/{userId}/daily + Method: GET + GetWeaknessAnalysis: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/stats/{userId}/weakness + Method: GET + + VocabVoiceFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: group2-englishstudy-vocab-voice-handler + CodeUri: ServerlessFunction + Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.VoiceHandler::handleRequest + Description: Convert word to speech using Polly + SnapStart: + ApplyOn: PublishedVersions + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref VocabTable + - S3CrudPolicy: + BucketName: group2-englishstudy + - Statement: + - Effect: Allow + Action: + - polly:SynthesizeSpeech + - polly:DescribeVoices + Resource: "*" + Events: + TextToSpeech: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/voice/synthesize + Method: POST + + ############################################# + # DynamoDB Tables + ############################################# + + ChatTable: + Type: AWS::DynamoDB::Table + Properties: + TableName: group2-englishstudy-chat + BillingMode: PAY_PER_REQUEST + AttributeDefinitions: + - AttributeName: PK + AttributeType: S + - AttributeName: SK + AttributeType: S + - AttributeName: GSI1PK + AttributeType: S + - AttributeName: GSI1SK + AttributeType: S + - AttributeName: GSI2PK + AttributeType: S + - AttributeName: GSI2SK + AttributeType: S + KeySchema: + - AttributeName: PK + KeyType: HASH + - AttributeName: SK + KeyType: RANGE + GlobalSecondaryIndexes: + - IndexName: GSI1 + KeySchema: + - AttributeName: GSI1PK + KeyType: HASH + - AttributeName: GSI1SK + KeyType: RANGE + Projection: + ProjectionType: ALL + - IndexName: GSI2 + KeySchema: + - AttributeName: GSI2PK + KeyType: HASH + - AttributeName: GSI2SK + KeyType: RANGE + Projection: + ProjectionType: ALL + TimeToLiveSpecification: + AttributeName: ttl + Enabled: true + + VocabTable: + Type: AWS::DynamoDB::Table + Properties: + TableName: group2-englishstudy-vocab + BillingMode: PAY_PER_REQUEST + AttributeDefinitions: + - AttributeName: PK + AttributeType: S + - AttributeName: SK + AttributeType: S + - AttributeName: GSI1PK + AttributeType: S + - AttributeName: GSI1SK + AttributeType: S + - AttributeName: GSI2PK + AttributeType: S + - AttributeName: GSI2SK + AttributeType: S + KeySchema: + - AttributeName: PK + KeyType: HASH + - AttributeName: SK + KeyType: RANGE + GlobalSecondaryIndexes: + - IndexName: GSI1 + KeySchema: + - AttributeName: GSI1PK + KeyType: HASH + - AttributeName: GSI1SK + KeyType: RANGE + Projection: + ProjectionType: ALL + - IndexName: GSI2 + KeySchema: + - AttributeName: GSI2PK + KeyType: HASH + - AttributeName: GSI2SK + KeyType: RANGE + Projection: + ProjectionType: ALL + TimeToLiveSpecification: + AttributeName: ttl + Enabled: true + + ############################################# + # SNS / SQS for Async Statistics Processing + ############################################# + + # SNS Topic - 시험 결과 이벤트 발행 + TestResultTopic: + Type: AWS::SNS::Topic + Properties: + TopicName: group2-englishstudy-test-result-topic + + # SQS Dead Letter Queue - 실패한 메시지 보관 + StatisticsDeadLetterQueue: + Type: AWS::SQS::Queue + Properties: + QueueName: group2-englishstudy-statistics-dlq + MessageRetentionPeriod: 1209600 # 14일 + + # SQS Queue - 통계 처리용 + StatisticsQueue: + Type: AWS::SQS::Queue + Properties: + QueueName: group2-englishstudy-statistics-queue + VisibilityTimeout: 60 + RedrivePolicy: + deadLetterTargetArn: !GetAtt StatisticsDeadLetterQueue.Arn + maxReceiveCount: 3 + + # SQS Queue Policy - SNS에서 메시지 수신 허용 + StatisticsQueuePolicy: + Type: AWS::SQS::QueuePolicy + Properties: + Queues: + - !Ref StatisticsQueue + PolicyDocument: + Statement: + - Effect: Allow + Principal: + Service: sns.amazonaws.com + Action: sqs:SendMessage + Resource: !GetAtt StatisticsQueue.Arn + Condition: + ArnEquals: + aws:SourceArn: !Ref TestResultTopic + + # SNS → SQS 구독 + StatisticsQueueSubscription: + Type: AWS::SNS::Subscription + Properties: + Protocol: sqs + TopicArn: !Ref TestResultTopic + Endpoint: !GetAtt StatisticsQueue.Arn + RawMessageDelivery: true + + # Statistics Processor Lambda - SQS에서 메시지 소비하여 통계 업데이트 + StatisticsProcessorFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: group2-englishstudy-statistics-processor + CodeUri: ServerlessFunction + Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.StatisticsHandler::handleRequest + Description: Process test results and update user word statistics + Timeout: 60 + SnapStart: + ApplyOn: PublishedVersions + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref VocabTable + - SQSPollerPolicy: + QueueName: !GetAtt StatisticsQueue.QueueName + Events: + SQSEvent: + Type: SQS + Properties: + Queue: !GetAtt StatisticsQueue.Arn + BatchSize: 10 + +############################################# +# Outputs +############################################# + +Outputs: + ApiUrl: + Description: Unified API Gateway endpoint URL + Value: !Sub 'https://${MainApi}.execute-api.${AWS::Region}.amazonaws.com/dev/' + + ChatTableName: + Description: Chat DynamoDB Table Name + Value: !Ref ChatTable + + VocabTableName: + Description: Vocab DynamoDB Table Name + Value: !Ref VocabTable + + BucketName: + Description: S3 Bucket Name + Value: group2-englishstudy diff --git a/deploy.sh b/deploy.sh deleted file mode 100755 index 9df6b9ac..00000000 --- a/deploy.sh +++ /dev/null @@ -1,146 +0,0 @@ -#!/bin/bash - -# Group2 English Study - 통합 빌드 & 배포 스크립트 -# 사용법: ./deploy.sh [build|deploy|all] - -set -e - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -CHATTING_DIR="$SCRIPT_DIR/chatting" - -# 색상 정의 -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -NC='\033[0m' # No Color - -log_info() { - echo -e "${GREEN}[INFO]${NC} $1" -} - -log_warn() { - echo -e "${YELLOW}[WARN]${NC} $1" -} - -log_error() { - echo -e "${RED}[ERROR]${NC} $1" -} - -build() { - log_info "==========================================" - log_info "빌드 시작: Chat + Vocab 통합" - log_info "==========================================" - - cd "$CHATTING_DIR" - sam build - - log_info "빌드 완료!" -} - -deploy() { - log_info "==========================================" - log_info "배포 시작: group2-englishstudy-chatting" - log_info "==========================================" - - cd "$CHATTING_DIR" - sam deploy --no-confirm-changeset - - # API URL 출력 - log_info "==========================================" - log_info "배포 완료!" - API_URL=$(aws cloudformation describe-stacks \ - --stack-name group2-englishstudy-chatting \ - --profile mzc \ - --region ap-northeast-2 \ - --query 'Stacks[0].Outputs[?OutputKey==`ApiUrl`].OutputValue' \ - --output text 2>/dev/null) - - if [ -n "$API_URL" ]; then - log_info "API URL: $API_URL" - fi - log_info "==========================================" -} - -validate() { - log_info "템플릿 검증 중..." - cd "$CHATTING_DIR" - sam validate - log_info "템플릿 검증 완료!" -} - -status() { - log_info "스택 상태 확인 중..." - aws cloudformation describe-stacks \ - --stack-name group2-englishstudy-chatting \ - --profile mzc \ - --region ap-northeast-2 \ - --query 'Stacks[0].{Status:StackStatus,LastUpdated:LastUpdatedTime}' \ - --output table 2>/dev/null || log_warn "스택이 존재하지 않습니다." -} - -delete() { - log_warn "==========================================" - log_warn "스택 삭제: group2-englishstudy-chatting" - log_warn "==========================================" - read -p "정말 삭제하시겠습니까? (y/N): " confirm - if [ "$confirm" = "y" ] || [ "$confirm" = "Y" ]; then - aws cloudformation delete-stack \ - --stack-name group2-englishstudy-chatting \ - --profile mzc \ - --region ap-northeast-2 - log_info "삭제 요청 완료. 'aws cloudformation wait stack-delete-complete'로 완료 대기 가능" - else - log_info "삭제 취소됨" - fi -} - -show_help() { - echo "Group2 English Study - 빌드 & 배포 스크립트" - echo "" - echo "사용법: $0 [command]" - echo "" - echo "Commands:" - echo " build SAM 빌드만 실행" - echo " deploy SAM 배포만 실행" - echo " all 빌드 + 배포 (기본값)" - echo " validate 템플릿 검증" - echo " status 스택 상태 확인" - echo " delete 스택 삭제" - echo " help 도움말 표시" - echo "" - echo "예시:" - echo " $0 all # 빌드 후 배포" - echo " $0 build # 빌드만" - echo " $0 deploy # 배포만" -} - -# 메인 로직 -case "${1:-all}" in - build) - build - ;; - deploy) - deploy - ;; - all) - build - deploy - ;; - validate) - validate - ;; - status) - status - ;; - delete) - delete - ;; - help|--help|-h) - show_help - ;; - *) - log_error "알 수 없는 명령: $1" - show_help - exit 1 - ;; -esac From 1239b3751efd0642633e148b91c6686c29f7b03f Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Wed, 7 Jan 2026 17:34:41 +0900 Subject: [PATCH 017/528] =?UTF-8?q?fix:=20template.yaml=20CodeUri=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ServerlessFunction/template.yaml | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index a60c6a80..f419c402 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -40,7 +40,7 @@ Resources: Type: AWS::Serverless::Function Properties: FunctionName: group2-englishstudy-chat-room-handler - CodeUri: ServerlessFunction + CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.ChatRoomHandler::handleRequest Description: Handle chat room CRUD operations SnapStart: @@ -90,7 +90,7 @@ Resources: Type: AWS::Serverless::Function Properties: FunctionName: group2-englishstudy-chat-message-handler - CodeUri: ServerlessFunction + CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.ChatMessageHandler::handleRequest Description: Handle chat messages SnapStart: @@ -136,7 +136,7 @@ Resources: Type: AWS::Serverless::Function Properties: FunctionName: group2-englishstudy-chat-ai-handler - CodeUri: ServerlessFunction + CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.ChatAIHandler::handleRequest Description: Generate AI responses using Bedrock Timeout: 60 @@ -164,7 +164,7 @@ Resources: Type: AWS::Serverless::Function Properties: FunctionName: group2-englishstudy-chat-voice-handler - CodeUri: ServerlessFunction + CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.ChatVoiceHandler::handleRequest Description: Convert text to speech using Polly SnapStart: @@ -196,7 +196,7 @@ Resources: Type: AWS::Serverless::Function Properties: FunctionName: group2-englishstudy-vocab-word-handler - CodeUri: ServerlessFunction + CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.WordHandler::handleRequest Description: Handle word CRUD operations SnapStart: @@ -252,7 +252,7 @@ Resources: Type: AWS::Serverless::Function Properties: FunctionName: group2-englishstudy-vocab-userword-handler - CodeUri: ServerlessFunction + CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.UserWordHandler::handleRequest Description: Handle user word learning status SnapStart: @@ -290,7 +290,7 @@ Resources: Type: AWS::Serverless::Function Properties: FunctionName: group2-englishstudy-vocab-daily-handler - CodeUri: ServerlessFunction + CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.DailyStudyHandler::handleRequest Description: Handle daily study word assignment SnapStart: @@ -316,7 +316,7 @@ Resources: Type: AWS::Serverless::Function Properties: FunctionName: group2-englishstudy-vocab-test-handler - CodeUri: ServerlessFunction + CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.TestHandler::handleRequest Description: Handle vocabulary tests SnapStart: @@ -353,7 +353,7 @@ Resources: Type: AWS::Serverless::Function Properties: FunctionName: group2-englishstudy-vocab-stats-handler - CodeUri: ServerlessFunction + CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.StatsHandler::handleRequest Description: Handle user learning statistics SnapStart: @@ -385,7 +385,7 @@ Resources: Type: AWS::Serverless::Function Properties: FunctionName: group2-englishstudy-vocab-voice-handler - CodeUri: ServerlessFunction + CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.VoiceHandler::handleRequest Description: Convert word to speech using Polly SnapStart: @@ -559,7 +559,7 @@ Resources: Type: AWS::Serverless::Function Properties: FunctionName: group2-englishstudy-statistics-processor - CodeUri: ServerlessFunction + CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.StatisticsHandler::handleRequest Description: Process test results and update user word statistics Timeout: 60 From a529981d2afa099f14595bb82faffbe7212f5141 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Wed, 7 Jan 2026 17:53:02 +0900 Subject: [PATCH 018/528] =?UTF-8?q?refactor:=20Handler=EB=93=A4=20createRe?= =?UTF-8?q?sponse=EB=A5=BC=20ResponseUtil=EB=A1=9C=20=ED=86=B5=EC=9D=BC=20?= =?UTF-8?q?(#60)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 11개 Handler의 중복 createResponse 메서드 제거 - ResponseUtil.createResponse() static import로 대체 - 중복 Gson 인스턴스 제거 → ResponseUtil.gson() 사용 --- .../chatting/handler/ChatAIHandler.java | 16 +------------ .../chatting/handler/ChatMessageHandler.java | 19 +++------------ .../chatting/handler/ChatRoomHandler.java | 23 ++++--------------- .../chatting/handler/ChatVoiceHandler.java | 19 +++------------ .../vocabulary/handler/DailyStudyHandler.java | 16 +------------ .../vocabulary/handler/StatisticsHandler.java | 6 ++--- .../vocabulary/handler/StatsHandler.java | 16 +------------ .../vocabulary/handler/TestHandler.java | 23 ++++--------------- .../vocabulary/handler/UserWordHandler.java | 21 ++++------------- .../vocabulary/handler/VoiceHandler.java | 19 +++------------ .../vocabulary/handler/WordHandler.java | 23 ++++--------------- 11 files changed, 33 insertions(+), 168 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatAIHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatAIHandler.java index 646fa0e5..9f3bbdff 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatAIHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatAIHandler.java @@ -4,9 +4,8 @@ import com.amazonaws.services.lambda.runtime.RequestHandler; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; import com.mzc.secondproject.serverless.common.dto.ApiResponse; +import static com.mzc.secondproject.serverless.common.util.ResponseUtil.createResponse; import com.mzc.secondproject.serverless.domain.chatting.service.BedrockService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -16,7 +15,6 @@ public class ChatAIHandler implements RequestHandler { private static final Logger logger = LoggerFactory.getLogger(ChatAIHandler.class); - private static final Gson gson = new GsonBuilder().setPrettyPrinting().create(); private final BedrockService bedrockService; @@ -45,16 +43,4 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent re return createResponse(500, ApiResponse.error("Internal server error: " + e.getMessage())); } } - - private APIGatewayProxyResponseEvent createResponse(int statusCode, Object body) { - return new APIGatewayProxyResponseEvent() - .withStatusCode(statusCode) - .withHeaders(Map.of( - "Content-Type", "application/json", - "Access-Control-Allow-Origin", "*", - "Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS", - "Access-Control-Allow-Headers", "Content-Type,Authorization" - )) - .withBody(gson.toJson(body)); - } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatMessageHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatMessageHandler.java index 5a89bbc5..582b64e3 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatMessageHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatMessageHandler.java @@ -4,9 +4,9 @@ import com.amazonaws.services.lambda.runtime.RequestHandler; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; import com.mzc.secondproject.serverless.common.dto.ApiResponse; +import com.mzc.secondproject.serverless.common.util.ResponseUtil; +import static com.mzc.secondproject.serverless.common.util.ResponseUtil.createResponse; import com.mzc.secondproject.serverless.domain.chatting.model.ChatMessage; import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; import com.mzc.secondproject.serverless.domain.chatting.repository.ChatMessageRepository; @@ -24,7 +24,6 @@ public class ChatMessageHandler implements RequestHandler { private static final Logger logger = LoggerFactory.getLogger(ChatMessageHandler.class); - private static final Gson gson = new GsonBuilder().setPrettyPrinting().create(); private final ChatMessageService chatMessageService; private final ChatRoomRepository chatRoomRepository; @@ -59,7 +58,7 @@ private APIGatewayProxyResponseEvent handlePost(APIGatewayProxyRequestEvent requ } String body = request.getBody(); - Map requestBody = gson.fromJson(body, Map.class); + Map requestBody = ResponseUtil.gson().fromJson(body, Map.class); String userId = (String) requestBody.get("userId"); String content = (String) requestBody.get("content"); @@ -137,16 +136,4 @@ private APIGatewayProxyResponseEvent handleGet(APIGatewayProxyRequestEvent reque return createResponse(200, ApiResponse.success("Messages retrieved", result)); } - - private APIGatewayProxyResponseEvent createResponse(int statusCode, Object body) { - return new APIGatewayProxyResponseEvent() - .withStatusCode(statusCode) - .withHeaders(Map.of( - "Content-Type", "application/json", - "Access-Control-Allow-Origin", "*", - "Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS", - "Access-Control-Allow-Headers", "Content-Type,Authorization" - )) - .withBody(gson.toJson(body)); - } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java index c5d86e24..4cd49ad7 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java @@ -4,9 +4,9 @@ import com.amazonaws.services.lambda.runtime.RequestHandler; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; import com.mzc.secondproject.serverless.common.dto.ApiResponse; +import com.mzc.secondproject.serverless.common.util.ResponseUtil; +import static com.mzc.secondproject.serverless.common.util.ResponseUtil.createResponse; import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; import com.mzc.secondproject.serverless.domain.chatting.repository.ChatRoomRepository; import org.mindrot.jbcrypt.BCrypt; @@ -24,7 +24,6 @@ public class ChatRoomHandler implements RequestHandler { private static final Logger logger = LoggerFactory.getLogger(ChatRoomHandler.class); - private static final Gson gson = new GsonBuilder().setPrettyPrinting().create(); private final ChatRoomRepository roomRepository; @@ -80,7 +79,7 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent re private APIGatewayProxyResponseEvent createRoom(APIGatewayProxyRequestEvent request) { String body = request.getBody(); - Map requestBody = gson.fromJson(body, Map.class); + Map requestBody = ResponseUtil.gson().fromJson(body, Map.class); String name = (String) requestBody.get("name"); String description = (String) requestBody.get("description"); @@ -189,7 +188,7 @@ private APIGatewayProxyResponseEvent joinRoom(APIGatewayProxyRequestEvent reques String roomId = pathParams != null ? pathParams.get("roomId") : null; String body = request.getBody(); - Map requestBody = gson.fromJson(body, Map.class); + Map requestBody = ResponseUtil.gson().fromJson(body, Map.class); String userId = requestBody.get("userId"); String password = requestBody.get("password"); @@ -241,7 +240,7 @@ private APIGatewayProxyResponseEvent leaveRoom(APIGatewayProxyRequestEvent reque String roomId = pathParams != null ? pathParams.get("roomId") : null; String body = request.getBody(); - Map requestBody = gson.fromJson(body, Map.class); + Map requestBody = ResponseUtil.gson().fromJson(body, Map.class); String userId = requestBody.get("userId"); if (roomId == null || userId == null) { @@ -305,16 +304,4 @@ private APIGatewayProxyResponseEvent deleteRoom(APIGatewayProxyRequestEvent requ return createResponse(200, ApiResponse.success("Room deleted", null)); } - - private APIGatewayProxyResponseEvent createResponse(int statusCode, Object body) { - return new APIGatewayProxyResponseEvent() - .withStatusCode(statusCode) - .withHeaders(Map.of( - "Content-Type", "application/json", - "Access-Control-Allow-Origin", "*", - "Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS", - "Access-Control-Allow-Headers", "Content-Type,Authorization" - )) - .withBody(gson.toJson(body)); - } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatVoiceHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatVoiceHandler.java index f0f0f80d..0c68260a 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatVoiceHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatVoiceHandler.java @@ -4,9 +4,9 @@ import com.amazonaws.services.lambda.runtime.RequestHandler; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; import com.mzc.secondproject.serverless.common.dto.ApiResponse; +import com.mzc.secondproject.serverless.common.util.ResponseUtil; +import static com.mzc.secondproject.serverless.common.util.ResponseUtil.createResponse; import com.mzc.secondproject.serverless.domain.chatting.model.ChatMessage; import com.mzc.secondproject.serverless.domain.chatting.repository.ChatMessageRepository; import com.mzc.secondproject.serverless.domain.chatting.service.PollyService; @@ -20,7 +20,6 @@ public class ChatVoiceHandler implements RequestHandler { private static final Logger logger = LoggerFactory.getLogger(ChatVoiceHandler.class); - private static final Gson gson = new GsonBuilder().setPrettyPrinting().create(); private final PollyService pollyService; private final ChatMessageRepository messageRepository; @@ -40,7 +39,7 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent re } String body = request.getBody(); - Map requestBody = gson.fromJson(body, Map.class); + Map requestBody = ResponseUtil.gson().fromJson(body, Map.class); String messageId = requestBody.get("messageId"); String roomId = requestBody.get("roomId"); String voice = requestBody.getOrDefault("voice", "FEMALE"); @@ -102,16 +101,4 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent re return createResponse(500, ApiResponse.error("Internal server error: " + e.getMessage())); } } - - private APIGatewayProxyResponseEvent createResponse(int statusCode, Object body) { - return new APIGatewayProxyResponseEvent() - .withStatusCode(statusCode) - .withHeaders(Map.of( - "Content-Type", "application/json", - "Access-Control-Allow-Origin", "*", - "Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS", - "Access-Control-Allow-Headers", "Content-Type,Authorization" - )) - .withBody(gson.toJson(body)); - } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/DailyStudyHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/DailyStudyHandler.java index 8adc68b8..1fd3bd57 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/DailyStudyHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/DailyStudyHandler.java @@ -4,9 +4,8 @@ import com.amazonaws.services.lambda.runtime.RequestHandler; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; import com.mzc.secondproject.serverless.common.dto.ApiResponse; +import static com.mzc.secondproject.serverless.common.util.ResponseUtil.createResponse; import com.mzc.secondproject.serverless.domain.vocabulary.model.DailyStudy; import com.mzc.secondproject.serverless.domain.vocabulary.model.UserWord; import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; @@ -28,7 +27,6 @@ public class DailyStudyHandler implements RequestHandler { private static final Logger logger = LoggerFactory.getLogger(DailyStudyHandler.class); - private static final Gson gson = new GsonBuilder().setPrettyPrinting().create(); private static final int NEW_WORDS_COUNT = 50; private static final int REVIEW_WORDS_COUNT = 5; @@ -238,16 +236,4 @@ private APIGatewayProxyResponseEvent markWordLearned(APIGatewayProxyRequestEvent logger.info("Marked word as learned: userId={}, wordId={}", userId, wordId); return createResponse(200, ApiResponse.success("Word marked as learned", calculateProgress(updatedDailyStudy))); } - - private APIGatewayProxyResponseEvent createResponse(int statusCode, Object body) { - return new APIGatewayProxyResponseEvent() - .withStatusCode(statusCode) - .withHeaders(Map.of( - "Content-Type", "application/json", - "Access-Control-Allow-Origin", "*", - "Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS", - "Access-Control-Allow-Headers", "Content-Type,Authorization" - )) - .withBody(gson.toJson(body)); - } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatisticsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatisticsHandler.java index d3f255a1..18b101f9 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatisticsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatisticsHandler.java @@ -3,8 +3,7 @@ import com.amazonaws.services.lambda.runtime.Context; import com.amazonaws.services.lambda.runtime.RequestHandler; import com.amazonaws.services.lambda.runtime.events.SQSEvent; -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; +import com.mzc.secondproject.serverless.common.util.ResponseUtil; import com.mzc.secondproject.serverless.domain.vocabulary.model.UserWord; import com.mzc.secondproject.serverless.domain.vocabulary.repository.UserWordRepository; import org.slf4j.Logger; @@ -23,7 +22,6 @@ public class StatisticsHandler implements RequestHandler { private static final Logger logger = LoggerFactory.getLogger(StatisticsHandler.class); - private static final Gson gson = new GsonBuilder().create(); private final UserWordRepository userWordRepository; @@ -53,7 +51,7 @@ private void processMessage(SQSEvent.SQSMessage message) { String body = message.getBody(); logger.info("Processing message: {}", body); - Map testResult = gson.fromJson(body, Map.class); + Map testResult = ResponseUtil.gson().fromJson(body, Map.class); String userId = (String) testResult.get("userId"); List> results = (List>) testResult.get("results"); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatsHandler.java index dc5758b9..a1eb347e 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatsHandler.java @@ -4,9 +4,8 @@ import com.amazonaws.services.lambda.runtime.RequestHandler; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; import com.mzc.secondproject.serverless.common.dto.ApiResponse; +import static com.mzc.secondproject.serverless.common.util.ResponseUtil.createResponse; import com.mzc.secondproject.serverless.domain.vocabulary.model.DailyStudy; import com.mzc.secondproject.serverless.domain.vocabulary.model.TestResult; import com.mzc.secondproject.serverless.domain.vocabulary.model.UserWord; @@ -28,7 +27,6 @@ public class StatsHandler implements RequestHandler { private static final Logger logger = LoggerFactory.getLogger(StatsHandler.class); - private static final Gson gson = new GsonBuilder().setPrettyPrinting().create(); private final UserWordRepository userWordRepository; private final DailyStudyRepository dailyStudyRepository; @@ -325,16 +323,4 @@ private APIGatewayProxyResponseEvent getWeaknessAnalysis(APIGatewayProxyRequestE return createResponse(200, ApiResponse.success("Weakness analysis completed", result)); } - - private APIGatewayProxyResponseEvent createResponse(int statusCode, Object body) { - return new APIGatewayProxyResponseEvent() - .withStatusCode(statusCode) - .withHeaders(Map.of( - "Content-Type", "application/json", - "Access-Control-Allow-Origin", "*", - "Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS", - "Access-Control-Allow-Headers", "Content-Type,Authorization" - )) - .withBody(gson.toJson(body)); - } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/TestHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/TestHandler.java index 6f0ccbb9..09140bb1 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/TestHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/TestHandler.java @@ -4,9 +4,9 @@ import com.amazonaws.services.lambda.runtime.RequestHandler; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; import com.mzc.secondproject.serverless.common.dto.ApiResponse; +import com.mzc.secondproject.serverless.common.util.ResponseUtil; +import static com.mzc.secondproject.serverless.common.util.ResponseUtil.createResponse; import com.mzc.secondproject.serverless.domain.vocabulary.model.DailyStudy; import com.mzc.secondproject.serverless.domain.vocabulary.model.TestResult; import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; @@ -33,7 +33,6 @@ public class TestHandler implements RequestHandler { private static final Logger logger = LoggerFactory.getLogger(TestHandler.class); - private static final Gson gson = new GsonBuilder().setPrettyPrinting().create(); private static final SnsClient snsClient = SnsClient.builder().build(); private static final String TEST_RESULT_TOPIC_ARN = System.getenv("TEST_RESULT_TOPIC_ARN"); @@ -87,7 +86,7 @@ private APIGatewayProxyResponseEvent startTest(APIGatewayProxyRequestEvent reque } String body = request.getBody(); - Map requestBody = gson.fromJson(body, Map.class); + Map requestBody = ResponseUtil.gson().fromJson(body, Map.class); String testType = (String) requestBody.getOrDefault("testType", "DAILY"); String today = LocalDate.now().toString(); @@ -160,7 +159,7 @@ private APIGatewayProxyResponseEvent submitAnswer(APIGatewayProxyRequestEvent re } String body = request.getBody(); - Map requestBody = gson.fromJson(body, Map.class); + Map requestBody = ResponseUtil.gson().fromJson(body, Map.class); String testId = (String) requestBody.get("testId"); String testType = (String) requestBody.getOrDefault("testType", "DAILY"); @@ -348,7 +347,7 @@ private void publishTestResultToSns(String userId, List> res message.put("userId", userId); message.put("results", results); - String messageJson = gson.toJson(message); + String messageJson = ResponseUtil.gson().toJson(message); PublishRequest publishRequest = PublishRequest.builder() .topicArn(TEST_RESULT_TOPIC_ARN) @@ -362,16 +361,4 @@ private void publishTestResultToSns(String userId, List> res logger.error("Failed to publish test result to SNS for user: {}", userId, e); } } - - private APIGatewayProxyResponseEvent createResponse(int statusCode, Object body) { - return new APIGatewayProxyResponseEvent() - .withStatusCode(statusCode) - .withHeaders(Map.of( - "Content-Type", "application/json", - "Access-Control-Allow-Origin", "*", - "Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS", - "Access-Control-Allow-Headers", "Content-Type,Authorization" - )) - .withBody(gson.toJson(body)); - } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java index 28bf53de..c38418af 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java @@ -4,9 +4,9 @@ import com.amazonaws.services.lambda.runtime.RequestHandler; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; import com.mzc.secondproject.serverless.common.dto.ApiResponse; +import com.mzc.secondproject.serverless.common.util.ResponseUtil; +import static com.mzc.secondproject.serverless.common.util.ResponseUtil.createResponse; import com.mzc.secondproject.serverless.domain.vocabulary.model.UserWord; import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; import com.mzc.secondproject.serverless.domain.vocabulary.repository.UserWordRepository; @@ -26,7 +26,6 @@ public class UserWordHandler implements RequestHandler { private static final Logger logger = LoggerFactory.getLogger(UserWordHandler.class); - private static final Gson gson = new GsonBuilder().setPrettyPrinting().create(); private final UserWordRepository userWordRepository; private final WordRepository wordRepository; @@ -143,7 +142,7 @@ private APIGatewayProxyResponseEvent updateUserWord(APIGatewayProxyRequestEvent } String body = request.getBody(); - Map requestBody = gson.fromJson(body, Map.class); + Map requestBody = ResponseUtil.gson().fromJson(body, Map.class); // 정답/오답 여부 Boolean isCorrect = (Boolean) requestBody.get("isCorrect"); @@ -204,7 +203,7 @@ private APIGatewayProxyResponseEvent updateUserWordTag(APIGatewayProxyRequestEve } String body = request.getBody(); - Map requestBody = gson.fromJson(body, Map.class); + Map requestBody = ResponseUtil.gson().fromJson(body, Map.class); Optional optUserWord = userWordRepository.findByUserIdAndWordId(userId, wordId); UserWord userWord; @@ -356,16 +355,4 @@ private List> enrichWithWordInfo(List userWords) { return enrichedList; } - - private APIGatewayProxyResponseEvent createResponse(int statusCode, Object body) { - return new APIGatewayProxyResponseEvent() - .withStatusCode(statusCode) - .withHeaders(Map.of( - "Content-Type", "application/json", - "Access-Control-Allow-Origin", "*", - "Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS", - "Access-Control-Allow-Headers", "Content-Type,Authorization" - )) - .withBody(gson.toJson(body)); - } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/VoiceHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/VoiceHandler.java index 5936cd34..22898c15 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/VoiceHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/VoiceHandler.java @@ -4,9 +4,9 @@ import com.amazonaws.services.lambda.runtime.RequestHandler; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; import com.mzc.secondproject.serverless.common.dto.ApiResponse; +import com.mzc.secondproject.serverless.common.util.ResponseUtil; +import static com.mzc.secondproject.serverless.common.util.ResponseUtil.createResponse; import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; import com.mzc.secondproject.serverless.domain.vocabulary.repository.WordRepository; import com.mzc.secondproject.serverless.domain.vocabulary.service.PollyService; @@ -20,7 +20,6 @@ public class VoiceHandler implements RequestHandler { private static final Logger logger = LoggerFactory.getLogger(VoiceHandler.class); - private static final Gson gson = new GsonBuilder().setPrettyPrinting().create(); private final WordRepository wordRepository; private final PollyService pollyService; @@ -53,7 +52,7 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent re private APIGatewayProxyResponseEvent synthesizeSpeech(APIGatewayProxyRequestEvent request) { String body = request.getBody(); - Map requestBody = gson.fromJson(body, Map.class); + Map requestBody = ResponseUtil.gson().fromJson(body, Map.class); String wordId = (String) requestBody.get("wordId"); String voice = (String) requestBody.getOrDefault("voice", "FEMALE"); @@ -108,16 +107,4 @@ private APIGatewayProxyResponseEvent synthesizeSpeech(APIGatewayProxyRequestEven return createResponse(200, ApiResponse.success("Speech synthesized", responseData)); } - - private APIGatewayProxyResponseEvent createResponse(int statusCode, Object body) { - return new APIGatewayProxyResponseEvent() - .withStatusCode(statusCode) - .withHeaders(Map.of( - "Content-Type", "application/json", - "Access-Control-Allow-Origin", "*", - "Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS", - "Access-Control-Allow-Headers", "Content-Type,Authorization" - )) - .withBody(gson.toJson(body)); - } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/WordHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/WordHandler.java index f9919374..45747fd4 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/WordHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/WordHandler.java @@ -4,9 +4,9 @@ import com.amazonaws.services.lambda.runtime.RequestHandler; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; import com.mzc.secondproject.serverless.common.dto.ApiResponse; +import com.mzc.secondproject.serverless.common.util.ResponseUtil; +import static com.mzc.secondproject.serverless.common.util.ResponseUtil.createResponse; import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; import com.mzc.secondproject.serverless.domain.vocabulary.repository.WordRepository; import org.slf4j.Logger; @@ -23,7 +23,6 @@ public class WordHandler implements RequestHandler { private static final Logger logger = LoggerFactory.getLogger(WordHandler.class); - private static final Gson gson = new GsonBuilder().setPrettyPrinting().create(); private final WordRepository wordRepository; @@ -84,7 +83,7 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent re private APIGatewayProxyResponseEvent createWord(APIGatewayProxyRequestEvent request) { String body = request.getBody(); - Map requestBody = gson.fromJson(body, Map.class); + Map requestBody = ResponseUtil.gson().fromJson(body, Map.class); String english = (String) requestBody.get("english"); String korean = (String) requestBody.get("korean"); @@ -185,7 +184,7 @@ private APIGatewayProxyResponseEvent updateWord(APIGatewayProxyRequestEvent requ Word word = optWord.get(); String body = request.getBody(); - Map requestBody = gson.fromJson(body, Map.class); + Map requestBody = ResponseUtil.gson().fromJson(body, Map.class); if (requestBody.containsKey("english")) { word.setEnglish((String) requestBody.get("english")); @@ -235,7 +234,7 @@ private APIGatewayProxyResponseEvent deleteWord(APIGatewayProxyRequestEvent requ @SuppressWarnings("unchecked") private APIGatewayProxyResponseEvent createWordsBatch(APIGatewayProxyRequestEvent request) { String body = request.getBody(); - Map requestBody = gson.fromJson(body, Map.class); + Map requestBody = ResponseUtil.gson().fromJson(body, Map.class); List> wordsList = (List>) requestBody.get("words"); if (wordsList == null || wordsList.isEmpty()) { @@ -322,16 +321,4 @@ private APIGatewayProxyResponseEvent searchWords(APIGatewayProxyRequestEvent req return createResponse(200, ApiResponse.success("Search completed", result)); } - - private APIGatewayProxyResponseEvent createResponse(int statusCode, Object body) { - return new APIGatewayProxyResponseEvent() - .withStatusCode(statusCode) - .withHeaders(Map.of( - "Content-Type", "application/json", - "Access-Control-Allow-Origin", "*", - "Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS", - "Access-Control-Allow-Headers", "Content-Type,Authorization" - )) - .withBody(gson.toJson(body)); - } } From 3ced302a2b3d7108980b97a43b630e32b6bc9618 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 8 Jan 2026 10:15:14 +0900 Subject: [PATCH 019/528] =?UTF-8?q?remove:=20=EC=82=AD=EC=A0=9C=EB=90=9C?= =?UTF-8?q?=20deploy.sh=20=EB=B0=8F=20template.yaml=20(#62)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SAM 배포 및 템플릿 스크립트 제거 - Unified API 스택과 관련된 모든 리소스 정의 삭제 --- deploy.sh | 146 ------------ template.yaml | 599 -------------------------------------------------- 2 files changed, 745 deletions(-) delete mode 100755 deploy.sh delete mode 100644 template.yaml diff --git a/deploy.sh b/deploy.sh deleted file mode 100755 index 9df6b9ac..00000000 --- a/deploy.sh +++ /dev/null @@ -1,146 +0,0 @@ -#!/bin/bash - -# Group2 English Study - 통합 빌드 & 배포 스크립트 -# 사용법: ./deploy.sh [build|deploy|all] - -set -e - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -CHATTING_DIR="$SCRIPT_DIR/chatting" - -# 색상 정의 -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -NC='\033[0m' # No Color - -log_info() { - echo -e "${GREEN}[INFO]${NC} $1" -} - -log_warn() { - echo -e "${YELLOW}[WARN]${NC} $1" -} - -log_error() { - echo -e "${RED}[ERROR]${NC} $1" -} - -build() { - log_info "==========================================" - log_info "빌드 시작: Chat + Vocab 통합" - log_info "==========================================" - - cd "$CHATTING_DIR" - sam build - - log_info "빌드 완료!" -} - -deploy() { - log_info "==========================================" - log_info "배포 시작: group2-englishstudy-chatting" - log_info "==========================================" - - cd "$CHATTING_DIR" - sam deploy --no-confirm-changeset - - # API URL 출력 - log_info "==========================================" - log_info "배포 완료!" - API_URL=$(aws cloudformation describe-stacks \ - --stack-name group2-englishstudy-chatting \ - --profile mzc \ - --region ap-northeast-2 \ - --query 'Stacks[0].Outputs[?OutputKey==`ApiUrl`].OutputValue' \ - --output text 2>/dev/null) - - if [ -n "$API_URL" ]; then - log_info "API URL: $API_URL" - fi - log_info "==========================================" -} - -validate() { - log_info "템플릿 검증 중..." - cd "$CHATTING_DIR" - sam validate - log_info "템플릿 검증 완료!" -} - -status() { - log_info "스택 상태 확인 중..." - aws cloudformation describe-stacks \ - --stack-name group2-englishstudy-chatting \ - --profile mzc \ - --region ap-northeast-2 \ - --query 'Stacks[0].{Status:StackStatus,LastUpdated:LastUpdatedTime}' \ - --output table 2>/dev/null || log_warn "스택이 존재하지 않습니다." -} - -delete() { - log_warn "==========================================" - log_warn "스택 삭제: group2-englishstudy-chatting" - log_warn "==========================================" - read -p "정말 삭제하시겠습니까? (y/N): " confirm - if [ "$confirm" = "y" ] || [ "$confirm" = "Y" ]; then - aws cloudformation delete-stack \ - --stack-name group2-englishstudy-chatting \ - --profile mzc \ - --region ap-northeast-2 - log_info "삭제 요청 완료. 'aws cloudformation wait stack-delete-complete'로 완료 대기 가능" - else - log_info "삭제 취소됨" - fi -} - -show_help() { - echo "Group2 English Study - 빌드 & 배포 스크립트" - echo "" - echo "사용법: $0 [command]" - echo "" - echo "Commands:" - echo " build SAM 빌드만 실행" - echo " deploy SAM 배포만 실행" - echo " all 빌드 + 배포 (기본값)" - echo " validate 템플릿 검증" - echo " status 스택 상태 확인" - echo " delete 스택 삭제" - echo " help 도움말 표시" - echo "" - echo "예시:" - echo " $0 all # 빌드 후 배포" - echo " $0 build # 빌드만" - echo " $0 deploy # 배포만" -} - -# 메인 로직 -case "${1:-all}" in - build) - build - ;; - deploy) - deploy - ;; - all) - build - deploy - ;; - validate) - validate - ;; - status) - status - ;; - delete) - delete - ;; - help|--help|-h) - show_help - ;; - *) - log_error "알 수 없는 명령: $1" - show_help - exit 1 - ;; -esac diff --git a/template.yaml b/template.yaml deleted file mode 100644 index 0aac4a05..00000000 --- a/template.yaml +++ /dev/null @@ -1,599 +0,0 @@ -AWSTemplateFormatVersion: '2010-09-09' -Transform: AWS::Serverless-2016-10-31 -Description: Group2 English Study - Unified API (Chatting + Vocabulary) - -Globals: - Function: - Timeout: 30 - MemorySize: 512 - Runtime: java21 - Architectures: - - x86_64 - Environment: - Variables: - CHAT_TABLE_NAME: !Ref ChatTable - VOCAB_TABLE_NAME: !Ref VocabTable - CHAT_BUCKET_NAME: group2-englishstudy - VOCAB_BUCKET_NAME: group2-englishstudy - AWS_REGION_NAME: !Ref AWS::Region - -Resources: - ############################################# - # API Gateway (Unified) - ############################################# - - MainApi: - Type: AWS::Serverless::Api - Properties: - Name: group2-englishstudy-api - StageName: dev - Cors: - AllowMethods: "'GET,POST,PUT,DELETE,OPTIONS'" - AllowHeaders: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" - AllowOrigin: "'*'" - - ############################################# - # Chatting Lambda Functions - ############################################# - - ChatRoomFunction: - Type: AWS::Serverless::Function - Properties: - FunctionName: group2-englishstudy-chat-room-handler - CodeUri: ServerlessFunction - Handler: com.mzc.secondproject.serverless.chatting.handler.ChatRoomHandler::handleRequest - Description: Handle chat room CRUD operations - SnapStart: - ApplyOn: PublishedVersions - Policies: - - DynamoDBCrudPolicy: - TableName: !Ref ChatTable - Events: - CreateRoom: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /chat/rooms - Method: POST - GetRooms: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /chat/rooms - Method: GET - GetRoom: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /chat/rooms/{roomId} - Method: GET - DeleteRoom: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /chat/rooms/{roomId} - Method: DELETE - JoinRoom: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /chat/rooms/{roomId}/join - Method: POST - LeaveRoom: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /chat/rooms/{roomId}/leave - Method: POST - - ChatMessageFunction: - Type: AWS::Serverless::Function - Properties: - FunctionName: group2-englishstudy-chat-message-handler - CodeUri: ServerlessFunction - Handler: com.mzc.secondproject.serverless.chatting.handler.ChatMessageHandler::handleRequest - Description: Handle chat messages - SnapStart: - ApplyOn: PublishedVersions - Policies: - - DynamoDBCrudPolicy: - TableName: !Ref ChatTable - - S3CrudPolicy: - BucketName: group2-englishstudy - - Statement: - - Effect: Allow - Action: - - bedrock:InvokeModel - - bedrock:InvokeModelWithResponseStream - Resource: "*" - - Statement: - - Effect: Allow - Action: - - polly:SynthesizeSpeech - - polly:DescribeVoices - Resource: "*" - Events: - SendMessage: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /chat/rooms/{roomId}/messages - Method: POST - GetMessages: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /chat/rooms/{roomId}/messages - Method: GET - GetMessage: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /chat/rooms/{roomId}/messages/{messageId} - Method: GET - - ChatAIFunction: - Type: AWS::Serverless::Function - Properties: - FunctionName: group2-englishstudy-chat-ai-handler - CodeUri: ServerlessFunction - Handler: com.mzc.secondproject.serverless.chatting.handler.ChatAIHandler::handleRequest - Description: Generate AI responses using Bedrock - Timeout: 60 - MemorySize: 1024 - SnapStart: - ApplyOn: PublishedVersions - Policies: - - DynamoDBCrudPolicy: - TableName: !Ref ChatTable - - Statement: - - Effect: Allow - Action: - - bedrock:InvokeModel - - bedrock:InvokeModelWithResponseStream - Resource: "*" - Events: - GenerateAI: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /chat/ai/generate - Method: POST - - ChatVoiceFunction: - Type: AWS::Serverless::Function - Properties: - FunctionName: group2-englishstudy-chat-voice-handler - CodeUri: ServerlessFunction - Handler: com.mzc.secondproject.serverless.chatting.handler.ChatVoiceHandler::handleRequest - Description: Convert text to speech using Polly - SnapStart: - ApplyOn: PublishedVersions - Policies: - - DynamoDBCrudPolicy: - TableName: !Ref ChatTable - - S3CrudPolicy: - BucketName: group2-englishstudy - - Statement: - - Effect: Allow - Action: - - polly:SynthesizeSpeech - - polly:DescribeVoices - Resource: "*" - Events: - TextToSpeech: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /chat/voice/synthesize - Method: POST - - ############################################# - # Vocabulary Lambda Functions - ############################################# - - WordFunction: - Type: AWS::Serverless::Function - Properties: - FunctionName: group2-englishstudy-vocab-word-handler - CodeUri: ServerlessFunction - Handler: com.mzc.secondproject.serverless.vocabulary.handler.WordHandler::handleRequest - Description: Handle word CRUD operations - SnapStart: - ApplyOn: PublishedVersions - Policies: - - DynamoDBCrudPolicy: - TableName: !Ref VocabTable - Events: - CreateWord: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /vocab/words - Method: POST - BatchCreateWords: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /vocab/words/batch - Method: POST - SearchWords: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /vocab/words/search - Method: GET - GetWords: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /vocab/words - Method: GET - GetWord: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /vocab/words/{wordId} - Method: GET - UpdateWord: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /vocab/words/{wordId} - Method: PUT - DeleteWord: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /vocab/words/{wordId} - Method: DELETE - - UserWordFunction: - Type: AWS::Serverless::Function - Properties: - FunctionName: group2-englishstudy-vocab-userword-handler - CodeUri: ServerlessFunction - Handler: com.mzc.secondproject.serverless.vocabulary.handler.UserWordHandler::handleRequest - Description: Handle user word learning status - SnapStart: - ApplyOn: PublishedVersions - Policies: - - DynamoDBCrudPolicy: - TableName: !Ref VocabTable - Events: - GetUserWords: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /vocab/users/{userId}/words - Method: GET - GetUserWord: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /vocab/users/{userId}/words/{wordId} - Method: GET - UpdateUserWord: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /vocab/users/{userId}/words/{wordId} - Method: PUT - UpdateUserWordTag: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /vocab/users/{userId}/words/{wordId}/tag - Method: PUT - - DailyStudyFunction: - Type: AWS::Serverless::Function - Properties: - FunctionName: group2-englishstudy-vocab-daily-handler - CodeUri: ServerlessFunction - Handler: com.mzc.secondproject.serverless.vocabulary.handler.DailyStudyHandler::handleRequest - Description: Handle daily study word assignment - SnapStart: - ApplyOn: PublishedVersions - Policies: - - DynamoDBCrudPolicy: - TableName: !Ref VocabTable - Events: - GetDailyWords: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /vocab/daily/{userId} - Method: GET - MarkWordLearned: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /vocab/daily/{userId}/words/{wordId}/learned - Method: POST - - TestFunction: - Type: AWS::Serverless::Function - Properties: - FunctionName: group2-englishstudy-vocab-test-handler - CodeUri: ServerlessFunction - Handler: com.mzc.secondproject.serverless.vocabulary.handler.TestHandler::handleRequest - Description: Handle vocabulary tests - SnapStart: - ApplyOn: PublishedVersions - Environment: - Variables: - TEST_RESULT_TOPIC_ARN: !Ref TestResultTopic - Policies: - - DynamoDBCrudPolicy: - TableName: !Ref VocabTable - - SNSPublishMessagePolicy: - TopicName: !GetAtt TestResultTopic.TopicName - Events: - StartTest: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /vocab/test/{userId}/start - Method: POST - SubmitAnswer: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /vocab/test/{userId}/submit - Method: POST - GetTestResult: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /vocab/test/{userId}/results - Method: GET - - StatsFunction: - Type: AWS::Serverless::Function - Properties: - FunctionName: group2-englishstudy-vocab-stats-handler - CodeUri: ServerlessFunction - Handler: com.mzc.secondproject.serverless.vocabulary.handler.StatsHandler::handleRequest - Description: Handle user learning statistics - SnapStart: - ApplyOn: PublishedVersions - Policies: - - DynamoDBCrudPolicy: - TableName: !Ref VocabTable - Events: - GetStats: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /vocab/stats/{userId} - Method: GET - GetDailyStats: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /vocab/stats/{userId}/daily - Method: GET - GetWeaknessAnalysis: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /vocab/stats/{userId}/weakness - Method: GET - - VocabVoiceFunction: - Type: AWS::Serverless::Function - Properties: - FunctionName: group2-englishstudy-vocab-voice-handler - CodeUri: ServerlessFunction - Handler: com.mzc.secondproject.serverless.vocabulary.handler.VoiceHandler::handleRequest - Description: Convert word to speech using Polly - SnapStart: - ApplyOn: PublishedVersions - Policies: - - DynamoDBCrudPolicy: - TableName: !Ref VocabTable - - S3CrudPolicy: - BucketName: group2-englishstudy - - Statement: - - Effect: Allow - Action: - - polly:SynthesizeSpeech - - polly:DescribeVoices - Resource: "*" - Events: - TextToSpeech: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /vocab/voice/synthesize - Method: POST - - ############################################# - # DynamoDB Tables - ############################################# - - ChatTable: - Type: AWS::DynamoDB::Table - Properties: - TableName: group2-englishstudy-chat - BillingMode: PAY_PER_REQUEST - AttributeDefinitions: - - AttributeName: PK - AttributeType: S - - AttributeName: SK - AttributeType: S - - AttributeName: GSI1PK - AttributeType: S - - AttributeName: GSI1SK - AttributeType: S - - AttributeName: GSI2PK - AttributeType: S - - AttributeName: GSI2SK - AttributeType: S - KeySchema: - - AttributeName: PK - KeyType: HASH - - AttributeName: SK - KeyType: RANGE - GlobalSecondaryIndexes: - - IndexName: GSI1 - KeySchema: - - AttributeName: GSI1PK - KeyType: HASH - - AttributeName: GSI1SK - KeyType: RANGE - Projection: - ProjectionType: ALL - - IndexName: GSI2 - KeySchema: - - AttributeName: GSI2PK - KeyType: HASH - - AttributeName: GSI2SK - KeyType: RANGE - Projection: - ProjectionType: ALL - TimeToLiveSpecification: - AttributeName: ttl - Enabled: true - - VocabTable: - Type: AWS::DynamoDB::Table - Properties: - TableName: group2-englishstudy-vocab - BillingMode: PAY_PER_REQUEST - AttributeDefinitions: - - AttributeName: PK - AttributeType: S - - AttributeName: SK - AttributeType: S - - AttributeName: GSI1PK - AttributeType: S - - AttributeName: GSI1SK - AttributeType: S - - AttributeName: GSI2PK - AttributeType: S - - AttributeName: GSI2SK - AttributeType: S - KeySchema: - - AttributeName: PK - KeyType: HASH - - AttributeName: SK - KeyType: RANGE - GlobalSecondaryIndexes: - - IndexName: GSI1 - KeySchema: - - AttributeName: GSI1PK - KeyType: HASH - - AttributeName: GSI1SK - KeyType: RANGE - Projection: - ProjectionType: ALL - - IndexName: GSI2 - KeySchema: - - AttributeName: GSI2PK - KeyType: HASH - - AttributeName: GSI2SK - KeyType: RANGE - Projection: - ProjectionType: ALL - TimeToLiveSpecification: - AttributeName: ttl - Enabled: true - - ############################################# - # SNS / SQS for Async Statistics Processing - ############################################# - - # SNS Topic - 시험 결과 이벤트 발행 - TestResultTopic: - Type: AWS::SNS::Topic - Properties: - TopicName: group2-englishstudy-test-result-topic - - # SQS Dead Letter Queue - 실패한 메시지 보관 - StatisticsDeadLetterQueue: - Type: AWS::SQS::Queue - Properties: - QueueName: group2-englishstudy-statistics-dlq - MessageRetentionPeriod: 1209600 # 14일 - - # SQS Queue - 통계 처리용 - StatisticsQueue: - Type: AWS::SQS::Queue - Properties: - QueueName: group2-englishstudy-statistics-queue - VisibilityTimeout: 60 - RedrivePolicy: - deadLetterTargetArn: !GetAtt StatisticsDeadLetterQueue.Arn - maxReceiveCount: 3 - - # SQS Queue Policy - SNS에서 메시지 수신 허용 - StatisticsQueuePolicy: - Type: AWS::SQS::QueuePolicy - Properties: - Queues: - - !Ref StatisticsQueue - PolicyDocument: - Statement: - - Effect: Allow - Principal: - Service: sns.amazonaws.com - Action: sqs:SendMessage - Resource: !GetAtt StatisticsQueue.Arn - Condition: - ArnEquals: - aws:SourceArn: !Ref TestResultTopic - - # SNS → SQS 구독 - StatisticsQueueSubscription: - Type: AWS::SNS::Subscription - Properties: - Protocol: sqs - TopicArn: !Ref TestResultTopic - Endpoint: !GetAtt StatisticsQueue.Arn - RawMessageDelivery: true - - # Statistics Processor Lambda - SQS에서 메시지 소비하여 통계 업데이트 - StatisticsProcessorFunction: - Type: AWS::Serverless::Function - Properties: - FunctionName: group2-englishstudy-statistics-processor - CodeUri: ServerlessFunction - Handler: com.mzc.secondproject.serverless.vocabulary.handler.StatisticsHandler::handleRequest - Description: Process test results and update user word statistics - Timeout: 60 - SnapStart: - ApplyOn: PublishedVersions - Policies: - - DynamoDBCrudPolicy: - TableName: !Ref VocabTable - - SQSPollerPolicy: - QueueName: !GetAtt StatisticsQueue.QueueName - Events: - SQSEvent: - Type: SQS - Properties: - Queue: !GetAtt StatisticsQueue.Arn - BatchSize: 10 - -############################################# -# Outputs -############################################# - -Outputs: - ApiUrl: - Description: Unified API Gateway endpoint URL - Value: !Sub 'https://${MainApi}.execute-api.${AWS::Region}.amazonaws.com/dev/' - - ChatTableName: - Description: Chat DynamoDB Table Name - Value: !Ref ChatTable - - VocabTableName: - Description: Vocab DynamoDB Table Name - Value: !Ref VocabTable - - BucketName: - Description: S3 Bucket Name - Value: group2-englishstudy From 0101a1b1bf3f3dedcae9d39cb23805f0c391b552 Mon Sep 17 00:00:00 2001 From: DDING JOO Date: Thu, 8 Jan 2026 11:33:16 +0900 Subject: [PATCH 020/528] =?UTF-8?q?feat(test):=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=ED=99=98=EA=B2=BD=20=EC=84=A4=EC=A0=95=20refs=20#6?= =?UTF-8?q?3=20(#70)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * build: Groovy 플러그인 및 Spock 테스트 프레임워크 의존성 추가 * test: Spock 샘플 테스트 및 테스트 디렉토리 구조 추가 * build: JaCoCo 테스트 커버리지 플러그인 및 리포트 설정 추가 (#69) --- ServerlessFunction/build.gradle | 19 ++++++++++ .../serverless/SampleSpec.groovy | 38 +++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/SampleSpec.groovy diff --git a/ServerlessFunction/build.gradle b/ServerlessFunction/build.gradle index 0307538c..c2787bd2 100644 --- a/ServerlessFunction/build.gradle +++ b/ServerlessFunction/build.gradle @@ -1,5 +1,7 @@ plugins { id 'java' + id 'groovy' + id 'jacoco' } group = 'com.mzc.secondproject.serverless' @@ -48,10 +50,27 @@ dependencies { // Testing testImplementation 'org.junit.jupiter:junit-jupiter:5.10.1' testImplementation 'org.mockito:mockito-core:5.8.0' + + // Groovy & Spock + testImplementation 'org.apache.groovy:groovy-all:4.0.15' + testImplementation 'org.spockframework:spock-core:2.4-M4-groovy-4.0' } test { useJUnitPlatform() + finalizedBy jacocoTestReport +} + +jacoco { + toolVersion = "0.8.11" +} + +jacocoTestReport { + dependsOn test + reports { + xml.required = true + html.required = true + } } task buildZip(type: Zip) { diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/SampleSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/SampleSpec.groovy new file mode 100644 index 00000000..e79f76ba --- /dev/null +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/SampleSpec.groovy @@ -0,0 +1,38 @@ +package com.mzc.secondproject.serverless + +import spock.lang.Specification +import spock.lang.Subject + +/** + * Spock 테스트 환경 설정 확인을 위한 샘플 테스트 + */ +class SampleSpec extends Specification { + + def "Spock 테스트 환경이 정상적으로 설정되었는지 확인"() { + expect: + 1 + 1 == 2 + } + + def "given-when-then 블록 테스트"() { + given: "두 개의 숫자" + def a = 10 + def b = 20 + + when: "두 숫자를 더하면" + def result = a + b + + then: "결과는 30이다" + result == 30 + } + + def "where 블록을 이용한 파라미터화 테스트"() { + expect: + Math.max(a, b) == max + + where: + a | b || max + 1 | 2 || 2 + 5 | 3 || 5 + 10 | 10 || 10 + } +} From 0bf903679c045ac70525b87c8cb5805a63044584 Mon Sep 17 00:00:00 2001 From: DDING JOO Date: Thu, 8 Jan 2026 12:07:07 +0900 Subject: [PATCH 021/528] =?UTF-8?q?[Story=20#72]=20=EC=A4=91=EB=B3=B5=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=A0=9C=EA=B1=B0=20(#89)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: CursorUtil 클래스 추출 (#73) (#86) * feat: CursorUtil 클래스 추가 - 페이지네이션 커서 인코딩/디코딩 유틸리티 * refactor: ChatRoomRepository에서 CursorUtil 사용하도록 변경 * refactor: ChatMessageRepository에서 CursorUtil 사용하도록 변경 * refactor: WordRepository에서 CursorUtil 사용하도록 변경 * refactor: UserWordRepository에서 CursorUtil 사용하도록 변경 * refactor: DailyStudyRepository에서 CursorUtil 사용하도록 변경 * refactor: TestResultRepository에서 CursorUtil 사용하도록 변경 * [Task #74] PaginatedResult 제네릭 클래스로 Inner Page 클래스 통합 (#87) * feat: PaginatedResult 제네릭 클래스 추가 * refactor: ChatRoomRepository에서 RoomPage를 PaginatedResult로 대체 * refactor: ChatRoomHandler에서 PaginatedResult 사용 * refactor: ChatMessageRepository에서 MessagePage를 PaginatedResult로 대체 * refactor: ChatMessageService에서 PaginatedResult 사용 * refactor: ChatMessageHandler에서 PaginatedResult 사용 * refactor: WordRepository에서 WordPage를 PaginatedResult로 대체 * refactor: WordHandler에서 PaginatedResult 사용 * refactor: UserWordRepository에서 UserWordPage를 PaginatedResult로 대체 * refactor: UserWordHandler에서 PaginatedResult 사용 * refactor: DailyStudyRepository에서 DailyStudyPage를 PaginatedResult로 대체 * refactor: DailyStudyHandler에서 PaginatedResult 사용 * refactor: TestResultRepository에서 TestResultPage를 PaginatedResult로 대체 * refactor: TestHandler에서 PaginatedResult 사용 * refactor: StatsHandler에서 PaginatedResult 사용 * [Task #75] Polly 서비스 통합 (#88) * refactor: VoiceHandler에서 공통 PollyService 사용 * refactor: ChatVoiceHandler에서 공통 PollyService 사용 * refactor: vocabulary 도메인 PollyService 삭제 (공통 서비스로 통합) * refactor: chatting 도메인 PollyService 삭제 (공통 서비스로 통합) --- .../common/dto/PaginatedResult.java | 32 ++++ .../serverless/common/util/CursorUtil.java | 73 ++++++++ .../chatting/handler/ChatMessageHandler.java | 5 +- .../chatting/handler/ChatRoomHandler.java | 5 +- .../chatting/handler/ChatVoiceHandler.java | 9 +- .../repository/ChatMessageRepository.java | 85 ++------- .../repository/ChatRoomRepository.java | 83 ++------- .../chatting/service/ChatMessageService.java | 5 +- .../domain/chatting/service/PollyService.java | 167 ------------------ .../vocabulary/handler/DailyStudyHandler.java | 13 +- .../vocabulary/handler/StatsHandler.java | 23 +-- .../vocabulary/handler/TestHandler.java | 9 +- .../vocabulary/handler/UserWordHandler.java | 5 +- .../vocabulary/handler/VoiceHandler.java | 7 +- .../vocabulary/handler/WordHandler.java | 9 +- .../repository/DailyStudyRepository.java | 67 +------ .../repository/TestResultRepository.java | 68 +------ .../repository/UserWordRepository.java | 100 +++-------- .../vocabulary/repository/WordRepository.java | 85 ++------- .../vocabulary/service/PollyService.java | 160 ----------------- 20 files changed, 229 insertions(+), 781 deletions(-) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/PaginatedResult.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/CursorUtil.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/PollyService.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/PollyService.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/PaginatedResult.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/PaginatedResult.java new file mode 100644 index 00000000..c6105258 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/PaginatedResult.java @@ -0,0 +1,32 @@ +package com.mzc.secondproject.serverless.common.dto; + +import java.util.List; + +/** + * 페이지네이션 결과를 담는 제네릭 클래스 + * 모든 Repository에서 공통으로 사용 + * + * @param 결과 아이템 타입 + */ +public class PaginatedResult { + + private final List items; + private final String nextCursor; + + public PaginatedResult(List items, String nextCursor) { + this.items = items; + this.nextCursor = nextCursor; + } + + public List getItems() { + return items; + } + + public String getNextCursor() { + return nextCursor; + } + + public boolean hasMore() { + return nextCursor != null; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/CursorUtil.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/CursorUtil.java new file mode 100644 index 00000000..6c710259 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/CursorUtil.java @@ -0,0 +1,73 @@ +package com.mzc.secondproject.serverless.common.util; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; + +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; + +/** + * DynamoDB 페이지네이션 커서 유틸리티 + * - lastEvaluatedKey를 Base64 인코딩/디코딩하여 커서로 사용 + */ +public class CursorUtil { + + private static final Logger logger = LoggerFactory.getLogger(CursorUtil.class); + + private CursorUtil() { + // 유틸리티 클래스 - 인스턴스화 방지 + } + + /** + * DynamoDB lastEvaluatedKey를 Base64 인코딩된 커서로 변환 + * + * @param lastEvaluatedKey DynamoDB 쿼리 결과의 lastEvaluatedKey + * @return Base64 URL-safe 인코딩된 커서 문자열, 또는 null (더 이상 페이지가 없는 경우) + */ + public static String encode(Map lastEvaluatedKey) { + if (lastEvaluatedKey == null || lastEvaluatedKey.isEmpty()) { + return null; + } + + StringBuilder sb = new StringBuilder(); + for (Map.Entry entry : lastEvaluatedKey.entrySet()) { + if (sb.length() > 0) { + sb.append("|"); + } + sb.append(entry.getKey()).append("=").append(entry.getValue().s()); + } + + return Base64.getUrlEncoder().encodeToString(sb.toString().getBytes()); + } + + /** + * Base64 인코딩된 커서를 DynamoDB exclusiveStartKey로 변환 + * + * @param cursor Base64 URL-safe 인코딩된 커서 문자열 + * @return DynamoDB exclusiveStartKey로 사용할 Map, 또는 null (잘못된 커서인 경우) + */ + public static Map decode(String cursor) { + if (cursor == null || cursor.isEmpty()) { + return null; + } + + try { + String decoded = new String(Base64.getUrlDecoder().decode(cursor)); + Map result = new HashMap<>(); + + for (String pair : decoded.split("\\|")) { + String[] kv = pair.split("=", 2); + if (kv.length == 2) { + result.put(kv[0], AttributeValue.builder().s(kv[1]).build()); + } + } + + return result.isEmpty() ? null : result; + } catch (Exception e) { + logger.error("Failed to decode cursor: {}", cursor, e); + return null; + } + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatMessageHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatMessageHandler.java index 582b64e3..c6f06930 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatMessageHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatMessageHandler.java @@ -5,6 +5,7 @@ import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; import com.mzc.secondproject.serverless.common.dto.ApiResponse; +import com.mzc.secondproject.serverless.common.dto.PaginatedResult; import com.mzc.secondproject.serverless.common.util.ResponseUtil; import static com.mzc.secondproject.serverless.common.util.ResponseUtil.createResponse; import com.mzc.secondproject.serverless.domain.chatting.model.ChatMessage; @@ -127,10 +128,10 @@ private APIGatewayProxyResponseEvent handleGet(APIGatewayProxyRequestEvent reque } // 메시지 목록 조회 (최신순, 페이지네이션) - ChatMessageRepository.MessagePage messagePage = chatMessageService.getMessagesByRoomWithPagination(roomId, limit, cursor); + PaginatedResult messagePage = chatMessageService.getMessagesByRoomWithPagination(roomId, limit, cursor); Map result = new HashMap<>(); - result.put("messages", messagePage.getMessages()); + result.put("messages", messagePage.getItems()); result.put("nextCursor", messagePage.getNextCursor()); result.put("hasMore", messagePage.hasMore()); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java index 4cd49ad7..83646b3c 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java @@ -5,6 +5,7 @@ import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; import com.mzc.secondproject.serverless.common.dto.ApiResponse; +import com.mzc.secondproject.serverless.common.dto.PaginatedResult; import com.mzc.secondproject.serverless.common.util.ResponseUtil; import static com.mzc.secondproject.serverless.common.util.ResponseUtil.createResponse; import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; @@ -137,14 +138,14 @@ private APIGatewayProxyResponseEvent getRooms(APIGatewayProxyRequestEvent reques limit = Math.min(Integer.parseInt(queryParams.get("limit")), 20); // 최대 20 } - ChatRoomRepository.RoomPage roomPage; + PaginatedResult roomPage; if (level != null && !level.isEmpty()) { roomPage = roomRepository.findByLevelWithPagination(level, limit, cursor); } else { roomPage = roomRepository.findAllWithPagination(limit, cursor); } - List rooms = roomPage.getRooms(); + List rooms = roomPage.getItems(); // "참여중" 필터 - userId가 memberIds에 포함된 방만 if ("true".equals(joined) && userId != null) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatVoiceHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatVoiceHandler.java index 0c68260a..1124d2a2 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatVoiceHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatVoiceHandler.java @@ -9,8 +9,8 @@ import static com.mzc.secondproject.serverless.common.util.ResponseUtil.createResponse; import com.mzc.secondproject.serverless.domain.chatting.model.ChatMessage; import com.mzc.secondproject.serverless.domain.chatting.repository.ChatMessageRepository; -import com.mzc.secondproject.serverless.domain.chatting.service.PollyService; -import com.mzc.secondproject.serverless.domain.chatting.service.PollyService.VoiceSynthesisResult; +import com.mzc.secondproject.serverless.common.service.PollyService; +import com.mzc.secondproject.serverless.common.service.PollyService.VoiceSynthesisResult; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -20,12 +20,13 @@ public class ChatVoiceHandler implements RequestHandler { private static final Logger logger = LoggerFactory.getLogger(ChatVoiceHandler.class); + private static final String BUCKET_NAME = System.getenv("CHAT_BUCKET_NAME"); private final PollyService pollyService; private final ChatMessageRepository messageRepository; public ChatVoiceHandler() { - this.pollyService = new PollyService(); + this.pollyService = new PollyService(BUCKET_NAME, "voice/"); this.messageRepository = new ChatMessageRepository(); } @@ -73,7 +74,7 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent re cached = true; } else { // 캐시 미스: Polly 변환 → S3 저장 → DynamoDB 업데이트 - VoiceSynthesisResult result = pollyService.synthesizeSpeechForMessage( + VoiceSynthesisResult result = pollyService.synthesizeSpeech( messageId, message.getContent(), voice); // DynamoDB에 S3 키 저장 diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatMessageRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatMessageRepository.java index 053c1dc3..445a0d23 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatMessageRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatMessageRepository.java @@ -13,8 +13,9 @@ import software.amazon.awssdk.services.dynamodb.DynamoDbClient; import software.amazon.awssdk.services.dynamodb.model.AttributeValue; -import java.util.Base64; -import java.util.HashMap; +import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.common.util.CursorUtil; + import java.util.List; import java.util.Map; import java.util.Optional; @@ -64,7 +65,7 @@ public Optional findByRoomIdAndMessageId(String roomId, String mess * @param cursor Base64 인코딩된 커서 (무한스크롤용) * @return 메시지 목록과 다음 페이지 커서 */ - public MessagePage findByRoomIdWithPagination(String roomId, int limit, String cursor) { + public PaginatedResult findByRoomIdWithPagination(String roomId, int limit, String cursor) { QueryConditional queryConditional = QueryConditional .sortBeginsWith(Key.builder() .partitionValue("ROOM#" + roomId) @@ -78,7 +79,7 @@ public MessagePage findByRoomIdWithPagination(String roomId, int limit, String c // 커서 기반 페이지네이션 (Base64 디코딩) if (cursor != null && !cursor.isEmpty()) { - Map exclusiveStartKey = decodeCursor(cursor); + Map exclusiveStartKey = CursorUtil.decode(cursor); if (exclusiveStartKey != null) { requestBuilder.exclusiveStartKey(exclusiveStartKey); } @@ -88,54 +89,15 @@ public MessagePage findByRoomIdWithPagination(String roomId, int limit, String c List messages = page.items(); // 다음 페이지 커서 (Base64 인코딩) - String nextCursor = encodeCursor(page.lastEvaluatedKey()); - - return new MessagePage(messages, nextCursor); - } - - /** - * 커서 인코딩 (lastEvaluatedKey -> Base64) - */ - private String encodeCursor(Map lastEvaluatedKey) { - if (lastEvaluatedKey == null || lastEvaluatedKey.isEmpty()) { - return null; - } - - StringBuilder sb = new StringBuilder(); - for (Map.Entry entry : lastEvaluatedKey.entrySet()) { - if (sb.length() > 0) sb.append("|"); - sb.append(entry.getKey()).append("=").append(entry.getValue().s()); - } - - return Base64.getUrlEncoder().encodeToString(sb.toString().getBytes()); - } - - /** - * 커서 디코딩 (Base64 -> exclusiveStartKey) - */ - private Map decodeCursor(String cursor) { - try { - String decoded = new String(Base64.getUrlDecoder().decode(cursor)); - Map result = new HashMap<>(); - - for (String pair : decoded.split("\\|")) { - String[] kv = pair.split("=", 2); - if (kv.length == 2) { - result.put(kv[0], AttributeValue.builder().s(kv[1]).build()); - } - } + String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); - return result.isEmpty() ? null : result; - } catch (Exception e) { - logger.error("Failed to decode cursor: {}", cursor, e); - return null; - } + return new PaginatedResult<>(messages, nextCursor); } /** * 사용자별 메시지 조회 - 페이지네이션 지원 (OOM 방지) */ - public MessagePage findByUserIdWithPagination(String userId, int limit, String cursor) { + public PaginatedResult findByUserIdWithPagination(String userId, int limit, String cursor) { QueryConditional queryConditional = QueryConditional .keyEqualTo(Key.builder().partitionValue("USER#" + userId).build()); @@ -145,7 +107,7 @@ public MessagePage findByUserIdWithPagination(String userId, int limit, String c .limit(limit); if (cursor != null && !cursor.isEmpty()) { - Map exclusiveStartKey = decodeCursor(cursor); + Map exclusiveStartKey = CursorUtil.decode(cursor); if (exclusiveStartKey != null) { requestBuilder.exclusiveStartKey(exclusiveStartKey); } @@ -156,32 +118,7 @@ public MessagePage findByUserIdWithPagination(String userId, int limit, String c .iterator() .next(); - String nextCursor = encodeCursor(page.lastEvaluatedKey()); - return new MessagePage(page.items(), nextCursor); - } - - /** - * 페이지네이션 결과 클래스 - */ - public static class MessagePage { - private final List messages; - private final String nextCursor; - - public MessagePage(List messages, String nextCursor) { - this.messages = messages; - this.nextCursor = nextCursor; - } - - public List getMessages() { - return messages; - } - - public String getNextCursor() { - return nextCursor; - } - - public boolean hasMore() { - return nextCursor != null; - } + String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); + return new PaginatedResult<>(page.items(), nextCursor); } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatRoomRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatRoomRepository.java index eaa49764..f81ca775 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatRoomRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatRoomRepository.java @@ -15,7 +15,9 @@ import software.amazon.awssdk.services.dynamodb.model.AttributeValue; import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest; -import java.util.Base64; +import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.common.util.CursorUtil; + import java.util.HashMap; import java.util.List; import java.util.Map; @@ -60,7 +62,7 @@ public Optional findById(String roomId) { * @param cursor Base64 인코딩된 커서 (무한스크롤용) * @return 채팅방 목록과 다음 페이지 커서 */ - public RoomPage findAllWithPagination(int limit, String cursor) { + public PaginatedResult findAllWithPagination(int limit, String cursor) { QueryConditional queryConditional = QueryConditional .keyEqualTo(Key.builder().partitionValue("ROOMS").build()); @@ -71,7 +73,7 @@ public RoomPage findAllWithPagination(int limit, String cursor) { // 커서 기반 페이지네이션 (Base64 디코딩) if (cursor != null && !cursor.isEmpty()) { - Map exclusiveStartKey = decodeCursor(cursor); + Map exclusiveStartKey = CursorUtil.decode(cursor); if (exclusiveStartKey != null) { requestBuilder.exclusiveStartKey(exclusiveStartKey); } @@ -82,15 +84,15 @@ public RoomPage findAllWithPagination(int limit, String cursor) { List rooms = page.items(); // 다음 페이지 커서 (Base64 인코딩) - String nextCursor = encodeCursor(page.lastEvaluatedKey()); + String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); - return new RoomPage(rooms, nextCursor); + return new PaginatedResult<>(rooms, nextCursor); } /** * 레벨별 채팅방 조회 - 최신순, 페이지네이션 지원 */ - public RoomPage findByLevelWithPagination(String level, int limit, String cursor) { + public PaginatedResult findByLevelWithPagination(String level, int limit, String cursor) { QueryConditional queryConditional = QueryConditional .sortBeginsWith(Key.builder() .partitionValue("ROOMS") @@ -103,7 +105,7 @@ public RoomPage findByLevelWithPagination(String level, int limit, String cursor .limit(limit); if (cursor != null && !cursor.isEmpty()) { - Map exclusiveStartKey = decodeCursor(cursor); + Map exclusiveStartKey = CursorUtil.decode(cursor); if (exclusiveStartKey != null) { requestBuilder.exclusiveStartKey(exclusiveStartKey); } @@ -113,48 +115,9 @@ public RoomPage findByLevelWithPagination(String level, int limit, String cursor Page page = gsi1.query(requestBuilder.build()).iterator().next(); List rooms = page.items(); - String nextCursor = encodeCursor(page.lastEvaluatedKey()); - - return new RoomPage(rooms, nextCursor); - } - - /** - * 커서 인코딩 (lastEvaluatedKey -> Base64) - */ - private String encodeCursor(Map lastEvaluatedKey) { - if (lastEvaluatedKey == null || lastEvaluatedKey.isEmpty()) { - return null; - } - - StringBuilder sb = new StringBuilder(); - for (Map.Entry entry : lastEvaluatedKey.entrySet()) { - if (sb.length() > 0) sb.append("|"); - sb.append(entry.getKey()).append("=").append(entry.getValue().s()); - } + String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); - return Base64.getUrlEncoder().encodeToString(sb.toString().getBytes()); - } - - /** - * 커서 디코딩 (Base64 -> exclusiveStartKey) - */ - private Map decodeCursor(String cursor) { - try { - String decoded = new String(Base64.getUrlDecoder().decode(cursor)); - Map result = new HashMap<>(); - - for (String pair : decoded.split("\\|")) { - String[] kv = pair.split("=", 2); - if (kv.length == 2) { - result.put(kv[0], AttributeValue.builder().s(kv[1]).build()); - } - } - - return result.isEmpty() ? null : result; - } catch (Exception e) { - logger.error("Failed to decode cursor: {}", cursor, e); - return null; - } + return new PaginatedResult<>(rooms, nextCursor); } public void delete(String roomId) { @@ -189,28 +152,4 @@ public void updateLastMessageAt(String roomId, String timestamp) { logger.info("Updated lastMessageAt for room: {}", roomId); } - /** - * 페이지네이션 결과 클래스 - */ - public static class RoomPage { - private final List rooms; - private final String nextCursor; - - public RoomPage(List rooms, String nextCursor) { - this.rooms = rooms; - this.nextCursor = nextCursor; - } - - public List getRooms() { - return rooms; - } - - public String getNextCursor() { - return nextCursor; - } - - public boolean hasMore() { - return nextCursor != null; - } - } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatMessageService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatMessageService.java index 11bd7af4..ec6f1075 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatMessageService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatMessageService.java @@ -1,5 +1,6 @@ package com.mzc.secondproject.serverless.domain.chatting.service; +import com.mzc.secondproject.serverless.common.dto.PaginatedResult; import com.mzc.secondproject.serverless.domain.chatting.model.ChatMessage; import com.mzc.secondproject.serverless.domain.chatting.repository.ChatMessageRepository; import org.slf4j.Logger; @@ -28,12 +29,12 @@ public Optional getMessage(String roomId, String messageId) { return repository.findByRoomIdAndMessageId(roomId, messageId); } - public ChatMessageRepository.MessagePage getMessagesByRoomWithPagination(String roomId, int limit, String cursor) { + public PaginatedResult getMessagesByRoomWithPagination(String roomId, int limit, String cursor) { logger.info("Getting messages for room: {} with limit: {}", roomId, limit); return repository.findByRoomIdWithPagination(roomId, limit, cursor); } - public ChatMessageRepository.MessagePage getMessagesByUserWithPagination(String userId, int limit, String cursor) { + public PaginatedResult getMessagesByUserWithPagination(String userId, int limit, String cursor) { logger.info("Getting messages for user: {} with limit: {}", userId, limit); return repository.findByUserIdWithPagination(userId, limit, cursor); } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/PollyService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/PollyService.java deleted file mode 100644 index 8e353ef4..00000000 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/PollyService.java +++ /dev/null @@ -1,167 +0,0 @@ -package com.mzc.secondproject.serverless.domain.chatting.service; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import software.amazon.awssdk.core.sync.RequestBody; -import software.amazon.awssdk.services.polly.PollyClient; -import software.amazon.awssdk.services.polly.model.OutputFormat; -import software.amazon.awssdk.services.polly.model.SynthesizeSpeechRequest; -import software.amazon.awssdk.services.polly.model.VoiceId; -import software.amazon.awssdk.services.s3.S3Client; -import software.amazon.awssdk.services.s3.model.PutObjectRequest; -import software.amazon.awssdk.services.s3.presigner.S3Presigner; -import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; -import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest; -import software.amazon.awssdk.services.s3.model.GetObjectRequest; - -import software.amazon.awssdk.services.s3.model.HeadObjectRequest; -import software.amazon.awssdk.services.s3.model.NoSuchKeyException; - -import java.io.ByteArrayOutputStream; -import java.io.InputStream; -import java.time.Duration; - -public class PollyService { - - private static final Logger logger = LoggerFactory.getLogger(PollyService.class); - - private final PollyClient pollyClient; - private final S3Client s3Client; - private final S3Presigner s3Presigner; - private final String bucketName; - - public PollyService() { - this.pollyClient = PollyClient.builder().build(); - this.s3Client = S3Client.builder().build(); - this.s3Presigner = S3Presigner.builder().build(); - this.bucketName = System.getenv("CHAT_BUCKET_NAME"); - } - - /** - * 메시지 ID 기반으로 음성 합성 (캐시 지원) - * S3에 파일이 있으면 바로 URL 반환, 없으면 Polly 변환 후 저장 - */ - public VoiceSynthesisResult synthesizeSpeechForMessage(String messageId, String text, String voice) { - String s3Key = generateS3Key(messageId, voice); - - // 캐시 확인: S3에 이미 존재하는지 체크 - if (existsInS3(s3Key)) { - logger.info("Cache hit: {}", s3Key); - String presignedUrl = getPresignedUrl(s3Key); - return new VoiceSynthesisResult(s3Key, presignedUrl, true); - } - - // 캐시 미스: Polly 변환 후 S3 저장 - logger.info("Cache miss: synthesizing and saving to {}", s3Key); - synthesizeAndSave(text, voice, s3Key); - String presignedUrl = getPresignedUrl(s3Key); - return new VoiceSynthesisResult(s3Key, presignedUrl, false); - } - - /** - * S3 키로 Pre-signed URL 생성 - */ - public String getPresignedUrl(String s3Key) { - GetObjectRequest getObjectRequest = GetObjectRequest.builder() - .bucket(bucketName) - .key(s3Key) - .build(); - - GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() - .signatureDuration(Duration.ofHours(1)) - .getObjectRequest(getObjectRequest) - .build(); - - PresignedGetObjectRequest presignedRequest = s3Presigner.presignGetObject(presignRequest); - return presignedRequest.url().toString(); - } - - /** - * S3에 파일 존재 여부 확인 - */ - public boolean existsInS3(String s3Key) { - try { - s3Client.headObject(HeadObjectRequest.builder() - .bucket(bucketName) - .key(s3Key) - .build()); - return true; - } catch (NoSuchKeyException e) { - return false; - } - } - - /** - * Polly로 음성 변환 후 지정된 S3 키로 저장 - */ - private void synthesizeAndSave(String text, String voice, String s3Key) { - VoiceId voiceId = resolveVoiceId(voice); - - try { - SynthesizeSpeechRequest request = SynthesizeSpeechRequest.builder() - .text(text) - .voiceId(voiceId) - .engine("neural") - .outputFormat(OutputFormat.MP3) - .build(); - - InputStream audioStream = pollyClient.synthesizeSpeech(request); - - ByteArrayOutputStream buffer = new ByteArrayOutputStream(); - byte[] data = new byte[4096]; - int bytesRead; - while ((bytesRead = audioStream.read(data, 0, data.length)) != -1) { - buffer.write(data, 0, bytesRead); - } - byte[] audioBytes = buffer.toByteArray(); - - s3Client.putObject( - PutObjectRequest.builder() - .bucket(bucketName) - .key(s3Key) - .contentType("audio/mpeg") - .build(), - RequestBody.fromBytes(audioBytes) - ); - - logger.info("Saved audio to S3: {}", s3Key); - } catch (Exception e) { - logger.error("Error synthesizing speech", e); - throw new RuntimeException("Failed to synthesize speech", e); - } - } - - /** - * 메시지 ID와 음성 타입으로 S3 키 생성 - */ - public String generateS3Key(String messageId, String voice) { - String voiceSuffix = "MALE".equalsIgnoreCase(voice) ? "male" : "female"; - return "voice/" + messageId + "_" + voiceSuffix + ".mp3"; - } - - /** - * 음성 합성 결과 - */ - public static class VoiceSynthesisResult { - private final String s3Key; - private final String audioUrl; - private final boolean cached; - - public VoiceSynthesisResult(String s3Key, String audioUrl, boolean cached) { - this.s3Key = s3Key; - this.audioUrl = audioUrl; - this.cached = cached; - } - - public String getS3Key() { return s3Key; } - public String getAudioUrl() { return audioUrl; } - public boolean isCached() { return cached; } - } - - private VoiceId resolveVoiceId(String voice) { - if ("MALE".equalsIgnoreCase(voice)) { - return VoiceId.MATTHEW; // 미국 영어 남성 (Neural 지원) - } - return VoiceId.JOANNA; // 미국 영어 여성 (Neural 지원, 기본값) - } -} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/DailyStudyHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/DailyStudyHandler.java index 1fd3bd57..0017c6b8 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/DailyStudyHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/DailyStudyHandler.java @@ -5,6 +5,7 @@ import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; import com.mzc.secondproject.serverless.common.dto.ApiResponse; +import com.mzc.secondproject.serverless.common.dto.PaginatedResult; import static com.mzc.secondproject.serverless.common.util.ResponseUtil.createResponse; import com.mzc.secondproject.serverless.domain.vocabulary.model.DailyStudy; import com.mzc.secondproject.serverless.domain.vocabulary.model.UserWord; @@ -117,8 +118,8 @@ private DailyStudy createDailyStudy(String userId, String date, String level) { String now = Instant.now().toString(); // 복습 대상 단어 조회 (5개) - UserWordRepository.UserWordPage reviewPage = userWordRepository.findReviewDueWords(userId, date, REVIEW_WORDS_COUNT, null); - List reviewWordIds = reviewPage.getUserWords().stream() + PaginatedResult reviewPage = userWordRepository.findReviewDueWords(userId, date, REVIEW_WORDS_COUNT, null); + List reviewWordIds = reviewPage.getItems().stream() .map(UserWord::getWordId) .collect(Collectors.toList()); @@ -150,8 +151,8 @@ private DailyStudy createDailyStudy(String userId, String date, String level) { private List getNewWordsForUser(String userId, String level, int count) { // 사용자가 학습한 단어 목록 - UserWordRepository.UserWordPage userWordPage = userWordRepository.findByUserIdWithPagination(userId, 1000, null); - List learnedWordIds = userWordPage.getUserWords().stream() + PaginatedResult userWordPage = userWordRepository.findByUserIdWithPagination(userId, 1000, null); + List learnedWordIds = userWordPage.getItems().stream() .map(UserWord::getWordId) .collect(Collectors.toList()); @@ -161,8 +162,8 @@ private List getNewWordsForUser(String userId, String level, int count) // 페이지네이션으로 해당 레벨의 모든 단어 조회 do { - WordRepository.WordPage wordPage = wordRepository.findByLevelWithPagination(level, count * 2, lastEvaluatedKey); - for (Word word : wordPage.getWords()) { + PaginatedResult wordPage = wordRepository.findByLevelWithPagination(level, count * 2, lastEvaluatedKey); + for (Word word : wordPage.getItems()) { if (!learnedWordIds.contains(word.getWordId()) && !newWordIds.contains(word.getWordId())) { newWordIds.add(word.getWordId()); if (newWordIds.size() >= count) break; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatsHandler.java index a1eb347e..db8a7176 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatsHandler.java @@ -5,6 +5,7 @@ import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; import com.mzc.secondproject.serverless.common.dto.ApiResponse; +import com.mzc.secondproject.serverless.common.dto.PaginatedResult; import static com.mzc.secondproject.serverless.common.util.ResponseUtil.createResponse; import com.mzc.secondproject.serverless.domain.vocabulary.model.DailyStudy; import com.mzc.secondproject.serverless.domain.vocabulary.model.TestResult; @@ -92,8 +93,8 @@ private APIGatewayProxyResponseEvent getOverallStats(APIGatewayProxyRequestEvent // 사용자 단어 통계 조회 String cursor = null; do { - UserWordRepository.UserWordPage page = userWordRepository.findByUserIdWithPagination(userId, 100, cursor); - for (UserWord userWord : page.getUserWords()) { + PaginatedResult page = userWordRepository.findByUserIdWithPagination(userId, 100, cursor); + for (UserWord userWord : page.getItems()) { String status = userWord.getStatus(); wordStatusCounts.merge(status, 1, Integer::sum); totalCorrect += userWord.getCorrectCount() != null ? userWord.getCorrectCount() : 0; @@ -105,8 +106,8 @@ private APIGatewayProxyResponseEvent getOverallStats(APIGatewayProxyRequestEvent int totalWords = wordStatusCounts.values().stream().mapToInt(Integer::intValue).sum(); // 시험 통계 - TestResultRepository.TestResultPage testPage = testResultRepository.findByUserIdWithPagination(userId, 100, null); - List testResults = testPage.getTestResults(); + PaginatedResult testPage = testResultRepository.findByUserIdWithPagination(userId, 100, null); + List testResults = testPage.getItems(); double avgSuccessRate = testResults.stream() .mapToDouble(TestResult::getSuccessRate) @@ -114,9 +115,9 @@ private APIGatewayProxyResponseEvent getOverallStats(APIGatewayProxyRequestEvent .orElse(0.0); // 학습 일수 - DailyStudyRepository.DailyStudyPage dailyPage = dailyStudyRepository.findByUserIdWithPagination(userId, 365, null); - int studyDays = dailyPage.getDailyStudies().size(); - int completedDays = (int) dailyPage.getDailyStudies().stream() + PaginatedResult dailyPage = dailyStudyRepository.findByUserIdWithPagination(userId, 365, null); + int studyDays = dailyPage.getItems().size(); + int completedDays = (int) dailyPage.getItems().stream() .filter(d -> Boolean.TRUE.equals(d.getIsCompleted())) .count(); @@ -152,9 +153,9 @@ private APIGatewayProxyResponseEvent getDailyStats(APIGatewayProxyRequestEvent r limit = Math.min(Integer.parseInt(queryParams.get("limit")), 90); } - DailyStudyRepository.DailyStudyPage dailyPage = dailyStudyRepository.findByUserIdWithPagination(userId, limit, cursor); + PaginatedResult dailyPage = dailyStudyRepository.findByUserIdWithPagination(userId, limit, cursor); - List> dailyStats = dailyPage.getDailyStudies().stream() + List> dailyStats = dailyPage.getItems().stream() .map(daily -> { Map stat = new HashMap<>(); stat.put("date", daily.getDate()); @@ -190,8 +191,8 @@ private APIGatewayProxyResponseEvent getWeaknessAnalysis(APIGatewayProxyRequestE List allUserWords = new ArrayList<>(); String cursor = null; do { - UserWordRepository.UserWordPage page = userWordRepository.findByUserIdWithPagination(userId, 100, cursor); - allUserWords.addAll(page.getUserWords()); + PaginatedResult page = userWordRepository.findByUserIdWithPagination(userId, 100, cursor); + allUserWords.addAll(page.getItems()); cursor = page.getNextCursor(); } while (cursor != null); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/TestHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/TestHandler.java index 09140bb1..db4ffb91 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/TestHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/TestHandler.java @@ -5,6 +5,7 @@ import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; import com.mzc.secondproject.serverless.common.dto.ApiResponse; +import com.mzc.secondproject.serverless.common.dto.PaginatedResult; import com.mzc.secondproject.serverless.common.util.ResponseUtil; import static com.mzc.secondproject.serverless.common.util.ResponseUtil.createResponse; import com.mzc.secondproject.serverless.domain.vocabulary.model.DailyStudy; @@ -269,10 +270,10 @@ private APIGatewayProxyResponseEvent getTestResults(APIGatewayProxyRequestEvent limit = Math.min(Integer.parseInt(queryParams.get("limit")), 50); } - TestResultRepository.TestResultPage resultPage = testResultRepository.findByUserIdWithPagination(userId, limit, cursor); + PaginatedResult resultPage = testResultRepository.findByUserIdWithPagination(userId, limit, cursor); Map result = new HashMap<>(); - result.put("testResults", resultPage.getTestResults()); + result.put("testResults", resultPage.getItems()); result.put("nextCursor", resultPage.getNextCursor()); result.put("hasMore", resultPage.hasMore()); @@ -283,8 +284,8 @@ private APIGatewayProxyResponseEvent getTestResults(APIGatewayProxyRequestEvent * 해당 레벨에서 오답 후보 단어들의 한국어 뜻 목록을 가져옴 */ private List getDistractorsForLevel(String level, List excludeWordIds) { - WordRepository.WordPage wordPage = wordRepository.findByLevelWithPagination(level, 50, null); - return wordPage.getWords().stream() + PaginatedResult wordPage = wordRepository.findByLevelWithPagination(level, 50, null); + return wordPage.getItems().stream() .filter(w -> !excludeWordIds.contains(w.getWordId())) .map(Word::getKorean) .collect(Collectors.toList()); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java index c38418af..aaabe290 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java @@ -5,6 +5,7 @@ import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; import com.mzc.secondproject.serverless.common.dto.ApiResponse; +import com.mzc.secondproject.serverless.common.dto.PaginatedResult; import com.mzc.secondproject.serverless.common.util.ResponseUtil; import static com.mzc.secondproject.serverless.common.util.ResponseUtil.createResponse; import com.mzc.secondproject.serverless.domain.vocabulary.model.UserWord; @@ -90,7 +91,7 @@ private APIGatewayProxyResponseEvent getUserWords(APIGatewayProxyRequestEvent re limit = Math.min(Integer.parseInt(queryParams.get("limit")), 50); } - UserWordRepository.UserWordPage userWordPage; + PaginatedResult userWordPage; // 필터 우선순위: bookmarked > incorrectOnly > status > 전체 if ("true".equalsIgnoreCase(bookmarked)) { @@ -104,7 +105,7 @@ private APIGatewayProxyResponseEvent getUserWords(APIGatewayProxyRequestEvent re } // Word 정보 조인 (BatchGetItem) - List userWords = userWordPage.getUserWords(); + List userWords = userWordPage.getItems(); List> enrichedUserWords = enrichWithWordInfo(userWords); Map result = new HashMap<>(); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/VoiceHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/VoiceHandler.java index 22898c15..1e1fc219 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/VoiceHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/VoiceHandler.java @@ -9,7 +9,7 @@ import static com.mzc.secondproject.serverless.common.util.ResponseUtil.createResponse; import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; import com.mzc.secondproject.serverless.domain.vocabulary.repository.WordRepository; -import com.mzc.secondproject.serverless.domain.vocabulary.service.PollyService; +import com.mzc.secondproject.serverless.common.service.PollyService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -20,13 +20,14 @@ public class VoiceHandler implements RequestHandler { private static final Logger logger = LoggerFactory.getLogger(VoiceHandler.class); + private static final String BUCKET_NAME = System.getenv("VOCAB_BUCKET_NAME"); private final WordRepository wordRepository; private final PollyService pollyService; public VoiceHandler() { this.wordRepository = new WordRepository(); - this.pollyService = new PollyService(); + this.pollyService = new PollyService(BUCKET_NAME, "vocab/voice/"); } @Override @@ -82,7 +83,7 @@ private APIGatewayProxyResponseEvent synthesizeSpeech(APIGatewayProxyRequestEven logger.info("Cache hit from DB: wordId={}, voice={}", wordId, voice); } else { // 캐시 미스: Polly 변환 후 S3 저장 - PollyService.VoiceSynthesisResult result = pollyService.synthesizeSpeechForWord( + PollyService.VoiceSynthesisResult result = pollyService.synthesizeSpeech( wordId, word.getEnglish(), voice); audioUrl = result.getAudioUrl(); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/WordHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/WordHandler.java index 45747fd4..eb070a67 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/WordHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/WordHandler.java @@ -5,6 +5,7 @@ import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; import com.mzc.secondproject.serverless.common.dto.ApiResponse; +import com.mzc.secondproject.serverless.common.dto.PaginatedResult; import com.mzc.secondproject.serverless.common.util.ResponseUtil; import static com.mzc.secondproject.serverless.common.util.ResponseUtil.createResponse; import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; @@ -135,7 +136,7 @@ private APIGatewayProxyResponseEvent getWords(APIGatewayProxyRequestEvent reques limit = Math.min(Integer.parseInt(queryParams.get("limit")), 50); } - WordRepository.WordPage wordPage; + PaginatedResult wordPage; if (level != null && !level.isEmpty()) { wordPage = wordRepository.findByLevelWithPagination(level, limit, cursor); } else if (category != null && !category.isEmpty()) { @@ -146,7 +147,7 @@ private APIGatewayProxyResponseEvent getWords(APIGatewayProxyRequestEvent reques } Map result = new HashMap<>(); - result.put("words", wordPage.getWords()); + result.put("words", wordPage.getItems()); result.put("nextCursor", wordPage.getNextCursor()); result.put("hasMore", wordPage.hasMore()); @@ -311,10 +312,10 @@ private APIGatewayProxyResponseEvent searchWords(APIGatewayProxyRequestEvent req } // 영어/한국어 모두 검색 - WordRepository.WordPage wordPage = wordRepository.searchByKeyword(query, limit, cursor); + PaginatedResult wordPage = wordRepository.searchByKeyword(query, limit, cursor); Map result = new HashMap<>(); - result.put("words", wordPage.getWords()); + result.put("words", wordPage.getItems()); result.put("query", query); result.put("nextCursor", wordPage.getNextCursor()); result.put("hasMore", wordPage.hasMore()); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/DailyStudyRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/DailyStudyRepository.java index 49d4ed39..fc364817 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/DailyStudyRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/DailyStudyRepository.java @@ -14,7 +14,9 @@ import software.amazon.awssdk.services.dynamodb.model.AttributeValue; import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest; -import java.util.Base64; +import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.common.util.CursorUtil; + import java.util.HashMap; import java.util.List; import java.util.Map; @@ -56,7 +58,7 @@ public Optional findByUserIdAndDate(String userId, String date) { /** * 사용자의 일일 학습 기록 조회 - 최신순, 페이지네이션 */ - public DailyStudyPage findByUserIdWithPagination(String userId, int limit, String cursor) { + public PaginatedResult findByUserIdWithPagination(String userId, int limit, String cursor) { QueryConditional queryConditional = QueryConditional .sortBeginsWith(Key.builder() .partitionValue("DAILY#" + userId) @@ -69,16 +71,16 @@ public DailyStudyPage findByUserIdWithPagination(String userId, int limit, Strin .limit(limit); if (cursor != null && !cursor.isEmpty()) { - Map exclusiveStartKey = decodeCursor(cursor); + Map exclusiveStartKey = CursorUtil.decode(cursor); if (exclusiveStartKey != null) { requestBuilder.exclusiveStartKey(exclusiveStartKey); } } Page page = table.query(requestBuilder.build()).iterator().next(); - String nextCursor = encodeCursor(page.lastEvaluatedKey()); + String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); - return new DailyStudyPage(page.items(), nextCursor); + return new PaginatedResult<>(page.items(), nextCursor); } /** @@ -103,59 +105,4 @@ public void addLearnedWord(String userId, String date, String wordId) { dynamoDbClient.updateItem(updateRequest); logger.info("Added learned word: userId={}, date={}, wordId={}", userId, date, wordId); } - - private String encodeCursor(Map lastEvaluatedKey) { - if (lastEvaluatedKey == null || lastEvaluatedKey.isEmpty()) { - return null; - } - - StringBuilder sb = new StringBuilder(); - for (Map.Entry entry : lastEvaluatedKey.entrySet()) { - if (sb.length() > 0) sb.append("|"); - sb.append(entry.getKey()).append("=").append(entry.getValue().s()); - } - - return Base64.getUrlEncoder().encodeToString(sb.toString().getBytes()); - } - - private Map decodeCursor(String cursor) { - try { - String decoded = new String(Base64.getUrlDecoder().decode(cursor)); - Map result = new HashMap<>(); - - for (String pair : decoded.split("\\|")) { - String[] kv = pair.split("=", 2); - if (kv.length == 2) { - result.put(kv[0], AttributeValue.builder().s(kv[1]).build()); - } - } - - return result.isEmpty() ? null : result; - } catch (Exception e) { - logger.error("Failed to decode cursor: {}", cursor, e); - return null; - } - } - - public static class DailyStudyPage { - private final List dailyStudies; - private final String nextCursor; - - public DailyStudyPage(List dailyStudies, String nextCursor) { - this.dailyStudies = dailyStudies; - this.nextCursor = nextCursor; - } - - public List getDailyStudies() { - return dailyStudies; - } - - public String getNextCursor() { - return nextCursor; - } - - public boolean hasMore() { - return nextCursor != null; - } - } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/TestResultRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/TestResultRepository.java index 751f939d..5133333e 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/TestResultRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/TestResultRepository.java @@ -13,8 +13,9 @@ import software.amazon.awssdk.services.dynamodb.DynamoDbClient; import software.amazon.awssdk.services.dynamodb.model.AttributeValue; -import java.util.Base64; -import java.util.HashMap; +import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.common.util.CursorUtil; + import java.util.List; import java.util.Map; import java.util.Optional; @@ -55,7 +56,7 @@ public Optional findByUserIdAndTestId(String userId, String timestam /** * 사용자의 시험 결과 조회 - 최신순, 페이지네이션 */ - public TestResultPage findByUserIdWithPagination(String userId, int limit, String cursor) { + public PaginatedResult findByUserIdWithPagination(String userId, int limit, String cursor) { QueryConditional queryConditional = QueryConditional .sortBeginsWith(Key.builder() .partitionValue("TEST#" + userId) @@ -68,70 +69,15 @@ public TestResultPage findByUserIdWithPagination(String userId, int limit, Strin .limit(limit); if (cursor != null && !cursor.isEmpty()) { - Map exclusiveStartKey = decodeCursor(cursor); + Map exclusiveStartKey = CursorUtil.decode(cursor); if (exclusiveStartKey != null) { requestBuilder.exclusiveStartKey(exclusiveStartKey); } } Page page = table.query(requestBuilder.build()).iterator().next(); - String nextCursor = encodeCursor(page.lastEvaluatedKey()); - - return new TestResultPage(page.items(), nextCursor); - } - - private String encodeCursor(Map lastEvaluatedKey) { - if (lastEvaluatedKey == null || lastEvaluatedKey.isEmpty()) { - return null; - } - - StringBuilder sb = new StringBuilder(); - for (Map.Entry entry : lastEvaluatedKey.entrySet()) { - if (sb.length() > 0) sb.append("|"); - sb.append(entry.getKey()).append("=").append(entry.getValue().s()); - } - - return Base64.getUrlEncoder().encodeToString(sb.toString().getBytes()); - } - - private Map decodeCursor(String cursor) { - try { - String decoded = new String(Base64.getUrlDecoder().decode(cursor)); - Map result = new HashMap<>(); - - for (String pair : decoded.split("\\|")) { - String[] kv = pair.split("=", 2); - if (kv.length == 2) { - result.put(kv[0], AttributeValue.builder().s(kv[1]).build()); - } - } + String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); - return result.isEmpty() ? null : result; - } catch (Exception e) { - logger.error("Failed to decode cursor: {}", cursor, e); - return null; - } - } - - public static class TestResultPage { - private final List testResults; - private final String nextCursor; - - public TestResultPage(List testResults, String nextCursor) { - this.testResults = testResults; - this.nextCursor = nextCursor; - } - - public List getTestResults() { - return testResults; - } - - public String getNextCursor() { - return nextCursor; - } - - public boolean hasMore() { - return nextCursor != null; - } + return new PaginatedResult<>(page.items(), nextCursor); } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/UserWordRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/UserWordRepository.java index db461c7c..ac01da18 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/UserWordRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/UserWordRepository.java @@ -15,8 +15,9 @@ import software.amazon.awssdk.enhanced.dynamodb.Expression; import software.amazon.awssdk.services.dynamodb.model.AttributeValue; -import java.util.Base64; -import java.util.HashMap; +import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.common.util.CursorUtil; + import java.util.List; import java.util.Map; import java.util.Optional; @@ -57,7 +58,7 @@ public Optional findByUserIdAndWordId(String userId, String wordId) { /** * 사용자의 모든 단어 학습 상태 조회 - 페이지네이션 */ - public UserWordPage findByUserIdWithPagination(String userId, int limit, String cursor) { + public PaginatedResult findByUserIdWithPagination(String userId, int limit, String cursor) { QueryConditional queryConditional = QueryConditional .sortBeginsWith(Key.builder() .partitionValue("USER#" + userId) @@ -69,22 +70,22 @@ public UserWordPage findByUserIdWithPagination(String userId, int limit, String .limit(limit); if (cursor != null && !cursor.isEmpty()) { - Map exclusiveStartKey = decodeCursor(cursor); + Map exclusiveStartKey = CursorUtil.decode(cursor); if (exclusiveStartKey != null) { requestBuilder.exclusiveStartKey(exclusiveStartKey); } } Page page = table.query(requestBuilder.build()).iterator().next(); - String nextCursor = encodeCursor(page.lastEvaluatedKey()); + String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); - return new UserWordPage(page.items(), nextCursor); + return new PaginatedResult<>(page.items(), nextCursor); } /** * 복습 예정 단어 조회 (오늘 이전 날짜) - 페이지네이션 */ - public UserWordPage findReviewDueWords(String userId, String todayDate, int limit, String cursor) { + public PaginatedResult findReviewDueWords(String userId, String todayDate, int limit, String cursor) { QueryConditional queryConditional = QueryConditional .sortLessThanOrEqualTo(Key.builder() .partitionValue("USER#" + userId + "#REVIEW") @@ -96,7 +97,7 @@ public UserWordPage findReviewDueWords(String userId, String todayDate, int limi .limit(limit); if (cursor != null && !cursor.isEmpty()) { - Map exclusiveStartKey = decodeCursor(cursor); + Map exclusiveStartKey = CursorUtil.decode(cursor); if (exclusiveStartKey != null) { requestBuilder.exclusiveStartKey(exclusiveStartKey); } @@ -104,15 +105,15 @@ public UserWordPage findReviewDueWords(String userId, String todayDate, int limi DynamoDbIndex gsi1 = table.index("GSI1"); Page page = gsi1.query(requestBuilder.build()).iterator().next(); - String nextCursor = encodeCursor(page.lastEvaluatedKey()); + String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); - return new UserWordPage(page.items(), nextCursor); + return new PaginatedResult<>(page.items(), nextCursor); } /** * 북마크된 단어만 조회 - FilterExpression 사용 (GSI 추가 없이 비용 최적화) */ - public UserWordPage findBookmarkedWords(String userId, int limit, String cursor) { + public PaginatedResult findBookmarkedWords(String userId, int limit, String cursor) { QueryConditional queryConditional = QueryConditional .sortBeginsWith(Key.builder() .partitionValue("USER#" + userId) @@ -130,22 +131,22 @@ public UserWordPage findBookmarkedWords(String userId, int limit, String cursor) .limit(limit); if (cursor != null && !cursor.isEmpty()) { - Map exclusiveStartKey = decodeCursor(cursor); + Map exclusiveStartKey = CursorUtil.decode(cursor); if (exclusiveStartKey != null) { requestBuilder.exclusiveStartKey(exclusiveStartKey); } } Page page = table.query(requestBuilder.build()).iterator().next(); - String nextCursor = encodeCursor(page.lastEvaluatedKey()); + String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); - return new UserWordPage(page.items(), nextCursor); + return new PaginatedResult<>(page.items(), nextCursor); } /** * 틀린 적 있는 단어만 조회 - FilterExpression 사용 (GSI 추가 없이 비용 최적화) */ - public UserWordPage findIncorrectWords(String userId, int limit, String cursor) { + public PaginatedResult findIncorrectWords(String userId, int limit, String cursor) { QueryConditional queryConditional = QueryConditional .sortBeginsWith(Key.builder() .partitionValue("USER#" + userId) @@ -163,22 +164,22 @@ public UserWordPage findIncorrectWords(String userId, int limit, String cursor) .limit(limit); if (cursor != null && !cursor.isEmpty()) { - Map exclusiveStartKey = decodeCursor(cursor); + Map exclusiveStartKey = CursorUtil.decode(cursor); if (exclusiveStartKey != null) { requestBuilder.exclusiveStartKey(exclusiveStartKey); } } Page page = table.query(requestBuilder.build()).iterator().next(); - String nextCursor = encodeCursor(page.lastEvaluatedKey()); + String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); - return new UserWordPage(page.items(), nextCursor); + return new PaginatedResult<>(page.items(), nextCursor); } /** * 상태별 단어 조회 - 페이지네이션 */ - public UserWordPage findByUserIdAndStatus(String userId, String status, int limit, String cursor) { + public PaginatedResult findByUserIdAndStatus(String userId, String status, int limit, String cursor) { QueryConditional queryConditional = QueryConditional .keyEqualTo(Key.builder() .partitionValue("USER#" + userId + "#STATUS") @@ -190,7 +191,7 @@ public UserWordPage findByUserIdAndStatus(String userId, String status, int limi .limit(limit); if (cursor != null && !cursor.isEmpty()) { - Map exclusiveStartKey = decodeCursor(cursor); + Map exclusiveStartKey = CursorUtil.decode(cursor); if (exclusiveStartKey != null) { requestBuilder.exclusiveStartKey(exclusiveStartKey); } @@ -198,63 +199,8 @@ public UserWordPage findByUserIdAndStatus(String userId, String status, int limi DynamoDbIndex gsi2 = table.index("GSI2"); Page page = gsi2.query(requestBuilder.build()).iterator().next(); - String nextCursor = encodeCursor(page.lastEvaluatedKey()); - - return new UserWordPage(page.items(), nextCursor); - } - - private String encodeCursor(Map lastEvaluatedKey) { - if (lastEvaluatedKey == null || lastEvaluatedKey.isEmpty()) { - return null; - } - - StringBuilder sb = new StringBuilder(); - for (Map.Entry entry : lastEvaluatedKey.entrySet()) { - if (sb.length() > 0) sb.append("|"); - sb.append(entry.getKey()).append("=").append(entry.getValue().s()); - } - - return Base64.getUrlEncoder().encodeToString(sb.toString().getBytes()); - } - - private Map decodeCursor(String cursor) { - try { - String decoded = new String(Base64.getUrlDecoder().decode(cursor)); - Map result = new HashMap<>(); - - for (String pair : decoded.split("\\|")) { - String[] kv = pair.split("=", 2); - if (kv.length == 2) { - result.put(kv[0], AttributeValue.builder().s(kv[1]).build()); - } - } + String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); - return result.isEmpty() ? null : result; - } catch (Exception e) { - logger.error("Failed to decode cursor: {}", cursor, e); - return null; - } - } - - public static class UserWordPage { - private final List userWords; - private final String nextCursor; - - public UserWordPage(List userWords, String nextCursor) { - this.userWords = userWords; - this.nextCursor = nextCursor; - } - - public List getUserWords() { - return userWords; - } - - public String getNextCursor() { - return nextCursor; - } - - public boolean hasMore() { - return nextCursor != null; - } + return new PaginatedResult<>(page.items(), nextCursor); } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordRepository.java index 29e783e3..38c9baf0 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordRepository.java @@ -18,10 +18,10 @@ import software.amazon.awssdk.services.dynamodb.DynamoDbClient; import software.amazon.awssdk.services.dynamodb.model.AttributeValue; -import java.util.ArrayList; +import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.common.util.CursorUtil; -import java.util.Base64; -import java.util.HashMap; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Optional; @@ -114,7 +114,7 @@ public void delete(String wordId) { /** * 난이도별 단어 조회 - 페이지네이션 */ - public WordPage findByLevelWithPagination(String level, int limit, String cursor) { + public PaginatedResult findByLevelWithPagination(String level, int limit, String cursor) { QueryConditional queryConditional = QueryConditional .keyEqualTo(Key.builder().partitionValue("LEVEL#" + level).build()); @@ -123,7 +123,7 @@ public WordPage findByLevelWithPagination(String level, int limit, String cursor .limit(limit); if (cursor != null && !cursor.isEmpty()) { - Map exclusiveStartKey = decodeCursor(cursor); + Map exclusiveStartKey = CursorUtil.decode(cursor); if (exclusiveStartKey != null) { requestBuilder.exclusiveStartKey(exclusiveStartKey); } @@ -131,15 +131,15 @@ public WordPage findByLevelWithPagination(String level, int limit, String cursor DynamoDbIndex gsi1 = table.index("GSI1"); Page page = gsi1.query(requestBuilder.build()).iterator().next(); - String nextCursor = encodeCursor(page.lastEvaluatedKey()); + String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); - return new WordPage(page.items(), nextCursor); + return new PaginatedResult<>(page.items(), nextCursor); } /** * 카테고리별 단어 조회 - 페이지네이션 */ - public WordPage findByCategoryWithPagination(String category, int limit, String cursor) { + public PaginatedResult findByCategoryWithPagination(String category, int limit, String cursor) { QueryConditional queryConditional = QueryConditional .keyEqualTo(Key.builder().partitionValue("CATEGORY#" + category).build()); @@ -148,7 +148,7 @@ public WordPage findByCategoryWithPagination(String category, int limit, String .limit(limit); if (cursor != null && !cursor.isEmpty()) { - Map exclusiveStartKey = decodeCursor(cursor); + Map exclusiveStartKey = CursorUtil.decode(cursor); if (exclusiveStartKey != null) { requestBuilder.exclusiveStartKey(exclusiveStartKey); } @@ -156,49 +156,16 @@ public WordPage findByCategoryWithPagination(String category, int limit, String DynamoDbIndex gsi2 = table.index("GSI2"); Page page = gsi2.query(requestBuilder.build()).iterator().next(); - String nextCursor = encodeCursor(page.lastEvaluatedKey()); + String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); - return new WordPage(page.items(), nextCursor); - } - - private String encodeCursor(Map lastEvaluatedKey) { - if (lastEvaluatedKey == null || lastEvaluatedKey.isEmpty()) { - return null; - } - - StringBuilder sb = new StringBuilder(); - for (Map.Entry entry : lastEvaluatedKey.entrySet()) { - if (sb.length() > 0) sb.append("|"); - sb.append(entry.getKey()).append("=").append(entry.getValue().s()); - } - - return Base64.getUrlEncoder().encodeToString(sb.toString().getBytes()); - } - - private Map decodeCursor(String cursor) { - try { - String decoded = new String(Base64.getUrlDecoder().decode(cursor)); - Map result = new HashMap<>(); - - for (String pair : decoded.split("\\|")) { - String[] kv = pair.split("=", 2); - if (kv.length == 2) { - result.put(kv[0], AttributeValue.builder().s(kv[1]).build()); - } - } - - return result.isEmpty() ? null : result; - } catch (Exception e) { - logger.error("Failed to decode cursor: {}", cursor, e); - return null; - } + return new PaginatedResult<>(page.items(), nextCursor); } /** * 키워드로 단어 검색 (영어/한국어 contains) * 참고: Scan은 비용이 높으므로 데이터가 많아지면 OpenSearch 도입 권장 */ - public WordPage searchByKeyword(String keyword, int limit, String cursor) { + public PaginatedResult searchByKeyword(String keyword, int limit, String cursor) { String lowerKeyword = keyword.toLowerCase(); // Filter: PK가 WORD#로 시작하고, english 또는 korean에 keyword 포함 @@ -214,7 +181,7 @@ public WordPage searchByKeyword(String keyword, int limit, String cursor) { .limit(limit * 3); // filter 적용되므로 넉넉히 if (cursor != null && !cursor.isEmpty()) { - Map exclusiveStartKey = decodeCursor(cursor); + Map exclusiveStartKey = CursorUtil.decode(cursor); if (exclusiveStartKey != null) { requestBuilder.exclusiveStartKey(exclusiveStartKey); } @@ -236,29 +203,7 @@ public WordPage searchByKeyword(String keyword, int limit, String cursor) { if (results.size() >= limit) break; } - String nextCursor = results.size() >= limit ? encodeCursor(lastKey) : null; - return new WordPage(results, nextCursor); - } - - public static class WordPage { - private final List words; - private final String nextCursor; - - public WordPage(List words, String nextCursor) { - this.words = words; - this.nextCursor = nextCursor; - } - - public List getWords() { - return words; - } - - public String getNextCursor() { - return nextCursor; - } - - public boolean hasMore() { - return nextCursor != null; - } + String nextCursor = results.size() >= limit ? CursorUtil.encode(lastKey) : null; + return new PaginatedResult<>(results, nextCursor); } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/PollyService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/PollyService.java deleted file mode 100644 index a03437c8..00000000 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/PollyService.java +++ /dev/null @@ -1,160 +0,0 @@ -package com.mzc.secondproject.serverless.domain.vocabulary.service; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import software.amazon.awssdk.core.sync.RequestBody; -import software.amazon.awssdk.services.polly.PollyClient; -import software.amazon.awssdk.services.polly.model.OutputFormat; -import software.amazon.awssdk.services.polly.model.SynthesizeSpeechRequest; -import software.amazon.awssdk.services.polly.model.VoiceId; -import software.amazon.awssdk.services.s3.S3Client; -import software.amazon.awssdk.services.s3.model.PutObjectRequest; -import software.amazon.awssdk.services.s3.presigner.S3Presigner; -import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; -import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest; -import software.amazon.awssdk.services.s3.model.GetObjectRequest; -import software.amazon.awssdk.services.s3.model.HeadObjectRequest; -import software.amazon.awssdk.services.s3.model.NoSuchKeyException; - -import java.io.ByteArrayOutputStream; -import java.io.InputStream; -import java.time.Duration; - -public class PollyService { - - private static final Logger logger = LoggerFactory.getLogger(PollyService.class); - - // Singleton 패턴으로 Cold Start 최적화 - private static final PollyClient pollyClient = PollyClient.builder().build(); - private static final S3Client s3Client = S3Client.builder().build(); - private static final S3Presigner s3Presigner = S3Presigner.builder().build(); - private static final String bucketName = System.getenv("VOCAB_BUCKET_NAME"); - - /** - * 단어 ID 기반으로 음성 합성 (캐시 지원) - * S3에 파일이 있으면 바로 URL 반환, 없으면 Polly 변환 후 저장 - */ - public VoiceSynthesisResult synthesizeSpeechForWord(String wordId, String text, String voice) { - String s3Key = generateS3Key(wordId, voice); - - // 캐시 확인: S3에 이미 존재하는지 체크 - if (existsInS3(s3Key)) { - logger.info("Cache hit: {}", s3Key); - String presignedUrl = getPresignedUrl(s3Key); - return new VoiceSynthesisResult(s3Key, presignedUrl, true); - } - - // 캐시 미스: Polly 변환 후 S3 저장 - logger.info("Cache miss: synthesizing and saving to {}", s3Key); - synthesizeAndSave(text, voice, s3Key); - String presignedUrl = getPresignedUrl(s3Key); - return new VoiceSynthesisResult(s3Key, presignedUrl, false); - } - - /** - * S3 키로 Pre-signed URL 생성 - */ - public String getPresignedUrl(String s3Key) { - GetObjectRequest getObjectRequest = GetObjectRequest.builder() - .bucket(bucketName) - .key(s3Key) - .build(); - - GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() - .signatureDuration(Duration.ofHours(1)) - .getObjectRequest(getObjectRequest) - .build(); - - PresignedGetObjectRequest presignedRequest = s3Presigner.presignGetObject(presignRequest); - return presignedRequest.url().toString(); - } - - /** - * S3에 파일 존재 여부 확인 - */ - public boolean existsInS3(String s3Key) { - try { - s3Client.headObject(HeadObjectRequest.builder() - .bucket(bucketName) - .key(s3Key) - .build()); - return true; - } catch (NoSuchKeyException e) { - return false; - } - } - - /** - * Polly로 음성 변환 후 지정된 S3 키로 저장 - */ - private void synthesizeAndSave(String text, String voice, String s3Key) { - VoiceId voiceId = resolveVoiceId(voice); - - try { - SynthesizeSpeechRequest request = SynthesizeSpeechRequest.builder() - .text(text) - .voiceId(voiceId) - .engine("neural") - .outputFormat(OutputFormat.MP3) - .build(); - - InputStream audioStream = pollyClient.synthesizeSpeech(request); - - ByteArrayOutputStream buffer = new ByteArrayOutputStream(); - byte[] data = new byte[4096]; - int bytesRead; - while ((bytesRead = audioStream.read(data, 0, data.length)) != -1) { - buffer.write(data, 0, bytesRead); - } - byte[] audioBytes = buffer.toByteArray(); - - s3Client.putObject( - PutObjectRequest.builder() - .bucket(bucketName) - .key(s3Key) - .contentType("audio/mpeg") - .build(), - RequestBody.fromBytes(audioBytes) - ); - - logger.info("Saved audio to S3: {}", s3Key); - } catch (Exception e) { - logger.error("Error synthesizing speech", e); - throw new RuntimeException("Failed to synthesize speech", e); - } - } - - /** - * 단어 ID와 음성 타입으로 S3 키 생성 - */ - public String generateS3Key(String wordId, String voice) { - String voiceSuffix = "MALE".equalsIgnoreCase(voice) ? "male" : "female"; - return "vocab/voice/" + wordId + "_" + voiceSuffix + ".mp3"; - } - - /** - * 음성 합성 결과 - */ - public static class VoiceSynthesisResult { - private final String s3Key; - private final String audioUrl; - private final boolean cached; - - public VoiceSynthesisResult(String s3Key, String audioUrl, boolean cached) { - this.s3Key = s3Key; - this.audioUrl = audioUrl; - this.cached = cached; - } - - public String getS3Key() { return s3Key; } - public String getAudioUrl() { return audioUrl; } - public boolean isCached() { return cached; } - } - - private VoiceId resolveVoiceId(String voice) { - if ("MALE".equalsIgnoreCase(voice)) { - return VoiceId.MATTHEW; // 미국 영어 남성 (Neural 지원) - } - return VoiceId.JOANNA; // 미국 영어 여성 (Neural 지원, 기본값) - } -} From 0abc39a44ee1f6f01b64fb8bbc9a0a036e015892 Mon Sep 17 00:00:00 2001 From: DDING JOO Date: Thu, 8 Jan 2026 12:27:59 +0900 Subject: [PATCH 022/528] =?UTF-8?q?refactor:=20Repository=20=EA=B3=84?= =?UTF-8?q?=EC=B8=B5=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=20(#76)=20(#93?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: WordRepository에서 AwsClients 사용 * refactor: UserWordRepository에서 AwsClients 사용 * refactor: TestResultRepository에서 AwsClients 사용 * refactor: DailyStudyRepository에서 AwsClients 사용 * refactor: ChatRoomRepository에서 AwsClients 사용 * refactor: ChatMessageRepository에서 AwsClients 사용 --- .../repository/ChatMessageRepository.java | 11 +++-------- .../chatting/repository/ChatRoomRepository.java | 15 +++++---------- .../repository/DailyStudyRepository.java | 15 +++++---------- .../repository/TestResultRepository.java | 11 +++-------- .../vocabulary/repository/UserWordRepository.java | 11 +++-------- .../vocabulary/repository/WordRepository.java | 13 +++++-------- 6 files changed, 24 insertions(+), 52 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatMessageRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatMessageRepository.java index 445a0d23..6c70ea8f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatMessageRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatMessageRepository.java @@ -14,6 +14,7 @@ import software.amazon.awssdk.services.dynamodb.model.AttributeValue; import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.common.util.AwsClients; import com.mzc.secondproject.serverless.common.util.CursorUtil; import java.util.List; @@ -23,18 +24,12 @@ public class ChatMessageRepository { private static final Logger logger = LoggerFactory.getLogger(ChatMessageRepository.class); - - // Singleton 패턴으로 Cold Start 최적화 - private static final DynamoDbClient dynamoDbClient = DynamoDbClient.builder().build(); - private static final DynamoDbEnhancedClient enhancedClient = DynamoDbEnhancedClient.builder() - .dynamoDbClient(dynamoDbClient) - .build(); - private static final String tableName = System.getenv("CHAT_TABLE_NAME"); + private static final String TABLE_NAME = System.getenv("CHAT_TABLE_NAME"); private final DynamoDbTable table; public ChatMessageRepository() { - this.table = enhancedClient.table(tableName, TableSchema.fromBean(ChatMessage.class)); + this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(ChatMessage.class)); } public ChatMessage save(ChatMessage message) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatRoomRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatRoomRepository.java index f81ca775..e51e2dc6 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatRoomRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatRoomRepository.java @@ -16,6 +16,7 @@ import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest; import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.common.util.AwsClients; import com.mzc.secondproject.serverless.common.util.CursorUtil; import java.util.HashMap; @@ -26,18 +27,12 @@ public class ChatRoomRepository { private static final Logger logger = LoggerFactory.getLogger(ChatRoomRepository.class); - - // Singleton 패턴으로 Cold Start 최적화 - private static final DynamoDbClient dynamoDbClient = DynamoDbClient.builder().build(); - private static final DynamoDbEnhancedClient enhancedClient = DynamoDbEnhancedClient.builder() - .dynamoDbClient(dynamoDbClient) - .build(); - private static final String tableName = System.getenv("CHAT_TABLE_NAME"); + private static final String TABLE_NAME = System.getenv("CHAT_TABLE_NAME"); private final DynamoDbTable table; public ChatRoomRepository() { - this.table = enhancedClient.table(tableName, TableSchema.fromBean(ChatRoom.class)); + this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(ChatRoom.class)); } public ChatRoom save(ChatRoom room) { @@ -142,13 +137,13 @@ public void updateLastMessageAt(String roomId, String timestamp) { expressionValues.put(":ts", AttributeValue.builder().s(timestamp).build()); UpdateItemRequest updateRequest = UpdateItemRequest.builder() - .tableName(tableName) + .tableName(TABLE_NAME) .key(key) .updateExpression("SET lastMessageAt = :ts") .expressionAttributeValues(expressionValues) .build(); - dynamoDbClient.updateItem(updateRequest); + AwsClients.dynamoDb().updateItem(updateRequest); logger.info("Updated lastMessageAt for room: {}", roomId); } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/DailyStudyRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/DailyStudyRepository.java index fc364817..b2f768a6 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/DailyStudyRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/DailyStudyRepository.java @@ -15,6 +15,7 @@ import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest; import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.common.util.AwsClients; import com.mzc.secondproject.serverless.common.util.CursorUtil; import java.util.HashMap; @@ -25,18 +26,12 @@ public class DailyStudyRepository { private static final Logger logger = LoggerFactory.getLogger(DailyStudyRepository.class); - - // Singleton 패턴으로 Cold Start 최적화 - private static final DynamoDbClient dynamoDbClient = DynamoDbClient.builder().build(); - private static final DynamoDbEnhancedClient enhancedClient = DynamoDbEnhancedClient.builder() - .dynamoDbClient(dynamoDbClient) - .build(); - private static final String tableName = System.getenv("VOCAB_TABLE_NAME"); + private static final String TABLE_NAME = System.getenv("VOCAB_TABLE_NAME"); private final DynamoDbTable table; public DailyStudyRepository() { - this.table = enhancedClient.table(tableName, TableSchema.fromBean(DailyStudy.class)); + this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(DailyStudy.class)); } public DailyStudy save(DailyStudy dailyStudy) { @@ -96,13 +91,13 @@ public void addLearnedWord(String userId, String date, String wordId) { expressionValues.put(":one", AttributeValue.builder().n("1").build()); UpdateItemRequest updateRequest = UpdateItemRequest.builder() - .tableName(tableName) + .tableName(TABLE_NAME) .key(key) .updateExpression("ADD learnedWordIds :wordId, learnedCount :one") .expressionAttributeValues(expressionValues) .build(); - dynamoDbClient.updateItem(updateRequest); + AwsClients.dynamoDb().updateItem(updateRequest); logger.info("Added learned word: userId={}, date={}, wordId={}", userId, date, wordId); } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/TestResultRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/TestResultRepository.java index 5133333e..42113caf 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/TestResultRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/TestResultRepository.java @@ -14,6 +14,7 @@ import software.amazon.awssdk.services.dynamodb.model.AttributeValue; import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.common.util.AwsClients; import com.mzc.secondproject.serverless.common.util.CursorUtil; import java.util.List; @@ -23,18 +24,12 @@ public class TestResultRepository { private static final Logger logger = LoggerFactory.getLogger(TestResultRepository.class); - - // Singleton 패턴으로 Cold Start 최적화 - private static final DynamoDbClient dynamoDbClient = DynamoDbClient.builder().build(); - private static final DynamoDbEnhancedClient enhancedClient = DynamoDbEnhancedClient.builder() - .dynamoDbClient(dynamoDbClient) - .build(); - private static final String tableName = System.getenv("VOCAB_TABLE_NAME"); + private static final String TABLE_NAME = System.getenv("VOCAB_TABLE_NAME"); private final DynamoDbTable table; public TestResultRepository() { - this.table = enhancedClient.table(tableName, TableSchema.fromBean(TestResult.class)); + this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(TestResult.class)); } public TestResult save(TestResult testResult) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/UserWordRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/UserWordRepository.java index ac01da18..9751266b 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/UserWordRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/UserWordRepository.java @@ -16,6 +16,7 @@ import software.amazon.awssdk.services.dynamodb.model.AttributeValue; import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.common.util.AwsClients; import com.mzc.secondproject.serverless.common.util.CursorUtil; import java.util.List; @@ -25,18 +26,12 @@ public class UserWordRepository { private static final Logger logger = LoggerFactory.getLogger(UserWordRepository.class); - - // Singleton 패턴으로 Cold Start 최적화 - private static final DynamoDbClient dynamoDbClient = DynamoDbClient.builder().build(); - private static final DynamoDbEnhancedClient enhancedClient = DynamoDbEnhancedClient.builder() - .dynamoDbClient(dynamoDbClient) - .build(); - private static final String tableName = System.getenv("VOCAB_TABLE_NAME"); + private static final String TABLE_NAME = System.getenv("VOCAB_TABLE_NAME"); private final DynamoDbTable table; public UserWordRepository() { - this.table = enhancedClient.table(tableName, TableSchema.fromBean(UserWord.class)); + this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(UserWord.class)); } public UserWord save(UserWord userWord) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordRepository.java index 38c9baf0..53388e79 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordRepository.java @@ -19,6 +19,7 @@ import software.amazon.awssdk.services.dynamodb.model.AttributeValue; import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.common.util.AwsClients; import com.mzc.secondproject.serverless.common.util.CursorUtil; import java.util.ArrayList; @@ -29,18 +30,14 @@ public class WordRepository { private static final Logger logger = LoggerFactory.getLogger(WordRepository.class); + private static final String TABLE_NAME = System.getenv("VOCAB_TABLE_NAME"); - // Singleton 패턴으로 Cold Start 최적화 - private static final DynamoDbClient dynamoDbClient = DynamoDbClient.builder().build(); - private static final DynamoDbEnhancedClient enhancedClient = DynamoDbEnhancedClient.builder() - .dynamoDbClient(dynamoDbClient) - .build(); - private static final String tableName = System.getenv("VOCAB_TABLE_NAME"); - + private final DynamoDbEnhancedClient enhancedClient; private final DynamoDbTable table; public WordRepository() { - this.table = enhancedClient.table(tableName, TableSchema.fromBean(Word.class)); + this.enhancedClient = AwsClients.dynamoDbEnhanced(); + this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(Word.class)); } public Word save(Word word) { From fac4ddb67289ea033305f6b0987e20c352e25493 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 8 Jan 2026 12:37:17 +0900 Subject: [PATCH 023/528] =?UTF-8?q?feat:=20WordService=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20refs=20#81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vocabulary/service/WordService.java | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordService.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordService.java new file mode 100644 index 00000000..84f7473a --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordService.java @@ -0,0 +1,164 @@ +package com.mzc.secondproject.serverless.domain.vocabulary.service; + +import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; +import com.mzc.secondproject.serverless.domain.vocabulary.repository.WordRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +public class WordService { + + private static final Logger logger = LoggerFactory.getLogger(WordService.class); + + private final WordRepository wordRepository; + + public WordService() { + this.wordRepository = new WordRepository(); + } + + public Word createWord(String english, String korean, String example, String level, String category) { + String wordId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + + Word word = Word.builder() + .pk("WORD#" + wordId) + .sk("METADATA") + .gsi1pk("LEVEL#" + level) + .gsi1sk("WORD#" + wordId) + .gsi2pk("CATEGORY#" + category) + .gsi2sk("WORD#" + wordId) + .wordId(wordId) + .english(english) + .korean(korean) + .example(example) + .level(level) + .category(category) + .createdAt(now) + .build(); + + wordRepository.save(word); + logger.info("Created word: {}", wordId); + + return word; + } + + public Optional getWord(String wordId) { + return wordRepository.findById(wordId); + } + + public PaginatedResult getWords(String level, String category, int limit, String cursor) { + if (level != null && !level.isEmpty()) { + return wordRepository.findByLevelWithPagination(level, limit, cursor); + } else if (category != null && !category.isEmpty()) { + return wordRepository.findByCategoryWithPagination(category, limit, cursor); + } + return wordRepository.findByLevelWithPagination("BEGINNER", limit, cursor); + } + + public Word updateWord(String wordId, Map updates) { + Optional optWord = wordRepository.findById(wordId); + if (optWord.isEmpty()) { + throw new IllegalArgumentException("Word not found"); + } + + Word word = optWord.get(); + + if (updates.containsKey("english")) { + word.setEnglish((String) updates.get("english")); + } + if (updates.containsKey("korean")) { + word.setKorean((String) updates.get("korean")); + } + if (updates.containsKey("example")) { + word.setExample((String) updates.get("example")); + } + if (updates.containsKey("level")) { + String newLevel = (String) updates.get("level"); + word.setLevel(newLevel); + word.setGsi1pk("LEVEL#" + newLevel); + } + if (updates.containsKey("category")) { + String newCategory = (String) updates.get("category"); + word.setCategory(newCategory); + word.setGsi2pk("CATEGORY#" + newCategory); + } + + wordRepository.save(word); + logger.info("Updated word: {}", wordId); + + return word; + } + + public void deleteWord(String wordId) { + Optional optWord = wordRepository.findById(wordId); + if (optWord.isEmpty()) { + throw new IllegalArgumentException("Word not found"); + } + + wordRepository.delete(wordId); + logger.info("Deleted word: {}", wordId); + } + + public BatchResult createWordsBatch(List> wordsList) { + String now = Instant.now().toString(); + List createdWords = new ArrayList<>(); + int successCount = 0; + int failCount = 0; + + for (Map wordData : wordsList) { + try { + String english = (String) wordData.get("english"); + String korean = (String) wordData.get("korean"); + String example = (String) wordData.get("example"); + String level = (String) wordData.getOrDefault("level", "BEGINNER"); + String category = (String) wordData.getOrDefault("category", "DAILY"); + + if (english == null || korean == null) { + failCount++; + continue; + } + + String wordId = UUID.randomUUID().toString(); + + Word word = Word.builder() + .pk("WORD#" + wordId) + .sk("METADATA") + .gsi1pk("LEVEL#" + level) + .gsi1sk("WORD#" + wordId) + .gsi2pk("CATEGORY#" + category) + .gsi2sk("WORD#" + wordId) + .wordId(wordId) + .english(english) + .korean(korean) + .example(example) + .level(level) + .category(category) + .createdAt(now) + .build(); + + wordRepository.save(word); + createdWords.add(word); + successCount++; + } catch (Exception e) { + logger.error("Failed to create word", e); + failCount++; + } + } + + logger.info("Batch created {} words, failed {}", successCount, failCount); + return new BatchResult(successCount, failCount, wordsList.size()); + } + + public PaginatedResult searchWords(String query, int limit, String cursor) { + return wordRepository.searchByKeyword(query, limit, cursor); + } + + public record BatchResult(int successCount, int failCount, int totalRequested) {} +} From 425a4fba532de6513884e850aba140c0aa1df6c8 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 8 Jan 2026 12:37:17 +0900 Subject: [PATCH 024/528] =?UTF-8?q?refactor:=20WordHandler=EC=97=90?= =?UTF-8?q?=EC=84=9C=20Service=20=EA=B3=84=EC=B8=B5=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=20refs=20#81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vocabulary/handler/WordHandler.java | 157 +++--------------- 1 file changed, 23 insertions(+), 134 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/WordHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/WordHandler.java index eb070a67..b1237cc2 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/WordHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/WordHandler.java @@ -9,26 +9,23 @@ import com.mzc.secondproject.serverless.common.util.ResponseUtil; import static com.mzc.secondproject.serverless.common.util.ResponseUtil.createResponse; import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; -import com.mzc.secondproject.serverless.domain.vocabulary.repository.WordRepository; +import com.mzc.secondproject.serverless.domain.vocabulary.service.WordService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.time.Instant; -import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; -import java.util.UUID; public class WordHandler implements RequestHandler { private static final Logger logger = LoggerFactory.getLogger(WordHandler.class); - private final WordRepository wordRepository; + private final WordService wordService; public WordHandler() { - this.wordRepository = new WordRepository(); + this.wordService = new WordService(); } @Override @@ -39,37 +36,30 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent re logger.info("Received request: {} {}", httpMethod, path); try { - // POST /vocab/words/batch - 단어 일괄 등록 if ("POST".equals(httpMethod) && path.endsWith("/batch")) { return createWordsBatch(request); } - // GET /vocab/words/search - 단어 검색 if ("GET".equals(httpMethod) && path.endsWith("/search")) { return searchWords(request); } - // POST /vocab/words - 단어 생성 if ("POST".equals(httpMethod) && path.endsWith("/words")) { return createWord(request); } - // GET /vocab/words - 단어 목록 조회 if ("GET".equals(httpMethod) && path.endsWith("/words")) { return getWords(request); } - // GET /vocab/words/{wordId} - 단어 상세 조회 if ("GET".equals(httpMethod) && path.contains("/words/") && !path.contains("/search") && !path.contains("/batch")) { return getWord(request); } - // PUT /vocab/words/{wordId} - 단어 수정 if ("PUT".equals(httpMethod) && path.contains("/words/")) { return updateWord(request); } - // DELETE /vocab/words/{wordId} - 단어 삭제 if ("DELETE".equals(httpMethod) && path.contains("/words/")) { return deleteWord(request); } @@ -99,28 +89,7 @@ private APIGatewayProxyResponseEvent createWord(APIGatewayProxyRequestEvent requ return createResponse(400, ApiResponse.error("korean is required")); } - String wordId = UUID.randomUUID().toString(); - String now = Instant.now().toString(); - - Word word = Word.builder() - .pk("WORD#" + wordId) - .sk("METADATA") - .gsi1pk("LEVEL#" + level) - .gsi1sk("WORD#" + wordId) - .gsi2pk("CATEGORY#" + category) - .gsi2sk("WORD#" + wordId) - .wordId(wordId) - .english(english) - .korean(korean) - .example(example) - .level(level) - .category(category) - .createdAt(now) - .build(); - - wordRepository.save(word); - - logger.info("Created word: {}", wordId); + Word word = wordService.createWord(english, korean, example, level, category); return createResponse(201, ApiResponse.success("Word created", word)); } @@ -136,15 +105,7 @@ private APIGatewayProxyResponseEvent getWords(APIGatewayProxyRequestEvent reques limit = Math.min(Integer.parseInt(queryParams.get("limit")), 50); } - PaginatedResult wordPage; - if (level != null && !level.isEmpty()) { - wordPage = wordRepository.findByLevelWithPagination(level, limit, cursor); - } else if (category != null && !category.isEmpty()) { - wordPage = wordRepository.findByCategoryWithPagination(category, limit, cursor); - } else { - // 기본: BEGINNER 레벨 - wordPage = wordRepository.findByLevelWithPagination("BEGINNER", limit, cursor); - } + PaginatedResult wordPage = wordService.getWords(level, category, limit, cursor); Map result = new HashMap<>(); result.put("words", wordPage.getItems()); @@ -162,7 +123,7 @@ private APIGatewayProxyResponseEvent getWord(APIGatewayProxyRequestEvent request return createResponse(400, ApiResponse.error("wordId is required")); } - Optional optWord = wordRepository.findById(wordId); + Optional optWord = wordService.getWord(wordId); if (optWord.isEmpty()) { return createResponse(404, ApiResponse.error("Word not found")); } @@ -178,39 +139,15 @@ private APIGatewayProxyResponseEvent updateWord(APIGatewayProxyRequestEvent requ return createResponse(400, ApiResponse.error("wordId is required")); } - Optional optWord = wordRepository.findById(wordId); - if (optWord.isEmpty()) { - return createResponse(404, ApiResponse.error("Word not found")); - } - - Word word = optWord.get(); String body = request.getBody(); Map requestBody = ResponseUtil.gson().fromJson(body, Map.class); - if (requestBody.containsKey("english")) { - word.setEnglish((String) requestBody.get("english")); - } - if (requestBody.containsKey("korean")) { - word.setKorean((String) requestBody.get("korean")); - } - if (requestBody.containsKey("example")) { - word.setExample((String) requestBody.get("example")); - } - if (requestBody.containsKey("level")) { - String newLevel = (String) requestBody.get("level"); - word.setLevel(newLevel); - word.setGsi1pk("LEVEL#" + newLevel); - } - if (requestBody.containsKey("category")) { - String newCategory = (String) requestBody.get("category"); - word.setCategory(newCategory); - word.setGsi2pk("CATEGORY#" + newCategory); + try { + Word word = wordService.updateWord(wordId, requestBody); + return createResponse(200, ApiResponse.success("Word updated", word)); + } catch (IllegalArgumentException e) { + return createResponse(404, ApiResponse.error(e.getMessage())); } - - wordRepository.save(word); - - logger.info("Updated word: {}", wordId); - return createResponse(200, ApiResponse.success("Word updated", word)); } private APIGatewayProxyResponseEvent deleteWord(APIGatewayProxyRequestEvent request) { @@ -221,15 +158,12 @@ private APIGatewayProxyResponseEvent deleteWord(APIGatewayProxyRequestEvent requ return createResponse(400, ApiResponse.error("wordId is required")); } - Optional optWord = wordRepository.findById(wordId); - if (optWord.isEmpty()) { - return createResponse(404, ApiResponse.error("Word not found")); + try { + wordService.deleteWord(wordId); + return createResponse(200, ApiResponse.success("Word deleted", null)); + } catch (IllegalArgumentException e) { + return createResponse(404, ApiResponse.error(e.getMessage())); } - - wordRepository.delete(wordId); - - logger.info("Deleted word: {}", wordId); - return createResponse(200, ApiResponse.success("Word deleted", null)); } @SuppressWarnings("unchecked") @@ -242,58 +176,14 @@ private APIGatewayProxyResponseEvent createWordsBatch(APIGatewayProxyRequestEven return createResponse(400, ApiResponse.error("words array is required")); } - String now = Instant.now().toString(); - List createdWords = new ArrayList<>(); - int successCount = 0; - int failCount = 0; - - for (Map wordData : wordsList) { - try { - String english = (String) wordData.get("english"); - String korean = (String) wordData.get("korean"); - String example = (String) wordData.get("example"); - String level = (String) wordData.getOrDefault("level", "BEGINNER"); - String category = (String) wordData.getOrDefault("category", "DAILY"); - - if (english == null || korean == null) { - failCount++; - continue; - } - - String wordId = UUID.randomUUID().toString(); - - Word word = Word.builder() - .pk("WORD#" + wordId) - .sk("METADATA") - .gsi1pk("LEVEL#" + level) - .gsi1sk("WORD#" + wordId) - .gsi2pk("CATEGORY#" + category) - .gsi2sk("WORD#" + wordId) - .wordId(wordId) - .english(english) - .korean(korean) - .example(example) - .level(level) - .category(category) - .createdAt(now) - .build(); - - wordRepository.save(word); - createdWords.add(word); - successCount++; - } catch (Exception e) { - logger.error("Failed to create word", e); - failCount++; - } - } + WordService.BatchResult result = wordService.createWordsBatch(wordsList); - Map result = new HashMap<>(); - result.put("successCount", successCount); - result.put("failCount", failCount); - result.put("totalRequested", wordsList.size()); + Map response = new HashMap<>(); + response.put("successCount", result.successCount()); + response.put("failCount", result.failCount()); + response.put("totalRequested", result.totalRequested()); - logger.info("Batch created {} words, failed {}", successCount, failCount); - return createResponse(201, ApiResponse.success("Batch completed", result)); + return createResponse(201, ApiResponse.success("Batch completed", response)); } private APIGatewayProxyResponseEvent searchWords(APIGatewayProxyRequestEvent request) { @@ -311,8 +201,7 @@ private APIGatewayProxyResponseEvent searchWords(APIGatewayProxyRequestEvent req limit = Math.min(Integer.parseInt(queryParams.get("limit")), 50); } - // 영어/한국어 모두 검색 - PaginatedResult wordPage = wordRepository.searchByKeyword(query, limit, cursor); + PaginatedResult wordPage = wordService.searchWords(query, limit, cursor); Map result = new HashMap<>(); result.put("words", wordPage.getItems()); From 56563fa4f8ec0c0e5917699b645732639ce74d0a Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 8 Jan 2026 12:39:28 +0900 Subject: [PATCH 025/528] =?UTF-8?q?feat:=20TestService=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20refs=20#82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vocabulary/service/TestService.java | 242 ++++++++++++++++++ 1 file changed, 242 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestService.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestService.java new file mode 100644 index 00000000..cc31edfb --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestService.java @@ -0,0 +1,242 @@ +package com.mzc.secondproject.serverless.domain.vocabulary.service; + +import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.common.util.AwsClients; +import com.mzc.secondproject.serverless.common.util.ResponseUtil; +import com.mzc.secondproject.serverless.domain.vocabulary.model.DailyStudy; +import com.mzc.secondproject.serverless.domain.vocabulary.model.TestResult; +import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; +import com.mzc.secondproject.serverless.domain.vocabulary.repository.DailyStudyRepository; +import com.mzc.secondproject.serverless.domain.vocabulary.repository.TestResultRepository; +import com.mzc.secondproject.serverless.domain.vocabulary.repository.WordRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.services.sns.model.PublishRequest; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Random; +import java.util.UUID; +import java.util.stream.Collectors; + +public class TestService { + + private static final Logger logger = LoggerFactory.getLogger(TestService.class); + private static final String TEST_RESULT_TOPIC_ARN = System.getenv("TEST_RESULT_TOPIC_ARN"); + + private final TestResultRepository testResultRepository; + private final DailyStudyRepository dailyStudyRepository; + private final WordRepository wordRepository; + + public TestService() { + this.testResultRepository = new TestResultRepository(); + this.dailyStudyRepository = new DailyStudyRepository(); + this.wordRepository = new WordRepository(); + } + + public StartTestResult startTest(String userId, String testType) { + String today = LocalDate.now().toString(); + + Optional optDailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today); + if (optDailyStudy.isEmpty()) { + throw new IllegalStateException("No daily study found for today"); + } + + DailyStudy dailyStudy = optDailyStudy.get(); + List allWordIds = new ArrayList<>(); + if (dailyStudy.getNewWordIds() != null) allWordIds.addAll(dailyStudy.getNewWordIds()); + if (dailyStudy.getReviewWordIds() != null) allWordIds.addAll(dailyStudy.getReviewWordIds()); + + if (allWordIds.isEmpty()) { + throw new IllegalStateException("No words to test"); + } + + List words = wordRepository.findByIds(allWordIds); + + Map> wordsByLevel = words.stream() + .collect(Collectors.groupingBy(Word::getLevel)); + + Map> distractorsByLevel = new HashMap<>(); + for (String level : wordsByLevel.keySet()) { + List distractors = getDistractorsForLevel(level, allWordIds); + distractorsByLevel.put(level, distractors); + } + + Random random = new Random(); + List> questions = new ArrayList<>(); + for (Word word : words) { + Map question = new HashMap<>(); + question.put("wordId", word.getWordId()); + question.put("english", word.getEnglish()); + question.put("example", word.getExample()); + + List options = generateOptions(word, wordsByLevel, distractorsByLevel, random); + question.put("options", options); + + questions.add(question); + } + + String testId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + + logger.info("Started test: userId={}, testId={}, questions={}", userId, testId, questions.size()); + + return new StartTestResult(testId, testType, questions, questions.size(), now); + } + + public SubmitTestResult submitTest(String userId, String testId, String testType, + List> answers, String startedAt) { + String now = Instant.now().toString(); + String today = LocalDate.now().toString(); + + int correctCount = 0; + int incorrectCount = 0; + List incorrectWordIds = new ArrayList<>(); + List> results = new ArrayList<>(); + + List wordIds = answers.stream() + .map(a -> (String) a.get("wordId")) + .collect(Collectors.toList()); + List words = wordRepository.findByIds(wordIds); + + Map wordMap = words.stream() + .collect(Collectors.toMap(Word::getWordId, w -> w)); + + for (Map answer : answers) { + String wordId = (String) answer.get("wordId"); + String userAnswer = (String) answer.get("answer"); + + Word word = wordMap.get(wordId); + if (word != null) { + boolean isCorrect = word.getKorean().trim().equalsIgnoreCase(userAnswer.trim()); + + Map resultItem = new HashMap<>(); + resultItem.put("wordId", wordId); + resultItem.put("english", word.getEnglish()); + resultItem.put("correctAnswer", word.getKorean()); + resultItem.put("userAnswer", userAnswer); + resultItem.put("isCorrect", isCorrect); + results.add(resultItem); + + if (isCorrect) { + correctCount++; + } else { + incorrectCount++; + incorrectWordIds.add(wordId); + } + } + } + + int totalQuestions = answers.size(); + double successRate = totalQuestions > 0 ? (correctCount * 100.0 / totalQuestions) : 0; + + TestResult testResult = TestResult.builder() + .pk("TEST#" + userId) + .sk("RESULT#" + now) + .gsi1pk("TEST#ALL") + .gsi1sk("DATE#" + today) + .testId(testId) + .userId(userId) + .testType(testType) + .totalQuestions(totalQuestions) + .correctAnswers(correctCount) + .incorrectAnswers(incorrectCount) + .successRate(successRate) + .incorrectWordIds(incorrectWordIds) + .startedAt(startedAt) + .completedAt(now) + .build(); + + testResultRepository.save(testResult); + + publishTestResultToSns(userId, results); + + logger.info("Test submitted: userId={}, testId={}, successRate={}%", userId, testId, successRate); + + return new SubmitTestResult(testId, testType, totalQuestions, correctCount, incorrectCount, successRate, results); + } + + public PaginatedResult getTestResults(String userId, int limit, String cursor) { + return testResultRepository.findByUserIdWithPagination(userId, limit, cursor); + } + + private List getDistractorsForLevel(String level, List excludeWordIds) { + PaginatedResult wordPage = wordRepository.findByLevelWithPagination(level, 50, null); + return wordPage.getItems().stream() + .filter(w -> !excludeWordIds.contains(w.getWordId())) + .map(Word::getKorean) + .collect(Collectors.toList()); + } + + private List generateOptions(Word correctWord, Map> wordsByLevel, + Map> distractorsByLevel, Random random) { + List options = new ArrayList<>(); + String correctAnswer = correctWord.getKorean(); + options.add(correctAnswer); + + String level = correctWord.getLevel(); + + List sameLevelOptions = wordsByLevel.getOrDefault(level, new ArrayList<>()).stream() + .filter(w -> !w.getWordId().equals(correctWord.getWordId())) + .map(Word::getKorean) + .collect(Collectors.toList()); + + List additionalDistractors = distractorsByLevel.getOrDefault(level, new ArrayList<>()); + + List allDistractors = new ArrayList<>(); + allDistractors.addAll(sameLevelOptions); + allDistractors.addAll(additionalDistractors); + + allDistractors = allDistractors.stream() + .filter(d -> !d.equals(correctAnswer)) + .distinct() + .collect(Collectors.toList()); + + Collections.shuffle(allDistractors, random); + int distractorCount = Math.min(3, allDistractors.size()); + for (int i = 0; i < distractorCount; i++) { + options.add(allDistractors.get(i)); + } + + Collections.shuffle(options, random); + return options; + } + + private void publishTestResultToSns(String userId, List> results) { + if (TEST_RESULT_TOPIC_ARN == null || TEST_RESULT_TOPIC_ARN.isEmpty()) { + logger.warn("TEST_RESULT_TOPIC_ARN is not configured, skipping SNS publish"); + return; + } + + try { + Map message = new HashMap<>(); + message.put("userId", userId); + message.put("results", results); + + String messageJson = ResponseUtil.gson().toJson(message); + + PublishRequest publishRequest = PublishRequest.builder() + .topicArn(TEST_RESULT_TOPIC_ARN) + .message(messageJson) + .build(); + + AwsClients.sns().publish(publishRequest); + logger.info("Published test result to SNS for user: {}", userId); + } catch (Exception e) { + logger.error("Failed to publish test result to SNS for user: {}", userId, e); + } + } + + public record StartTestResult(String testId, String testType, List> questions, + int totalQuestions, String startedAt) {} + + public record SubmitTestResult(String testId, String testType, int totalQuestions, + int correctCount, int incorrectCount, double successRate, + List> results) {} +} From a73369b69f48c535a29b8215c14147c6e5d302ba Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 8 Jan 2026 12:39:28 +0900 Subject: [PATCH 026/528] =?UTF-8?q?refactor:=20TestHandler=EC=97=90?= =?UTF-8?q?=EC=84=9C=20Service=20=EA=B3=84=EC=B8=B5=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=20refs=20#82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vocabulary/handler/TestHandler.java | 277 ++---------------- 1 file changed, 29 insertions(+), 248 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/TestHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/TestHandler.java index db4ffb91..32f2316b 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/TestHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/TestHandler.java @@ -8,43 +8,23 @@ import com.mzc.secondproject.serverless.common.dto.PaginatedResult; import com.mzc.secondproject.serverless.common.util.ResponseUtil; import static com.mzc.secondproject.serverless.common.util.ResponseUtil.createResponse; -import com.mzc.secondproject.serverless.domain.vocabulary.model.DailyStudy; import com.mzc.secondproject.serverless.domain.vocabulary.model.TestResult; -import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; -import com.mzc.secondproject.serverless.domain.vocabulary.repository.DailyStudyRepository; -import com.mzc.secondproject.serverless.domain.vocabulary.repository.TestResultRepository; -import com.mzc.secondproject.serverless.domain.vocabulary.repository.WordRepository; +import com.mzc.secondproject.serverless.domain.vocabulary.service.TestService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import software.amazon.awssdk.services.sns.SnsClient; -import software.amazon.awssdk.services.sns.model.PublishRequest; -import java.time.Instant; -import java.time.LocalDate; -import java.util.ArrayList; -import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.Optional; -import java.util.Random; -import java.util.UUID; -import java.util.stream.Collectors; public class TestHandler implements RequestHandler { private static final Logger logger = LoggerFactory.getLogger(TestHandler.class); - private static final SnsClient snsClient = SnsClient.builder().build(); - private static final String TEST_RESULT_TOPIC_ARN = System.getenv("TEST_RESULT_TOPIC_ARN"); - private final TestResultRepository testResultRepository; - private final DailyStudyRepository dailyStudyRepository; - private final WordRepository wordRepository; + private final TestService testService; public TestHandler() { - this.testResultRepository = new TestResultRepository(); - this.dailyStudyRepository = new DailyStudyRepository(); - this.wordRepository = new WordRepository(); + this.testService = new TestService(); } @Override @@ -55,17 +35,14 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent re logger.info("Received request: {} {}", httpMethod, path); try { - // POST /vocab/test/{userId}/start - 시험 시작 if ("POST".equals(httpMethod) && path.endsWith("/start")) { return startTest(request); } - // POST /vocab/test/{userId}/submit - 답안 제출 if ("POST".equals(httpMethod) && path.endsWith("/submit")) { return submitAnswer(request); } - // GET /vocab/test/{userId}/results - 시험 결과 조회 if ("GET".equals(httpMethod) && path.endsWith("/results")) { return getTestResults(request); } @@ -90,64 +67,20 @@ private APIGatewayProxyResponseEvent startTest(APIGatewayProxyRequestEvent reque Map requestBody = ResponseUtil.gson().fromJson(body, Map.class); String testType = (String) requestBody.getOrDefault("testType", "DAILY"); - String today = LocalDate.now().toString(); - - // 오늘 학습한 단어 기반 시험 - Optional optDailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today); - if (optDailyStudy.isEmpty()) { - return createResponse(404, ApiResponse.error("No daily study found for today")); - } - - DailyStudy dailyStudy = optDailyStudy.get(); - List allWordIds = new ArrayList<>(); - if (dailyStudy.getNewWordIds() != null) allWordIds.addAll(dailyStudy.getNewWordIds()); - if (dailyStudy.getReviewWordIds() != null) allWordIds.addAll(dailyStudy.getReviewWordIds()); - - if (allWordIds.isEmpty()) { - return createResponse(400, ApiResponse.error("No words to test")); - } - - // 시험 문제 생성 (BatchGetItem으로 한 번에 조회) - List words = wordRepository.findByIds(allWordIds); - - // 레벨별로 단어 그룹화하여 오답 후보 준비 - Map> wordsByLevel = words.stream() - .collect(Collectors.groupingBy(Word::getLevel)); - - // 각 레벨별 추가 오답 후보 단어 조회 (문제 단어 외의 다른 단어들) - Map> distractorsByLevel = new HashMap<>(); - for (String level : wordsByLevel.keySet()) { - List distractors = getDistractorsForLevel(level, allWordIds); - distractorsByLevel.put(level, distractors); - } - - Random random = new Random(); - List> questions = new ArrayList<>(); - for (Word word : words) { - Map question = new HashMap<>(); - question.put("wordId", word.getWordId()); - question.put("english", word.getEnglish()); - question.put("example", word.getExample()); - - // 4지선다 옵션 생성 - List options = generateOptions(word, wordsByLevel, distractorsByLevel, random); - question.put("options", options); - - questions.add(question); + try { + TestService.StartTestResult result = testService.startTest(userId, testType); + + Map response = new HashMap<>(); + response.put("testId", result.testId()); + response.put("testType", result.testType()); + response.put("questions", result.questions()); + response.put("totalQuestions", result.totalQuestions()); + response.put("startedAt", result.startedAt()); + + return createResponse(200, ApiResponse.success("Test started", response)); + } catch (IllegalStateException e) { + return createResponse(404, ApiResponse.error(e.getMessage())); } - - String testId = UUID.randomUUID().toString(); - String now = Instant.now().toString(); - - Map result = new HashMap<>(); - result.put("testId", testId); - result.put("testType", testType); - result.put("questions", questions); - result.put("totalQuestions", questions.size()); - result.put("startedAt", now); - - logger.info("Started test: userId={}, testId={}, questions={}", userId, testId, questions.size()); - return createResponse(200, ApiResponse.success("Test started", result)); } @SuppressWarnings("unchecked") @@ -165,93 +98,24 @@ private APIGatewayProxyResponseEvent submitAnswer(APIGatewayProxyRequestEvent re String testId = (String) requestBody.get("testId"); String testType = (String) requestBody.getOrDefault("testType", "DAILY"); List> answers = (List>) requestBody.get("answers"); + String startedAt = (String) requestBody.get("startedAt"); if (testId == null || answers == null) { return createResponse(400, ApiResponse.error("testId and answers are required")); } - String now = Instant.now().toString(); - String today = LocalDate.now().toString(); - - int correctCount = 0; - int incorrectCount = 0; - List incorrectWordIds = new ArrayList<>(); - List> results = new ArrayList<>(); - - // 모든 wordId를 추출하여 BatchGetItem으로 한 번에 조회 - List wordIds = answers.stream() - .map(a -> (String) a.get("wordId")) - .collect(java.util.stream.Collectors.toList()); - List words = wordRepository.findByIds(wordIds); - - // wordId -> Word 맵 생성 - Map wordMap = words.stream() - .collect(java.util.stream.Collectors.toMap(Word::getWordId, w -> w)); - - for (Map answer : answers) { - String wordId = (String) answer.get("wordId"); - String userAnswer = (String) answer.get("answer"); - - Word word = wordMap.get(wordId); - if (word != null) { - // 대소문자 무시, 공백 제거 후 비교 - boolean isCorrect = word.getKorean().trim().equalsIgnoreCase(userAnswer.trim()); - - // 결과 상세 정보 추가 - Map resultItem = new HashMap<>(); - resultItem.put("wordId", wordId); - resultItem.put("english", word.getEnglish()); - resultItem.put("correctAnswer", word.getKorean()); - resultItem.put("userAnswer", userAnswer); - resultItem.put("isCorrect", isCorrect); - results.add(resultItem); - - if (isCorrect) { - correctCount++; - } else { - incorrectCount++; - incorrectWordIds.add(wordId); - } - } - } + TestService.SubmitTestResult result = testService.submitTest(userId, testId, testType, answers, startedAt); - int totalQuestions = answers.size(); - double successRate = totalQuestions > 0 ? (correctCount * 100.0 / totalQuestions) : 0; - - TestResult testResult = TestResult.builder() - .pk("TEST#" + userId) - .sk("RESULT#" + now) - .gsi1pk("TEST#ALL") - .gsi1sk("DATE#" + today) - .testId(testId) - .userId(userId) - .testType(testType) - .totalQuestions(totalQuestions) - .correctAnswers(correctCount) - .incorrectAnswers(incorrectCount) - .successRate(successRate) - .incorrectWordIds(incorrectWordIds) - .startedAt((String) requestBody.get("startedAt")) - .completedAt(now) - .build(); - - testResultRepository.save(testResult); - - // SNS로 시험 결과 발행 (비동기 통계 처리용) - publishTestResultToSns(userId, results); - - // 응답 데이터 구성 (results 포함) - Map responseData = new HashMap<>(); - responseData.put("testId", testId); - responseData.put("testType", testType); - responseData.put("totalQuestions", totalQuestions); - responseData.put("correctCount", correctCount); - responseData.put("incorrectCount", incorrectCount); - responseData.put("successRate", successRate); - responseData.put("results", results); - - logger.info("Test submitted: userId={}, testId={}, successRate={}%", userId, testId, successRate); - return createResponse(200, ApiResponse.success("Test submitted", responseData)); + Map response = new HashMap<>(); + response.put("testId", result.testId()); + response.put("testType", result.testType()); + response.put("totalQuestions", result.totalQuestions()); + response.put("correctCount", result.correctCount()); + response.put("incorrectCount", result.incorrectCount()); + response.put("successRate", result.successRate()); + response.put("results", result.results()); + + return createResponse(200, ApiResponse.success("Test submitted", response)); } private APIGatewayProxyResponseEvent getTestResults(APIGatewayProxyRequestEvent request) { @@ -270,7 +134,7 @@ private APIGatewayProxyResponseEvent getTestResults(APIGatewayProxyRequestEvent limit = Math.min(Integer.parseInt(queryParams.get("limit")), 50); } - PaginatedResult resultPage = testResultRepository.findByUserIdWithPagination(userId, limit, cursor); + PaginatedResult resultPage = testService.getTestResults(userId, limit, cursor); Map result = new HashMap<>(); result.put("testResults", resultPage.getItems()); @@ -279,87 +143,4 @@ private APIGatewayProxyResponseEvent getTestResults(APIGatewayProxyRequestEvent return createResponse(200, ApiResponse.success("Test results retrieved", result)); } - - /** - * 해당 레벨에서 오답 후보 단어들의 한국어 뜻 목록을 가져옴 - */ - private List getDistractorsForLevel(String level, List excludeWordIds) { - PaginatedResult wordPage = wordRepository.findByLevelWithPagination(level, 50, null); - return wordPage.getItems().stream() - .filter(w -> !excludeWordIds.contains(w.getWordId())) - .map(Word::getKorean) - .collect(Collectors.toList()); - } - - /** - * 4지선다 옵션 생성 (정답 1개 + 오답 3개, 셔플됨) - */ - private List generateOptions(Word correctWord, Map> wordsByLevel, - Map> distractorsByLevel, Random random) { - List options = new ArrayList<>(); - String correctAnswer = correctWord.getKorean(); - options.add(correctAnswer); - - String level = correctWord.getLevel(); - - // 같은 레벨의 다른 문제 단어들에서 오답 후보 추출 - List sameLevelOptions = wordsByLevel.getOrDefault(level, new ArrayList<>()).stream() - .filter(w -> !w.getWordId().equals(correctWord.getWordId())) - .map(Word::getKorean) - .collect(Collectors.toList()); - - // 추가 오답 후보 (문제에 포함되지 않은 단어들) - List additionalDistractors = distractorsByLevel.getOrDefault(level, new ArrayList<>()); - - // 모든 오답 후보 합치기 - List allDistractors = new ArrayList<>(); - allDistractors.addAll(sameLevelOptions); - allDistractors.addAll(additionalDistractors); - - // 중복 및 정답 제거 - allDistractors = allDistractors.stream() - .filter(d -> !d.equals(correctAnswer)) - .distinct() - .collect(Collectors.toList()); - - // 랜덤하게 3개 선택 - Collections.shuffle(allDistractors, random); - int distractorCount = Math.min(3, allDistractors.size()); - for (int i = 0; i < distractorCount; i++) { - options.add(allDistractors.get(i)); - } - - // 옵션 셔플 - Collections.shuffle(options, random); - return options; - } - - /** - * SNS로 시험 결과 발행 (비동기 통계 처리용) - */ - private void publishTestResultToSns(String userId, List> results) { - if (TEST_RESULT_TOPIC_ARN == null || TEST_RESULT_TOPIC_ARN.isEmpty()) { - logger.warn("TEST_RESULT_TOPIC_ARN is not configured, skipping SNS publish"); - return; - } - - try { - Map message = new HashMap<>(); - message.put("userId", userId); - message.put("results", results); - - String messageJson = ResponseUtil.gson().toJson(message); - - PublishRequest publishRequest = PublishRequest.builder() - .topicArn(TEST_RESULT_TOPIC_ARN) - .message(messageJson) - .build(); - - snsClient.publish(publishRequest); - logger.info("Published test result to SNS for user: {}", userId); - } catch (Exception e) { - // SNS 발행 실패해도 API 응답에는 영향 없음 (fire-and-forget) - logger.error("Failed to publish test result to SNS for user: {}", userId, e); - } - } } From e866e64a32de07860303fae211ade64bda9f9723 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 8 Jan 2026 12:41:19 +0900 Subject: [PATCH 027/528] =?UTF-8?q?refactor:=20PollyService=EC=97=90?= =?UTF-8?q?=EC=84=9C=20AwsClients=20=EC=82=AC=EC=9A=A9=20refs=20#82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../serverless/common/service/PollyService.java | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/service/PollyService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/service/PollyService.java index 6c26db6e..c9678c98 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/service/PollyService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/service/PollyService.java @@ -1,15 +1,13 @@ package com.mzc.secondproject.serverless.common.service; +import com.mzc.secondproject.serverless.common.util.AwsClients; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import software.amazon.awssdk.core.sync.RequestBody; -import software.amazon.awssdk.services.polly.PollyClient; import software.amazon.awssdk.services.polly.model.OutputFormat; import software.amazon.awssdk.services.polly.model.SynthesizeSpeechRequest; import software.amazon.awssdk.services.polly.model.VoiceId; -import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.model.PutObjectRequest; -import software.amazon.awssdk.services.s3.presigner.S3Presigner; import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest; import software.amazon.awssdk.services.s3.model.GetObjectRequest; @@ -28,11 +26,6 @@ public class PollyService { private static final Logger logger = LoggerFactory.getLogger(PollyService.class); - // Singleton 패턴으로 Cold Start 최적화 - private static final PollyClient pollyClient = PollyClient.builder().build(); - private static final S3Client s3Client = S3Client.builder().build(); - private static final S3Presigner s3Presigner = S3Presigner.builder().build(); - private final String bucketName; private final String s3KeyPrefix; @@ -80,7 +73,7 @@ public String getPresignedUrl(String s3Key) { .getObjectRequest(getObjectRequest) .build(); - PresignedGetObjectRequest presignedRequest = s3Presigner.presignGetObject(presignRequest); + PresignedGetObjectRequest presignedRequest = AwsClients.s3Presigner().presignGetObject(presignRequest); return presignedRequest.url().toString(); } @@ -89,7 +82,7 @@ public String getPresignedUrl(String s3Key) { */ public boolean existsInS3(String s3Key) { try { - s3Client.headObject(HeadObjectRequest.builder() + AwsClients.s3().headObject(HeadObjectRequest.builder() .bucket(bucketName) .key(s3Key) .build()); @@ -113,7 +106,7 @@ private void synthesizeAndSave(String text, String voice, String s3Key) { .outputFormat(OutputFormat.MP3) .build(); - InputStream audioStream = pollyClient.synthesizeSpeech(request); + InputStream audioStream = AwsClients.polly().synthesizeSpeech(request); ByteArrayOutputStream buffer = new ByteArrayOutputStream(); byte[] data = new byte[4096]; @@ -123,7 +116,7 @@ private void synthesizeAndSave(String text, String voice, String s3Key) { } byte[] audioBytes = buffer.toByteArray(); - s3Client.putObject( + AwsClients.s3().putObject( PutObjectRequest.builder() .bucket(bucketName) .key(s3Key) From 826e7d68aa9210b62083604db4417e975a274d78 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 8 Jan 2026 12:41:19 +0900 Subject: [PATCH 028/528] =?UTF-8?q?refactor:=20BedrockService=EC=97=90?= =?UTF-8?q?=EC=84=9C=20AwsClients=20=EC=82=AC=EC=9A=A9=20refs=20#82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../serverless/domain/chatting/service/BedrockService.java | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/BedrockService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/BedrockService.java index 01a4688c..b82ee520 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/BedrockService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/BedrockService.java @@ -1,9 +1,9 @@ package com.mzc.secondproject.serverless.domain.chatting.service; +import com.mzc.secondproject.serverless.common.util.AwsClients; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import software.amazon.awssdk.core.SdkBytes; -import software.amazon.awssdk.services.bedrockruntime.BedrockRuntimeClient; import software.amazon.awssdk.services.bedrockruntime.model.InvokeModelRequest; import software.amazon.awssdk.services.bedrockruntime.model.InvokeModelResponse; @@ -19,10 +19,7 @@ public class BedrockService { // Claude 3 Sonnet 모델 ID private static final String MODEL_ID = "anthropic.claude-3-sonnet-20240229-v1:0"; - private final BedrockRuntimeClient bedrockClient; - public BedrockService() { - this.bedrockClient = BedrockRuntimeClient.builder().build(); } public String generateResponse(String prompt) { @@ -49,7 +46,7 @@ public String generateResponse(String prompt) { .body(SdkBytes.fromUtf8String(gson.toJson(requestBody))) .build(); - InvokeModelResponse response = bedrockClient.invokeModel(request); + InvokeModelResponse response = AwsClients.bedrock().invokeModel(request); String responseBody = response.body().asUtf8String(); JsonObject jsonResponse = gson.fromJson(responseBody, JsonObject.class); From 6c37aaf484153960edf325bc8caf5120afd78b42 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 8 Jan 2026 12:44:05 +0900 Subject: [PATCH 029/528] =?UTF-8?q?feat:=20DailyStudyService=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20refs=20#94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vocabulary/service/DailyStudyService.java | 170 ++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyService.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyService.java new file mode 100644 index 00000000..17f763e6 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyService.java @@ -0,0 +1,170 @@ +package com.mzc.secondproject.serverless.domain.vocabulary.service; + +import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.domain.vocabulary.model.DailyStudy; +import com.mzc.secondproject.serverless.domain.vocabulary.model.UserWord; +import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; +import com.mzc.secondproject.serverless.domain.vocabulary.repository.DailyStudyRepository; +import com.mzc.secondproject.serverless.domain.vocabulary.repository.UserWordRepository; +import com.mzc.secondproject.serverless.domain.vocabulary.repository.WordRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +public class DailyStudyService { + + private static final Logger logger = LoggerFactory.getLogger(DailyStudyService.class); + + private static final int NEW_WORDS_COUNT = 50; + private static final int REVIEW_WORDS_COUNT = 5; + + private final DailyStudyRepository dailyStudyRepository; + private final UserWordRepository userWordRepository; + private final WordRepository wordRepository; + + public DailyStudyService() { + this.dailyStudyRepository = new DailyStudyRepository(); + this.userWordRepository = new UserWordRepository(); + this.wordRepository = new WordRepository(); + } + + public DailyStudyResult getDailyWords(String userId, String level) { + String today = LocalDate.now().toString(); + + Optional optDailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today); + + DailyStudy dailyStudy; + if (optDailyStudy.isPresent()) { + dailyStudy = optDailyStudy.get(); + } else { + if (level == null || level.isEmpty()) { + throw new IllegalArgumentException("level is required for first daily study (BEGINNER, INTERMEDIATE, ADVANCED)"); + } + if (!level.equals("BEGINNER") && !level.equals("INTERMEDIATE") && !level.equals("ADVANCED")) { + throw new IllegalArgumentException("Invalid level. Must be BEGINNER, INTERMEDIATE, or ADVANCED"); + } + dailyStudy = createDailyStudy(userId, today, level); + } + + List newWords = getWordDetails(dailyStudy.getNewWordIds()); + List reviewWords = getWordDetails(dailyStudy.getReviewWordIds()); + Map progress = calculateProgress(dailyStudy); + + return new DailyStudyResult(dailyStudy, newWords, reviewWords, progress); + } + + public Map markWordLearned(String userId, String wordId) { + String today = LocalDate.now().toString(); + + Optional optDailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today); + if (optDailyStudy.isEmpty()) { + throw new IllegalStateException("Daily study not found"); + } + + DailyStudy dailyStudy = optDailyStudy.get(); + + if (dailyStudy.getLearnedWordIds() != null && dailyStudy.getLearnedWordIds().contains(wordId)) { + return calculateProgress(dailyStudy); + } + + dailyStudyRepository.addLearnedWord(userId, today, wordId); + + DailyStudy updatedDailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today).orElse(dailyStudy); + + if (updatedDailyStudy.getLearnedCount() >= updatedDailyStudy.getTotalWords()) { + updatedDailyStudy.setIsCompleted(true); + dailyStudyRepository.save(updatedDailyStudy); + } + + logger.info("Marked word as learned: userId={}, wordId={}", userId, wordId); + return calculateProgress(updatedDailyStudy); + } + + private DailyStudy createDailyStudy(String userId, String date, String level) { + String now = Instant.now().toString(); + + PaginatedResult reviewPage = userWordRepository.findReviewDueWords(userId, date, REVIEW_WORDS_COUNT, null); + List reviewWordIds = reviewPage.getItems().stream() + .map(UserWord::getWordId) + .collect(Collectors.toList()); + + List newWordIds = getNewWordsForUser(userId, level, NEW_WORDS_COUNT); + + DailyStudy dailyStudy = DailyStudy.builder() + .pk("DAILY#" + userId) + .sk("DATE#" + date) + .gsi1pk("DAILY#ALL") + .gsi1sk("DATE#" + date) + .userId(userId) + .date(date) + .newWordIds(newWordIds) + .reviewWordIds(reviewWordIds) + .learnedWordIds(new ArrayList<>()) + .totalWords(newWordIds.size() + reviewWordIds.size()) + .learnedCount(0) + .isCompleted(false) + .createdAt(now) + .updatedAt(now) + .build(); + + dailyStudyRepository.save(dailyStudy); + logger.info("Created daily study for user: {}, date: {}", userId, date); + + return dailyStudy; + } + + private List getNewWordsForUser(String userId, String level, int count) { + PaginatedResult userWordPage = userWordRepository.findByUserIdWithPagination(userId, 1000, null); + List learnedWordIds = userWordPage.getItems().stream() + .map(UserWord::getWordId) + .collect(Collectors.toList()); + + List newWordIds = new ArrayList<>(); + String lastEvaluatedKey = null; + + do { + PaginatedResult wordPage = wordRepository.findByLevelWithPagination(level, count * 2, lastEvaluatedKey); + for (Word word : wordPage.getItems()) { + if (!learnedWordIds.contains(word.getWordId()) && !newWordIds.contains(word.getWordId())) { + newWordIds.add(word.getWordId()); + if (newWordIds.size() >= count) break; + } + } + lastEvaluatedKey = wordPage.getNextCursor(); + } while (newWordIds.size() < count && lastEvaluatedKey != null); + + logger.info("Selected {} new words for user {} at level {}", newWordIds.size(), userId, level); + return newWordIds; + } + + private List getWordDetails(List wordIds) { + if (wordIds == null || wordIds.isEmpty()) { + return new ArrayList<>(); + } + return wordRepository.findByIds(wordIds); + } + + private Map calculateProgress(DailyStudy dailyStudy) { + Map progress = new HashMap<>(); + int total = dailyStudy.getTotalWords(); + int learned = dailyStudy.getLearnedCount(); + + progress.put("total", total); + progress.put("learned", learned); + progress.put("remaining", total - learned); + progress.put("percentage", total > 0 ? (learned * 100.0 / total) : 0); + progress.put("isCompleted", dailyStudy.getIsCompleted()); + + return progress; + } + + public record DailyStudyResult(DailyStudy dailyStudy, List newWords, List reviewWords, Map progress) {} +} From 7e677e3cc8ff2119f3ed45d87b82874776406583 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 8 Jan 2026 12:44:05 +0900 Subject: [PATCH 030/528] =?UTF-8?q?refactor:=20DailyStudyHandler=EC=97=90?= =?UTF-8?q?=EC=84=9C=20Service=20=EA=B3=84=EC=B8=B5=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=20refs=20#94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vocabulary/handler/DailyStudyHandler.java | 184 ++---------------- 1 file changed, 18 insertions(+), 166 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/DailyStudyHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/DailyStudyHandler.java index 0017c6b8..fc35c000 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/DailyStudyHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/DailyStudyHandler.java @@ -5,41 +5,22 @@ import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; import com.mzc.secondproject.serverless.common.dto.ApiResponse; -import com.mzc.secondproject.serverless.common.dto.PaginatedResult; import static com.mzc.secondproject.serverless.common.util.ResponseUtil.createResponse; -import com.mzc.secondproject.serverless.domain.vocabulary.model.DailyStudy; -import com.mzc.secondproject.serverless.domain.vocabulary.model.UserWord; -import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; -import com.mzc.secondproject.serverless.domain.vocabulary.repository.DailyStudyRepository; -import com.mzc.secondproject.serverless.domain.vocabulary.repository.UserWordRepository; -import com.mzc.secondproject.serverless.domain.vocabulary.repository.WordRepository; +import com.mzc.secondproject.serverless.domain.vocabulary.service.DailyStudyService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.time.Instant; -import java.time.LocalDate; -import java.util.ArrayList; import java.util.HashMap; -import java.util.List; import java.util.Map; -import java.util.Optional; -import java.util.stream.Collectors; public class DailyStudyHandler implements RequestHandler { private static final Logger logger = LoggerFactory.getLogger(DailyStudyHandler.class); - private static final int NEW_WORDS_COUNT = 50; - private static final int REVIEW_WORDS_COUNT = 5; - - private final DailyStudyRepository dailyStudyRepository; - private final UserWordRepository userWordRepository; - private final WordRepository wordRepository; + private final DailyStudyService dailyStudyService; public DailyStudyHandler() { - this.dailyStudyRepository = new DailyStudyRepository(); - this.userWordRepository = new UserWordRepository(); - this.wordRepository = new WordRepository(); + this.dailyStudyService = new DailyStudyService(); } @Override @@ -50,12 +31,10 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent re logger.info("Received request: {} {}", httpMethod, path); try { - // GET /vocab/daily/{userId} - 오늘의 학습 단어 if ("GET".equals(httpMethod) && !path.contains("/learned")) { return getDailyWords(request); } - // POST /vocab/daily/{userId}/words/{wordId}/learned - 학습 완료 if ("POST".equals(httpMethod) && path.endsWith("/learned")) { return markWordLearned(request); } @@ -77,126 +56,21 @@ private APIGatewayProxyResponseEvent getDailyWords(APIGatewayProxyRequestEvent r return createResponse(400, ApiResponse.error("userId is required")); } - // 레벨 파라미터 (첫 생성 시 필수) String level = queryParams != null ? queryParams.get("level") : null; - String today = LocalDate.now().toString(); + try { + DailyStudyService.DailyStudyResult result = dailyStudyService.getDailyWords(userId, level); - // 오늘의 학습 데이터 조회 - Optional optDailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today); + Map response = new HashMap<>(); + response.put("dailyStudy", result.dailyStudy()); + response.put("newWords", result.newWords()); + response.put("reviewWords", result.reviewWords()); + response.put("progress", result.progress()); - DailyStudy dailyStudy; - if (optDailyStudy.isPresent()) { - dailyStudy = optDailyStudy.get(); - } else { - // 첫 생성 시 레벨 필수 - if (level == null || level.isEmpty()) { - return createResponse(400, ApiResponse.error("level is required for first daily study (BEGINNER, INTERMEDIATE, ADVANCED)")); - } - // 레벨 유효성 검사 - if (!level.equals("BEGINNER") && !level.equals("INTERMEDIATE") && !level.equals("ADVANCED")) { - return createResponse(400, ApiResponse.error("Invalid level. Must be BEGINNER, INTERMEDIATE, or ADVANCED")); - } - // 새로운 일일 학습 생성 - dailyStudy = createDailyStudy(userId, today, level); + return createResponse(200, ApiResponse.success("Daily words retrieved", response)); + } catch (IllegalArgumentException e) { + return createResponse(400, ApiResponse.error(e.getMessage())); } - - // 단어 상세 정보 조회 - List newWords = getWordDetails(dailyStudy.getNewWordIds()); - List reviewWords = getWordDetails(dailyStudy.getReviewWordIds()); - - Map result = new HashMap<>(); - result.put("dailyStudy", dailyStudy); - result.put("newWords", newWords); - result.put("reviewWords", reviewWords); - result.put("progress", calculateProgress(dailyStudy)); - - return createResponse(200, ApiResponse.success("Daily words retrieved", result)); - } - - private DailyStudy createDailyStudy(String userId, String date, String level) { - String now = Instant.now().toString(); - - // 복습 대상 단어 조회 (5개) - PaginatedResult reviewPage = userWordRepository.findReviewDueWords(userId, date, REVIEW_WORDS_COUNT, null); - List reviewWordIds = reviewPage.getItems().stream() - .map(UserWord::getWordId) - .collect(Collectors.toList()); - - // 신규 단어 조회 (50개) - 해당 레벨에서 아직 학습하지 않은 단어 - List newWordIds = getNewWordsForUser(userId, level, NEW_WORDS_COUNT); - - DailyStudy dailyStudy = DailyStudy.builder() - .pk("DAILY#" + userId) - .sk("DATE#" + date) - .gsi1pk("DAILY#ALL") - .gsi1sk("DATE#" + date) - .userId(userId) - .date(date) - .newWordIds(newWordIds) - .reviewWordIds(reviewWordIds) - .learnedWordIds(new ArrayList<>()) - .totalWords(newWordIds.size() + reviewWordIds.size()) - .learnedCount(0) - .isCompleted(false) - .createdAt(now) - .updatedAt(now) - .build(); - - dailyStudyRepository.save(dailyStudy); - logger.info("Created daily study for user: {}, date: {}", userId, date); - - return dailyStudy; - } - - private List getNewWordsForUser(String userId, String level, int count) { - // 사용자가 학습한 단어 목록 - PaginatedResult userWordPage = userWordRepository.findByUserIdWithPagination(userId, 1000, null); - List learnedWordIds = userWordPage.getItems().stream() - .map(UserWord::getWordId) - .collect(Collectors.toList()); - - // 해당 레벨에서 학습하지 않은 단어 선택 - List newWordIds = new ArrayList<>(); - String lastEvaluatedKey = null; - - // 페이지네이션으로 해당 레벨의 모든 단어 조회 - do { - PaginatedResult wordPage = wordRepository.findByLevelWithPagination(level, count * 2, lastEvaluatedKey); - for (Word word : wordPage.getItems()) { - if (!learnedWordIds.contains(word.getWordId()) && !newWordIds.contains(word.getWordId())) { - newWordIds.add(word.getWordId()); - if (newWordIds.size() >= count) break; - } - } - lastEvaluatedKey = wordPage.getNextCursor(); - } while (newWordIds.size() < count && lastEvaluatedKey != null); - - logger.info("Selected {} new words for user {} at level {}", newWordIds.size(), userId, level); - return newWordIds; - } - - private List getWordDetails(List wordIds) { - if (wordIds == null || wordIds.isEmpty()) { - return new ArrayList<>(); - } - - // BatchGetItem으로 한 번에 조회 (N+1 문제 해결) - return wordRepository.findByIds(wordIds); - } - - private Map calculateProgress(DailyStudy dailyStudy) { - Map progress = new HashMap<>(); - int total = dailyStudy.getTotalWords(); - int learned = dailyStudy.getLearnedCount(); - - progress.put("total", total); - progress.put("learned", learned); - progress.put("remaining", total - learned); - progress.put("percentage", total > 0 ? (learned * 100.0 / total) : 0); - progress.put("isCompleted", dailyStudy.getIsCompleted()); - - return progress; } private APIGatewayProxyResponseEvent markWordLearned(APIGatewayProxyRequestEvent request) { @@ -208,33 +82,11 @@ private APIGatewayProxyResponseEvent markWordLearned(APIGatewayProxyRequestEvent return createResponse(400, ApiResponse.error("userId and wordId are required")); } - String today = LocalDate.now().toString(); - - Optional optDailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today); - if (optDailyStudy.isEmpty()) { - return createResponse(404, ApiResponse.error("Daily study not found")); - } - - DailyStudy dailyStudy = optDailyStudy.get(); - - // 이미 학습 완료된 단어인지 확인 - if (dailyStudy.getLearnedWordIds() != null && dailyStudy.getLearnedWordIds().contains(wordId)) { - return createResponse(200, ApiResponse.success("Already marked as learned", dailyStudy)); - } - - // 학습 완료 처리 - dailyStudyRepository.addLearnedWord(userId, today, wordId); - - // 업데이트된 데이터 조회 - DailyStudy updatedDailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today).orElse(dailyStudy); - - // 완료 여부 확인 - if (updatedDailyStudy.getLearnedCount() >= updatedDailyStudy.getTotalWords()) { - updatedDailyStudy.setIsCompleted(true); - dailyStudyRepository.save(updatedDailyStudy); + try { + Map progress = dailyStudyService.markWordLearned(userId, wordId); + return createResponse(200, ApiResponse.success("Word marked as learned", progress)); + } catch (IllegalStateException e) { + return createResponse(404, ApiResponse.error(e.getMessage())); } - - logger.info("Marked word as learned: userId={}, wordId={}", userId, wordId); - return createResponse(200, ApiResponse.success("Word marked as learned", calculateProgress(updatedDailyStudy))); } } From 5b17d7cb278a76e63815f6eddd05a1004bb1e29c Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 8 Jan 2026 12:45:39 +0900 Subject: [PATCH 031/528] =?UTF-8?q?feat:=20UserWordService=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20refs=20#95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vocabulary/service/UserWordService.java | 226 ++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordService.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordService.java new file mode 100644 index 00000000..175a6f43 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordService.java @@ -0,0 +1,226 @@ +package com.mzc.secondproject.serverless.domain.vocabulary.service; + +import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.domain.vocabulary.model.UserWord; +import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; +import com.mzc.secondproject.serverless.domain.vocabulary.repository.UserWordRepository; +import com.mzc.secondproject.serverless.domain.vocabulary.repository.WordRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +public class UserWordService { + + private static final Logger logger = LoggerFactory.getLogger(UserWordService.class); + + private final UserWordRepository userWordRepository; + private final WordRepository wordRepository; + + public UserWordService() { + this.userWordRepository = new UserWordRepository(); + this.wordRepository = new WordRepository(); + } + + public UserWordsResult getUserWords(String userId, String status, String bookmarked, + String incorrectOnly, int limit, String cursor) { + PaginatedResult userWordPage; + + if ("true".equalsIgnoreCase(bookmarked)) { + userWordPage = userWordRepository.findBookmarkedWords(userId, limit, cursor); + } else if ("true".equalsIgnoreCase(incorrectOnly)) { + userWordPage = userWordRepository.findIncorrectWords(userId, limit, cursor); + } else if (status != null && !status.isEmpty()) { + userWordPage = userWordRepository.findByUserIdAndStatus(userId, status, limit, cursor); + } else { + userWordPage = userWordRepository.findByUserIdWithPagination(userId, limit, cursor); + } + + List> enrichedUserWords = enrichWithWordInfo(userWordPage.getItems()); + + return new UserWordsResult(enrichedUserWords, userWordPage.getNextCursor(), userWordPage.hasMore()); + } + + public Optional getUserWord(String userId, String wordId) { + return userWordRepository.findByUserIdAndWordId(userId, wordId); + } + + public UserWord updateUserWord(String userId, String wordId, boolean isCorrect) { + Optional optUserWord = userWordRepository.findByUserIdAndWordId(userId, wordId); + UserWord userWord; + String now = Instant.now().toString(); + + if (optUserWord.isEmpty()) { + userWord = UserWord.builder() + .pk("USER#" + userId) + .sk("WORD#" + wordId) + .gsi1pk("USER#" + userId + "#REVIEW") + .gsi2pk("USER#" + userId + "#STATUS") + .userId(userId) + .wordId(wordId) + .status("NEW") + .interval(1) + .easeFactor(2.5) + .repetitions(0) + .correctCount(0) + .incorrectCount(0) + .createdAt(now) + .build(); + } else { + userWord = optUserWord.get(); + } + + applySpacedRepetition(userWord, isCorrect); + userWord.setUpdatedAt(now); + userWord.setLastReviewedAt(now); + + userWord.setGsi1sk("DATE#" + userWord.getNextReviewAt()); + userWord.setGsi2sk("STATUS#" + userWord.getStatus()); + + userWordRepository.save(userWord); + + logger.info("Updated user word: userId={}, wordId={}, isCorrect={}", userId, wordId, isCorrect); + return userWord; + } + + public UserWord updateUserWordTag(String userId, String wordId, Boolean bookmarked, + Boolean favorite, String difficulty) { + Optional optUserWord = userWordRepository.findByUserIdAndWordId(userId, wordId); + UserWord userWord; + String now = Instant.now().toString(); + + if (optUserWord.isEmpty()) { + userWord = UserWord.builder() + .pk("USER#" + userId) + .sk("WORD#" + wordId) + .gsi1pk("USER#" + userId + "#REVIEW") + .gsi2pk("USER#" + userId + "#STATUS") + .gsi2sk("STATUS#NEW") + .userId(userId) + .wordId(wordId) + .status("NEW") + .interval(1) + .easeFactor(2.5) + .repetitions(0) + .correctCount(0) + .incorrectCount(0) + .bookmarked(false) + .favorite(false) + .createdAt(now) + .build(); + } else { + userWord = optUserWord.get(); + } + + if (bookmarked != null) { + userWord.setBookmarked(bookmarked); + } + if (favorite != null) { + userWord.setFavorite(favorite); + } + if (difficulty != null) { + if (!difficulty.equals("EASY") && !difficulty.equals("NORMAL") && !difficulty.equals("HARD")) { + throw new IllegalArgumentException("difficulty must be EASY, NORMAL, or HARD"); + } + userWord.setDifficulty(difficulty); + } + + userWord.setUpdatedAt(now); + userWordRepository.save(userWord); + + logger.info("Updated user word tag: userId={}, wordId={}", userId, wordId); + return userWord; + } + + private void applySpacedRepetition(UserWord userWord, boolean isCorrect) { + if (isCorrect) { + userWord.setCorrectCount(userWord.getCorrectCount() + 1); + userWord.setRepetitions(userWord.getRepetitions() + 1); + + if (userWord.getRepetitions() == 1) { + userWord.setInterval(1); + } else if (userWord.getRepetitions() == 2) { + userWord.setInterval(6); + } else { + int newInterval = (int) Math.round(userWord.getInterval() * userWord.getEaseFactor()); + userWord.setInterval(newInterval); + } + + if (userWord.getRepetitions() >= 5) { + userWord.setStatus("MASTERED"); + } else if (userWord.getRepetitions() >= 2) { + userWord.setStatus("REVIEWING"); + } else { + userWord.setStatus("LEARNING"); + } + } else { + userWord.setIncorrectCount(userWord.getIncorrectCount() + 1); + userWord.setRepetitions(0); + userWord.setInterval(1); + userWord.setStatus("LEARNING"); + + double newEaseFactor = userWord.getEaseFactor() - 0.2; + userWord.setEaseFactor(Math.max(1.3, newEaseFactor)); + } + + LocalDate nextReview = LocalDate.now().plusDays(userWord.getInterval()); + userWord.setNextReviewAt(nextReview.toString()); + } + + private List> enrichWithWordInfo(List userWords) { + if (userWords == null || userWords.isEmpty()) { + return new ArrayList<>(); + } + + List wordIds = userWords.stream() + .map(UserWord::getWordId) + .collect(Collectors.toList()); + + List words = wordRepository.findByIds(wordIds); + + Map wordMap = words.stream() + .collect(Collectors.toMap(Word::getWordId, w -> w, (w1, w2) -> w1)); + + List> enrichedList = new ArrayList<>(); + for (UserWord userWord : userWords) { + Map enriched = new HashMap<>(); + + enriched.put("wordId", userWord.getWordId()); + enriched.put("userId", userWord.getUserId()); + enriched.put("status", userWord.getStatus()); + enriched.put("correctCount", userWord.getCorrectCount()); + enriched.put("incorrectCount", userWord.getIncorrectCount()); + enriched.put("bookmarked", userWord.getBookmarked()); + enriched.put("favorite", userWord.getFavorite()); + enriched.put("difficulty", userWord.getDifficulty()); + enriched.put("nextReviewAt", userWord.getNextReviewAt()); + enriched.put("lastReviewedAt", userWord.getLastReviewedAt()); + enriched.put("repetitions", userWord.getRepetitions()); + enriched.put("interval", userWord.getInterval()); + + Word word = wordMap.get(userWord.getWordId()); + if (word != null) { + enriched.put("english", word.getEnglish()); + enriched.put("korean", word.getKorean()); + enriched.put("level", word.getLevel()); + enriched.put("category", word.getCategory()); + enriched.put("example", word.getExample()); + enriched.put("maleVoiceKey", word.getMaleVoiceKey()); + enriched.put("femaleVoiceKey", word.getFemaleVoiceKey()); + } + + enrichedList.add(enriched); + } + + return enrichedList; + } + + public record UserWordsResult(List> userWords, String nextCursor, boolean hasMore) {} +} From 3dc23d6c09f1ca143d9afbd7a8cd0706c5fd3c4a Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 8 Jan 2026 12:45:39 +0900 Subject: [PATCH 032/528] =?UTF-8?q?refactor:=20UserWordHandler=EC=97=90?= =?UTF-8?q?=EC=84=9C=20Service=20=EA=B3=84=EC=B8=B5=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=20refs=20#95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vocabulary/handler/UserWordHandler.java | 246 ++---------------- 1 file changed, 19 insertions(+), 227 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java index aaabe290..c4e6b762 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java @@ -5,35 +5,25 @@ import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; import com.mzc.secondproject.serverless.common.dto.ApiResponse; -import com.mzc.secondproject.serverless.common.dto.PaginatedResult; import com.mzc.secondproject.serverless.common.util.ResponseUtil; import static com.mzc.secondproject.serverless.common.util.ResponseUtil.createResponse; import com.mzc.secondproject.serverless.domain.vocabulary.model.UserWord; -import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; -import com.mzc.secondproject.serverless.domain.vocabulary.repository.UserWordRepository; -import com.mzc.secondproject.serverless.domain.vocabulary.repository.WordRepository; +import com.mzc.secondproject.serverless.domain.vocabulary.service.UserWordService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.time.Instant; -import java.time.LocalDate; -import java.util.ArrayList; import java.util.HashMap; -import java.util.List; import java.util.Map; import java.util.Optional; -import java.util.stream.Collectors; public class UserWordHandler implements RequestHandler { private static final Logger logger = LoggerFactory.getLogger(UserWordHandler.class); - private final UserWordRepository userWordRepository; - private final WordRepository wordRepository; + private final UserWordService userWordService; public UserWordHandler() { - this.userWordRepository = new UserWordRepository(); - this.wordRepository = new WordRepository(); + this.userWordService = new UserWordService(); } @Override @@ -44,22 +34,18 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent re logger.info("Received request: {} {}", httpMethod, path); try { - // GET /vocab/users/{userId}/words - 사용자 단어 목록 if ("GET".equals(httpMethod) && path.endsWith("/words")) { return getUserWords(request); } - // GET /vocab/users/{userId}/words/{wordId} - 사용자 단어 상세 if ("GET".equals(httpMethod) && path.contains("/words/")) { return getUserWord(request); } - // PUT /vocab/users/{userId}/words/{wordId}/tag - 태그 변경 if ("PUT".equals(httpMethod) && path.endsWith("/tag")) { return updateUserWordTag(request); } - // PUT /vocab/users/{userId}/words/{wordId} - 학습 상태 업데이트 if ("PUT".equals(httpMethod) && path.contains("/words/")) { return updateUserWord(request); } @@ -91,29 +77,14 @@ private APIGatewayProxyResponseEvent getUserWords(APIGatewayProxyRequestEvent re limit = Math.min(Integer.parseInt(queryParams.get("limit")), 50); } - PaginatedResult userWordPage; - - // 필터 우선순위: bookmarked > incorrectOnly > status > 전체 - if ("true".equalsIgnoreCase(bookmarked)) { - userWordPage = userWordRepository.findBookmarkedWords(userId, limit, cursor); - } else if ("true".equalsIgnoreCase(incorrectOnly)) { - userWordPage = userWordRepository.findIncorrectWords(userId, limit, cursor); - } else if (status != null && !status.isEmpty()) { - userWordPage = userWordRepository.findByUserIdAndStatus(userId, status, limit, cursor); - } else { - userWordPage = userWordRepository.findByUserIdWithPagination(userId, limit, cursor); - } - - // Word 정보 조인 (BatchGetItem) - List userWords = userWordPage.getItems(); - List> enrichedUserWords = enrichWithWordInfo(userWords); + UserWordService.UserWordsResult result = userWordService.getUserWords(userId, status, bookmarked, incorrectOnly, limit, cursor); - Map result = new HashMap<>(); - result.put("userWords", enrichedUserWords); - result.put("nextCursor", userWordPage.getNextCursor()); - result.put("hasMore", userWordPage.hasMore()); + Map response = new HashMap<>(); + response.put("userWords", result.userWords()); + response.put("nextCursor", result.nextCursor()); + response.put("hasMore", result.hasMore()); - return createResponse(200, ApiResponse.success("User words retrieved", result)); + return createResponse(200, ApiResponse.success("User words retrieved", response)); } private APIGatewayProxyResponseEvent getUserWord(APIGatewayProxyRequestEvent request) { @@ -125,7 +96,7 @@ private APIGatewayProxyResponseEvent getUserWord(APIGatewayProxyRequestEvent req return createResponse(400, ApiResponse.error("userId and wordId are required")); } - Optional optUserWord = userWordRepository.findByUserIdAndWordId(userId, wordId); + Optional optUserWord = userWordService.getUserWord(userId, wordId); if (optUserWord.isEmpty()) { return createResponse(404, ApiResponse.error("UserWord not found")); } @@ -145,55 +116,15 @@ private APIGatewayProxyResponseEvent updateUserWord(APIGatewayProxyRequestEvent String body = request.getBody(); Map requestBody = ResponseUtil.gson().fromJson(body, Map.class); - // 정답/오답 여부 Boolean isCorrect = (Boolean) requestBody.get("isCorrect"); if (isCorrect == null) { return createResponse(400, ApiResponse.error("isCorrect is required")); } - Optional optUserWord = userWordRepository.findByUserIdAndWordId(userId, wordId); - UserWord userWord; - String now = Instant.now().toString(); - - if (optUserWord.isEmpty()) { - // 새로운 UserWord 생성 - userWord = UserWord.builder() - .pk("USER#" + userId) - .sk("WORD#" + wordId) - .gsi1pk("USER#" + userId + "#REVIEW") - .gsi2pk("USER#" + userId + "#STATUS") - .userId(userId) - .wordId(wordId) - .status("NEW") - .interval(1) - .easeFactor(2.5) - .repetitions(0) - .correctCount(0) - .incorrectCount(0) - .createdAt(now) - .build(); - } else { - userWord = optUserWord.get(); - } - - // Spaced Repetition 알고리즘 적용 - applySpacedRepetition(userWord, isCorrect); - userWord.setUpdatedAt(now); - userWord.setLastReviewedAt(now); - - // GSI 업데이트 - userWord.setGsi1sk("DATE#" + userWord.getNextReviewAt()); - userWord.setGsi2sk("STATUS#" + userWord.getStatus()); - - userWordRepository.save(userWord); - - logger.info("Updated user word: userId={}, wordId={}, isCorrect={}", userId, wordId, isCorrect); + UserWord userWord = userWordService.updateUserWord(userId, wordId, isCorrect); return createResponse(200, ApiResponse.success("UserWord updated", userWord)); } - /** - * 사용자 단어 태그 변경 (북마크, 즐겨찾기, 난이도) - */ private APIGatewayProxyResponseEvent updateUserWordTag(APIGatewayProxyRequestEvent request) { Map pathParams = request.getPathParameters(); String userId = pathParams != null ? pathParams.get("userId") : null; @@ -206,154 +137,15 @@ private APIGatewayProxyResponseEvent updateUserWordTag(APIGatewayProxyRequestEve String body = request.getBody(); Map requestBody = ResponseUtil.gson().fromJson(body, Map.class); - Optional optUserWord = userWordRepository.findByUserIdAndWordId(userId, wordId); - UserWord userWord; - String now = Instant.now().toString(); - - if (optUserWord.isEmpty()) { - // 새로운 UserWord 생성 (태그만 설정) - userWord = UserWord.builder() - .pk("USER#" + userId) - .sk("WORD#" + wordId) - .gsi1pk("USER#" + userId + "#REVIEW") - .gsi2pk("USER#" + userId + "#STATUS") - .gsi2sk("STATUS#NEW") - .userId(userId) - .wordId(wordId) - .status("NEW") - .interval(1) - .easeFactor(2.5) - .repetitions(0) - .correctCount(0) - .incorrectCount(0) - .bookmarked(false) - .favorite(false) - .createdAt(now) - .build(); - } else { - userWord = optUserWord.get(); - } - - // 태그 업데이트 - if (requestBody.containsKey("bookmarked")) { - userWord.setBookmarked((Boolean) requestBody.get("bookmarked")); - } - if (requestBody.containsKey("favorite")) { - userWord.setFavorite((Boolean) requestBody.get("favorite")); - } - if (requestBody.containsKey("difficulty")) { - String difficulty = (String) requestBody.get("difficulty"); - if (difficulty != null && (difficulty.equals("EASY") || difficulty.equals("NORMAL") || difficulty.equals("HARD"))) { - userWord.setDifficulty(difficulty); - } else if (difficulty != null) { - return createResponse(400, ApiResponse.error("difficulty must be EASY, NORMAL, or HARD")); - } - } + Boolean bookmarked = (Boolean) requestBody.get("bookmarked"); + Boolean favorite = (Boolean) requestBody.get("favorite"); + String difficulty = (String) requestBody.get("difficulty"); - userWord.setUpdatedAt(now); - userWordRepository.save(userWord); - - logger.info("Updated user word tag: userId={}, wordId={}", userId, wordId); - return createResponse(200, ApiResponse.success("Tag updated", userWord)); - } - - /** - * SM-2 Spaced Repetition 알고리즘 적용 - */ - private void applySpacedRepetition(UserWord userWord, boolean isCorrect) { - if (isCorrect) { - userWord.setCorrectCount(userWord.getCorrectCount() + 1); - userWord.setRepetitions(userWord.getRepetitions() + 1); - - // 간격 계산 - if (userWord.getRepetitions() == 1) { - userWord.setInterval(1); - } else if (userWord.getRepetitions() == 2) { - userWord.setInterval(6); - } else { - int newInterval = (int) Math.round(userWord.getInterval() * userWord.getEaseFactor()); - userWord.setInterval(newInterval); - } - - // 상태 업데이트 - if (userWord.getRepetitions() >= 5) { - userWord.setStatus("MASTERED"); - } else if (userWord.getRepetitions() >= 2) { - userWord.setStatus("REVIEWING"); - } else { - userWord.setStatus("LEARNING"); - } - } else { - userWord.setIncorrectCount(userWord.getIncorrectCount() + 1); - userWord.setRepetitions(0); - userWord.setInterval(1); - userWord.setStatus("LEARNING"); - - // easeFactor 감소 (최소 1.3) - double newEaseFactor = userWord.getEaseFactor() - 0.2; - userWord.setEaseFactor(Math.max(1.3, newEaseFactor)); - } - - // 다음 복습일 계산 - LocalDate nextReview = LocalDate.now().plusDays(userWord.getInterval()); - userWord.setNextReviewAt(nextReview.toString()); - } - - /** - * UserWord 목록에 Word 정보(english, korean, level 등)를 조인 - * BatchGetItem으로 한 번에 조회하여 N+1 문제 방지 - */ - private List> enrichWithWordInfo(List userWords) { - if (userWords == null || userWords.isEmpty()) { - return new ArrayList<>(); - } - - // wordId 목록 추출 - List wordIds = userWords.stream() - .map(UserWord::getWordId) - .collect(Collectors.toList()); - - // BatchGetItem으로 Word 정보 한 번에 조회 - List words = wordRepository.findByIds(wordIds); - - // wordId -> Word 맵 생성 - Map wordMap = words.stream() - .collect(Collectors.toMap(Word::getWordId, w -> w, (w1, w2) -> w1)); - - // UserWord + Word 정보 합치기 - List> enrichedList = new ArrayList<>(); - for (UserWord userWord : userWords) { - Map enriched = new HashMap<>(); - - // UserWord 정보 - enriched.put("wordId", userWord.getWordId()); - enriched.put("userId", userWord.getUserId()); - enriched.put("status", userWord.getStatus()); - enriched.put("correctCount", userWord.getCorrectCount()); - enriched.put("incorrectCount", userWord.getIncorrectCount()); - enriched.put("bookmarked", userWord.getBookmarked()); - enriched.put("favorite", userWord.getFavorite()); - enriched.put("difficulty", userWord.getDifficulty()); - enriched.put("nextReviewAt", userWord.getNextReviewAt()); - enriched.put("lastReviewedAt", userWord.getLastReviewedAt()); - enriched.put("repetitions", userWord.getRepetitions()); - enriched.put("interval", userWord.getInterval()); - - // Word 정보 추가 - Word word = wordMap.get(userWord.getWordId()); - if (word != null) { - enriched.put("english", word.getEnglish()); - enriched.put("korean", word.getKorean()); - enriched.put("level", word.getLevel()); - enriched.put("category", word.getCategory()); - enriched.put("example", word.getExample()); - enriched.put("maleVoiceKey", word.getMaleVoiceKey()); - enriched.put("femaleVoiceKey", word.getFemaleVoiceKey()); - } - - enrichedList.add(enriched); + try { + UserWord userWord = userWordService.updateUserWordTag(userId, wordId, bookmarked, favorite, difficulty); + return createResponse(200, ApiResponse.success("Tag updated", userWord)); + } catch (IllegalArgumentException e) { + return createResponse(400, ApiResponse.error(e.getMessage())); } - - return enrichedList; } } From 95a7a476966eaeaf8e5fe96b91bde6910827479a Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 8 Jan 2026 12:49:54 +0900 Subject: [PATCH 033/528] =?UTF-8?q?feat:=20StatsService=20=EA=B3=84?= =?UTF-8?q?=EC=B8=B5=20=EC=B6=94=EA=B0=80=20refs=20#96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vocabulary/service/StatsService.java | 237 ++++++++++++++++++ 1 file changed, 237 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/StatsService.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/StatsService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/StatsService.java new file mode 100644 index 00000000..71a9fc0d --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/StatsService.java @@ -0,0 +1,237 @@ +package com.mzc.secondproject.serverless.domain.vocabulary.service; + +import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.domain.vocabulary.model.DailyStudy; +import com.mzc.secondproject.serverless.domain.vocabulary.model.TestResult; +import com.mzc.secondproject.serverless.domain.vocabulary.model.UserWord; +import com.mzc.secondproject.serverless.domain.vocabulary.repository.DailyStudyRepository; +import com.mzc.secondproject.serverless.domain.vocabulary.repository.TestResultRepository; +import com.mzc.secondproject.serverless.domain.vocabulary.repository.UserWordRepository; +import com.mzc.secondproject.serverless.domain.vocabulary.repository.WordRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +public class StatsService { + + private static final Logger logger = LoggerFactory.getLogger(StatsService.class); + + private final UserWordRepository userWordRepository; + private final DailyStudyRepository dailyStudyRepository; + private final TestResultRepository testResultRepository; + private final WordRepository wordRepository; + + public StatsService() { + this.userWordRepository = new UserWordRepository(); + this.dailyStudyRepository = new DailyStudyRepository(); + this.testResultRepository = new TestResultRepository(); + this.wordRepository = new WordRepository(); + } + + public Map getOverallStats(String userId) { + Map wordStatusCounts = new HashMap<>(); + wordStatusCounts.put("NEW", 0); + wordStatusCounts.put("LEARNING", 0); + wordStatusCounts.put("REVIEWING", 0); + wordStatusCounts.put("MASTERED", 0); + + int totalCorrect = 0; + int totalIncorrect = 0; + + String cursor = null; + do { + PaginatedResult page = userWordRepository.findByUserIdWithPagination(userId, 100, cursor); + for (UserWord userWord : page.getItems()) { + String status = userWord.getStatus(); + wordStatusCounts.merge(status, 1, Integer::sum); + totalCorrect += userWord.getCorrectCount() != null ? userWord.getCorrectCount() : 0; + totalIncorrect += userWord.getIncorrectCount() != null ? userWord.getIncorrectCount() : 0; + } + cursor = page.getNextCursor(); + } while (cursor != null); + + int totalWords = wordStatusCounts.values().stream().mapToInt(Integer::intValue).sum(); + + PaginatedResult testPage = testResultRepository.findByUserIdWithPagination(userId, 100, null); + List testResults = testPage.getItems(); + + double avgSuccessRate = testResults.stream() + .mapToDouble(TestResult::getSuccessRate) + .average() + .orElse(0.0); + + PaginatedResult dailyPage = dailyStudyRepository.findByUserIdWithPagination(userId, 365, null); + int studyDays = dailyPage.getItems().size(); + int completedDays = (int) dailyPage.getItems().stream() + .filter(d -> Boolean.TRUE.equals(d.getIsCompleted())) + .count(); + + Map stats = new HashMap<>(); + stats.put("totalWords", totalWords); + stats.put("wordStatusCounts", wordStatusCounts); + stats.put("totalCorrect", totalCorrect); + stats.put("totalIncorrect", totalIncorrect); + stats.put("accuracy", totalCorrect + totalIncorrect > 0 + ? (totalCorrect * 100.0 / (totalCorrect + totalIncorrect)) : 0); + stats.put("testCount", testResults.size()); + stats.put("avgSuccessRate", avgSuccessRate); + stats.put("studyDays", studyDays); + stats.put("completedDays", completedDays); + stats.put("completionRate", studyDays > 0 ? (completedDays * 100.0 / studyDays) : 0); + + return stats; + } + + public DailyStatsResult getDailyStats(String userId, int limit, String cursor) { + PaginatedResult dailyPage = dailyStudyRepository.findByUserIdWithPagination(userId, limit, cursor); + + List> dailyStats = dailyPage.getItems().stream() + .map(daily -> { + Map stat = new HashMap<>(); + stat.put("date", daily.getDate()); + stat.put("totalWords", daily.getTotalWords()); + stat.put("learnedCount", daily.getLearnedCount()); + stat.put("isCompleted", daily.getIsCompleted()); + stat.put("progress", daily.getTotalWords() > 0 + ? (daily.getLearnedCount() * 100.0 / daily.getTotalWords()) : 0); + return stat; + }) + .toList(); + + return new DailyStatsResult(dailyStats, dailyPage.getNextCursor(), dailyPage.hasMore()); + } + + public Map getWeaknessAnalysis(String userId) { + List allUserWords = new ArrayList<>(); + String cursor = null; + do { + PaginatedResult page = userWordRepository.findByUserIdWithPagination(userId, 100, cursor); + allUserWords.addAll(page.getItems()); + cursor = page.getNextCursor(); + } while (cursor != null); + + if (allUserWords.isEmpty()) { + Map emptyResult = new HashMap<>(); + emptyResult.put("weakestWords", List.of()); + emptyResult.put("categoryAnalysis", Map.of()); + emptyResult.put("levelAnalysis", Map.of()); + emptyResult.put("suggestions", List.of()); + return emptyResult; + } + + List> weakestWords = allUserWords.stream() + .filter(uw -> uw.getIncorrectCount() != null && uw.getIncorrectCount() > 0) + .sorted(Comparator.comparingInt(UserWord::getIncorrectCount).reversed()) + .limit(10) + .map(uw -> { + Map wordInfo = new HashMap<>(); + wordInfo.put("wordId", uw.getWordId()); + wordInfo.put("incorrectCount", uw.getIncorrectCount()); + wordInfo.put("correctCount", uw.getCorrectCount()); + wordInfo.put("status", uw.getStatus()); + + wordRepository.findById(uw.getWordId()).ifPresent(word -> { + wordInfo.put("english", word.getEnglish()); + wordInfo.put("korean", word.getKorean()); + wordInfo.put("level", word.getLevel()); + wordInfo.put("category", word.getCategory()); + }); + + int total = (uw.getCorrectCount() != null ? uw.getCorrectCount() : 0) + + (uw.getIncorrectCount() != null ? uw.getIncorrectCount() : 0); + wordInfo.put("accuracy", total > 0 ? + (uw.getCorrectCount() != null ? uw.getCorrectCount() * 100.0 / total : 0) : 0); + + return wordInfo; + }) + .collect(Collectors.toList()); + + Map> categoryAnalysis = new HashMap<>(); + Map> levelAnalysis = new HashMap<>(); + + for (UserWord uw : allUserWords) { + wordRepository.findById(uw.getWordId()).ifPresent(word -> { + String category = word.getCategory(); + String level = word.getLevel(); + + int correct = uw.getCorrectCount() != null ? uw.getCorrectCount() : 0; + int incorrect = uw.getIncorrectCount() != null ? uw.getIncorrectCount() : 0; + + categoryAnalysis.computeIfAbsent(category, k -> { + Map stats = new HashMap<>(); + stats.put("totalCorrect", 0); + stats.put("totalIncorrect", 0); + stats.put("wordCount", 0); + return stats; + }); + Map catStats = categoryAnalysis.get(category); + catStats.put("totalCorrect", (Integer) catStats.get("totalCorrect") + correct); + catStats.put("totalIncorrect", (Integer) catStats.get("totalIncorrect") + incorrect); + catStats.put("wordCount", (Integer) catStats.get("wordCount") + 1); + + levelAnalysis.computeIfAbsent(level, k -> { + Map stats = new HashMap<>(); + stats.put("totalCorrect", 0); + stats.put("totalIncorrect", 0); + stats.put("wordCount", 0); + return stats; + }); + Map lvlStats = levelAnalysis.get(level); + lvlStats.put("totalCorrect", (Integer) lvlStats.get("totalCorrect") + correct); + lvlStats.put("totalIncorrect", (Integer) lvlStats.get("totalIncorrect") + incorrect); + lvlStats.put("wordCount", (Integer) lvlStats.get("wordCount") + 1); + }); + } + + categoryAnalysis.values().forEach(stats -> { + int correct = (Integer) stats.get("totalCorrect"); + int incorrect = (Integer) stats.get("totalIncorrect"); + int total = correct + incorrect; + stats.put("accuracy", total > 0 ? (correct * 100.0 / total) : 0); + }); + + levelAnalysis.values().forEach(stats -> { + int correct = (Integer) stats.get("totalCorrect"); + int incorrect = (Integer) stats.get("totalIncorrect"); + int total = correct + incorrect; + stats.put("accuracy", total > 0 ? (correct * 100.0 / total) : 0); + }); + + List suggestions = new ArrayList<>(); + + categoryAnalysis.entrySet().stream() + .filter(e -> (Integer) e.getValue().get("wordCount") >= 3) + .min(Comparator.comparingDouble(e -> (Double) e.getValue().get("accuracy"))) + .ifPresent(e -> suggestions.add( + String.format("%s 카테고리의 정확도가 %.1f%%로 가장 낮습니다. 집중 학습을 권장합니다.", + e.getKey(), e.getValue().get("accuracy")))); + + levelAnalysis.entrySet().stream() + .filter(e -> (Integer) e.getValue().get("wordCount") >= 3) + .min(Comparator.comparingDouble(e -> (Double) e.getValue().get("accuracy"))) + .ifPresent(e -> suggestions.add( + String.format("%s 레벨의 정확도가 %.1f%%입니다. 이 레벨의 단어들을 더 복습해보세요.", + e.getKey(), e.getValue().get("accuracy")))); + + if (!weakestWords.isEmpty()) { + suggestions.add(String.format("자주 틀리는 단어 %d개가 있습니다. 북마크하여 집중 복습하세요.", + weakestWords.size())); + } + + Map result = new HashMap<>(); + result.put("weakestWords", weakestWords); + result.put("categoryAnalysis", categoryAnalysis); + result.put("levelAnalysis", levelAnalysis); + result.put("suggestions", suggestions); + + return result; + } + + public record DailyStatsResult(List> dailyStats, String nextCursor, boolean hasMore) {} +} From efd981762861f282aebae84728acef85b077afde Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 8 Jan 2026 12:49:58 +0900 Subject: [PATCH 034/528] =?UTF-8?q?refactor:=20StatsHandler=EA=B0=80=20Sta?= =?UTF-8?q?tsService=EB=A5=BC=20=EC=82=AC=EC=9A=A9=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=20refs=20#96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vocabulary/handler/StatsHandler.java | 244 +----------------- 1 file changed, 13 insertions(+), 231 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatsHandler.java index db8a7176..bbeaacdb 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatsHandler.java @@ -5,40 +5,22 @@ import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; import com.mzc.secondproject.serverless.common.dto.ApiResponse; -import com.mzc.secondproject.serverless.common.dto.PaginatedResult; import static com.mzc.secondproject.serverless.common.util.ResponseUtil.createResponse; -import com.mzc.secondproject.serverless.domain.vocabulary.model.DailyStudy; -import com.mzc.secondproject.serverless.domain.vocabulary.model.TestResult; -import com.mzc.secondproject.serverless.domain.vocabulary.model.UserWord; -import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; -import com.mzc.secondproject.serverless.domain.vocabulary.repository.DailyStudyRepository; -import com.mzc.secondproject.serverless.domain.vocabulary.repository.TestResultRepository; -import com.mzc.secondproject.serverless.domain.vocabulary.repository.UserWordRepository; -import com.mzc.secondproject.serverless.domain.vocabulary.repository.WordRepository; +import com.mzc.secondproject.serverless.domain.vocabulary.service.StatsService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.util.ArrayList; -import java.util.Comparator; import java.util.HashMap; -import java.util.List; import java.util.Map; -import java.util.stream.Collectors; public class StatsHandler implements RequestHandler { private static final Logger logger = LoggerFactory.getLogger(StatsHandler.class); - private final UserWordRepository userWordRepository; - private final DailyStudyRepository dailyStudyRepository; - private final TestResultRepository testResultRepository; - private final WordRepository wordRepository; + private final StatsService statsService; public StatsHandler() { - this.userWordRepository = new UserWordRepository(); - this.dailyStudyRepository = new DailyStudyRepository(); - this.testResultRepository = new TestResultRepository(); - this.wordRepository = new WordRepository(); + this.statsService = new StatsService(); } @Override @@ -66,6 +48,9 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent re return createResponse(404, ApiResponse.error("Not found")); + } catch (IllegalArgumentException e) { + logger.warn("Bad request: {}", e.getMessage()); + return createResponse(400, ApiResponse.error(e.getMessage())); } catch (Exception e) { logger.error("Error handling request", e); return createResponse(500, ApiResponse.error("Internal server error: " + e.getMessage())); @@ -80,60 +65,7 @@ private APIGatewayProxyResponseEvent getOverallStats(APIGatewayProxyRequestEvent return createResponse(400, ApiResponse.error("userId is required")); } - // 단어 학습 상태별 통계 - Map wordStatusCounts = new HashMap<>(); - wordStatusCounts.put("NEW", 0); - wordStatusCounts.put("LEARNING", 0); - wordStatusCounts.put("REVIEWING", 0); - wordStatusCounts.put("MASTERED", 0); - - int totalCorrect = 0; - int totalIncorrect = 0; - - // 사용자 단어 통계 조회 - String cursor = null; - do { - PaginatedResult page = userWordRepository.findByUserIdWithPagination(userId, 100, cursor); - for (UserWord userWord : page.getItems()) { - String status = userWord.getStatus(); - wordStatusCounts.merge(status, 1, Integer::sum); - totalCorrect += userWord.getCorrectCount() != null ? userWord.getCorrectCount() : 0; - totalIncorrect += userWord.getIncorrectCount() != null ? userWord.getIncorrectCount() : 0; - } - cursor = page.getNextCursor(); - } while (cursor != null); - - int totalWords = wordStatusCounts.values().stream().mapToInt(Integer::intValue).sum(); - - // 시험 통계 - PaginatedResult testPage = testResultRepository.findByUserIdWithPagination(userId, 100, null); - List testResults = testPage.getItems(); - - double avgSuccessRate = testResults.stream() - .mapToDouble(TestResult::getSuccessRate) - .average() - .orElse(0.0); - - // 학습 일수 - PaginatedResult dailyPage = dailyStudyRepository.findByUserIdWithPagination(userId, 365, null); - int studyDays = dailyPage.getItems().size(); - int completedDays = (int) dailyPage.getItems().stream() - .filter(d -> Boolean.TRUE.equals(d.getIsCompleted())) - .count(); - - Map stats = new HashMap<>(); - stats.put("totalWords", totalWords); - stats.put("wordStatusCounts", wordStatusCounts); - stats.put("totalCorrect", totalCorrect); - stats.put("totalIncorrect", totalIncorrect); - stats.put("accuracy", totalCorrect + totalIncorrect > 0 - ? (totalCorrect * 100.0 / (totalCorrect + totalIncorrect)) : 0); - stats.put("testCount", testResults.size()); - stats.put("avgSuccessRate", avgSuccessRate); - stats.put("studyDays", studyDays); - stats.put("completedDays", completedDays); - stats.put("completionRate", studyDays > 0 ? (completedDays * 100.0 / studyDays) : 0); - + Map stats = statsService.getOverallStats(userId); return createResponse(200, ApiResponse.success("Stats retrieved", stats)); } @@ -153,32 +85,16 @@ private APIGatewayProxyResponseEvent getDailyStats(APIGatewayProxyRequestEvent r limit = Math.min(Integer.parseInt(queryParams.get("limit")), 90); } - PaginatedResult dailyPage = dailyStudyRepository.findByUserIdWithPagination(userId, limit, cursor); - - List> dailyStats = dailyPage.getItems().stream() - .map(daily -> { - Map stat = new HashMap<>(); - stat.put("date", daily.getDate()); - stat.put("totalWords", daily.getTotalWords()); - stat.put("learnedCount", daily.getLearnedCount()); - stat.put("isCompleted", daily.getIsCompleted()); - stat.put("progress", daily.getTotalWords() > 0 - ? (daily.getLearnedCount() * 100.0 / daily.getTotalWords()) : 0); - return stat; - }) - .toList(); + StatsService.DailyStatsResult dailyResult = statsService.getDailyStats(userId, limit, cursor); Map result = new HashMap<>(); - result.put("dailyStats", dailyStats); - result.put("nextCursor", dailyPage.getNextCursor()); - result.put("hasMore", dailyPage.hasMore()); + result.put("dailyStats", dailyResult.dailyStats()); + result.put("nextCursor", dailyResult.nextCursor()); + result.put("hasMore", dailyResult.hasMore()); return createResponse(200, ApiResponse.success("Daily stats retrieved", result)); } - /** - * 약점 분석 - 틀린 횟수가 많은 단어, 카테고리/레벨별 정확도 분석 - */ private APIGatewayProxyResponseEvent getWeaknessAnalysis(APIGatewayProxyRequestEvent request) { Map pathParams = request.getPathParameters(); String userId = pathParams != null ? pathParams.get("userId") : null; @@ -187,141 +103,7 @@ private APIGatewayProxyResponseEvent getWeaknessAnalysis(APIGatewayProxyRequestE return createResponse(400, ApiResponse.error("userId is required")); } - // 사용자의 모든 학습 단어 조회 - List allUserWords = new ArrayList<>(); - String cursor = null; - do { - PaginatedResult page = userWordRepository.findByUserIdWithPagination(userId, 100, cursor); - allUserWords.addAll(page.getItems()); - cursor = page.getNextCursor(); - } while (cursor != null); - - if (allUserWords.isEmpty()) { - Map emptyResult = new HashMap<>(); - emptyResult.put("weakestWords", List.of()); - emptyResult.put("categoryAnalysis", Map.of()); - emptyResult.put("levelAnalysis", Map.of()); - emptyResult.put("suggestions", List.of()); - return createResponse(200, ApiResponse.success("No learning data", emptyResult)); - } - - // 1. 가장 많이 틀린 단어 Top 10 - List> weakestWords = allUserWords.stream() - .filter(uw -> uw.getIncorrectCount() != null && uw.getIncorrectCount() > 0) - .sorted(Comparator.comparingInt(UserWord::getIncorrectCount).reversed()) - .limit(10) - .map(uw -> { - Map wordInfo = new HashMap<>(); - wordInfo.put("wordId", uw.getWordId()); - wordInfo.put("incorrectCount", uw.getIncorrectCount()); - wordInfo.put("correctCount", uw.getCorrectCount()); - wordInfo.put("status", uw.getStatus()); - - // 단어 상세 정보 조회 - wordRepository.findById(uw.getWordId()).ifPresent(word -> { - wordInfo.put("english", word.getEnglish()); - wordInfo.put("korean", word.getKorean()); - wordInfo.put("level", word.getLevel()); - wordInfo.put("category", word.getCategory()); - }); - - int total = (uw.getCorrectCount() != null ? uw.getCorrectCount() : 0) + - (uw.getIncorrectCount() != null ? uw.getIncorrectCount() : 0); - wordInfo.put("accuracy", total > 0 ? - (uw.getCorrectCount() != null ? uw.getCorrectCount() * 100.0 / total : 0) : 0); - - return wordInfo; - }) - .collect(Collectors.toList()); - - // 2. 카테고리별 정확도 분석 - Map> categoryAnalysis = new HashMap<>(); - // 3. 레벨별 정확도 분석 - Map> levelAnalysis = new HashMap<>(); - - for (UserWord uw : allUserWords) { - // 단어 정보 조회 - wordRepository.findById(uw.getWordId()).ifPresent(word -> { - String category = word.getCategory(); - String level = word.getLevel(); - - int correct = uw.getCorrectCount() != null ? uw.getCorrectCount() : 0; - int incorrect = uw.getIncorrectCount() != null ? uw.getIncorrectCount() : 0; - - // 카테고리별 집계 - categoryAnalysis.computeIfAbsent(category, k -> { - Map stats = new HashMap<>(); - stats.put("totalCorrect", 0); - stats.put("totalIncorrect", 0); - stats.put("wordCount", 0); - return stats; - }); - Map catStats = categoryAnalysis.get(category); - catStats.put("totalCorrect", (Integer) catStats.get("totalCorrect") + correct); - catStats.put("totalIncorrect", (Integer) catStats.get("totalIncorrect") + incorrect); - catStats.put("wordCount", (Integer) catStats.get("wordCount") + 1); - - // 레벨별 집계 - levelAnalysis.computeIfAbsent(level, k -> { - Map stats = new HashMap<>(); - stats.put("totalCorrect", 0); - stats.put("totalIncorrect", 0); - stats.put("wordCount", 0); - return stats; - }); - Map lvlStats = levelAnalysis.get(level); - lvlStats.put("totalCorrect", (Integer) lvlStats.get("totalCorrect") + correct); - lvlStats.put("totalIncorrect", (Integer) lvlStats.get("totalIncorrect") + incorrect); - lvlStats.put("wordCount", (Integer) lvlStats.get("wordCount") + 1); - }); - } - - // 정확도 계산 - categoryAnalysis.values().forEach(stats -> { - int correct = (Integer) stats.get("totalCorrect"); - int incorrect = (Integer) stats.get("totalIncorrect"); - int total = correct + incorrect; - stats.put("accuracy", total > 0 ? (correct * 100.0 / total) : 0); - }); - - levelAnalysis.values().forEach(stats -> { - int correct = (Integer) stats.get("totalCorrect"); - int incorrect = (Integer) stats.get("totalIncorrect"); - int total = correct + incorrect; - stats.put("accuracy", total > 0 ? (correct * 100.0 / total) : 0); - }); - - // 4. 학습 제안 생성 - List suggestions = new ArrayList<>(); - - // 가장 약한 카테고리 찾기 - categoryAnalysis.entrySet().stream() - .filter(e -> (Integer) e.getValue().get("wordCount") >= 3) // 최소 3개 이상 학습한 카테고리만 - .min(Comparator.comparingDouble(e -> (Double) e.getValue().get("accuracy"))) - .ifPresent(e -> suggestions.add( - String.format("%s 카테고리의 정확도가 %.1f%%로 가장 낮습니다. 집중 학습을 권장합니다.", - e.getKey(), e.getValue().get("accuracy")))); - - // 가장 약한 레벨 찾기 - levelAnalysis.entrySet().stream() - .filter(e -> (Integer) e.getValue().get("wordCount") >= 3) - .min(Comparator.comparingDouble(e -> (Double) e.getValue().get("accuracy"))) - .ifPresent(e -> suggestions.add( - String.format("%s 레벨의 정확도가 %.1f%%입니다. 이 레벨의 단어들을 더 복습해보세요.", - e.getKey(), e.getValue().get("accuracy")))); - - // 많이 틀린 단어가 있는 경우 - if (!weakestWords.isEmpty()) { - suggestions.add(String.format("자주 틀리는 단어 %d개가 있습니다. 북마크하여 집중 복습하세요.", - weakestWords.size())); - } - - Map result = new HashMap<>(); - result.put("weakestWords", weakestWords); - result.put("categoryAnalysis", categoryAnalysis); - result.put("levelAnalysis", levelAnalysis); - result.put("suggestions", suggestions); - - return createResponse(200, ApiResponse.success("Weakness analysis completed", result)); + Map analysis = statsService.getWeaknessAnalysis(userId); + return createResponse(200, ApiResponse.success("Weakness analysis completed", analysis)); } } From d801ec7248267ded3b8f319a1b95098ca24e2fc9 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 8 Jan 2026 12:51:21 +0900 Subject: [PATCH 035/528] =?UTF-8?q?feat:=20StatisticsService=20=EA=B3=84?= =?UTF-8?q?=EC=B8=B5=20=EC=B6=94=EA=B0=80=20refs=20#97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vocabulary/service/StatisticsService.java | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/StatisticsService.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/StatisticsService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/StatisticsService.java new file mode 100644 index 00000000..eebece22 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/StatisticsService.java @@ -0,0 +1,133 @@ +package com.mzc.secondproject.serverless.domain.vocabulary.service; + +import com.mzc.secondproject.serverless.domain.vocabulary.model.UserWord; +import com.mzc.secondproject.serverless.domain.vocabulary.repository.UserWordRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +public class StatisticsService { + + private static final Logger logger = LoggerFactory.getLogger(StatisticsService.class); + + private final UserWordRepository userWordRepository; + + public StatisticsService() { + this.userWordRepository = new UserWordRepository(); + } + + /** + * 시험 결과를 처리하여 UserWord 통계를 업데이트 + * @param userId 사용자 ID + * @param results 시험 결과 목록 (wordId, isCorrect 포함) + * @return 업데이트된 단어 수 + */ + @SuppressWarnings("unchecked") + public int processTestResults(String userId, List> results) { + if (userId == null || results == null) { + throw new IllegalArgumentException("userId and results are required"); + } + + String now = Instant.now().toString(); + int updatedCount = 0; + + for (Map result : results) { + String wordId = (String) result.get("wordId"); + Boolean isCorrect = (Boolean) result.get("isCorrect"); + + if (wordId == null || isCorrect == null) { + continue; + } + + updateUserWordStatistics(userId, wordId, isCorrect, now); + updatedCount++; + } + + logger.info("Processed test result for user: {}, {} words updated", userId, updatedCount); + return updatedCount; + } + + /** + * 단일 단어의 학습 결과를 업데이트 + */ + public void updateUserWordStatistics(String userId, String wordId, boolean isCorrect, String now) { + Optional optUserWord = userWordRepository.findByUserIdAndWordId(userId, wordId); + UserWord userWord; + + if (optUserWord.isEmpty()) { + userWord = createNewUserWord(userId, wordId, now); + } else { + userWord = optUserWord.get(); + } + + applySpacedRepetition(userWord, isCorrect); + userWord.setUpdatedAt(now); + userWord.setLastReviewedAt(now); + + userWord.setGsi1sk("DATE#" + userWord.getNextReviewAt()); + userWord.setGsi2sk("STATUS#" + userWord.getStatus()); + + userWordRepository.save(userWord); + } + + private UserWord createNewUserWord(String userId, String wordId, String now) { + return UserWord.builder() + .pk("USER#" + userId) + .sk("WORD#" + wordId) + .gsi1pk("USER#" + userId + "#REVIEW") + .gsi2pk("USER#" + userId + "#STATUS") + .userId(userId) + .wordId(wordId) + .status("NEW") + .interval(1) + .easeFactor(2.5) + .repetitions(0) + .correctCount(0) + .incorrectCount(0) + .createdAt(now) + .build(); + } + + /** + * SM-2 Spaced Repetition 알고리즘 적용 + */ + private void applySpacedRepetition(UserWord userWord, boolean isCorrect) { + if (isCorrect) { + userWord.setCorrectCount(userWord.getCorrectCount() + 1); + userWord.setRepetitions(userWord.getRepetitions() + 1); + + if (userWord.getRepetitions() == 1) { + userWord.setInterval(1); + } else if (userWord.getRepetitions() == 2) { + userWord.setInterval(6); + } else { + int newInterval = (int) Math.round(userWord.getInterval() * userWord.getEaseFactor()); + userWord.setInterval(newInterval); + } + + if (userWord.getRepetitions() >= 5) { + userWord.setStatus("MASTERED"); + } else if (userWord.getRepetitions() >= 2) { + userWord.setStatus("REVIEWING"); + } else { + userWord.setStatus("LEARNING"); + } + } else { + userWord.setIncorrectCount(userWord.getIncorrectCount() + 1); + userWord.setRepetitions(0); + userWord.setInterval(1); + userWord.setStatus("LEARNING"); + + double newEaseFactor = userWord.getEaseFactor() - 0.2; + userWord.setEaseFactor(Math.max(1.3, newEaseFactor)); + } + + LocalDate nextReview = LocalDate.now().plusDays(userWord.getInterval()); + userWord.setNextReviewAt(nextReview.toString()); + } +} From 2b5f4f896f342af63ff2f65c3def204b4f04afc4 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 8 Jan 2026 12:51:26 +0900 Subject: [PATCH 036/528] =?UTF-8?q?refactor:=20StatisticsHandler=EA=B0=80?= =?UTF-8?q?=20StatisticsService=EB=A5=BC=20=EC=82=AC=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=20ref?= =?UTF-8?q?s=20#97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vocabulary/handler/StatisticsHandler.java | 105 +----------------- 1 file changed, 4 insertions(+), 101 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatisticsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatisticsHandler.java index 18b101f9..8e8e7c80 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatisticsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatisticsHandler.java @@ -4,16 +4,12 @@ import com.amazonaws.services.lambda.runtime.RequestHandler; import com.amazonaws.services.lambda.runtime.events.SQSEvent; import com.mzc.secondproject.serverless.common.util.ResponseUtil; -import com.mzc.secondproject.serverless.domain.vocabulary.model.UserWord; -import com.mzc.secondproject.serverless.domain.vocabulary.repository.UserWordRepository; +import com.mzc.secondproject.serverless.domain.vocabulary.service.StatisticsService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.time.Instant; -import java.time.LocalDate; import java.util.List; import java.util.Map; -import java.util.Optional; /** * SQS에서 시험 결과 메시지를 받아 UserWord 통계를 업데이트하는 Lambda @@ -23,10 +19,10 @@ public class StatisticsHandler implements RequestHandler { private static final Logger logger = LoggerFactory.getLogger(StatisticsHandler.class); - private final UserWordRepository userWordRepository; + private final StatisticsService statisticsService; public StatisticsHandler() { - this.userWordRepository = new UserWordRepository(); + this.statisticsService = new StatisticsService(); } @Override @@ -38,7 +34,6 @@ public Void handleRequest(SQSEvent event, Context context) { processMessage(message); } catch (Exception e) { logger.error("Failed to process message: {}", message.getMessageId(), e); - // 실패한 메시지는 DLQ로 이동됨 (SQS 설정에 의해) throw new RuntimeException("Failed to process message", e); } } @@ -61,98 +56,6 @@ private void processMessage(SQSEvent.SQSMessage message) { return; } - String now = Instant.now().toString(); - - for (Map result : results) { - String wordId = (String) result.get("wordId"); - Boolean isCorrect = (Boolean) result.get("isCorrect"); - - if (wordId == null || isCorrect == null) { - continue; - } - - updateUserWordStatistics(userId, wordId, isCorrect, now); - } - - logger.info("Successfully processed test result for user: {}, {} words updated", userId, results.size()); - } - - private void updateUserWordStatistics(String userId, String wordId, boolean isCorrect, String now) { - Optional optUserWord = userWordRepository.findByUserIdAndWordId(userId, wordId); - UserWord userWord; - - if (optUserWord.isEmpty()) { - // 새로운 UserWord 생성 - userWord = UserWord.builder() - .pk("USER#" + userId) - .sk("WORD#" + wordId) - .gsi1pk("USER#" + userId + "#REVIEW") - .gsi2pk("USER#" + userId + "#STATUS") - .userId(userId) - .wordId(wordId) - .status("NEW") - .interval(1) - .easeFactor(2.5) - .repetitions(0) - .correctCount(0) - .incorrectCount(0) - .createdAt(now) - .build(); - } else { - userWord = optUserWord.get(); - } - - // Spaced Repetition 알고리즘 적용 - applySpacedRepetition(userWord, isCorrect); - userWord.setUpdatedAt(now); - userWord.setLastReviewedAt(now); - - // GSI 업데이트 - userWord.setGsi1sk("DATE#" + userWord.getNextReviewAt()); - userWord.setGsi2sk("STATUS#" + userWord.getStatus()); - - userWordRepository.save(userWord); - } - - /** - * SM-2 Spaced Repetition 알고리즘 적용 - */ - private void applySpacedRepetition(UserWord userWord, boolean isCorrect) { - if (isCorrect) { - userWord.setCorrectCount(userWord.getCorrectCount() + 1); - userWord.setRepetitions(userWord.getRepetitions() + 1); - - // 간격 계산 - if (userWord.getRepetitions() == 1) { - userWord.setInterval(1); - } else if (userWord.getRepetitions() == 2) { - userWord.setInterval(6); - } else { - int newInterval = (int) Math.round(userWord.getInterval() * userWord.getEaseFactor()); - userWord.setInterval(newInterval); - } - - // 상태 업데이트 - if (userWord.getRepetitions() >= 5) { - userWord.setStatus("MASTERED"); - } else if (userWord.getRepetitions() >= 2) { - userWord.setStatus("REVIEWING"); - } else { - userWord.setStatus("LEARNING"); - } - } else { - userWord.setIncorrectCount(userWord.getIncorrectCount() + 1); - userWord.setRepetitions(0); - userWord.setInterval(1); - userWord.setStatus("LEARNING"); - - // easeFactor 감소 (최소 1.3) - double newEaseFactor = userWord.getEaseFactor() - 0.2; - userWord.setEaseFactor(Math.max(1.3, newEaseFactor)); - } - - // 다음 복습일 계산 - LocalDate nextReview = LocalDate.now().plusDays(userWord.getInterval()); - userWord.setNextReviewAt(nextReview.toString()); + statisticsService.processTestResults(userId, results); } } From d50b8dda2bfe25d5791be16d6d030b8a44006001 Mon Sep 17 00:00:00 2001 From: DDING JOO Date: Thu, 8 Jan 2026 12:53:29 +0900 Subject: [PATCH 037/528] =?UTF-8?q?[Task=20#80]=20ChatRoom=20Service=20?= =?UTF-8?q?=EA=B3=84=EC=B8=B5=20=EC=B6=94=EA=B0=80=20(#98)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: ChatRoomService 생성 refs #80 * refactor: ChatRoomHandler에서 Service 계층 사용 refs #80 --- .../chatting/handler/ChatRoomHandler.java | 160 ++++-------------- .../chatting/service/ChatRoomService.java | 149 ++++++++++++++++ 2 files changed, 183 insertions(+), 126 deletions(-) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomService.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java index 83646b3c..b3a7bb13 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java @@ -9,27 +9,23 @@ import com.mzc.secondproject.serverless.common.util.ResponseUtil; import static com.mzc.secondproject.serverless.common.util.ResponseUtil.createResponse; import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; -import com.mzc.secondproject.serverless.domain.chatting.repository.ChatRoomRepository; -import org.mindrot.jbcrypt.BCrypt; +import com.mzc.secondproject.serverless.domain.chatting.service.ChatRoomService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.time.Instant; -import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; -import java.util.UUID; public class ChatRoomHandler implements RequestHandler { private static final Logger logger = LoggerFactory.getLogger(ChatRoomHandler.class); - private final ChatRoomRepository roomRepository; + private final ChatRoomService roomService; public ChatRoomHandler() { - this.roomRepository = new ChatRoomRepository(); + this.roomService = new ChatRoomService(); } @Override @@ -40,32 +36,26 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent re logger.info("Received request: {} {}", httpMethod, path); try { - // POST /chat/rooms - 방 생성 if ("POST".equals(httpMethod) && path.endsWith("/rooms")) { return createRoom(request); } - // GET /chat/rooms - 방 목록 조회 if ("GET".equals(httpMethod) && path.endsWith("/rooms")) { return getRooms(request); } - // GET /chat/rooms/{roomId} - 방 상세 조회 if ("GET".equals(httpMethod) && path.contains("/rooms/") && !path.contains("/join")) { return getRoom(request); } - // POST /chat/rooms/{roomId}/join - 방 입장 if ("POST".equals(httpMethod) && path.endsWith("/join")) { return joinRoom(request); } - // POST /chat/rooms/{roomId}/leave - 방 퇴장 if ("POST".equals(httpMethod) && path.endsWith("/leave")) { return leaveRoom(request); } - // DELETE /chat/rooms/{roomId} - 방 삭제 if ("DELETE".equals(httpMethod) && path.contains("/rooms/")) { return deleteRoom(request); } @@ -94,34 +84,9 @@ private APIGatewayProxyResponseEvent createRoom(APIGatewayProxyRequestEvent requ return createResponse(400, ApiResponse.error("name is required")); } - String roomId = UUID.randomUUID().toString(); - String now = Instant.now().toString(); - - ChatRoom room = ChatRoom.builder() - .pk("ROOM#" + roomId) - .sk("METADATA") - .gsi1pk("ROOMS") - .gsi1sk(level + "#" + now) - .roomId(roomId) - .name(name) - .description(description) - .level(level) - .currentMembers(1) // 방장 포함 - .maxMembers(maxMembers) - .isPrivate(isPrivate) - .password(isPrivate && password != null ? BCrypt.hashpw(password, BCrypt.gensalt()) : null) - .createdBy(createdBy) - .createdAt(now) - .lastMessageAt(now) - .memberIds(new ArrayList<>(List.of(createdBy))) - .build(); - - roomRepository.save(room); - - // 비밀번호는 응답에서 제외 + ChatRoom room = roomService.createRoom(name, description, level, maxMembers, isPrivate, password, createdBy); room.setPassword(null); - logger.info("Created room: {}", roomId); return createResponse(201, ApiResponse.success("Room created", room)); } @@ -133,28 +98,18 @@ private APIGatewayProxyResponseEvent getRooms(APIGatewayProxyRequestEvent reques String joined = queryParams != null ? queryParams.get("joined") : null; String cursor = queryParams != null ? queryParams.get("cursor") : null; - int limit = 10; // 기본값 + int limit = 10; if (queryParams != null && queryParams.get("limit") != null) { - limit = Math.min(Integer.parseInt(queryParams.get("limit")), 20); // 최대 20 - } - - PaginatedResult roomPage; - if (level != null && !level.isEmpty()) { - roomPage = roomRepository.findByLevelWithPagination(level, limit, cursor); - } else { - roomPage = roomRepository.findAllWithPagination(limit, cursor); + limit = Math.min(Integer.parseInt(queryParams.get("limit")), 20); } + PaginatedResult roomPage = roomService.getRooms(level, limit, cursor); List rooms = roomPage.getItems(); - // "참여중" 필터 - userId가 memberIds에 포함된 방만 if ("true".equals(joined) && userId != null) { - rooms = rooms.stream() - .filter(room -> room.getMemberIds() != null && room.getMemberIds().contains(userId)) - .toList(); + rooms = roomService.filterByJoinedUser(rooms, userId); } - // 비밀번호 제외 rooms.forEach(room -> room.setPassword(null)); Map result = new HashMap<>(); @@ -173,7 +128,7 @@ private APIGatewayProxyResponseEvent getRoom(APIGatewayProxyRequestEvent request return createResponse(400, ApiResponse.error("roomId is required")); } - Optional optRoom = roomRepository.findById(roomId); + Optional optRoom = roomService.getRoom(roomId); if (optRoom.isEmpty()) { return createResponse(404, ApiResponse.error("Room not found")); } @@ -197,43 +152,17 @@ private APIGatewayProxyResponseEvent joinRoom(APIGatewayProxyRequestEvent reques return createResponse(400, ApiResponse.error("roomId and userId are required")); } - Optional optRoom = roomRepository.findById(roomId); - if (optRoom.isEmpty()) { - return createResponse(404, ApiResponse.error("Room not found")); - } - - ChatRoom room = optRoom.get(); - - // 비밀번호 확인 (BCrypt 해시 검증) - if (room.getIsPrivate()) { - if (password == null || room.getPassword() == null || !BCrypt.checkpw(password, room.getPassword())) { - return createResponse(403, ApiResponse.error("Invalid password")); - } - } - - // 인원 확인 - if (room.getCurrentMembers() >= room.getMaxMembers()) { - return createResponse(400, ApiResponse.error("Room is full")); - } - - // 이미 참여 중인지 확인 - if (room.getMemberIds() != null && room.getMemberIds().contains(userId)) { + try { + ChatRoom room = roomService.joinRoom(roomId, userId, password); room.setPassword(null); - return createResponse(200, ApiResponse.success("Already joined", room)); - } - - // 멤버 추가 - if (room.getMemberIds() == null) { - room.setMemberIds(new ArrayList<>()); + return createResponse(200, ApiResponse.success("Joined room", room)); + } catch (IllegalArgumentException e) { + return createResponse(404, ApiResponse.error(e.getMessage())); + } catch (SecurityException e) { + return createResponse(403, ApiResponse.error(e.getMessage())); + } catch (IllegalStateException e) { + return createResponse(400, ApiResponse.error(e.getMessage())); } - room.getMemberIds().add(userId); - room.setCurrentMembers(room.getCurrentMembers() + 1); - - roomRepository.save(room); - room.setPassword(null); - - logger.info("User {} joined room {}", userId, roomId); - return createResponse(200, ApiResponse.success("Joined room", room)); } private APIGatewayProxyResponseEvent leaveRoom(APIGatewayProxyRequestEvent request) { @@ -248,30 +177,16 @@ private APIGatewayProxyResponseEvent leaveRoom(APIGatewayProxyRequestEvent reque return createResponse(400, ApiResponse.error("roomId and userId are required")); } - Optional optRoom = roomRepository.findById(roomId); - if (optRoom.isEmpty()) { - return createResponse(404, ApiResponse.error("Room not found")); - } - - ChatRoom room = optRoom.get(); - - // 멤버에서 제거 - if (room.getMemberIds() != null) { - room.getMemberIds().remove(userId); - room.setCurrentMembers(Math.max(0, room.getCurrentMembers() - 1)); - } - - // 방장이 나가거나 인원이 0이면 방 삭제 - if (userId.equals(room.getCreatedBy()) || room.getCurrentMembers() <= 0) { - roomRepository.delete(roomId); - return createResponse(200, ApiResponse.success("Room deleted", null)); + try { + ChatRoomService.LeaveResult result = roomService.leaveRoom(roomId, userId); + if (result.deleted()) { + return createResponse(200, ApiResponse.success("Room deleted", null)); + } + result.room().setPassword(null); + return createResponse(200, ApiResponse.success("Left room", result.room())); + } catch (IllegalArgumentException e) { + return createResponse(404, ApiResponse.error(e.getMessage())); } - - roomRepository.save(room); - room.setPassword(null); - - logger.info("User {} left room {}", userId, roomId); - return createResponse(200, ApiResponse.success("Left room", room)); } private APIGatewayProxyResponseEvent deleteRoom(APIGatewayProxyRequestEvent request) { @@ -289,20 +204,13 @@ private APIGatewayProxyResponseEvent deleteRoom(APIGatewayProxyRequestEvent requ return createResponse(400, ApiResponse.error("userId is required")); } - // 방장 확인 - Optional optRoom = roomRepository.findById(roomId); - if (optRoom.isEmpty()) { - return createResponse(404, ApiResponse.error("Room not found")); - } - - ChatRoom room = optRoom.get(); - if (!userId.equals(room.getCreatedBy())) { - return createResponse(403, ApiResponse.error("Only the room owner can delete the room")); + try { + roomService.deleteRoom(roomId, userId); + return createResponse(200, ApiResponse.success("Room deleted", null)); + } catch (IllegalArgumentException e) { + return createResponse(404, ApiResponse.error(e.getMessage())); + } catch (SecurityException e) { + return createResponse(403, ApiResponse.error(e.getMessage())); } - - roomRepository.delete(roomId); - logger.info("Deleted room: {} by owner: {}", roomId, userId); - - return createResponse(200, ApiResponse.success("Room deleted", null)); } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomService.java new file mode 100644 index 00000000..f9428f60 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomService.java @@ -0,0 +1,149 @@ +package com.mzc.secondproject.serverless.domain.chatting.service; + +import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; +import com.mzc.secondproject.serverless.domain.chatting.repository.ChatRoomRepository; +import org.mindrot.jbcrypt.BCrypt; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public class ChatRoomService { + + private static final Logger logger = LoggerFactory.getLogger(ChatRoomService.class); + + private final ChatRoomRepository roomRepository; + + public ChatRoomService() { + this.roomRepository = new ChatRoomRepository(); + } + + public ChatRoom createRoom(String name, String description, String level, Integer maxMembers, + Boolean isPrivate, String password, String createdBy) { + String roomId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + + ChatRoom room = ChatRoom.builder() + .pk("ROOM#" + roomId) + .sk("METADATA") + .gsi1pk("ROOMS") + .gsi1sk(level + "#" + now) + .roomId(roomId) + .name(name) + .description(description) + .level(level) + .currentMembers(1) + .maxMembers(maxMembers) + .isPrivate(isPrivate) + .password(isPrivate && password != null ? BCrypt.hashpw(password, BCrypt.gensalt()) : null) + .createdBy(createdBy) + .createdAt(now) + .lastMessageAt(now) + .memberIds(new ArrayList<>(List.of(createdBy))) + .build(); + + roomRepository.save(room); + logger.info("Created room: {}", roomId); + + return room; + } + + public Optional getRoom(String roomId) { + return roomRepository.findById(roomId); + } + + public PaginatedResult getRooms(String level, int limit, String cursor) { + if (level != null && !level.isEmpty()) { + return roomRepository.findByLevelWithPagination(level, limit, cursor); + } + return roomRepository.findAllWithPagination(limit, cursor); + } + + public List filterByJoinedUser(List rooms, String userId) { + return rooms.stream() + .filter(room -> room.getMemberIds() != null && room.getMemberIds().contains(userId)) + .toList(); + } + + public ChatRoom joinRoom(String roomId, String userId, String password) { + Optional optRoom = roomRepository.findById(roomId); + if (optRoom.isEmpty()) { + throw new IllegalArgumentException("Room not found"); + } + + ChatRoom room = optRoom.get(); + + if (room.getIsPrivate()) { + if (password == null || room.getPassword() == null || !BCrypt.checkpw(password, room.getPassword())) { + throw new SecurityException("Invalid password"); + } + } + + if (room.getCurrentMembers() >= room.getMaxMembers()) { + throw new IllegalStateException("Room is full"); + } + + if (room.getMemberIds() != null && room.getMemberIds().contains(userId)) { + logger.info("User {} already in room {}", userId, roomId); + return room; + } + + if (room.getMemberIds() == null) { + room.setMemberIds(new ArrayList<>()); + } + room.getMemberIds().add(userId); + room.setCurrentMembers(room.getCurrentMembers() + 1); + + roomRepository.save(room); + logger.info("User {} joined room {}", userId, roomId); + + return room; + } + + public LeaveResult leaveRoom(String roomId, String userId) { + Optional optRoom = roomRepository.findById(roomId); + if (optRoom.isEmpty()) { + throw new IllegalArgumentException("Room not found"); + } + + ChatRoom room = optRoom.get(); + + if (room.getMemberIds() != null) { + room.getMemberIds().remove(userId); + room.setCurrentMembers(Math.max(0, room.getCurrentMembers() - 1)); + } + + if (userId.equals(room.getCreatedBy()) || room.getCurrentMembers() <= 0) { + roomRepository.delete(roomId); + logger.info("Room {} deleted (owner left or empty)", roomId); + return new LeaveResult(true, null); + } + + roomRepository.save(room); + logger.info("User {} left room {}", userId, roomId); + + return new LeaveResult(false, room); + } + + public void deleteRoom(String roomId, String userId) { + Optional optRoom = roomRepository.findById(roomId); + if (optRoom.isEmpty()) { + throw new IllegalArgumentException("Room not found"); + } + + ChatRoom room = optRoom.get(); + if (!userId.equals(room.getCreatedBy())) { + throw new SecurityException("Only the room owner can delete the room"); + } + + roomRepository.delete(roomId); + logger.info("Deleted room: {} by owner: {}", roomId, userId); + } + + public record LeaveResult(boolean deleted, ChatRoom room) {} +} \ No newline at end of file From 8ac0c2e0c62a0aee94f72820f6aa4539a98f168a Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 8 Jan 2026 12:59:31 +0900 Subject: [PATCH 038/528] =?UTF-8?q?feat:=20Route=20=EB=A0=88=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80=20refs=20#108?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../serverless/common/router/Route.java | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/Route.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/Route.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/Route.java new file mode 100644 index 00000000..44765b7c --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/Route.java @@ -0,0 +1,38 @@ +package com.mzc.secondproject.serverless.common.router; + +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; + +import java.util.function.Function; + +/** + * HTTP 라우트 정의 + * @param method HTTP 메서드 (GET, POST, PUT, DELETE 등) + * @param pathPattern 경로 패턴 (예: "/rooms", "/rooms/{roomId}", "/rooms/{roomId}/join") + * @param handler 요청 처리 함수 + */ +public record Route( + String method, + String pathPattern, + Function handler +) { + public static Route get(String pathPattern, Function handler) { + return new Route("GET", pathPattern, handler); + } + + public static Route post(String pathPattern, Function handler) { + return new Route("POST", pathPattern, handler); + } + + public static Route put(String pathPattern, Function handler) { + return new Route("PUT", pathPattern, handler); + } + + public static Route delete(String pathPattern, Function handler) { + return new Route("DELETE", pathPattern, handler); + } + + public static Route patch(String pathPattern, Function handler) { + return new Route("PATCH", pathPattern, handler); + } +} From 859e8f7d49dcfba530a4e95a060a19c7a47b7b88 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 8 Jan 2026 12:59:35 +0900 Subject: [PATCH 039/528] =?UTF-8?q?feat:=20HandlerRouter=20=EC=9C=A0?= =?UTF-8?q?=ED=8B=B8=EB=A6=AC=ED=8B=B0=20=EC=B6=94=EA=B0=80=20refs=20#108?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/router/HandlerRouter.java | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/HandlerRouter.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/HandlerRouter.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/HandlerRouter.java new file mode 100644 index 00000000..42eafcbb --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/HandlerRouter.java @@ -0,0 +1,103 @@ +package com.mzc.secondproject.serverless.common.router; + +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import com.mzc.secondproject.serverless.common.dto.ApiResponse; +import static com.mzc.secondproject.serverless.common.util.ResponseUtil.createResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Lambda Handler를 위한 HTTP 라우터 + * if-else 체인 대신 선언적 라우팅 제공 + */ +public class HandlerRouter { + + private static final Logger logger = LoggerFactory.getLogger(HandlerRouter.class); + + private final List routes = new ArrayList<>(); + + /** + * 라우트 등록 + */ + public HandlerRouter addRoute(Route route) { + String regex = convertPatternToRegex(route.pathPattern()); + Pattern pattern = Pattern.compile(regex); + routes.add(new RouteEntry(route, pattern)); + return this; + } + + /** + * 여러 라우트 한번에 등록 + */ + public HandlerRouter addRoutes(Route... routeArray) { + for (Route route : routeArray) { + addRoute(route); + } + return this; + } + + /** + * 요청을 적절한 핸들러로 라우팅 + */ + public APIGatewayProxyResponseEvent route(APIGatewayProxyRequestEvent request) { + String method = request.getHttpMethod(); + String path = request.getPath(); + + logger.info("Routing request: {} {}", method, path); + + for (RouteEntry entry : routes) { + if (entry.matches(method, path)) { + logger.debug("Matched route: {} {}", entry.route.method(), entry.route.pathPattern()); + try { + return entry.route.handler().apply(request); + } catch (IllegalArgumentException e) { + logger.warn("Bad request: {}", e.getMessage()); + return createResponse(400, ApiResponse.error(e.getMessage())); + } catch (IllegalStateException e) { + logger.warn("Conflict: {}", e.getMessage()); + return createResponse(409, ApiResponse.error(e.getMessage())); + } catch (SecurityException e) { + logger.warn("Forbidden: {}", e.getMessage()); + return createResponse(403, ApiResponse.error(e.getMessage())); + } catch (Exception e) { + logger.error("Error handling request", e); + return createResponse(500, ApiResponse.error("Internal server error: " + e.getMessage())); + } + } + } + + logger.warn("No route found for: {} {}", method, path); + return createResponse(404, ApiResponse.error("Not found")); + } + + /** + * 경로 패턴을 정규식으로 변환 + * /rooms/{roomId} -> /rooms/[^/]+ + * /rooms/{roomId}/join -> /rooms/[^/]+/join + */ + private String convertPatternToRegex(String pathPattern) { + String regex = pathPattern + .replaceAll("\\{[^}]+\\}", "[^/]+") + .replace("/", "\\/"); + return ".*" + regex + "$"; + } + + /** + * 라우트 엔트리 (라우트 + 컴파일된 패턴) + */ + private record RouteEntry(Route route, Pattern pattern) { + boolean matches(String method, String path) { + if (!route.method().equalsIgnoreCase(method)) { + return false; + } + Matcher matcher = pattern.matcher(path); + return matcher.matches(); + } + } +} From b3fc4031a103902a9eb9248e523790349834295f Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 8 Jan 2026 13:00:39 +0900 Subject: [PATCH 040/528] =?UTF-8?q?refactor:=20ChatRoomHandler=EC=97=90=20?= =?UTF-8?q?HandlerRouter=20=EC=A0=81=EC=9A=A9=20refs=20#109?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chatting/handler/ChatRoomHandler.java | 91 ++++++------------- 1 file changed, 27 insertions(+), 64 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java index b3a7bb13..3a6583a9 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java @@ -6,6 +6,8 @@ import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; import com.mzc.secondproject.serverless.common.dto.ApiResponse; import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.common.router.HandlerRouter; +import com.mzc.secondproject.serverless.common.router.Route; import com.mzc.secondproject.serverless.common.util.ResponseUtil; import static com.mzc.secondproject.serverless.common.util.ResponseUtil.createResponse; import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; @@ -23,49 +25,28 @@ public class ChatRoomHandler implements RequestHandler Date: Thu, 8 Jan 2026 13:02:03 +0900 Subject: [PATCH 041/528] =?UTF-8?q?refactor:=20ChatMessageHandler=EC=97=90?= =?UTF-8?q?=20HandlerRouter=20=EC=A0=81=EC=9A=A9=20refs=20#110?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chatting/handler/ChatMessageHandler.java | 66 ++++++++++--------- 1 file changed, 34 insertions(+), 32 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatMessageHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatMessageHandler.java index c6f06930..3f94f55a 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatMessageHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatMessageHandler.java @@ -6,11 +6,11 @@ import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; import com.mzc.secondproject.serverless.common.dto.ApiResponse; import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.common.router.HandlerRouter; +import com.mzc.secondproject.serverless.common.router.Route; import com.mzc.secondproject.serverless.common.util.ResponseUtil; import static com.mzc.secondproject.serverless.common.util.ResponseUtil.createResponse; import com.mzc.secondproject.serverless.domain.chatting.model.ChatMessage; -import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; -import com.mzc.secondproject.serverless.domain.chatting.repository.ChatMessageRepository; import com.mzc.secondproject.serverless.domain.chatting.repository.ChatRoomRepository; import com.mzc.secondproject.serverless.domain.chatting.service.ChatMessageService; import org.slf4j.Logger; @@ -28,29 +28,29 @@ public class ChatMessageHandler implements RequestHandler handlePost(request); - case "GET" -> handleGet(request); - default -> createResponse(405, ApiResponse.error("Method not allowed")); - }; - } catch (Exception e) { - logger.error("Error processing request", e); - return createResponse(500, ApiResponse.error("Internal server error: " + e.getMessage())); - } + return router.route(request); } - private APIGatewayProxyResponseEvent handlePost(APIGatewayProxyRequestEvent request) { + private APIGatewayProxyResponseEvent sendMessage(APIGatewayProxyRequestEvent request) { Map pathParams = request.getPathParameters(); String roomId = pathParams != null ? pathParams.get("roomId") : null; @@ -77,7 +77,7 @@ private APIGatewayProxyResponseEvent handlePost(APIGatewayProxyRequestEvent requ .sk("MSG#" + now + "#" + messageId) .gsi1pk("USER#" + userId) .gsi1sk("MSG#" + now) - .gsi2pk("MSG#" + messageId) // GSI2: messageId로 직접 조회용 + .gsi2pk("MSG#" + messageId) .gsi2sk("ROOM#" + roomId) .messageId(messageId) .roomId(roomId) @@ -88,46 +88,48 @@ private APIGatewayProxyResponseEvent handlePost(APIGatewayProxyRequestEvent requ .build(); ChatMessage savedMessage = chatMessageService.saveMessage(message); - - // 채팅방 lastMessageAt 업데이트 (UpdateExpression으로 1회 호출) chatRoomRepository.updateLastMessageAt(roomId, now); logger.info("Message sent: {} in room: {}", messageId, roomId); return createResponse(201, ApiResponse.success("Message sent", savedMessage)); } - private APIGatewayProxyResponseEvent handleGet(APIGatewayProxyRequestEvent request) { + private APIGatewayProxyResponseEvent getMessage(APIGatewayProxyRequestEvent request) { + Map pathParams = request.getPathParameters(); + String roomId = pathParams != null ? pathParams.get("roomId") : null; + String messageId = pathParams != null ? pathParams.get("messageId") : null; + + if (roomId == null || messageId == null) { + return createResponse(400, ApiResponse.error("roomId and messageId are required")); + } + + Optional message = chatMessageService.getMessage(roomId, messageId); + if (message.isEmpty()) { + return createResponse(404, ApiResponse.error("Message not found")); + } + return createResponse(200, ApiResponse.success("Message retrieved", message.get())); + } + + private APIGatewayProxyResponseEvent getMessages(APIGatewayProxyRequestEvent request) { Map pathParams = request.getPathParameters(); Map queryParams = request.getQueryStringParameters(); String roomId = pathParams != null ? pathParams.get("roomId") : null; - String messageId = pathParams != null ? pathParams.get("messageId") : null; if (roomId == null) { return createResponse(400, ApiResponse.error("roomId is required")); } - if (messageId != null) { - // Get single message - Optional message = chatMessageService.getMessage(roomId, messageId); - if (message.isEmpty()) { - return createResponse(404, ApiResponse.error("Message not found")); - } - return createResponse(200, ApiResponse.success("Message retrieved", message.get())); - } - - // 페이지네이션 파라미터 - int limit = 20; // 기본값 + int limit = 20; String cursor = null; if (queryParams != null) { if (queryParams.get("limit") != null) { - limit = Math.min(Integer.parseInt(queryParams.get("limit")), 50); // 최대 50 + limit = Math.min(Integer.parseInt(queryParams.get("limit")), 50); } cursor = queryParams.get("cursor"); } - // 메시지 목록 조회 (최신순, 페이지네이션) PaginatedResult messagePage = chatMessageService.getMessagesByRoomWithPagination(roomId, limit, cursor); Map result = new HashMap<>(); From 96a77c704470153de46ec5db68f1166b98763dde Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 8 Jan 2026 13:03:08 +0900 Subject: [PATCH 042/528] =?UTF-8?q?refactor:=20WordHandler=EC=97=90=20Hand?= =?UTF-8?q?lerRouter=20=EC=A0=81=EC=9A=A9=20refs=20#111?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vocabulary/handler/WordHandler.java | 74 ++++++------------- 1 file changed, 22 insertions(+), 52 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/WordHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/WordHandler.java index b1237cc2..dbc56853 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/WordHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/WordHandler.java @@ -6,6 +6,8 @@ import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; import com.mzc.secondproject.serverless.common.dto.ApiResponse; import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.common.router.HandlerRouter; +import com.mzc.secondproject.serverless.common.router.Route; import com.mzc.secondproject.serverless.common.util.ResponseUtil; import static com.mzc.secondproject.serverless.common.util.ResponseUtil.createResponse; import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; @@ -23,53 +25,29 @@ public class WordHandler implements RequestHandler requestBody = ResponseUtil.gson().fromJson(body, Map.class); - try { - Word word = wordService.updateWord(wordId, requestBody); - return createResponse(200, ApiResponse.success("Word updated", word)); - } catch (IllegalArgumentException e) { - return createResponse(404, ApiResponse.error(e.getMessage())); - } + Word word = wordService.updateWord(wordId, requestBody); + return createResponse(200, ApiResponse.success("Word updated", word)); } private APIGatewayProxyResponseEvent deleteWord(APIGatewayProxyRequestEvent request) { @@ -158,12 +132,8 @@ private APIGatewayProxyResponseEvent deleteWord(APIGatewayProxyRequestEvent requ return createResponse(400, ApiResponse.error("wordId is required")); } - try { - wordService.deleteWord(wordId); - return createResponse(200, ApiResponse.success("Word deleted", null)); - } catch (IllegalArgumentException e) { - return createResponse(404, ApiResponse.error(e.getMessage())); - } + wordService.deleteWord(wordId); + return createResponse(200, ApiResponse.success("Word deleted", null)); } @SuppressWarnings("unchecked") From 53ad71f6970473beceb01ebfc0e7a28d22059c50 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 8 Jan 2026 13:03:45 +0900 Subject: [PATCH 043/528] =?UTF-8?q?refactor:=20TestHandler=EC=97=90=20Hand?= =?UTF-8?q?lerRouter=20=EC=A0=81=EC=9A=A9=20refs=20#112?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vocabulary/handler/TestHandler.java | 58 +++++++------------ 1 file changed, 22 insertions(+), 36 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/TestHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/TestHandler.java index 32f2316b..7a3485f1 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/TestHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/TestHandler.java @@ -6,6 +6,8 @@ import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; import com.mzc.secondproject.serverless.common.dto.ApiResponse; import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.common.router.HandlerRouter; +import com.mzc.secondproject.serverless.common.router.Route; import com.mzc.secondproject.serverless.common.util.ResponseUtil; import static com.mzc.secondproject.serverless.common.util.ResponseUtil.createResponse; import com.mzc.secondproject.serverless.domain.vocabulary.model.TestResult; @@ -22,37 +24,25 @@ public class TestHandler implements RequestHandler requestBody = ResponseUtil.gson().fromJson(body, Map.class); String testType = (String) requestBody.getOrDefault("testType", "DAILY"); - try { - TestService.StartTestResult result = testService.startTest(userId, testType); + TestService.StartTestResult result = testService.startTest(userId, testType); - Map response = new HashMap<>(); - response.put("testId", result.testId()); - response.put("testType", result.testType()); - response.put("questions", result.questions()); - response.put("totalQuestions", result.totalQuestions()); - response.put("startedAt", result.startedAt()); + Map response = new HashMap<>(); + response.put("testId", result.testId()); + response.put("testType", result.testType()); + response.put("questions", result.questions()); + response.put("totalQuestions", result.totalQuestions()); + response.put("startedAt", result.startedAt()); - return createResponse(200, ApiResponse.success("Test started", response)); - } catch (IllegalStateException e) { - return createResponse(404, ApiResponse.error(e.getMessage())); - } + return createResponse(200, ApiResponse.success("Test started", response)); } @SuppressWarnings("unchecked") From 9311421a6f2303f6944c90b0ca53e6cb97dd4a78 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 8 Jan 2026 13:04:40 +0900 Subject: [PATCH 044/528] =?UTF-8?q?refactor:=20DailyStudyHandler=EC=97=90?= =?UTF-8?q?=20HandlerRouter=20=EC=A0=81=EC=9A=A9=20refs=20#113?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vocabulary/handler/DailyStudyHandler.java | 59 +++++++------------ 1 file changed, 22 insertions(+), 37 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/DailyStudyHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/DailyStudyHandler.java index fc35c000..6e34e6ef 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/DailyStudyHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/DailyStudyHandler.java @@ -5,6 +5,8 @@ import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; import com.mzc.secondproject.serverless.common.dto.ApiResponse; +import com.mzc.secondproject.serverless.common.router.HandlerRouter; +import com.mzc.secondproject.serverless.common.router.Route; import static com.mzc.secondproject.serverless.common.util.ResponseUtil.createResponse; import com.mzc.secondproject.serverless.domain.vocabulary.service.DailyStudyService; import org.slf4j.Logger; @@ -18,33 +20,24 @@ public class DailyStudyHandler implements RequestHandler response = new HashMap<>(); - response.put("dailyStudy", result.dailyStudy()); - response.put("newWords", result.newWords()); - response.put("reviewWords", result.reviewWords()); - response.put("progress", result.progress()); + Map response = new HashMap<>(); + response.put("dailyStudy", result.dailyStudy()); + response.put("newWords", result.newWords()); + response.put("reviewWords", result.reviewWords()); + response.put("progress", result.progress()); - return createResponse(200, ApiResponse.success("Daily words retrieved", response)); - } catch (IllegalArgumentException e) { - return createResponse(400, ApiResponse.error(e.getMessage())); - } + return createResponse(200, ApiResponse.success("Daily words retrieved", response)); } private APIGatewayProxyResponseEvent markWordLearned(APIGatewayProxyRequestEvent request) { @@ -82,11 +71,7 @@ private APIGatewayProxyResponseEvent markWordLearned(APIGatewayProxyRequestEvent return createResponse(400, ApiResponse.error("userId and wordId are required")); } - try { - Map progress = dailyStudyService.markWordLearned(userId, wordId); - return createResponse(200, ApiResponse.success("Word marked as learned", progress)); - } catch (IllegalStateException e) { - return createResponse(404, ApiResponse.error(e.getMessage())); - } + Map progress = dailyStudyService.markWordLearned(userId, wordId); + return createResponse(200, ApiResponse.success("Word marked as learned", progress)); } } From 4da1bc388a08e9f4538212783516cf0918688c70 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 8 Jan 2026 13:05:19 +0900 Subject: [PATCH 045/528] =?UTF-8?q?refactor:=20UserWordHandler=EC=97=90=20?= =?UTF-8?q?HandlerRouter=20=EC=A0=81=EC=9A=A9=20refs=20#114?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vocabulary/handler/UserWordHandler.java | 51 +++++++------------ 1 file changed, 17 insertions(+), 34 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java index c4e6b762..1abad357 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java @@ -5,6 +5,8 @@ import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; import com.mzc.secondproject.serverless.common.dto.ApiResponse; +import com.mzc.secondproject.serverless.common.router.HandlerRouter; +import com.mzc.secondproject.serverless.common.router.Route; import com.mzc.secondproject.serverless.common.util.ResponseUtil; import static com.mzc.secondproject.serverless.common.util.ResponseUtil.createResponse; import com.mzc.secondproject.serverless.domain.vocabulary.model.UserWord; @@ -21,41 +23,26 @@ public class UserWordHandler implements RequestHandler Date: Thu, 8 Jan 2026 13:05:52 +0900 Subject: [PATCH 046/528] =?UTF-8?q?refactor:=20StatsHandler=EC=97=90=20Han?= =?UTF-8?q?dlerRouter=20=EC=A0=81=EC=9A=A9=20refs=20#115?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vocabulary/handler/StatsHandler.java | 46 ++++++------------- 1 file changed, 15 insertions(+), 31 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatsHandler.java index bbeaacdb..8702637e 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatsHandler.java @@ -5,6 +5,8 @@ import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; import com.mzc.secondproject.serverless.common.dto.ApiResponse; +import com.mzc.secondproject.serverless.common.router.HandlerRouter; +import com.mzc.secondproject.serverless.common.router.Route; import static com.mzc.secondproject.serverless.common.util.ResponseUtil.createResponse; import com.mzc.secondproject.serverless.domain.vocabulary.service.StatsService; import org.slf4j.Logger; @@ -18,43 +20,25 @@ public class StatsHandler implements RequestHandler Date: Thu, 8 Jan 2026 13:12:33 +0900 Subject: [PATCH 047/528] =?UTF-8?q?feat(chatting):=20ChatRoomQueryService?= =?UTF-8?q?=20=EB=B6=84=EB=A6=AC=20refs=20#117?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/ChatRoomQueryService.java | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomQueryService.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomQueryService.java new file mode 100644 index 00000000..72762903 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomQueryService.java @@ -0,0 +1,41 @@ +package com.mzc.secondproject.serverless.domain.chatting.service; + +import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; +import com.mzc.secondproject.serverless.domain.chatting.repository.ChatRoomRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; +import java.util.Optional; + +/** + * ChatRoom 조회 전용 서비스 (CQRS Query) + */ +public class ChatRoomQueryService { + + private static final Logger logger = LoggerFactory.getLogger(ChatRoomQueryService.class); + + private final ChatRoomRepository roomRepository; + + public ChatRoomQueryService() { + this.roomRepository = new ChatRoomRepository(); + } + + public Optional getRoom(String roomId) { + return roomRepository.findById(roomId); + } + + public PaginatedResult getRooms(String level, int limit, String cursor) { + if (level != null && !level.isEmpty()) { + return roomRepository.findByLevelWithPagination(level, limit, cursor); + } + return roomRepository.findAllWithPagination(limit, cursor); + } + + public List filterByJoinedUser(List rooms, String userId) { + return rooms.stream() + .filter(room -> room.getMemberIds() != null && room.getMemberIds().contains(userId)) + .toList(); + } +} From 25dc87d2016e54f6a3dcab8aaa78214211ace2bb Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 8 Jan 2026 13:12:44 +0900 Subject: [PATCH 048/528] =?UTF-8?q?feat(chatting):=20ChatRoomCommandServic?= =?UTF-8?q?e=20=EB=B6=84=EB=A6=AC=20refs=20#117?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/ChatRoomCommandService.java | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomCommandService.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomCommandService.java new file mode 100644 index 00000000..277766d4 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomCommandService.java @@ -0,0 +1,134 @@ +package com.mzc.secondproject.serverless.domain.chatting.service; + +import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; +import com.mzc.secondproject.serverless.domain.chatting.repository.ChatRoomRepository; +import org.mindrot.jbcrypt.BCrypt; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +/** + * ChatRoom 변경 전용 서비스 (CQRS Command) + */ +public class ChatRoomCommandService { + + private static final Logger logger = LoggerFactory.getLogger(ChatRoomCommandService.class); + + private final ChatRoomRepository roomRepository; + + public ChatRoomCommandService() { + this.roomRepository = new ChatRoomRepository(); + } + + public ChatRoom createRoom(String name, String description, String level, Integer maxMembers, + Boolean isPrivate, String password, String createdBy) { + String roomId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + + ChatRoom room = ChatRoom.builder() + .pk("ROOM#" + roomId) + .sk("METADATA") + .gsi1pk("ROOMS") + .gsi1sk(level + "#" + now) + .roomId(roomId) + .name(name) + .description(description) + .level(level) + .currentMembers(1) + .maxMembers(maxMembers) + .isPrivate(isPrivate) + .password(isPrivate && password != null ? BCrypt.hashpw(password, BCrypt.gensalt()) : null) + .createdBy(createdBy) + .createdAt(now) + .lastMessageAt(now) + .memberIds(new ArrayList<>(List.of(createdBy))) + .build(); + + roomRepository.save(room); + logger.info("Created room: {}", roomId); + + return room; + } + + public ChatRoom joinRoom(String roomId, String userId, String password) { + Optional optRoom = roomRepository.findById(roomId); + if (optRoom.isEmpty()) { + throw new IllegalArgumentException("Room not found"); + } + + ChatRoom room = optRoom.get(); + + if (room.getIsPrivate()) { + if (password == null || room.getPassword() == null || !BCrypt.checkpw(password, room.getPassword())) { + throw new SecurityException("Invalid password"); + } + } + + if (room.getCurrentMembers() >= room.getMaxMembers()) { + throw new IllegalStateException("Room is full"); + } + + if (room.getMemberIds() != null && room.getMemberIds().contains(userId)) { + logger.info("User {} already in room {}", userId, roomId); + return room; + } + + if (room.getMemberIds() == null) { + room.setMemberIds(new ArrayList<>()); + } + room.getMemberIds().add(userId); + room.setCurrentMembers(room.getCurrentMembers() + 1); + + roomRepository.save(room); + logger.info("User {} joined room {}", userId, roomId); + + return room; + } + + public LeaveResult leaveRoom(String roomId, String userId) { + Optional optRoom = roomRepository.findById(roomId); + if (optRoom.isEmpty()) { + throw new IllegalArgumentException("Room not found"); + } + + ChatRoom room = optRoom.get(); + + if (room.getMemberIds() != null) { + room.getMemberIds().remove(userId); + room.setCurrentMembers(Math.max(0, room.getCurrentMembers() - 1)); + } + + if (userId.equals(room.getCreatedBy()) || room.getCurrentMembers() <= 0) { + roomRepository.delete(roomId); + logger.info("Room {} deleted (owner left or empty)", roomId); + return new LeaveResult(true, null); + } + + roomRepository.save(room); + logger.info("User {} left room {}", userId, roomId); + + return new LeaveResult(false, room); + } + + public void deleteRoom(String roomId, String userId) { + Optional optRoom = roomRepository.findById(roomId); + if (optRoom.isEmpty()) { + throw new IllegalArgumentException("Room not found"); + } + + ChatRoom room = optRoom.get(); + if (!userId.equals(room.getCreatedBy())) { + throw new SecurityException("Only the room owner can delete the room"); + } + + roomRepository.delete(roomId); + logger.info("Deleted room: {} by owner: {}", roomId, userId); + } + + public record LeaveResult(boolean deleted, ChatRoom room) {} +} From abe8822e91030a5a298a57caa7880cb86dc01514 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 8 Jan 2026 13:12:52 +0900 Subject: [PATCH 049/528] =?UTF-8?q?refactor(chatting):=20ChatRoomHandler?= =?UTF-8?q?=20CQRS=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=A0=81=EC=9A=A9=20re?= =?UTF-8?q?fs=20#117?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chatting/handler/ChatRoomHandler.java | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java index 3a6583a9..71006974 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java @@ -11,7 +11,8 @@ import com.mzc.secondproject.serverless.common.util.ResponseUtil; import static com.mzc.secondproject.serverless.common.util.ResponseUtil.createResponse; import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; -import com.mzc.secondproject.serverless.domain.chatting.service.ChatRoomService; +import com.mzc.secondproject.serverless.domain.chatting.service.ChatRoomCommandService; +import com.mzc.secondproject.serverless.domain.chatting.service.ChatRoomQueryService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -24,11 +25,13 @@ public class ChatRoomHandler implements RequestHandler roomPage = roomService.getRooms(level, limit, cursor); + PaginatedResult roomPage = queryService.getRooms(level, limit, cursor); List rooms = roomPage.getItems(); if ("true".equals(joined) && userId != null) { - rooms = roomService.filterByJoinedUser(rooms, userId); + rooms = queryService.filterByJoinedUser(rooms, userId); } rooms.forEach(room -> room.setPassword(null)); @@ -109,7 +112,7 @@ private APIGatewayProxyResponseEvent getRoom(APIGatewayProxyRequestEvent request return createResponse(400, ApiResponse.error("roomId is required")); } - Optional optRoom = roomService.getRoom(roomId); + Optional optRoom = queryService.getRoom(roomId); if (optRoom.isEmpty()) { return createResponse(404, ApiResponse.error("Room not found")); } @@ -133,7 +136,7 @@ private APIGatewayProxyResponseEvent joinRoom(APIGatewayProxyRequestEvent reques return createResponse(400, ApiResponse.error("roomId and userId are required")); } - ChatRoom room = roomService.joinRoom(roomId, userId, password); + ChatRoom room = commandService.joinRoom(roomId, userId, password); room.setPassword(null); return createResponse(200, ApiResponse.success("Joined room", room)); } @@ -150,7 +153,7 @@ private APIGatewayProxyResponseEvent leaveRoom(APIGatewayProxyRequestEvent reque return createResponse(400, ApiResponse.error("roomId and userId are required")); } - ChatRoomService.LeaveResult result = roomService.leaveRoom(roomId, userId); + ChatRoomCommandService.LeaveResult result = commandService.leaveRoom(roomId, userId); if (result.deleted()) { return createResponse(200, ApiResponse.success("Room deleted", null)); } @@ -173,7 +176,7 @@ private APIGatewayProxyResponseEvent deleteRoom(APIGatewayProxyRequestEvent requ return createResponse(400, ApiResponse.error("userId is required")); } - roomService.deleteRoom(roomId, userId); + commandService.deleteRoom(roomId, userId); return createResponse(200, ApiResponse.success("Room deleted", null)); } } From adc32fefd5230dc7b154b5917541ded80a4245f4 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 8 Jan 2026 13:17:31 +0900 Subject: [PATCH 050/528] =?UTF-8?q?feat(vocabulary):=20WordQueryService=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=20refs=20#118?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vocabulary/service/WordQueryService.java | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordQueryService.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordQueryService.java new file mode 100644 index 00000000..97f3257c --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordQueryService.java @@ -0,0 +1,40 @@ +package com.mzc.secondproject.serverless.domain.vocabulary.service; + +import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; +import com.mzc.secondproject.serverless.domain.vocabulary.repository.WordRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Optional; + +/** + * Word 조회 전용 서비스 (CQRS Query) + */ +public class WordQueryService { + + private static final Logger logger = LoggerFactory.getLogger(WordQueryService.class); + + private final WordRepository wordRepository; + + public WordQueryService() { + this.wordRepository = new WordRepository(); + } + + public Optional getWord(String wordId) { + return wordRepository.findById(wordId); + } + + public PaginatedResult getWords(String level, String category, int limit, String cursor) { + if (level != null && !level.isEmpty()) { + return wordRepository.findByLevelWithPagination(level, limit, cursor); + } else if (category != null && !category.isEmpty()) { + return wordRepository.findByCategoryWithPagination(category, limit, cursor); + } + return wordRepository.findByLevelWithPagination("BEGINNER", limit, cursor); + } + + public PaginatedResult searchWords(String query, int limit, String cursor) { + return wordRepository.searchByKeyword(query, limit, cursor); + } +} From 00f928ede8ceb1f872ca98fe225ed388573ba4c9 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 8 Jan 2026 13:17:41 +0900 Subject: [PATCH 051/528] =?UTF-8?q?feat(vocabulary):=20WordCommandService?= =?UTF-8?q?=20=EB=B6=84=EB=A6=AC=20refs=20#118?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/WordCommandService.java | 149 ++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordCommandService.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordCommandService.java new file mode 100644 index 00000000..fd173fd9 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordCommandService.java @@ -0,0 +1,149 @@ +package com.mzc.secondproject.serverless.domain.vocabulary.service; + +import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; +import com.mzc.secondproject.serverless.domain.vocabulary.repository.WordRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +/** + * Word 변경 전용 서비스 (CQRS Command) + */ +public class WordCommandService { + + private static final Logger logger = LoggerFactory.getLogger(WordCommandService.class); + + private final WordRepository wordRepository; + + public WordCommandService() { + this.wordRepository = new WordRepository(); + } + + public Word createWord(String english, String korean, String example, String level, String category) { + String wordId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + + Word word = Word.builder() + .pk("WORD#" + wordId) + .sk("METADATA") + .gsi1pk("LEVEL#" + level) + .gsi1sk("WORD#" + wordId) + .gsi2pk("CATEGORY#" + category) + .gsi2sk("WORD#" + wordId) + .wordId(wordId) + .english(english) + .korean(korean) + .example(example) + .level(level) + .category(category) + .createdAt(now) + .build(); + + wordRepository.save(word); + logger.info("Created word: {}", wordId); + + return word; + } + + public Word updateWord(String wordId, Map updates) { + Optional optWord = wordRepository.findById(wordId); + if (optWord.isEmpty()) { + throw new IllegalArgumentException("Word not found"); + } + + Word word = optWord.get(); + + if (updates.containsKey("english")) { + word.setEnglish((String) updates.get("english")); + } + if (updates.containsKey("korean")) { + word.setKorean((String) updates.get("korean")); + } + if (updates.containsKey("example")) { + word.setExample((String) updates.get("example")); + } + if (updates.containsKey("level")) { + String newLevel = (String) updates.get("level"); + word.setLevel(newLevel); + word.setGsi1pk("LEVEL#" + newLevel); + } + if (updates.containsKey("category")) { + String newCategory = (String) updates.get("category"); + word.setCategory(newCategory); + word.setGsi2pk("CATEGORY#" + newCategory); + } + + wordRepository.save(word); + logger.info("Updated word: {}", wordId); + + return word; + } + + public void deleteWord(String wordId) { + Optional optWord = wordRepository.findById(wordId); + if (optWord.isEmpty()) { + throw new IllegalArgumentException("Word not found"); + } + + wordRepository.delete(wordId); + logger.info("Deleted word: {}", wordId); + } + + public BatchResult createWordsBatch(List> wordsList) { + String now = Instant.now().toString(); + List createdWords = new ArrayList<>(); + int successCount = 0; + int failCount = 0; + + for (Map wordData : wordsList) { + try { + String english = (String) wordData.get("english"); + String korean = (String) wordData.get("korean"); + String example = (String) wordData.get("example"); + String level = (String) wordData.getOrDefault("level", "BEGINNER"); + String category = (String) wordData.getOrDefault("category", "DAILY"); + + if (english == null || korean == null) { + failCount++; + continue; + } + + String wordId = UUID.randomUUID().toString(); + + Word word = Word.builder() + .pk("WORD#" + wordId) + .sk("METADATA") + .gsi1pk("LEVEL#" + level) + .gsi1sk("WORD#" + wordId) + .gsi2pk("CATEGORY#" + category) + .gsi2sk("WORD#" + wordId) + .wordId(wordId) + .english(english) + .korean(korean) + .example(example) + .level(level) + .category(category) + .createdAt(now) + .build(); + + wordRepository.save(word); + createdWords.add(word); + successCount++; + } catch (Exception e) { + logger.error("Failed to create word", e); + failCount++; + } + } + + logger.info("Batch created {} words, failed {}", successCount, failCount); + return new BatchResult(successCount, failCount, wordsList.size()); + } + + public record BatchResult(int successCount, int failCount, int totalRequested) {} +} From 35c5ea6e9955ee051af74982d8d812658009a867 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 8 Jan 2026 13:17:49 +0900 Subject: [PATCH 052/528] =?UTF-8?q?refactor(vocabulary):=20WordHandler=20C?= =?UTF-8?q?QRS=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=A0=81=EC=9A=A9=20refs?= =?UTF-8?q?=20#118?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vocabulary/handler/WordHandler.java | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/WordHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/WordHandler.java index dbc56853..1c660096 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/WordHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/WordHandler.java @@ -11,7 +11,8 @@ import com.mzc.secondproject.serverless.common.util.ResponseUtil; import static com.mzc.secondproject.serverless.common.util.ResponseUtil.createResponse; import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; -import com.mzc.secondproject.serverless.domain.vocabulary.service.WordService; +import com.mzc.secondproject.serverless.domain.vocabulary.service.WordCommandService; +import com.mzc.secondproject.serverless.domain.vocabulary.service.WordQueryService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -24,11 +25,13 @@ public class WordHandler implements RequestHandler wordPage = wordService.getWords(level, category, limit, cursor); + PaginatedResult wordPage = queryService.getWords(level, category, limit, cursor); Map result = new HashMap<>(); result.put("words", wordPage.getItems()); @@ -101,7 +104,7 @@ private APIGatewayProxyResponseEvent getWord(APIGatewayProxyRequestEvent request return createResponse(400, ApiResponse.error("wordId is required")); } - Optional optWord = wordService.getWord(wordId); + Optional optWord = queryService.getWord(wordId); if (optWord.isEmpty()) { return createResponse(404, ApiResponse.error("Word not found")); } @@ -120,7 +123,7 @@ private APIGatewayProxyResponseEvent updateWord(APIGatewayProxyRequestEvent requ String body = request.getBody(); Map requestBody = ResponseUtil.gson().fromJson(body, Map.class); - Word word = wordService.updateWord(wordId, requestBody); + Word word = commandService.updateWord(wordId, requestBody); return createResponse(200, ApiResponse.success("Word updated", word)); } @@ -132,7 +135,7 @@ private APIGatewayProxyResponseEvent deleteWord(APIGatewayProxyRequestEvent requ return createResponse(400, ApiResponse.error("wordId is required")); } - wordService.deleteWord(wordId); + commandService.deleteWord(wordId); return createResponse(200, ApiResponse.success("Word deleted", null)); } @@ -146,7 +149,7 @@ private APIGatewayProxyResponseEvent createWordsBatch(APIGatewayProxyRequestEven return createResponse(400, ApiResponse.error("words array is required")); } - WordService.BatchResult result = wordService.createWordsBatch(wordsList); + WordCommandService.BatchResult result = commandService.createWordsBatch(wordsList); Map response = new HashMap<>(); response.put("successCount", result.successCount()); @@ -171,7 +174,7 @@ private APIGatewayProxyResponseEvent searchWords(APIGatewayProxyRequestEvent req limit = Math.min(Integer.parseInt(queryParams.get("limit")), 50); } - PaginatedResult wordPage = wordService.searchWords(query, limit, cursor); + PaginatedResult wordPage = queryService.searchWords(query, limit, cursor); Map result = new HashMap<>(); result.put("words", wordPage.getItems()); From f13528fbf5e6e91d63cad95f1124ca8a682c2b0d Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 8 Jan 2026 13:21:13 +0900 Subject: [PATCH 053/528] =?UTF-8?q?feat(vocabulary):=20TestQueryService=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=20refs=20#119?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vocabulary/service/TestQueryService.java | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestQueryService.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestQueryService.java new file mode 100644 index 00000000..3fc815cc --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestQueryService.java @@ -0,0 +1,25 @@ +package com.mzc.secondproject.serverless.domain.vocabulary.service; + +import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.domain.vocabulary.model.TestResult; +import com.mzc.secondproject.serverless.domain.vocabulary.repository.TestResultRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Test 조회 전용 서비스 (CQRS Query) + */ +public class TestQueryService { + + private static final Logger logger = LoggerFactory.getLogger(TestQueryService.class); + + private final TestResultRepository testResultRepository; + + public TestQueryService() { + this.testResultRepository = new TestResultRepository(); + } + + public PaginatedResult getTestResults(String userId, int limit, String cursor) { + return testResultRepository.findByUserIdWithPagination(userId, limit, cursor); + } +} From 52c49e10996ecdc07f97e384985d401e013de300 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 8 Jan 2026 13:21:22 +0900 Subject: [PATCH 054/528] =?UTF-8?q?feat(vocabulary):=20TestCommandService?= =?UTF-8?q?=20=EB=B6=84=EB=A6=AC=20refs=20#119?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/TestCommandService.java | 241 ++++++++++++++++++ 1 file changed, 241 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java new file mode 100644 index 00000000..5ba6d171 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java @@ -0,0 +1,241 @@ +package com.mzc.secondproject.serverless.domain.vocabulary.service; + +import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.common.util.AwsClients; +import com.mzc.secondproject.serverless.common.util.ResponseUtil; +import com.mzc.secondproject.serverless.domain.vocabulary.model.DailyStudy; +import com.mzc.secondproject.serverless.domain.vocabulary.model.TestResult; +import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; +import com.mzc.secondproject.serverless.domain.vocabulary.repository.DailyStudyRepository; +import com.mzc.secondproject.serverless.domain.vocabulary.repository.TestResultRepository; +import com.mzc.secondproject.serverless.domain.vocabulary.repository.WordRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.services.sns.model.PublishRequest; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Random; +import java.util.UUID; +import java.util.stream.Collectors; + +/** + * Test 변경 전용 서비스 (CQRS Command) + */ +public class TestCommandService { + + private static final Logger logger = LoggerFactory.getLogger(TestCommandService.class); + private static final String TEST_RESULT_TOPIC_ARN = System.getenv("TEST_RESULT_TOPIC_ARN"); + + private final TestResultRepository testResultRepository; + private final DailyStudyRepository dailyStudyRepository; + private final WordRepository wordRepository; + + public TestCommandService() { + this.testResultRepository = new TestResultRepository(); + this.dailyStudyRepository = new DailyStudyRepository(); + this.wordRepository = new WordRepository(); + } + + public StartTestResult startTest(String userId, String testType) { + String today = LocalDate.now().toString(); + + Optional optDailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today); + if (optDailyStudy.isEmpty()) { + throw new IllegalStateException("No daily study found for today"); + } + + DailyStudy dailyStudy = optDailyStudy.get(); + List allWordIds = new ArrayList<>(); + if (dailyStudy.getNewWordIds() != null) allWordIds.addAll(dailyStudy.getNewWordIds()); + if (dailyStudy.getReviewWordIds() != null) allWordIds.addAll(dailyStudy.getReviewWordIds()); + + if (allWordIds.isEmpty()) { + throw new IllegalStateException("No words to test"); + } + + List words = wordRepository.findByIds(allWordIds); + + Map> wordsByLevel = words.stream() + .collect(Collectors.groupingBy(Word::getLevel)); + + Map> distractorsByLevel = new HashMap<>(); + for (String level : wordsByLevel.keySet()) { + List distractors = getDistractorsForLevel(level, allWordIds); + distractorsByLevel.put(level, distractors); + } + + Random random = new Random(); + List> questions = new ArrayList<>(); + for (Word word : words) { + Map question = new HashMap<>(); + question.put("wordId", word.getWordId()); + question.put("english", word.getEnglish()); + question.put("example", word.getExample()); + + List options = generateOptions(word, wordsByLevel, distractorsByLevel, random); + question.put("options", options); + + questions.add(question); + } + + String testId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + + logger.info("Started test: userId={}, testId={}, questions={}", userId, testId, questions.size()); + + return new StartTestResult(testId, testType, questions, questions.size(), now); + } + + public SubmitTestResult submitTest(String userId, String testId, String testType, + List> answers, String startedAt) { + String now = Instant.now().toString(); + String today = LocalDate.now().toString(); + + int correctCount = 0; + int incorrectCount = 0; + List incorrectWordIds = new ArrayList<>(); + List> results = new ArrayList<>(); + + List wordIds = answers.stream() + .map(a -> (String) a.get("wordId")) + .collect(Collectors.toList()); + List words = wordRepository.findByIds(wordIds); + + Map wordMap = words.stream() + .collect(Collectors.toMap(Word::getWordId, w -> w)); + + for (Map answer : answers) { + String wordId = (String) answer.get("wordId"); + String userAnswer = (String) answer.get("answer"); + + Word word = wordMap.get(wordId); + if (word != null) { + boolean isCorrect = word.getKorean().trim().equalsIgnoreCase(userAnswer.trim()); + + Map resultItem = new HashMap<>(); + resultItem.put("wordId", wordId); + resultItem.put("english", word.getEnglish()); + resultItem.put("correctAnswer", word.getKorean()); + resultItem.put("userAnswer", userAnswer); + resultItem.put("isCorrect", isCorrect); + results.add(resultItem); + + if (isCorrect) { + correctCount++; + } else { + incorrectCount++; + incorrectWordIds.add(wordId); + } + } + } + + int totalQuestions = answers.size(); + double successRate = totalQuestions > 0 ? (correctCount * 100.0 / totalQuestions) : 0; + + TestResult testResult = TestResult.builder() + .pk("TEST#" + userId) + .sk("RESULT#" + now) + .gsi1pk("TEST#ALL") + .gsi1sk("DATE#" + today) + .testId(testId) + .userId(userId) + .testType(testType) + .totalQuestions(totalQuestions) + .correctAnswers(correctCount) + .incorrectAnswers(incorrectCount) + .successRate(successRate) + .incorrectWordIds(incorrectWordIds) + .startedAt(startedAt) + .completedAt(now) + .build(); + + testResultRepository.save(testResult); + + publishTestResultToSns(userId, results); + + logger.info("Test submitted: userId={}, testId={}, successRate={}%", userId, testId, successRate); + + return new SubmitTestResult(testId, testType, totalQuestions, correctCount, incorrectCount, successRate, results); + } + + private List getDistractorsForLevel(String level, List excludeWordIds) { + PaginatedResult wordPage = wordRepository.findByLevelWithPagination(level, 50, null); + return wordPage.getItems().stream() + .filter(w -> !excludeWordIds.contains(w.getWordId())) + .map(Word::getKorean) + .collect(Collectors.toList()); + } + + private List generateOptions(Word correctWord, Map> wordsByLevel, + Map> distractorsByLevel, Random random) { + List options = new ArrayList<>(); + String correctAnswer = correctWord.getKorean(); + options.add(correctAnswer); + + String level = correctWord.getLevel(); + + List sameLevelOptions = wordsByLevel.getOrDefault(level, new ArrayList<>()).stream() + .filter(w -> !w.getWordId().equals(correctWord.getWordId())) + .map(Word::getKorean) + .collect(Collectors.toList()); + + List additionalDistractors = distractorsByLevel.getOrDefault(level, new ArrayList<>()); + + List allDistractors = new ArrayList<>(); + allDistractors.addAll(sameLevelOptions); + allDistractors.addAll(additionalDistractors); + + allDistractors = allDistractors.stream() + .filter(d -> !d.equals(correctAnswer)) + .distinct() + .collect(Collectors.toList()); + + Collections.shuffle(allDistractors, random); + int distractorCount = Math.min(3, allDistractors.size()); + for (int i = 0; i < distractorCount; i++) { + options.add(allDistractors.get(i)); + } + + Collections.shuffle(options, random); + return options; + } + + private void publishTestResultToSns(String userId, List> results) { + if (TEST_RESULT_TOPIC_ARN == null || TEST_RESULT_TOPIC_ARN.isEmpty()) { + logger.warn("TEST_RESULT_TOPIC_ARN is not configured, skipping SNS publish"); + return; + } + + try { + Map message = new HashMap<>(); + message.put("userId", userId); + message.put("results", results); + + String messageJson = ResponseUtil.gson().toJson(message); + + PublishRequest publishRequest = PublishRequest.builder() + .topicArn(TEST_RESULT_TOPIC_ARN) + .message(messageJson) + .build(); + + AwsClients.sns().publish(publishRequest); + logger.info("Published test result to SNS for user: {}", userId); + } catch (Exception e) { + logger.error("Failed to publish test result to SNS for user: {}", userId, e); + } + } + + public record StartTestResult(String testId, String testType, List> questions, + int totalQuestions, String startedAt) {} + + public record SubmitTestResult(String testId, String testType, int totalQuestions, + int correctCount, int incorrectCount, double successRate, + List> results) {} +} From 2c03a2ac72d5b1e809466a1b4252286769f3fe53 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 8 Jan 2026 13:21:31 +0900 Subject: [PATCH 055/528] =?UTF-8?q?refactor(vocabulary):=20TestHandler=20C?= =?UTF-8?q?QRS=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=A0=81=EC=9A=A9=20refs?= =?UTF-8?q?=20#119?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/vocabulary/handler/TestHandler.java | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/TestHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/TestHandler.java index 7a3485f1..54c57e53 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/TestHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/TestHandler.java @@ -11,7 +11,8 @@ import com.mzc.secondproject.serverless.common.util.ResponseUtil; import static com.mzc.secondproject.serverless.common.util.ResponseUtil.createResponse; import com.mzc.secondproject.serverless.domain.vocabulary.model.TestResult; -import com.mzc.secondproject.serverless.domain.vocabulary.service.TestService; +import com.mzc.secondproject.serverless.domain.vocabulary.service.TestCommandService; +import com.mzc.secondproject.serverless.domain.vocabulary.service.TestQueryService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -23,11 +24,13 @@ public class TestHandler implements RequestHandler requestBody = ResponseUtil.gson().fromJson(body, Map.class); String testType = (String) requestBody.getOrDefault("testType", "DAILY"); - TestService.StartTestResult result = testService.startTest(userId, testType); + TestCommandService.StartTestResult result = commandService.startTest(userId, testType); Map response = new HashMap<>(); response.put("testId", result.testId()); @@ -90,7 +93,7 @@ private APIGatewayProxyResponseEvent submitAnswer(APIGatewayProxyRequestEvent re return createResponse(400, ApiResponse.error("testId and answers are required")); } - TestService.SubmitTestResult result = testService.submitTest(userId, testId, testType, answers, startedAt); + TestCommandService.SubmitTestResult result = commandService.submitTest(userId, testId, testType, answers, startedAt); Map response = new HashMap<>(); response.put("testId", result.testId()); @@ -120,7 +123,7 @@ private APIGatewayProxyResponseEvent getTestResults(APIGatewayProxyRequestEvent limit = Math.min(Integer.parseInt(queryParams.get("limit")), 50); } - PaginatedResult resultPage = testService.getTestResults(userId, limit, cursor); + PaginatedResult resultPage = queryService.getTestResults(userId, limit, cursor); Map result = new HashMap<>(); result.put("testResults", resultPage.getItems()); From e1adfcd4b770dac931fa8f57aac29f1e3a45d873 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 8 Jan 2026 13:24:46 +0900 Subject: [PATCH 056/528] =?UTF-8?q?feat(vocabulary):=20DailyStudyQueryServ?= =?UTF-8?q?ice=20=EB=B6=84=EB=A6=AC=20refs=20#120?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/DailyStudyQueryService.java | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyQueryService.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyQueryService.java new file mode 100644 index 00000000..fe3b33e6 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyQueryService.java @@ -0,0 +1,61 @@ +package com.mzc.secondproject.serverless.domain.vocabulary.service; + +import com.mzc.secondproject.serverless.domain.vocabulary.model.DailyStudy; +import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; +import com.mzc.secondproject.serverless.domain.vocabulary.repository.DailyStudyRepository; +import com.mzc.secondproject.serverless.domain.vocabulary.repository.WordRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * DailyStudy 조회 전용 서비스 (CQRS Query) + */ +public class DailyStudyQueryService { + + private static final Logger logger = LoggerFactory.getLogger(DailyStudyQueryService.class); + + private final DailyStudyRepository dailyStudyRepository; + private final WordRepository wordRepository; + + public DailyStudyQueryService() { + this.dailyStudyRepository = new DailyStudyRepository(); + this.wordRepository = new WordRepository(); + } + + public Optional getDailyStudy(String userId, String date) { + return dailyStudyRepository.findByUserIdAndDate(userId, date); + } + + public Optional getTodayDailyStudy(String userId) { + String today = LocalDate.now().toString(); + return dailyStudyRepository.findByUserIdAndDate(userId, today); + } + + public List getWordDetails(List wordIds) { + if (wordIds == null || wordIds.isEmpty()) { + return new ArrayList<>(); + } + return wordRepository.findByIds(wordIds); + } + + public Map calculateProgress(DailyStudy dailyStudy) { + Map progress = new HashMap<>(); + int total = dailyStudy.getTotalWords(); + int learned = dailyStudy.getLearnedCount(); + + progress.put("total", total); + progress.put("learned", learned); + progress.put("remaining", total - learned); + progress.put("percentage", total > 0 ? (learned * 100.0 / total) : 0); + progress.put("isCompleted", dailyStudy.getIsCompleted()); + + return progress; + } +} From 327d2e5add7b67f429f59945f443675ffecff510 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 8 Jan 2026 13:24:55 +0900 Subject: [PATCH 057/528] =?UTF-8?q?feat(vocabulary):=20DailyStudyCommandSe?= =?UTF-8?q?rvice=20=EB=B6=84=EB=A6=AC=20refs=20#120?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/DailyStudyCommandService.java | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyCommandService.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyCommandService.java new file mode 100644 index 00000000..39202eb7 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyCommandService.java @@ -0,0 +1,173 @@ +package com.mzc.secondproject.serverless.domain.vocabulary.service; + +import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.domain.vocabulary.model.DailyStudy; +import com.mzc.secondproject.serverless.domain.vocabulary.model.UserWord; +import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; +import com.mzc.secondproject.serverless.domain.vocabulary.repository.DailyStudyRepository; +import com.mzc.secondproject.serverless.domain.vocabulary.repository.UserWordRepository; +import com.mzc.secondproject.serverless.domain.vocabulary.repository.WordRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * DailyStudy 변경 전용 서비스 (CQRS Command) + */ +public class DailyStudyCommandService { + + private static final Logger logger = LoggerFactory.getLogger(DailyStudyCommandService.class); + + private static final int NEW_WORDS_COUNT = 50; + private static final int REVIEW_WORDS_COUNT = 5; + + private final DailyStudyRepository dailyStudyRepository; + private final UserWordRepository userWordRepository; + private final WordRepository wordRepository; + + public DailyStudyCommandService() { + this.dailyStudyRepository = new DailyStudyRepository(); + this.userWordRepository = new UserWordRepository(); + this.wordRepository = new WordRepository(); + } + + public DailyStudyResult getDailyWords(String userId, String level) { + String today = LocalDate.now().toString(); + + Optional optDailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today); + + DailyStudy dailyStudy; + if (optDailyStudy.isPresent()) { + dailyStudy = optDailyStudy.get(); + } else { + if (level == null || level.isEmpty()) { + throw new IllegalArgumentException("level is required for first daily study (BEGINNER, INTERMEDIATE, ADVANCED)"); + } + if (!level.equals("BEGINNER") && !level.equals("INTERMEDIATE") && !level.equals("ADVANCED")) { + throw new IllegalArgumentException("Invalid level. Must be BEGINNER, INTERMEDIATE, or ADVANCED"); + } + dailyStudy = createDailyStudy(userId, today, level); + } + + List newWords = getWordDetails(dailyStudy.getNewWordIds()); + List reviewWords = getWordDetails(dailyStudy.getReviewWordIds()); + Map progress = calculateProgress(dailyStudy); + + return new DailyStudyResult(dailyStudy, newWords, reviewWords, progress); + } + + public Map markWordLearned(String userId, String wordId) { + String today = LocalDate.now().toString(); + + Optional optDailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today); + if (optDailyStudy.isEmpty()) { + throw new IllegalStateException("Daily study not found"); + } + + DailyStudy dailyStudy = optDailyStudy.get(); + + if (dailyStudy.getLearnedWordIds() != null && dailyStudy.getLearnedWordIds().contains(wordId)) { + return calculateProgress(dailyStudy); + } + + dailyStudyRepository.addLearnedWord(userId, today, wordId); + + DailyStudy updatedDailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today).orElse(dailyStudy); + + if (updatedDailyStudy.getLearnedCount() >= updatedDailyStudy.getTotalWords()) { + updatedDailyStudy.setIsCompleted(true); + dailyStudyRepository.save(updatedDailyStudy); + } + + logger.info("Marked word as learned: userId={}, wordId={}", userId, wordId); + return calculateProgress(updatedDailyStudy); + } + + private DailyStudy createDailyStudy(String userId, String date, String level) { + String now = Instant.now().toString(); + + PaginatedResult reviewPage = userWordRepository.findReviewDueWords(userId, date, REVIEW_WORDS_COUNT, null); + List reviewWordIds = reviewPage.getItems().stream() + .map(UserWord::getWordId) + .collect(Collectors.toList()); + + List newWordIds = getNewWordsForUser(userId, level, NEW_WORDS_COUNT); + + DailyStudy dailyStudy = DailyStudy.builder() + .pk("DAILY#" + userId) + .sk("DATE#" + date) + .gsi1pk("DAILY#ALL") + .gsi1sk("DATE#" + date) + .userId(userId) + .date(date) + .newWordIds(newWordIds) + .reviewWordIds(reviewWordIds) + .learnedWordIds(new ArrayList<>()) + .totalWords(newWordIds.size() + reviewWordIds.size()) + .learnedCount(0) + .isCompleted(false) + .createdAt(now) + .updatedAt(now) + .build(); + + dailyStudyRepository.save(dailyStudy); + logger.info("Created daily study for user: {}, date: {}", userId, date); + + return dailyStudy; + } + + private List getNewWordsForUser(String userId, String level, int count) { + PaginatedResult userWordPage = userWordRepository.findByUserIdWithPagination(userId, 1000, null); + List learnedWordIds = userWordPage.getItems().stream() + .map(UserWord::getWordId) + .collect(Collectors.toList()); + + List newWordIds = new ArrayList<>(); + String lastEvaluatedKey = null; + + do { + PaginatedResult wordPage = wordRepository.findByLevelWithPagination(level, count * 2, lastEvaluatedKey); + for (Word word : wordPage.getItems()) { + if (!learnedWordIds.contains(word.getWordId()) && !newWordIds.contains(word.getWordId())) { + newWordIds.add(word.getWordId()); + if (newWordIds.size() >= count) break; + } + } + lastEvaluatedKey = wordPage.getNextCursor(); + } while (newWordIds.size() < count && lastEvaluatedKey != null); + + logger.info("Selected {} new words for user {} at level {}", newWordIds.size(), userId, level); + return newWordIds; + } + + private List getWordDetails(List wordIds) { + if (wordIds == null || wordIds.isEmpty()) { + return new ArrayList<>(); + } + return wordRepository.findByIds(wordIds); + } + + private Map calculateProgress(DailyStudy dailyStudy) { + Map progress = new HashMap<>(); + int total = dailyStudy.getTotalWords(); + int learned = dailyStudy.getLearnedCount(); + + progress.put("total", total); + progress.put("learned", learned); + progress.put("remaining", total - learned); + progress.put("percentage", total > 0 ? (learned * 100.0 / total) : 0); + progress.put("isCompleted", dailyStudy.getIsCompleted()); + + return progress; + } + + public record DailyStudyResult(DailyStudy dailyStudy, List newWords, List reviewWords, Map progress) {} +} From 7a417bc4dd1a2455a47f92389e6e56e96579be75 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 8 Jan 2026 13:25:04 +0900 Subject: [PATCH 058/528] =?UTF-8?q?refactor(vocabulary):=20DailyStudyHandl?= =?UTF-8?q?er=20CQRS=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=A0=81=EC=9A=A9=20?= =?UTF-8?q?refs=20#120?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vocabulary/handler/DailyStudyHandler.java | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/DailyStudyHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/DailyStudyHandler.java index 6e34e6ef..9b54c756 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/DailyStudyHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/DailyStudyHandler.java @@ -8,7 +8,8 @@ import com.mzc.secondproject.serverless.common.router.HandlerRouter; import com.mzc.secondproject.serverless.common.router.Route; import static com.mzc.secondproject.serverless.common.util.ResponseUtil.createResponse; -import com.mzc.secondproject.serverless.domain.vocabulary.service.DailyStudyService; +import com.mzc.secondproject.serverless.domain.vocabulary.service.DailyStudyCommandService; +import com.mzc.secondproject.serverless.domain.vocabulary.service.DailyStudyQueryService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -19,11 +20,13 @@ public class DailyStudyHandler implements RequestHandler response = new HashMap<>(); response.put("dailyStudy", result.dailyStudy()); @@ -71,7 +74,7 @@ private APIGatewayProxyResponseEvent markWordLearned(APIGatewayProxyRequestEvent return createResponse(400, ApiResponse.error("userId and wordId are required")); } - Map progress = dailyStudyService.markWordLearned(userId, wordId); + Map progress = commandService.markWordLearned(userId, wordId); return createResponse(200, ApiResponse.success("Word marked as learned", progress)); } } From d711ad6999970a2826db73653d693f71531c4a40 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 8 Jan 2026 13:28:32 +0900 Subject: [PATCH 059/528] =?UTF-8?q?feat(vocabulary):=20UserWordQueryServic?= =?UTF-8?q?e=20=EB=B6=84=EB=A6=AC=20refs=20#121?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/UserWordQueryService.java | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordQueryService.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordQueryService.java new file mode 100644 index 00000000..d57e8470 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordQueryService.java @@ -0,0 +1,105 @@ +package com.mzc.secondproject.serverless.domain.vocabulary.service; + +import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.domain.vocabulary.model.UserWord; +import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; +import com.mzc.secondproject.serverless.domain.vocabulary.repository.UserWordRepository; +import com.mzc.secondproject.serverless.domain.vocabulary.repository.WordRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * UserWord 조회 전용 서비스 (CQRS Query) + */ +public class UserWordQueryService { + + private static final Logger logger = LoggerFactory.getLogger(UserWordQueryService.class); + + private final UserWordRepository userWordRepository; + private final WordRepository wordRepository; + + public UserWordQueryService() { + this.userWordRepository = new UserWordRepository(); + this.wordRepository = new WordRepository(); + } + + public UserWordsResult getUserWords(String userId, String status, String bookmarked, + String incorrectOnly, int limit, String cursor) { + PaginatedResult userWordPage; + + if ("true".equalsIgnoreCase(bookmarked)) { + userWordPage = userWordRepository.findBookmarkedWords(userId, limit, cursor); + } else if ("true".equalsIgnoreCase(incorrectOnly)) { + userWordPage = userWordRepository.findIncorrectWords(userId, limit, cursor); + } else if (status != null && !status.isEmpty()) { + userWordPage = userWordRepository.findByUserIdAndStatus(userId, status, limit, cursor); + } else { + userWordPage = userWordRepository.findByUserIdWithPagination(userId, limit, cursor); + } + + List> enrichedUserWords = enrichWithWordInfo(userWordPage.getItems()); + + return new UserWordsResult(enrichedUserWords, userWordPage.getNextCursor(), userWordPage.hasMore()); + } + + public Optional getUserWord(String userId, String wordId) { + return userWordRepository.findByUserIdAndWordId(userId, wordId); + } + + private List> enrichWithWordInfo(List userWords) { + if (userWords == null || userWords.isEmpty()) { + return new ArrayList<>(); + } + + List wordIds = userWords.stream() + .map(UserWord::getWordId) + .collect(Collectors.toList()); + + List words = wordRepository.findByIds(wordIds); + + Map wordMap = words.stream() + .collect(Collectors.toMap(Word::getWordId, w -> w, (w1, w2) -> w1)); + + List> enrichedList = new ArrayList<>(); + for (UserWord userWord : userWords) { + Map enriched = new HashMap<>(); + + enriched.put("wordId", userWord.getWordId()); + enriched.put("userId", userWord.getUserId()); + enriched.put("status", userWord.getStatus()); + enriched.put("correctCount", userWord.getCorrectCount()); + enriched.put("incorrectCount", userWord.getIncorrectCount()); + enriched.put("bookmarked", userWord.getBookmarked()); + enriched.put("favorite", userWord.getFavorite()); + enriched.put("difficulty", userWord.getDifficulty()); + enriched.put("nextReviewAt", userWord.getNextReviewAt()); + enriched.put("lastReviewedAt", userWord.getLastReviewedAt()); + enriched.put("repetitions", userWord.getRepetitions()); + enriched.put("interval", userWord.getInterval()); + + Word word = wordMap.get(userWord.getWordId()); + if (word != null) { + enriched.put("english", word.getEnglish()); + enriched.put("korean", word.getKorean()); + enriched.put("level", word.getLevel()); + enriched.put("category", word.getCategory()); + enriched.put("example", word.getExample()); + enriched.put("maleVoiceKey", word.getMaleVoiceKey()); + enriched.put("femaleVoiceKey", word.getFemaleVoiceKey()); + } + + enrichedList.add(enriched); + } + + return enrichedList; + } + + public record UserWordsResult(List> userWords, String nextCursor, boolean hasMore) {} +} From 07632f5d12f4847de3b3cd6fe27dc22e0e3f2c1b Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 8 Jan 2026 13:28:41 +0900 Subject: [PATCH 060/528] =?UTF-8?q?feat(vocabulary):=20UserWordCommandServ?= =?UTF-8?q?ice=20=EB=B6=84=EB=A6=AC=20refs=20#121?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/UserWordCommandService.java | 146 ++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordCommandService.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordCommandService.java new file mode 100644 index 00000000..d6c11294 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordCommandService.java @@ -0,0 +1,146 @@ +package com.mzc.secondproject.serverless.domain.vocabulary.service; + +import com.mzc.secondproject.serverless.domain.vocabulary.model.UserWord; +import com.mzc.secondproject.serverless.domain.vocabulary.repository.UserWordRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.Optional; + +/** + * UserWord 변경 전용 서비스 (CQRS Command) + */ +public class UserWordCommandService { + + private static final Logger logger = LoggerFactory.getLogger(UserWordCommandService.class); + + private final UserWordRepository userWordRepository; + + public UserWordCommandService() { + this.userWordRepository = new UserWordRepository(); + } + + public UserWord updateUserWord(String userId, String wordId, boolean isCorrect) { + Optional optUserWord = userWordRepository.findByUserIdAndWordId(userId, wordId); + UserWord userWord; + String now = Instant.now().toString(); + + if (optUserWord.isEmpty()) { + userWord = UserWord.builder() + .pk("USER#" + userId) + .sk("WORD#" + wordId) + .gsi1pk("USER#" + userId + "#REVIEW") + .gsi2pk("USER#" + userId + "#STATUS") + .userId(userId) + .wordId(wordId) + .status("NEW") + .interval(1) + .easeFactor(2.5) + .repetitions(0) + .correctCount(0) + .incorrectCount(0) + .createdAt(now) + .build(); + } else { + userWord = optUserWord.get(); + } + + applySpacedRepetition(userWord, isCorrect); + userWord.setUpdatedAt(now); + userWord.setLastReviewedAt(now); + + userWord.setGsi1sk("DATE#" + userWord.getNextReviewAt()); + userWord.setGsi2sk("STATUS#" + userWord.getStatus()); + + userWordRepository.save(userWord); + + logger.info("Updated user word: userId={}, wordId={}, isCorrect={}", userId, wordId, isCorrect); + return userWord; + } + + public UserWord updateUserWordTag(String userId, String wordId, Boolean bookmarked, + Boolean favorite, String difficulty) { + Optional optUserWord = userWordRepository.findByUserIdAndWordId(userId, wordId); + UserWord userWord; + String now = Instant.now().toString(); + + if (optUserWord.isEmpty()) { + userWord = UserWord.builder() + .pk("USER#" + userId) + .sk("WORD#" + wordId) + .gsi1pk("USER#" + userId + "#REVIEW") + .gsi2pk("USER#" + userId + "#STATUS") + .gsi2sk("STATUS#NEW") + .userId(userId) + .wordId(wordId) + .status("NEW") + .interval(1) + .easeFactor(2.5) + .repetitions(0) + .correctCount(0) + .incorrectCount(0) + .bookmarked(false) + .favorite(false) + .createdAt(now) + .build(); + } else { + userWord = optUserWord.get(); + } + + if (bookmarked != null) { + userWord.setBookmarked(bookmarked); + } + if (favorite != null) { + userWord.setFavorite(favorite); + } + if (difficulty != null) { + if (!difficulty.equals("EASY") && !difficulty.equals("NORMAL") && !difficulty.equals("HARD")) { + throw new IllegalArgumentException("difficulty must be EASY, NORMAL, or HARD"); + } + userWord.setDifficulty(difficulty); + } + + userWord.setUpdatedAt(now); + userWordRepository.save(userWord); + + logger.info("Updated user word tag: userId={}, wordId={}", userId, wordId); + return userWord; + } + + private void applySpacedRepetition(UserWord userWord, boolean isCorrect) { + if (isCorrect) { + userWord.setCorrectCount(userWord.getCorrectCount() + 1); + userWord.setRepetitions(userWord.getRepetitions() + 1); + + if (userWord.getRepetitions() == 1) { + userWord.setInterval(1); + } else if (userWord.getRepetitions() == 2) { + userWord.setInterval(6); + } else { + int newInterval = (int) Math.round(userWord.getInterval() * userWord.getEaseFactor()); + userWord.setInterval(newInterval); + } + + if (userWord.getRepetitions() >= 5) { + userWord.setStatus("MASTERED"); + } else if (userWord.getRepetitions() >= 2) { + userWord.setStatus("REVIEWING"); + } else { + userWord.setStatus("LEARNING"); + } + } else { + userWord.setIncorrectCount(userWord.getIncorrectCount() + 1); + userWord.setRepetitions(0); + userWord.setInterval(1); + userWord.setStatus("LEARNING"); + + double newEaseFactor = userWord.getEaseFactor() - 0.2; + userWord.setEaseFactor(Math.max(1.3, newEaseFactor)); + } + + LocalDate nextReview = LocalDate.now().plusDays(userWord.getInterval()); + userWord.setNextReviewAt(nextReview.toString()); + } +} From 5f1a60b7d2e854fbc8ab980f948311c3bfff890d Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 8 Jan 2026 13:28:50 +0900 Subject: [PATCH 061/528] =?UTF-8?q?refactor(vocabulary):=20UserWordHandler?= =?UTF-8?q?=20CQRS=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=A0=81=EC=9A=A9=20re?= =?UTF-8?q?fs=20#121?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vocabulary/handler/UserWordHandler.java | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java index 1abad357..faf2db38 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java @@ -10,7 +10,8 @@ import com.mzc.secondproject.serverless.common.util.ResponseUtil; import static com.mzc.secondproject.serverless.common.util.ResponseUtil.createResponse; import com.mzc.secondproject.serverless.domain.vocabulary.model.UserWord; -import com.mzc.secondproject.serverless.domain.vocabulary.service.UserWordService; +import com.mzc.secondproject.serverless.domain.vocabulary.service.UserWordCommandService; +import com.mzc.secondproject.serverless.domain.vocabulary.service.UserWordQueryService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -22,11 +23,13 @@ public class UserWordHandler implements RequestHandler response = new HashMap<>(); response.put("userWords", result.userWords()); @@ -83,7 +86,7 @@ private APIGatewayProxyResponseEvent getUserWord(APIGatewayProxyRequestEvent req return createResponse(400, ApiResponse.error("userId and wordId are required")); } - Optional optUserWord = userWordService.getUserWord(userId, wordId); + Optional optUserWord = queryService.getUserWord(userId, wordId); if (optUserWord.isEmpty()) { return createResponse(404, ApiResponse.error("UserWord not found")); } @@ -108,7 +111,7 @@ private APIGatewayProxyResponseEvent updateUserWord(APIGatewayProxyRequestEvent return createResponse(400, ApiResponse.error("isCorrect is required")); } - UserWord userWord = userWordService.updateUserWord(userId, wordId, isCorrect); + UserWord userWord = commandService.updateUserWord(userId, wordId, isCorrect); return createResponse(200, ApiResponse.success("UserWord updated", userWord)); } @@ -128,7 +131,7 @@ private APIGatewayProxyResponseEvent updateUserWordTag(APIGatewayProxyRequestEve Boolean favorite = (Boolean) requestBody.get("favorite"); String difficulty = (String) requestBody.get("difficulty"); - UserWord userWord = userWordService.updateUserWordTag(userId, wordId, bookmarked, favorite, difficulty); + UserWord userWord = commandService.updateUserWordTag(userId, wordId, bookmarked, favorite, difficulty); return createResponse(200, ApiResponse.success("Tag updated", userWord)); } } From 7c4952f9bc6933a0487a1512be04ec4663160173 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 8 Jan 2026 13:49:40 +0900 Subject: [PATCH 062/528] =?UTF-8?q?refactor(vocabulary):=20WordHandler=20D?= =?UTF-8?q?TO=20=EC=A0=81=EC=9A=A9=20refs=20#84=20-=20CreateWordRequest=20?= =?UTF-8?q?DTO=EB=A5=BC=20=EC=82=AC=EC=9A=A9=ED=95=98=EC=97=AC=20=EB=8B=A8?= =?UTF-8?q?=EC=96=B4=20=EC=83=9D=EC=84=B1=20=EC=9A=94=EC=B2=AD=20=ED=8C=8C?= =?UTF-8?q?=EC=8B=B1=20-=20CreateWordsBatchRequest=20DTO=EB=A5=BC=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=ED=95=98=EC=97=AC=20=EB=B0=B0=EC=B9=98=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EC=9A=94=EC=B2=AD=20=ED=8C=8C=EC=8B=B1=20?= =?UTF-8?q?-=20WordCommandService.createWordsBatch()=20=EB=A9=94=EC=84=9C?= =?UTF-8?q?=EB=93=9C=20=EC=8B=9C=EA=B7=B8=EB=8B=88=EC=B2=98=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20-=20Map=20=EA=B8=B0=EB=B0=98=20=ED=8C=8C=EC=8B=B1?= =?UTF-8?q?=EC=9D=84=20=ED=83=80=EC=9E=85=20=EC=95=88=EC=A0=84=ED=95=9C=20?= =?UTF-8?q?DTO=20=ED=81=B4=EB=9E=98=EC=8A=A4=EB=A1=9C=20=EB=8C=80=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vocabulary/handler/WordHandler.java | 27 +++++++++---------- .../service/WordCommandService.java | 15 ++++++----- 2 files changed, 20 insertions(+), 22 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/WordHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/WordHandler.java index 1c660096..38b46e3b 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/WordHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/WordHandler.java @@ -6,6 +6,8 @@ import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; import com.mzc.secondproject.serverless.common.dto.ApiResponse; import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.common.dto.request.vocabulary.CreateWordRequest; +import com.mzc.secondproject.serverless.common.dto.request.vocabulary.CreateWordsBatchRequest; import com.mzc.secondproject.serverless.common.router.HandlerRouter; import com.mzc.secondproject.serverless.common.router.Route; import com.mzc.secondproject.serverless.common.util.ResponseUtil; @@ -55,22 +57,19 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent re private APIGatewayProxyResponseEvent createWord(APIGatewayProxyRequestEvent request) { String body = request.getBody(); - Map requestBody = ResponseUtil.gson().fromJson(body, Map.class); - - String english = (String) requestBody.get("english"); - String korean = (String) requestBody.get("korean"); - String example = (String) requestBody.get("example"); - String level = (String) requestBody.getOrDefault("level", "BEGINNER"); - String category = (String) requestBody.getOrDefault("category", "DAILY"); + CreateWordRequest req = ResponseUtil.gson().fromJson(body, CreateWordRequest.class); - if (english == null || english.isEmpty()) { + if (req.getEnglish() == null || req.getEnglish().isEmpty()) { return createResponse(400, ApiResponse.error("english is required")); } - if (korean == null || korean.isEmpty()) { + if (req.getKorean() == null || req.getKorean().isEmpty()) { return createResponse(400, ApiResponse.error("korean is required")); } - Word word = commandService.createWord(english, korean, example, level, category); + String level = req.getLevel() != null ? req.getLevel() : "BEGINNER"; + String category = req.getCategory() != null ? req.getCategory() : "DAILY"; + + Word word = commandService.createWord(req.getEnglish(), req.getKorean(), req.getExample(), level, category); return createResponse(201, ApiResponse.success("Word created", word)); } @@ -139,17 +138,15 @@ private APIGatewayProxyResponseEvent deleteWord(APIGatewayProxyRequestEvent requ return createResponse(200, ApiResponse.success("Word deleted", null)); } - @SuppressWarnings("unchecked") private APIGatewayProxyResponseEvent createWordsBatch(APIGatewayProxyRequestEvent request) { String body = request.getBody(); - Map requestBody = ResponseUtil.gson().fromJson(body, Map.class); + CreateWordsBatchRequest req = ResponseUtil.gson().fromJson(body, CreateWordsBatchRequest.class); - List> wordsList = (List>) requestBody.get("words"); - if (wordsList == null || wordsList.isEmpty()) { + if (req.getWords() == null || req.getWords().isEmpty()) { return createResponse(400, ApiResponse.error("words array is required")); } - WordCommandService.BatchResult result = commandService.createWordsBatch(wordsList); + WordCommandService.BatchResult result = commandService.createWordsBatch(req.getWords()); Map response = new HashMap<>(); response.put("successCount", result.successCount()); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordCommandService.java index fd173fd9..7b5fa174 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordCommandService.java @@ -1,5 +1,6 @@ package com.mzc.secondproject.serverless.domain.vocabulary.service; +import com.mzc.secondproject.serverless.common.dto.request.vocabulary.CreateWordRequest; import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; import com.mzc.secondproject.serverless.domain.vocabulary.repository.WordRepository; import org.slf4j.Logger; @@ -95,19 +96,19 @@ public void deleteWord(String wordId) { logger.info("Deleted word: {}", wordId); } - public BatchResult createWordsBatch(List> wordsList) { + public BatchResult createWordsBatch(List wordsList) { String now = Instant.now().toString(); List createdWords = new ArrayList<>(); int successCount = 0; int failCount = 0; - for (Map wordData : wordsList) { + for (CreateWordRequest wordData : wordsList) { try { - String english = (String) wordData.get("english"); - String korean = (String) wordData.get("korean"); - String example = (String) wordData.get("example"); - String level = (String) wordData.getOrDefault("level", "BEGINNER"); - String category = (String) wordData.getOrDefault("category", "DAILY"); + String english = wordData.getEnglish(); + String korean = wordData.getKorean(); + String example = wordData.getExample(); + String level = wordData.getLevel() != null ? wordData.getLevel() : "BEGINNER"; + String category = wordData.getCategory() != null ? wordData.getCategory() : "DAILY"; if (english == null || korean == null) { failCount++; From 3271a52a444e8f14873dc1e907bb2614118241be Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 8 Jan 2026 13:49:45 +0900 Subject: [PATCH 063/528] =?UTF-8?q?refactor(vocabulary):=20TestHandler=20D?= =?UTF-8?q?TO=20=EC=A0=81=EC=9A=A9=20refs=20#84=20-=20StartTestRequest=20D?= =?UTF-8?q?TO=EB=A5=BC=20=EC=82=AC=EC=9A=A9=ED=95=98=EC=97=AC=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=8B=9C=EC=9E=91=20=EC=9A=94=EC=B2=AD=20?= =?UTF-8?q?=ED=8C=8C=EC=8B=B1=20-=20SubmitTestRequest=20DTO=EB=A5=BC=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=ED=95=98=EC=97=AC=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=A0=9C=EC=B6=9C=20=EC=9A=94=EC=B2=AD=20=ED=8C=8C?= =?UTF-8?q?=EC=8B=B1=20-=20TestCommandService.submitTest()=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=20=EC=8B=9C=EA=B7=B8=EB=8B=88=EC=B2=98=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20-=20@SuppressWarnings("unchecked")=20?= =?UTF-8?q?=EC=96=B4=EB=85=B8=ED=85=8C=EC=9D=B4=EC=85=98=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vocabulary/handler/TestHandler.java | 20 +++++++++---------- .../service/TestCommandService.java | 11 +++++----- 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/TestHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/TestHandler.java index 54c57e53..0c0cc77d 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/TestHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/TestHandler.java @@ -6,6 +6,8 @@ import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; import com.mzc.secondproject.serverless.common.dto.ApiResponse; import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.common.dto.request.vocabulary.StartTestRequest; +import com.mzc.secondproject.serverless.common.dto.request.vocabulary.SubmitTestRequest; import com.mzc.secondproject.serverless.common.router.HandlerRouter; import com.mzc.secondproject.serverless.common.router.Route; import com.mzc.secondproject.serverless.common.util.ResponseUtil; @@ -57,8 +59,8 @@ private APIGatewayProxyResponseEvent startTest(APIGatewayProxyRequestEvent reque } String body = request.getBody(); - Map requestBody = ResponseUtil.gson().fromJson(body, Map.class); - String testType = (String) requestBody.getOrDefault("testType", "DAILY"); + StartTestRequest req = ResponseUtil.gson().fromJson(body, StartTestRequest.class); + String testType = req != null && req.getTestType() != null ? req.getTestType() : "DAILY"; TestCommandService.StartTestResult result = commandService.startTest(userId, testType); @@ -72,7 +74,6 @@ private APIGatewayProxyResponseEvent startTest(APIGatewayProxyRequestEvent reque return createResponse(200, ApiResponse.success("Test started", response)); } - @SuppressWarnings("unchecked") private APIGatewayProxyResponseEvent submitAnswer(APIGatewayProxyRequestEvent request) { Map pathParams = request.getPathParameters(); String userId = pathParams != null ? pathParams.get("userId") : null; @@ -82,18 +83,15 @@ private APIGatewayProxyResponseEvent submitAnswer(APIGatewayProxyRequestEvent re } String body = request.getBody(); - Map requestBody = ResponseUtil.gson().fromJson(body, Map.class); + SubmitTestRequest req = ResponseUtil.gson().fromJson(body, SubmitTestRequest.class); - String testId = (String) requestBody.get("testId"); - String testType = (String) requestBody.getOrDefault("testType", "DAILY"); - List> answers = (List>) requestBody.get("answers"); - String startedAt = (String) requestBody.get("startedAt"); - - if (testId == null || answers == null) { + if (req.getTestId() == null || req.getAnswers() == null) { return createResponse(400, ApiResponse.error("testId and answers are required")); } - TestCommandService.SubmitTestResult result = commandService.submitTest(userId, testId, testType, answers, startedAt); + String testType = req.getTestType() != null ? req.getTestType() : "DAILY"; + + TestCommandService.SubmitTestResult result = commandService.submitTest(userId, req.getTestId(), testType, req.getAnswers(), req.getStartedAt()); Map response = new HashMap<>(); response.put("testId", result.testId()); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java index 5ba6d171..57d8dab9 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java @@ -1,6 +1,7 @@ package com.mzc.secondproject.serverless.domain.vocabulary.service; import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.common.dto.request.vocabulary.SubmitTestRequest; import com.mzc.secondproject.serverless.common.util.AwsClients; import com.mzc.secondproject.serverless.common.util.ResponseUtil; import com.mzc.secondproject.serverless.domain.vocabulary.model.DailyStudy; @@ -94,7 +95,7 @@ public StartTestResult startTest(String userId, String testType) { } public SubmitTestResult submitTest(String userId, String testId, String testType, - List> answers, String startedAt) { + List answers, String startedAt) { String now = Instant.now().toString(); String today = LocalDate.now().toString(); @@ -104,16 +105,16 @@ public SubmitTestResult submitTest(String userId, String testId, String testType List> results = new ArrayList<>(); List wordIds = answers.stream() - .map(a -> (String) a.get("wordId")) + .map(SubmitTestRequest.TestAnswer::getWordId) .collect(Collectors.toList()); List words = wordRepository.findByIds(wordIds); Map wordMap = words.stream() .collect(Collectors.toMap(Word::getWordId, w -> w)); - for (Map answer : answers) { - String wordId = (String) answer.get("wordId"); - String userAnswer = (String) answer.get("answer"); + for (SubmitTestRequest.TestAnswer answer : answers) { + String wordId = answer.getWordId(); + String userAnswer = answer.getAnswer(); Word word = wordMap.get(wordId); if (word != null) { From e9c2de06b8c2bea60e388d03ef9b644e4e81104c Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 8 Jan 2026 13:49:50 +0900 Subject: [PATCH 064/528] =?UTF-8?q?refactor(vocabulary):=20UserWordHandler?= =?UTF-8?q?=20DTO=20=EC=A0=81=EC=9A=A9=20refs=20#84=20-=20UpdateUserWordRe?= =?UTF-8?q?quest=20DTO=EB=A5=BC=20=EC=82=AC=EC=9A=A9=ED=95=98=EC=97=AC=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20=EB=8B=A8=EC=96=B4=20=EC=97=85?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=8A=B8=20=EC=9A=94=EC=B2=AD=20=ED=8C=8C?= =?UTF-8?q?=EC=8B=B1=20-=20UpdateUserWordTagRequest=20DTO=EB=A5=BC=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=ED=95=98=EC=97=AC=20=ED=83=9C=EA=B7=B8=20?= =?UTF-8?q?=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20=EC=9A=94=EC=B2=AD=20?= =?UTF-8?q?=ED=8C=8C=EC=8B=B1=20-=20Map=20=EA=B8=B0=EB=B0=98=20=ED=8C=8C?= =?UTF-8?q?=EC=8B=B1=EC=9D=84=20=ED=83=80=EC=9E=85=20=EC=95=88=EC=A0=84?= =?UTF-8?q?=ED=95=9C=20DTO=20=ED=81=B4=EB=9E=98=EC=8A=A4=EB=A1=9C=20?= =?UTF-8?q?=EB=8C=80=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vocabulary/handler/UserWordHandler.java | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java index faf2db38..45847034 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java @@ -5,6 +5,8 @@ import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; import com.mzc.secondproject.serverless.common.dto.ApiResponse; +import com.mzc.secondproject.serverless.common.dto.request.vocabulary.UpdateUserWordRequest; +import com.mzc.secondproject.serverless.common.dto.request.vocabulary.UpdateUserWordTagRequest; import com.mzc.secondproject.serverless.common.router.HandlerRouter; import com.mzc.secondproject.serverless.common.router.Route; import com.mzc.secondproject.serverless.common.util.ResponseUtil; @@ -104,14 +106,13 @@ private APIGatewayProxyResponseEvent updateUserWord(APIGatewayProxyRequestEvent } String body = request.getBody(); - Map requestBody = ResponseUtil.gson().fromJson(body, Map.class); + UpdateUserWordRequest req = ResponseUtil.gson().fromJson(body, UpdateUserWordRequest.class); - Boolean isCorrect = (Boolean) requestBody.get("isCorrect"); - if (isCorrect == null) { + if (req.getIsCorrect() == null) { return createResponse(400, ApiResponse.error("isCorrect is required")); } - UserWord userWord = commandService.updateUserWord(userId, wordId, isCorrect); + UserWord userWord = commandService.updateUserWord(userId, wordId, req.getIsCorrect()); return createResponse(200, ApiResponse.success("UserWord updated", userWord)); } @@ -125,13 +126,9 @@ private APIGatewayProxyResponseEvent updateUserWordTag(APIGatewayProxyRequestEve } String body = request.getBody(); - Map requestBody = ResponseUtil.gson().fromJson(body, Map.class); + UpdateUserWordTagRequest req = ResponseUtil.gson().fromJson(body, UpdateUserWordTagRequest.class); - Boolean bookmarked = (Boolean) requestBody.get("bookmarked"); - Boolean favorite = (Boolean) requestBody.get("favorite"); - String difficulty = (String) requestBody.get("difficulty"); - - UserWord userWord = commandService.updateUserWordTag(userId, wordId, bookmarked, favorite, difficulty); + UserWord userWord = commandService.updateUserWordTag(userId, wordId, req.getBookmarked(), req.getFavorite(), req.getDifficulty()); return createResponse(200, ApiResponse.success("Tag updated", userWord)); } } From 119f05be63fd133cdbd955af0c92180883f50bcd Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 8 Jan 2026 13:49:54 +0900 Subject: [PATCH 065/528] =?UTF-8?q?refactor(vocabulary):=20VoiceHandler=20?= =?UTF-8?q?DTO=20=EC=A0=81=EC=9A=A9=20refs=20#84=20-=20SynthesizeVoiceRequ?= =?UTF-8?q?est=20DTO=EB=A5=BC=20=EC=82=AC=EC=9A=A9=ED=95=98=EC=97=AC=20?= =?UTF-8?q?=EC=9D=8C=EC=84=B1=20=ED=95=A9=EC=84=B1=20=EC=9A=94=EC=B2=AD=20?= =?UTF-8?q?=ED=8C=8C=EC=8B=B1=20-=20Map=20=EA=B8=B0=EB=B0=98=20=ED=8C=8C?= =?UTF-8?q?=EC=8B=B1=EC=9D=84=20=ED=83=80=EC=9E=85=20=EC=95=88=EC=A0=84?= =?UTF-8?q?=ED=95=9C=20DTO=20=ED=81=B4=EB=9E=98=EC=8A=A4=EB=A1=9C=20?= =?UTF-8?q?=EB=8C=80=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/vocabulary/handler/VoiceHandler.java | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/VoiceHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/VoiceHandler.java index 1e1fc219..50b19394 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/VoiceHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/VoiceHandler.java @@ -5,6 +5,7 @@ import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; import com.mzc.secondproject.serverless.common.dto.ApiResponse; +import com.mzc.secondproject.serverless.common.dto.request.vocabulary.SynthesizeVoiceRequest; import com.mzc.secondproject.serverless.common.util.ResponseUtil; import static com.mzc.secondproject.serverless.common.util.ResponseUtil.createResponse; import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; @@ -53,15 +54,15 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent re private APIGatewayProxyResponseEvent synthesizeSpeech(APIGatewayProxyRequestEvent request) { String body = request.getBody(); - Map requestBody = ResponseUtil.gson().fromJson(body, Map.class); + SynthesizeVoiceRequest req = ResponseUtil.gson().fromJson(body, SynthesizeVoiceRequest.class); - String wordId = (String) requestBody.get("wordId"); - String voice = (String) requestBody.getOrDefault("voice", "FEMALE"); - - if (wordId == null || wordId.isEmpty()) { + if (req.getWordId() == null || req.getWordId().isEmpty()) { return createResponse(400, ApiResponse.error("wordId is required")); } + String wordId = req.getWordId(); + String voice = req.getVoice() != null ? req.getVoice() : "FEMALE"; + // 단어 조회 Optional optWord = wordRepository.findById(wordId); if (optWord.isEmpty()) { From 7de5f8e3f7e4ee8a4c4bb4fa15c445e149ef2cc4 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 8 Jan 2026 13:50:53 +0900 Subject: [PATCH 066/528] =?UTF-8?q?feat(common):=20Request=20DTO=20?= =?UTF-8?q?=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=83=9D=EC=84=B1=20refs=20#84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../request/chatting/CreateRoomRequest.java | 23 +++++++++++++++ .../dto/request/chatting/JoinRoomRequest.java | 15 ++++++++++ .../request/chatting/LeaveRoomRequest.java | 14 +++++++++ .../request/chatting/SendMessageRequest.java | 17 +++++++++++ .../request/vocabulary/CreateWordRequest.java | 20 +++++++++++++ .../vocabulary/CreateWordsBatchRequest.java | 16 ++++++++++ .../request/vocabulary/StartTestRequest.java | 15 ++++++++++ .../request/vocabulary/SubmitTestRequest.java | 29 +++++++++++++++++++ .../vocabulary/SynthesizeVoiceRequest.java | 16 ++++++++++ .../vocabulary/UpdateUserWordRequest.java | 14 +++++++++ .../vocabulary/UpdateUserWordTagRequest.java | 16 ++++++++++ 11 files changed, 195 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/chatting/CreateRoomRequest.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/chatting/JoinRoomRequest.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/chatting/LeaveRoomRequest.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/chatting/SendMessageRequest.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/vocabulary/CreateWordRequest.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/vocabulary/CreateWordsBatchRequest.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/vocabulary/StartTestRequest.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/vocabulary/SubmitTestRequest.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/vocabulary/SynthesizeVoiceRequest.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/vocabulary/UpdateUserWordRequest.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/vocabulary/UpdateUserWordTagRequest.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/chatting/CreateRoomRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/chatting/CreateRoomRequest.java new file mode 100644 index 00000000..44cf19ee --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/chatting/CreateRoomRequest.java @@ -0,0 +1,23 @@ +package com.mzc.secondproject.serverless.common.dto.request.chatting; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CreateRoomRequest { + private String name; + private String description; + @Builder.Default + private String level = "beginner"; + @Builder.Default + private Integer maxMembers = 6; + @Builder.Default + private Boolean isPrivate = false; + private String password; + private String createdBy; +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/chatting/JoinRoomRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/chatting/JoinRoomRequest.java new file mode 100644 index 00000000..070c3f64 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/chatting/JoinRoomRequest.java @@ -0,0 +1,15 @@ +package com.mzc.secondproject.serverless.common.dto.request.chatting; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class JoinRoomRequest { + private String userId; + private String password; +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/chatting/LeaveRoomRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/chatting/LeaveRoomRequest.java new file mode 100644 index 00000000..ec2f07a1 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/chatting/LeaveRoomRequest.java @@ -0,0 +1,14 @@ +package com.mzc.secondproject.serverless.common.dto.request.chatting; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class LeaveRoomRequest { + private String userId; +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/chatting/SendMessageRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/chatting/SendMessageRequest.java new file mode 100644 index 00000000..89321480 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/chatting/SendMessageRequest.java @@ -0,0 +1,17 @@ +package com.mzc.secondproject.serverless.common.dto.request.chatting; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SendMessageRequest { + private String userId; + private String content; + @Builder.Default + private String messageType = "TEXT"; +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/vocabulary/CreateWordRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/vocabulary/CreateWordRequest.java new file mode 100644 index 00000000..813f3ed8 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/vocabulary/CreateWordRequest.java @@ -0,0 +1,20 @@ +package com.mzc.secondproject.serverless.common.dto.request.vocabulary; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CreateWordRequest { + private String english; + private String korean; + private String example; + @Builder.Default + private String level = "BEGINNER"; + @Builder.Default + private String category = "DAILY"; +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/vocabulary/CreateWordsBatchRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/vocabulary/CreateWordsBatchRequest.java new file mode 100644 index 00000000..ec361ec6 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/vocabulary/CreateWordsBatchRequest.java @@ -0,0 +1,16 @@ +package com.mzc.secondproject.serverless.common.dto.request.vocabulary; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CreateWordsBatchRequest { + private List words; +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/vocabulary/StartTestRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/vocabulary/StartTestRequest.java new file mode 100644 index 00000000..3da35578 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/vocabulary/StartTestRequest.java @@ -0,0 +1,15 @@ +package com.mzc.secondproject.serverless.common.dto.request.vocabulary; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class StartTestRequest { + @Builder.Default + private String testType = "DAILY"; +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/vocabulary/SubmitTestRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/vocabulary/SubmitTestRequest.java new file mode 100644 index 00000000..14b955bd --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/vocabulary/SubmitTestRequest.java @@ -0,0 +1,29 @@ +package com.mzc.secondproject.serverless.common.dto.request.vocabulary; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SubmitTestRequest { + private String testId; + @Builder.Default + private String testType = "DAILY"; + private List answers; + private String startedAt; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class TestAnswer { + private String wordId; + private String answer; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/vocabulary/SynthesizeVoiceRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/vocabulary/SynthesizeVoiceRequest.java new file mode 100644 index 00000000..b5fb9e12 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/vocabulary/SynthesizeVoiceRequest.java @@ -0,0 +1,16 @@ +package com.mzc.secondproject.serverless.common.dto.request.vocabulary; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SynthesizeVoiceRequest { + private String wordId; + @Builder.Default + private String voice = "FEMALE"; +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/vocabulary/UpdateUserWordRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/vocabulary/UpdateUserWordRequest.java new file mode 100644 index 00000000..9f901095 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/vocabulary/UpdateUserWordRequest.java @@ -0,0 +1,14 @@ +package com.mzc.secondproject.serverless.common.dto.request.vocabulary; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class UpdateUserWordRequest { + private Boolean isCorrect; +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/vocabulary/UpdateUserWordTagRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/vocabulary/UpdateUserWordTagRequest.java new file mode 100644 index 00000000..02132cf3 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/vocabulary/UpdateUserWordTagRequest.java @@ -0,0 +1,16 @@ +package com.mzc.secondproject.serverless.common.dto.request.vocabulary; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class UpdateUserWordTagRequest { + private Boolean bookmarked; + private Boolean favorite; + private String difficulty; +} From b1c84ab01f76e350487d67986fbbc911746b1c4b Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 8 Jan 2026 13:51:04 +0900 Subject: [PATCH 067/528] =?UTF-8?q?refactor(chatting):=20ChatRoomHandler?= =?UTF-8?q?=20DTO=20=EC=A0=81=EC=9A=A9=20refs=20#84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chatting/handler/ChatRoomHandler.java | 42 ++++++++----------- 1 file changed, 18 insertions(+), 24 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java index 71006974..a11b26ce 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java @@ -6,6 +6,9 @@ import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; import com.mzc.secondproject.serverless.common.dto.ApiResponse; import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.common.dto.request.chatting.CreateRoomRequest; +import com.mzc.secondproject.serverless.common.dto.request.chatting.JoinRoomRequest; +import com.mzc.secondproject.serverless.common.dto.request.chatting.LeaveRoomRequest; import com.mzc.secondproject.serverless.common.router.HandlerRouter; import com.mzc.secondproject.serverless.common.router.Route; import com.mzc.secondproject.serverless.common.util.ResponseUtil; @@ -53,22 +56,18 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent re } private APIGatewayProxyResponseEvent createRoom(APIGatewayProxyRequestEvent request) { - String body = request.getBody(); - Map requestBody = ResponseUtil.gson().fromJson(body, Map.class); - - String name = (String) requestBody.get("name"); - String description = (String) requestBody.get("description"); - String level = (String) requestBody.getOrDefault("level", "beginner"); - Integer maxMembers = ((Double) requestBody.getOrDefault("maxMembers", 6.0)).intValue(); - Boolean isPrivate = (Boolean) requestBody.getOrDefault("isPrivate", false); - String password = (String) requestBody.get("password"); - String createdBy = (String) requestBody.get("createdBy"); - - if (name == null || name.isEmpty()) { + CreateRoomRequest req = ResponseUtil.gson().fromJson(request.getBody(), CreateRoomRequest.class); + + if (req.getName() == null || req.getName().isEmpty()) { return createResponse(400, ApiResponse.error("name is required")); } - ChatRoom room = commandService.createRoom(name, description, level, maxMembers, isPrivate, password, createdBy); + String level = req.getLevel() != null ? req.getLevel() : "beginner"; + Integer maxMembers = req.getMaxMembers() != null ? req.getMaxMembers() : 6; + Boolean isPrivate = req.getIsPrivate() != null ? req.getIsPrivate() : false; + + ChatRoom room = commandService.createRoom( + req.getName(), req.getDescription(), level, maxMembers, isPrivate, req.getPassword(), req.getCreatedBy()); room.setPassword(null); return createResponse(201, ApiResponse.success("Room created", room)); @@ -127,16 +126,13 @@ private APIGatewayProxyResponseEvent joinRoom(APIGatewayProxyRequestEvent reques Map pathParams = request.getPathParameters(); String roomId = pathParams != null ? pathParams.get("roomId") : null; - String body = request.getBody(); - Map requestBody = ResponseUtil.gson().fromJson(body, Map.class); - String userId = requestBody.get("userId"); - String password = requestBody.get("password"); + JoinRoomRequest req = ResponseUtil.gson().fromJson(request.getBody(), JoinRoomRequest.class); - if (roomId == null || userId == null) { + if (roomId == null || req.getUserId() == null) { return createResponse(400, ApiResponse.error("roomId and userId are required")); } - ChatRoom room = commandService.joinRoom(roomId, userId, password); + ChatRoom room = commandService.joinRoom(roomId, req.getUserId(), req.getPassword()); room.setPassword(null); return createResponse(200, ApiResponse.success("Joined room", room)); } @@ -145,15 +141,13 @@ private APIGatewayProxyResponseEvent leaveRoom(APIGatewayProxyRequestEvent reque Map pathParams = request.getPathParameters(); String roomId = pathParams != null ? pathParams.get("roomId") : null; - String body = request.getBody(); - Map requestBody = ResponseUtil.gson().fromJson(body, Map.class); - String userId = requestBody.get("userId"); + LeaveRoomRequest req = ResponseUtil.gson().fromJson(request.getBody(), LeaveRoomRequest.class); - if (roomId == null || userId == null) { + if (roomId == null || req.getUserId() == null) { return createResponse(400, ApiResponse.error("roomId and userId are required")); } - ChatRoomCommandService.LeaveResult result = commandService.leaveRoom(roomId, userId); + ChatRoomCommandService.LeaveResult result = commandService.leaveRoom(roomId, req.getUserId()); if (result.deleted()) { return createResponse(200, ApiResponse.success("Room deleted", null)); } From 7d1da872a92b6fd3ae8dbab1bb707c11bb8d1513 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 8 Jan 2026 13:51:13 +0900 Subject: [PATCH 068/528] =?UTF-8?q?refactor(chatting):=20ChatMessageHandle?= =?UTF-8?q?r=20DTO=20=EC=A0=81=EC=9A=A9=20refs=20#84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chatting/handler/ChatMessageHandler.java | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatMessageHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatMessageHandler.java index 3f94f55a..d6e759cb 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatMessageHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatMessageHandler.java @@ -6,6 +6,7 @@ import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; import com.mzc.secondproject.serverless.common.dto.ApiResponse; import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.common.dto.request.chatting.SendMessageRequest; import com.mzc.secondproject.serverless.common.router.HandlerRouter; import com.mzc.secondproject.serverless.common.router.Route; import com.mzc.secondproject.serverless.common.util.ResponseUtil; @@ -58,31 +59,27 @@ private APIGatewayProxyResponseEvent sendMessage(APIGatewayProxyRequestEvent req return createResponse(400, ApiResponse.error("roomId is required")); } - String body = request.getBody(); - Map requestBody = ResponseUtil.gson().fromJson(body, Map.class); + SendMessageRequest req = ResponseUtil.gson().fromJson(request.getBody(), SendMessageRequest.class); - String userId = (String) requestBody.get("userId"); - String content = (String) requestBody.get("content"); - String messageType = (String) requestBody.getOrDefault("messageType", "TEXT"); - - if (userId == null || content == null) { + if (req.getUserId() == null || req.getContent() == null) { return createResponse(400, ApiResponse.error("userId and content are required")); } + String messageType = req.getMessageType() != null ? req.getMessageType() : "TEXT"; String messageId = UUID.randomUUID().toString(); String now = Instant.now().toString(); ChatMessage message = ChatMessage.builder() .pk("ROOM#" + roomId) .sk("MSG#" + now + "#" + messageId) - .gsi1pk("USER#" + userId) + .gsi1pk("USER#" + req.getUserId()) .gsi1sk("MSG#" + now) .gsi2pk("MSG#" + messageId) .gsi2sk("ROOM#" + roomId) .messageId(messageId) .roomId(roomId) - .userId(userId) - .content(content) + .userId(req.getUserId()) + .content(req.getContent()) .messageType(messageType) .createdAt(now) .build(); From c106ce6c190de85a9484b9d185d775b447832f16 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 8 Jan 2026 14:03:59 +0900 Subject: [PATCH 069/528] =?UTF-8?q?refactor(dto):=20DTO=EB=A5=BC=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=EB=B3=84=EB=A1=9C=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99=20=EB=B0=8F=20=EA=B2=80=EC=A6=9D=20=EC=9C=A0=ED=8B=B8?= =?UTF-8?q?=EB=A6=AC=ED=8B=B0=20=EC=B6=94=EA=B0=80=20refs=20#85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - common/dto/request → domain/{chatting,vocabulary}/dto/request 로 이동 - 검증 유틸리티 클래스 추가 (ValidationResult, RequestValidator) - 핸들러 및 서비스의 import 경로 수정 --- .../common/validation/RequestValidator.java | 81 +++++++++++++++++++ .../common/validation/ValidationResult.java | 33 ++++++++ .../dto/request}/CreateRoomRequest.java | 2 +- .../dto/request}/JoinRoomRequest.java | 2 +- .../dto/request}/LeaveRoomRequest.java | 2 +- .../dto/request}/SendMessageRequest.java | 2 +- .../chatting/handler/ChatMessageHandler.java | 2 +- .../chatting/handler/ChatRoomHandler.java | 6 +- .../dto/request}/CreateWordRequest.java | 2 +- .../dto/request}/CreateWordsBatchRequest.java | 2 +- .../dto/request}/StartTestRequest.java | 2 +- .../dto/request}/SubmitTestRequest.java | 2 +- .../dto/request}/SynthesizeVoiceRequest.java | 2 +- .../dto/request}/UpdateUserWordRequest.java | 2 +- .../request}/UpdateUserWordTagRequest.java | 2 +- .../vocabulary/handler/TestHandler.java | 4 +- .../vocabulary/handler/UserWordHandler.java | 4 +- .../vocabulary/handler/VoiceHandler.java | 2 +- .../vocabulary/handler/WordHandler.java | 4 +- .../service/TestCommandService.java | 2 +- .../service/WordCommandService.java | 2 +- 21 files changed, 138 insertions(+), 24 deletions(-) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/validation/RequestValidator.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/validation/ValidationResult.java rename ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/{common/dto/request/chatting => domain/chatting/dto/request}/CreateRoomRequest.java (87%) rename ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/{common/dto/request/chatting => domain/chatting/dto/request}/JoinRoomRequest.java (78%) rename ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/{common/dto/request/chatting => domain/chatting/dto/request}/LeaveRoomRequest.java (76%) rename ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/{common/dto/request/chatting => domain/chatting/dto/request}/SendMessageRequest.java (82%) rename ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/{common/dto/request/vocabulary => domain/vocabulary/dto/request}/CreateWordRequest.java (84%) rename ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/{common/dto/request/vocabulary => domain/vocabulary/dto/request}/CreateWordsBatchRequest.java (79%) rename ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/{common/dto/request/vocabulary => domain/vocabulary/dto/request}/StartTestRequest.java (78%) rename ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/{common/dto/request/vocabulary => domain/vocabulary/dto/request}/SubmitTestRequest.java (88%) rename ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/{common/dto/request/vocabulary => domain/vocabulary/dto/request}/SynthesizeVoiceRequest.java (80%) rename ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/{common/dto/request/vocabulary => domain/vocabulary/dto/request}/UpdateUserWordRequest.java (76%) rename ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/{common/dto/request/vocabulary => domain/vocabulary/dto/request}/UpdateUserWordTagRequest.java (80%) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/validation/RequestValidator.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/validation/RequestValidator.java new file mode 100644 index 00000000..46e264f9 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/validation/RequestValidator.java @@ -0,0 +1,81 @@ +package com.mzc.secondproject.serverless.common.validation; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +public class RequestValidator { + + private final List errors = new ArrayList<>(); + + public static RequestValidator create() { + return new RequestValidator(); + } + + public RequestValidator requireNotNull(Object value, String fieldName) { + if (value == null) { + errors.add(fieldName + " is required"); + } + return this; + } + + public RequestValidator requireNotEmpty(String value, String fieldName) { + if (value == null || value.isEmpty()) { + errors.add(fieldName + " is required"); + } + return this; + } + + public RequestValidator requireNotEmpty(Collection value, String fieldName) { + if (value == null || value.isEmpty()) { + errors.add(fieldName + " is required"); + } + return this; + } + + public RequestValidator requirePositive(Integer value, String fieldName) { + if (value != null && value <= 0) { + errors.add(fieldName + " must be positive"); + } + return this; + } + + public RequestValidator requireInRange(Integer value, int min, int max, String fieldName) { + if (value != null && (value < min || value > max)) { + errors.add(fieldName + " must be between " + min + " and " + max); + } + return this; + } + + public RequestValidator requireAnyNotNull(String message, Object... values) { + for (Object value : values) { + if (value != null) { + return this; + } + } + errors.add(message); + return this; + } + + public RequestValidator validate(boolean condition, String errorMessage) { + if (!condition) { + errors.add(errorMessage); + } + return this; + } + + public ValidationResult build() { + if (errors.isEmpty()) { + return ValidationResult.ok(); + } + return ValidationResult.error(String.join(", ", errors)); + } + + public boolean hasErrors() { + return !errors.isEmpty(); + } + + public String getFirstError() { + return errors.isEmpty() ? null : errors.get(0); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/validation/ValidationResult.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/validation/ValidationResult.java new file mode 100644 index 00000000..5f78f18d --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/validation/ValidationResult.java @@ -0,0 +1,33 @@ +package com.mzc.secondproject.serverless.common.validation; + +import java.util.Optional; + +public class ValidationResult { + private final boolean valid; + private final String errorMessage; + + private ValidationResult(boolean valid, String errorMessage) { + this.valid = valid; + this.errorMessage = errorMessage; + } + + public static ValidationResult ok() { + return new ValidationResult(true, null); + } + + public static ValidationResult error(String message) { + return new ValidationResult(false, message); + } + + public boolean isValid() { + return valid; + } + + public boolean isInvalid() { + return !valid; + } + + public Optional getErrorMessage() { + return Optional.ofNullable(errorMessage); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/chatting/CreateRoomRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/CreateRoomRequest.java similarity index 87% rename from ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/chatting/CreateRoomRequest.java rename to ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/CreateRoomRequest.java index 44cf19ee..616f0b24 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/chatting/CreateRoomRequest.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/CreateRoomRequest.java @@ -1,4 +1,4 @@ -package com.mzc.secondproject.serverless.common.dto.request.chatting; +package com.mzc.secondproject.serverless.domain.chatting.dto.request; import lombok.AllArgsConstructor; import lombok.Builder; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/chatting/JoinRoomRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/JoinRoomRequest.java similarity index 78% rename from ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/chatting/JoinRoomRequest.java rename to ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/JoinRoomRequest.java index 070c3f64..1be8e8c9 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/chatting/JoinRoomRequest.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/JoinRoomRequest.java @@ -1,4 +1,4 @@ -package com.mzc.secondproject.serverless.common.dto.request.chatting; +package com.mzc.secondproject.serverless.domain.chatting.dto.request; import lombok.AllArgsConstructor; import lombok.Builder; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/chatting/LeaveRoomRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/LeaveRoomRequest.java similarity index 76% rename from ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/chatting/LeaveRoomRequest.java rename to ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/LeaveRoomRequest.java index ec2f07a1..d9fc5c69 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/chatting/LeaveRoomRequest.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/LeaveRoomRequest.java @@ -1,4 +1,4 @@ -package com.mzc.secondproject.serverless.common.dto.request.chatting; +package com.mzc.secondproject.serverless.domain.chatting.dto.request; import lombok.AllArgsConstructor; import lombok.Builder; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/chatting/SendMessageRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/SendMessageRequest.java similarity index 82% rename from ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/chatting/SendMessageRequest.java rename to ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/SendMessageRequest.java index 89321480..6d4bf0a3 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/chatting/SendMessageRequest.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/SendMessageRequest.java @@ -1,4 +1,4 @@ -package com.mzc.secondproject.serverless.common.dto.request.chatting; +package com.mzc.secondproject.serverless.domain.chatting.dto.request; import lombok.AllArgsConstructor; import lombok.Builder; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatMessageHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatMessageHandler.java index d6e759cb..6faea299 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatMessageHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatMessageHandler.java @@ -6,7 +6,7 @@ import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; import com.mzc.secondproject.serverless.common.dto.ApiResponse; import com.mzc.secondproject.serverless.common.dto.PaginatedResult; -import com.mzc.secondproject.serverless.common.dto.request.chatting.SendMessageRequest; +import com.mzc.secondproject.serverless.domain.chatting.dto.request.SendMessageRequest; import com.mzc.secondproject.serverless.common.router.HandlerRouter; import com.mzc.secondproject.serverless.common.router.Route; import com.mzc.secondproject.serverless.common.util.ResponseUtil; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java index a11b26ce..99d37232 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java @@ -6,9 +6,9 @@ import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; import com.mzc.secondproject.serverless.common.dto.ApiResponse; import com.mzc.secondproject.serverless.common.dto.PaginatedResult; -import com.mzc.secondproject.serverless.common.dto.request.chatting.CreateRoomRequest; -import com.mzc.secondproject.serverless.common.dto.request.chatting.JoinRoomRequest; -import com.mzc.secondproject.serverless.common.dto.request.chatting.LeaveRoomRequest; +import com.mzc.secondproject.serverless.domain.chatting.dto.request.CreateRoomRequest; +import com.mzc.secondproject.serverless.domain.chatting.dto.request.JoinRoomRequest; +import com.mzc.secondproject.serverless.domain.chatting.dto.request.LeaveRoomRequest; import com.mzc.secondproject.serverless.common.router.HandlerRouter; import com.mzc.secondproject.serverless.common.router.Route; import com.mzc.secondproject.serverless.common.util.ResponseUtil; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/vocabulary/CreateWordRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/CreateWordRequest.java similarity index 84% rename from ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/vocabulary/CreateWordRequest.java rename to ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/CreateWordRequest.java index 813f3ed8..6d232dae 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/vocabulary/CreateWordRequest.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/CreateWordRequest.java @@ -1,4 +1,4 @@ -package com.mzc.secondproject.serverless.common.dto.request.vocabulary; +package com.mzc.secondproject.serverless.domain.vocabulary.dto.request; import lombok.AllArgsConstructor; import lombok.Builder; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/vocabulary/CreateWordsBatchRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/CreateWordsBatchRequest.java similarity index 79% rename from ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/vocabulary/CreateWordsBatchRequest.java rename to ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/CreateWordsBatchRequest.java index ec361ec6..06fb4e47 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/vocabulary/CreateWordsBatchRequest.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/CreateWordsBatchRequest.java @@ -1,4 +1,4 @@ -package com.mzc.secondproject.serverless.common.dto.request.vocabulary; +package com.mzc.secondproject.serverless.domain.vocabulary.dto.request; import lombok.AllArgsConstructor; import lombok.Builder; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/vocabulary/StartTestRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/StartTestRequest.java similarity index 78% rename from ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/vocabulary/StartTestRequest.java rename to ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/StartTestRequest.java index 3da35578..cebc1335 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/vocabulary/StartTestRequest.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/StartTestRequest.java @@ -1,4 +1,4 @@ -package com.mzc.secondproject.serverless.common.dto.request.vocabulary; +package com.mzc.secondproject.serverless.domain.vocabulary.dto.request; import lombok.AllArgsConstructor; import lombok.Builder; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/vocabulary/SubmitTestRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/SubmitTestRequest.java similarity index 88% rename from ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/vocabulary/SubmitTestRequest.java rename to ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/SubmitTestRequest.java index 14b955bd..584d1883 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/vocabulary/SubmitTestRequest.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/SubmitTestRequest.java @@ -1,4 +1,4 @@ -package com.mzc.secondproject.serverless.common.dto.request.vocabulary; +package com.mzc.secondproject.serverless.domain.vocabulary.dto.request; import lombok.AllArgsConstructor; import lombok.Builder; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/vocabulary/SynthesizeVoiceRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/SynthesizeVoiceRequest.java similarity index 80% rename from ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/vocabulary/SynthesizeVoiceRequest.java rename to ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/SynthesizeVoiceRequest.java index b5fb9e12..9d113c53 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/vocabulary/SynthesizeVoiceRequest.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/SynthesizeVoiceRequest.java @@ -1,4 +1,4 @@ -package com.mzc.secondproject.serverless.common.dto.request.vocabulary; +package com.mzc.secondproject.serverless.domain.vocabulary.dto.request; import lombok.AllArgsConstructor; import lombok.Builder; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/vocabulary/UpdateUserWordRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/UpdateUserWordRequest.java similarity index 76% rename from ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/vocabulary/UpdateUserWordRequest.java rename to ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/UpdateUserWordRequest.java index 9f901095..023a0405 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/vocabulary/UpdateUserWordRequest.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/UpdateUserWordRequest.java @@ -1,4 +1,4 @@ -package com.mzc.secondproject.serverless.common.dto.request.vocabulary; +package com.mzc.secondproject.serverless.domain.vocabulary.dto.request; import lombok.AllArgsConstructor; import lombok.Builder; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/vocabulary/UpdateUserWordTagRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/UpdateUserWordTagRequest.java similarity index 80% rename from ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/vocabulary/UpdateUserWordTagRequest.java rename to ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/UpdateUserWordTagRequest.java index 02132cf3..b4596498 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/request/vocabulary/UpdateUserWordTagRequest.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/UpdateUserWordTagRequest.java @@ -1,4 +1,4 @@ -package com.mzc.secondproject.serverless.common.dto.request.vocabulary; +package com.mzc.secondproject.serverless.domain.vocabulary.dto.request; import lombok.AllArgsConstructor; import lombok.Builder; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/TestHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/TestHandler.java index 0c0cc77d..7ea54baf 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/TestHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/TestHandler.java @@ -6,8 +6,8 @@ import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; import com.mzc.secondproject.serverless.common.dto.ApiResponse; import com.mzc.secondproject.serverless.common.dto.PaginatedResult; -import com.mzc.secondproject.serverless.common.dto.request.vocabulary.StartTestRequest; -import com.mzc.secondproject.serverless.common.dto.request.vocabulary.SubmitTestRequest; +import com.mzc.secondproject.serverless.domain.vocabulary.dto.request.StartTestRequest; +import com.mzc.secondproject.serverless.domain.vocabulary.dto.request.SubmitTestRequest; import com.mzc.secondproject.serverless.common.router.HandlerRouter; import com.mzc.secondproject.serverless.common.router.Route; import com.mzc.secondproject.serverless.common.util.ResponseUtil; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java index 45847034..7d198b3c 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java @@ -5,8 +5,8 @@ import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; import com.mzc.secondproject.serverless.common.dto.ApiResponse; -import com.mzc.secondproject.serverless.common.dto.request.vocabulary.UpdateUserWordRequest; -import com.mzc.secondproject.serverless.common.dto.request.vocabulary.UpdateUserWordTagRequest; +import com.mzc.secondproject.serverless.domain.vocabulary.dto.request.UpdateUserWordRequest; +import com.mzc.secondproject.serverless.domain.vocabulary.dto.request.UpdateUserWordTagRequest; import com.mzc.secondproject.serverless.common.router.HandlerRouter; import com.mzc.secondproject.serverless.common.router.Route; import com.mzc.secondproject.serverless.common.util.ResponseUtil; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/VoiceHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/VoiceHandler.java index 50b19394..a2b32807 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/VoiceHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/VoiceHandler.java @@ -5,7 +5,7 @@ import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; import com.mzc.secondproject.serverless.common.dto.ApiResponse; -import com.mzc.secondproject.serverless.common.dto.request.vocabulary.SynthesizeVoiceRequest; +import com.mzc.secondproject.serverless.domain.vocabulary.dto.request.SynthesizeVoiceRequest; import com.mzc.secondproject.serverless.common.util.ResponseUtil; import static com.mzc.secondproject.serverless.common.util.ResponseUtil.createResponse; import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/WordHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/WordHandler.java index 38b46e3b..7b07a587 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/WordHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/WordHandler.java @@ -6,8 +6,8 @@ import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; import com.mzc.secondproject.serverless.common.dto.ApiResponse; import com.mzc.secondproject.serverless.common.dto.PaginatedResult; -import com.mzc.secondproject.serverless.common.dto.request.vocabulary.CreateWordRequest; -import com.mzc.secondproject.serverless.common.dto.request.vocabulary.CreateWordsBatchRequest; +import com.mzc.secondproject.serverless.domain.vocabulary.dto.request.CreateWordRequest; +import com.mzc.secondproject.serverless.domain.vocabulary.dto.request.CreateWordsBatchRequest; import com.mzc.secondproject.serverless.common.router.HandlerRouter; import com.mzc.secondproject.serverless.common.router.Route; import com.mzc.secondproject.serverless.common.util.ResponseUtil; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java index 57d8dab9..94741084 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java @@ -1,7 +1,7 @@ package com.mzc.secondproject.serverless.domain.vocabulary.service; import com.mzc.secondproject.serverless.common.dto.PaginatedResult; -import com.mzc.secondproject.serverless.common.dto.request.vocabulary.SubmitTestRequest; +import com.mzc.secondproject.serverless.domain.vocabulary.dto.request.SubmitTestRequest; import com.mzc.secondproject.serverless.common.util.AwsClients; import com.mzc.secondproject.serverless.common.util.ResponseUtil; import com.mzc.secondproject.serverless.domain.vocabulary.model.DailyStudy; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordCommandService.java index 7b5fa174..dbbcb11f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordCommandService.java @@ -1,6 +1,6 @@ package com.mzc.secondproject.serverless.domain.vocabulary.service; -import com.mzc.secondproject.serverless.common.dto.request.vocabulary.CreateWordRequest; +import com.mzc.secondproject.serverless.domain.vocabulary.dto.request.CreateWordRequest; import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; import com.mzc.secondproject.serverless.domain.vocabulary.repository.WordRepository; import org.slf4j.Logger; From 4b4ea2095f5bf509a76e47ba7a46f35d8c78a254 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 8 Jan 2026 14:07:57 +0900 Subject: [PATCH 070/528] refactor(common): move AwsClients to config package and update imports accordingly --- .../serverless/common/{util => config}/AwsClients.java | 2 +- .../serverless/common/service/PollyService.java | 2 +- .../domain/chatting/repository/ChatMessageRepository.java | 4 +--- .../domain/chatting/repository/ChatRoomRepository.java | 4 +--- .../serverless/domain/chatting/service/BedrockService.java | 2 +- .../domain/vocabulary/repository/DailyStudyRepository.java | 5 +---- .../domain/vocabulary/repository/TestResultRepository.java | 5 +---- .../domain/vocabulary/repository/UserWordRepository.java | 5 +---- .../domain/vocabulary/repository/WordRepository.java | 3 +-- .../domain/vocabulary/service/TestCommandService.java | 2 +- .../serverless/domain/vocabulary/service/TestService.java | 2 +- 11 files changed, 11 insertions(+), 25 deletions(-) rename ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/{util => config}/AwsClients.java (97%) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/AwsClients.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/AwsClients.java similarity index 97% rename from ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/AwsClients.java rename to ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/AwsClients.java index 851342ba..7505fbd6 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/AwsClients.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/AwsClients.java @@ -1,4 +1,4 @@ -package com.mzc.secondproject.serverless.common.util; +package com.mzc.secondproject.serverless.common.config; import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; import software.amazon.awssdk.services.dynamodb.DynamoDbClient; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/service/PollyService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/service/PollyService.java index c9678c98..beaa84c9 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/service/PollyService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/service/PollyService.java @@ -1,6 +1,6 @@ package com.mzc.secondproject.serverless.common.service; -import com.mzc.secondproject.serverless.common.util.AwsClients; +import com.mzc.secondproject.serverless.common.config.AwsClients; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import software.amazon.awssdk.core.sync.RequestBody; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatMessageRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatMessageRepository.java index 6c70ea8f..fc4eef68 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatMessageRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatMessageRepository.java @@ -3,18 +3,16 @@ import com.mzc.secondproject.serverless.domain.chatting.model.ChatMessage; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; import software.amazon.awssdk.enhanced.dynamodb.Key; import software.amazon.awssdk.enhanced.dynamodb.TableSchema; import software.amazon.awssdk.enhanced.dynamodb.model.Page; import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; -import software.amazon.awssdk.services.dynamodb.DynamoDbClient; import software.amazon.awssdk.services.dynamodb.model.AttributeValue; import com.mzc.secondproject.serverless.common.dto.PaginatedResult; -import com.mzc.secondproject.serverless.common.util.AwsClients; +import com.mzc.secondproject.serverless.common.config.AwsClients; import com.mzc.secondproject.serverless.common.util.CursorUtil; import java.util.List; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatRoomRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatRoomRepository.java index e51e2dc6..695e261b 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatRoomRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatRoomRepository.java @@ -3,7 +3,6 @@ import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; import software.amazon.awssdk.enhanced.dynamodb.DynamoDbIndex; import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; import software.amazon.awssdk.enhanced.dynamodb.Key; @@ -11,12 +10,11 @@ import software.amazon.awssdk.enhanced.dynamodb.model.Page; import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; -import software.amazon.awssdk.services.dynamodb.DynamoDbClient; import software.amazon.awssdk.services.dynamodb.model.AttributeValue; import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest; import com.mzc.secondproject.serverless.common.dto.PaginatedResult; -import com.mzc.secondproject.serverless.common.util.AwsClients; +import com.mzc.secondproject.serverless.common.config.AwsClients; import com.mzc.secondproject.serverless.common.util.CursorUtil; import java.util.HashMap; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/BedrockService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/BedrockService.java index b82ee520..2b92dd79 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/BedrockService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/BedrockService.java @@ -1,6 +1,6 @@ package com.mzc.secondproject.serverless.domain.chatting.service; -import com.mzc.secondproject.serverless.common.util.AwsClients; +import com.mzc.secondproject.serverless.common.config.AwsClients; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import software.amazon.awssdk.core.SdkBytes; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/DailyStudyRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/DailyStudyRepository.java index b2f768a6..e8faab86 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/DailyStudyRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/DailyStudyRepository.java @@ -3,23 +3,20 @@ import com.mzc.secondproject.serverless.domain.vocabulary.model.DailyStudy; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; import software.amazon.awssdk.enhanced.dynamodb.Key; import software.amazon.awssdk.enhanced.dynamodb.TableSchema; import software.amazon.awssdk.enhanced.dynamodb.model.Page; import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; -import software.amazon.awssdk.services.dynamodb.DynamoDbClient; import software.amazon.awssdk.services.dynamodb.model.AttributeValue; import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest; import com.mzc.secondproject.serverless.common.dto.PaginatedResult; -import com.mzc.secondproject.serverless.common.util.AwsClients; +import com.mzc.secondproject.serverless.common.config.AwsClients; import com.mzc.secondproject.serverless.common.util.CursorUtil; import java.util.HashMap; -import java.util.List; import java.util.Map; import java.util.Optional; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/TestResultRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/TestResultRepository.java index 42113caf..e932c260 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/TestResultRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/TestResultRepository.java @@ -3,21 +3,18 @@ import com.mzc.secondproject.serverless.domain.vocabulary.model.TestResult; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; import software.amazon.awssdk.enhanced.dynamodb.Key; import software.amazon.awssdk.enhanced.dynamodb.TableSchema; import software.amazon.awssdk.enhanced.dynamodb.model.Page; import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; -import software.amazon.awssdk.services.dynamodb.DynamoDbClient; import software.amazon.awssdk.services.dynamodb.model.AttributeValue; import com.mzc.secondproject.serverless.common.dto.PaginatedResult; -import com.mzc.secondproject.serverless.common.util.AwsClients; +import com.mzc.secondproject.serverless.common.config.AwsClients; import com.mzc.secondproject.serverless.common.util.CursorUtil; -import java.util.List; import java.util.Map; import java.util.Optional; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/UserWordRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/UserWordRepository.java index 9751266b..6addfd5b 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/UserWordRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/UserWordRepository.java @@ -3,7 +3,6 @@ import com.mzc.secondproject.serverless.domain.vocabulary.model.UserWord; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; import software.amazon.awssdk.enhanced.dynamodb.DynamoDbIndex; import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; import software.amazon.awssdk.enhanced.dynamodb.Key; @@ -11,15 +10,13 @@ import software.amazon.awssdk.enhanced.dynamodb.model.Page; import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; -import software.amazon.awssdk.services.dynamodb.DynamoDbClient; import software.amazon.awssdk.enhanced.dynamodb.Expression; import software.amazon.awssdk.services.dynamodb.model.AttributeValue; import com.mzc.secondproject.serverless.common.dto.PaginatedResult; -import com.mzc.secondproject.serverless.common.util.AwsClients; +import com.mzc.secondproject.serverless.common.config.AwsClients; import com.mzc.secondproject.serverless.common.util.CursorUtil; -import java.util.List; import java.util.Map; import java.util.Optional; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordRepository.java index 53388e79..53037970 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordRepository.java @@ -15,11 +15,10 @@ import software.amazon.awssdk.enhanced.dynamodb.model.ReadBatch; import software.amazon.awssdk.enhanced.dynamodb.model.ScanEnhancedRequest; import software.amazon.awssdk.enhanced.dynamodb.Expression; -import software.amazon.awssdk.services.dynamodb.DynamoDbClient; import software.amazon.awssdk.services.dynamodb.model.AttributeValue; import com.mzc.secondproject.serverless.common.dto.PaginatedResult; -import com.mzc.secondproject.serverless.common.util.AwsClients; +import com.mzc.secondproject.serverless.common.config.AwsClients; import com.mzc.secondproject.serverless.common.util.CursorUtil; import java.util.ArrayList; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java index 94741084..ad9d066c 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java @@ -2,7 +2,7 @@ import com.mzc.secondproject.serverless.common.dto.PaginatedResult; import com.mzc.secondproject.serverless.domain.vocabulary.dto.request.SubmitTestRequest; -import com.mzc.secondproject.serverless.common.util.AwsClients; +import com.mzc.secondproject.serverless.common.config.AwsClients; import com.mzc.secondproject.serverless.common.util.ResponseUtil; import com.mzc.secondproject.serverless.domain.vocabulary.model.DailyStudy; import com.mzc.secondproject.serverless.domain.vocabulary.model.TestResult; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestService.java index cc31edfb..5c820edb 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestService.java @@ -1,7 +1,7 @@ package com.mzc.secondproject.serverless.domain.vocabulary.service; import com.mzc.secondproject.serverless.common.dto.PaginatedResult; -import com.mzc.secondproject.serverless.common.util.AwsClients; +import com.mzc.secondproject.serverless.common.config.AwsClients; import com.mzc.secondproject.serverless.common.util.ResponseUtil; import com.mzc.secondproject.serverless.domain.vocabulary.model.DailyStudy; import com.mzc.secondproject.serverless.domain.vocabulary.model.TestResult; From 508e98a07b07f03463a88d08a9124cc1da233300 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 8 Jan 2026 14:30:57 +0900 Subject: [PATCH 071/528] refactor(vocabulary): update TestHandler routes to use singular "test" path --- .../serverless/domain/vocabulary/handler/TestHandler.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/TestHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/TestHandler.java index 7ea54baf..de25bba6 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/TestHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/TestHandler.java @@ -38,9 +38,9 @@ public TestHandler() { private HandlerRouter initRouter() { return new HandlerRouter().addRoutes( - Route.post("/tests/{userId}/start", this::startTest), - Route.post("/tests/{userId}/submit", this::submitAnswer), - Route.get("/tests/{userId}/results", this::getTestResults) + Route.post("/test/{userId}/start", this::startTest), + Route.post("/test/{userId}/submit", this::submitAnswer), + Route.get("/test/{userId}/results", this::getTestResults) ); } From 865fde388d610770aea11707249e01aa0957ff24 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 8 Jan 2026 14:58:26 +0900 Subject: [PATCH 072/528] =?UTF-8?q?feat(vocabulary):=20=ED=95=99=EC=8A=B5?= =?UTF-8?q?=20=EB=B3=B4=EC=A1=B0=20API=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 단어 배치 조회 API (POST /vocab/words/batch/get) closes #134 - 특정 날짜 학습 단어 조회 (GET /vocab/daily/{userId}?date=) closes #135 - 시험 결과 상세 조회 (GET /vocab/test/{userId}/results/{testId}) closes #136 refs #40 --- .../dto/request/BatchGetWordsRequest.java | 15 +++++++++ .../vocabulary/handler/DailyStudyHandler.java | 28 ++++++++++++++++ .../vocabulary/handler/TestHandler.java | 32 +++++++++++++++++++ .../vocabulary/handler/WordHandler.java | 24 ++++++++++++++ .../repository/TestResultRepository.java | 30 ++++++++++++++++- .../vocabulary/service/TestQueryService.java | 26 +++++++++++++++ .../vocabulary/service/WordQueryService.java | 5 +++ ServerlessFunction/template.yaml | 14 +++++++- 8 files changed, 172 insertions(+), 2 deletions(-) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/BatchGetWordsRequest.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/BatchGetWordsRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/BatchGetWordsRequest.java new file mode 100644 index 00000000..b24f5bd0 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/BatchGetWordsRequest.java @@ -0,0 +1,15 @@ +package com.mzc.secondproject.serverless.domain.vocabulary.dto.request; + +import java.util.List; + +public class BatchGetWordsRequest { + private List wordIds; + + public List getWordIds() { + return wordIds; + } + + public void setWordIds(List wordIds) { + this.wordIds = wordIds; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/DailyStudyHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/DailyStudyHandler.java index 9b54c756..09581fe5 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/DailyStudyHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/DailyStudyHandler.java @@ -52,8 +52,15 @@ private APIGatewayProxyResponseEvent getDailyWords(APIGatewayProxyRequestEvent r return createResponse(400, ApiResponse.error("userId is required")); } + String date = queryParams != null ? queryParams.get("date") : null; String level = queryParams != null ? queryParams.get("level") : null; + // 특정 날짜 조회 (읽기 전용) + if (date != null && !date.isEmpty()) { + return getDailyStudyByDate(userId, date); + } + + // 오늘 날짜 (없으면 생성) DailyStudyCommandService.DailyStudyResult result = commandService.getDailyWords(userId, level); Map response = new HashMap<>(); @@ -65,6 +72,27 @@ private APIGatewayProxyResponseEvent getDailyWords(APIGatewayProxyRequestEvent r return createResponse(200, ApiResponse.success("Daily words retrieved", response)); } + private APIGatewayProxyResponseEvent getDailyStudyByDate(String userId, String date) { + var optDailyStudy = queryService.getDailyStudy(userId, date); + + if (optDailyStudy.isEmpty()) { + return createResponse(404, ApiResponse.error("No daily study found for date: " + date)); + } + + var dailyStudy = optDailyStudy.get(); + var newWords = queryService.getWordDetails(dailyStudy.getNewWordIds()); + var reviewWords = queryService.getWordDetails(dailyStudy.getReviewWordIds()); + var progress = queryService.calculateProgress(dailyStudy); + + Map response = new HashMap<>(); + response.put("dailyStudy", dailyStudy); + response.put("newWords", newWords); + response.put("reviewWords", reviewWords); + response.put("progress", progress); + + return createResponse(200, ApiResponse.success("Daily study retrieved for " + date, response)); + } + private APIGatewayProxyResponseEvent markWordLearned(APIGatewayProxyRequestEvent request) { Map pathParams = request.getPathParameters(); String userId = pathParams != null ? pathParams.get("userId") : null; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/TestHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/TestHandler.java index de25bba6..dae249a3 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/TestHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/TestHandler.java @@ -40,6 +40,7 @@ private HandlerRouter initRouter() { return new HandlerRouter().addRoutes( Route.post("/test/{userId}/start", this::startTest), Route.post("/test/{userId}/submit", this::submitAnswer), + Route.get("/test/{userId}/results/{testId}", this::getTestResultDetail), Route.get("/test/{userId}/results", this::getTestResults) ); } @@ -130,4 +131,35 @@ private APIGatewayProxyResponseEvent getTestResults(APIGatewayProxyRequestEvent return createResponse(200, ApiResponse.success("Test results retrieved", result)); } + + private APIGatewayProxyResponseEvent getTestResultDetail(APIGatewayProxyRequestEvent request) { + Map pathParams = request.getPathParameters(); + + String userId = pathParams != null ? pathParams.get("userId") : null; + String testId = pathParams != null ? pathParams.get("testId") : null; + + if (userId == null || testId == null) { + return createResponse(400, ApiResponse.error("userId and testId are required")); + } + + var optDetail = queryService.getTestResultDetail(userId, testId); + if (optDetail.isEmpty()) { + return createResponse(404, ApiResponse.error("Test result not found")); + } + + var detail = optDetail.get(); + + Map result = new HashMap<>(); + result.put("testId", detail.testResult().getTestId()); + result.put("testType", detail.testResult().getTestType()); + result.put("totalQuestions", detail.testResult().getTotalQuestions()); + result.put("correctAnswers", detail.testResult().getCorrectAnswers()); + result.put("incorrectAnswers", detail.testResult().getIncorrectAnswers()); + result.put("successRate", detail.testResult().getSuccessRate()); + result.put("incorrectWords", detail.incorrectWords()); + result.put("startedAt", detail.testResult().getStartedAt()); + result.put("completedAt", detail.testResult().getCompletedAt()); + + return createResponse(200, ApiResponse.success("Test result detail retrieved", result)); + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/WordHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/WordHandler.java index 7b07a587..1c0f2e82 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/WordHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/WordHandler.java @@ -6,6 +6,7 @@ import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; import com.mzc.secondproject.serverless.common.dto.ApiResponse; import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.domain.vocabulary.dto.request.BatchGetWordsRequest; import com.mzc.secondproject.serverless.domain.vocabulary.dto.request.CreateWordRequest; import com.mzc.secondproject.serverless.domain.vocabulary.dto.request.CreateWordsBatchRequest; import com.mzc.secondproject.serverless.common.router.HandlerRouter; @@ -39,6 +40,7 @@ public WordHandler() { private HandlerRouter initRouter() { return new HandlerRouter().addRoutes( + Route.post("/words/batch/get", this::getWordsBatch), Route.post("/words/batch", this::createWordsBatch), Route.get("/words/search", this::searchWords), Route.post("/words", this::createWord), @@ -181,4 +183,26 @@ private APIGatewayProxyResponseEvent searchWords(APIGatewayProxyRequestEvent req return createResponse(200, ApiResponse.success("Search completed", result)); } + + private APIGatewayProxyResponseEvent getWordsBatch(APIGatewayProxyRequestEvent request) { + String body = request.getBody(); + BatchGetWordsRequest req = ResponseUtil.gson().fromJson(body, BatchGetWordsRequest.class); + + if (req.getWordIds() == null || req.getWordIds().isEmpty()) { + return createResponse(400, ApiResponse.error("wordIds array is required")); + } + + if (req.getWordIds().size() > 100) { + return createResponse(400, ApiResponse.error("Maximum 100 wordIds allowed per request")); + } + + List words = queryService.getWordsByIds(req.getWordIds()); + + Map result = new HashMap<>(); + result.put("words", words); + result.put("requestedCount", req.getWordIds().size()); + result.put("retrievedCount", words.size()); + + return createResponse(200, ApiResponse.success("Words retrieved", result)); + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/TestResultRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/TestResultRepository.java index e932c260..e7cd6fd0 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/TestResultRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/TestResultRepository.java @@ -6,6 +6,7 @@ import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; import software.amazon.awssdk.enhanced.dynamodb.Key; import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.Expression; import software.amazon.awssdk.enhanced.dynamodb.model.Page; import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; @@ -35,7 +36,7 @@ public TestResult save(TestResult testResult) { return testResult; } - public Optional findByUserIdAndTestId(String userId, String timestamp) { + public Optional findByUserIdAndTimestamp(String userId, String timestamp) { Key key = Key.builder() .partitionValue("TEST#" + userId) .sortValue("RESULT#" + timestamp) @@ -45,6 +46,33 @@ public Optional findByUserIdAndTestId(String userId, String timestam return Optional.ofNullable(testResult); } + public Optional findByUserIdAndTestId(String userId, String testId) { + QueryConditional queryConditional = QueryConditional + .sortBeginsWith(Key.builder() + .partitionValue("TEST#" + userId) + .sortValue("RESULT#") + .build()); + + Expression filterExpression = Expression.builder() + .expression("testId = :testId") + .putExpressionValue(":testId", AttributeValue.builder().s(testId).build()) + .build(); + + QueryEnhancedRequest request = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .filterExpression(filterExpression) + .limit(1) + .build(); + + for (Page page : table.query(request)) { + if (!page.items().isEmpty()) { + return Optional.of(page.items().get(0)); + } + } + + return Optional.empty(); + } + /** * 사용자의 시험 결과 조회 - 최신순, 페이지네이션 */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestQueryService.java index 3fc815cc..eb980723 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestQueryService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestQueryService.java @@ -2,10 +2,15 @@ import com.mzc.secondproject.serverless.common.dto.PaginatedResult; import com.mzc.secondproject.serverless.domain.vocabulary.model.TestResult; +import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; import com.mzc.secondproject.serverless.domain.vocabulary.repository.TestResultRepository; +import com.mzc.secondproject.serverless.domain.vocabulary.repository.WordRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.util.List; +import java.util.Optional; + /** * Test 조회 전용 서비스 (CQRS Query) */ @@ -14,12 +19,33 @@ public class TestQueryService { private static final Logger logger = LoggerFactory.getLogger(TestQueryService.class); private final TestResultRepository testResultRepository; + private final WordRepository wordRepository; public TestQueryService() { this.testResultRepository = new TestResultRepository(); + this.wordRepository = new WordRepository(); } public PaginatedResult getTestResults(String userId, int limit, String cursor) { return testResultRepository.findByUserIdWithPagination(userId, limit, cursor); } + + public Optional getTestResultDetail(String userId, String testId) { + Optional optResult = testResultRepository.findByUserIdAndTestId(userId, testId); + + if (optResult.isEmpty()) { + return Optional.empty(); + } + + TestResult testResult = optResult.get(); + List incorrectWords = List.of(); + + if (testResult.getIncorrectWordIds() != null && !testResult.getIncorrectWordIds().isEmpty()) { + incorrectWords = wordRepository.findByIds(testResult.getIncorrectWordIds()); + } + + return Optional.of(new TestResultDetail(testResult, incorrectWords)); + } + + public record TestResultDetail(TestResult testResult, List incorrectWords) {} } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordQueryService.java index 97f3257c..c0f0fb4c 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordQueryService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordQueryService.java @@ -6,6 +6,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.util.List; import java.util.Optional; /** @@ -37,4 +38,8 @@ public PaginatedResult getWords(String level, String category, int limit, public PaginatedResult searchWords(String query, int limit, String cursor) { return wordRepository.searchByKeyword(query, limit, cursor); } + + public List getWordsByIds(List wordIds) { + return wordRepository.findByIds(wordIds); + } } diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index f419c402..79bb97f0 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -217,6 +217,12 @@ Resources: RestApiId: !Ref MainApi Path: /vocab/words/batch Method: POST + BatchGetWords: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/words/batch/get + Method: POST SearchWords: Type: Api Properties: @@ -342,12 +348,18 @@ Resources: RestApiId: !Ref MainApi Path: /vocab/test/{userId}/submit Method: POST - GetTestResult: + GetTestResults: Type: Api Properties: RestApiId: !Ref MainApi Path: /vocab/test/{userId}/results Method: GET + GetTestResultDetail: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/test/{userId}/results/{testId} + Method: GET StatsFunction: Type: AWS::Serverless::Function From 5da95faccdade5cbb1488388132a5cf5bf93bd87 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 8 Jan 2026 15:08:52 +0900 Subject: [PATCH 073/528] =?UTF-8?q?feat(vocabulary):=20=EC=98=A4=EB=8B=B5?= =?UTF-8?q?=20=EB=85=B8=ED=8A=B8=20API=20=EA=B5=AC=ED=98=84=20closes=20#13?= =?UTF-8?q?8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /vocab/users/{userId}/wrong-answers 엔드포인트 추가 - minCount 파라미터로 최소 오답 횟수 필터링 - 오답 횟수 기준 내림차순 정렬 - RequestValidator 활용한 입력 검증 --- .../vocabulary/handler/UserWordHandler.java | 43 +++++++++++++++++++ .../repository/UserWordRepository.java | 13 ++++-- .../service/UserWordQueryService.java | 21 +++++++++ ServerlessFunction/template.yaml | 6 +++ 4 files changed, 80 insertions(+), 3 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java index 7d198b3c..54d2a8b5 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java @@ -5,6 +5,8 @@ import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; import com.mzc.secondproject.serverless.common.dto.ApiResponse; +import com.mzc.secondproject.serverless.common.validation.RequestValidator; +import com.mzc.secondproject.serverless.common.validation.ValidationResult; import com.mzc.secondproject.serverless.domain.vocabulary.dto.request.UpdateUserWordRequest; import com.mzc.secondproject.serverless.domain.vocabulary.dto.request.UpdateUserWordTagRequest; import com.mzc.secondproject.serverless.common.router.HandlerRouter; @@ -37,6 +39,7 @@ public UserWordHandler() { private HandlerRouter initRouter() { return new HandlerRouter().addRoutes( + Route.get("/users/{userId}/wrong-answers", this::getWrongAnswers), Route.get("/users/{userId}/words", this::getUserWords), Route.get("/users/{userId}/words/{wordId}", this::getUserWord), Route.put("/users/{userId}/words/{wordId}/tag", this::updateUserWordTag), @@ -131,4 +134,44 @@ private APIGatewayProxyResponseEvent updateUserWordTag(APIGatewayProxyRequestEve UserWord userWord = commandService.updateUserWordTag(userId, wordId, req.getBookmarked(), req.getFavorite(), req.getDifficulty()); return createResponse(200, ApiResponse.success("Tag updated", userWord)); } + + private APIGatewayProxyResponseEvent getWrongAnswers(APIGatewayProxyRequestEvent request) { + Map pathParams = request.getPathParameters(); + Map queryParams = request.getQueryStringParameters(); + + String userId = pathParams != null ? pathParams.get("userId") : null; + String cursor = queryParams != null ? queryParams.get("cursor") : null; + + ValidationResult validation = RequestValidator.create() + .requireNotEmpty(userId, "userId") + .build(); + + if (validation.isInvalid()) { + return createResponse(400, ApiResponse.error(validation.getErrorMessage().orElse("Validation failed"))); + } + + int limit = parseIntParam(queryParams, "limit", 20, 1, 50); + int minCount = parseIntParam(queryParams, "minCount", 1, 1, 100); + + UserWordQueryService.UserWordsResult result = queryService.getWrongAnswers(userId, minCount, limit, cursor); + + Map response = new HashMap<>(); + response.put("wrongAnswers", result.userWords()); + response.put("nextCursor", result.nextCursor()); + response.put("hasMore", result.hasMore()); + + return createResponse(200, ApiResponse.success("Wrong answers retrieved", response)); + } + + private int parseIntParam(Map params, String key, int defaultValue, int min, int max) { + if (params == null || params.get(key) == null) { + return defaultValue; + } + try { + int value = Integer.parseInt(params.get(key)); + return Math.max(min, Math.min(value, max)); + } catch (NumberFormatException e) { + return defaultValue; + } + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/UserWordRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/UserWordRepository.java index 6addfd5b..cef54919 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/UserWordRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/UserWordRepository.java @@ -139,6 +139,13 @@ public PaginatedResult findBookmarkedWords(String userId, int limit, S * 틀린 적 있는 단어만 조회 - FilterExpression 사용 (GSI 추가 없이 비용 최적화) */ public PaginatedResult findIncorrectWords(String userId, int limit, String cursor) { + return findIncorrectWords(userId, 1, limit, cursor); + } + + /** + * 최소 N회 이상 틀린 단어 조회 + */ + public PaginatedResult findIncorrectWords(String userId, int minCount, int limit, String cursor) { QueryConditional queryConditional = QueryConditional .sortBeginsWith(Key.builder() .partitionValue("USER#" + userId) @@ -146,14 +153,14 @@ public PaginatedResult findIncorrectWords(String userId, int limit, St .build()); Expression filterExpression = Expression.builder() - .expression("incorrectCount > :zero") - .putExpressionValue(":zero", AttributeValue.builder().n("0").build()) + .expression("incorrectCount >= :minCount") + .putExpressionValue(":minCount", AttributeValue.builder().n(String.valueOf(minCount)).build()) .build(); QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() .queryConditional(queryConditional) .filterExpression(filterExpression) - .limit(limit); + .limit(limit * 3); // FilterExpression 적용되므로 넉넉히 if (cursor != null && !cursor.isEmpty()) { Map exclusiveStartKey = CursorUtil.decode(cursor); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordQueryService.java index d57e8470..b2fc31fa 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordQueryService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordQueryService.java @@ -49,6 +49,27 @@ public UserWordsResult getUserWords(String userId, String status, String bookmar return new UserWordsResult(enrichedUserWords, userWordPage.getNextCursor(), userWordPage.hasMore()); } + /** + * 오답 노트 조회 - 오답 횟수 기준 내림차순 정렬 + */ + public UserWordsResult getWrongAnswers(String userId, int minCount, int limit, String cursor) { + PaginatedResult userWordPage = userWordRepository.findIncorrectWords(userId, minCount, limit * 2, cursor); + + // 오답 횟수 기준 내림차순 정렬 + List sorted = userWordPage.getItems().stream() + .sorted((a, b) -> { + int countA = a.getIncorrectCount() != null ? a.getIncorrectCount() : 0; + int countB = b.getIncorrectCount() != null ? b.getIncorrectCount() : 0; + return Integer.compare(countB, countA); + }) + .limit(limit) + .collect(Collectors.toList()); + + List> enrichedUserWords = enrichWithWordInfo(sorted); + + return new UserWordsResult(enrichedUserWords, userWordPage.getNextCursor(), userWordPage.hasMore()); + } + public Optional getUserWord(String userId, String wordId) { return userWordRepository.findByUserIdAndWordId(userId, wordId); } diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 79bb97f0..6f7efc1b 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -267,6 +267,12 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref VocabTable Events: + GetWrongAnswers: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/users/{userId}/wrong-answers + Method: GET GetUserWords: Type: Api Properties: From 4fdecf6b5fc93600244f1785e7291e91f08f3c01 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 8 Jan 2026 15:12:53 +0900 Subject: [PATCH 074/528] =?UTF-8?q?feat(vocabulary):=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=20=EC=BB=A4=EC=8A=A4=ED=85=80=20=EB=8B=A8=EC=96=B4?= =?UTF-8?q?=EC=9E=A5=20=EA=B7=B8=EB=A3=B9=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20closes=20#139?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WordGroup 모델 및 Repository 생성 - WordGroupCommandService/QueryService (CQRS 패턴) - WordGroupHandler - 그룹 CRUD + 단어 추가/제거 API - RequestValidator 활용한 입력 검증 - Single Table Design 유지 (VOCAB_TABLE 재사용) --- .../dto/request/CreateWordGroupRequest.java | 22 ++ .../vocabulary/handler/WordGroupHandler.java | 246 ++++++++++++++++++ .../domain/vocabulary/model/WordGroup.java | 50 ++++ .../repository/WordGroupRepository.java | 80 ++++++ .../service/WordGroupCommandService.java | 123 +++++++++ .../service/WordGroupQueryService.java | 54 ++++ ServerlessFunction/template.yaml | 56 ++++ 7 files changed, 631 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/CreateWordGroupRequest.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/WordGroupHandler.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/model/WordGroup.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordGroupRepository.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordGroupCommandService.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordGroupQueryService.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/CreateWordGroupRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/CreateWordGroupRequest.java new file mode 100644 index 00000000..141d057a --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/CreateWordGroupRequest.java @@ -0,0 +1,22 @@ +package com.mzc.secondproject.serverless.domain.vocabulary.dto.request; + +public class CreateWordGroupRequest { + private String groupName; + private String description; + + public String getGroupName() { + return groupName; + } + + public void setGroupName(String groupName) { + this.groupName = groupName; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/WordGroupHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/WordGroupHandler.java new file mode 100644 index 00000000..8e3b0d1d --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/WordGroupHandler.java @@ -0,0 +1,246 @@ +package com.mzc.secondproject.serverless.domain.vocabulary.handler; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import com.mzc.secondproject.serverless.common.dto.ApiResponse; +import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.common.router.HandlerRouter; +import com.mzc.secondproject.serverless.common.router.Route; +import com.mzc.secondproject.serverless.common.util.ResponseUtil; +import com.mzc.secondproject.serverless.common.validation.RequestValidator; +import com.mzc.secondproject.serverless.common.validation.ValidationResult; +import com.mzc.secondproject.serverless.domain.vocabulary.dto.request.CreateWordGroupRequest; +import com.mzc.secondproject.serverless.domain.vocabulary.model.WordGroup; +import com.mzc.secondproject.serverless.domain.vocabulary.service.WordGroupCommandService; +import com.mzc.secondproject.serverless.domain.vocabulary.service.WordGroupQueryService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.HashMap; +import java.util.Map; + +import static com.mzc.secondproject.serverless.common.util.ResponseUtil.createResponse; + +public class WordGroupHandler implements RequestHandler { + + private static final Logger logger = LoggerFactory.getLogger(WordGroupHandler.class); + + private final WordGroupCommandService commandService; + private final WordGroupQueryService queryService; + private final HandlerRouter router; + + public WordGroupHandler() { + this.commandService = new WordGroupCommandService(); + this.queryService = new WordGroupQueryService(); + this.router = initRouter(); + } + + private HandlerRouter initRouter() { + return new HandlerRouter().addRoutes( + Route.post("/users/{userId}/groups", this::createGroup), + Route.get("/users/{userId}/groups", this::getGroups), + Route.get("/users/{userId}/groups/{groupId}", this::getGroupDetail), + Route.put("/users/{userId}/groups/{groupId}", this::updateGroup), + Route.delete("/users/{userId}/groups/{groupId}", this::deleteGroup), + Route.post("/users/{userId}/groups/{groupId}/words/{wordId}", this::addWordToGroup), + Route.delete("/users/{userId}/groups/{groupId}/words/{wordId}", this::removeWordFromGroup) + ); + } + + @Override + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { + logger.info("Received request: {} {}", request.getHttpMethod(), request.getPath()); + return router.route(request); + } + + private APIGatewayProxyResponseEvent createGroup(APIGatewayProxyRequestEvent request) { + Map pathParams = request.getPathParameters(); + String userId = pathParams != null ? pathParams.get("userId") : null; + + String body = request.getBody(); + CreateWordGroupRequest req = ResponseUtil.gson().fromJson(body, CreateWordGroupRequest.class); + + ValidationResult validation = RequestValidator.create() + .requireNotEmpty(userId, "userId") + .requireNotEmpty(req != null ? req.getGroupName() : null, "groupName") + .build(); + + if (validation.isInvalid()) { + return createResponse(400, ApiResponse.error(validation.getErrorMessage().orElse("Validation failed"))); + } + + WordGroup group = commandService.createGroup(userId, req.getGroupName(), req.getDescription()); + return createResponse(201, ApiResponse.success("Group created", group)); + } + + private APIGatewayProxyResponseEvent getGroups(APIGatewayProxyRequestEvent request) { + Map pathParams = request.getPathParameters(); + Map queryParams = request.getQueryStringParameters(); + + String userId = pathParams != null ? pathParams.get("userId") : null; + String cursor = queryParams != null ? queryParams.get("cursor") : null; + + ValidationResult validation = RequestValidator.create() + .requireNotEmpty(userId, "userId") + .build(); + + if (validation.isInvalid()) { + return createResponse(400, ApiResponse.error(validation.getErrorMessage().orElse("Validation failed"))); + } + + int limit = parseIntParam(queryParams, "limit", 20, 1, 50); + + PaginatedResult result = queryService.getGroups(userId, limit, cursor); + + Map response = new HashMap<>(); + response.put("groups", result.getItems()); + response.put("nextCursor", result.getNextCursor()); + response.put("hasMore", result.hasMore()); + + return createResponse(200, ApiResponse.success("Groups retrieved", response)); + } + + private APIGatewayProxyResponseEvent getGroupDetail(APIGatewayProxyRequestEvent request) { + Map pathParams = request.getPathParameters(); + String userId = pathParams != null ? pathParams.get("userId") : null; + String groupId = pathParams != null ? pathParams.get("groupId") : null; + + ValidationResult validation = RequestValidator.create() + .requireNotEmpty(userId, "userId") + .requireNotEmpty(groupId, "groupId") + .build(); + + if (validation.isInvalid()) { + return createResponse(400, ApiResponse.error(validation.getErrorMessage().orElse("Validation failed"))); + } + + var optDetail = queryService.getGroupDetail(userId, groupId); + if (optDetail.isEmpty()) { + return createResponse(404, ApiResponse.error("Group not found")); + } + + var detail = optDetail.get(); + + Map response = new HashMap<>(); + response.put("groupId", detail.group().getGroupId()); + response.put("groupName", detail.group().getGroupName()); + response.put("description", detail.group().getDescription()); + response.put("wordCount", detail.group().getWordCount()); + response.put("words", detail.words()); + response.put("createdAt", detail.group().getCreatedAt()); + response.put("updatedAt", detail.group().getUpdatedAt()); + + return createResponse(200, ApiResponse.success("Group detail retrieved", response)); + } + + private APIGatewayProxyResponseEvent updateGroup(APIGatewayProxyRequestEvent request) { + Map pathParams = request.getPathParameters(); + String userId = pathParams != null ? pathParams.get("userId") : null; + String groupId = pathParams != null ? pathParams.get("groupId") : null; + + ValidationResult validation = RequestValidator.create() + .requireNotEmpty(userId, "userId") + .requireNotEmpty(groupId, "groupId") + .build(); + + if (validation.isInvalid()) { + return createResponse(400, ApiResponse.error(validation.getErrorMessage().orElse("Validation failed"))); + } + + String body = request.getBody(); + CreateWordGroupRequest req = ResponseUtil.gson().fromJson(body, CreateWordGroupRequest.class); + + try { + WordGroup group = commandService.updateGroup(userId, groupId, + req != null ? req.getGroupName() : null, + req != null ? req.getDescription() : null); + return createResponse(200, ApiResponse.success("Group updated", group)); + } catch (IllegalArgumentException e) { + return createResponse(404, ApiResponse.error(e.getMessage())); + } + } + + private APIGatewayProxyResponseEvent deleteGroup(APIGatewayProxyRequestEvent request) { + Map pathParams = request.getPathParameters(); + String userId = pathParams != null ? pathParams.get("userId") : null; + String groupId = pathParams != null ? pathParams.get("groupId") : null; + + ValidationResult validation = RequestValidator.create() + .requireNotEmpty(userId, "userId") + .requireNotEmpty(groupId, "groupId") + .build(); + + if (validation.isInvalid()) { + return createResponse(400, ApiResponse.error(validation.getErrorMessage().orElse("Validation failed"))); + } + + try { + commandService.deleteGroup(userId, groupId); + return createResponse(200, ApiResponse.success("Group deleted", null)); + } catch (IllegalArgumentException e) { + return createResponse(404, ApiResponse.error(e.getMessage())); + } + } + + private APIGatewayProxyResponseEvent addWordToGroup(APIGatewayProxyRequestEvent request) { + Map pathParams = request.getPathParameters(); + String userId = pathParams != null ? pathParams.get("userId") : null; + String groupId = pathParams != null ? pathParams.get("groupId") : null; + String wordId = pathParams != null ? pathParams.get("wordId") : null; + + ValidationResult validation = RequestValidator.create() + .requireNotEmpty(userId, "userId") + .requireNotEmpty(groupId, "groupId") + .requireNotEmpty(wordId, "wordId") + .build(); + + if (validation.isInvalid()) { + return createResponse(400, ApiResponse.error(validation.getErrorMessage().orElse("Validation failed"))); + } + + try { + WordGroup group = commandService.addWordToGroup(userId, groupId, wordId); + return createResponse(200, ApiResponse.success("Word added to group", group)); + } catch (IllegalArgumentException e) { + return createResponse(404, ApiResponse.error(e.getMessage())); + } + } + + private APIGatewayProxyResponseEvent removeWordFromGroup(APIGatewayProxyRequestEvent request) { + Map pathParams = request.getPathParameters(); + String userId = pathParams != null ? pathParams.get("userId") : null; + String groupId = pathParams != null ? pathParams.get("groupId") : null; + String wordId = pathParams != null ? pathParams.get("wordId") : null; + + ValidationResult validation = RequestValidator.create() + .requireNotEmpty(userId, "userId") + .requireNotEmpty(groupId, "groupId") + .requireNotEmpty(wordId, "wordId") + .build(); + + if (validation.isInvalid()) { + return createResponse(400, ApiResponse.error(validation.getErrorMessage().orElse("Validation failed"))); + } + + try { + WordGroup group = commandService.removeWordFromGroup(userId, groupId, wordId); + return createResponse(200, ApiResponse.success("Word removed from group", group)); + } catch (IllegalArgumentException e) { + return createResponse(404, ApiResponse.error(e.getMessage())); + } + } + + private int parseIntParam(Map params, String key, int defaultValue, int min, int max) { + if (params == null || params.get(key) == null) { + return defaultValue; + } + try { + int value = Integer.parseInt(params.get(key)); + return Math.max(min, Math.min(value, max)); + } catch (NumberFormatException e) { + return defaultValue; + } + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/model/WordGroup.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/model/WordGroup.java new file mode 100644 index 00000000..c65f9091 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/model/WordGroup.java @@ -0,0 +1,50 @@ +package com.mzc.secondproject.serverless.domain.vocabulary.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbAttribute; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSortKey; + +import java.util.List; + +/** + * 사용자 커스텀 단어장 그룹 + * PK: USER#{userId}#GROUP + * SK: GROUP#{groupId} + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DynamoDbBean +public class WordGroup { + + private String pk; // USER#{userId}#GROUP + private String sk; // GROUP#{groupId} + + private String groupId; + private String userId; + private String groupName; // TOEIC, TOEFL, 내 단어장 등 + private String description; + private List wordIds; + private Integer wordCount; + + private String createdAt; + private String updatedAt; + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { + return pk; + } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { + return sk; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordGroupRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordGroupRepository.java new file mode 100644 index 00000000..4c277647 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordGroupRepository.java @@ -0,0 +1,80 @@ +package com.mzc.secondproject.serverless.domain.vocabulary.repository; + +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.common.util.CursorUtil; +import com.mzc.secondproject.serverless.domain.vocabulary.model.WordGroup; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; +import software.amazon.awssdk.enhanced.dynamodb.Key; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.model.Page; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; + +import java.util.Map; +import java.util.Optional; + +public class WordGroupRepository { + + private static final Logger logger = LoggerFactory.getLogger(WordGroupRepository.class); + private static final String TABLE_NAME = System.getenv("VOCAB_TABLE_NAME"); + + private final DynamoDbTable table; + + public WordGroupRepository() { + this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(WordGroup.class)); + } + + public WordGroup save(WordGroup wordGroup) { + logger.info("Saving word group: userId={}, groupId={}", wordGroup.getUserId(), wordGroup.getGroupId()); + table.putItem(wordGroup); + return wordGroup; + } + + public Optional findByUserIdAndGroupId(String userId, String groupId) { + Key key = Key.builder() + .partitionValue("USER#" + userId + "#GROUP") + .sortValue("GROUP#" + groupId) + .build(); + + WordGroup wordGroup = table.getItem(key); + return Optional.ofNullable(wordGroup); + } + + public void delete(String userId, String groupId) { + Key key = Key.builder() + .partitionValue("USER#" + userId + "#GROUP") + .sortValue("GROUP#" + groupId) + .build(); + + table.deleteItem(key); + logger.info("Deleted word group: userId={}, groupId={}", userId, groupId); + } + + public PaginatedResult findByUserId(String userId, int limit, String cursor) { + QueryConditional queryConditional = QueryConditional + .sortBeginsWith(Key.builder() + .partitionValue("USER#" + userId + "#GROUP") + .sortValue("GROUP#") + .build()); + + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .limit(limit); + + if (cursor != null && !cursor.isEmpty()) { + Map exclusiveStartKey = CursorUtil.decode(cursor); + if (exclusiveStartKey != null) { + requestBuilder.exclusiveStartKey(exclusiveStartKey); + } + } + + Page page = table.query(requestBuilder.build()).iterator().next(); + String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); + + return new PaginatedResult<>(page.items(), nextCursor); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordGroupCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordGroupCommandService.java new file mode 100644 index 00000000..ecf805fc --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordGroupCommandService.java @@ -0,0 +1,123 @@ +package com.mzc.secondproject.serverless.domain.vocabulary.service; + +import com.mzc.secondproject.serverless.domain.vocabulary.model.WordGroup; +import com.mzc.secondproject.serverless.domain.vocabulary.repository.WordGroupRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +/** + * WordGroup 변경 전용 서비스 (CQRS Command) + */ +public class WordGroupCommandService { + + private static final Logger logger = LoggerFactory.getLogger(WordGroupCommandService.class); + + private final WordGroupRepository wordGroupRepository; + + public WordGroupCommandService() { + this.wordGroupRepository = new WordGroupRepository(); + } + + public WordGroup createGroup(String userId, String groupName, String description) { + String groupId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + + WordGroup wordGroup = WordGroup.builder() + .pk("USER#" + userId + "#GROUP") + .sk("GROUP#" + groupId) + .groupId(groupId) + .userId(userId) + .groupName(groupName) + .description(description) + .wordIds(new ArrayList<>()) + .wordCount(0) + .createdAt(now) + .updatedAt(now) + .build(); + + wordGroupRepository.save(wordGroup); + logger.info("Created word group: userId={}, groupId={}, name={}", userId, groupId, groupName); + + return wordGroup; + } + + public WordGroup updateGroup(String userId, String groupId, String groupName, String description) { + Optional optGroup = wordGroupRepository.findByUserIdAndGroupId(userId, groupId); + if (optGroup.isEmpty()) { + throw new IllegalArgumentException("Word group not found"); + } + + WordGroup group = optGroup.get(); + if (groupName != null) { + group.setGroupName(groupName); + } + if (description != null) { + group.setDescription(description); + } + group.setUpdatedAt(Instant.now().toString()); + + wordGroupRepository.save(group); + logger.info("Updated word group: userId={}, groupId={}", userId, groupId); + + return group; + } + + public void deleteGroup(String userId, String groupId) { + Optional optGroup = wordGroupRepository.findByUserIdAndGroupId(userId, groupId); + if (optGroup.isEmpty()) { + throw new IllegalArgumentException("Word group not found"); + } + + wordGroupRepository.delete(userId, groupId); + logger.info("Deleted word group: userId={}, groupId={}", userId, groupId); + } + + public WordGroup addWordToGroup(String userId, String groupId, String wordId) { + Optional optGroup = wordGroupRepository.findByUserIdAndGroupId(userId, groupId); + if (optGroup.isEmpty()) { + throw new IllegalArgumentException("Word group not found"); + } + + WordGroup group = optGroup.get(); + List wordIds = group.getWordIds(); + if (wordIds == null) { + wordIds = new ArrayList<>(); + } + + if (!wordIds.contains(wordId)) { + wordIds.add(wordId); + group.setWordIds(wordIds); + group.setWordCount(wordIds.size()); + group.setUpdatedAt(Instant.now().toString()); + wordGroupRepository.save(group); + logger.info("Added word to group: userId={}, groupId={}, wordId={}", userId, groupId, wordId); + } + + return group; + } + + public WordGroup removeWordFromGroup(String userId, String groupId, String wordId) { + Optional optGroup = wordGroupRepository.findByUserIdAndGroupId(userId, groupId); + if (optGroup.isEmpty()) { + throw new IllegalArgumentException("Word group not found"); + } + + WordGroup group = optGroup.get(); + List wordIds = group.getWordIds(); + if (wordIds != null && wordIds.remove(wordId)) { + group.setWordIds(wordIds); + group.setWordCount(wordIds.size()); + group.setUpdatedAt(Instant.now().toString()); + wordGroupRepository.save(group); + logger.info("Removed word from group: userId={}, groupId={}, wordId={}", userId, groupId, wordId); + } + + return group; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordGroupQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordGroupQueryService.java new file mode 100644 index 00000000..9cbf6080 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordGroupQueryService.java @@ -0,0 +1,54 @@ +package com.mzc.secondproject.serverless.domain.vocabulary.service; + +import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; +import com.mzc.secondproject.serverless.domain.vocabulary.model.WordGroup; +import com.mzc.secondproject.serverless.domain.vocabulary.repository.WordGroupRepository; +import com.mzc.secondproject.serverless.domain.vocabulary.repository.WordRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; +import java.util.Optional; + +/** + * WordGroup 조회 전용 서비스 (CQRS Query) + */ +public class WordGroupQueryService { + + private static final Logger logger = LoggerFactory.getLogger(WordGroupQueryService.class); + + private final WordGroupRepository wordGroupRepository; + private final WordRepository wordRepository; + + public WordGroupQueryService() { + this.wordGroupRepository = new WordGroupRepository(); + this.wordRepository = new WordRepository(); + } + + public PaginatedResult getGroups(String userId, int limit, String cursor) { + return wordGroupRepository.findByUserId(userId, limit, cursor); + } + + public Optional getGroup(String userId, String groupId) { + return wordGroupRepository.findByUserIdAndGroupId(userId, groupId); + } + + public Optional getGroupDetail(String userId, String groupId) { + Optional optGroup = wordGroupRepository.findByUserIdAndGroupId(userId, groupId); + if (optGroup.isEmpty()) { + return Optional.empty(); + } + + WordGroup group = optGroup.get(); + List words = List.of(); + + if (group.getWordIds() != null && !group.getWordIds().isEmpty()) { + words = wordRepository.findByIds(group.getWordIds()); + } + + return Optional.of(new WordGroupDetail(group, words)); + } + + public record WordGroupDetail(WordGroup group, List words) {} +} diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 6f7efc1b..053f37a7 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -298,6 +298,62 @@ Resources: Path: /vocab/users/{userId}/words/{wordId}/tag Method: PUT + WordGroupFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: group2-englishstudy-vocab-wordgroup-handler + CodeUri: . + Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.WordGroupHandler::handleRequest + Description: Handle user custom word groups + SnapStart: + ApplyOn: PublishedVersions + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref VocabTable + Events: + CreateGroup: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/users/{userId}/groups + Method: POST + GetGroups: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/users/{userId}/groups + Method: GET + GetGroupDetail: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/users/{userId}/groups/{groupId} + Method: GET + UpdateGroup: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/users/{userId}/groups/{groupId} + Method: PUT + DeleteGroup: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/users/{userId}/groups/{groupId} + Method: DELETE + AddWordToGroup: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/users/{userId}/groups/{groupId}/words/{wordId} + Method: POST + RemoveWordFromGroup: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/users/{userId}/groups/{groupId}/words/{wordId} + Method: DELETE + DailyStudyFunction: Type: AWS::Serverless::Function Properties: From 1341b24602603985be778198a20ef38adeea98f3 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 8 Jan 2026 15:33:23 +0900 Subject: [PATCH 075/528] =?UTF-8?q?feat(voice):=20=EB=8B=A8=EC=96=B4/?= =?UTF-8?q?=EC=98=88=EB=AC=B8=20=EA=B0=9C=EB=B3=84=20=EC=9D=8C=EC=84=B1=20?= =?UTF-8?q?=EC=9E=AC=EC=83=9D=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?refs=20#141=20(#142)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Word 모델에 예문 음성 캐시 필드 추가 (maleExampleVoiceKey, femaleExampleVoiceKey) - SynthesizeVoiceRequest에 type 파라미터 추가 (WORD/EXAMPLE) - VoiceHandler에서 type에 따른 음성 합성 분기 처리 - 예문 요청 시 예문이 없는 경우 400 에러 반환 --- .../dto/request/SynthesizeVoiceRequest.java | 4 +- .../vocabulary/handler/VoiceHandler.java | 54 +++++++++++++++---- .../domain/vocabulary/model/Word.java | 6 ++- 3 files changed, 51 insertions(+), 13 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/SynthesizeVoiceRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/SynthesizeVoiceRequest.java index 9d113c53..819d124d 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/SynthesizeVoiceRequest.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/SynthesizeVoiceRequest.java @@ -12,5 +12,7 @@ public class SynthesizeVoiceRequest { private String wordId; @Builder.Default - private String voice = "FEMALE"; + private String voice = "FEMALE"; // MALE 또는 FEMALE + @Builder.Default + private String type = "WORD"; // WORD 또는 EXAMPLE } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/VoiceHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/VoiceHandler.java index a2b32807..5613c93c 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/VoiceHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/VoiceHandler.java @@ -62,6 +62,7 @@ private APIGatewayProxyResponseEvent synthesizeSpeech(APIGatewayProxyRequestEven String wordId = req.getWordId(); String voice = req.getVoice() != null ? req.getVoice() : "FEMALE"; + String type = req.getType() != null ? req.getType() : "WORD"; // 단어 조회 Optional optWord = wordRepository.findById(wordId); @@ -71,9 +72,18 @@ private APIGatewayProxyResponseEvent synthesizeSpeech(APIGatewayProxyRequestEven Word word = optWord.get(); boolean isMale = "MALE".equalsIgnoreCase(voice); + boolean isExample = "EXAMPLE".equalsIgnoreCase(type); - // 캐시 확인: DynamoDB에 저장된 S3 키 확인 - String cachedKey = isMale ? word.getMaleVoiceKey() : word.getFemaleVoiceKey(); + // 예문 요청인데 예문이 없는 경우 + if (isExample && (word.getExample() == null || word.getExample().isEmpty())) { + return createResponse(400, ApiResponse.error("This word has no example sentence")); + } + + // 음성 합성할 텍스트 결정 + String textToSynthesize = isExample ? word.getExample() : word.getEnglish(); + + // 캐시 키 결정 + String cachedKey = getCachedKey(word, isMale, isExample); String audioUrl; boolean cached = false; @@ -81,32 +91,54 @@ private APIGatewayProxyResponseEvent synthesizeSpeech(APIGatewayProxyRequestEven // DB에 캐시 키가 있으면 Pre-signed URL 생성 audioUrl = pollyService.getPresignedUrl(cachedKey); cached = true; - logger.info("Cache hit from DB: wordId={}, voice={}", wordId, voice); + logger.info("Cache hit from DB: wordId={}, voice={}, type={}", wordId, voice, type); } else { // 캐시 미스: Polly 변환 후 S3 저장 + String s3KeySuffix = isExample ? "_example" : ""; PollyService.VoiceSynthesisResult result = pollyService.synthesizeSpeech( - wordId, word.getEnglish(), voice); + wordId + s3KeySuffix, textToSynthesize, voice); audioUrl = result.getAudioUrl(); cached = result.isCached(); // DynamoDB에 S3 키 저장 - if (isMale) { - word.setMaleVoiceKey(result.getS3Key()); - } else { - word.setFemaleVoiceKey(result.getS3Key()); - } + setCachedKey(word, isMale, isExample, result.getS3Key()); wordRepository.save(word); - logger.info("Saved voice cache to DB: wordId={}, voice={}", wordId, voice); + logger.info("Saved voice cache to DB: wordId={}, voice={}, type={}", wordId, voice, type); } Map responseData = new HashMap<>(); responseData.put("wordId", wordId); - responseData.put("english", word.getEnglish()); + responseData.put("text", textToSynthesize); + responseData.put("type", type); responseData.put("voice", voice); responseData.put("audioUrl", audioUrl); responseData.put("cached", cached); return createResponse(200, ApiResponse.success("Speech synthesized", responseData)); } + + private String getCachedKey(Word word, boolean isMale, boolean isExample) { + if (isExample) { + return isMale ? word.getMaleExampleVoiceKey() : word.getFemaleExampleVoiceKey(); + } else { + return isMale ? word.getMaleVoiceKey() : word.getFemaleVoiceKey(); + } + } + + private void setCachedKey(Word word, boolean isMale, boolean isExample, String s3Key) { + if (isExample) { + if (isMale) { + word.setMaleExampleVoiceKey(s3Key); + } else { + word.setFemaleExampleVoiceKey(s3Key); + } + } else { + if (isMale) { + word.setMaleVoiceKey(s3Key); + } else { + word.setFemaleVoiceKey(s3Key); + } + } + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/model/Word.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/model/Word.java index 51eb8e30..f217362d 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/model/Word.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/model/Word.java @@ -41,10 +41,14 @@ public class Word { private String createdAt; private Long ttl; - // 음성 캐시용 S3 키 (vocab/voice/{wordId}_{voice}.mp3) + // 단어 음성 캐시용 S3 키 (vocab/voice/{wordId}_{voice}.mp3) private String maleVoiceKey; private String femaleVoiceKey; + // 예문 음성 캐시용 S3 키 (vocab/voice/{wordId}_{voice}_example.mp3) + private String maleExampleVoiceKey; + private String femaleExampleVoiceKey; + @DynamoDbPartitionKey @DynamoDbAttribute("PK") public String getPk() { From 25139134c1f92012a95c7a8cdc7c769d91a18ed8 Mon Sep 17 00:00:00 2001 From: DDING JOO Date: Fri, 9 Jan 2026 09:14:39 +0900 Subject: [PATCH 076/528] =?UTF-8?q?feat(chatting):=20WebSocket=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=20=EA=B4=80=EB=A6=AC=20=EA=B5=AC=ED=98=84=20refs=20#1?= =?UTF-8?q?44=20(#150)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(chatting): Connection 엔티티 및 레포지토리 생성 closes #147 - Connection 엔티티 추가 (connectionId, userId, roomId, TTL) - ConnectionRepository 추가 (save, delete, findByRoomId, findByUserId) - DynamoDB GSI1/GSI2로 방별/사용자별 연결 조회 지원 * feat(chatting): WebSocketConnectHandler 구현 closes #148 - WebSocketConnectHandler Lambda 핸들러 추가 - WebSocketConfig로 환경변수 관리 (TTL 설정) - $connect 시 Connection 정보 DynamoDB 저장 * feat(chatting): WebSocketDisconnectHandler 구현 closes #149 - WebSocketDisconnectHandler Lambda 핸들러 추가 - $disconnect 시 Connection 정보 DynamoDB 삭제 --- .../common/config/WebSocketConfig.java | 50 ++++++++++ .../websocket/WebSocketConnectHandler.java | 91 +++++++++++++++++ .../websocket/WebSocketDisconnectHandler.java | 66 +++++++++++++ .../domain/chatting/model/Connection.java | 69 +++++++++++++ .../repository/ConnectionRepository.java | 97 +++++++++++++++++++ 5 files changed, 373 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/WebSocketConfig.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketConnectHandler.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketDisconnectHandler.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/Connection.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ConnectionRepository.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/WebSocketConfig.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/WebSocketConfig.java new file mode 100644 index 00000000..18416c5d --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/WebSocketConfig.java @@ -0,0 +1,50 @@ +package com.mzc.secondproject.serverless.common.config; + +/** + * WebSocket 관련 환경변수 설정 + * Lambda 환경변수에서 값을 읽어오며, 없을 경우 기본값 사용 + */ +public final class WebSocketConfig { + + private WebSocketConfig() { + // 인스턴스화 방지 + } + + // 환경변수 키 + private static final String ENV_CONNECTION_TTL_SECONDS = "WEBSOCKET_CONNECTION_TTL_SECONDS"; + private static final String ENV_WEBSOCKET_ENDPOINT = "WEBSOCKET_ENDPOINT"; + + // 기본값 + private static final long DEFAULT_CONNECTION_TTL_SECONDS = 600L; // 10분 + + // 캐시된 값 (Cold Start 최적화) + private static final long CONNECTION_TTL_SECONDS = parseConnectionTtl(); + private static final String WEBSOCKET_ENDPOINT = System.getenv(ENV_WEBSOCKET_ENDPOINT); + + private static long parseConnectionTtl() { + String value = System.getenv(ENV_CONNECTION_TTL_SECONDS); + if (value != null) { + try { + return Long.parseLong(value); + } catch (NumberFormatException ignored) { + // 파싱 실패 시 기본값 사용 + } + } + return DEFAULT_CONNECTION_TTL_SECONDS; + } + + /** + * WebSocket 연결 TTL (초) + */ + public static long connectionTtlSeconds() { + return CONNECTION_TTL_SECONDS; + } + + /** + * WebSocket API Gateway 엔드포인트 URL + * 메시지 브로드캐스트 시 사용 + */ + public static String websocketEndpoint() { + return WEBSOCKET_ENDPOINT; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketConnectHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketConnectHandler.java new file mode 100644 index 00000000..40ae0a03 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketConnectHandler.java @@ -0,0 +1,91 @@ +package com.mzc.secondproject.serverless.domain.chatting.handler.websocket; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.mzc.secondproject.serverless.common.config.WebSocketConfig; +import com.mzc.secondproject.serverless.domain.chatting.model.Connection; +import com.mzc.secondproject.serverless.domain.chatting.repository.ConnectionRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; + +/** + * WebSocket $connect 라우트 핸들러 + * 클라이언트 연결 시 Connection 정보를 DynamoDB에 저장 + */ +public class WebSocketConnectHandler implements RequestHandler, Map> { + + private static final Logger logger = LoggerFactory.getLogger(WebSocketConnectHandler.class); + + private final ConnectionRepository connectionRepository; + + public WebSocketConnectHandler() { + this.connectionRepository = new ConnectionRepository(); + } + + @Override + public Map handleRequest(Map event, Context context) { + logger.info("WebSocket connect event: {}", event); + + try { + String connectionId = extractConnectionId(event); + Map queryParams = extractQueryStringParameters(event); + + String userId = queryParams.get("userId"); + String roomId = queryParams.get("roomId"); + + if (userId == null || roomId == null) { + logger.warn("Missing required parameters: userId={}, roomId={}", userId, roomId); + return createResponse(400, "userId and roomId are required"); + } + + String now = Instant.now().toString(); + long ttl = Instant.now().plusSeconds(WebSocketConfig.connectionTtlSeconds()).getEpochSecond(); + + Connection connection = Connection.builder() + .pk("CONN#" + connectionId) + .sk("METADATA") + .gsi1pk("ROOM#" + roomId) + .gsi1sk("CONN#" + connectionId) + .gsi2pk("USER#" + userId) + .gsi2sk("CONN#" + connectionId) + .connectionId(connectionId) + .userId(userId) + .roomId(roomId) + .connectedAt(now) + .ttl(ttl) + .build(); + + connectionRepository.save(connection); + + logger.info("Connection saved: connectionId={}, userId={}, roomId={}", connectionId, userId, roomId); + return createResponse(200, "Connected"); + + } catch (Exception e) { + logger.error("Error handling connect: {}", e.getMessage(), e); + return createResponse(500, "Internal server error"); + } + } + + @SuppressWarnings("unchecked") + private String extractConnectionId(Map event) { + Map requestContext = (Map) event.get("requestContext"); + return (String) requestContext.get("connectionId"); + } + + @SuppressWarnings("unchecked") + private Map extractQueryStringParameters(Map event) { + Map params = (Map) event.get("queryStringParameters"); + return params != null ? params : new HashMap<>(); + } + + private Map createResponse(int statusCode, String body) { + Map response = new HashMap<>(); + response.put("statusCode", statusCode); + response.put("body", body); + return response; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketDisconnectHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketDisconnectHandler.java new file mode 100644 index 00000000..c23971ec --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketDisconnectHandler.java @@ -0,0 +1,66 @@ +package com.mzc.secondproject.serverless.domain.chatting.handler.websocket; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.mzc.secondproject.serverless.domain.chatting.model.Connection; +import com.mzc.secondproject.serverless.domain.chatting.repository.ConnectionRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +/** + * WebSocket $disconnect 라우트 핸들러 + * 클라이언트 연결 해제 시 Connection 정보를 DynamoDB에서 삭제 + */ +public class WebSocketDisconnectHandler implements RequestHandler, Map> { + + private static final Logger logger = LoggerFactory.getLogger(WebSocketDisconnectHandler.class); + + private final ConnectionRepository connectionRepository; + + public WebSocketDisconnectHandler() { + this.connectionRepository = new ConnectionRepository(); + } + + @Override + public Map handleRequest(Map event, Context context) { + logger.info("WebSocket disconnect event: {}", event); + + try { + String connectionId = extractConnectionId(event); + + Optional connection = connectionRepository.findByConnectionId(connectionId); + + if (connection.isPresent()) { + Connection conn = connection.get(); + connectionRepository.delete(connectionId); + logger.info("Connection deleted: connectionId={}, userId={}, roomId={}", + connectionId, conn.getUserId(), conn.getRoomId()); + } else { + logger.warn("Connection not found for deletion: connectionId={}", connectionId); + } + + return createResponse(200, "Disconnected"); + + } catch (Exception e) { + logger.error("Error handling disconnect: {}", e.getMessage(), e); + return createResponse(500, "Internal server error"); + } + } + + @SuppressWarnings("unchecked") + private String extractConnectionId(Map event) { + Map requestContext = (Map) event.get("requestContext"); + return (String) requestContext.get("connectionId"); + } + + private Map createResponse(int statusCode, String body) { + Map response = new HashMap<>(); + response.put("statusCode", statusCode); + response.put("body", body); + return response; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/Connection.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/Connection.java new file mode 100644 index 00000000..cbb1445c --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/Connection.java @@ -0,0 +1,69 @@ +package com.mzc.secondproject.serverless.domain.chatting.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbAttribute; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondaryPartitionKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondarySortKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSortKey; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DynamoDbBean +public class Connection { + + private String pk; // CONN#{connectionId} + private String sk; // METADATA + private String gsi1pk; // ROOM#{roomId} - 방별 연결 조회용 + private String gsi1sk; // CONN#{connectionId} + private String gsi2pk; // USER#{userId} - 사용자별 연결 조회용 + private String gsi2sk; // CONN#{connectionId} + + private String connectionId; + private String userId; + private String roomId; + private String connectedAt; + private Long ttl; // 10분 후 자동 삭제 + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { + return pk; + } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { + return sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1PK") + public String getGsi1pk() { + return gsi1pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1SK") + public String getGsi1sk() { + return gsi1sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI2") + @DynamoDbAttribute("GSI2PK") + public String getGsi2pk() { + return gsi2pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI2") + @DynamoDbAttribute("GSI2SK") + public String getGsi2sk() { + return gsi2sk; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ConnectionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ConnectionRepository.java new file mode 100644 index 00000000..0bd599c2 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ConnectionRepository.java @@ -0,0 +1,97 @@ +package com.mzc.secondproject.serverless.domain.chatting.repository; + +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.domain.chatting.model.Connection; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbIndex; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; +import software.amazon.awssdk.enhanced.dynamodb.Key; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +public class ConnectionRepository { + + private static final Logger logger = LoggerFactory.getLogger(ConnectionRepository.class); + private static final String TABLE_NAME = System.getenv("CHAT_TABLE_NAME"); + + private final DynamoDbTable table; + + public ConnectionRepository() { + this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(Connection.class)); + } + + public Connection save(Connection connection) { + logger.info("Saving connection: {} for user: {} in room: {}", + connection.getConnectionId(), connection.getUserId(), connection.getRoomId()); + table.putItem(connection); + return connection; + } + + public void delete(String connectionId) { + Key key = Key.builder() + .partitionValue("CONN#" + connectionId) + .sortValue("METADATA") + .build(); + + table.deleteItem(key); + logger.info("Deleted connection: {}", connectionId); + } + + public Optional findByConnectionId(String connectionId) { + Key key = Key.builder() + .partitionValue("CONN#" + connectionId) + .sortValue("METADATA") + .build(); + + Connection connection = table.getItem(key); + return Optional.ofNullable(connection); + } + + /** + * 채팅방의 모든 연결 조회 (브로드캐스트용) + * GSI1: ROOM#{roomId}로 조회 + */ + public List findByRoomId(String roomId) { + QueryConditional queryConditional = QueryConditional + .keyEqualTo(Key.builder() + .partitionValue("ROOM#" + roomId) + .build()); + + QueryEnhancedRequest request = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .build(); + + DynamoDbIndex gsi1 = table.index("GSI1"); + + return gsi1.query(request).stream() + .flatMap(page -> page.items().stream()) + .collect(Collectors.toList()); + } + + /** + * 사용자의 모든 연결 조회 (다중 기기 지원) + * GSI2: USER#{userId}로 조회 + */ + public List findByUserId(String userId) { + QueryConditional queryConditional = QueryConditional + .keyEqualTo(Key.builder() + .partitionValue("USER#" + userId) + .build()); + + QueryEnhancedRequest request = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .build(); + + DynamoDbIndex gsi2 = table.index("GSI2"); + + return gsi2.query(request).stream() + .flatMap(page -> page.items().stream()) + .collect(Collectors.toList()); + } +} \ No newline at end of file From 402c806d303ef8821f0a2c15517a7bf4886ce861 Mon Sep 17 00:00:00 2001 From: DDING JOO Date: Fri, 9 Jan 2026 09:17:13 +0900 Subject: [PATCH 077/528] =?UTF-8?q?feat(chatting):=20WebSocketMessageHandl?= =?UTF-8?q?er=20=EB=B0=8F=20Broadcaster=20=EA=B5=AC=ED=98=84=20closes=20#1?= =?UTF-8?q?51=20(#152)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - apigatewaymanagementapi 의존성 추가 - WebSocketBroadcaster 유틸리티 추가 - WebSocketMessageHandler: 메시지 저장 및 브로드캐스트 - 실패한 연결 자동 정리 --- ServerlessFunction/build.gradle | 1 + .../common/util/WebSocketBroadcaster.java | 83 ++++++++++++ .../websocket/WebSocketMessageHandler.java | 126 ++++++++++++++++++ 3 files changed, 210 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketBroadcaster.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java diff --git a/ServerlessFunction/build.gradle b/ServerlessFunction/build.gradle index c2787bd2..30581597 100644 --- a/ServerlessFunction/build.gradle +++ b/ServerlessFunction/build.gradle @@ -30,6 +30,7 @@ dependencies { implementation 'software.amazon.awssdk:polly' implementation 'software.amazon.awssdk:sns' implementation 'software.amazon.awssdk:bedrockruntime' + implementation 'software.amazon.awssdk:apigatewaymanagementapi' // JSON Processing implementation 'com.google.code.gson:gson:2.10.1' diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketBroadcaster.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketBroadcaster.java new file mode 100644 index 00000000..f6d9d935 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketBroadcaster.java @@ -0,0 +1,83 @@ +package com.mzc.secondproject.serverless.common.util; + +import com.mzc.secondproject.serverless.common.config.WebSocketConfig; +import com.mzc.secondproject.serverless.domain.chatting.model.Connection; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.core.SdkBytes; +import software.amazon.awssdk.services.apigatewaymanagementapi.ApiGatewayManagementApiClient; +import software.amazon.awssdk.services.apigatewaymanagementapi.model.GoneException; +import software.amazon.awssdk.services.apigatewaymanagementapi.model.PostToConnectionRequest; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +/** + * WebSocket 연결들에게 메시지를 브로드캐스트하는 유틸리티 + */ +public class WebSocketBroadcaster { + + private static final Logger logger = LoggerFactory.getLogger(WebSocketBroadcaster.class); + + private final ApiGatewayManagementApiClient apiClient; + + public WebSocketBroadcaster() { + String endpoint = WebSocketConfig.websocketEndpoint(); + this.apiClient = ApiGatewayManagementApiClient.builder() + .endpointOverride(URI.create(endpoint)) + .build(); + } + + public WebSocketBroadcaster(String endpoint) { + this.apiClient = ApiGatewayManagementApiClient.builder() + .endpointOverride(URI.create(endpoint)) + .build(); + } + + /** + * 단일 연결에 메시지 전송 + * @return 전송 성공 여부 + */ + public boolean sendToConnection(String connectionId, String message) { + try { + PostToConnectionRequest request = PostToConnectionRequest.builder() + .connectionId(connectionId) + .data(SdkBytes.fromString(message, StandardCharsets.UTF_8)) + .build(); + + apiClient.postToConnection(request); + logger.debug("Message sent to connection: {}", connectionId); + return true; + + } catch (GoneException e) { + logger.warn("Connection gone: {}", connectionId); + return false; + + } catch (Exception e) { + logger.error("Failed to send message to connection {}: {}", connectionId, e.getMessage()); + return false; + } + } + + /** + * 여러 연결에 메시지 브로드캐스트 + * @return 전송 실패한 connectionId 목록 + */ + public List broadcast(List connections, String message) { + List failedConnections = new ArrayList<>(); + + for (Connection connection : connections) { + boolean success = sendToConnection(connection.getConnectionId(), message); + if (!success) { + failedConnections.add(connection.getConnectionId()); + } + } + + logger.info("Broadcast completed: total={}, failed={}", + connections.size(), failedConnections.size()); + + return failedConnections; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java new file mode 100644 index 00000000..b42b23cd --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java @@ -0,0 +1,126 @@ +package com.mzc.secondproject.serverless.domain.chatting.handler.websocket; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.mzc.secondproject.serverless.common.util.WebSocketBroadcaster; +import com.mzc.secondproject.serverless.domain.chatting.model.ChatMessage; +import com.mzc.secondproject.serverless.domain.chatting.model.Connection; +import com.mzc.secondproject.serverless.domain.chatting.repository.ConnectionRepository; +import com.mzc.secondproject.serverless.domain.chatting.repository.ChatRoomRepository; +import com.mzc.secondproject.serverless.domain.chatting.service.ChatMessageService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * WebSocket sendMessage 라우트 핸들러 + * 메시지 저장 및 같은 방 연결들에게 브로드캐스트 + */ +public class WebSocketMessageHandler implements RequestHandler, Map> { + + private static final Logger logger = LoggerFactory.getLogger(WebSocketMessageHandler.class); + private static final Gson gson = new GsonBuilder().create(); + + private final ChatMessageService chatMessageService; + private final ChatRoomRepository chatRoomRepository; + private final ConnectionRepository connectionRepository; + private final WebSocketBroadcaster broadcaster; + + public WebSocketMessageHandler() { + this.chatMessageService = new ChatMessageService(); + this.chatRoomRepository = new ChatRoomRepository(); + this.connectionRepository = new ConnectionRepository(); + this.broadcaster = new WebSocketBroadcaster(); + } + + @Override + public Map handleRequest(Map event, Context context) { + logger.info("WebSocket message event: {}", event); + + try { + String connectionId = extractConnectionId(event); + String body = (String) event.get("body"); + + if (body == null || body.isEmpty()) { + return createResponse(400, "Message body is required"); + } + + MessagePayload payload = gson.fromJson(body, MessagePayload.class); + + if (payload.roomId == null || payload.userId == null || payload.content == null) { + return createResponse(400, "roomId, userId, and content are required"); + } + + String messageType = payload.messageType != null ? payload.messageType : "TEXT"; + String messageId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + + ChatMessage message = ChatMessage.builder() + .pk("ROOM#" + payload.roomId) + .sk("MSG#" + now + "#" + messageId) + .gsi1pk("USER#" + payload.userId) + .gsi1sk("MSG#" + now) + .gsi2pk("MSG#" + messageId) + .gsi2sk("ROOM#" + payload.roomId) + .messageId(messageId) + .roomId(payload.roomId) + .userId(payload.userId) + .content(payload.content) + .messageType(messageType) + .createdAt(now) + .build(); + + ChatMessage savedMessage = chatMessageService.saveMessage(message); + chatRoomRepository.updateLastMessageAt(payload.roomId, now); + + logger.info("Message saved: messageId={}, roomId={}", messageId, payload.roomId); + + // 브로드캐스트 + List connections = connectionRepository.findByRoomId(payload.roomId); + String broadcastPayload = gson.toJson(savedMessage); + List failedConnections = broadcaster.broadcast(connections, broadcastPayload); + + // 실패한 연결 정리 + for (String failedConnectionId : failedConnections) { + connectionRepository.delete(failedConnectionId); + logger.info("Deleted stale connection: {}", failedConnectionId); + } + + return createResponse(200, "Message sent"); + + } catch (Exception e) { + logger.error("Error handling message: {}", e.getMessage(), e); + return createResponse(500, "Internal server error"); + } + } + + @SuppressWarnings("unchecked") + private String extractConnectionId(Map event) { + Map requestContext = (Map) event.get("requestContext"); + return (String) requestContext.get("connectionId"); + } + + private Map createResponse(int statusCode, String body) { + Map response = new HashMap<>(); + response.put("statusCode", statusCode); + response.put("body", body); + return response; + } + + /** + * 메시지 페이로드 DTO + */ + private static class MessagePayload { + String roomId; + String userId; + String content; + String messageType; + } +} From 7e63b96ba2326d3420926229a134d9ef1f2e16c4 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 9 Jan 2026 09:19:40 +0900 Subject: [PATCH 078/528] =?UTF-8?q?feat(infra):=20WebSocket=20API=20Gatewa?= =?UTF-8?q?y=20SAM=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80=20closes=20#1?= =?UTF-8?q?53?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WebSocket API Gateway (AWS::ApiGatewayV2::Api) 추가 - $connect, $disconnect, sendMessage 라우트 설정 - WebSocket Lambda 함수 3개 정의 - execute-api:ManageConnections IAM 권한 추가 - WEBSOCKET_ENDPOINT, TTL 환경변수 설정 - Outputs에 WebSocketUrl 추가 --- ServerlessFunction/template.yaml | 148 +++++++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 053f37a7..eeaa4559 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -32,6 +32,150 @@ Resources: AllowHeaders: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" AllowOrigin: "'*'" + ############################################# + # WebSocket API Gateway + ############################################# + + WebSocketApi: + Type: AWS::ApiGatewayV2::Api + Properties: + Name: group2-englishstudy-websocket + ProtocolType: WEBSOCKET + RouteSelectionExpression: "$request.body.action" + + WebSocketStage: + Type: AWS::ApiGatewayV2::Stage + Properties: + ApiId: !Ref WebSocketApi + StageName: dev + AutoDeploy: true + + # WebSocket Connect Route + ConnectRoute: + Type: AWS::ApiGatewayV2::Route + Properties: + ApiId: !Ref WebSocketApi + RouteKey: $connect + AuthorizationType: NONE + Target: !Sub integrations/${ConnectIntegration} + + ConnectIntegration: + Type: AWS::ApiGatewayV2::Integration + Properties: + ApiId: !Ref WebSocketApi + IntegrationType: AWS_PROXY + IntegrationUri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${WebSocketConnectFunction.Arn}/invocations + + # WebSocket Disconnect Route + DisconnectRoute: + Type: AWS::ApiGatewayV2::Route + Properties: + ApiId: !Ref WebSocketApi + RouteKey: $disconnect + AuthorizationType: NONE + Target: !Sub integrations/${DisconnectIntegration} + + DisconnectIntegration: + Type: AWS::ApiGatewayV2::Integration + Properties: + ApiId: !Ref WebSocketApi + IntegrationType: AWS_PROXY + IntegrationUri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${WebSocketDisconnectFunction.Arn}/invocations + + # WebSocket SendMessage Route + SendMessageRoute: + Type: AWS::ApiGatewayV2::Route + Properties: + ApiId: !Ref WebSocketApi + RouteKey: sendMessage + AuthorizationType: NONE + Target: !Sub integrations/${SendMessageIntegration} + + SendMessageIntegration: + Type: AWS::ApiGatewayV2::Integration + Properties: + ApiId: !Ref WebSocketApi + IntegrationType: AWS_PROXY + IntegrationUri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${WebSocketMessageFunction.Arn}/invocations + + ############################################# + # WebSocket Lambda Functions + ############################################# + + WebSocketConnectFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: group2-englishstudy-ws-connect + CodeUri: . + Handler: com.mzc.secondproject.serverless.domain.chatting.handler.websocket.WebSocketConnectHandler::handleRequest + Description: Handle WebSocket $connect + SnapStart: + ApplyOn: PublishedVersions + Environment: + Variables: + WEBSOCKET_CONNECTION_TTL_SECONDS: "600" + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref ChatTable + + WebSocketConnectPermission: + Type: AWS::Lambda::Permission + Properties: + Action: lambda:InvokeFunction + FunctionName: !Ref WebSocketConnectFunction + Principal: apigateway.amazonaws.com + SourceArn: !Sub arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${WebSocketApi}/*/$connect + + WebSocketDisconnectFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: group2-englishstudy-ws-disconnect + CodeUri: . + Handler: com.mzc.secondproject.serverless.domain.chatting.handler.websocket.WebSocketDisconnectHandler::handleRequest + Description: Handle WebSocket $disconnect + SnapStart: + ApplyOn: PublishedVersions + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref ChatTable + + WebSocketDisconnectPermission: + Type: AWS::Lambda::Permission + Properties: + Action: lambda:InvokeFunction + FunctionName: !Ref WebSocketDisconnectFunction + Principal: apigateway.amazonaws.com + SourceArn: !Sub arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${WebSocketApi}/*/$disconnect + + WebSocketMessageFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: group2-englishstudy-ws-message + CodeUri: . + Handler: com.mzc.secondproject.serverless.domain.chatting.handler.websocket.WebSocketMessageHandler::handleRequest + Description: Handle WebSocket sendMessage + SnapStart: + ApplyOn: PublishedVersions + Environment: + Variables: + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref ChatTable + - Statement: + - Effect: Allow + Action: + - execute-api:ManageConnections + Resource: !Sub arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${WebSocketApi}/* + + WebSocketMessagePermission: + Type: AWS::Lambda::Permission + Properties: + Action: lambda:InvokeFunction + FunctionName: !Ref WebSocketMessageFunction + Principal: apigateway.amazonaws.com + SourceArn: !Sub arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${WebSocketApi}/*/sendMessage + ############################################# # Chatting Lambda Functions ############################################# @@ -660,6 +804,10 @@ Outputs: Description: Unified API Gateway endpoint URL Value: !Sub 'https://${MainApi}.execute-api.${AWS::Region}.amazonaws.com/dev/' + WebSocketUrl: + Description: WebSocket API Gateway endpoint URL + Value: !Sub 'wss://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev' + ChatTableName: Description: Chat DynamoDB Table Name Value: !Ref ChatTable From 2457b8d146e13d4f0ac4b52fc16de85b5b004edc Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 9 Jan 2026 09:47:40 +0900 Subject: [PATCH 079/528] =?UTF-8?q?feat(chatting):=20RoomToken=20=EB=AA=A8?= =?UTF-8?q?=EB=8D=B8=20=EB=B0=8F=20Repository=20=EA=B5=AC=ED=98=84=20close?= =?UTF-8?q?s=20#156?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RoomToken DynamoDB 엔티티 생성 (PK: TOKEN#{token}) - RoomTokenRepository CRUD 구현 - TTL 필드로 토큰 자동 만료 지원 --- .../domain/chatting/model/RoomToken.java | 42 ++++++++++++++++ .../repository/RoomTokenRepository.java | 50 +++++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/RoomToken.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/RoomTokenRepository.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/RoomToken.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/RoomToken.java new file mode 100644 index 00000000..ea0d313f --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/RoomToken.java @@ -0,0 +1,42 @@ +package com.mzc.secondproject.serverless.domain.chatting.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbAttribute; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSortKey; + +/** + * 채팅방 입장 토큰 + * REST API에서 발급받아 WebSocket 연결 시 인증에 사용 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DynamoDbBean +public class RoomToken { + + private String pk; // TOKEN#{token} + private String sk; // METADATA + private String token; + private String roomId; + private String userId; + private String createdAt; + private Long ttl; // 자동 만료 + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { + return pk; + } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { + return sk; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/RoomTokenRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/RoomTokenRepository.java new file mode 100644 index 00000000..a071dc3f --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/RoomTokenRepository.java @@ -0,0 +1,50 @@ +package com.mzc.secondproject.serverless.domain.chatting.repository; + +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.domain.chatting.model.RoomToken; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; +import software.amazon.awssdk.enhanced.dynamodb.Key; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; + +import java.util.Optional; + +public class RoomTokenRepository { + + private static final Logger logger = LoggerFactory.getLogger(RoomTokenRepository.class); + private static final String TABLE_NAME = System.getenv("CHAT_TABLE_NAME"); + + private final DynamoDbTable table; + + public RoomTokenRepository() { + this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(RoomToken.class)); + } + + public RoomToken save(RoomToken roomToken) { + logger.info("Saving room token for user: {} in room: {}", + roomToken.getUserId(), roomToken.getRoomId()); + table.putItem(roomToken); + return roomToken; + } + + public void delete(String token) { + Key key = Key.builder() + .partitionValue("TOKEN#" + token) + .sortValue("METADATA") + .build(); + + table.deleteItem(key); + logger.info("Deleted room token: {}", token); + } + + public Optional findByToken(String token) { + Key key = Key.builder() + .partitionValue("TOKEN#" + token) + .sortValue("METADATA") + .build(); + + RoomToken roomToken = table.getItem(key); + return Optional.ofNullable(roomToken); + } +} From 8eaa38a5f7b973c9ac9b240022861d983a2b79b9 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 9 Jan 2026 09:48:33 +0900 Subject: [PATCH 080/528] =?UTF-8?q?feat(chatting):=20RoomTokenService=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20closes=20#157?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RoomTokenConfig 환경변수 설정 (TTL 5분 기본값) - RoomTokenService 토큰 생성/검증/삭제 기능 - TTL 만료 이중 체크 (DynamoDB 유예 기간 대비) --- .../common/config/RoomTokenConfig.java | 40 +++++++++ .../chatting/service/RoomTokenService.java | 87 +++++++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/RoomTokenConfig.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/RoomTokenService.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/RoomTokenConfig.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/RoomTokenConfig.java new file mode 100644 index 00000000..182e5c0e --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/RoomTokenConfig.java @@ -0,0 +1,40 @@ +package com.mzc.secondproject.serverless.common.config; + +/** + * RoomToken 관련 환경변수 설정 + * Lambda 환경변수에서 값을 읽어오며, 없을 경우 기본값 사용 + */ +public final class RoomTokenConfig { + + private RoomTokenConfig() { + // 인스턴스화 방지 + } + + // 환경변수 키 + private static final String ENV_TOKEN_TTL_SECONDS = "ROOM_TOKEN_TTL_SECONDS"; + + // 기본값: 5분 + private static final long DEFAULT_TOKEN_TTL_SECONDS = 300L; + + // 캐시된 값 (Cold Start 최적화) + private static final long TOKEN_TTL_SECONDS = parseTokenTtl(); + + private static long parseTokenTtl() { + String value = System.getenv(ENV_TOKEN_TTL_SECONDS); + if (value != null) { + try { + return Long.parseLong(value); + } catch (NumberFormatException ignored) { + // 파싱 실패 시 기본값 사용 + } + } + return DEFAULT_TOKEN_TTL_SECONDS; + } + + /** + * RoomToken TTL (초) + */ + public static long tokenTtlSeconds() { + return TOKEN_TTL_SECONDS; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/RoomTokenService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/RoomTokenService.java new file mode 100644 index 00000000..4b5686e3 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/RoomTokenService.java @@ -0,0 +1,87 @@ +package com.mzc.secondproject.serverless.domain.chatting.service; + +import com.mzc.secondproject.serverless.common.config.RoomTokenConfig; +import com.mzc.secondproject.serverless.domain.chatting.model.RoomToken; +import com.mzc.secondproject.serverless.domain.chatting.repository.RoomTokenRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.util.Optional; +import java.util.UUID; + +/** + * 채팅방 입장 토큰 서비스 + * REST API에서 토큰 발급, WebSocket 연결 시 토큰 검증 + */ +public class RoomTokenService { + + private static final Logger logger = LoggerFactory.getLogger(RoomTokenService.class); + + private final RoomTokenRepository tokenRepository; + + public RoomTokenService() { + this.tokenRepository = new RoomTokenRepository(); + } + + /** + * 채팅방 입장 토큰 생성 + */ + public RoomToken generateToken(String roomId, String userId) { + String token = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + long ttl = Instant.now().getEpochSecond() + RoomTokenConfig.tokenTtlSeconds(); + + RoomToken roomToken = RoomToken.builder() + .pk("TOKEN#" + token) + .sk("METADATA") + .token(token) + .roomId(roomId) + .userId(userId) + .createdAt(now) + .ttl(ttl) + .build(); + + tokenRepository.save(roomToken); + logger.info("Generated room token for user: {} in room: {}", userId, roomId); + + return roomToken; + } + + /** + * 토큰 검증 및 정보 조회 + * @return 유효한 토큰이면 RoomToken, 그렇지 않으면 empty + */ + public Optional validateToken(String token) { + if (token == null || token.isEmpty()) { + logger.warn("Token is null or empty"); + return Optional.empty(); + } + + Optional optToken = tokenRepository.findByToken(token); + + if (optToken.isEmpty()) { + logger.warn("Token not found: {}", token); + return Optional.empty(); + } + + RoomToken roomToken = optToken.get(); + + // TTL 만료 체크 (DynamoDB TTL 삭제 전 유예 기간 대비) + if (roomToken.getTtl() != null && roomToken.getTtl() < Instant.now().getEpochSecond()) { + logger.warn("Token expired: {}", token); + return Optional.empty(); + } + + logger.info("Token validated for user: {} in room: {}", roomToken.getUserId(), roomToken.getRoomId()); + return Optional.of(roomToken); + } + + /** + * 토큰 삭제 (사용 후 또는 명시적 삭제) + */ + public void deleteToken(String token) { + tokenRepository.delete(token); + logger.info("Deleted room token: {}", token); + } +} From 92b36647fdb545022cf3635f8b2b3dd006f922ad Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 9 Jan 2026 09:50:43 +0900 Subject: [PATCH 081/528] =?UTF-8?q?feat(chatting):=20joinRoom=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=EC=97=90=20roomToken=20=EC=B6=94=EA=B0=80=20closes=20?= =?UTF-8?q?#158?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - JoinRoomResponse DTO 생성 (room, roomToken, tokenExpiresAt) - ChatRoomCommandService.joinRoom 토큰 발급 추가 - ChatRoomHandler 응답 형식 변경 --- .../dto/response/JoinRoomResponse.java | 21 ++++++++++++ .../chatting/handler/ChatRoomHandler.java | 7 ++-- .../service/ChatRoomCommandService.java | 34 ++++++++++++------- 3 files changed, 47 insertions(+), 15 deletions(-) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/JoinRoomResponse.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/JoinRoomResponse.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/JoinRoomResponse.java new file mode 100644 index 00000000..8561cefe --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/JoinRoomResponse.java @@ -0,0 +1,21 @@ +package com.mzc.secondproject.serverless.domain.chatting.dto.response; + +import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 채팅방 입장 응답 + * 방 정보와 WebSocket 연결용 토큰 포함 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class JoinRoomResponse { + private ChatRoom room; + private String roomToken; + private Long tokenExpiresAt; +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java index 99d37232..9206e97e 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java @@ -9,6 +9,7 @@ import com.mzc.secondproject.serverless.domain.chatting.dto.request.CreateRoomRequest; import com.mzc.secondproject.serverless.domain.chatting.dto.request.JoinRoomRequest; import com.mzc.secondproject.serverless.domain.chatting.dto.request.LeaveRoomRequest; +import com.mzc.secondproject.serverless.domain.chatting.dto.response.JoinRoomResponse; import com.mzc.secondproject.serverless.common.router.HandlerRouter; import com.mzc.secondproject.serverless.common.router.Route; import com.mzc.secondproject.serverless.common.util.ResponseUtil; @@ -132,9 +133,9 @@ private APIGatewayProxyResponseEvent joinRoom(APIGatewayProxyRequestEvent reques return createResponse(400, ApiResponse.error("roomId and userId are required")); } - ChatRoom room = commandService.joinRoom(roomId, req.getUserId(), req.getPassword()); - room.setPassword(null); - return createResponse(200, ApiResponse.success("Joined room", room)); + JoinRoomResponse response = commandService.joinRoom(roomId, req.getUserId(), req.getPassword()); + response.getRoom().setPassword(null); + return createResponse(200, ApiResponse.success("Joined room", response)); } private APIGatewayProxyResponseEvent leaveRoom(APIGatewayProxyRequestEvent request) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomCommandService.java index 277766d4..8535e3f1 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomCommandService.java @@ -1,6 +1,8 @@ package com.mzc.secondproject.serverless.domain.chatting.service; +import com.mzc.secondproject.serverless.domain.chatting.dto.response.JoinRoomResponse; import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; +import com.mzc.secondproject.serverless.domain.chatting.model.RoomToken; import com.mzc.secondproject.serverless.domain.chatting.repository.ChatRoomRepository; import org.mindrot.jbcrypt.BCrypt; import org.slf4j.Logger; @@ -20,9 +22,11 @@ public class ChatRoomCommandService { private static final Logger logger = LoggerFactory.getLogger(ChatRoomCommandService.class); private final ChatRoomRepository roomRepository; + private final RoomTokenService roomTokenService; public ChatRoomCommandService() { this.roomRepository = new ChatRoomRepository(); + this.roomTokenService = new RoomTokenService(); } public ChatRoom createRoom(String name, String description, String level, Integer maxMembers, @@ -55,7 +59,7 @@ public ChatRoom createRoom(String name, String description, String level, Intege return room; } - public ChatRoom joinRoom(String roomId, String userId, String password) { + public JoinRoomResponse joinRoom(String roomId, String userId, String password) { Optional optRoom = roomRepository.findById(roomId); if (optRoom.isEmpty()) { throw new IllegalArgumentException("Room not found"); @@ -73,21 +77,27 @@ public ChatRoom joinRoom(String roomId, String userId, String password) { throw new IllegalStateException("Room is full"); } - if (room.getMemberIds() != null && room.getMemberIds().contains(userId)) { + boolean alreadyMember = room.getMemberIds() != null && room.getMemberIds().contains(userId); + if (!alreadyMember) { + if (room.getMemberIds() == null) { + room.setMemberIds(new ArrayList<>()); + } + room.getMemberIds().add(userId); + room.setCurrentMembers(room.getCurrentMembers() + 1); + roomRepository.save(room); + logger.info("User {} joined room {}", userId, roomId); + } else { logger.info("User {} already in room {}", userId, roomId); - return room; - } - - if (room.getMemberIds() == null) { - room.setMemberIds(new ArrayList<>()); } - room.getMemberIds().add(userId); - room.setCurrentMembers(room.getCurrentMembers() + 1); - roomRepository.save(room); - logger.info("User {} joined room {}", userId, roomId); + // 토큰 발급 + RoomToken token = roomTokenService.generateToken(roomId, userId); - return room; + return JoinRoomResponse.builder() + .room(room) + .roomToken(token.getToken()) + .tokenExpiresAt(token.getTtl()) + .build(); } public LeaveResult leaveRoom(String roomId, String userId) { From 1d1339cf9db37aa05f7add38f29738da95be92a9 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 9 Jan 2026 09:52:16 +0900 Subject: [PATCH 082/528] =?UTF-8?q?feat(chatting):=20WebSocketConnectHandl?= =?UTF-8?q?er=20=ED=86=A0=ED=81=B0=20=EA=B2=80=EC=A6=9D=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20closes=20#159?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - roomToken 쿼리 파라미터로 인증 - 토큰에서 roomId, userId 추출 - 유효하지 않은 토큰 시 401 응답 - template.yaml에 ROOM_TOKEN_TTL_SECONDS 환경변수 추가 --- .../websocket/WebSocketConnectHandler.java | 27 ++++++++++++++----- ServerlessFunction/template.yaml | 1 + 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketConnectHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketConnectHandler.java index 40ae0a03..329fe66c 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketConnectHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketConnectHandler.java @@ -4,26 +4,31 @@ import com.amazonaws.services.lambda.runtime.RequestHandler; import com.mzc.secondproject.serverless.common.config.WebSocketConfig; import com.mzc.secondproject.serverless.domain.chatting.model.Connection; +import com.mzc.secondproject.serverless.domain.chatting.model.RoomToken; import com.mzc.secondproject.serverless.domain.chatting.repository.ConnectionRepository; +import com.mzc.secondproject.serverless.domain.chatting.service.RoomTokenService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.time.Instant; import java.util.HashMap; import java.util.Map; +import java.util.Optional; /** * WebSocket $connect 라우트 핸들러 - * 클라이언트 연결 시 Connection 정보를 DynamoDB에 저장 + * roomToken 검증 후 Connection 정보를 DynamoDB에 저장 */ public class WebSocketConnectHandler implements RequestHandler, Map> { private static final Logger logger = LoggerFactory.getLogger(WebSocketConnectHandler.class); private final ConnectionRepository connectionRepository; + private final RoomTokenService roomTokenService; public WebSocketConnectHandler() { this.connectionRepository = new ConnectionRepository(); + this.roomTokenService = new RoomTokenService(); } @Override @@ -34,14 +39,24 @@ public Map handleRequest(Map event, Context cont String connectionId = extractConnectionId(event); Map queryParams = extractQueryStringParameters(event); - String userId = queryParams.get("userId"); - String roomId = queryParams.get("roomId"); + String roomToken = queryParams.get("roomToken"); - if (userId == null || roomId == null) { - logger.warn("Missing required parameters: userId={}, roomId={}", userId, roomId); - return createResponse(400, "userId and roomId are required"); + if (roomToken == null || roomToken.isEmpty()) { + logger.warn("Missing roomToken parameter"); + return createResponse(401, "roomToken is required"); } + // 토큰 검증 + Optional optToken = roomTokenService.validateToken(roomToken); + if (optToken.isEmpty()) { + logger.warn("Invalid or expired roomToken: {}", roomToken); + return createResponse(401, "Invalid or expired token"); + } + + RoomToken token = optToken.get(); + String userId = token.getUserId(); + String roomId = token.getRoomId(); + String now = Instant.now().toString(); long ttl = Instant.now().plusSeconds(WebSocketConfig.connectionTtlSeconds()).getEpochSecond(); diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index eeaa4559..789f8600 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -16,6 +16,7 @@ Globals: CHAT_BUCKET_NAME: group2-englishstudy VOCAB_BUCKET_NAME: group2-englishstudy AWS_REGION_NAME: !Ref AWS::Region + ROOM_TOKEN_TTL_SECONDS: "300" Resources: ############################################# From ba63277a37031cb0cb28a7b86a013b59e4d7ae58 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 9 Jan 2026 10:57:02 +0900 Subject: [PATCH 083/528] `docs: replace VOCAB_API_SPEC.md with CHATTING-GUIDE.md` --- docs/ReadMe.md | 1 - docs/VOCAB_API_SPEC.md | 678 --------------- docs/chatting/CHATTING-GUIDE.md | 944 ++++++++++++++++++++ docs/vocabulary/VOCABULARY-GUIDE.md | 1247 +++++++++++++++++++++++++++ 4 files changed, 2191 insertions(+), 679 deletions(-) delete mode 100644 docs/VOCAB_API_SPEC.md create mode 100644 docs/chatting/CHATTING-GUIDE.md create mode 100644 docs/vocabulary/VOCABULARY-GUIDE.md diff --git a/docs/ReadMe.md b/docs/ReadMe.md index 8b137891..e69de29b 100644 --- a/docs/ReadMe.md +++ b/docs/ReadMe.md @@ -1 +0,0 @@ - diff --git a/docs/VOCAB_API_SPEC.md b/docs/VOCAB_API_SPEC.md deleted file mode 100644 index a972647a..00000000 --- a/docs/VOCAB_API_SPEC.md +++ /dev/null @@ -1,678 +0,0 @@ -# 단어 암기 서비스 API 명세서 - -## 개요 -영어 단어 암기 학습을 위한 백엔드 API입니다. -- **Base URL**: `https://gc8l9ijhzc.execute-api.ap-northeast-2.amazonaws.com/dev` -- **Content-Type**: `application/json` - ---- - -## 핵심 기능 - -| 기능 | 설명 | -|------|------| -| 일일 학습 | 매일 55개 단어 (새 단어 50개 + 복습 5개) | -| Spaced Repetition | SM-2 알고리즘 기반 최적 복습 주기 | -| 시험 | 학습한 단어 테스트 및 성적 기록 | -| TTS | Polly 기반 발음 듣기 (남성/여성) | -| 약점 분석 | 틀린 단어, 카테고리별 정확도 분석 | - ---- - -## 1. 단어 관리 API - -### 1.1 단어 목록 조회 -``` -GET /vocab/words -``` - -**Query Parameters** -| 파라미터 | 타입 | 필수 | 설명 | -|---------|------|------|------| -| level | string | N | `BEGINNER`, `INTERMEDIATE`, `ADVANCED` | -| category | string | N | `DAILY`, `BUSINESS`, `ACADEMIC` | -| limit | number | N | 페이지 크기 (기본: 20, 최대: 50) | -| cursor | string | N | 페이지네이션 커서 | - -**Response** -```json -{ - "success": true, - "message": "Words retrieved", - "data": { - "words": [ - { - "wordId": "uuid", - "english": "apple", - "korean": "사과", - "example": "I eat an apple every day.", - "level": "BEGINNER", - "category": "DAILY" - } - ], - "nextCursor": "base64-encoded-cursor", - "hasMore": true - } -} -``` - ---- - -### 1.2 단어 검색 -``` -GET /vocab/words/search -``` - -**Query Parameters** -| 파라미터 | 타입 | 필수 | 설명 | -|---------|------|------|------| -| q | string | Y | 검색어 (영어/한국어 모두 가능) | -| limit | number | N | 결과 개수 (기본: 20) | -| cursor | string | N | 페이지네이션 커서 | - -**Response** -```json -{ - "success": true, - "message": "Search completed", - "data": { - "words": [...], - "query": "apple", - "nextCursor": null, - "hasMore": false - } -} -``` - ---- - -### 1.3 단어 상세 조회 -``` -GET /vocab/words/{wordId} -``` - -**Response** -```json -{ - "success": true, - "message": "Word retrieved", - "data": { - "wordId": "uuid", - "english": "apple", - "korean": "사과", - "example": "I eat an apple every day.", - "level": "BEGINNER", - "category": "DAILY", - "createdAt": "2024-01-07T12:00:00Z" - } -} -``` - ---- - -## 2. 일일 학습 API - -### 2.1 오늘의 학습 단어 조회 -``` -GET /vocab/daily/{userId} -``` - -**설명**: 오늘 학습할 55개 단어를 반환합니다. -- 새 단어 50개 (아직 학습하지 않은 단어) -- 복습 단어 5개 (Spaced Repetition 기반) - -**Response** -```json -{ - "success": true, - "message": "Daily words retrieved", - "data": { - "date": "2024-01-07", - "userId": "user123", - "totalWords": 55, - "learnedCount": 10, - "isCompleted": false, - "newWords": [ - { - "wordId": "uuid", - "english": "apple", - "korean": "사과", - "example": "I eat an apple every day." - } - ], - "reviewWords": [ - { - "wordId": "uuid", - "english": "book", - "korean": "책", - "lastReviewedAt": "2024-01-05", - "correctCount": 3, - "incorrectCount": 1 - } - ] - } -} -``` - ---- - -### 2.2 단어 학습 완료 표시 -``` -POST /vocab/daily/{userId}/words/{wordId}/learned -``` - -**Request Body** -```json -{ - "isCorrect": true -} -``` - -**Response** -```json -{ - "success": true, - "message": "Word marked as learned", - "data": { - "wordId": "uuid", - "status": "LEARNING", - "nextReviewAt": "2024-01-08" - } -} -``` - ---- - -## 3. 사용자 단어 학습 상태 API - -### 3.1 학습 상태 조회 -``` -GET /vocab/users/{userId}/words -``` - -**Query Parameters** -| 파라미터 | 타입 | 필수 | 설명 | -|---------|------|------|------| -| status | string | N | `NEW`, `LEARNING`, `REVIEWING`, `MASTERED` | -| limit | number | N | 페이지 크기 (기본: 20) | -| cursor | string | N | 페이지네이션 커서 | - -**Response** -```json -{ - "success": true, - "message": "User words retrieved", - "data": { - "userWords": [ - { - "wordId": "uuid", - "userId": "user123", - "status": "LEARNING", - "correctCount": 5, - "incorrectCount": 2, - "interval": 6, - "nextReviewAt": "2024-01-13", - "lastReviewedAt": "2024-01-07", - "bookmarked": true, - "favorite": false, - "difficulty": "HARD" - } - ], - "nextCursor": null, - "hasMore": false - } -} -``` - -**학습 상태 설명** -| 상태 | 설명 | -|------|------| -| `NEW` | 아직 학습하지 않음 | -| `LEARNING` | 학습 중 (1-2회 정답) | -| `REVIEWING` | 복습 단계 (2-4회 정답) | -| `MASTERED` | 완전 암기 (5회 이상 연속 정답) | - ---- - -### 3.2 학습 결과 업데이트 (정답/오답) -``` -PUT /vocab/users/{userId}/words/{wordId} -``` - -**Request Body** -```json -{ - "isCorrect": true -} -``` - -**Response** -```json -{ - "success": true, - "message": "UserWord updated", - "data": { - "wordId": "uuid", - "status": "REVIEWING", - "interval": 6, - "easeFactor": 2.5, - "repetitions": 3, - "nextReviewAt": "2024-01-13", - "correctCount": 6, - "incorrectCount": 2 - } -} -``` - -**Spaced Repetition 알고리즘 (SM-2)** -- 정답 시: `interval` 증가 (1일 → 6일 → 이전간격 × easeFactor) -- 오답 시: `interval = 1`, `easeFactor` 감소 (최소 1.3) -- 5회 연속 정답 시 `MASTERED` 상태 - ---- - -### 3.3 단어 태그 변경 (북마크/즐겨찾기/난이도) -``` -PUT /vocab/users/{userId}/words/{wordId}/tag -``` - -**Request Body** -```json -{ - "bookmarked": true, - "favorite": false, - "difficulty": "HARD" -} -``` - -| 필드 | 타입 | 설명 | -|------|------|------| -| bookmarked | boolean | 북마크 여부 | -| favorite | boolean | 즐겨찾기 여부 | -| difficulty | string | `EASY`, `NORMAL`, `HARD` | - -**Response** -```json -{ - "success": true, - "message": "Tag updated", - "data": { - "wordId": "uuid", - "bookmarked": true, - "favorite": false, - "difficulty": "HARD" - } -} -``` - ---- - -## 4. 시험 API - -### 4.1 시험 시작 -``` -POST /vocab/test/{userId}/start -``` - -**Request Body** -```json -{ - "wordCount": 20, - "level": "BEGINNER", - "type": "KOREAN_TO_ENGLISH" -} -``` - -| 필드 | 타입 | 필수 | 설명 | -|------|------|------|------| -| wordCount | number | N | 문제 수 (기본: 20) | -| level | string | N | 출제 레벨 필터 | -| type | string | N | `KOREAN_TO_ENGLISH`, `ENGLISH_TO_KOREAN` | - -**Response** -```json -{ - "success": true, - "message": "Test started", - "data": { - "testId": "uuid", - "startedAt": "2024-01-07T12:00:00Z", - "wordCount": 20, - "questions": [ - { - "questionId": 1, - "wordId": "uuid", - "question": "사과", - "options": ["apple", "banana", "orange", "grape"], - "type": "KOREAN_TO_ENGLISH" - } - ] - } -} -``` - ---- - -### 4.2 답안 제출 -``` -POST /vocab/test/{userId}/submit -``` - -**Request Body** -```json -{ - "testId": "uuid", - "answers": [ - {"questionId": 1, "wordId": "uuid", "answer": "apple"}, - {"questionId": 2, "wordId": "uuid", "answer": "book"} - ] -} -``` - -**Response** -```json -{ - "success": true, - "message": "Test submitted", - "data": { - "testId": "uuid", - "totalQuestions": 20, - "correctCount": 18, - "incorrectCount": 2, - "successRate": 90.0, - "results": [ - { - "questionId": 1, - "wordId": "uuid", - "isCorrect": true, - "userAnswer": "apple", - "correctAnswer": "apple" - }, - { - "questionId": 5, - "wordId": "uuid", - "isCorrect": false, - "userAnswer": "banana", - "correctAnswer": "apple" - } - ], - "completedAt": "2024-01-07T12:15:00Z" - } -} -``` - ---- - -### 4.3 시험 결과 조회 -``` -GET /vocab/test/{userId}/results -``` - -**Query Parameters** -| 파라미터 | 타입 | 필수 | 설명 | -|---------|------|------|------| -| limit | number | N | 결과 개수 (기본: 20) | -| cursor | string | N | 페이지네이션 커서 | - -**Response** -```json -{ - "success": true, - "message": "Test results retrieved", - "data": { - "testResults": [ - { - "testId": "uuid", - "totalQuestions": 20, - "correctCount": 18, - "successRate": 90.0, - "completedAt": "2024-01-07T12:15:00Z" - } - ], - "nextCursor": null, - "hasMore": false - } -} -``` - ---- - -## 5. 통계 API - -### 5.1 전체 학습 통계 -``` -GET /vocab/stats/{userId} -``` - -**Response** -```json -{ - "success": true, - "message": "Stats retrieved", - "data": { - "totalWords": 150, - "wordStatusCounts": { - "NEW": 50, - "LEARNING": 40, - "REVIEWING": 35, - "MASTERED": 25 - }, - "totalCorrect": 500, - "totalIncorrect": 100, - "accuracy": 83.3, - "testCount": 15, - "avgSuccessRate": 85.5, - "studyDays": 30, - "completedDays": 25, - "completionRate": 83.3 - } -} -``` - ---- - -### 5.2 일별 학습 통계 -``` -GET /vocab/stats/{userId}/daily -``` - -**Query Parameters** -| 파라미터 | 타입 | 필수 | 설명 | -|---------|------|------|------| -| limit | number | N | 조회 일수 (기본: 30, 최대: 90) | - -**Response** -```json -{ - "success": true, - "message": "Daily stats retrieved", - "data": { - "dailyStats": [ - { - "date": "2024-01-07", - "totalWords": 55, - "learnedCount": 55, - "isCompleted": true, - "progress": 100.0 - }, - { - "date": "2024-01-06", - "totalWords": 55, - "learnedCount": 40, - "isCompleted": false, - "progress": 72.7 - } - ], - "nextCursor": null, - "hasMore": false - } -} -``` - ---- - -### 5.3 약점 분석 -``` -GET /vocab/stats/{userId}/weakness -``` - -**Response** -```json -{ - "success": true, - "message": "Weakness analysis completed", - "data": { - "weakestWords": [ - { - "wordId": "uuid", - "english": "hypothesis", - "korean": "가설", - "level": "ADVANCED", - "category": "ACADEMIC", - "incorrectCount": 5, - "correctCount": 2, - "accuracy": 28.6, - "status": "LEARNING" - } - ], - "categoryAnalysis": { - "DAILY": { - "totalCorrect": 200, - "totalIncorrect": 30, - "wordCount": 50, - "accuracy": 87.0 - }, - "BUSINESS": { - "totalCorrect": 150, - "totalIncorrect": 50, - "wordCount": 40, - "accuracy": 75.0 - }, - "ACADEMIC": { - "totalCorrect": 80, - "totalIncorrect": 40, - "wordCount": 30, - "accuracy": 66.7 - } - }, - "levelAnalysis": { - "BEGINNER": {"accuracy": 90.0, "wordCount": 50}, - "INTERMEDIATE": {"accuracy": 78.0, "wordCount": 40}, - "ADVANCED": {"accuracy": 65.0, "wordCount": 30} - }, - "suggestions": [ - "ACADEMIC 카테고리의 정확도가 66.7%로 가장 낮습니다. 집중 학습을 권장합니다.", - "ADVANCED 레벨의 정확도가 65.0%입니다. 이 레벨의 단어들을 더 복습해보세요.", - "자주 틀리는 단어 10개가 있습니다. 북마크하여 집중 복습하세요." - ] - } -} -``` - ---- - -## 6. 음성 API (TTS) - -### 6.1 단어 발음 듣기 -``` -POST /vocab/voice/synthesize -``` - -**Request Body** -```json -{ - "wordId": "uuid", - "text": "apple", - "voice": "FEMALE" -} -``` - -| 필드 | 타입 | 필수 | 설명 | -|------|------|------|------| -| wordId | string | Y | 단어 ID (캐시 키로 사용) | -| text | string | Y | 발음할 텍스트 | -| voice | string | N | `MALE` (Matthew), `FEMALE` (Joanna, 기본값) | - -**Response** -```json -{ - "success": true, - "message": "Speech synthesized", - "data": { - "audioUrl": "https://s3.ap-northeast-2.amazonaws.com/...", - "s3Key": "vocab/voice/uuid_female.mp3", - "cached": true - } -} -``` - -**참고**: -- `audioUrl`은 1시간 동안 유효한 Pre-signed URL입니다. -- 동일 단어+음성 조합은 S3에 캐시되어 재사용됩니다. - ---- - -## 에러 응답 형식 - -```json -{ - "success": false, - "error": "에러 메시지" -} -``` - -**HTTP 상태 코드** -| 코드 | 설명 | -|------|------| -| 200 | 성공 | -| 201 | 생성 성공 | -| 400 | 잘못된 요청 (필수 파라미터 누락 등) | -| 404 | 리소스 없음 | -| 500 | 서버 에러 | - ---- - -## 프론트엔드 구현 가이드 - -### 추천 화면 구성 - -1. **메인 대시보드** - - 오늘의 학습 진행률 (GET /vocab/daily/{userId}) - - 전체 통계 요약 (GET /vocab/stats/{userId}) - -2. **일일 학습 화면** - - 플래시카드 UI - - 정답/오답 버튼 → PUT /vocab/users/{userId}/words/{wordId} - - TTS 발음 듣기 버튼 → POST /vocab/voice/synthesize - -3. **시험 화면** - - 시험 시작 → POST /vocab/test/{userId}/start - - 4지선다 문제 표시 - - 답안 제출 → POST /vocab/test/{userId}/submit - - 결과 화면 (정답/오답 표시) - -4. **단어장 화면** - - 전체 단어 목록 (GET /vocab/words) - - 검색 기능 (GET /vocab/words/search) - - 북마크/즐겨찾기 토글 (PUT /vocab/users/{userId}/words/{wordId}/tag) - -5. **통계/분석 화면** - - 학습 달력 (일별 완료 여부) - - 약점 분석 차트 (GET /vocab/stats/{userId}/weakness) - - 레벨/카테고리별 정확도 그래프 - -### 상태 관리 추천 -- userId는 로그인 후 전역 상태로 관리 -- 일일 학습 단어 목록은 세션 캐시 -- 북마크/즐겨찾기는 낙관적 업데이트 - ---- - -## 테스트 데이터 - -현재 등록된 시드 데이터: -- **BEGINNER + DAILY**: 25개 (apple, book, cat, ...) -- **INTERMEDIATE + BUSINESS**: 25개 (achieve, benefit, ...) -- **ADVANCED + ACADEMIC**: 19개 (abstract, hypothesis, ...) - -총 **69개 단어** 등록됨 diff --git a/docs/chatting/CHATTING-GUIDE.md b/docs/chatting/CHATTING-GUIDE.md new file mode 100644 index 00000000..467a1c6c --- /dev/null +++ b/docs/chatting/CHATTING-GUIDE.md @@ -0,0 +1,944 @@ +# Chatting Server 가이드 문서 + +## 1. 개요 + +### 1.1 목적 + +Chatting Server는 영어 회화 학습 플랫폼의 실시간 채팅 기능을 담당하는 서버리스 마이크로서비스이다. 사용자들이 영어 난이도별 채팅방에 참여하여 실시간으로 대화하고, AI 응답 및 TTS 기능을 활용할 수 있다. + +### 1.2 주요 기능 + +| 기능 | 설명 | +|------|------| +| 채팅방 관리 | 생성, 조회, 입장, 퇴장, 삭제 | +| 실시간 메시징 | WebSocket 기반 양방향 통신 | +| 토큰 인증 | REST → WebSocket 전환 시 RoomToken 검증 | +| 난이도별 필터링 | BEGINNER, INTERMEDIATE, ADVANCED | +| AI 응답 | AWS Bedrock 기반 AI 메시지 생성 | +| TTS (음성 합성) | AWS Polly 기반 음성 변환 | +| 비밀방 | BCrypt 암호화 비밀번호 지원 | + +### 1.3 기술 스택 + +| 구분 | 기술 | +|------|------| +| Platform | AWS Lambda (Serverless) | +| Language | Java 21 (Eclipse Temurin) | +| Database | AWS DynamoDB (Single Table Design) | +| Real-time | API Gateway WebSocket | +| AI | AWS Bedrock (Claude/Llama) | +| TTS | AWS Polly | +| Storage | AWS S3 (음성 캐시) | + +--- + +## 2. 시스템 아키텍처 + +### 2.1 전체 구조 + +```mermaid +flowchart TB + subgraph Client + APP[Mobile App] + WEB[Web Client] + end + + subgraph AWS_Gateway["API Gateway"] + REST[HTTP API] + WS[WebSocket API] + end + + subgraph Lambda["AWS Lambda"] + ROOM_H[ChatRoomHandler] + MSG_H[ChatMessageHandler] + AI_H[ChatAIHandler] + VOICE_H[ChatVoiceHandler] + WS_CONN[WebSocketConnectHandler] + WS_MSG[WebSocketMessageHandler] + WS_DISC[WebSocketDisconnectHandler] + end + + subgraph Data_Layer["Data Layer"] + DYNAMO[(DynamoDB)] + S3[(S3 Bucket)] + end + + subgraph AWS_AI["AI Services"] + BEDROCK[AWS Bedrock] + POLLY[AWS Polly] + end + + APP --> REST + WEB --> REST + APP --> WS + WEB --> WS + + REST --> ROOM_H + REST --> MSG_H + REST --> AI_H + REST --> VOICE_H + + WS -->|$connect| WS_CONN + WS -->|sendMessage| WS_MSG + WS -->|$disconnect| WS_DISC + + ROOM_H --> DYNAMO + MSG_H --> DYNAMO + WS_CONN --> DYNAMO + WS_MSG --> DYNAMO + WS_DISC --> DYNAMO + + AI_H --> BEDROCK + VOICE_H --> POLLY + VOICE_H --> S3 +``` + +### 2.2 레이어 아키텍처 + +```mermaid +flowchart TB + subgraph Presentation["Presentation Layer"] + HANDLER[Lambda Handlers] + ROUTER[HandlerRouter] + DTO[Request/Response DTOs] + end + + subgraph Application["Application Layer (CQRS)"] + CMD[Command Services] + QRY[Query Services] + end + + subgraph Domain["Domain Layer"] + MODEL[Models] + REPO[Repositories] + end + + subgraph Infrastructure["Infrastructure Layer"] + DYNAMO_CLIENT[DynamoDB Enhanced Client] + S3_CLIENT[S3 Client] + BROADCASTER[WebSocket Broadcaster] + end + + HANDLER --> ROUTER + ROUTER --> CMD + ROUTER --> QRY + CMD --> MODEL + QRY --> MODEL + MODEL --> REPO + REPO --> DYNAMO_CLIENT + HANDLER --> BROADCASTER + BROADCASTER --> WS_API[API Gateway Management API] +``` + +### 2.3 채팅방 생성 흐름 + +```mermaid +sequenceDiagram + participant C as Client + participant GW as API Gateway + participant H as ChatRoomHandler + participant CMD as ChatRoomCommandService + participant REPO as ChatRoomRepository + participant DB as DynamoDB + + C->>GW: POST /rooms + Note over C,GW: {name, level, maxMembers, isPrivate, password} + GW->>H: Lambda Invoke + H->>H: Request Validation + H->>CMD: createRoom(...) + CMD->>CMD: Generate UUID + CMD->>CMD: BCrypt Hash Password (if private) + CMD->>CMD: Build ChatRoom Entity + CMD->>REPO: save(room) + REPO->>DB: PutItem + Note over REPO,DB: PK=ROOM#{roomId}
GSI1PK=ROOMS + DB-->>REPO: Success + REPO-->>CMD: ChatRoom + CMD-->>H: ChatRoom + H-->>GW: 201 Created + GW-->>C: {success: true, data: room} +``` + +### 2.4 채팅방 입장 및 WebSocket 연결 흐름 + +```mermaid +sequenceDiagram + participant C as Client + participant REST as REST API + participant WS as WebSocket API + participant JOIN_H as ChatRoomHandler + participant CONN_H as WebSocketConnectHandler + participant TOKEN_S as RoomTokenService + participant CMD as ChatRoomCommandService + participant CONN_REPO as ConnectionRepository + participant DB as DynamoDB + + Note over C,DB: Phase 1: REST API로 입장 및 토큰 발급 + C->>REST: POST /rooms/{roomId}/join + Note over C,REST: {userId, password?} + REST->>JOIN_H: Lambda Invoke + JOIN_H->>CMD: joinRoom(roomId, userId, password) + CMD->>CMD: Validate Password (BCrypt) + CMD->>CMD: Check Room Capacity + CMD->>CMD: Add User to memberIds + CMD->>TOKEN_S: generateToken(roomId, userId) + TOKEN_S->>DB: Save RoomToken (TTL: 5분) + Note over TOKEN_S,DB: PK=TOKEN#{token} + TOKEN_S-->>CMD: RoomToken + CMD-->>JOIN_H: JoinRoomResponse + JOIN_H-->>C: {room, roomToken, tokenExpiresAt} + + Note over C,DB: Phase 2: WebSocket 연결 (토큰 검증) + C->>WS: $connect?roomToken={token} + WS->>CONN_H: Lambda Invoke + CONN_H->>TOKEN_S: validateToken(token) + TOKEN_S->>DB: GetItem TOKEN#{token} + DB-->>TOKEN_S: RoomToken (or empty) + alt Token Valid + TOKEN_S-->>CONN_H: RoomToken + CONN_H->>CONN_H: Build Connection Entity + CONN_H->>CONN_REPO: save(connection) + CONN_REPO->>DB: PutItem + Note over CONN_REPO,DB: PK=CONN#{connId}
GSI1PK=ROOM#{roomId}
GSI2PK=USER#{userId} + CONN_H-->>C: 200 Connected + else Token Invalid/Expired + TOKEN_S-->>CONN_H: Empty + CONN_H-->>C: 401 Unauthorized + end +``` + +### 2.5 메시지 전송 및 브로드캐스트 흐름 + +```mermaid +sequenceDiagram + participant C as Client + participant WS as WebSocket API + participant MSG_H as WebSocketMessageHandler + participant MSG_S as ChatMessageService + participant ROOM_REPO as ChatRoomRepository + participant CONN_REPO as ConnectionRepository + participant BC as WebSocketBroadcaster + participant DB as DynamoDB + participant OTHERS as Other Clients + + C->>WS: sendMessage + Note over C,WS: {roomId, userId, content, messageType} + WS->>MSG_H: Lambda Invoke + MSG_H->>MSG_H: Parse Payload + MSG_H->>MSG_H: Build ChatMessage Entity + MSG_H->>MSG_S: saveMessage(message) + MSG_S->>DB: PutItem + Note over MSG_S,DB: PK=ROOM#{roomId}
SK=MSG#{timestamp}#{msgId} + MSG_H->>ROOM_REPO: updateLastMessageAt(roomId) + ROOM_REPO->>DB: UpdateItem + + MSG_H->>CONN_REPO: findByRoomId(roomId) + CONN_REPO->>DB: Query GSI1 (ROOM#{roomId}) + DB-->>CONN_REPO: List + CONN_REPO-->>MSG_H: Connections + + MSG_H->>BC: broadcast(connections, payload) + loop Each Connection + BC->>WS: PostToConnection + WS->>OTHERS: Push Message + alt Connection Failed + BC->>BC: Add to failedList + end + end + BC-->>MSG_H: failedConnections + + loop Each Failed Connection + MSG_H->>CONN_REPO: delete(connectionId) + Note over MSG_H,CONN_REPO: Cleanup stale connections + end + + MSG_H-->>C: 200 Message Sent +``` + +### 2.6 WebSocket 연결 해제 흐름 + +```mermaid +sequenceDiagram + participant C as Client + participant WS as WebSocket API + participant DISC_H as WebSocketDisconnectHandler + participant CONN_REPO as ConnectionRepository + participant DB as DynamoDB + + C->>WS: $disconnect + Note over C,WS: Connection closed + WS->>DISC_H: Lambda Invoke + DISC_H->>DISC_H: Extract connectionId + DISC_H->>CONN_REPO: delete(connectionId) + CONN_REPO->>DB: DeleteItem + Note over CONN_REPO,DB: PK=CONN#{connectionId} + DISC_H-->>WS: 200 OK +``` + +--- + +## 3. 데이터 모델 + +### 3.1 ERD (DynamoDB Single Table Design) + +```mermaid +erDiagram + ChatTable ||--o{ ChatRoom : contains + ChatTable ||--o{ ChatMessage : contains + ChatTable ||--o{ Connection : contains + ChatTable ||--o{ RoomToken : contains + + ChatRoom { + string partitionKey "ROOM#{roomId}" + string sortKey "METADATA" + string gsi1PartitionKey "ROOMS" + string gsi1SortKey "level#createdAt" + string roomId "UUID" + string name "방 이름" + string description "설명" + string level "BEGINNER/INTERMEDIATE/ADVANCED" + int currentMembers "현재 인원" + int maxMembers "최대 인원 (기본 6)" + boolean isPrivate "비밀방 여부" + string password "BCrypt 해시" + string createdBy "방장 userId" + string memberIds "참여자 목록" + string createdAt "생성 시각" + string lastMessageAt "마지막 메시지 시각" + } + + ChatMessage { + string partitionKey "ROOM#{roomId}" + string sortKey "MSG#{timestamp}#{messageId}" + string gsi1PartitionKey "USER#{userId}" + string gsi1SortKey "MSG#{timestamp}" + string gsi2PartitionKey "MSG#{messageId}" + string gsi2SortKey "ROOM#{roomId}" + string messageId "UUID" + string roomId "방 ID" + string userId "발신자 ID" + string content "메시지 내용" + string messageType "TEXT/IMAGE/VOICE/AI_RESPONSE" + string maleVoiceKey "S3 음성 키 (남성)" + string femaleVoiceKey "S3 음성 키 (여성)" + string createdAt "전송 시각" + } + + Connection { + string partitionKey "CONN#{connectionId}" + string sortKey "METADATA" + string gsi1PartitionKey "ROOM#{roomId}" + string gsi1SortKey "CONN#{connectionId}" + string gsi2PartitionKey "USER#{userId}" + string gsi2SortKey "CONN#{connectionId}" + string connectionId "WebSocket Connection ID" + string userId "사용자 ID" + string roomId "방 ID" + string connectedAt "연결 시각" + long ttl "자동 만료 (10분)" + } + + RoomToken { + string partitionKey "TOKEN#{token}" + string sortKey "METADATA" + string token "UUID 토큰" + string roomId "방 ID" + string userId "사용자 ID" + string createdAt "발급 시각" + long ttl "자동 만료 (5분)" + } +``` + +### 3.2 테이블 상세 + +#### ChatRoom (채팅방) + +| 필드 | 타입 | 필수 | 설명 | +|------|------|------|------| +| PK | String | Y | ROOM#{roomId} | +| SK | String | Y | METADATA | +| GSI1PK | String | Y | ROOMS (전체 조회용) | +| GSI1SK | String | Y | {level}#{createdAt} (정렬) | +| roomId | String | Y | UUID | +| name | String | Y | 채팅방 이름 | +| description | String | N | 설명 | +| level | String | Y | beginner, intermediate, advanced | +| currentMembers | Integer | Y | 현재 참여 인원 | +| maxMembers | Integer | Y | 최대 인원 (기본: 6) | +| isPrivate | Boolean | Y | 비밀방 여부 | +| password | String | N | BCrypt 해시 비밀번호 | +| createdBy | String | Y | 방장 userId | +| memberIds | List | Y | 참여자 userId 목록 | +| createdAt | String | Y | ISO 8601 형식 | +| lastMessageAt | String | Y | 마지막 메시지 시각 | + +#### ChatMessage (채팅 메시지) + +| 필드 | 타입 | 필수 | 설명 | +|------|------|------|------| +| PK | String | Y | ROOM#{roomId} | +| SK | String | Y | MSG#{timestamp}#{messageId} | +| GSI1PK | String | Y | USER#{userId} | +| GSI1SK | String | Y | MSG#{timestamp} | +| GSI2PK | String | Y | MSG#{messageId} | +| GSI2SK | String | Y | ROOM#{roomId} | +| messageId | String | Y | UUID | +| roomId | String | Y | 채팅방 ID | +| userId | String | Y | 발신자 ID | +| content | String | Y | 메시지 내용 | +| messageType | String | Y | TEXT, IMAGE, VOICE, AI_RESPONSE | +| maleVoiceKey | String | N | S3 음성 파일 키 (남성) | +| femaleVoiceKey | String | N | S3 음성 파일 키 (여성) | +| createdAt | String | Y | ISO 8601 형식 | + +#### Connection (WebSocket 연결) + +| 필드 | 타입 | 필수 | 설명 | +|------|------|------|------| +| PK | String | Y | CONN#{connectionId} | +| SK | String | Y | METADATA | +| GSI1PK | String | Y | ROOM#{roomId} | +| GSI1SK | String | Y | CONN#{connectionId} | +| GSI2PK | String | Y | USER#{userId} | +| GSI2SK | String | Y | CONN#{connectionId} | +| connectionId | String | Y | API Gateway Connection ID | +| userId | String | Y | 사용자 ID | +| roomId | String | Y | 채팅방 ID | +| connectedAt | String | Y | 연결 시각 | +| ttl | Long | Y | DynamoDB TTL (10분 후 자동 삭제) | + +#### RoomToken (입장 토큰) + +| 필드 | 타입 | 필수 | 설명 | +|------|------|------|------| +| PK | String | Y | TOKEN#{token} | +| SK | String | Y | METADATA | +| token | String | Y | UUID 토큰 | +| roomId | String | Y | 채팅방 ID | +| userId | String | Y | 사용자 ID | +| createdAt | String | Y | 발급 시각 | +| ttl | Long | Y | DynamoDB TTL (5분 후 자동 삭제) | + +### 3.3 GSI (Global Secondary Index) 설계 + +```mermaid +flowchart LR + subgraph GSI1["GSI1: 범용 조회"] + direction TB + G1_ROOMS["ROOMS → 전체 방 조회"] + G1_USER["USER#{userId} → 사용자 메시지"] + G1_ROOM_CONN["ROOM#{roomId} → 방별 연결"] + G1_USER_REVIEW["USER#{userId}#REVIEW → 복습 예정"] + end + + subgraph GSI2["GSI2: 보조 조회"] + direction TB + G2_MSG["MSG#{messageId} → 메시지 직접 조회"] + G2_USER_CONN["USER#{userId} → 사용자 연결"] + G2_USER_STATUS["USER#{userId}#STATUS → 상태별"] + end +``` + +--- + +## 4. API 명세 + +### 4.1 채팅방 생성 + +#### POST /rooms + +**Request** + +```json +{ + "name": "English Beginners", + "description": "영어 초보자를 위한 채팅방", + "level": "beginner", + "maxMembers": 6, + "isPrivate": false, + "password": null, + "createdBy": "user123" +} +``` + +**Response (201 Created)** + +```json +{ + "success": true, + "message": "Room created", + "data": { + "roomId": "550e8400-e29b-41d4-a716-446655440000", + "name": "English Beginners", + "description": "영어 초보자를 위한 채팅방", + "level": "beginner", + "currentMembers": 1, + "maxMembers": 6, + "isPrivate": false, + "createdBy": "user123", + "memberIds": ["user123"], + "createdAt": "2026-01-09T10:00:00Z", + "lastMessageAt": "2026-01-09T10:00:00Z" + } +} +``` + +### 4.2 채팅방 목록 조회 + +#### GET /rooms + +**Query Parameters** + +| 파라미터 | 타입 | 필수 | 설명 | +|---------|------|------|------| +| level | String | N | 난이도 필터 (beginner, intermediate, advanced) | +| userId | String | N | 사용자 ID (joined 필터 시 필수) | +| joined | String | N | "true"면 가입된 방만 조회 | +| cursor | String | N | 페이징 커서 | +| limit | Integer | N | 페이지 크기 (기본: 10, 최대: 20) | + +**Response (200 OK)** + +```json +{ + "success": true, + "message": "Rooms retrieved", + "data": { + "rooms": [ + { + "roomId": "...", + "name": "English Beginners", + "level": "beginner", + "currentMembers": 3, + "maxMembers": 6, + "isPrivate": false, + "lastMessageAt": "2026-01-09T10:30:00Z" + } + ], + "nextCursor": "eyJQSyI6IlJPT00jLi4uIiwiU0siOiJNRVRBREFUQSJ9", + "hasMore": true + } +} +``` + +### 4.3 채팅방 상세 조회 + +#### GET /rooms/{roomId} + +**Response (200 OK)** + +```json +{ + "success": true, + "message": "Room retrieved", + "data": { + "roomId": "550e8400-e29b-41d4-a716-446655440000", + "name": "English Beginners", + "description": "영어 초보자를 위한 채팅방", + "level": "beginner", + "currentMembers": 3, + "maxMembers": 6, + "isPrivate": false, + "createdBy": "user123", + "memberIds": ["user123", "user456", "user789"], + "createdAt": "2026-01-09T10:00:00Z", + "lastMessageAt": "2026-01-09T10:30:00Z" + } +} +``` + +### 4.4 채팅방 입장 + +#### POST /rooms/{roomId}/join + +**Request** + +```json +{ + "userId": "user456", + "password": "secret123" +} +``` + +**Response (200 OK)** + +```json +{ + "success": true, + "message": "Joined room", + "data": { + "room": { + "roomId": "...", + "name": "English Beginners", + "currentMembers": 4 + }, + "roomToken": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "tokenExpiresAt": 1704793800 + } +} +``` + +### 4.5 채팅방 퇴장 + +#### POST /rooms/{roomId}/leave + +**Request** + +```json +{ + "userId": "user456" +} +``` + +**Response (200 OK)** + +```json +{ + "success": true, + "message": "Left room", + "data": { + "roomId": "...", + "currentMembers": 3 + } +} +``` + +### 4.6 채팅방 삭제 + +#### DELETE /rooms/{roomId}?userId={userId} + +**Response (200 OK)** + +```json +{ + "success": true, + "message": "Room deleted", + "data": null +} +``` + +### 4.7 WebSocket 엔드포인트 + +#### $connect + +**Query Parameter** + +| 파라미터 | 타입 | 필수 | 설명 | +|---------|------|------|------| +| roomToken | String | Y | joinRoom에서 발급받은 토큰 | + +**연결 URL 예시** + +``` +wss://api.example.com/ws?roomToken=a1b2c3d4-e5f6-7890-abcd-ef1234567890 +``` + +#### sendMessage (Action) + +**Payload** + +```json +{ + "action": "sendMessage", + "roomId": "550e8400-e29b-41d4-a716-446655440000", + "userId": "user456", + "content": "Hello everyone!", + "messageType": "TEXT" +} +``` + +**Broadcast Payload (수신)** + +```json +{ + "messageId": "msg-uuid-here", + "roomId": "550e8400-e29b-41d4-a716-446655440000", + "userId": "user456", + "content": "Hello everyone!", + "messageType": "TEXT", + "createdAt": "2026-01-09T10:35:00Z" +} +``` + +--- + +## 5. 비즈니스 규칙 + +### 5.1 채팅방 상태 전이 + +```mermaid +stateDiagram-v2 + [*] --> CREATED: 방 생성 + CREATED --> ACTIVE: 첫 메시지 + ACTIVE --> ACTIVE: 메시지 송수신 + ACTIVE --> EMPTY: 모든 멤버 퇴장 + ACTIVE --> DELETED: 방장 삭제 + EMPTY --> DELETED: 자동 삭제 + DELETED --> [*] +``` + +### 5.2 토큰 상태 전이 + +```mermaid +stateDiagram-v2 + [*] --> ISSUED: joinRoom 호출 + ISSUED --> VALIDATED: WebSocket 연결 성공 + ISSUED --> EXPIRED: TTL 만료 (5분) + VALIDATED --> [*]: 토큰 사용 완료 + EXPIRED --> [*]: 자동 삭제 +``` + +### 5.3 접근 제어 + +| 기능 | 조건 | +|------|------| +| 방 생성 | 모든 사용자 | +| 방 조회 | 모든 사용자 | +| 방 입장 | 비밀방인 경우 비밀번호 필요 | +| 방 퇴장 | 참여 멤버만 | +| 방 삭제 | 방장(createdBy)만 | +| WebSocket 연결 | 유효한 roomToken 필요 | + +### 5.4 비밀번호 처리 + +```mermaid +flowchart LR + A[Plain Password] -->|BCrypt.hashpw| B[Hashed Password] + B -->|저장| DB[(DynamoDB)] + + C[입력 Password] -->|BCrypt.checkpw| D{일치?} + DB -->|조회| D + D -->|Yes| E[입장 허용] + D -->|No| F[403 Forbidden] +``` + +### 5.5 제한 사항 + +| 항목 | 제한 | +|------|------| +| 최대 참여 인원 | 기본 6명, 최대 설정 가능 | +| 방 목록 페이지 크기 | 최대 20 | +| RoomToken 유효 시간 | 5분 (300초) | +| Connection TTL | 10분 (600초) | +| 비밀번호 | BCrypt 해시 | + +--- + +## 6. 에러 코드 + +### 6.1 HTTP 에러 + +| HTTP Code | 설명 | 예시 | +|-----------|------|------| +| 400 | 잘못된 요청 | 필수 파라미터 누락 | +| 401 | 인증 실패 | 유효하지 않은 토큰 | +| 403 | 권한 없음 | 비밀번호 불일치, 방장 아님 | +| 404 | 리소스 없음 | 존재하지 않는 방 | +| 409 | 충돌 | 정원 초과 | +| 500 | 서버 오류 | 내부 오류 | + +### 6.2 에러 응답 형식 + +```json +{ + "success": false, + "error": "Room not found" +} +``` + +--- + +## 7. 환경 설정 + +### 7.1 환경 변수 (template.yaml) + +```yaml +Environment: + Variables: + CHAT_TABLE_NAME: ChatTable + CHAT_BUCKET_NAME: group2-englishstudy + ROOM_TOKEN_TTL_SECONDS: "300" + AWS_REGION_NAME: ap-northeast-2 +``` + +### 7.2 DynamoDB 테이블 설정 + +```yaml +ChatTable: + Type: AWS::DynamoDB::Table + Properties: + TableName: ChatTable + BillingMode: PAY_PER_REQUEST + AttributeDefinitions: + - AttributeName: PK + AttributeType: S + - AttributeName: SK + AttributeType: S + - AttributeName: GSI1PK + AttributeType: S + - AttributeName: GSI1SK + AttributeType: S + - AttributeName: GSI2PK + AttributeType: S + - AttributeName: GSI2SK + AttributeType: S + KeySchema: + - AttributeName: PK + KeyType: HASH + - AttributeName: SK + KeyType: RANGE + GlobalSecondaryIndexes: + - IndexName: GSI1 + KeySchema: + - AttributeName: GSI1PK + KeyType: HASH + - AttributeName: GSI1SK + KeyType: RANGE + Projection: + ProjectionType: ALL + - IndexName: GSI2 + KeySchema: + - AttributeName: GSI2PK + KeyType: HASH + - AttributeName: GSI2SK + KeyType: RANGE + Projection: + ProjectionType: ALL + TimeToLiveSpecification: + AttributeName: ttl + Enabled: true +``` + +### 7.3 API Gateway WebSocket 설정 + +```yaml +WebSocketApi: + Type: AWS::ApiGatewayV2::Api + Properties: + Name: ChatWebSocketApi + ProtocolType: WEBSOCKET + RouteSelectionExpression: "$request.body.action" + +Routes: + - $connect → WebSocketConnectHandler + - $disconnect → WebSocketDisconnectHandler + - sendMessage → WebSocketMessageHandler +``` + +--- + +## 8. 프로젝트 구조 + +``` +domain/chatting/ +├── handler/ +│ ├── ChatRoomHandler.java # REST API - 채팅방 CRUD +│ ├── ChatMessageHandler.java # REST API - 메시지 조회 +│ ├── ChatAIHandler.java # REST API - AI 응답 +│ ├── ChatVoiceHandler.java # REST API - TTS +│ └── websocket/ +│ ├── WebSocketConnectHandler.java # $connect +│ ├── WebSocketMessageHandler.java # sendMessage +│ └── WebSocketDisconnectHandler.java # $disconnect +│ +├── service/ +│ ├── ChatRoomCommandService.java # 방 변경 (CQRS Command) +│ ├── ChatRoomQueryService.java # 방 조회 (CQRS Query) +│ ├── ChatMessageService.java # 메시지 저장/조회 +│ ├── RoomTokenService.java # 토큰 발급/검증 +│ └── BedrockService.java # AI 응답 생성 +│ +├── repository/ +│ ├── ChatRoomRepository.java # 채팅방 데이터 접근 +│ ├── ChatMessageRepository.java # 메시지 데이터 접근 +│ ├── ConnectionRepository.java # WebSocket 연결 데이터 접근 +│ └── RoomTokenRepository.java # 토큰 데이터 접근 +│ +├── model/ +│ ├── ChatRoom.java # 채팅방 엔티티 +│ ├── ChatMessage.java # 메시지 엔티티 +│ ├── Connection.java # 연결 엔티티 +│ └── RoomToken.java # 토큰 엔티티 +│ +└── dto/ + ├── request/ + │ ├── CreateRoomRequest.java + │ ├── JoinRoomRequest.java + │ ├── LeaveRoomRequest.java + │ └── SendMessageRequest.java + └── response/ + └── JoinRoomResponse.java +``` + +--- + +## 9. 테스트 + +### 9.1 테스트 시나리오 + +```mermaid +flowchart TB + subgraph Unit["단위 테스트"] + U1[Service Layer] + U2[Repository Layer] + U3[Model Layer] + end + + subgraph Integration["통합 테스트"] + I1[Handler + Service] + I2[WebSocket Flow] + I3[DynamoDB Integration] + end + + subgraph E2E["E2E 테스트"] + E1[방 생성 → 입장 → 메시지 → 퇴장] + E2[WebSocket 연결 → 브로드캐스트] + end +``` + +### 9.2 로컬 테스트 + +```bash +# SAM Local 실행 +sam local start-api + +# WebSocket 테스트 (wscat) +wscat -c "wss://localhost:3001?roomToken=test-token" +``` + +--- + +## 10. 구현 현황 + +### Phase 1 - 핵심 기능 (완료) + +- [x] 채팅방 CRUD +- [x] REST API Handler +- [x] CQRS 패턴 적용 +- [x] 커서 기반 페이징 + +### Phase 2 - 실시간 통신 (완료) + +- [x] WebSocket 연결/해제 +- [x] 메시지 브로드캐스트 +- [x] RoomToken 인증 +- [x] Connection 관리 + +### Phase 3 - 고급 기능 (완료) + +- [x] 비밀방 (BCrypt) +- [x] 난이도별 필터링 +- [x] AI 응답 (Bedrock) +- [x] TTS (Polly) + +### Phase 4 - 최적화 (진행 중) + +- [ ] 연결 상태 모니터링 +- [ ] 메시지 캐싱 +- [ ] 알림 기능 (SNS) + +--- + +**버전**: 1.0.0 +**최종 업데이트**: 2026-01-09 +**팀**: MZC 2nd Project Team diff --git a/docs/vocabulary/VOCABULARY-GUIDE.md b/docs/vocabulary/VOCABULARY-GUIDE.md new file mode 100644 index 00000000..61829dc5 --- /dev/null +++ b/docs/vocabulary/VOCABULARY-GUIDE.md @@ -0,0 +1,1247 @@ +# Vocabulary Server 가이드 문서 + +## 1. 개요 + +### 1.1 목적 + +Vocabulary Server는 영어 단어 학습 플랫폼의 단어 관리 및 학습 기능을 담당하는 서버리스 마이크로서비스이다. Spaced Repetition 알고리즘을 활용한 효율적인 단어 암기, 시험 기능, 일일 학습 추적 등을 제공한다. + +### 1.2 주요 기능 + +| 기능 | 설명 | +|------|------| +| 단어 관리 | CRUD, 배치 생성/조회, 검색 | +| 사용자 학습 | Spaced Repetition 기반 복습 스케줄 | +| 시험 기능 | DAILY, WEEKLY, CUSTOM 테스트 | +| 일일 학습 | 학습 기록 및 진도 추적 | +| 통계 | 학습 통계 및 성취도 분석 | +| TTS | AWS Polly 기반 발음 듣기 | +| 단어 그룹 | 카테고리/레벨별 그룹화 | + +### 1.3 기술 스택 + +| 구분 | 기술 | +|------|------| +| Platform | AWS Lambda (Serverless) | +| Language | Java 21 (Eclipse Temurin) | +| Database | AWS DynamoDB (Single Table Design) | +| TTS | AWS Polly | +| Storage | AWS S3 (음성 캐시) | +| Algorithm | SM-2 기반 Spaced Repetition | + +--- + +## 2. 시스템 아키텍처 + +### 2.1 전체 구조 + +```mermaid +flowchart TB + subgraph Client + APP[Mobile App] + WEB[Web Client] + end + + subgraph AWS_Gateway["API Gateway"] + REST[HTTP API] + end + + subgraph Lambda["AWS Lambda"] + WORD_H[WordHandler] + UWORD_H[UserWordHandler] + TEST_H[TestHandler] + DAILY_H[DailyStudyHandler] + STATS_H[StatisticsHandler] + VOICE_H[VoiceHandler] + GROUP_H[WordGroupHandler] + end + + subgraph Data_Layer["Data Layer"] + DYNAMO[(DynamoDB)] + S3[(S3 Bucket)] + end + + subgraph AWS_AI["AI Services"] + POLLY[AWS Polly] + end + + APP --> REST + WEB --> REST + + REST --> WORD_H + REST --> UWORD_H + REST --> TEST_H + REST --> DAILY_H + REST --> STATS_H + REST --> VOICE_H + REST --> GROUP_H + + WORD_H --> DYNAMO + UWORD_H --> DYNAMO + TEST_H --> DYNAMO + DAILY_H --> DYNAMO + STATS_H --> DYNAMO + GROUP_H --> DYNAMO + + VOICE_H --> POLLY + VOICE_H --> S3 +``` + +### 2.2 레이어 아키텍처 + +```mermaid +flowchart TB + subgraph Presentation["Presentation Layer"] + HANDLER[Lambda Handlers] + ROUTER[HandlerRouter] + DTO[Request/Response DTOs] + end + + subgraph Application["Application Layer (CQRS)"] + CMD[Command Services] + QRY[Query Services] + ALGO[Spaced Repetition Algorithm] + end + + subgraph Domain["Domain Layer"] + MODEL[Models] + REPO[Repositories] + end + + subgraph Infrastructure["Infrastructure Layer"] + DYNAMO_CLIENT[DynamoDB Enhanced Client] + S3_CLIENT[S3 Client] + POLLY_CLIENT[Polly Client] + end + + HANDLER --> ROUTER + ROUTER --> CMD + ROUTER --> QRY + CMD --> ALGO + CMD --> MODEL + QRY --> MODEL + MODEL --> REPO + REPO --> DYNAMO_CLIENT +``` + +### 2.3 단어 학습 흐름 (Spaced Repetition) + +```mermaid +sequenceDiagram + participant C as Client + participant H as UserWordHandler + participant CMD as UserWordCommandService + participant REPO as UserWordRepository + participant DB as DynamoDB + + C->>H: POST /user-words/{wordId}/review + Note over C,H: {userId, isCorrect: true/false} + H->>CMD: updateUserWord(userId, wordId, isCorrect) + + alt UserWord 없음 + CMD->>CMD: Create new UserWord + Note over CMD: status=NEW, interval=1, easeFactor=2.5 + end + + CMD->>CMD: applySpacedRepetition(userWord, isCorrect) + + alt isCorrect = true + CMD->>CMD: repetitions++ + CMD->>CMD: Calculate new interval + Note over CMD: interval = interval * easeFactor + CMD->>CMD: Update status + Note over CMD: LEARNING → REVIEWING → MASTERED + else isCorrect = false + CMD->>CMD: Reset repetitions to 0 + CMD->>CMD: interval = 1 + CMD->>CMD: Decrease easeFactor + Note over CMD: easeFactor = max(1.3, easeFactor - 0.2) + CMD->>CMD: status = LEARNING + end + + CMD->>CMD: Calculate nextReviewAt + Note over CMD: today + interval days + CMD->>CMD: Update GSI keys + CMD->>REPO: save(userWord) + REPO->>DB: PutItem + CMD-->>H: UserWord + H-->>C: 200 OK +``` + +### 2.4 시험 흐름 + +```mermaid +sequenceDiagram + participant C as Client + participant H as TestHandler + participant CMD as TestCommandService + participant QRY as WordQueryService + participant REPO as TestResultRepository + participant DB as DynamoDB + + Note over C,DB: Phase 1: 시험 시작 + C->>H: POST /tests/start + Note over C,H: {userId, testType, wordCount} + H->>CMD: startTest(userId, testType, wordCount) + CMD->>QRY: getRandomWords(wordCount) + QRY->>DB: Query Words + DB-->>QRY: Words + QRY-->>CMD: Word List + CMD-->>H: {testId, words} + H-->>C: 200 OK (시험 문제) + + Note over C,DB: Phase 2: 시험 제출 + C->>H: POST /tests/{testId}/submit + Note over C,H: {userId, answers: [{wordId, answer}]} + H->>CMD: submitTest(testId, userId, answers) + CMD->>CMD: Grade answers + CMD->>CMD: Calculate successRate + CMD->>CMD: Build TestResult + Note over CMD: totalQuestions, correctAnswers
incorrectWordIds, successRate + CMD->>REPO: save(testResult) + REPO->>DB: PutItem + Note over REPO,DB: PK=TEST#{userId}
SK=RESULT#{timestamp} + CMD-->>H: TestResult + H-->>C: 200 OK (결과) +``` + +### 2.5 일일 학습 흐름 + +```mermaid +sequenceDiagram + participant C as Client + participant H as DailyStudyHandler + participant CMD as DailyStudyCommandService + participant QRY as DailyStudyQueryService + participant REPO as DailyStudyRepository + participant DB as DynamoDB + + C->>H: POST /daily-study/record + Note over C,H: {userId, wordId, isCorrect, studyType} + H->>CMD: recordStudy(...) + + CMD->>QRY: getTodayStudy(userId) + QRY->>DB: Query by date + + alt 오늘 기록 없음 + CMD->>CMD: Create new DailyStudy + Note over CMD: date=today, wordsStudied=0 + end + + CMD->>CMD: Update statistics + Note over CMD: wordsStudied++, correct/incorrect count + CMD->>REPO: save(dailyStudy) + REPO->>DB: PutItem + CMD-->>H: DailyStudy + H-->>C: 200 OK +``` + +### 2.6 TTS 음성 생성 흐름 + +```mermaid +sequenceDiagram + participant C as Client + participant H as VoiceHandler + participant S as VoiceService + participant REPO as WordRepository + participant POLLY as AWS Polly + participant S3 as S3 Bucket + participant DB as DynamoDB + + C->>H: POST /voice/synthesize + Note over C,H: {wordId, text, voice: "male"/"female"} + H->>S: synthesize(wordId, text, voice) + + S->>REPO: findById(wordId) + REPO->>DB: GetItem + DB-->>REPO: Word + + alt 캐시된 음성 있음 + REPO-->>S: Word (with voiceKey) + S->>S3: GetObject(voiceKey) + S3-->>S: Audio Data + S-->>H: Cached Audio URL + else 캐시 없음 + S->>POLLY: SynthesizeSpeech + Note over S,POLLY: Engine: neural
Voice: Matthew/Joanna + POLLY-->>S: Audio Stream + S->>S3: PutObject + Note over S,S3: Key: vocab/voice/{wordId}_{voice}.mp3 + S3-->>S: Upload Success + S->>REPO: Update voiceKey + REPO->>DB: UpdateItem + S-->>H: New Audio URL + end + + H-->>C: 200 OK (audioUrl) +``` + +--- + +## 3. 데이터 모델 + +### 3.1 ERD (DynamoDB Single Table Design) + +```mermaid +erDiagram + VocabTable ||--o{ Word : contains + VocabTable ||--o{ UserWord : contains + VocabTable ||--o{ TestResult : contains + VocabTable ||--o{ DailyStudy : contains + VocabTable ||--o{ WordGroup : contains + + Word { + string partitionKey "WORD#{wordId}" + string sortKey "METADATA" + string gsi1PartitionKey "LEVEL#{level}" + string gsi1SortKey "WORD#{wordId}" + string gsi2PartitionKey "CATEGORY#{category}" + string gsi2SortKey "WORD#{wordId}" + string wordId "UUID" + string english "영어 단어" + string korean "한국어 뜻" + string example "예문" + string level "BEGINNER/INTERMEDIATE/ADVANCED" + string category "DAILY/BUSINESS/ACADEMIC" + string maleVoiceKey "S3 음성 키 (남성)" + string femaleVoiceKey "S3 음성 키 (여성)" + string createdAt "생성 시각" + } + + UserWord { + string partitionKey "USER#{userId}" + string sortKey "WORD#{wordId}" + string gsi1PartitionKey "USER#{userId}#REVIEW" + string gsi1SortKey "DATE#{nextReviewAt}" + string gsi2PartitionKey "USER#{userId}#STATUS" + string gsi2SortKey "STATUS#{status}" + string userId "사용자 ID" + string wordId "단어 ID" + string status "NEW/LEARNING/REVIEWING/MASTERED" + int interval "복습 간격 (일)" + double easeFactor "난이도 계수" + int repetitions "연속 정답 횟수" + string nextReviewAt "다음 복습일" + string lastReviewedAt "마지막 복습일" + int correctCount "정답 횟수" + int incorrectCount "오답 횟수" + boolean bookmarked "북마크" + boolean favorite "즐겨찾기" + string difficulty "사용자 난이도" + } + + TestResult { + string partitionKey "TEST#{userId}" + string sortKey "RESULT#{timestamp}" + string gsi1PartitionKey "TEST#ALL" + string gsi1SortKey "DATE#{date}" + string testId "UUID" + string userId "사용자 ID" + string testType "DAILY/WEEKLY/CUSTOM" + int totalQuestions "총 문제 수" + int correctAnswers "정답 수" + double successRate "성공률" + string incorrectWordIds "오답 단어 목록" + string startedAt "시작 시각" + string completedAt "완료 시각" + } + + DailyStudy { + string partitionKey "DAILY#{userId}" + string sortKey "DATE#{date}" + string gsi1PartitionKey "DAILY#ALL" + string gsi1SortKey "DATE#{date}" + string userId "사용자 ID" + string date "학습 날짜" + int wordsStudied "학습 단어 수" + int correctCount "정답 수" + int incorrectCount "오답 수" + int studyTimeMinutes "학습 시간 (분)" + string wordIds "학습한 단어 목록" + } + + WordGroup { + string partitionKey "GROUP#{groupId}" + string sortKey "METADATA" + string gsi1PartitionKey "USER#{userId}" + string gsi1SortKey "GROUP#{groupId}" + string groupId "UUID" + string userId "생성자 ID" + string name "그룹명" + string description "설명" + string wordIds "포함 단어 목록" + string createdAt "생성 시각" + } +``` + +### 3.2 테이블 상세 + +#### Word (단어) + +| 필드 | 타입 | 필수 | 설명 | +|------|------|------|------| +| PK | String | Y | WORD#{wordId} | +| SK | String | Y | METADATA | +| GSI1PK | String | Y | LEVEL#{level} | +| GSI1SK | String | Y | WORD#{wordId} | +| GSI2PK | String | Y | CATEGORY#{category} | +| GSI2SK | String | Y | WORD#{wordId} | +| wordId | String | Y | UUID | +| english | String | Y | 영어 단어 | +| korean | String | Y | 한국어 뜻 | +| example | String | N | 예문 | +| level | String | Y | BEGINNER, INTERMEDIATE, ADVANCED | +| category | String | Y | DAILY, BUSINESS, ACADEMIC | +| maleVoiceKey | String | N | S3 음성 파일 키 (남성) | +| femaleVoiceKey | String | N | S3 음성 파일 키 (여성) | +| maleExampleVoiceKey | String | N | S3 예문 음성 키 (남성) | +| femaleExampleVoiceKey | String | N | S3 예문 음성 키 (여성) | +| createdAt | String | Y | ISO 8601 형식 | + +#### UserWord (사용자 학습 상태) + +| 필드 | 타입 | 필수 | 설명 | +|------|------|------|------| +| PK | String | Y | USER#{userId} | +| SK | String | Y | WORD#{wordId} | +| GSI1PK | String | Y | USER#{userId}#REVIEW | +| GSI1SK | String | Y | DATE#{nextReviewAt} | +| GSI2PK | String | Y | USER#{userId}#STATUS | +| GSI2SK | String | Y | STATUS#{status} | +| userId | String | Y | 사용자 ID | +| wordId | String | Y | 단어 ID | +| status | String | Y | NEW, LEARNING, REVIEWING, MASTERED | +| interval | Integer | Y | 복습 간격 (일) | +| easeFactor | Double | Y | 난이도 계수 (기본: 2.5) | +| repetitions | Integer | Y | 연속 정답 횟수 | +| nextReviewAt | String | N | 다음 복습 예정일 | +| lastReviewedAt | String | N | 마지막 복습일 | +| correctCount | Integer | Y | 총 정답 횟수 | +| incorrectCount | Integer | Y | 총 오답 횟수 | +| bookmarked | Boolean | N | 북마크 여부 | +| favorite | Boolean | N | 즐겨찾기 여부 | +| difficulty | String | N | EASY, NORMAL, HARD | +| createdAt | String | Y | 생성 시각 | +| updatedAt | String | Y | 수정 시각 | + +#### TestResult (시험 결과) + +| 필드 | 타입 | 필수 | 설명 | +|------|------|------|------| +| PK | String | Y | TEST#{userId} | +| SK | String | Y | RESULT#{timestamp} | +| GSI1PK | String | Y | TEST#ALL | +| GSI1SK | String | Y | DATE#{date} | +| testId | String | Y | UUID | +| userId | String | Y | 사용자 ID | +| testType | String | Y | DAILY, WEEKLY, CUSTOM | +| totalQuestions | Integer | Y | 총 문제 수 | +| correctAnswers | Integer | Y | 정답 수 | +| incorrectAnswers | Integer | Y | 오답 수 | +| successRate | Double | Y | 성공률 (%) | +| incorrectWordIds | List | N | 오답 단어 ID 목록 | +| startedAt | String | Y | 시험 시작 시각 | +| completedAt | String | Y | 시험 완료 시각 | + +### 3.3 Spaced Repetition 알고리즘 + +```mermaid +flowchart TB + subgraph Input + A[학습 결과 입력] + B{정답?} + end + + subgraph Correct["정답 처리"] + C1[repetitions++] + C2{repetitions} + C3[interval = 1] + C4[interval = 6] + C5["interval = interval × easeFactor"] + C6{repetitions >= 5?} + C7[status = MASTERED] + C8{repetitions >= 2?} + C9[status = REVIEWING] + C10[status = LEARNING] + end + + subgraph Incorrect["오답 처리"] + I1[repetitions = 0] + I2[interval = 1] + I3["easeFactor = max(1.3, easeFactor - 0.2)"] + I4[status = LEARNING] + end + + subgraph Calculate + N[nextReviewAt = today + interval] + end + + A --> B + B -->|Yes| C1 + B -->|No| I1 + + C1 --> C2 + C2 -->|= 1| C3 + C2 -->|= 2| C4 + C2 -->|>= 3| C5 + C3 --> C6 + C4 --> C6 + C5 --> C6 + + C6 -->|Yes| C7 + C6 -->|No| C8 + C8 -->|Yes| C9 + C8 -->|No| C10 + + I1 --> I2 + I2 --> I3 + I3 --> I4 + + C7 --> N + C9 --> N + C10 --> N + I4 --> N +``` + +### 3.4 학습 상태 전이 + +```mermaid +stateDiagram-v2 + [*] --> NEW: 단어 추가 + NEW --> LEARNING: 첫 학습 + LEARNING --> LEARNING: 오답 + LEARNING --> REVIEWING: 2회 연속 정답 + REVIEWING --> LEARNING: 오답 + REVIEWING --> REVIEWING: 정답 (rep < 5) + REVIEWING --> MASTERED: 5회 연속 정답 + MASTERED --> LEARNING: 오답 + MASTERED --> MASTERED: 정답 (유지) +``` + +--- + +## 4. API 명세 + +### 4.1 단어 생성 + +#### POST /words + +**Request** + +```json +{ + "english": "perseverance", + "korean": "인내, 끈기", + "example": "Success requires perseverance.", + "level": "ADVANCED", + "category": "DAILY" +} +``` + +**Response (201 Created)** + +```json +{ + "success": true, + "message": "Word created", + "data": { + "wordId": "550e8400-e29b-41d4-a716-446655440000", + "english": "perseverance", + "korean": "인내, 끈기", + "example": "Success requires perseverance.", + "level": "ADVANCED", + "category": "DAILY", + "createdAt": "2026-01-09T10:00:00Z" + } +} +``` + +### 4.2 단어 목록 조회 + +#### GET /words + +**Query Parameters** + +| 파라미터 | 타입 | 필수 | 설명 | +|---------|------|------|------| +| level | String | N | 난이도 필터 | +| category | String | N | 카테고리 필터 | +| cursor | String | N | 페이징 커서 | +| limit | Integer | N | 페이지 크기 (기본: 20, 최대: 50) | + +**Response (200 OK)** + +```json +{ + "success": true, + "message": "Words retrieved", + "data": { + "words": [ + { + "wordId": "...", + "english": "perseverance", + "korean": "인내, 끈기", + "level": "ADVANCED", + "category": "DAILY" + } + ], + "nextCursor": "eyJQSyI6IldPUkQjLi4uIn0=", + "hasMore": true + } +} +``` + +### 4.3 단어 검색 + +#### GET /words/search + +**Query Parameters** + +| 파라미터 | 타입 | 필수 | 설명 | +|---------|------|------|------| +| q | String | Y | 검색어 (영어/한국어) | +| cursor | String | N | 페이징 커서 | +| limit | Integer | N | 페이지 크기 (기본: 20) | + +**Response (200 OK)** + +```json +{ + "success": true, + "message": "Search completed", + "data": { + "words": [...], + "query": "perseverance", + "nextCursor": "...", + "hasMore": false + } +} +``` + +### 4.4 배치 단어 생성 + +#### POST /words/batch + +**Request** + +```json +{ + "words": [ + { + "english": "apple", + "korean": "사과", + "level": "BEGINNER", + "category": "DAILY" + }, + { + "english": "banana", + "korean": "바나나", + "level": "BEGINNER", + "category": "DAILY" + } + ] +} +``` + +**Response (201 Created)** + +```json +{ + "success": true, + "message": "Batch completed", + "data": { + "successCount": 2, + "failCount": 0, + "totalRequested": 2 + } +} +``` + +### 4.5 배치 단어 조회 + +#### POST /words/batch/get + +**Request** + +```json +{ + "wordIds": [ + "word-id-1", + "word-id-2", + "word-id-3" + ] +} +``` + +**Response (200 OK)** + +```json +{ + "success": true, + "message": "Words retrieved", + "data": { + "words": [...], + "requestedCount": 3, + "retrievedCount": 3 + } +} +``` + +**제한**: 최대 100개 ID + +### 4.6 사용자 단어 학습 업데이트 + +#### POST /user-words/{wordId}/review + +**Request** + +```json +{ + "userId": "user123", + "isCorrect": true +} +``` + +**Response (200 OK)** + +```json +{ + "success": true, + "message": "Updated user word", + "data": { + "userId": "user123", + "wordId": "word-id-1", + "status": "REVIEWING", + "interval": 6, + "easeFactor": 2.5, + "repetitions": 2, + "nextReviewAt": "2026-01-15", + "lastReviewedAt": "2026-01-09T10:00:00Z", + "correctCount": 5, + "incorrectCount": 1 + } +} +``` + +### 4.7 사용자 단어 태그 업데이트 + +#### PATCH /user-words/{wordId}/tag + +**Request** + +```json +{ + "userId": "user123", + "bookmarked": true, + "favorite": false, + "difficulty": "HARD" +} +``` + +**Response (200 OK)** + +```json +{ + "success": true, + "message": "Updated user word tag", + "data": { + "userId": "user123", + "wordId": "word-id-1", + "bookmarked": true, + "favorite": false, + "difficulty": "HARD" + } +} +``` + +### 4.8 복습 예정 단어 조회 + +#### GET /user-words/review + +**Query Parameters** + +| 파라미터 | 타입 | 필수 | 설명 | +|---------|------|------|------| +| userId | String | Y | 사용자 ID | +| date | String | N | 조회 날짜 (기본: 오늘) | +| cursor | String | N | 페이징 커서 | +| limit | Integer | N | 페이지 크기 | + +**Response (200 OK)** + +```json +{ + "success": true, + "message": "Review words retrieved", + "data": { + "words": [ + { + "wordId": "...", + "english": "perseverance", + "korean": "인내, 끈기", + "status": "REVIEWING", + "nextReviewAt": "2026-01-09" + } + ], + "nextCursor": "...", + "hasMore": true + } +} +``` + +### 4.9 시험 시작 + +#### POST /tests/start + +**Request** + +```json +{ + "userId": "user123", + "testType": "DAILY", + "wordCount": 20, + "level": "INTERMEDIATE" +} +``` + +**Response (200 OK)** + +```json +{ + "success": true, + "message": "Test started", + "data": { + "testId": "test-uuid", + "testType": "DAILY", + "words": [ + { + "wordId": "...", + "english": "perseverance", + "options": ["인내", "용기", "지혜", "성실"] + } + ], + "startedAt": "2026-01-09T10:00:00Z" + } +} +``` + +### 4.10 시험 제출 + +#### POST /tests/{testId}/submit + +**Request** + +```json +{ + "userId": "user123", + "answers": [ + {"wordId": "word-1", "answer": "인내"}, + {"wordId": "word-2", "answer": "용기"} + ] +} +``` + +**Response (200 OK)** + +```json +{ + "success": true, + "message": "Test submitted", + "data": { + "testId": "test-uuid", + "totalQuestions": 20, + "correctAnswers": 18, + "incorrectAnswers": 2, + "successRate": 90.0, + "incorrectWordIds": ["word-5", "word-12"], + "completedAt": "2026-01-09T10:15:00Z" + } +} +``` + +### 4.11 일일 학습 기록 + +#### POST /daily-study/record + +**Request** + +```json +{ + "userId": "user123", + "wordId": "word-id-1", + "isCorrect": true, + "studyType": "REVIEW" +} +``` + +**Response (200 OK)** + +```json +{ + "success": true, + "message": "Study recorded", + "data": { + "userId": "user123", + "date": "2026-01-09", + "wordsStudied": 15, + "correctCount": 12, + "incorrectCount": 3 + } +} +``` + +### 4.12 학습 통계 조회 + +#### GET /statistics + +**Query Parameters** + +| 파라미터 | 타입 | 필수 | 설명 | +|---------|------|------|------| +| userId | String | Y | 사용자 ID | +| period | String | N | WEEK, MONTH, ALL (기본: WEEK) | + +**Response (200 OK)** + +```json +{ + "success": true, + "message": "Statistics retrieved", + "data": { + "totalWords": 150, + "masteredWords": 45, + "learningWords": 80, + "newWords": 25, + "averageSuccessRate": 85.5, + "studyStreak": 7, + "dailyStats": [ + {"date": "2026-01-09", "wordsStudied": 20, "successRate": 90.0} + ] + } +} +``` + +### 4.13 음성 합성 + +#### POST /voice/synthesize + +**Request** + +```json +{ + "wordId": "word-id-1", + "text": "perseverance", + "voice": "male", + "type": "word" +} +``` + +**Response (200 OK)** + +```json +{ + "success": true, + "message": "Voice synthesized", + "data": { + "audioUrl": "https://s3.amazonaws.com/bucket/vocab/voice/word-id-1_male.mp3", + "cached": true + } +} +``` + +--- + +## 5. 비즈니스 규칙 + +### 5.1 Spaced Repetition 규칙 + +| 조건 | interval 계산 | status 변경 | +|------|--------------|-------------| +| 첫 정답 (rep=1) | 1일 | LEARNING | +| 두번째 정답 (rep=2) | 6일 | REVIEWING | +| 이후 정답 (rep>=3) | interval × easeFactor | REVIEWING | +| 5회 연속 정답 | 유지 | MASTERED | +| 오답 | 1일 (리셋) | LEARNING | + +### 5.2 easeFactor 규칙 + +| 조건 | easeFactor 변경 | +|------|-----------------| +| 초기값 | 2.5 | +| 오답 시 | max(1.3, easeFactor - 0.2) | +| 정답 시 | 유지 | + +### 5.3 난이도별 카테고리 + +```mermaid +flowchart LR + subgraph Level + BEG[BEGINNER] + INT[INTERMEDIATE] + ADV[ADVANCED] + end + + subgraph Category + DAILY[DAILY
일상생활] + BIZ[BUSINESS
비즈니스] + ACAD[ACADEMIC
학술] + end + + BEG --> DAILY + INT --> DAILY + INT --> BIZ + ADV --> DAILY + ADV --> BIZ + ADV --> ACAD +``` + +### 5.4 제한 사항 + +| 항목 | 제한 | +|------|------| +| 단어 목록 페이지 크기 | 최대 50 | +| 배치 조회 ID | 최대 100개 | +| 시험 문제 수 | 최소 5, 최대 50 | +| 사용자 난이도 | EASY, NORMAL, HARD | + +--- + +## 6. 에러 코드 + +### 6.1 HTTP 에러 + +| HTTP Code | 설명 | 예시 | +|-----------|------|------| +| 400 | 잘못된 요청 | 필수 파라미터 누락, 잘못된 difficulty 값 | +| 404 | 리소스 없음 | 존재하지 않는 단어 | +| 500 | 서버 오류 | 내부 오류 | + +### 6.2 에러 응답 형식 + +```json +{ + "success": false, + "error": "Word not found" +} +``` + +--- + +## 7. 환경 설정 + +### 7.1 환경 변수 (template.yaml) + +```yaml +Environment: + Variables: + VOCAB_TABLE_NAME: VocabTable + VOCAB_BUCKET_NAME: group2-englishstudy + AWS_REGION_NAME: ap-northeast-2 +``` + +### 7.2 DynamoDB 테이블 설정 + +```yaml +VocabTable: + Type: AWS::DynamoDB::Table + Properties: + TableName: VocabTable + BillingMode: PAY_PER_REQUEST + AttributeDefinitions: + - AttributeName: PK + AttributeType: S + - AttributeName: SK + AttributeType: S + - AttributeName: GSI1PK + AttributeType: S + - AttributeName: GSI1SK + AttributeType: S + - AttributeName: GSI2PK + AttributeType: S + - AttributeName: GSI2SK + AttributeType: S + KeySchema: + - AttributeName: PK + KeyType: HASH + - AttributeName: SK + KeyType: RANGE + GlobalSecondaryIndexes: + - IndexName: GSI1 + KeySchema: + - AttributeName: GSI1PK + KeyType: HASH + - AttributeName: GSI1SK + KeyType: RANGE + Projection: + ProjectionType: ALL + - IndexName: GSI2 + KeySchema: + - AttributeName: GSI2PK + KeyType: HASH + - AttributeName: GSI2SK + KeyType: RANGE + Projection: + ProjectionType: ALL + TimeToLiveSpecification: + AttributeName: ttl + Enabled: true +``` + +### 7.3 S3 버킷 구조 + +``` +group2-englishstudy/ +└── vocab/ + └── voice/ + ├── {wordId}_male.mp3 + ├── {wordId}_female.mp3 + ├── {wordId}_male_example.mp3 + └── {wordId}_female_example.mp3 +``` + +--- + +## 8. 프로젝트 구조 + +``` +domain/vocabulary/ +├── handler/ +│ ├── WordHandler.java # 단어 CRUD +│ ├── UserWordHandler.java # 사용자 학습 상태 +│ ├── TestHandler.java # 시험 기능 +│ ├── DailyStudyHandler.java # 일일 학습 +│ ├── StatisticsHandler.java # 통계 +│ ├── StatsHandler.java # 간단 통계 +│ ├── VoiceHandler.java # TTS +│ └── WordGroupHandler.java # 단어 그룹 +│ +├── service/ +│ ├── WordCommandService.java # 단어 변경 (CQRS) +│ ├── WordQueryService.java # 단어 조회 (CQRS) +│ ├── WordService.java # 단어 통합 서비스 +│ ├── UserWordCommandService.java # 사용자 학습 변경 +│ ├── UserWordQueryService.java # 사용자 학습 조회 +│ ├── UserWordService.java # 사용자 학습 통합 +│ ├── TestCommandService.java # 시험 변경 +│ ├── TestQueryService.java # 시험 조회 +│ ├── TestService.java # 시험 통합 +│ ├── DailyStudyCommandService.java # 일일학습 변경 +│ ├── DailyStudyQueryService.java # 일일학습 조회 +│ ├── DailyStudyService.java # 일일학습 통합 +│ ├── WordGroupCommandService.java # 그룹 변경 +│ ├── WordGroupQueryService.java # 그룹 조회 +│ ├── StatisticsService.java # 통계 +│ └── StatsService.java # 간단 통계 +│ +├── repository/ +│ ├── WordRepository.java # 단어 데이터 접근 +│ ├── UserWordRepository.java # 사용자 학습 데이터 접근 +│ ├── TestResultRepository.java # 시험 결과 데이터 접근 +│ ├── DailyStudyRepository.java # 일일 학습 데이터 접근 +│ └── WordGroupRepository.java # 그룹 데이터 접근 +│ +├── model/ +│ ├── Word.java # 단어 엔티티 +│ ├── UserWord.java # 사용자 학습 엔티티 +│ ├── TestResult.java # 시험 결과 엔티티 +│ ├── DailyStudy.java # 일일 학습 엔티티 +│ └── WordGroup.java # 단어 그룹 엔티티 +│ +└── dto/ + └── request/ + ├── CreateWordRequest.java + ├── CreateWordsBatchRequest.java + ├── BatchGetWordsRequest.java + ├── UpdateUserWordRequest.java + ├── UpdateUserWordTagRequest.java + ├── StartTestRequest.java + ├── SubmitTestRequest.java + ├── CreateWordGroupRequest.java + └── SynthesizeVoiceRequest.java +``` + +--- + +## 9. GSI 사용 패턴 + +### 9.1 Word GSI + +```mermaid +flowchart TB + subgraph GSI1["GSI1: 난이도별 조회"] + G1_BEG["LEVEL#BEGINNER"] + G1_INT["LEVEL#INTERMEDIATE"] + G1_ADV["LEVEL#ADVANCED"] + end + + subgraph GSI2["GSI2: 카테고리별 조회"] + G2_DAILY["CATEGORY#DAILY"] + G2_BIZ["CATEGORY#BUSINESS"] + G2_ACAD["CATEGORY#ACADEMIC"] + end + + Q1[getWordsByLevel] --> GSI1 + Q2[getWordsByCategory] --> GSI2 +``` + +### 9.2 UserWord GSI + +```mermaid +flowchart TB + subgraph GSI1["GSI1: 복습 예정 조회"] + G1_REV["USER#{userId}#REVIEW"] + G1_DATE["DATE#{nextReviewAt}"] + end + + subgraph GSI2["GSI2: 상태별 조회"] + G2_STATUS["USER#{userId}#STATUS"] + G2_VAL["STATUS#MASTERED/LEARNING/..."] + end + + Q1[getReviewSchedule] --> GSI1 + Q2[getWordsByStatus] --> GSI2 +``` + +--- + +## 10. 구현 현황 + +### Phase 1 - 핵심 기능 (완료) + +- [x] 단어 CRUD +- [x] 배치 생성/조회 +- [x] 단어 검색 +- [x] CQRS 패턴 적용 +- [x] 커서 기반 페이징 + +### Phase 2 - 학습 기능 (완료) + +- [x] Spaced Repetition 알고리즘 +- [x] UserWord 상태 관리 +- [x] 복습 예정 조회 +- [x] 북마크/즐겨찾기 + +### Phase 3 - 시험/통계 (완료) + +- [x] 시험 시작/제출 +- [x] 시험 결과 저장 +- [x] 일일 학습 기록 +- [x] 학습 통계 + +### Phase 4 - 고급 기능 (완료) + +- [x] TTS (AWS Polly) +- [x] 음성 캐싱 (S3) +- [x] 단어 그룹 + +### Phase 5 - 최적화 (진행 중) + +- [ ] 복습 알림 (SNS) +- [ ] 성취 배지 +- [ ] 랭킹 시스템 + +--- + +**버전**: 1.0.0 +**최종 업데이트**: 2026-01-09 +**팀**: MZC 2nd Project Team From 78527e7fcefa27bb45ee8b8e300a1b084f550e92 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 9 Jan 2026 14:51:51 +0900 Subject: [PATCH 084/528] =?UTF-8?q?feat(common):=20StudyLevel=20Enum=20?= =?UTF-8?q?=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=83=9D=EC=84=B1=20refs=20#166?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../serverless/common/enums/StudyLevel.java | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/enums/StudyLevel.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/enums/StudyLevel.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/enums/StudyLevel.java new file mode 100644 index 00000000..73f2ca05 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/enums/StudyLevel.java @@ -0,0 +1,48 @@ +package com.mzc.secondproject.serverless.common.enums; + +import java.util.Arrays; + +public enum StudyLevel { + BEGINNER("beginner", "초급"), + INTERMEDIATE("intermediate", "중급"), + ADVANCED("advanced", "고급"); + + private final String code; + private final String displayName; + + StudyLevel(String code, String displayName) { + this.code = code; + this.displayName = displayName; + } + + public String getCode() { + return code; + } + + public String getDisplayName() { + return displayName; + } + + public static boolean isValid(String value) { + if (value == null) return false; + return Arrays.stream(values()) + .anyMatch(level -> level.name().equalsIgnoreCase(value) || level.code.equalsIgnoreCase(value)); + } + + public static StudyLevel fromString(String value) { + if (value == null) { + throw new IllegalArgumentException("StudyLevel value cannot be null"); + } + return Arrays.stream(values()) + .filter(level -> level.name().equalsIgnoreCase(value) || level.code.equalsIgnoreCase(value)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Unknown StudyLevel: " + value)); + } + + public static StudyLevel fromStringOrDefault(String value, StudyLevel defaultValue) { + if (value == null || !isValid(value)) { + return defaultValue; + } + return fromString(value); + } +} From 74596ba3fb8790b3700b9fb4f51ddd1e038504a9 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 9 Jan 2026 14:53:09 +0900 Subject: [PATCH 085/528] =?UTF-8?q?feat(vocabulary):=20WordStatus=20Enum?= =?UTF-8?q?=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=83=9D=EC=84=B1=20refs=20#1?= =?UTF-8?q?66?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/vocabulary/enums/WordStatus.java | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/enums/WordStatus.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/enums/WordStatus.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/enums/WordStatus.java new file mode 100644 index 00000000..e27c22f5 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/enums/WordStatus.java @@ -0,0 +1,49 @@ +package com.mzc.secondproject.serverless.domain.vocabulary.enums; + +import java.util.Arrays; + +public enum WordStatus { + NEW("new", "새 단어"), + LEARNING("learning", "학습 중"), + REVIEWING("reviewing", "복습 중"), + MASTERED("mastered", "완료"); + + private final String code; + private final String displayName; + + WordStatus(String code, String displayName) { + this.code = code; + this.displayName = displayName; + } + + public String getCode() { + return code; + } + + public String getDisplayName() { + return displayName; + } + + public static boolean isValid(String value) { + if (value == null) return false; + return Arrays.stream(values()) + .anyMatch(status -> status.name().equalsIgnoreCase(value) || status.code.equalsIgnoreCase(value)); + } + + public static WordStatus fromString(String value) { + if (value == null) { + throw new IllegalArgumentException("WordStatus value cannot be null"); + } + return Arrays.stream(values()) + .filter(status -> status.name().equalsIgnoreCase(value) || status.code.equalsIgnoreCase(value)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Unknown WordStatus: " + value)); + } + + public static WordStatus fromStringOrDefault(String value, WordStatus defaultValue) { + if (value == null || !isValid(value)) { + return defaultValue; + } + return fromString(value); + } +} From a36acfe0836b232d4edface9ad8c383dded77808 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 9 Jan 2026 14:53:36 +0900 Subject: [PATCH 086/528] =?UTF-8?q?feat(common):=20Difficulty=20Enum=20?= =?UTF-8?q?=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=83=9D=EC=84=B1=20refs=20#166?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../serverless/common/enums/Difficulty.java | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/enums/Difficulty.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/enums/Difficulty.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/enums/Difficulty.java new file mode 100644 index 00000000..ae9e54f8 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/enums/Difficulty.java @@ -0,0 +1,48 @@ +package com.mzc.secondproject.serverless.common.enums; + +import java.util.Arrays; + +public enum Difficulty { + EASY("easy", "쉬움"), + NORMAL("normal", "보통"), + HARD("hard", "어려움"); + + private final String code; + private final String displayName; + + Difficulty(String code, String displayName) { + this.code = code; + this.displayName = displayName; + } + + public String getCode() { + return code; + } + + public String getDisplayName() { + return displayName; + } + + public static boolean isValid(String value) { + if (value == null) return false; + return Arrays.stream(values()) + .anyMatch(d -> d.name().equalsIgnoreCase(value) || d.code.equalsIgnoreCase(value)); + } + + public static Difficulty fromString(String value) { + if (value == null) { + throw new IllegalArgumentException("Difficulty value cannot be null"); + } + return Arrays.stream(values()) + .filter(d -> d.name().equalsIgnoreCase(value) || d.code.equalsIgnoreCase(value)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Unknown Difficulty: " + value)); + } + + public static Difficulty fromStringOrDefault(String value, Difficulty defaultValue) { + if (value == null || !isValid(value)) { + return defaultValue; + } + return fromString(value); + } +} From d8fdf612673077e51710fbde98fbd5efc047eb3b Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 9 Jan 2026 14:54:04 +0900 Subject: [PATCH 087/528] =?UTF-8?q?feat(chatting):=20ChatLevel=20Enum=20?= =?UTF-8?q?=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=83=9D=EC=84=B1=20refs=20#166?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/chatting/enums/ChatLevel.java | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/ChatLevel.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/ChatLevel.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/ChatLevel.java new file mode 100644 index 00000000..b529b706 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/ChatLevel.java @@ -0,0 +1,48 @@ +package com.mzc.secondproject.serverless.domain.chatting.enums; + +import java.util.Arrays; + +public enum ChatLevel { + BEGINNER("beginner", "초급"), + INTERMEDIATE("intermediate", "중급"), + ADVANCED("advanced", "고급"); + + private final String code; + private final String displayName; + + ChatLevel(String code, String displayName) { + this.code = code; + this.displayName = displayName; + } + + public String getCode() { + return code; + } + + public String getDisplayName() { + return displayName; + } + + public static boolean isValid(String value) { + if (value == null) return false; + return Arrays.stream(values()) + .anyMatch(level -> level.name().equalsIgnoreCase(value) || level.code.equalsIgnoreCase(value)); + } + + public static ChatLevel fromString(String value) { + if (value == null) { + throw new IllegalArgumentException("ChatLevel value cannot be null"); + } + return Arrays.stream(values()) + .filter(level -> level.name().equalsIgnoreCase(value) || level.code.equalsIgnoreCase(value)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Unknown ChatLevel: " + value)); + } + + public static ChatLevel fromStringOrDefault(String value, ChatLevel defaultValue) { + if (value == null || !isValid(value)) { + return defaultValue; + } + return fromString(value); + } +} From 4495fdb57bcb566f5b80fd486a3b074884797b6a Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 9 Jan 2026 14:54:04 +0900 Subject: [PATCH 088/528] =?UTF-8?q?feat(chatting):=20MessageType=20Enum=20?= =?UTF-8?q?=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=83=9D=EC=84=B1=20refs=20#166?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/chatting/enums/MessageType.java | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/MessageType.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/MessageType.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/MessageType.java new file mode 100644 index 00000000..7b1480ce --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/MessageType.java @@ -0,0 +1,49 @@ +package com.mzc.secondproject.serverless.domain.chatting.enums; + +import java.util.Arrays; + +public enum MessageType { + TEXT("text", "텍스트"), + IMAGE("image", "이미지"), + VOICE("voice", "음성"), + AI_RESPONSE("ai_response", "AI 응답"); + + private final String code; + private final String displayName; + + MessageType(String code, String displayName) { + this.code = code; + this.displayName = displayName; + } + + public String getCode() { + return code; + } + + public String getDisplayName() { + return displayName; + } + + public static boolean isValid(String value) { + if (value == null) return false; + return Arrays.stream(values()) + .anyMatch(type -> type.name().equalsIgnoreCase(value) || type.code.equalsIgnoreCase(value)); + } + + public static MessageType fromString(String value) { + if (value == null) { + throw new IllegalArgumentException("MessageType value cannot be null"); + } + return Arrays.stream(values()) + .filter(type -> type.name().equalsIgnoreCase(value) || type.code.equalsIgnoreCase(value)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Unknown MessageType: " + value)); + } + + public static MessageType fromStringOrDefault(String value, MessageType defaultValue) { + if (value == null || !isValid(value)) { + return defaultValue; + } + return fromString(value); + } +} From 217c8226cf4d9e8b0799a6bc5002cab7e12b2a77 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 9 Jan 2026 14:54:10 +0900 Subject: [PATCH 089/528] =?UTF-8?q?feat(vocabulary):=20WordCategory=20Enum?= =?UTF-8?q?=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=83=9D=EC=84=B1=20refs=20#1?= =?UTF-8?q?66?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/vocabulary/enums/WordCategory.java | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/enums/WordCategory.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/enums/WordCategory.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/enums/WordCategory.java new file mode 100644 index 00000000..cfbad6b7 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/enums/WordCategory.java @@ -0,0 +1,50 @@ +package com.mzc.secondproject.serverless.domain.vocabulary.enums; + +import java.util.Arrays; + +public enum WordCategory { + DAILY("daily", "일상"), + BUSINESS("business", "비즈니스"), + ACADEMIC("academic", "학술"), + TRAVEL("travel", "여행"), + TECHNOLOGY("technology", "기술"); + + private final String code; + private final String displayName; + + WordCategory(String code, String displayName) { + this.code = code; + this.displayName = displayName; + } + + public String getCode() { + return code; + } + + public String getDisplayName() { + return displayName; + } + + public static boolean isValid(String value) { + if (value == null) return false; + return Arrays.stream(values()) + .anyMatch(cat -> cat.name().equalsIgnoreCase(value) || cat.code.equalsIgnoreCase(value)); + } + + public static WordCategory fromString(String value) { + if (value == null) { + throw new IllegalArgumentException("WordCategory value cannot be null"); + } + return Arrays.stream(values()) + .filter(cat -> cat.name().equalsIgnoreCase(value) || cat.code.equalsIgnoreCase(value)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Unknown WordCategory: " + value)); + } + + public static WordCategory fromStringOrDefault(String value, WordCategory defaultValue) { + if (value == null || !isValid(value)) { + return defaultValue; + } + return fromString(value); + } +} From c80c96fde182249e68415e6e381b3c2714901f5f Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 9 Jan 2026 14:54:10 +0900 Subject: [PATCH 090/528] =?UTF-8?q?feat(vocabulary):=20TestType=20Enum=20?= =?UTF-8?q?=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=83=9D=EC=84=B1=20refs=20#166?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/vocabulary/enums/TestType.java | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/enums/TestType.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/enums/TestType.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/enums/TestType.java new file mode 100644 index 00000000..2fd34207 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/enums/TestType.java @@ -0,0 +1,48 @@ +package com.mzc.secondproject.serverless.domain.vocabulary.enums; + +import java.util.Arrays; + +public enum TestType { + DAILY("daily", "일일 테스트"), + WEEKLY("weekly", "주간 테스트"), + CUSTOM("custom", "사용자 지정 테스트"); + + private final String code; + private final String displayName; + + TestType(String code, String displayName) { + this.code = code; + this.displayName = displayName; + } + + public String getCode() { + return code; + } + + public String getDisplayName() { + return displayName; + } + + public static boolean isValid(String value) { + if (value == null) return false; + return Arrays.stream(values()) + .anyMatch(type -> type.name().equalsIgnoreCase(value) || type.code.equalsIgnoreCase(value)); + } + + public static TestType fromString(String value) { + if (value == null) { + throw new IllegalArgumentException("TestType value cannot be null"); + } + return Arrays.stream(values()) + .filter(type -> type.name().equalsIgnoreCase(value) || type.code.equalsIgnoreCase(value)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Unknown TestType: " + value)); + } + + public static TestType fromStringOrDefault(String value, TestType defaultValue) { + if (value == null || !isValid(value)) { + return defaultValue; + } + return fromString(value); + } +} From a90ba9cef7ab56c7f52d1f0c927af0d23048c28e Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 9 Jan 2026 14:55:12 +0900 Subject: [PATCH 091/528] =?UTF-8?q?feat(common):=20DynamoDbKey=20=EC=83=81?= =?UTF-8?q?=EC=88=98=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?refs=20#167?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/constants/DynamoDbKey.java | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/constants/DynamoDbKey.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/constants/DynamoDbKey.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/constants/DynamoDbKey.java new file mode 100644 index 00000000..94cbd939 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/constants/DynamoDbKey.java @@ -0,0 +1,109 @@ +package com.mzc.secondproject.serverless.common.constants; + +public final class DynamoDbKey { + + private DynamoDbKey() {} + + // Partition/Sort Key Attributes + public static final String PARTITION_KEY = "PK"; + public static final String SORT_KEY = "SK"; + + // GSI Key Attributes + public static final String GSI1_PK = "GSI1PK"; + public static final String GSI1_SK = "GSI1SK"; + public static final String GSI2_PK = "GSI2PK"; + public static final String GSI2_SK = "GSI2SK"; + + // Index Names + public static final String GSI1 = "GSI1"; + public static final String GSI2 = "GSI2"; + + // Entity Prefixes + public static final String PREFIX_ROOM = "ROOM#"; + public static final String PREFIX_MESSAGE = "MSG#"; + public static final String PREFIX_USER = "USER#"; + public static final String PREFIX_WORD = "WORD#"; + public static final String PREFIX_CONNECTION = "CONNECTION#"; + public static final String PREFIX_DAILY = "DAILY#"; + public static final String PREFIX_LEVEL = "LEVEL#"; + public static final String PREFIX_CATEGORY = "CATEGORY#"; + public static final String PREFIX_DATE = "DATE#"; + public static final String PREFIX_STATUS = "STATUS#"; + public static final String PREFIX_TEST = "TEST#"; + public static final String PREFIX_TOKEN = "TOKEN#"; + + // Suffix + public static final String SUFFIX_METADATA = "METADATA"; + public static final String SUFFIX_REVIEW = "#REVIEW"; + public static final String SUFFIX_STATUS = "#STATUS"; + public static final String SUFFIX_GROUP = "#GROUP"; + + // Special Keys + public static final String ROOMS_ALL = "ROOMS"; + public static final String DAILY_ALL = "DAILY#ALL"; + + // Key Builder Methods + public static String roomPk(String roomId) { + return PREFIX_ROOM + roomId; + } + + public static String messageSk(String messageId) { + return PREFIX_MESSAGE + messageId; + } + + public static String userPk(String userId) { + return PREFIX_USER + userId; + } + + public static String wordPk(String wordId) { + return PREFIX_WORD + wordId; + } + + public static String wordSk(String wordId) { + return PREFIX_WORD + wordId; + } + + public static String connectionPk(String connectionId) { + return PREFIX_CONNECTION + connectionId; + } + + public static String dailyPk(String userId) { + return PREFIX_DAILY + userId; + } + + public static String dateSk(String date) { + return PREFIX_DATE + date; + } + + public static String levelPk(String level) { + return PREFIX_LEVEL + level; + } + + public static String categoryPk(String category) { + return PREFIX_CATEGORY + category; + } + + public static String statusSk(String status) { + return PREFIX_STATUS + status; + } + + public static String userReviewPk(String userId) { + return PREFIX_USER + userId + SUFFIX_REVIEW; + } + + public static String userStatusPk(String userId) { + return PREFIX_USER + userId + SUFFIX_STATUS; + } + + public static String userGroupPk(String userId) { + return PREFIX_USER + userId + SUFFIX_GROUP; + } + + public static String testPk(String testId) { + return PREFIX_TEST + testId; + } + + public static String tokenPk(String token) { + return PREFIX_TOKEN + token; + } +} From 74fe262d49ab78a740e4ce950e4c08c39deffb4e Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 9 Jan 2026 14:55:51 +0900 Subject: [PATCH 092/528] =?UTF-8?q?feat(common):=20DynamoDbKey=20=EA=B3=B5?= =?UTF-8?q?=EC=9A=A9=20=EC=83=81=EC=88=98=20=ED=81=B4=EB=9E=98=EC=8A=A4=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20refs=20#167?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/constants/DynamoDbKey.java | 95 ++----------------- .../common/constants/StudyConfig.java | 30 ++++++ 2 files changed, 36 insertions(+), 89 deletions(-) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/constants/StudyConfig.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/constants/DynamoDbKey.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/constants/DynamoDbKey.java index 94cbd939..cdad0313 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/constants/DynamoDbKey.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/constants/DynamoDbKey.java @@ -5,8 +5,8 @@ public final class DynamoDbKey { private DynamoDbKey() {} // Partition/Sort Key Attributes - public static final String PARTITION_KEY = "PK"; - public static final String SORT_KEY = "SK"; + public static final String PK = "PK"; + public static final String SK = "SK"; // GSI Key Attributes public static final String GSI1_PK = "GSI1PK"; @@ -18,92 +18,9 @@ private DynamoDbKey() {} public static final String GSI1 = "GSI1"; public static final String GSI2 = "GSI2"; - // Entity Prefixes - public static final String PREFIX_ROOM = "ROOM#"; - public static final String PREFIX_MESSAGE = "MSG#"; - public static final String PREFIX_USER = "USER#"; - public static final String PREFIX_WORD = "WORD#"; - public static final String PREFIX_CONNECTION = "CONNECTION#"; - public static final String PREFIX_DAILY = "DAILY#"; - public static final String PREFIX_LEVEL = "LEVEL#"; - public static final String PREFIX_CATEGORY = "CATEGORY#"; - public static final String PREFIX_DATE = "DATE#"; - public static final String PREFIX_STATUS = "STATUS#"; - public static final String PREFIX_TEST = "TEST#"; - public static final String PREFIX_TOKEN = "TOKEN#"; + // Common Sort Key + public static final String METADATA = "METADATA"; - // Suffix - public static final String SUFFIX_METADATA = "METADATA"; - public static final String SUFFIX_REVIEW = "#REVIEW"; - public static final String SUFFIX_STATUS = "#STATUS"; - public static final String SUFFIX_GROUP = "#GROUP"; - - // Special Keys - public static final String ROOMS_ALL = "ROOMS"; - public static final String DAILY_ALL = "DAILY#ALL"; - - // Key Builder Methods - public static String roomPk(String roomId) { - return PREFIX_ROOM + roomId; - } - - public static String messageSk(String messageId) { - return PREFIX_MESSAGE + messageId; - } - - public static String userPk(String userId) { - return PREFIX_USER + userId; - } - - public static String wordPk(String wordId) { - return PREFIX_WORD + wordId; - } - - public static String wordSk(String wordId) { - return PREFIX_WORD + wordId; - } - - public static String connectionPk(String connectionId) { - return PREFIX_CONNECTION + connectionId; - } - - public static String dailyPk(String userId) { - return PREFIX_DAILY + userId; - } - - public static String dateSk(String date) { - return PREFIX_DATE + date; - } - - public static String levelPk(String level) { - return PREFIX_LEVEL + level; - } - - public static String categoryPk(String category) { - return PREFIX_CATEGORY + category; - } - - public static String statusSk(String status) { - return PREFIX_STATUS + status; - } - - public static String userReviewPk(String userId) { - return PREFIX_USER + userId + SUFFIX_REVIEW; - } - - public static String userStatusPk(String userId) { - return PREFIX_USER + userId + SUFFIX_STATUS; - } - - public static String userGroupPk(String userId) { - return PREFIX_USER + userId + SUFFIX_GROUP; - } - - public static String testPk(String testId) { - return PREFIX_TEST + testId; - } - - public static String tokenPk(String token) { - return PREFIX_TOKEN + token; - } + // 공용 Entity Prefix + public static final String USER = "USER#"; } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/constants/StudyConfig.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/constants/StudyConfig.java new file mode 100644 index 00000000..fe3ebff4 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/constants/StudyConfig.java @@ -0,0 +1,30 @@ +package com.mzc.secondproject.serverless.common.constants; + +public final class StudyConfig { + + private StudyConfig() {} + + // Spaced Repetition 기본값 + public static final int INITIAL_INTERVAL_DAYS = 1; + public static final double DEFAULT_EASE_FACTOR = 2.5; + public static final double MIN_EASE_FACTOR = 1.3; + public static final int INITIAL_REPETITIONS = 0; + + // 오답 관련 + public static final int MAX_WRONG_COUNT = 3; + + // 테스트 관련 + public static final int DEFAULT_WORD_COUNT = 20; + public static final int DAILY_TEST_WORD_COUNT = 10; + + // 복습 간격 (일 단위) + public static final int[] REVIEW_INTERVALS = {1, 3, 7, 14, 30}; + + // 상태 기본값 + public static final String DEFAULT_WORD_STATUS = "NEW"; + public static final String DEFAULT_DIFFICULTY = "NORMAL"; + + // 정답/오답 카운트 초기값 + public static final int INITIAL_CORRECT_COUNT = 0; + public static final int INITIAL_INCORRECT_COUNT = 0; +} From 0cf854ba22b43ca7f33479ce85129723c352e1cf Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 9 Jan 2026 15:02:19 +0900 Subject: [PATCH 093/528] =?UTF-8?q?feat(chatting):=20ChatKey=20=EC=83=81?= =?UTF-8?q?=EC=88=98=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?refs=20#167?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/chatting/constants/ChatKey.java | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/constants/ChatKey.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/constants/ChatKey.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/constants/ChatKey.java new file mode 100644 index 00000000..0189da34 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/constants/ChatKey.java @@ -0,0 +1,38 @@ +package com.mzc.secondproject.serverless.domain.chatting.constants; + +import com.mzc.secondproject.serverless.common.constants.DynamoDbKey; + +public final class ChatKey { + + private ChatKey() {} + + // Prefixes + public static final String ROOM = "ROOM#"; + public static final String MESSAGE = "MSG#"; + public static final String CONNECTION = "CONNECTION#"; + public static final String TOKEN = "TOKEN#"; + + // Special Keys + public static final String ROOMS_ALL = "ROOMS"; + + // Key Builders + public static String roomPk(String roomId) { + return ROOM + roomId; + } + + public static String messageSk(String messageId) { + return MESSAGE + messageId; + } + + public static String userPk(String userId) { + return DynamoDbKey.USER + userId; + } + + public static String connectionPk(String connectionId) { + return CONNECTION + connectionId; + } + + public static String tokenPk(String token) { + return TOKEN + token; + } +} From 9fe2bf668fdd4e3c2e3be03650e03f077455d371 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 9 Jan 2026 15:02:19 +0900 Subject: [PATCH 094/528] =?UTF-8?q?feat(vocabulary):=20VocabKey=20?= =?UTF-8?q?=EC=83=81=EC=88=98=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20refs=20#167?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/vocabulary/constants/VocabKey.java | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/constants/VocabKey.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/constants/VocabKey.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/constants/VocabKey.java new file mode 100644 index 00000000..06607a01 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/constants/VocabKey.java @@ -0,0 +1,74 @@ +package com.mzc.secondproject.serverless.domain.vocabulary.constants; + +import com.mzc.secondproject.serverless.common.constants.DynamoDbKey; + +public final class VocabKey { + + private VocabKey() {} + + // Prefixes + public static final String WORD = "WORD#"; + public static final String DAILY = "DAILY#"; + public static final String LEVEL = "LEVEL#"; + public static final String CATEGORY = "CATEGORY#"; + public static final String TEST = "TEST#"; + public static final String DATE = "DATE#"; + public static final String STATUS_PREFIX = "STATUS#"; + + // Suffix + public static final String SUFFIX_REVIEW = "#REVIEW"; + public static final String SUFFIX_STATUS = "#STATUS"; + public static final String SUFFIX_GROUP = "#GROUP"; + + // Special Keys + public static final String DAILY_ALL = "DAILY#ALL"; + + // Key Builders + public static String userPk(String userId) { + return DynamoDbKey.USER + userId; + } + + public static String wordPk(String wordId) { + return WORD + wordId; + } + + public static String wordSk(String wordId) { + return WORD + wordId; + } + + public static String dailyPk(String userId) { + return DAILY + userId; + } + + public static String dateSk(String date) { + return DATE + date; + } + + public static String levelPk(String level) { + return LEVEL + level; + } + + public static String categoryPk(String category) { + return CATEGORY + category; + } + + public static String statusSk(String status) { + return STATUS_PREFIX + status; + } + + public static String userReviewPk(String userId) { + return DynamoDbKey.USER + userId + SUFFIX_REVIEW; + } + + public static String userStatusPk(String userId) { + return DynamoDbKey.USER + userId + SUFFIX_STATUS; + } + + public static String userGroupPk(String userId) { + return DynamoDbKey.USER + userId + SUFFIX_GROUP; + } + + public static String testPk(String testId) { + return TEST + testId; + } +} From 6f20042c2ea93922c7d205206cfde5a4bab41c5e Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 9 Jan 2026 15:03:44 +0900 Subject: [PATCH 095/528] =?UTF-8?q?refactor(vocabulary):=20UserWordCommand?= =?UTF-8?q?Service=EC=97=90=20VocabKey,=20StudyConfig,=20Enum=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9=20refs=20#169?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/UserWordCommandService.java | 68 ++++++++++--------- 1 file changed, 36 insertions(+), 32 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordCommandService.java index d6c11294..6354ae2f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordCommandService.java @@ -1,5 +1,9 @@ package com.mzc.secondproject.serverless.domain.vocabulary.service; +import com.mzc.secondproject.serverless.common.constants.StudyConfig; +import com.mzc.secondproject.serverless.common.enums.Difficulty; +import com.mzc.secondproject.serverless.domain.vocabulary.constants.VocabKey; +import com.mzc.secondproject.serverless.domain.vocabulary.enums.WordStatus; import com.mzc.secondproject.serverless.domain.vocabulary.model.UserWord; import com.mzc.secondproject.serverless.domain.vocabulary.repository.UserWordRepository; import org.slf4j.Logger; @@ -29,18 +33,18 @@ public UserWord updateUserWord(String userId, String wordId, boolean isCorrect) if (optUserWord.isEmpty()) { userWord = UserWord.builder() - .pk("USER#" + userId) - .sk("WORD#" + wordId) - .gsi1pk("USER#" + userId + "#REVIEW") - .gsi2pk("USER#" + userId + "#STATUS") + .pk(VocabKey.userPk(userId)) + .sk(VocabKey.wordSk(wordId)) + .gsi1pk(VocabKey.userReviewPk(userId)) + .gsi2pk(VocabKey.userStatusPk(userId)) .userId(userId) .wordId(wordId) - .status("NEW") - .interval(1) - .easeFactor(2.5) - .repetitions(0) - .correctCount(0) - .incorrectCount(0) + .status(WordStatus.NEW.name()) + .interval(StudyConfig.INITIAL_INTERVAL_DAYS) + .easeFactor(StudyConfig.DEFAULT_EASE_FACTOR) + .repetitions(StudyConfig.INITIAL_REPETITIONS) + .correctCount(StudyConfig.INITIAL_CORRECT_COUNT) + .incorrectCount(StudyConfig.INITIAL_INCORRECT_COUNT) .createdAt(now) .build(); } else { @@ -51,8 +55,8 @@ public UserWord updateUserWord(String userId, String wordId, boolean isCorrect) userWord.setUpdatedAt(now); userWord.setLastReviewedAt(now); - userWord.setGsi1sk("DATE#" + userWord.getNextReviewAt()); - userWord.setGsi2sk("STATUS#" + userWord.getStatus()); + userWord.setGsi1sk(VocabKey.dateSk(userWord.getNextReviewAt())); + userWord.setGsi2sk(VocabKey.statusSk(userWord.getStatus())); userWordRepository.save(userWord); @@ -68,19 +72,19 @@ public UserWord updateUserWordTag(String userId, String wordId, Boolean bookmark if (optUserWord.isEmpty()) { userWord = UserWord.builder() - .pk("USER#" + userId) - .sk("WORD#" + wordId) - .gsi1pk("USER#" + userId + "#REVIEW") - .gsi2pk("USER#" + userId + "#STATUS") - .gsi2sk("STATUS#NEW") + .pk(VocabKey.userPk(userId)) + .sk(VocabKey.wordSk(wordId)) + .gsi1pk(VocabKey.userReviewPk(userId)) + .gsi2pk(VocabKey.userStatusPk(userId)) + .gsi2sk(VocabKey.statusSk(WordStatus.NEW.name())) .userId(userId) .wordId(wordId) - .status("NEW") - .interval(1) - .easeFactor(2.5) - .repetitions(0) - .correctCount(0) - .incorrectCount(0) + .status(WordStatus.NEW.name()) + .interval(StudyConfig.INITIAL_INTERVAL_DAYS) + .easeFactor(StudyConfig.DEFAULT_EASE_FACTOR) + .repetitions(StudyConfig.INITIAL_REPETITIONS) + .correctCount(StudyConfig.INITIAL_CORRECT_COUNT) + .incorrectCount(StudyConfig.INITIAL_INCORRECT_COUNT) .bookmarked(false) .favorite(false) .createdAt(now) @@ -96,7 +100,7 @@ public UserWord updateUserWordTag(String userId, String wordId, Boolean bookmark userWord.setFavorite(favorite); } if (difficulty != null) { - if (!difficulty.equals("EASY") && !difficulty.equals("NORMAL") && !difficulty.equals("HARD")) { + if (!Difficulty.isValid(difficulty)) { throw new IllegalArgumentException("difficulty must be EASY, NORMAL, or HARD"); } userWord.setDifficulty(difficulty); @@ -115,7 +119,7 @@ private void applySpacedRepetition(UserWord userWord, boolean isCorrect) { userWord.setRepetitions(userWord.getRepetitions() + 1); if (userWord.getRepetitions() == 1) { - userWord.setInterval(1); + userWord.setInterval(StudyConfig.INITIAL_INTERVAL_DAYS); } else if (userWord.getRepetitions() == 2) { userWord.setInterval(6); } else { @@ -124,20 +128,20 @@ private void applySpacedRepetition(UserWord userWord, boolean isCorrect) { } if (userWord.getRepetitions() >= 5) { - userWord.setStatus("MASTERED"); + userWord.setStatus(WordStatus.MASTERED.name()); } else if (userWord.getRepetitions() >= 2) { - userWord.setStatus("REVIEWING"); + userWord.setStatus(WordStatus.REVIEWING.name()); } else { - userWord.setStatus("LEARNING"); + userWord.setStatus(WordStatus.LEARNING.name()); } } else { userWord.setIncorrectCount(userWord.getIncorrectCount() + 1); - userWord.setRepetitions(0); - userWord.setInterval(1); - userWord.setStatus("LEARNING"); + userWord.setRepetitions(StudyConfig.INITIAL_REPETITIONS); + userWord.setInterval(StudyConfig.INITIAL_INTERVAL_DAYS); + userWord.setStatus(WordStatus.LEARNING.name()); double newEaseFactor = userWord.getEaseFactor() - 0.2; - userWord.setEaseFactor(Math.max(1.3, newEaseFactor)); + userWord.setEaseFactor(Math.max(StudyConfig.MIN_EASE_FACTOR, newEaseFactor)); } LocalDate nextReview = LocalDate.now().plusDays(userWord.getInterval()); From 5c44b62245ad0a03ac8c3f171a04b36acfe57c88 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 9 Jan 2026 15:04:42 +0900 Subject: [PATCH 096/528] =?UTF-8?q?refactor(vocabulary):=20WordCommandServ?= =?UTF-8?q?ice=EC=97=90=20VocabKey=20=EC=A0=81=EC=9A=A9=20refs=20#169?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/WordCommandService.java | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordCommandService.java index dbbcb11f..d9291e2e 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordCommandService.java @@ -1,5 +1,7 @@ package com.mzc.secondproject.serverless.domain.vocabulary.service; +import com.mzc.secondproject.serverless.common.constants.DynamoDbKey; +import com.mzc.secondproject.serverless.domain.vocabulary.constants.VocabKey; import com.mzc.secondproject.serverless.domain.vocabulary.dto.request.CreateWordRequest; import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; import com.mzc.secondproject.serverless.domain.vocabulary.repository.WordRepository; @@ -31,12 +33,12 @@ public Word createWord(String english, String korean, String example, String lev String now = Instant.now().toString(); Word word = Word.builder() - .pk("WORD#" + wordId) - .sk("METADATA") - .gsi1pk("LEVEL#" + level) - .gsi1sk("WORD#" + wordId) - .gsi2pk("CATEGORY#" + category) - .gsi2sk("WORD#" + wordId) + .pk(VocabKey.wordPk(wordId)) + .sk(DynamoDbKey.METADATA) + .gsi1pk(VocabKey.levelPk(level)) + .gsi1sk(VocabKey.wordSk(wordId)) + .gsi2pk(VocabKey.categoryPk(category)) + .gsi2sk(VocabKey.wordSk(wordId)) .wordId(wordId) .english(english) .korean(korean) @@ -72,12 +74,12 @@ public Word updateWord(String wordId, Map updates) { if (updates.containsKey("level")) { String newLevel = (String) updates.get("level"); word.setLevel(newLevel); - word.setGsi1pk("LEVEL#" + newLevel); + word.setGsi1pk(VocabKey.levelPk(newLevel)); } if (updates.containsKey("category")) { String newCategory = (String) updates.get("category"); word.setCategory(newCategory); - word.setGsi2pk("CATEGORY#" + newCategory); + word.setGsi2pk(VocabKey.categoryPk(newCategory)); } wordRepository.save(word); @@ -118,12 +120,12 @@ public BatchResult createWordsBatch(List wordsList) { String wordId = UUID.randomUUID().toString(); Word word = Word.builder() - .pk("WORD#" + wordId) - .sk("METADATA") - .gsi1pk("LEVEL#" + level) - .gsi1sk("WORD#" + wordId) - .gsi2pk("CATEGORY#" + category) - .gsi2sk("WORD#" + wordId) + .pk(VocabKey.wordPk(wordId)) + .sk(DynamoDbKey.METADATA) + .gsi1pk(VocabKey.levelPk(level)) + .gsi1sk(VocabKey.wordSk(wordId)) + .gsi2pk(VocabKey.categoryPk(category)) + .gsi2sk(VocabKey.wordSk(wordId)) .wordId(wordId) .english(english) .korean(korean) From 6b034468ad4b591e1a89dd97607c50b1dd3acd28 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 9 Jan 2026 15:06:03 +0900 Subject: [PATCH 097/528] =?UTF-8?q?refactor(vocabulary):=20DailyStudyComma?= =?UTF-8?q?ndService=EC=97=90=20VocabKey,=20StudyLevel=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9=20refs=20#169?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vocabulary/service/DailyStudyCommandService.java | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyCommandService.java index 39202eb7..1790d845 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyCommandService.java @@ -1,6 +1,8 @@ package com.mzc.secondproject.serverless.domain.vocabulary.service; import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.common.enums.StudyLevel; +import com.mzc.secondproject.serverless.domain.vocabulary.constants.VocabKey; import com.mzc.secondproject.serverless.domain.vocabulary.model.DailyStudy; import com.mzc.secondproject.serverless.domain.vocabulary.model.UserWord; import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; @@ -51,7 +53,7 @@ public DailyStudyResult getDailyWords(String userId, String level) { if (level == null || level.isEmpty()) { throw new IllegalArgumentException("level is required for first daily study (BEGINNER, INTERMEDIATE, ADVANCED)"); } - if (!level.equals("BEGINNER") && !level.equals("INTERMEDIATE") && !level.equals("ADVANCED")) { + if (!StudyLevel.isValid(level)) { throw new IllegalArgumentException("Invalid level. Must be BEGINNER, INTERMEDIATE, or ADVANCED"); } dailyStudy = createDailyStudy(userId, today, level); @@ -102,10 +104,10 @@ private DailyStudy createDailyStudy(String userId, String date, String level) { List newWordIds = getNewWordsForUser(userId, level, NEW_WORDS_COUNT); DailyStudy dailyStudy = DailyStudy.builder() - .pk("DAILY#" + userId) - .sk("DATE#" + date) - .gsi1pk("DAILY#ALL") - .gsi1sk("DATE#" + date) + .pk(VocabKey.dailyPk(userId)) + .sk(VocabKey.dateSk(date)) + .gsi1pk(VocabKey.DAILY_ALL) + .gsi1sk(VocabKey.dateSk(date)) .userId(userId) .date(date) .newWordIds(newWordIds) From e560b3db2784d58b876443dd90658f4b5c953d37 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 9 Jan 2026 15:13:26 +0900 Subject: [PATCH 098/528] =?UTF-8?q?feat(common):=20ErrorCode=20sealed=20in?= =?UTF-8?q?terface=20=EC=83=9D=EC=84=B1=20refs=20#170?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/exception/ErrorCode.java | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/ErrorCode.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/ErrorCode.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/ErrorCode.java new file mode 100644 index 00000000..1ca5f177 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/ErrorCode.java @@ -0,0 +1,48 @@ +package com.mzc.secondproject.serverless.common.exception; + +/** + * 에러 코드 표준 인터페이스 (Sealed Interface) + * + * 모든 에러 코드 enum이 구현해야 하는 표준 계약을 정의합니다. + * Sealed interface를 사용하여 허용된 구현체만 존재하도록 제한합니다. + * + * 계층 구조: + * ErrorCode (sealed) + * ├── CommonErrorCode (시스템/공통 에러) + * └── DomainErrorCode (non-sealed) - 도메인별 에러 + * ├── VocabularyErrorCode + * └── ChattingErrorCode + */ +public sealed interface ErrorCode permits CommonErrorCode, DomainErrorCode { + + /** + * 에러 코드 반환 (예: AUTH_001, VOCAB_001, CHAT_001) + */ + String getCode(); + + /** + * 에러 메시지 반환 + */ + String getMessage(); + + /** + * HTTP 상태 코드 반환 (예: 400, 404, 500) + */ + int getStatusCode(); + + /** + * 클라이언트 에러 여부 (4xx) + */ + default boolean isClientError() { + int status = getStatusCode(); + return status >= 400 && status < 500; + } + + /** + * 서버 에러 여부 (5xx) + */ + default boolean isServerError() { + int status = getStatusCode(); + return status >= 500 && status < 600; + } +} From 92b1c2463964e2c2c69ecd3ed9ef3ab09f4ef7fa Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 9 Jan 2026 15:13:26 +0900 Subject: [PATCH 099/528] =?UTF-8?q?feat(common):=20DomainErrorCode=20non-s?= =?UTF-8?q?ealed=20interface=20=EC=83=9D=EC=84=B1=20refs=20#170?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/exception/DomainErrorCode.java | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/DomainErrorCode.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/DomainErrorCode.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/DomainErrorCode.java new file mode 100644 index 00000000..0a51850f --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/DomainErrorCode.java @@ -0,0 +1,26 @@ +package com.mzc.secondproject.serverless.common.exception; + +/** + * 도메인별 에러 코드 인터페이스 + * + * 각 도메인(Vocabulary, Chatting 등)의 비즈니스 로직 관련 에러 코드가 구현하는 인터페이스입니다. + * ErrorCode를 확장하여 도메인 식별 기능을 추가합니다. + * + * 구현체: + * - VocabularyErrorCode - 단어 학습 도메인 + * - ChattingErrorCode - 채팅 도메인 + */ +public non-sealed interface DomainErrorCode extends ErrorCode { + + /** + * 도메인 이름 반환 (예: "VOCABULARY", "CHATTING") + */ + String getDomain(); + + /** + * 전체 에러 식별자 반환 (예: VOCABULARY.WORD_001) + */ + default String getFullCode() { + return getDomain() + "." + getCode(); + } +} From 712e6534d299cd2bd09f65689dc6cd66139d7001 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 9 Jan 2026 15:13:26 +0900 Subject: [PATCH 100/528] =?UTF-8?q?feat(common):=20CommonErrorCode=20enum?= =?UTF-8?q?=20=EC=83=9D=EC=84=B1=20refs=20#170?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/exception/CommonErrorCode.java | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/CommonErrorCode.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/CommonErrorCode.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/CommonErrorCode.java new file mode 100644 index 00000000..651fea95 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/CommonErrorCode.java @@ -0,0 +1,60 @@ +package com.mzc.secondproject.serverless.common.exception; + +/** + * 공통/시스템 에러 코드 + * + * 도메인에 종속되지 않는 공통 에러 코드를 정의합니다. + * - 인증/인가 에러 (AUTH_XXX) + * - 검증 에러 (VALIDATION_XXX) + * - 시스템 에러 (SYSTEM_XXX) + */ +public enum CommonErrorCode implements ErrorCode { + + // 인증/인가 관련 에러 + UNAUTHORIZED("AUTH_001", "인증이 필요합니다", 401), + FORBIDDEN("AUTH_002", "접근 권한이 없습니다", 403), + INVALID_TOKEN("AUTH_003", "유효하지 않은 토큰입니다", 401), + TOKEN_EXPIRED("AUTH_004", "토큰이 만료되었습니다", 401), + + // 검증 관련 에러 + INVALID_INPUT("VALIDATION_001", "잘못된 입력입니다", 400), + REQUIRED_FIELD_MISSING("VALIDATION_002", "필수 필드가 누락되었습니다", 400), + INVALID_FORMAT("VALIDATION_003", "형식이 올바르지 않습니다", 400), + VALUE_OUT_OF_RANGE("VALIDATION_004", "값이 허용 범위를 벗어났습니다", 400), + + // 리소스 관련 에러 + RESOURCE_NOT_FOUND("RESOURCE_001", "리소스를 찾을 수 없습니다", 404), + RESOURCE_ALREADY_EXISTS("RESOURCE_002", "이미 존재하는 리소스입니다", 409), + + // 시스템 에러 + INTERNAL_SERVER_ERROR("SYSTEM_001", "내부 서버 오류가 발생했습니다", 500), + DATABASE_ERROR("SYSTEM_002", "데이터베이스 오류가 발생했습니다", 500), + EXTERNAL_API_ERROR("SYSTEM_003", "외부 API 호출 오류가 발생했습니다", 502), + SERVICE_UNAVAILABLE("SYSTEM_004", "서비스를 일시적으로 사용할 수 없습니다", 503), + ; + + private final String code; + private final String message; + private final int statusCode; + + CommonErrorCode(String code, String message, int statusCode) { + this.code = code; + this.message = message; + this.statusCode = statusCode; + } + + @Override + public String getCode() { + return code; + } + + @Override + public String getMessage() { + return message; + } + + @Override + public int getStatusCode() { + return statusCode; + } +} From ba36174ea46de1e70a21e11c947d8dd7ba02f36e Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 9 Jan 2026 15:15:16 +0900 Subject: [PATCH 101/528] =?UTF-8?q?feat(exception):=20ServerlessException?= =?UTF-8?q?=20=EC=B6=94=EC=83=81=20=EA=B8=B0=EB=B0=98=20=ED=81=B4=EB=9E=98?= =?UTF-8?q?=EC=8A=A4=20=EA=B5=AC=ED=98=84=20closes=20#171?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/exception/ServerlessException.java | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/ServerlessException.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/ServerlessException.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/ServerlessException.java new file mode 100644 index 00000000..897c8fca --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/ServerlessException.java @@ -0,0 +1,86 @@ +package com.mzc.secondproject.serverless.common.exception; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * 서버리스 애플리케이션 기본 예외 클래스 + * + * 모든 비즈니스 예외의 추상 기반 클래스입니다. + * ErrorCode를 통해 표준화된 에러 정보를 제공합니다. + * + * 사용 예시: + * - CommonException: 공통/시스템 예외 + * - VocabularyException: 단어 학습 도메인 예외 + * - ChattingException: 채팅 도메인 예외 + */ +public abstract class ServerlessException extends RuntimeException { + + private final ErrorCode errorCode; + private final Map details; + + protected ServerlessException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + this.details = new HashMap<>(); + } + + protected ServerlessException(ErrorCode errorCode, String message) { + super(message); + this.errorCode = errorCode; + this.details = new HashMap<>(); + } + + protected ServerlessException(ErrorCode errorCode, Throwable cause) { + super(errorCode.getMessage(), cause); + this.errorCode = errorCode; + this.details = new HashMap<>(); + } + + protected ServerlessException(ErrorCode errorCode, String message, Throwable cause) { + super(message, cause); + this.errorCode = errorCode; + this.details = new HashMap<>(); + } + + public ErrorCode getErrorCode() { + return errorCode; + } + + public String getCode() { + return errorCode.getCode(); + } + + public int getStatusCode() { + return errorCode.getStatusCode(); + } + + public Map getDetails() { + return Collections.unmodifiableMap(details); + } + + /** + * 에러 상세 정보 추가 (메서드 체이닝 지원) + */ + public ServerlessException addDetail(String key, Object value) { + this.details.put(key, value); + return this; + } + + /** + * 여러 상세 정보 일괄 추가 + */ + public ServerlessException addDetails(Map details) { + this.details.putAll(details); + return this; + } + + public boolean isClientError() { + return errorCode.isClientError(); + } + + public boolean isServerError() { + return errorCode.isServerError(); + } +} From 17aa0bcb4221f0250e35f274cbaa524b58934b36 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 9 Jan 2026 15:15:38 +0900 Subject: [PATCH 102/528] =?UTF-8?q?feat(exception):=20CommonException=20?= =?UTF-8?q?=EC=A0=95=EC=A0=81=20=ED=8C=A9=ED=86=A0=EB=A6=AC=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=20=ED=8C=A8=ED=84=B4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/exception/CommonException.java | 137 ++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/CommonException.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/CommonException.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/CommonException.java new file mode 100644 index 00000000..5b72794d --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/CommonException.java @@ -0,0 +1,137 @@ +package com.mzc.secondproject.serverless.common.exception; + +/** + * 공통/시스템 예외 클래스 + * + * 도메인에 종속되지 않는 공통 예외를 처리합니다. + * 정적 팩토리 메서드를 통해 가독성 높은 예외 생성을 지원합니다. + * + * 사용 예시: + * throw CommonException.unauthorized(); + * throw CommonException.notFound("사용자"); + * throw CommonException.invalidInput("이메일 형식이 올바르지 않습니다"); + */ +public class CommonException extends ServerlessException { + + private CommonException(CommonErrorCode errorCode) { + super(errorCode); + } + + private CommonException(CommonErrorCode errorCode, String message) { + super(errorCode, message); + } + + private CommonException(CommonErrorCode errorCode, Throwable cause) { + super(errorCode, cause); + } + + private CommonException(CommonErrorCode errorCode, String message, Throwable cause) { + super(errorCode, message, cause); + } + + // === 인증/인가 예외 팩토리 메서드 === + + public static CommonException unauthorized() { + return new CommonException(CommonErrorCode.UNAUTHORIZED); + } + + public static CommonException unauthorized(String message) { + return new CommonException(CommonErrorCode.UNAUTHORIZED, message); + } + + public static CommonException forbidden() { + return new CommonException(CommonErrorCode.FORBIDDEN); + } + + public static CommonException forbidden(String resource) { + return new CommonException(CommonErrorCode.FORBIDDEN, + String.format("'%s'에 대한 접근 권한이 없습니다", resource)); + } + + public static CommonException invalidToken() { + return new CommonException(CommonErrorCode.INVALID_TOKEN); + } + + public static CommonException tokenExpired() { + return new CommonException(CommonErrorCode.TOKEN_EXPIRED); + } + + // === 검증 예외 팩토리 메서드 === + + public static CommonException invalidInput() { + return new CommonException(CommonErrorCode.INVALID_INPUT); + } + + public static CommonException invalidInput(String message) { + return new CommonException(CommonErrorCode.INVALID_INPUT, message); + } + + public static CommonException requiredFieldMissing(String fieldName) { + return new CommonException(CommonErrorCode.REQUIRED_FIELD_MISSING, + String.format("필수 필드 '%s'가 누락되었습니다", fieldName)); + } + + public static CommonException invalidFormat(String fieldName) { + return new CommonException(CommonErrorCode.INVALID_FORMAT, + String.format("'%s' 형식이 올바르지 않습니다", fieldName)); + } + + public static CommonException valueOutOfRange(String fieldName, Object min, Object max) { + return new CommonException(CommonErrorCode.VALUE_OUT_OF_RANGE, + String.format("'%s' 값은 %s ~ %s 범위여야 합니다", fieldName, min, max)); + } + + // === 리소스 예외 팩토리 메서드 === + + public static CommonException notFound(String resourceName) { + return new CommonException(CommonErrorCode.RESOURCE_NOT_FOUND, + String.format("'%s'를 찾을 수 없습니다", resourceName)); + } + + public static CommonException notFound(String resourceName, String identifier) { + return new CommonException(CommonErrorCode.RESOURCE_NOT_FOUND, + String.format("'%s' (ID: %s)를 찾을 수 없습니다", resourceName, identifier)); + } + + public static CommonException alreadyExists(String resourceName) { + return new CommonException(CommonErrorCode.RESOURCE_ALREADY_EXISTS, + String.format("'%s'가 이미 존재합니다", resourceName)); + } + + public static CommonException alreadyExists(String resourceName, String identifier) { + return new CommonException(CommonErrorCode.RESOURCE_ALREADY_EXISTS, + String.format("'%s' (ID: %s)가 이미 존재합니다", resourceName, identifier)); + } + + // === 시스템 예외 팩토리 메서드 === + + public static CommonException internalError() { + return new CommonException(CommonErrorCode.INTERNAL_SERVER_ERROR); + } + + public static CommonException internalError(Throwable cause) { + return new CommonException(CommonErrorCode.INTERNAL_SERVER_ERROR, cause); + } + + public static CommonException internalError(String message) { + return new CommonException(CommonErrorCode.INTERNAL_SERVER_ERROR, message); + } + + public static CommonException databaseError(Throwable cause) { + return new CommonException(CommonErrorCode.DATABASE_ERROR, cause); + } + + public static CommonException externalApiError(String apiName) { + return new CommonException(CommonErrorCode.EXTERNAL_API_ERROR, + String.format("'%s' API 호출 중 오류가 발생했습니다", apiName)); + } + + public static CommonException externalApiError(String apiName, Throwable cause) { + return new CommonException(CommonErrorCode.EXTERNAL_API_ERROR, + String.format("'%s' API 호출 중 오류가 발생했습니다", apiName), cause); + } + + public static CommonException serviceUnavailable() { + return new CommonException(CommonErrorCode.SERVICE_UNAVAILABLE); + } +} From 5207156b8886b66989818f72feb1a9c09a793f07 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 9 Jan 2026 15:16:05 +0900 Subject: [PATCH 103/528] =?UTF-8?q?feat(vocabulary):=20VocabularyErrorCode?= =?UTF-8?q?=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=97=90=EB=9F=AC=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../exception/VocabularyErrorCode.java | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/exception/VocabularyErrorCode.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/exception/VocabularyErrorCode.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/exception/VocabularyErrorCode.java new file mode 100644 index 00000000..6b4fe599 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/exception/VocabularyErrorCode.java @@ -0,0 +1,63 @@ +package com.mzc.secondproject.serverless.domain.vocabulary.exception; + +import com.mzc.secondproject.serverless.common.exception.DomainErrorCode; + +/** + * 단어 학습 도메인 에러 코드 + * + * 단어(Word), 사용자 단어(UserWord), 일일 학습(DailyStudy) 관련 에러 코드를 정의합니다. + */ +public enum VocabularyErrorCode implements DomainErrorCode { + + // 단어 관련 에러 + WORD_NOT_FOUND("WORD_001", "단어를 찾을 수 없습니다", 404), + WORD_ALREADY_EXISTS("WORD_002", "이미 존재하는 단어입니다", 409), + INVALID_WORD_DATA("WORD_003", "단어 데이터가 유효하지 않습니다", 400), + + // 사용자 단어 관련 에러 + USER_WORD_NOT_FOUND("USER_WORD_001", "사용자 단어 정보를 찾을 수 없습니다", 404), + INVALID_DIFFICULTY("USER_WORD_002", "유효하지 않은 난이도입니다", 400), + INVALID_WORD_STATUS("USER_WORD_003", "유효하지 않은 단어 상태입니다", 400), + + // 학습 관련 에러 + DAILY_STUDY_NOT_FOUND("STUDY_001", "일일 학습 정보를 찾을 수 없습니다", 404), + STUDY_LIMIT_EXCEEDED("STUDY_002", "일일 학습 한도를 초과했습니다", 400), + INVALID_STUDY_LEVEL("STUDY_003", "유효하지 않은 학습 레벨입니다", 400), + + // 카테고리/레벨 관련 에러 + INVALID_CATEGORY("CATEGORY_001", "유효하지 않은 카테고리입니다", 400), + INVALID_LEVEL("LEVEL_001", "유효하지 않은 레벨입니다", 400), + ; + + private static final String DOMAIN = "VOCABULARY"; + + private final String code; + private final String message; + private final int statusCode; + + VocabularyErrorCode(String code, String message, int statusCode) { + this.code = code; + this.message = message; + this.statusCode = statusCode; + } + + @Override + public String getDomain() { + return DOMAIN; + } + + @Override + public String getCode() { + return code; + } + + @Override + public String getMessage() { + return message; + } + + @Override + public int getStatusCode() { + return statusCode; + } +} From 72652b7161ce5b503242d8f406847f8a08507370 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 9 Jan 2026 15:16:25 +0900 Subject: [PATCH 104/528] =?UTF-8?q?feat(vocabulary):=20VocabularyException?= =?UTF-8?q?=20=EC=A0=95=EC=A0=81=20=ED=8C=A9=ED=86=A0=EB=A6=AC=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=20=ED=8C=A8=ED=84=B4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../exception/VocabularyException.java | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/exception/VocabularyException.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/exception/VocabularyException.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/exception/VocabularyException.java new file mode 100644 index 00000000..56bcf03e --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/exception/VocabularyException.java @@ -0,0 +1,102 @@ +package com.mzc.secondproject.serverless.domain.vocabulary.exception; + +import com.mzc.secondproject.serverless.common.exception.ServerlessException; + +/** + * 단어 학습 도메인 예외 클래스 + * + * 정적 팩토리 메서드를 통해 가독성 높은 예외 생성을 지원합니다. + * + * 사용 예시: + * throw VocabularyException.wordNotFound(wordId); + * throw VocabularyException.invalidDifficulty("INVALID"); + */ +public class VocabularyException extends ServerlessException { + + private VocabularyException(VocabularyErrorCode errorCode) { + super(errorCode); + } + + private VocabularyException(VocabularyErrorCode errorCode, String message) { + super(errorCode, message); + } + + private VocabularyException(VocabularyErrorCode errorCode, Throwable cause) { + super(errorCode, cause); + } + + // === 단어(Word) 관련 팩토리 메서드 === + + public static VocabularyException wordNotFound(String wordId) { + return (VocabularyException) new VocabularyException(VocabularyErrorCode.WORD_NOT_FOUND, + String.format("단어를 찾을 수 없습니다 (ID: %s)", wordId)) + .addDetail("wordId", wordId); + } + + public static VocabularyException wordAlreadyExists(String english) { + return (VocabularyException) new VocabularyException(VocabularyErrorCode.WORD_ALREADY_EXISTS, + String.format("이미 존재하는 단어입니다: '%s'", english)) + .addDetail("english", english); + } + + public static VocabularyException invalidWordData(String reason) { + return new VocabularyException(VocabularyErrorCode.INVALID_WORD_DATA, reason); + } + + // === 사용자 단어(UserWord) 관련 팩토리 메서드 === + + public static VocabularyException userWordNotFound(String userId, String wordId) { + return (VocabularyException) new VocabularyException(VocabularyErrorCode.USER_WORD_NOT_FOUND, + String.format("사용자 단어 정보를 찾을 수 없습니다 (userId: %s, wordId: %s)", userId, wordId)) + .addDetail("userId", userId) + .addDetail("wordId", wordId); + } + + public static VocabularyException invalidDifficulty(String difficulty) { + return (VocabularyException) new VocabularyException(VocabularyErrorCode.INVALID_DIFFICULTY, + String.format("유효하지 않은 난이도입니다: '%s'. EASY, NORMAL, HARD 중 하나여야 합니다", difficulty)) + .addDetail("invalidValue", difficulty) + .addDetail("allowedValues", "EASY, NORMAL, HARD"); + } + + public static VocabularyException invalidWordStatus(String status) { + return (VocabularyException) new VocabularyException(VocabularyErrorCode.INVALID_WORD_STATUS, + String.format("유효하지 않은 단어 상태입니다: '%s'", status)) + .addDetail("invalidValue", status); + } + + // === 학습(Study) 관련 팩토리 메서드 === + + public static VocabularyException dailyStudyNotFound(String userId, String date) { + return (VocabularyException) new VocabularyException(VocabularyErrorCode.DAILY_STUDY_NOT_FOUND, + String.format("일일 학습 정보를 찾을 수 없습니다 (userId: %s, date: %s)", userId, date)) + .addDetail("userId", userId) + .addDetail("date", date); + } + + public static VocabularyException studyLimitExceeded(int limit) { + return (VocabularyException) new VocabularyException(VocabularyErrorCode.STUDY_LIMIT_EXCEEDED, + String.format("일일 학습 한도(%d개)를 초과했습니다", limit)) + .addDetail("dailyLimit", limit); + } + + public static VocabularyException invalidStudyLevel(String level) { + return (VocabularyException) new VocabularyException(VocabularyErrorCode.INVALID_STUDY_LEVEL, + String.format("유효하지 않은 학습 레벨입니다: '%s'", level)) + .addDetail("invalidValue", level); + } + + // === 카테고리/레벨 관련 팩토리 메서드 === + + public static VocabularyException invalidCategory(String category) { + return (VocabularyException) new VocabularyException(VocabularyErrorCode.INVALID_CATEGORY, + String.format("유효하지 않은 카테고리입니다: '%s'", category)) + .addDetail("invalidValue", category); + } + + public static VocabularyException invalidLevel(String level) { + return (VocabularyException) new VocabularyException(VocabularyErrorCode.INVALID_LEVEL, + String.format("유효하지 않은 레벨입니다: '%s'", level)) + .addDetail("invalidValue", level); + } +} From ecd42bf59d11967557a6a5e3c52eb8e997892bc7 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 9 Jan 2026 15:16:56 +0900 Subject: [PATCH 105/528] =?UTF-8?q?feat(chatting):=20ChattingErrorCode=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=97=90=EB=9F=AC=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chatting/exception/ChattingErrorCode.java | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCode.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCode.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCode.java new file mode 100644 index 00000000..e2771356 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCode.java @@ -0,0 +1,67 @@ +package com.mzc.secondproject.serverless.domain.chatting.exception; + +import com.mzc.secondproject.serverless.common.exception.DomainErrorCode; + +/** + * 채팅 도메인 에러 코드 + * + * 채팅방(Room), 메시지(Message), 참여자(Participant) 관련 에러 코드를 정의합니다. + */ +public enum ChattingErrorCode implements DomainErrorCode { + + // 채팅방 관련 에러 + ROOM_NOT_FOUND("ROOM_001", "채팅방을 찾을 수 없습니다", 404), + ROOM_ALREADY_EXISTS("ROOM_002", "이미 존재하는 채팅방입니다", 409), + ROOM_FULL("ROOM_003", "채팅방 인원이 가득 찼습니다", 400), + ROOM_CLOSED("ROOM_004", "종료된 채팅방입니다", 400), + + // 메시지 관련 에러 + MESSAGE_NOT_FOUND("MSG_001", "메시지를 찾을 수 없습니다", 404), + MESSAGE_TOO_LONG("MSG_002", "메시지가 너무 깁니다", 400), + INVALID_MESSAGE_TYPE("MSG_003", "유효하지 않은 메시지 타입입니다", 400), + + // 참여자 관련 에러 + NOT_ROOM_MEMBER("MEMBER_001", "채팅방 멤버가 아닙니다", 403), + ALREADY_JOINED("MEMBER_002", "이미 참여 중인 채팅방입니다", 409), + INVALID_ROOM_TOKEN("MEMBER_003", "유효하지 않은 방 토큰입니다", 401), + + // 채팅 레벨 관련 에러 + INVALID_CHAT_LEVEL("LEVEL_001", "유효하지 않은 채팅 레벨입니다", 400), + + // 연결 관련 에러 + CONNECTION_FAILED("CONN_001", "연결에 실패했습니다", 500), + CONNECTION_TIMEOUT("CONN_002", "연결 시간이 초과되었습니다", 408), + ; + + private static final String DOMAIN = "CHATTING"; + + private final String code; + private final String message; + private final int statusCode; + + ChattingErrorCode(String code, String message, int statusCode) { + this.code = code; + this.message = message; + this.statusCode = statusCode; + } + + @Override + public String getDomain() { + return DOMAIN; + } + + @Override + public String getCode() { + return code; + } + + @Override + public String getMessage() { + return message; + } + + @Override + public int getStatusCode() { + return statusCode; + } +} From 117804394158ac99fa48f0e823ac1f88024c22af Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 9 Jan 2026 15:17:17 +0900 Subject: [PATCH 106/528] =?UTF-8?q?feat(chatting):=20ChattingException=20?= =?UTF-8?q?=EC=A0=95=EC=A0=81=20=ED=8C=A9=ED=86=A0=EB=A6=AC=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=20=ED=8C=A8=ED=84=B4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chatting/exception/ChattingException.java | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingException.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingException.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingException.java new file mode 100644 index 00000000..02bde4eb --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingException.java @@ -0,0 +1,119 @@ +package com.mzc.secondproject.serverless.domain.chatting.exception; + +import com.mzc.secondproject.serverless.common.exception.ServerlessException; + +/** + * 채팅 도메인 예외 클래스 + * + * 정적 팩토리 메서드를 통해 가독성 높은 예외 생성을 지원합니다. + * + * 사용 예시: + * throw ChattingException.roomNotFound(roomId); + * throw ChattingException.notRoomMember(userId, roomId); + */ +public class ChattingException extends ServerlessException { + + private ChattingException(ChattingErrorCode errorCode) { + super(errorCode); + } + + private ChattingException(ChattingErrorCode errorCode, String message) { + super(errorCode, message); + } + + private ChattingException(ChattingErrorCode errorCode, Throwable cause) { + super(errorCode, cause); + } + + // === 채팅방(Room) 관련 팩토리 메서드 === + + public static ChattingException roomNotFound(String roomId) { + return (ChattingException) new ChattingException(ChattingErrorCode.ROOM_NOT_FOUND, + String.format("채팅방을 찾을 수 없습니다 (ID: %s)", roomId)) + .addDetail("roomId", roomId); + } + + public static ChattingException roomAlreadyExists(String roomName) { + return (ChattingException) new ChattingException(ChattingErrorCode.ROOM_ALREADY_EXISTS, + String.format("이미 존재하는 채팅방입니다: '%s'", roomName)) + .addDetail("roomName", roomName); + } + + public static ChattingException roomFull(String roomId, int maxCapacity) { + return (ChattingException) new ChattingException(ChattingErrorCode.ROOM_FULL, + String.format("채팅방 인원이 가득 찼습니다 (최대 %d명)", maxCapacity)) + .addDetail("roomId", roomId) + .addDetail("maxCapacity", maxCapacity); + } + + public static ChattingException roomClosed(String roomId) { + return (ChattingException) new ChattingException(ChattingErrorCode.ROOM_CLOSED, + String.format("종료된 채팅방입니다 (ID: %s)", roomId)) + .addDetail("roomId", roomId); + } + + // === 메시지(Message) 관련 팩토리 메서드 === + + public static ChattingException messageNotFound(String messageId) { + return (ChattingException) new ChattingException(ChattingErrorCode.MESSAGE_NOT_FOUND, + String.format("메시지를 찾을 수 없습니다 (ID: %s)", messageId)) + .addDetail("messageId", messageId); + } + + public static ChattingException messageTooLong(int length, int maxLength) { + return (ChattingException) new ChattingException(ChattingErrorCode.MESSAGE_TOO_LONG, + String.format("메시지가 너무 깁니다 (%d자, 최대 %d자)", length, maxLength)) + .addDetail("length", length) + .addDetail("maxLength", maxLength); + } + + public static ChattingException invalidMessageType(String type) { + return (ChattingException) new ChattingException(ChattingErrorCode.INVALID_MESSAGE_TYPE, + String.format("유효하지 않은 메시지 타입입니다: '%s'", type)) + .addDetail("invalidValue", type); + } + + // === 참여자(Member) 관련 팩토리 메서드 === + + public static ChattingException notRoomMember(String userId, String roomId) { + return (ChattingException) new ChattingException(ChattingErrorCode.NOT_ROOM_MEMBER, + String.format("채팅방 멤버가 아닙니다 (userId: %s, roomId: %s)", userId, roomId)) + .addDetail("userId", userId) + .addDetail("roomId", roomId); + } + + public static ChattingException alreadyJoined(String userId, String roomId) { + return (ChattingException) new ChattingException(ChattingErrorCode.ALREADY_JOINED, + String.format("이미 참여 중인 채팅방입니다 (userId: %s, roomId: %s)", userId, roomId)) + .addDetail("userId", userId) + .addDetail("roomId", roomId); + } + + public static ChattingException invalidRoomToken() { + return new ChattingException(ChattingErrorCode.INVALID_ROOM_TOKEN); + } + + public static ChattingException invalidRoomToken(String reason) { + return new ChattingException(ChattingErrorCode.INVALID_ROOM_TOKEN, reason); + } + + // === 채팅 레벨 관련 팩토리 메서드 === + + public static ChattingException invalidChatLevel(String level) { + return (ChattingException) new ChattingException(ChattingErrorCode.INVALID_CHAT_LEVEL, + String.format("유효하지 않은 채팅 레벨입니다: '%s'", level)) + .addDetail("invalidValue", level); + } + + // === 연결 관련 팩토리 메서드 === + + public static ChattingException connectionFailed(Throwable cause) { + return (ChattingException) new ChattingException(ChattingErrorCode.CONNECTION_FAILED, cause); + } + + public static ChattingException connectionTimeout(String connectionId) { + return (ChattingException) new ChattingException(ChattingErrorCode.CONNECTION_TIMEOUT, + String.format("연결 시간이 초과되었습니다 (connectionId: %s)", connectionId)) + .addDetail("connectionId", connectionId); + } +} From 23a1bd9756f04187074e41fcdebf1c11ed5e42af Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 9 Jan 2026 15:17:55 +0900 Subject: [PATCH 107/528] =?UTF-8?q?feat(response):=20ApiResponse=20?= =?UTF-8?q?=ED=91=9C=EC=A4=80=20=EC=9D=91=EB=8B=B5=20=EB=9E=98=ED=8D=BC=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/response/ApiResponse.java | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/response/ApiResponse.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/response/ApiResponse.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/response/ApiResponse.java new file mode 100644 index 00000000..f480d809 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/response/ApiResponse.java @@ -0,0 +1,75 @@ +package com.mzc.secondproject.serverless.common.response; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import java.time.Instant; + +/** + * 표준 API 응답 래퍼 + * + * 모든 API 응답의 일관된 형식을 제공합니다. + * 성공/실패 여부와 관계없이 동일한 구조를 유지합니다. + * + * 성공 응답 예시: + * { + * "success": true, + * "data": { ... }, + * "timestamp": "2024-01-01T00:00:00Z" + * } + * + * 실패 응답 예시: + * { + * "success": false, + * "error": { ... }, + * "timestamp": "2024-01-01T00:00:00Z" + * } + * + * @param success 성공 여부 + * @param data 응답 데이터 (성공 시) + * @param error 에러 정보 (실패 시) + * @param timestamp 응답 시각 + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public record ApiResponse( + boolean success, + T data, + ErrorInfo error, + String timestamp +) { + + /** + * 성공 응답 생성 (데이터 포함) + */ + public static ApiResponse success(T data) { + return new ApiResponse<>(true, data, null, Instant.now().toString()); + } + + /** + * 성공 응답 생성 (데이터 없음) + */ + public static ApiResponse success() { + return new ApiResponse<>(true, null, null, Instant.now().toString()); + } + + /** + * 실패 응답 생성 (ErrorInfo 직접 전달) + */ + public static ApiResponse error(ErrorInfo errorInfo) { + return new ApiResponse<>(false, null, errorInfo, Instant.now().toString()); + } + + /** + * 실패 응답 생성 (에러 코드, 메시지 전달) + */ + public static ApiResponse error(String code, String message, int status) { + return new ApiResponse<>(false, null, + ErrorInfo.of(code, message, status), Instant.now().toString()); + } + + /** + * 실패 응답 생성 (ServerlessException에서 변환) + */ + public static ApiResponse error(com.mzc.secondproject.serverless.common.exception.ServerlessException ex) { + return new ApiResponse<>(false, null, ErrorInfo.from(ex), Instant.now().toString()); + } +} From 82294ea947aefa1746a85125827205046e450da8 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 9 Jan 2026 15:18:13 +0900 Subject: [PATCH 108/528] =?UTF-8?q?feat(response):=20ErrorInfo=20RFC=20780?= =?UTF-8?q?7=20=EC=8A=A4=ED=83=80=EC=9D=BC=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../serverless/common/response/ErrorInfo.java | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/response/ErrorInfo.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/response/ErrorInfo.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/response/ErrorInfo.java new file mode 100644 index 00000000..cbe5e8d3 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/response/ErrorInfo.java @@ -0,0 +1,89 @@ +package com.mzc.secondproject.serverless.common.response; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.mzc.secondproject.serverless.common.exception.DomainErrorCode; +import com.mzc.secondproject.serverless.common.exception.ErrorCode; +import com.mzc.secondproject.serverless.common.exception.ServerlessException; + +import java.util.Map; + +/** + * RFC 7807 스타일 에러 정보 + * + * Problem Details for HTTP APIs (RFC 7807) 표준을 참고한 에러 응답 형식입니다. + * + * 응답 예시: + * { + * "code": "VOCABULARY.WORD_001", + * "message": "단어를 찾을 수 없습니다", + * "status": 404, + * "details": { + * "wordId": "abc-123" + * } + * } + * + * @param code 에러 코드 (예: AUTH_001, VOCABULARY.WORD_001) + * @param message 에러 메시지 + * @param status HTTP 상태 코드 + * @param details 추가 상세 정보 (선택) + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public record ErrorInfo( + String code, + String message, + int status, + Map details +) { + + /** + * 기본 에러 정보 생성 + */ + public static ErrorInfo of(String code, String message, int status) { + return new ErrorInfo(code, message, status, null); + } + + /** + * 상세 정보 포함 에러 정보 생성 + */ + public static ErrorInfo of(String code, String message, int status, Map details) { + return new ErrorInfo(code, message, status, details); + } + + /** + * ErrorCode에서 에러 정보 생성 + */ + public static ErrorInfo from(ErrorCode errorCode) { + String code = errorCode instanceof DomainErrorCode domainCode + ? domainCode.getFullCode() + : errorCode.getCode(); + return new ErrorInfo(code, errorCode.getMessage(), errorCode.getStatusCode(), null); + } + + /** + * ServerlessException에서 에러 정보 생성 + */ + public static ErrorInfo from(ServerlessException ex) { + ErrorCode errorCode = ex.getErrorCode(); + String code = errorCode instanceof DomainErrorCode domainCode + ? domainCode.getFullCode() + : errorCode.getCode(); + + Map details = ex.getDetails().isEmpty() ? null : ex.getDetails(); + + return new ErrorInfo(code, ex.getMessage(), ex.getStatusCode(), details); + } + + /** + * 클라이언트 에러 여부 (4xx) + */ + public boolean isClientError() { + return status >= 400 && status < 500; + } + + /** + * 서버 에러 여부 (5xx) + */ + public boolean isServerError() { + return status >= 500 && status < 600; + } +} From e31166eb023c3051570fc7fd2257be07ffcdc314 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 9 Jan 2026 15:20:05 +0900 Subject: [PATCH 109/528] =?UTF-8?q?feat(router):=20HandlerRouter=20Serverl?= =?UTF-8?q?essException=20=EC=A4=91=EC=95=99=20=EC=98=88=EC=99=B8=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EA=B5=AC=ED=98=84=20closes=20#173?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/router/HandlerRouter.java | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/HandlerRouter.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/HandlerRouter.java index 42eafcbb..98058bbb 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/HandlerRouter.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/HandlerRouter.java @@ -3,6 +3,8 @@ import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; import com.mzc.secondproject.serverless.common.dto.ApiResponse; +import com.mzc.secondproject.serverless.common.exception.ServerlessException; +import com.mzc.secondproject.serverless.common.response.ErrorInfo; import static com.mzc.secondproject.serverless.common.util.ResponseUtil.createResponse; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -56,6 +58,8 @@ public APIGatewayProxyResponseEvent route(APIGatewayProxyRequestEvent request) { logger.debug("Matched route: {} {}", entry.route.method(), entry.route.pathPattern()); try { return entry.route.handler().apply(request); + } catch (ServerlessException e) { + return handleServerlessException(e); } catch (IllegalArgumentException e) { logger.warn("Bad request: {}", e.getMessage()); return createResponse(400, ApiResponse.error(e.getMessage())); @@ -88,6 +92,22 @@ private String convertPatternToRegex(String pathPattern) { return ".*" + regex + "$"; } + /** + * ServerlessException 처리 + * ErrorCode 기반의 표준화된 에러 응답 생성 + */ + private APIGatewayProxyResponseEvent handleServerlessException(ServerlessException e) { + ErrorInfo errorInfo = ErrorInfo.from(e); + + if (e.isClientError()) { + logger.warn("Client error [{}]: {}", errorInfo.code(), e.getMessage()); + } else { + logger.error("Server error [{}]: {}", errorInfo.code(), e.getMessage(), e); + } + + return createResponse(e.getStatusCode(), com.mzc.secondproject.serverless.common.response.ApiResponse.error(errorInfo)); + } + /** * 라우트 엔트리 (라우트 + 컴파일된 패턴) */ From d9723d5948cad1bb32c51b1100a4902696b7ea79 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 9 Jan 2026 15:21:57 +0900 Subject: [PATCH 110/528] =?UTF-8?q?fix(response):=20Jackson=20=EC=9D=98?= =?UTF-8?q?=EC=A1=B4=EC=84=B1=20=EC=A0=9C=EA=B1=B0=20(Gson=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../secondproject/serverless/common/response/ApiResponse.java | 3 --- .../secondproject/serverless/common/response/ErrorInfo.java | 2 -- 2 files changed, 5 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/response/ApiResponse.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/response/ApiResponse.java index f480d809..546e8a1c 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/response/ApiResponse.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/response/ApiResponse.java @@ -1,7 +1,5 @@ package com.mzc.secondproject.serverless.common.response; -import com.fasterxml.jackson.annotation.JsonInclude; - import java.time.Instant; /** @@ -29,7 +27,6 @@ * @param error 에러 정보 (실패 시) * @param timestamp 응답 시각 */ -@JsonInclude(JsonInclude.Include.NON_NULL) public record ApiResponse( boolean success, T data, diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/response/ErrorInfo.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/response/ErrorInfo.java index cbe5e8d3..52552123 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/response/ErrorInfo.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/response/ErrorInfo.java @@ -1,6 +1,5 @@ package com.mzc.secondproject.serverless.common.response; -import com.fasterxml.jackson.annotation.JsonInclude; import com.mzc.secondproject.serverless.common.exception.DomainErrorCode; import com.mzc.secondproject.serverless.common.exception.ErrorCode; import com.mzc.secondproject.serverless.common.exception.ServerlessException; @@ -27,7 +26,6 @@ * @param status HTTP 상태 코드 * @param details 추가 상세 정보 (선택) */ -@JsonInclude(JsonInclude.Include.NON_NULL) public record ErrorInfo( String code, String message, From 7a3bf2847456f9447120520147caa89e6da76005 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 9 Jan 2026 15:22:51 +0900 Subject: [PATCH 111/528] =?UTF-8?q?fix(response):=20record=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=B6=A9=EB=8F=8C=20=EB=B0=A9?= =?UTF-8?q?=EC=A7=80=EB=A5=BC=20=EC=9C=84=ED=95=B4=20=EB=A9=94=EC=84=9C?= =?UTF-8?q?=EB=93=9C=EB=AA=85=20=EB=B3=80=EA=B2=BD=20(success=E2=86=92ok,?= =?UTF-8?q?=20error=E2=86=92fail)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../serverless/common/response/ApiResponse.java | 10 +++++----- .../serverless/common/router/HandlerRouter.java | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/response/ApiResponse.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/response/ApiResponse.java index 546e8a1c..e28cf1a0 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/response/ApiResponse.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/response/ApiResponse.java @@ -37,28 +37,28 @@ public record ApiResponse( /** * 성공 응답 생성 (데이터 포함) */ - public static ApiResponse success(T data) { + public static ApiResponse ok(T data) { return new ApiResponse<>(true, data, null, Instant.now().toString()); } /** * 성공 응답 생성 (데이터 없음) */ - public static ApiResponse success() { + public static ApiResponse ok() { return new ApiResponse<>(true, null, null, Instant.now().toString()); } /** * 실패 응답 생성 (ErrorInfo 직접 전달) */ - public static ApiResponse error(ErrorInfo errorInfo) { + public static ApiResponse fail(ErrorInfo errorInfo) { return new ApiResponse<>(false, null, errorInfo, Instant.now().toString()); } /** * 실패 응답 생성 (에러 코드, 메시지 전달) */ - public static ApiResponse error(String code, String message, int status) { + public static ApiResponse fail(String code, String message, int status) { return new ApiResponse<>(false, null, ErrorInfo.of(code, message, status), Instant.now().toString()); } @@ -66,7 +66,7 @@ public static ApiResponse error(String code, String message, int status) /** * 실패 응답 생성 (ServerlessException에서 변환) */ - public static ApiResponse error(com.mzc.secondproject.serverless.common.exception.ServerlessException ex) { + public static ApiResponse fail(com.mzc.secondproject.serverless.common.exception.ServerlessException ex) { return new ApiResponse<>(false, null, ErrorInfo.from(ex), Instant.now().toString()); } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/HandlerRouter.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/HandlerRouter.java index 98058bbb..e3757156 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/HandlerRouter.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/HandlerRouter.java @@ -105,7 +105,7 @@ private APIGatewayProxyResponseEvent handleServerlessException(ServerlessExcepti logger.error("Server error [{}]: {}", errorInfo.code(), e.getMessage(), e); } - return createResponse(e.getStatusCode(), com.mzc.secondproject.serverless.common.response.ApiResponse.error(errorInfo)); + return createResponse(e.getStatusCode(), com.mzc.secondproject.serverless.common.response.ApiResponse.fail(errorInfo)); } /** From 92d47076bd476a81854180306890bf2a19beaa19 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 9 Jan 2026 15:33:22 +0900 Subject: [PATCH 112/528] =?UTF-8?q?refactor(dto):=20ApiResponse,=20Paginat?= =?UTF-8?q?edResult=20record=20=EC=A0=84=ED=99=98=20=EB=B0=8F=20=EC=A0=84?= =?UTF-8?q?=EC=B2=B4=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../serverless/common/dto/ApiResponse.java | 46 ++++++------ .../common/{response => dto}/ErrorInfo.java | 2 +- .../common/dto/PaginatedResult.java | 27 ++----- .../common/response/ApiResponse.java | 72 ------------------- .../common/router/HandlerRouter.java | 14 ++-- .../chatting/handler/ChatAIHandler.java | 6 +- .../chatting/handler/ChatMessageHandler.java | 20 +++--- .../chatting/handler/ChatRoomHandler.java | 32 ++++----- .../chatting/handler/ChatVoiceHandler.java | 12 ++-- .../vocabulary/handler/DailyStudyHandler.java | 12 ++-- .../vocabulary/handler/StatsHandler.java | 12 ++-- .../vocabulary/handler/TestHandler.java | 24 +++---- .../vocabulary/handler/UserWordHandler.java | 24 +++---- .../vocabulary/handler/VoiceHandler.java | 12 ++-- .../vocabulary/handler/WordGroupHandler.java | 42 +++++------ .../vocabulary/handler/WordHandler.java | 44 ++++++------ .../service/DailyStudyCommandService.java | 8 +-- .../vocabulary/service/DailyStudyService.java | 8 +-- .../vocabulary/service/StatsService.java | 18 ++--- .../service/TestCommandService.java | 2 +- .../vocabulary/service/TestService.java | 2 +- .../service/UserWordQueryService.java | 8 +-- .../vocabulary/service/UserWordService.java | 4 +- 23 files changed, 181 insertions(+), 270 deletions(-) rename ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/{response => dto}/ErrorInfo.java (97%) delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/response/ApiResponse.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/ApiResponse.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/ApiResponse.java index 3e1acd6b..f30e6358 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/ApiResponse.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/ApiResponse.java @@ -1,33 +1,29 @@ package com.mzc.secondproject.serverless.common.dto; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; +/** + * 표준 API 응답 래퍼 + * + * @param isSuccess 성공 여부 + * @param message 응답 메시지 + * @param data 응답 데이터 + * @param error 에러 메시지 + */ +public record ApiResponse( + boolean isSuccess, + String message, + T data, + String error +) { -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class ApiResponse { - - private boolean success; - private String message; - private T data; - private String error; + public static ApiResponse ok(String message, T data) { + return new ApiResponse<>(true, message, data, null); + } - public static ApiResponse success(String message, T data) { - return ApiResponse.builder() - .success(true) - .message(message) - .data(data) - .build(); + public static ApiResponse ok(T data) { + return new ApiResponse<>(true, null, data, null); } - public static ApiResponse error(String errorMessage) { - return ApiResponse.builder() - .success(false) - .error(errorMessage) - .build(); + public static ApiResponse fail(String errorMessage) { + return new ApiResponse<>(false, null, null, errorMessage); } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/response/ErrorInfo.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/ErrorInfo.java similarity index 97% rename from ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/response/ErrorInfo.java rename to ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/ErrorInfo.java index 52552123..912af4e4 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/response/ErrorInfo.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/ErrorInfo.java @@ -1,4 +1,4 @@ -package com.mzc.secondproject.serverless.common.response; +package com.mzc.secondproject.serverless.common.dto; import com.mzc.secondproject.serverless.common.exception.DomainErrorCode; import com.mzc.secondproject.serverless.common.exception.ErrorCode; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/PaginatedResult.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/PaginatedResult.java index c6105258..00e6290d 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/PaginatedResult.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/PaginatedResult.java @@ -3,28 +3,15 @@ import java.util.List; /** - * 페이지네이션 결과를 담는 제네릭 클래스 - * 모든 Repository에서 공통으로 사용 + * 페이지네이션 결과를 담는 제네릭 레코드 * - * @param 결과 아이템 타입 + * @param items 결과 아이템 목록 + * @param nextCursor 다음 페이지 커서 (없으면 null) */ -public class PaginatedResult { - - private final List items; - private final String nextCursor; - - public PaginatedResult(List items, String nextCursor) { - this.items = items; - this.nextCursor = nextCursor; - } - - public List getItems() { - return items; - } - - public String getNextCursor() { - return nextCursor; - } +public record PaginatedResult( + List items, + String nextCursor +) { public boolean hasMore() { return nextCursor != null; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/response/ApiResponse.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/response/ApiResponse.java deleted file mode 100644 index e28cf1a0..00000000 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/response/ApiResponse.java +++ /dev/null @@ -1,72 +0,0 @@ -package com.mzc.secondproject.serverless.common.response; - -import java.time.Instant; - -/** - * 표준 API 응답 래퍼 - * - * 모든 API 응답의 일관된 형식을 제공합니다. - * 성공/실패 여부와 관계없이 동일한 구조를 유지합니다. - * - * 성공 응답 예시: - * { - * "success": true, - * "data": { ... }, - * "timestamp": "2024-01-01T00:00:00Z" - * } - * - * 실패 응답 예시: - * { - * "success": false, - * "error": { ... }, - * "timestamp": "2024-01-01T00:00:00Z" - * } - * - * @param success 성공 여부 - * @param data 응답 데이터 (성공 시) - * @param error 에러 정보 (실패 시) - * @param timestamp 응답 시각 - */ -public record ApiResponse( - boolean success, - T data, - ErrorInfo error, - String timestamp -) { - - /** - * 성공 응답 생성 (데이터 포함) - */ - public static ApiResponse ok(T data) { - return new ApiResponse<>(true, data, null, Instant.now().toString()); - } - - /** - * 성공 응답 생성 (데이터 없음) - */ - public static ApiResponse ok() { - return new ApiResponse<>(true, null, null, Instant.now().toString()); - } - - /** - * 실패 응답 생성 (ErrorInfo 직접 전달) - */ - public static ApiResponse fail(ErrorInfo errorInfo) { - return new ApiResponse<>(false, null, errorInfo, Instant.now().toString()); - } - - /** - * 실패 응답 생성 (에러 코드, 메시지 전달) - */ - public static ApiResponse fail(String code, String message, int status) { - return new ApiResponse<>(false, null, - ErrorInfo.of(code, message, status), Instant.now().toString()); - } - - /** - * 실패 응답 생성 (ServerlessException에서 변환) - */ - public static ApiResponse fail(com.mzc.secondproject.serverless.common.exception.ServerlessException ex) { - return new ApiResponse<>(false, null, ErrorInfo.from(ex), Instant.now().toString()); - } -} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/HandlerRouter.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/HandlerRouter.java index e3757156..fb459d86 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/HandlerRouter.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/HandlerRouter.java @@ -4,7 +4,7 @@ import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; import com.mzc.secondproject.serverless.common.dto.ApiResponse; import com.mzc.secondproject.serverless.common.exception.ServerlessException; -import com.mzc.secondproject.serverless.common.response.ErrorInfo; +import com.mzc.secondproject.serverless.common.dto.ErrorInfo; import static com.mzc.secondproject.serverless.common.util.ResponseUtil.createResponse; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -62,22 +62,22 @@ public APIGatewayProxyResponseEvent route(APIGatewayProxyRequestEvent request) { return handleServerlessException(e); } catch (IllegalArgumentException e) { logger.warn("Bad request: {}", e.getMessage()); - return createResponse(400, ApiResponse.error(e.getMessage())); + return createResponse(400, ApiResponse.fail(e.getMessage())); } catch (IllegalStateException e) { logger.warn("Conflict: {}", e.getMessage()); - return createResponse(409, ApiResponse.error(e.getMessage())); + return createResponse(409, ApiResponse.fail(e.getMessage())); } catch (SecurityException e) { logger.warn("Forbidden: {}", e.getMessage()); - return createResponse(403, ApiResponse.error(e.getMessage())); + return createResponse(403, ApiResponse.fail(e.getMessage())); } catch (Exception e) { logger.error("Error handling request", e); - return createResponse(500, ApiResponse.error("Internal server error: " + e.getMessage())); + return createResponse(500, ApiResponse.fail("Internal server error: " + e.getMessage())); } } } logger.warn("No route found for: {} {}", method, path); - return createResponse(404, ApiResponse.error("Not found")); + return createResponse(404, ApiResponse.fail("Not found")); } /** @@ -105,7 +105,7 @@ private APIGatewayProxyResponseEvent handleServerlessException(ServerlessExcepti logger.error("Server error [{}]: {}", errorInfo.code(), e.getMessage(), e); } - return createResponse(e.getStatusCode(), com.mzc.secondproject.serverless.common.response.ApiResponse.fail(errorInfo)); + return createResponse(e.getStatusCode(), errorInfo); } /** diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatAIHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatAIHandler.java index 9f3bbdff..bf8a3628 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatAIHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatAIHandler.java @@ -28,7 +28,7 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent re try { if (!"POST".equals(request.getHttpMethod())) { - return createResponse(405, ApiResponse.error("Method not allowed")); + return createResponse(405, ApiResponse.fail("Method not allowed")); } String body = request.getBody(); @@ -36,11 +36,11 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent re String aiResponse = bedrockService.generateResponse("Hello, how can I help you?"); - return createResponse(200, ApiResponse.success("AI response generated", Map.of("response", aiResponse))); + return createResponse(200, ApiResponse.ok("AI response generated", Map.of("response", aiResponse))); } catch (Exception e) { logger.error("Error generating AI response", e); - return createResponse(500, ApiResponse.error("Internal server error: " + e.getMessage())); + return createResponse(500, ApiResponse.fail("Internal server error: " + e.getMessage())); } } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatMessageHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatMessageHandler.java index 6faea299..01c00e72 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatMessageHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatMessageHandler.java @@ -56,13 +56,13 @@ private APIGatewayProxyResponseEvent sendMessage(APIGatewayProxyRequestEvent req String roomId = pathParams != null ? pathParams.get("roomId") : null; if (roomId == null) { - return createResponse(400, ApiResponse.error("roomId is required")); + return createResponse(400, ApiResponse.fail("roomId is required")); } SendMessageRequest req = ResponseUtil.gson().fromJson(request.getBody(), SendMessageRequest.class); if (req.getUserId() == null || req.getContent() == null) { - return createResponse(400, ApiResponse.error("userId and content are required")); + return createResponse(400, ApiResponse.fail("userId and content are required")); } String messageType = req.getMessageType() != null ? req.getMessageType() : "TEXT"; @@ -88,7 +88,7 @@ private APIGatewayProxyResponseEvent sendMessage(APIGatewayProxyRequestEvent req chatRoomRepository.updateLastMessageAt(roomId, now); logger.info("Message sent: {} in room: {}", messageId, roomId); - return createResponse(201, ApiResponse.success("Message sent", savedMessage)); + return createResponse(201, ApiResponse.ok("Message sent", savedMessage)); } private APIGatewayProxyResponseEvent getMessage(APIGatewayProxyRequestEvent request) { @@ -97,14 +97,14 @@ private APIGatewayProxyResponseEvent getMessage(APIGatewayProxyRequestEvent requ String messageId = pathParams != null ? pathParams.get("messageId") : null; if (roomId == null || messageId == null) { - return createResponse(400, ApiResponse.error("roomId and messageId are required")); + return createResponse(400, ApiResponse.fail("roomId and messageId are required")); } Optional message = chatMessageService.getMessage(roomId, messageId); if (message.isEmpty()) { - return createResponse(404, ApiResponse.error("Message not found")); + return createResponse(404, ApiResponse.fail("Message not found")); } - return createResponse(200, ApiResponse.success("Message retrieved", message.get())); + return createResponse(200, ApiResponse.ok("Message retrieved", message.get())); } private APIGatewayProxyResponseEvent getMessages(APIGatewayProxyRequestEvent request) { @@ -114,7 +114,7 @@ private APIGatewayProxyResponseEvent getMessages(APIGatewayProxyRequestEvent req String roomId = pathParams != null ? pathParams.get("roomId") : null; if (roomId == null) { - return createResponse(400, ApiResponse.error("roomId is required")); + return createResponse(400, ApiResponse.fail("roomId is required")); } int limit = 20; @@ -130,10 +130,10 @@ private APIGatewayProxyResponseEvent getMessages(APIGatewayProxyRequestEvent req PaginatedResult messagePage = chatMessageService.getMessagesByRoomWithPagination(roomId, limit, cursor); Map result = new HashMap<>(); - result.put("messages", messagePage.getItems()); - result.put("nextCursor", messagePage.getNextCursor()); + result.put("messages", messagePage.items()); + result.put("nextCursor", messagePage.nextCursor()); result.put("hasMore", messagePage.hasMore()); - return createResponse(200, ApiResponse.success("Messages retrieved", result)); + return createResponse(200, ApiResponse.ok("Messages retrieved", result)); } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java index 9206e97e..de1e3e18 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java @@ -60,7 +60,7 @@ private APIGatewayProxyResponseEvent createRoom(APIGatewayProxyRequestEvent requ CreateRoomRequest req = ResponseUtil.gson().fromJson(request.getBody(), CreateRoomRequest.class); if (req.getName() == null || req.getName().isEmpty()) { - return createResponse(400, ApiResponse.error("name is required")); + return createResponse(400, ApiResponse.fail("name is required")); } String level = req.getLevel() != null ? req.getLevel() : "beginner"; @@ -71,7 +71,7 @@ private APIGatewayProxyResponseEvent createRoom(APIGatewayProxyRequestEvent requ req.getName(), req.getDescription(), level, maxMembers, isPrivate, req.getPassword(), req.getCreatedBy()); room.setPassword(null); - return createResponse(201, ApiResponse.success("Room created", room)); + return createResponse(201, ApiResponse.ok("Room created", room)); } private APIGatewayProxyResponseEvent getRooms(APIGatewayProxyRequestEvent request) { @@ -88,7 +88,7 @@ private APIGatewayProxyResponseEvent getRooms(APIGatewayProxyRequestEvent reques } PaginatedResult roomPage = queryService.getRooms(level, limit, cursor); - List rooms = roomPage.getItems(); + List rooms = roomPage.items(); if ("true".equals(joined) && userId != null) { rooms = queryService.filterByJoinedUser(rooms, userId); @@ -98,10 +98,10 @@ private APIGatewayProxyResponseEvent getRooms(APIGatewayProxyRequestEvent reques Map result = new HashMap<>(); result.put("rooms", rooms); - result.put("nextCursor", roomPage.getNextCursor()); + result.put("nextCursor", roomPage.nextCursor()); result.put("hasMore", roomPage.hasMore()); - return createResponse(200, ApiResponse.success("Rooms retrieved", result)); + return createResponse(200, ApiResponse.ok("Rooms retrieved", result)); } private APIGatewayProxyResponseEvent getRoom(APIGatewayProxyRequestEvent request) { @@ -109,18 +109,18 @@ private APIGatewayProxyResponseEvent getRoom(APIGatewayProxyRequestEvent request String roomId = pathParams != null ? pathParams.get("roomId") : null; if (roomId == null) { - return createResponse(400, ApiResponse.error("roomId is required")); + return createResponse(400, ApiResponse.fail("roomId is required")); } Optional optRoom = queryService.getRoom(roomId); if (optRoom.isEmpty()) { - return createResponse(404, ApiResponse.error("Room not found")); + return createResponse(404, ApiResponse.fail("Room not found")); } ChatRoom room = optRoom.get(); room.setPassword(null); - return createResponse(200, ApiResponse.success("Room retrieved", room)); + return createResponse(200, ApiResponse.ok("Room retrieved", room)); } private APIGatewayProxyResponseEvent joinRoom(APIGatewayProxyRequestEvent request) { @@ -130,12 +130,12 @@ private APIGatewayProxyResponseEvent joinRoom(APIGatewayProxyRequestEvent reques JoinRoomRequest req = ResponseUtil.gson().fromJson(request.getBody(), JoinRoomRequest.class); if (roomId == null || req.getUserId() == null) { - return createResponse(400, ApiResponse.error("roomId and userId are required")); + return createResponse(400, ApiResponse.fail("roomId and userId are required")); } JoinRoomResponse response = commandService.joinRoom(roomId, req.getUserId(), req.getPassword()); response.getRoom().setPassword(null); - return createResponse(200, ApiResponse.success("Joined room", response)); + return createResponse(200, ApiResponse.ok("Joined room", response)); } private APIGatewayProxyResponseEvent leaveRoom(APIGatewayProxyRequestEvent request) { @@ -145,15 +145,15 @@ private APIGatewayProxyResponseEvent leaveRoom(APIGatewayProxyRequestEvent reque LeaveRoomRequest req = ResponseUtil.gson().fromJson(request.getBody(), LeaveRoomRequest.class); if (roomId == null || req.getUserId() == null) { - return createResponse(400, ApiResponse.error("roomId and userId are required")); + return createResponse(400, ApiResponse.fail("roomId and userId are required")); } ChatRoomCommandService.LeaveResult result = commandService.leaveRoom(roomId, req.getUserId()); if (result.deleted()) { - return createResponse(200, ApiResponse.success("Room deleted", null)); + return createResponse(200, ApiResponse.ok("Room deleted", null)); } result.room().setPassword(null); - return createResponse(200, ApiResponse.success("Left room", result.room())); + return createResponse(200, ApiResponse.ok("Left room", result.room())); } private APIGatewayProxyResponseEvent deleteRoom(APIGatewayProxyRequestEvent request) { @@ -164,14 +164,14 @@ private APIGatewayProxyResponseEvent deleteRoom(APIGatewayProxyRequestEvent requ String userId = queryParams != null ? queryParams.get("userId") : null; if (roomId == null) { - return createResponse(400, ApiResponse.error("roomId is required")); + return createResponse(400, ApiResponse.fail("roomId is required")); } if (userId == null) { - return createResponse(400, ApiResponse.error("userId is required")); + return createResponse(400, ApiResponse.fail("userId is required")); } commandService.deleteRoom(roomId, userId); - return createResponse(200, ApiResponse.success("Room deleted", null)); + return createResponse(200, ApiResponse.ok("Room deleted", null)); } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatVoiceHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatVoiceHandler.java index 1124d2a2..d4082d1f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatVoiceHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatVoiceHandler.java @@ -36,7 +36,7 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent re try { if (!"POST".equals(request.getHttpMethod())) { - return createResponse(405, ApiResponse.error("Method not allowed")); + return createResponse(405, ApiResponse.fail("Method not allowed")); } String body = request.getBody(); @@ -46,16 +46,16 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent re String voice = requestBody.getOrDefault("voice", "FEMALE"); if (messageId == null || messageId.isEmpty()) { - return createResponse(400, ApiResponse.error("messageId is required")); + return createResponse(400, ApiResponse.fail("messageId is required")); } if (roomId == null || roomId.isEmpty()) { - return createResponse(400, ApiResponse.error("roomId is required")); + return createResponse(400, ApiResponse.fail("roomId is required")); } // 메시지 조회 Optional messageOpt = messageRepository.findByRoomIdAndMessageId(roomId, messageId); if (messageOpt.isEmpty()) { - return createResponse(404, ApiResponse.error("Message not found")); + return createResponse(404, ApiResponse.fail("Message not found")); } ChatMessage message = messageOpt.get(); @@ -89,7 +89,7 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent re cached = result.isCached(); } - return createResponse(200, ApiResponse.success( + return createResponse(200, ApiResponse.ok( cached ? "Speech retrieved from cache" : "Speech synthesized", Map.of( "audioUrl", audioUrl, @@ -99,7 +99,7 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent re } catch (Exception e) { logger.error("Error synthesizing speech", e); - return createResponse(500, ApiResponse.error("Internal server error: " + e.getMessage())); + return createResponse(500, ApiResponse.fail("Internal server error: " + e.getMessage())); } } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/DailyStudyHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/DailyStudyHandler.java index 09581fe5..df0c97b3 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/DailyStudyHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/DailyStudyHandler.java @@ -49,7 +49,7 @@ private APIGatewayProxyResponseEvent getDailyWords(APIGatewayProxyRequestEvent r String userId = pathParams != null ? pathParams.get("userId") : null; if (userId == null) { - return createResponse(400, ApiResponse.error("userId is required")); + return createResponse(400, ApiResponse.fail("userId is required")); } String date = queryParams != null ? queryParams.get("date") : null; @@ -69,14 +69,14 @@ private APIGatewayProxyResponseEvent getDailyWords(APIGatewayProxyRequestEvent r response.put("reviewWords", result.reviewWords()); response.put("progress", result.progress()); - return createResponse(200, ApiResponse.success("Daily words retrieved", response)); + return createResponse(200, ApiResponse.ok("Daily words retrieved", response)); } private APIGatewayProxyResponseEvent getDailyStudyByDate(String userId, String date) { var optDailyStudy = queryService.getDailyStudy(userId, date); if (optDailyStudy.isEmpty()) { - return createResponse(404, ApiResponse.error("No daily study found for date: " + date)); + return createResponse(404, ApiResponse.fail("No daily study found for date: " + date)); } var dailyStudy = optDailyStudy.get(); @@ -90,7 +90,7 @@ private APIGatewayProxyResponseEvent getDailyStudyByDate(String userId, String d response.put("reviewWords", reviewWords); response.put("progress", progress); - return createResponse(200, ApiResponse.success("Daily study retrieved for " + date, response)); + return createResponse(200, ApiResponse.ok("Daily study retrieved for " + date, response)); } private APIGatewayProxyResponseEvent markWordLearned(APIGatewayProxyRequestEvent request) { @@ -99,10 +99,10 @@ private APIGatewayProxyResponseEvent markWordLearned(APIGatewayProxyRequestEvent String wordId = pathParams != null ? pathParams.get("wordId") : null; if (userId == null || wordId == null) { - return createResponse(400, ApiResponse.error("userId and wordId are required")); + return createResponse(400, ApiResponse.fail("userId and wordId are required")); } Map progress = commandService.markWordLearned(userId, wordId); - return createResponse(200, ApiResponse.success("Word marked as learned", progress)); + return createResponse(200, ApiResponse.ok("Word marked as learned", progress)); } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatsHandler.java index 8702637e..c345394d 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatsHandler.java @@ -46,11 +46,11 @@ private APIGatewayProxyResponseEvent getOverallStats(APIGatewayProxyRequestEvent String userId = pathParams != null ? pathParams.get("userId") : null; if (userId == null) { - return createResponse(400, ApiResponse.error("userId is required")); + return createResponse(400, ApiResponse.fail("userId is required")); } Map stats = statsService.getOverallStats(userId); - return createResponse(200, ApiResponse.success("Stats retrieved", stats)); + return createResponse(200, ApiResponse.ok("Stats retrieved", stats)); } private APIGatewayProxyResponseEvent getDailyStats(APIGatewayProxyRequestEvent request) { @@ -61,7 +61,7 @@ private APIGatewayProxyResponseEvent getDailyStats(APIGatewayProxyRequestEvent r String cursor = queryParams != null ? queryParams.get("cursor") : null; if (userId == null) { - return createResponse(400, ApiResponse.error("userId is required")); + return createResponse(400, ApiResponse.fail("userId is required")); } int limit = 30; @@ -76,7 +76,7 @@ private APIGatewayProxyResponseEvent getDailyStats(APIGatewayProxyRequestEvent r result.put("nextCursor", dailyResult.nextCursor()); result.put("hasMore", dailyResult.hasMore()); - return createResponse(200, ApiResponse.success("Daily stats retrieved", result)); + return createResponse(200, ApiResponse.ok("Daily stats retrieved", result)); } private APIGatewayProxyResponseEvent getWeaknessAnalysis(APIGatewayProxyRequestEvent request) { @@ -84,10 +84,10 @@ private APIGatewayProxyResponseEvent getWeaknessAnalysis(APIGatewayProxyRequestE String userId = pathParams != null ? pathParams.get("userId") : null; if (userId == null) { - return createResponse(400, ApiResponse.error("userId is required")); + return createResponse(400, ApiResponse.fail("userId is required")); } Map analysis = statsService.getWeaknessAnalysis(userId); - return createResponse(200, ApiResponse.success("Weakness analysis completed", analysis)); + return createResponse(200, ApiResponse.ok("Weakness analysis completed", analysis)); } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/TestHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/TestHandler.java index dae249a3..2f0e5746 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/TestHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/TestHandler.java @@ -56,7 +56,7 @@ private APIGatewayProxyResponseEvent startTest(APIGatewayProxyRequestEvent reque String userId = pathParams != null ? pathParams.get("userId") : null; if (userId == null) { - return createResponse(400, ApiResponse.error("userId is required")); + return createResponse(400, ApiResponse.fail("userId is required")); } String body = request.getBody(); @@ -72,7 +72,7 @@ private APIGatewayProxyResponseEvent startTest(APIGatewayProxyRequestEvent reque response.put("totalQuestions", result.totalQuestions()); response.put("startedAt", result.startedAt()); - return createResponse(200, ApiResponse.success("Test started", response)); + return createResponse(200, ApiResponse.ok("Test started", response)); } private APIGatewayProxyResponseEvent submitAnswer(APIGatewayProxyRequestEvent request) { @@ -80,14 +80,14 @@ private APIGatewayProxyResponseEvent submitAnswer(APIGatewayProxyRequestEvent re String userId = pathParams != null ? pathParams.get("userId") : null; if (userId == null) { - return createResponse(400, ApiResponse.error("userId is required")); + return createResponse(400, ApiResponse.fail("userId is required")); } String body = request.getBody(); SubmitTestRequest req = ResponseUtil.gson().fromJson(body, SubmitTestRequest.class); if (req.getTestId() == null || req.getAnswers() == null) { - return createResponse(400, ApiResponse.error("testId and answers are required")); + return createResponse(400, ApiResponse.fail("testId and answers are required")); } String testType = req.getTestType() != null ? req.getTestType() : "DAILY"; @@ -103,7 +103,7 @@ private APIGatewayProxyResponseEvent submitAnswer(APIGatewayProxyRequestEvent re response.put("successRate", result.successRate()); response.put("results", result.results()); - return createResponse(200, ApiResponse.success("Test submitted", response)); + return createResponse(200, ApiResponse.ok("Test submitted", response)); } private APIGatewayProxyResponseEvent getTestResults(APIGatewayProxyRequestEvent request) { @@ -114,7 +114,7 @@ private APIGatewayProxyResponseEvent getTestResults(APIGatewayProxyRequestEvent String cursor = queryParams != null ? queryParams.get("cursor") : null; if (userId == null) { - return createResponse(400, ApiResponse.error("userId is required")); + return createResponse(400, ApiResponse.fail("userId is required")); } int limit = 10; @@ -125,11 +125,11 @@ private APIGatewayProxyResponseEvent getTestResults(APIGatewayProxyRequestEvent PaginatedResult resultPage = queryService.getTestResults(userId, limit, cursor); Map result = new HashMap<>(); - result.put("testResults", resultPage.getItems()); - result.put("nextCursor", resultPage.getNextCursor()); + result.put("testResults", resultPage.items()); + result.put("nextCursor", resultPage.nextCursor()); result.put("hasMore", resultPage.hasMore()); - return createResponse(200, ApiResponse.success("Test results retrieved", result)); + return createResponse(200, ApiResponse.ok("Test results retrieved", result)); } private APIGatewayProxyResponseEvent getTestResultDetail(APIGatewayProxyRequestEvent request) { @@ -139,12 +139,12 @@ private APIGatewayProxyResponseEvent getTestResultDetail(APIGatewayProxyRequestE String testId = pathParams != null ? pathParams.get("testId") : null; if (userId == null || testId == null) { - return createResponse(400, ApiResponse.error("userId and testId are required")); + return createResponse(400, ApiResponse.fail("userId and testId are required")); } var optDetail = queryService.getTestResultDetail(userId, testId); if (optDetail.isEmpty()) { - return createResponse(404, ApiResponse.error("Test result not found")); + return createResponse(404, ApiResponse.fail("Test result not found")); } var detail = optDetail.get(); @@ -160,6 +160,6 @@ private APIGatewayProxyResponseEvent getTestResultDetail(APIGatewayProxyRequestE result.put("startedAt", detail.testResult().getStartedAt()); result.put("completedAt", detail.testResult().getCompletedAt()); - return createResponse(200, ApiResponse.success("Test result detail retrieved", result)); + return createResponse(200, ApiResponse.ok("Test result detail retrieved", result)); } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java index 54d2a8b5..d6fc8d4c 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java @@ -64,7 +64,7 @@ private APIGatewayProxyResponseEvent getUserWords(APIGatewayProxyRequestEvent re String incorrectOnly = queryParams != null ? queryParams.get("incorrectOnly") : null; if (userId == null) { - return createResponse(400, ApiResponse.error("userId is required")); + return createResponse(400, ApiResponse.fail("userId is required")); } int limit = 20; @@ -79,7 +79,7 @@ private APIGatewayProxyResponseEvent getUserWords(APIGatewayProxyRequestEvent re response.put("nextCursor", result.nextCursor()); response.put("hasMore", result.hasMore()); - return createResponse(200, ApiResponse.success("User words retrieved", response)); + return createResponse(200, ApiResponse.ok("User words retrieved", response)); } private APIGatewayProxyResponseEvent getUserWord(APIGatewayProxyRequestEvent request) { @@ -88,15 +88,15 @@ private APIGatewayProxyResponseEvent getUserWord(APIGatewayProxyRequestEvent req String wordId = pathParams != null ? pathParams.get("wordId") : null; if (userId == null || wordId == null) { - return createResponse(400, ApiResponse.error("userId and wordId are required")); + return createResponse(400, ApiResponse.fail("userId and wordId are required")); } Optional optUserWord = queryService.getUserWord(userId, wordId); if (optUserWord.isEmpty()) { - return createResponse(404, ApiResponse.error("UserWord not found")); + return createResponse(404, ApiResponse.fail("UserWord not found")); } - return createResponse(200, ApiResponse.success("UserWord retrieved", optUserWord.get())); + return createResponse(200, ApiResponse.ok("UserWord retrieved", optUserWord.get())); } private APIGatewayProxyResponseEvent updateUserWord(APIGatewayProxyRequestEvent request) { @@ -105,18 +105,18 @@ private APIGatewayProxyResponseEvent updateUserWord(APIGatewayProxyRequestEvent String wordId = pathParams != null ? pathParams.get("wordId") : null; if (userId == null || wordId == null) { - return createResponse(400, ApiResponse.error("userId and wordId are required")); + return createResponse(400, ApiResponse.fail("userId and wordId are required")); } String body = request.getBody(); UpdateUserWordRequest req = ResponseUtil.gson().fromJson(body, UpdateUserWordRequest.class); if (req.getIsCorrect() == null) { - return createResponse(400, ApiResponse.error("isCorrect is required")); + return createResponse(400, ApiResponse.fail("isCorrect is required")); } UserWord userWord = commandService.updateUserWord(userId, wordId, req.getIsCorrect()); - return createResponse(200, ApiResponse.success("UserWord updated", userWord)); + return createResponse(200, ApiResponse.ok("UserWord updated", userWord)); } private APIGatewayProxyResponseEvent updateUserWordTag(APIGatewayProxyRequestEvent request) { @@ -125,14 +125,14 @@ private APIGatewayProxyResponseEvent updateUserWordTag(APIGatewayProxyRequestEve String wordId = pathParams != null ? pathParams.get("wordId") : null; if (userId == null || wordId == null) { - return createResponse(400, ApiResponse.error("userId and wordId are required")); + return createResponse(400, ApiResponse.fail("userId and wordId are required")); } String body = request.getBody(); UpdateUserWordTagRequest req = ResponseUtil.gson().fromJson(body, UpdateUserWordTagRequest.class); UserWord userWord = commandService.updateUserWordTag(userId, wordId, req.getBookmarked(), req.getFavorite(), req.getDifficulty()); - return createResponse(200, ApiResponse.success("Tag updated", userWord)); + return createResponse(200, ApiResponse.ok("Tag updated", userWord)); } private APIGatewayProxyResponseEvent getWrongAnswers(APIGatewayProxyRequestEvent request) { @@ -147,7 +147,7 @@ private APIGatewayProxyResponseEvent getWrongAnswers(APIGatewayProxyRequestEvent .build(); if (validation.isInvalid()) { - return createResponse(400, ApiResponse.error(validation.getErrorMessage().orElse("Validation failed"))); + return createResponse(400, ApiResponse.fail(validation.getErrorMessage().orElse("Validation failed"))); } int limit = parseIntParam(queryParams, "limit", 20, 1, 50); @@ -160,7 +160,7 @@ private APIGatewayProxyResponseEvent getWrongAnswers(APIGatewayProxyRequestEvent response.put("nextCursor", result.nextCursor()); response.put("hasMore", result.hasMore()); - return createResponse(200, ApiResponse.success("Wrong answers retrieved", response)); + return createResponse(200, ApiResponse.ok("Wrong answers retrieved", response)); } private int parseIntParam(Map params, String key, int defaultValue, int min, int max) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/VoiceHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/VoiceHandler.java index 5613c93c..f1807cba 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/VoiceHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/VoiceHandler.java @@ -44,11 +44,11 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent re return synthesizeSpeech(request); } - return createResponse(404, ApiResponse.error("Not found")); + return createResponse(404, ApiResponse.fail("Not found")); } catch (Exception e) { logger.error("Error handling request", e); - return createResponse(500, ApiResponse.error("Internal server error: " + e.getMessage())); + return createResponse(500, ApiResponse.fail("Internal server error: " + e.getMessage())); } } @@ -57,7 +57,7 @@ private APIGatewayProxyResponseEvent synthesizeSpeech(APIGatewayProxyRequestEven SynthesizeVoiceRequest req = ResponseUtil.gson().fromJson(body, SynthesizeVoiceRequest.class); if (req.getWordId() == null || req.getWordId().isEmpty()) { - return createResponse(400, ApiResponse.error("wordId is required")); + return createResponse(400, ApiResponse.fail("wordId is required")); } String wordId = req.getWordId(); @@ -67,7 +67,7 @@ private APIGatewayProxyResponseEvent synthesizeSpeech(APIGatewayProxyRequestEven // 단어 조회 Optional optWord = wordRepository.findById(wordId); if (optWord.isEmpty()) { - return createResponse(404, ApiResponse.error("Word not found")); + return createResponse(404, ApiResponse.fail("Word not found")); } Word word = optWord.get(); @@ -76,7 +76,7 @@ private APIGatewayProxyResponseEvent synthesizeSpeech(APIGatewayProxyRequestEven // 예문 요청인데 예문이 없는 경우 if (isExample && (word.getExample() == null || word.getExample().isEmpty())) { - return createResponse(400, ApiResponse.error("This word has no example sentence")); + return createResponse(400, ApiResponse.fail("This word has no example sentence")); } // 음성 합성할 텍스트 결정 @@ -115,7 +115,7 @@ private APIGatewayProxyResponseEvent synthesizeSpeech(APIGatewayProxyRequestEven responseData.put("audioUrl", audioUrl); responseData.put("cached", cached); - return createResponse(200, ApiResponse.success("Speech synthesized", responseData)); + return createResponse(200, ApiResponse.ok("Speech synthesized", responseData)); } private String getCachedKey(Word word, boolean isMale, boolean isExample) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/WordGroupHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/WordGroupHandler.java index 8e3b0d1d..2d26ceb0 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/WordGroupHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/WordGroupHandler.java @@ -68,11 +68,11 @@ private APIGatewayProxyResponseEvent createGroup(APIGatewayProxyRequestEvent req .build(); if (validation.isInvalid()) { - return createResponse(400, ApiResponse.error(validation.getErrorMessage().orElse("Validation failed"))); + return createResponse(400, ApiResponse.fail(validation.getErrorMessage().orElse("Validation failed"))); } WordGroup group = commandService.createGroup(userId, req.getGroupName(), req.getDescription()); - return createResponse(201, ApiResponse.success("Group created", group)); + return createResponse(201, ApiResponse.ok("Group created", group)); } private APIGatewayProxyResponseEvent getGroups(APIGatewayProxyRequestEvent request) { @@ -87,7 +87,7 @@ private APIGatewayProxyResponseEvent getGroups(APIGatewayProxyRequestEvent reque .build(); if (validation.isInvalid()) { - return createResponse(400, ApiResponse.error(validation.getErrorMessage().orElse("Validation failed"))); + return createResponse(400, ApiResponse.fail(validation.getErrorMessage().orElse("Validation failed"))); } int limit = parseIntParam(queryParams, "limit", 20, 1, 50); @@ -95,11 +95,11 @@ private APIGatewayProxyResponseEvent getGroups(APIGatewayProxyRequestEvent reque PaginatedResult result = queryService.getGroups(userId, limit, cursor); Map response = new HashMap<>(); - response.put("groups", result.getItems()); - response.put("nextCursor", result.getNextCursor()); + response.put("groups", result.items()); + response.put("nextCursor", result.nextCursor()); response.put("hasMore", result.hasMore()); - return createResponse(200, ApiResponse.success("Groups retrieved", response)); + return createResponse(200, ApiResponse.ok("Groups retrieved", response)); } private APIGatewayProxyResponseEvent getGroupDetail(APIGatewayProxyRequestEvent request) { @@ -113,12 +113,12 @@ private APIGatewayProxyResponseEvent getGroupDetail(APIGatewayProxyRequestEvent .build(); if (validation.isInvalid()) { - return createResponse(400, ApiResponse.error(validation.getErrorMessage().orElse("Validation failed"))); + return createResponse(400, ApiResponse.fail(validation.getErrorMessage().orElse("Validation failed"))); } var optDetail = queryService.getGroupDetail(userId, groupId); if (optDetail.isEmpty()) { - return createResponse(404, ApiResponse.error("Group not found")); + return createResponse(404, ApiResponse.fail("Group not found")); } var detail = optDetail.get(); @@ -132,7 +132,7 @@ private APIGatewayProxyResponseEvent getGroupDetail(APIGatewayProxyRequestEvent response.put("createdAt", detail.group().getCreatedAt()); response.put("updatedAt", detail.group().getUpdatedAt()); - return createResponse(200, ApiResponse.success("Group detail retrieved", response)); + return createResponse(200, ApiResponse.ok("Group detail retrieved", response)); } private APIGatewayProxyResponseEvent updateGroup(APIGatewayProxyRequestEvent request) { @@ -146,7 +146,7 @@ private APIGatewayProxyResponseEvent updateGroup(APIGatewayProxyRequestEvent req .build(); if (validation.isInvalid()) { - return createResponse(400, ApiResponse.error(validation.getErrorMessage().orElse("Validation failed"))); + return createResponse(400, ApiResponse.fail(validation.getErrorMessage().orElse("Validation failed"))); } String body = request.getBody(); @@ -156,9 +156,9 @@ private APIGatewayProxyResponseEvent updateGroup(APIGatewayProxyRequestEvent req WordGroup group = commandService.updateGroup(userId, groupId, req != null ? req.getGroupName() : null, req != null ? req.getDescription() : null); - return createResponse(200, ApiResponse.success("Group updated", group)); + return createResponse(200, ApiResponse.ok("Group updated", group)); } catch (IllegalArgumentException e) { - return createResponse(404, ApiResponse.error(e.getMessage())); + return createResponse(404, ApiResponse.fail(e.getMessage())); } } @@ -173,14 +173,14 @@ private APIGatewayProxyResponseEvent deleteGroup(APIGatewayProxyRequestEvent req .build(); if (validation.isInvalid()) { - return createResponse(400, ApiResponse.error(validation.getErrorMessage().orElse("Validation failed"))); + return createResponse(400, ApiResponse.fail(validation.getErrorMessage().orElse("Validation failed"))); } try { commandService.deleteGroup(userId, groupId); - return createResponse(200, ApiResponse.success("Group deleted", null)); + return createResponse(200, ApiResponse.ok("Group deleted", null)); } catch (IllegalArgumentException e) { - return createResponse(404, ApiResponse.error(e.getMessage())); + return createResponse(404, ApiResponse.fail(e.getMessage())); } } @@ -197,14 +197,14 @@ private APIGatewayProxyResponseEvent addWordToGroup(APIGatewayProxyRequestEvent .build(); if (validation.isInvalid()) { - return createResponse(400, ApiResponse.error(validation.getErrorMessage().orElse("Validation failed"))); + return createResponse(400, ApiResponse.fail(validation.getErrorMessage().orElse("Validation failed"))); } try { WordGroup group = commandService.addWordToGroup(userId, groupId, wordId); - return createResponse(200, ApiResponse.success("Word added to group", group)); + return createResponse(200, ApiResponse.ok("Word added to group", group)); } catch (IllegalArgumentException e) { - return createResponse(404, ApiResponse.error(e.getMessage())); + return createResponse(404, ApiResponse.fail(e.getMessage())); } } @@ -221,14 +221,14 @@ private APIGatewayProxyResponseEvent removeWordFromGroup(APIGatewayProxyRequestE .build(); if (validation.isInvalid()) { - return createResponse(400, ApiResponse.error(validation.getErrorMessage().orElse("Validation failed"))); + return createResponse(400, ApiResponse.fail(validation.getErrorMessage().orElse("Validation failed"))); } try { WordGroup group = commandService.removeWordFromGroup(userId, groupId, wordId); - return createResponse(200, ApiResponse.success("Word removed from group", group)); + return createResponse(200, ApiResponse.ok("Word removed from group", group)); } catch (IllegalArgumentException e) { - return createResponse(404, ApiResponse.error(e.getMessage())); + return createResponse(404, ApiResponse.fail(e.getMessage())); } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/WordHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/WordHandler.java index 1c0f2e82..c39d05f7 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/WordHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/WordHandler.java @@ -62,17 +62,17 @@ private APIGatewayProxyResponseEvent createWord(APIGatewayProxyRequestEvent requ CreateWordRequest req = ResponseUtil.gson().fromJson(body, CreateWordRequest.class); if (req.getEnglish() == null || req.getEnglish().isEmpty()) { - return createResponse(400, ApiResponse.error("english is required")); + return createResponse(400, ApiResponse.fail("english is required")); } if (req.getKorean() == null || req.getKorean().isEmpty()) { - return createResponse(400, ApiResponse.error("korean is required")); + return createResponse(400, ApiResponse.fail("korean is required")); } String level = req.getLevel() != null ? req.getLevel() : "BEGINNER"; String category = req.getCategory() != null ? req.getCategory() : "DAILY"; Word word = commandService.createWord(req.getEnglish(), req.getKorean(), req.getExample(), level, category); - return createResponse(201, ApiResponse.success("Word created", word)); + return createResponse(201, ApiResponse.ok("Word created", word)); } private APIGatewayProxyResponseEvent getWords(APIGatewayProxyRequestEvent request) { @@ -90,11 +90,11 @@ private APIGatewayProxyResponseEvent getWords(APIGatewayProxyRequestEvent reques PaginatedResult wordPage = queryService.getWords(level, category, limit, cursor); Map result = new HashMap<>(); - result.put("words", wordPage.getItems()); - result.put("nextCursor", wordPage.getNextCursor()); + result.put("words", wordPage.items()); + result.put("nextCursor", wordPage.nextCursor()); result.put("hasMore", wordPage.hasMore()); - return createResponse(200, ApiResponse.success("Words retrieved", result)); + return createResponse(200, ApiResponse.ok("Words retrieved", result)); } private APIGatewayProxyResponseEvent getWord(APIGatewayProxyRequestEvent request) { @@ -102,15 +102,15 @@ private APIGatewayProxyResponseEvent getWord(APIGatewayProxyRequestEvent request String wordId = pathParams != null ? pathParams.get("wordId") : null; if (wordId == null) { - return createResponse(400, ApiResponse.error("wordId is required")); + return createResponse(400, ApiResponse.fail("wordId is required")); } Optional optWord = queryService.getWord(wordId); if (optWord.isEmpty()) { - return createResponse(404, ApiResponse.error("Word not found")); + return createResponse(404, ApiResponse.fail("Word not found")); } - return createResponse(200, ApiResponse.success("Word retrieved", optWord.get())); + return createResponse(200, ApiResponse.ok("Word retrieved", optWord.get())); } private APIGatewayProxyResponseEvent updateWord(APIGatewayProxyRequestEvent request) { @@ -118,14 +118,14 @@ private APIGatewayProxyResponseEvent updateWord(APIGatewayProxyRequestEvent requ String wordId = pathParams != null ? pathParams.get("wordId") : null; if (wordId == null) { - return createResponse(400, ApiResponse.error("wordId is required")); + return createResponse(400, ApiResponse.fail("wordId is required")); } String body = request.getBody(); Map requestBody = ResponseUtil.gson().fromJson(body, Map.class); Word word = commandService.updateWord(wordId, requestBody); - return createResponse(200, ApiResponse.success("Word updated", word)); + return createResponse(200, ApiResponse.ok("Word updated", word)); } private APIGatewayProxyResponseEvent deleteWord(APIGatewayProxyRequestEvent request) { @@ -133,11 +133,11 @@ private APIGatewayProxyResponseEvent deleteWord(APIGatewayProxyRequestEvent requ String wordId = pathParams != null ? pathParams.get("wordId") : null; if (wordId == null) { - return createResponse(400, ApiResponse.error("wordId is required")); + return createResponse(400, ApiResponse.fail("wordId is required")); } commandService.deleteWord(wordId); - return createResponse(200, ApiResponse.success("Word deleted", null)); + return createResponse(200, ApiResponse.ok("Word deleted", null)); } private APIGatewayProxyResponseEvent createWordsBatch(APIGatewayProxyRequestEvent request) { @@ -145,7 +145,7 @@ private APIGatewayProxyResponseEvent createWordsBatch(APIGatewayProxyRequestEven CreateWordsBatchRequest req = ResponseUtil.gson().fromJson(body, CreateWordsBatchRequest.class); if (req.getWords() == null || req.getWords().isEmpty()) { - return createResponse(400, ApiResponse.error("words array is required")); + return createResponse(400, ApiResponse.fail("words array is required")); } WordCommandService.BatchResult result = commandService.createWordsBatch(req.getWords()); @@ -155,7 +155,7 @@ private APIGatewayProxyResponseEvent createWordsBatch(APIGatewayProxyRequestEven response.put("failCount", result.failCount()); response.put("totalRequested", result.totalRequested()); - return createResponse(201, ApiResponse.success("Batch completed", response)); + return createResponse(201, ApiResponse.ok("Batch completed", response)); } private APIGatewayProxyResponseEvent searchWords(APIGatewayProxyRequestEvent request) { @@ -165,7 +165,7 @@ private APIGatewayProxyResponseEvent searchWords(APIGatewayProxyRequestEvent req String cursor = queryParams != null ? queryParams.get("cursor") : null; if (query == null || query.isEmpty()) { - return createResponse(400, ApiResponse.error("q (query) parameter is required")); + return createResponse(400, ApiResponse.fail("q (query) parameter is required")); } int limit = 20; @@ -176,12 +176,12 @@ private APIGatewayProxyResponseEvent searchWords(APIGatewayProxyRequestEvent req PaginatedResult wordPage = queryService.searchWords(query, limit, cursor); Map result = new HashMap<>(); - result.put("words", wordPage.getItems()); + result.put("words", wordPage.items()); result.put("query", query); - result.put("nextCursor", wordPage.getNextCursor()); + result.put("nextCursor", wordPage.nextCursor()); result.put("hasMore", wordPage.hasMore()); - return createResponse(200, ApiResponse.success("Search completed", result)); + return createResponse(200, ApiResponse.ok("Search completed", result)); } private APIGatewayProxyResponseEvent getWordsBatch(APIGatewayProxyRequestEvent request) { @@ -189,11 +189,11 @@ private APIGatewayProxyResponseEvent getWordsBatch(APIGatewayProxyRequestEvent r BatchGetWordsRequest req = ResponseUtil.gson().fromJson(body, BatchGetWordsRequest.class); if (req.getWordIds() == null || req.getWordIds().isEmpty()) { - return createResponse(400, ApiResponse.error("wordIds array is required")); + return createResponse(400, ApiResponse.fail("wordIds array is required")); } if (req.getWordIds().size() > 100) { - return createResponse(400, ApiResponse.error("Maximum 100 wordIds allowed per request")); + return createResponse(400, ApiResponse.fail("Maximum 100 wordIds allowed per request")); } List words = queryService.getWordsByIds(req.getWordIds()); @@ -203,6 +203,6 @@ private APIGatewayProxyResponseEvent getWordsBatch(APIGatewayProxyRequestEvent r result.put("requestedCount", req.getWordIds().size()); result.put("retrievedCount", words.size()); - return createResponse(200, ApiResponse.success("Words retrieved", result)); + return createResponse(200, ApiResponse.ok("Words retrieved", result)); } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyCommandService.java index 1790d845..ad1cc1fa 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyCommandService.java @@ -97,7 +97,7 @@ private DailyStudy createDailyStudy(String userId, String date, String level) { String now = Instant.now().toString(); PaginatedResult reviewPage = userWordRepository.findReviewDueWords(userId, date, REVIEW_WORDS_COUNT, null); - List reviewWordIds = reviewPage.getItems().stream() + List reviewWordIds = reviewPage.items().stream() .map(UserWord::getWordId) .collect(Collectors.toList()); @@ -128,7 +128,7 @@ private DailyStudy createDailyStudy(String userId, String date, String level) { private List getNewWordsForUser(String userId, String level, int count) { PaginatedResult userWordPage = userWordRepository.findByUserIdWithPagination(userId, 1000, null); - List learnedWordIds = userWordPage.getItems().stream() + List learnedWordIds = userWordPage.items().stream() .map(UserWord::getWordId) .collect(Collectors.toList()); @@ -137,13 +137,13 @@ private List getNewWordsForUser(String userId, String level, int count) do { PaginatedResult wordPage = wordRepository.findByLevelWithPagination(level, count * 2, lastEvaluatedKey); - for (Word word : wordPage.getItems()) { + for (Word word : wordPage.items()) { if (!learnedWordIds.contains(word.getWordId()) && !newWordIds.contains(word.getWordId())) { newWordIds.add(word.getWordId()); if (newWordIds.size() >= count) break; } } - lastEvaluatedKey = wordPage.getNextCursor(); + lastEvaluatedKey = wordPage.nextCursor(); } while (newWordIds.size() < count && lastEvaluatedKey != null); logger.info("Selected {} new words for user {} at level {}", newWordIds.size(), userId, level); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyService.java index 17f763e6..6bc0d1c6 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyService.java @@ -92,7 +92,7 @@ private DailyStudy createDailyStudy(String userId, String date, String level) { String now = Instant.now().toString(); PaginatedResult reviewPage = userWordRepository.findReviewDueWords(userId, date, REVIEW_WORDS_COUNT, null); - List reviewWordIds = reviewPage.getItems().stream() + List reviewWordIds = reviewPage.items().stream() .map(UserWord::getWordId) .collect(Collectors.toList()); @@ -123,7 +123,7 @@ private DailyStudy createDailyStudy(String userId, String date, String level) { private List getNewWordsForUser(String userId, String level, int count) { PaginatedResult userWordPage = userWordRepository.findByUserIdWithPagination(userId, 1000, null); - List learnedWordIds = userWordPage.getItems().stream() + List learnedWordIds = userWordPage.items().stream() .map(UserWord::getWordId) .collect(Collectors.toList()); @@ -132,13 +132,13 @@ private List getNewWordsForUser(String userId, String level, int count) do { PaginatedResult wordPage = wordRepository.findByLevelWithPagination(level, count * 2, lastEvaluatedKey); - for (Word word : wordPage.getItems()) { + for (Word word : wordPage.items()) { if (!learnedWordIds.contains(word.getWordId()) && !newWordIds.contains(word.getWordId())) { newWordIds.add(word.getWordId()); if (newWordIds.size() >= count) break; } } - lastEvaluatedKey = wordPage.getNextCursor(); + lastEvaluatedKey = wordPage.nextCursor(); } while (newWordIds.size() < count && lastEvaluatedKey != null); logger.info("Selected {} new words for user {} at level {}", newWordIds.size(), userId, level); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/StatsService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/StatsService.java index 71a9fc0d..cb64feb0 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/StatsService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/StatsService.java @@ -47,19 +47,19 @@ public Map getOverallStats(String userId) { String cursor = null; do { PaginatedResult page = userWordRepository.findByUserIdWithPagination(userId, 100, cursor); - for (UserWord userWord : page.getItems()) { + for (UserWord userWord : page.items()) { String status = userWord.getStatus(); wordStatusCounts.merge(status, 1, Integer::sum); totalCorrect += userWord.getCorrectCount() != null ? userWord.getCorrectCount() : 0; totalIncorrect += userWord.getIncorrectCount() != null ? userWord.getIncorrectCount() : 0; } - cursor = page.getNextCursor(); + cursor = page.nextCursor(); } while (cursor != null); int totalWords = wordStatusCounts.values().stream().mapToInt(Integer::intValue).sum(); PaginatedResult testPage = testResultRepository.findByUserIdWithPagination(userId, 100, null); - List testResults = testPage.getItems(); + List testResults = testPage.items(); double avgSuccessRate = testResults.stream() .mapToDouble(TestResult::getSuccessRate) @@ -67,8 +67,8 @@ public Map getOverallStats(String userId) { .orElse(0.0); PaginatedResult dailyPage = dailyStudyRepository.findByUserIdWithPagination(userId, 365, null); - int studyDays = dailyPage.getItems().size(); - int completedDays = (int) dailyPage.getItems().stream() + int studyDays = dailyPage.items().size(); + int completedDays = (int) dailyPage.items().stream() .filter(d -> Boolean.TRUE.equals(d.getIsCompleted())) .count(); @@ -91,7 +91,7 @@ public Map getOverallStats(String userId) { public DailyStatsResult getDailyStats(String userId, int limit, String cursor) { PaginatedResult dailyPage = dailyStudyRepository.findByUserIdWithPagination(userId, limit, cursor); - List> dailyStats = dailyPage.getItems().stream() + List> dailyStats = dailyPage.items().stream() .map(daily -> { Map stat = new HashMap<>(); stat.put("date", daily.getDate()); @@ -104,7 +104,7 @@ public DailyStatsResult getDailyStats(String userId, int limit, String cursor) { }) .toList(); - return new DailyStatsResult(dailyStats, dailyPage.getNextCursor(), dailyPage.hasMore()); + return new DailyStatsResult(dailyStats, dailyPage.nextCursor(), dailyPage.hasMore()); } public Map getWeaknessAnalysis(String userId) { @@ -112,8 +112,8 @@ public Map getWeaknessAnalysis(String userId) { String cursor = null; do { PaginatedResult page = userWordRepository.findByUserIdWithPagination(userId, 100, cursor); - allUserWords.addAll(page.getItems()); - cursor = page.getNextCursor(); + allUserWords.addAll(page.items()); + cursor = page.nextCursor(); } while (cursor != null); if (allUserWords.isEmpty()) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java index ad9d066c..47844d20 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java @@ -168,7 +168,7 @@ public SubmitTestResult submitTest(String userId, String testId, String testType private List getDistractorsForLevel(String level, List excludeWordIds) { PaginatedResult wordPage = wordRepository.findByLevelWithPagination(level, 50, null); - return wordPage.getItems().stream() + return wordPage.items().stream() .filter(w -> !excludeWordIds.contains(w.getWordId())) .map(Word::getKorean) .collect(Collectors.toList()); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestService.java index 5c820edb..9b7ccb53 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestService.java @@ -168,7 +168,7 @@ public PaginatedResult getTestResults(String userId, int limit, Stri private List getDistractorsForLevel(String level, List excludeWordIds) { PaginatedResult wordPage = wordRepository.findByLevelWithPagination(level, 50, null); - return wordPage.getItems().stream() + return wordPage.items().stream() .filter(w -> !excludeWordIds.contains(w.getWordId())) .map(Word::getKorean) .collect(Collectors.toList()); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordQueryService.java index b2fc31fa..3fe3402a 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordQueryService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordQueryService.java @@ -44,9 +44,9 @@ public UserWordsResult getUserWords(String userId, String status, String bookmar userWordPage = userWordRepository.findByUserIdWithPagination(userId, limit, cursor); } - List> enrichedUserWords = enrichWithWordInfo(userWordPage.getItems()); + List> enrichedUserWords = enrichWithWordInfo(userWordPage.items()); - return new UserWordsResult(enrichedUserWords, userWordPage.getNextCursor(), userWordPage.hasMore()); + return new UserWordsResult(enrichedUserWords, userWordPage.nextCursor(), userWordPage.hasMore()); } /** @@ -56,7 +56,7 @@ public UserWordsResult getWrongAnswers(String userId, int minCount, int limit, S PaginatedResult userWordPage = userWordRepository.findIncorrectWords(userId, minCount, limit * 2, cursor); // 오답 횟수 기준 내림차순 정렬 - List sorted = userWordPage.getItems().stream() + List sorted = userWordPage.items().stream() .sorted((a, b) -> { int countA = a.getIncorrectCount() != null ? a.getIncorrectCount() : 0; int countB = b.getIncorrectCount() != null ? b.getIncorrectCount() : 0; @@ -67,7 +67,7 @@ public UserWordsResult getWrongAnswers(String userId, int minCount, int limit, S List> enrichedUserWords = enrichWithWordInfo(sorted); - return new UserWordsResult(enrichedUserWords, userWordPage.getNextCursor(), userWordPage.hasMore()); + return new UserWordsResult(enrichedUserWords, userWordPage.nextCursor(), userWordPage.hasMore()); } public Optional getUserWord(String userId, String wordId) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordService.java index 175a6f43..5815f71f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordService.java @@ -43,9 +43,9 @@ public UserWordsResult getUserWords(String userId, String status, String bookmar userWordPage = userWordRepository.findByUserIdWithPagination(userId, limit, cursor); } - List> enrichedUserWords = enrichWithWordInfo(userWordPage.getItems()); + List> enrichedUserWords = enrichWithWordInfo(userWordPage.items()); - return new UserWordsResult(enrichedUserWords, userWordPage.getNextCursor(), userWordPage.hasMore()); + return new UserWordsResult(enrichedUserWords, userWordPage.nextCursor(), userWordPage.hasMore()); } public Optional getUserWord(String userId, String wordId) { From a8f40a015eab1324703b61b6b5762a6d0c4c9e39 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 9 Jan 2026 16:37:18 +0900 Subject: [PATCH 113/528] =?UTF-8?q?feat:=20Jakarta=20Bean=20Validation=20?= =?UTF-8?q?=EB=B0=8F=20Route=20=EA=B8=B0=EB=B0=98=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Jakarta Bean Validation 의존성 추가 (hibernate-validator) - BeanValidator 유틸리티 클래스 생성 - DTO에 @NotBlank, @NotNull, @NotEmpty, @Valid 어노테이션 적용 - Route에 path parameter 자동 추출 및 검증 기능 추가 - HandlerRouter에 path/query parameter 자동 검증 로직 추가 - requireQueryParams() 메서드로 필수 쿼리 파라미터 지정 지원 - RequestValidator, ValidationResult 제거 (Route 검증으로 대체) - ResponseUtil을 ResponseGenerator로 리네이밍 - 빈 테스트 답변 오답 처리 로직 추가 closes #170, closes #171, closes #172, closes #173 --- ServerlessFunction/build.gradle | 5 + .../{constants => config}/StudyConfig.java | 2 +- .../serverless/common/dto/ErrorInfo.java | 10 + .../common/exception/CommonErrorCode.java | 1 + .../common/router/HandlerRouter.java | 74 +- .../serverless/common/router/Route.java | 46 +- .../common/util/ResponseGenerator.java | 112 +++ .../serverless/common/util/ResponseUtil.java | 78 -- .../common/util/WebSocketResponseUtil.java | 39 + .../common/validation/BeanValidator.java | 84 ++ .../common/validation/RequestValidator.java | 81 -- .../common/validation/ValidationResult.java | 33 - .../dto/request/CreateRoomRequest.java | 16 + .../chatting/dto/request/JoinRoomRequest.java | 4 + .../dto/request/LeaveRoomRequest.java | 3 + .../dto/request/SendMessageRequest.java | 8 + .../dto/request/VoiceSynthesisRequest.java | 23 + .../chatting/handler/ChatAIHandler.java | 10 +- .../chatting/handler/ChatMessageHandler.java | 95 +- .../chatting/handler/ChatRoomHandler.java | 112 +-- .../chatting/handler/ChatVoiceHandler.java | 111 ++- .../dto/request/BatchGetWordsRequest.java | 20 +- .../dto/request/CreateWordGroupRequest.java | 32 +- .../dto/request/CreateWordRequest.java | 12 + .../dto/request/CreateWordsBatchRequest.java | 5 + .../dto/request/SubmitTestRequest.java | 14 +- .../dto/request/SynthesizeVoiceRequest.java | 5 + .../dto/request/UpdateUserWordRequest.java | 3 + .../vocabulary/handler/DailyStudyHandler.java | 28 +- .../vocabulary/handler/StatisticsHandler.java | 4 +- .../vocabulary/handler/StatsHandler.java | 31 +- .../vocabulary/handler/TestHandler.java | 92 +- .../vocabulary/handler/UserWordHandler.java | 85 +- .../vocabulary/handler/VoiceHandler.java | 106 +- .../vocabulary/handler/WordGroupHandler.java | 145 +-- .../vocabulary/handler/WordHandler.java | 119 +-- .../service/TestCommandService.java | 11 +- .../vocabulary/service/TestService.java | 4 +- .../service/UserWordCommandService.java | 2 +- docs/IMPROVEMENT-GUIDE.md | 909 ++++++++++++++++++ 40 files changed, 1738 insertions(+), 836 deletions(-) rename ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/{constants => config}/StudyConfig.java (94%) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/ResponseGenerator.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/ResponseUtil.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketResponseUtil.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/validation/BeanValidator.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/validation/RequestValidator.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/validation/ValidationResult.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/VoiceSynthesisRequest.java create mode 100644 docs/IMPROVEMENT-GUIDE.md diff --git a/ServerlessFunction/build.gradle b/ServerlessFunction/build.gradle index 30581597..5e6a02d8 100644 --- a/ServerlessFunction/build.gradle +++ b/ServerlessFunction/build.gradle @@ -35,6 +35,11 @@ dependencies { // JSON Processing implementation 'com.google.code.gson:gson:2.10.1' + // Jakarta Bean Validation + implementation 'jakarta.validation:jakarta.validation-api:3.0.2' + implementation 'org.hibernate.validator:hibernate-validator:8.0.1.Final' + implementation 'org.glassfish.expressly:expressly:5.0.0' + // Password Hashing implementation 'org.mindrot:jbcrypt:0.4' diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/constants/StudyConfig.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/StudyConfig.java similarity index 94% rename from ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/constants/StudyConfig.java rename to ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/StudyConfig.java index fe3ebff4..0b3da670 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/constants/StudyConfig.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/StudyConfig.java @@ -1,4 +1,4 @@ -package com.mzc.secondproject.serverless.common.constants; +package com.mzc.secondproject.serverless.common.config; public final class StudyConfig { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/ErrorInfo.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/ErrorInfo.java index 912af4e4..74f8330d 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/ErrorInfo.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/ErrorInfo.java @@ -57,6 +57,16 @@ public static ErrorInfo from(ErrorCode errorCode) { return new ErrorInfo(code, errorCode.getMessage(), errorCode.getStatusCode(), null); } + /** + * ErrorCode에서 에러 정보 생성 (커스텀 메시지) + */ + public static ErrorInfo from(ErrorCode errorCode, String customMessage) { + String code = errorCode instanceof DomainErrorCode domainCode + ? domainCode.getFullCode() + : errorCode.getCode(); + return new ErrorInfo(code, customMessage, errorCode.getStatusCode(), null); + } + /** * ServerlessException에서 에러 정보 생성 */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/CommonErrorCode.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/CommonErrorCode.java index 651fea95..c97746ae 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/CommonErrorCode.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/CommonErrorCode.java @@ -24,6 +24,7 @@ public enum CommonErrorCode implements ErrorCode { // 리소스 관련 에러 RESOURCE_NOT_FOUND("RESOURCE_001", "리소스를 찾을 수 없습니다", 404), + METHOD_NOT_ALLOWED("RESOURCE_003", "허용되지 않는 메서드입니다", 405), RESOURCE_ALREADY_EXISTS("RESOURCE_002", "이미 존재하는 리소스입니다", 409), // 시스템 에러 diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/HandlerRouter.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/HandlerRouter.java index fb459d86..824cf4ae 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/HandlerRouter.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/HandlerRouter.java @@ -2,21 +2,32 @@ import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; -import com.mzc.secondproject.serverless.common.dto.ApiResponse; +import com.mzc.secondproject.serverless.common.exception.CommonErrorCode; import com.mzc.secondproject.serverless.common.exception.ServerlessException; import com.mzc.secondproject.serverless.common.dto.ErrorInfo; -import static com.mzc.secondproject.serverless.common.util.ResponseUtil.createResponse; +import com.mzc.secondproject.serverless.common.util.ResponseGenerator; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; +import java.util.stream.Collectors; /** * Lambda Handler를 위한 HTTP 라우터 - * if-else 체인 대신 선언적 라우팅 제공 + * + * 선언적 라우팅 + 자동 Path/Query 파라미터 검증 제공 + * + * 사용 예시: + *
+ * new HandlerRouter().addRoutes(
+ *     Route.get("/rooms/{roomId}", this::getRoom),  // roomId 자동 검증
+ *     Route.delete("/rooms/{roomId}", this::deleteRoom).requireQueryParams("userId")  // roomId + userId 검증
+ * );
+ * 
*/ public class HandlerRouter { @@ -56,28 +67,73 @@ public APIGatewayProxyResponseEvent route(APIGatewayProxyRequestEvent request) { for (RouteEntry entry : routes) { if (entry.matches(method, path)) { logger.debug("Matched route: {} {}", entry.route.method(), entry.route.pathPattern()); + + // Path/Query 파라미터 자동 검증 + String validationError = validateParams(request, entry.route); + if (validationError != null) { + logger.warn("Validation failed: {}", validationError); + return ResponseGenerator.fail(CommonErrorCode.REQUIRED_FIELD_MISSING, validationError); + } + try { return entry.route.handler().apply(request); } catch (ServerlessException e) { return handleServerlessException(e); } catch (IllegalArgumentException e) { logger.warn("Bad request: {}", e.getMessage()); - return createResponse(400, ApiResponse.fail(e.getMessage())); + return ResponseGenerator.fail(CommonErrorCode.INVALID_INPUT, e.getMessage()); } catch (IllegalStateException e) { logger.warn("Conflict: {}", e.getMessage()); - return createResponse(409, ApiResponse.fail(e.getMessage())); + return ResponseGenerator.fail(CommonErrorCode.RESOURCE_ALREADY_EXISTS, e.getMessage()); } catch (SecurityException e) { logger.warn("Forbidden: {}", e.getMessage()); - return createResponse(403, ApiResponse.fail(e.getMessage())); + return ResponseGenerator.fail(CommonErrorCode.FORBIDDEN, e.getMessage()); } catch (Exception e) { logger.error("Error handling request", e); - return createResponse(500, ApiResponse.fail("Internal server error: " + e.getMessage())); + return ResponseGenerator.fail(CommonErrorCode.INTERNAL_SERVER_ERROR); } } } logger.warn("No route found for: {} {}", method, path); - return createResponse(404, ApiResponse.fail("Not found")); + return ResponseGenerator.fail(CommonErrorCode.RESOURCE_NOT_FOUND); + } + + /** + * Path/Query 파라미터 검증 + * + * @return 에러 메시지 (검증 성공 시 null) + */ + private String validateParams(APIGatewayProxyRequestEvent request, Route route) { + List missingParams = new ArrayList<>(); + + // Path 파라미터 검증 + Map pathParams = request.getPathParameters(); + for (String param : route.requiredPathParams()) { + if (pathParams == null || isBlank(pathParams.get(param))) { + missingParams.add(param); + } + } + + // Query 파라미터 검증 + Map queryParams = request.getQueryStringParameters(); + for (String param : route.requiredQueryParams()) { + if (queryParams == null || isBlank(queryParams.get(param))) { + missingParams.add(param); + } + } + + if (missingParams.isEmpty()) { + return null; + } + + return missingParams.stream() + .map(p -> p + " is required") + .collect(Collectors.joining(", ")); + } + + private boolean isBlank(String value) { + return value == null || value.trim().isEmpty(); } /** @@ -105,7 +161,7 @@ private APIGatewayProxyResponseEvent handleServerlessException(ServerlessExcepti logger.error("Server error [{}]: {}", errorInfo.code(), e.getMessage(), e); } - return createResponse(e.getStatusCode(), errorInfo); + return ResponseGenerator.createResponse(e.getStatusCode(), errorInfo); } /** diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/Route.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/Route.java index 44765b7c..181b8d79 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/Route.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/Route.java @@ -3,36 +3,70 @@ import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; import java.util.function.Function; +import java.util.regex.Matcher; +import java.util.regex.Pattern; /** * HTTP 라우트 정의 + * + * Path 패턴에서 자동으로 필수 파라미터를 추출합니다. + * 예: "/rooms/{roomId}/messages/{messageId}" → ["roomId", "messageId"] + * * @param method HTTP 메서드 (GET, POST, PUT, DELETE 등) * @param pathPattern 경로 패턴 (예: "/rooms", "/rooms/{roomId}", "/rooms/{roomId}/join") * @param handler 요청 처리 함수 + * @param requiredPathParams 필수 Path 파라미터 목록 (자동 추출) + * @param requiredQueryParams 필수 Query 파라미터 목록 (선택적 지정) */ public record Route( String method, String pathPattern, - Function handler + Function handler, + List requiredPathParams, + List requiredQueryParams ) { + private static final Pattern PATH_PARAM_PATTERN = Pattern.compile("\\{([^}]+)}"); + + /** + * Path 패턴에서 파라미터 이름 추출 + */ + private static List extractPathParams(String pathPattern) { + List params = new ArrayList<>(); + Matcher matcher = PATH_PARAM_PATTERN.matcher(pathPattern); + while (matcher.find()) { + params.add(matcher.group(1)); + } + return Collections.unmodifiableList(params); + } + public static Route get(String pathPattern, Function handler) { - return new Route("GET", pathPattern, handler); + return new Route("GET", pathPattern, handler, extractPathParams(pathPattern), List.of()); } public static Route post(String pathPattern, Function handler) { - return new Route("POST", pathPattern, handler); + return new Route("POST", pathPattern, handler, extractPathParams(pathPattern), List.of()); } public static Route put(String pathPattern, Function handler) { - return new Route("PUT", pathPattern, handler); + return new Route("PUT", pathPattern, handler, extractPathParams(pathPattern), List.of()); } public static Route delete(String pathPattern, Function handler) { - return new Route("DELETE", pathPattern, handler); + return new Route("DELETE", pathPattern, handler, extractPathParams(pathPattern), List.of()); } public static Route patch(String pathPattern, Function handler) { - return new Route("PATCH", pathPattern, handler); + return new Route("PATCH", pathPattern, handler, extractPathParams(pathPattern), List.of()); + } + + /** + * 필수 Query 파라미터 추가 + */ + public Route requireQueryParams(String... params) { + return new Route(method, pathPattern, handler, requiredPathParams, List.of(params)); } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/ResponseGenerator.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/ResponseGenerator.java new file mode 100644 index 00000000..029997c8 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/ResponseGenerator.java @@ -0,0 +1,112 @@ +package com.mzc.secondproject.serverless.common.util; + +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.mzc.secondproject.serverless.common.dto.ApiResponse; +import com.mzc.secondproject.serverless.common.dto.ErrorInfo; +import com.mzc.secondproject.serverless.common.exception.ErrorCode; + +import java.util.Map; + +/** + * API Gateway 응답 생성기 + */ +public final class ResponseGenerator { + + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + + private static final Map CORS_HEADERS = Map.of( + "Content-Type", "application/json", + "Access-Control-Allow-Origin", "*", + "Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS", + "Access-Control-Allow-Headers", "Content-Type,Authorization" + ); + + private ResponseGenerator() {} + + // === 기본 응답 생성 === + + public static APIGatewayProxyResponseEvent createResponse(int statusCode, Object body) { + return new APIGatewayProxyResponseEvent() + .withStatusCode(statusCode) + .withHeaders(CORS_HEADERS) + .withBody(GSON.toJson(body)); + } + + // === 성공 응답 === + + public static APIGatewayProxyResponseEvent ok(String message, T data) { + return createResponse(200, ApiResponse.ok(message, data)); + } + + public static APIGatewayProxyResponseEvent ok(T data) { + return createResponse(200, ApiResponse.ok(data)); + } + + public static APIGatewayProxyResponseEvent created(String message, T data) { + return createResponse(201, ApiResponse.ok(message, data)); + } + + public static APIGatewayProxyResponseEvent noContent() { + return new APIGatewayProxyResponseEvent() + .withStatusCode(204) + .withHeaders(CORS_HEADERS); + } + + // === 실패 응답 === + + public static APIGatewayProxyResponseEvent badRequest(String message) { + return createResponse(400, ApiResponse.fail(message)); + } + + public static APIGatewayProxyResponseEvent unauthorized(String message) { + return createResponse(401, ApiResponse.fail(message)); + } + + public static APIGatewayProxyResponseEvent forbidden(String message) { + return createResponse(403, ApiResponse.fail(message)); + } + + public static APIGatewayProxyResponseEvent notFound(String message) { + return createResponse(404, ApiResponse.fail(message)); + } + + public static APIGatewayProxyResponseEvent methodNotAllowed(String message) { + return createResponse(405, ApiResponse.fail(message)); + } + + public static APIGatewayProxyResponseEvent conflict(String message) { + return createResponse(409, ApiResponse.fail(message)); + } + + public static APIGatewayProxyResponseEvent serverError(String message) { + return createResponse(500, ApiResponse.fail(message)); + } + + public static APIGatewayProxyResponseEvent fail(int statusCode, String message) { + return createResponse(statusCode, ApiResponse.fail(message)); + } + + /** + * ErrorCode 기반 에러 응답 생성 + */ + public static APIGatewayProxyResponseEvent fail(ErrorCode errorCode) { + ErrorInfo errorInfo = ErrorInfo.from(errorCode); + return createResponse(errorCode.getStatusCode(), errorInfo); + } + + /** + * ErrorCode 기반 에러 응답 생성 (커스텀 메시지) + */ + public static APIGatewayProxyResponseEvent fail(ErrorCode errorCode, String customMessage) { + ErrorInfo errorInfo = ErrorInfo.from(errorCode, customMessage); + return createResponse(errorCode.getStatusCode(), errorInfo); + } + + // === 유틸리티 === + + public static Gson gson() { + return GSON; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/ResponseUtil.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/ResponseUtil.java deleted file mode 100644 index a8afc407..00000000 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/ResponseUtil.java +++ /dev/null @@ -1,78 +0,0 @@ -package com.mzc.secondproject.serverless.common.util; - -import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; - -import java.util.Map; - -/** - * API Gateway 응답 생성 유틸리티 - */ -public final class ResponseUtil { - - private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); - - private static final Map CORS_HEADERS = Map.of( - "Content-Type", "application/json", - "Access-Control-Allow-Origin", "*", - "Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS", - "Access-Control-Allow-Headers", "Content-Type,Authorization" - ); - - private ResponseUtil() { - // 인스턴스화 방지 - } - - /** - * JSON 응답 생성 - */ - public static APIGatewayProxyResponseEvent createResponse(int statusCode, Object body) { - return new APIGatewayProxyResponseEvent() - .withStatusCode(statusCode) - .withHeaders(CORS_HEADERS) - .withBody(GSON.toJson(body)); - } - - /** - * 200 OK 응답 - */ - public static APIGatewayProxyResponseEvent ok(Object body) { - return createResponse(200, body); - } - - /** - * 201 Created 응답 - */ - public static APIGatewayProxyResponseEvent created(Object body) { - return createResponse(201, body); - } - - /** - * 400 Bad Request 응답 - */ - public static APIGatewayProxyResponseEvent badRequest(Object body) { - return createResponse(400, body); - } - - /** - * 404 Not Found 응답 - */ - public static APIGatewayProxyResponseEvent notFound(Object body) { - return createResponse(404, body); - } - - /** - * 500 Internal Server Error 응답 - */ - public static APIGatewayProxyResponseEvent serverError(Object body) { - return createResponse(500, body); - } - - /** - * Gson 인스턴스 반환 (JSON 파싱용) - */ - public static Gson gson() { - return GSON; - } -} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketResponseUtil.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketResponseUtil.java new file mode 100644 index 00000000..2d899873 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketResponseUtil.java @@ -0,0 +1,39 @@ +package com.mzc.secondproject.serverless.common.util; + +import java.util.Map; + +/** + * WebSocket API Gateway 응답 생성 유틸리티 + */ +public final class WebSocketResponseUtil { + + private WebSocketResponseUtil() {} + + public static Map ok(String message) { + return Map.of("statusCode", 200, "body", message); + } + + public static Map created(String message) { + return Map.of("statusCode", 201, "body", message); + } + + public static Map badRequest(String message) { + return Map.of("statusCode", 400, "body", message); + } + + public static Map unauthorized(String message) { + return Map.of("statusCode", 401, "body", message); + } + + public static Map forbidden(String message) { + return Map.of("statusCode", 403, "body", message); + } + + public static Map serverError(String message) { + return Map.of("statusCode", 500, "body", message); + } + + public static Map response(int statusCode, String message) { + return Map.of("statusCode", statusCode, "body", message); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/validation/BeanValidator.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/validation/BeanValidator.java new file mode 100644 index 00000000..1b2ab911 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/validation/BeanValidator.java @@ -0,0 +1,84 @@ +package com.mzc.secondproject.serverless.common.validation; + +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import com.mzc.secondproject.serverless.common.exception.CommonErrorCode; +import com.mzc.secondproject.serverless.common.util.ResponseGenerator; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import jakarta.validation.ValidatorFactory; + +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * Jakarta Bean Validation 기반 검증 유틸리티 + * + * DTO에 선언된 @NotNull, @NotEmpty 등의 어노테이션을 검증합니다. + * + * 사용 예시: + *
+ * CreateRoomRequest req = ResponseGenerator.gson().fromJson(body, CreateRoomRequest.class);
+ *
+ * return BeanValidator.validate(req)
+ *     .map(error -> ResponseGenerator.fail(CommonErrorCode.REQUIRED_FIELD_MISSING, error))
+ *     .orElseGet(() -> {
+ *         // 비즈니스 로직
+ *         return ResponseGenerator.ok("Success", result);
+ *     });
+ * 
+ */ +public final class BeanValidator { + + private static final Validator VALIDATOR; + + static { + ValidatorFactory factory = Validation.buildDefaultValidatorFactory(); + VALIDATOR = factory.getValidator(); + } + + private BeanValidator() {} + + /** + * 객체 검증 후 첫 번째 에러 메시지 반환 + * + * @param object 검증할 객체 + * @return 에러 메시지 (검증 성공 시 empty) + */ + public static Optional validate(T object) { + if (object == null) { + return Optional.of("Request body is required"); + } + + Set> violations = VALIDATOR.validate(object); + + if (violations.isEmpty()) { + return Optional.empty(); + } + + String errorMessage = violations.stream() + .map(v -> v.getPropertyPath() + " " + v.getMessage()) + .collect(Collectors.joining(", ")); + + return Optional.of(errorMessage); + } + + /** + * 검증 실패 시 에러 응답, 성공 시 핸들러 실행 + * + * @param object 검증할 객체 + * @param handler 검증 성공 시 실행할 핸들러 + * @return API 응답 + */ + public static APIGatewayProxyResponseEvent validateAndExecute( + T object, + Function handler) { + + return validate(object) + .map(error -> ResponseGenerator.fail(CommonErrorCode.REQUIRED_FIELD_MISSING, error)) + .orElseGet(() -> handler.apply(object)); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/validation/RequestValidator.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/validation/RequestValidator.java deleted file mode 100644 index 46e264f9..00000000 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/validation/RequestValidator.java +++ /dev/null @@ -1,81 +0,0 @@ -package com.mzc.secondproject.serverless.common.validation; - -import java.util.ArrayList; -import java.util.Collection; -import java.util.List; - -public class RequestValidator { - - private final List errors = new ArrayList<>(); - - public static RequestValidator create() { - return new RequestValidator(); - } - - public RequestValidator requireNotNull(Object value, String fieldName) { - if (value == null) { - errors.add(fieldName + " is required"); - } - return this; - } - - public RequestValidator requireNotEmpty(String value, String fieldName) { - if (value == null || value.isEmpty()) { - errors.add(fieldName + " is required"); - } - return this; - } - - public RequestValidator requireNotEmpty(Collection value, String fieldName) { - if (value == null || value.isEmpty()) { - errors.add(fieldName + " is required"); - } - return this; - } - - public RequestValidator requirePositive(Integer value, String fieldName) { - if (value != null && value <= 0) { - errors.add(fieldName + " must be positive"); - } - return this; - } - - public RequestValidator requireInRange(Integer value, int min, int max, String fieldName) { - if (value != null && (value < min || value > max)) { - errors.add(fieldName + " must be between " + min + " and " + max); - } - return this; - } - - public RequestValidator requireAnyNotNull(String message, Object... values) { - for (Object value : values) { - if (value != null) { - return this; - } - } - errors.add(message); - return this; - } - - public RequestValidator validate(boolean condition, String errorMessage) { - if (!condition) { - errors.add(errorMessage); - } - return this; - } - - public ValidationResult build() { - if (errors.isEmpty()) { - return ValidationResult.ok(); - } - return ValidationResult.error(String.join(", ", errors)); - } - - public boolean hasErrors() { - return !errors.isEmpty(); - } - - public String getFirstError() { - return errors.isEmpty() ? null : errors.get(0); - } -} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/validation/ValidationResult.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/validation/ValidationResult.java deleted file mode 100644 index 5f78f18d..00000000 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/validation/ValidationResult.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.mzc.secondproject.serverless.common.validation; - -import java.util.Optional; - -public class ValidationResult { - private final boolean valid; - private final String errorMessage; - - private ValidationResult(boolean valid, String errorMessage) { - this.valid = valid; - this.errorMessage = errorMessage; - } - - public static ValidationResult ok() { - return new ValidationResult(true, null); - } - - public static ValidationResult error(String message) { - return new ValidationResult(false, message); - } - - public boolean isValid() { - return valid; - } - - public boolean isInvalid() { - return !valid; - } - - public Optional getErrorMessage() { - return Optional.ofNullable(errorMessage); - } -} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/CreateRoomRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/CreateRoomRequest.java index 616f0b24..b7d65e74 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/CreateRoomRequest.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/CreateRoomRequest.java @@ -1,5 +1,9 @@ package com.mzc.secondproject.serverless.domain.chatting.dto.request; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -10,14 +14,26 @@ @NoArgsConstructor @AllArgsConstructor public class CreateRoomRequest { + + @NotBlank(message = "is required") + @Size(min = 1, max = 50, message = "must be between 1 and 50 characters") private String name; + + @Size(max = 200, message = "must be at most 200 characters") private String description; + @Builder.Default private String level = "beginner"; + + @Min(value = 2, message = "must be at least 2") + @Max(value = 10, message = "must be at most 10") @Builder.Default private Integer maxMembers = 6; + @Builder.Default private Boolean isPrivate = false; + private String password; + private String createdBy; } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/JoinRoomRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/JoinRoomRequest.java index 1be8e8c9..4ca12f4e 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/JoinRoomRequest.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/JoinRoomRequest.java @@ -1,5 +1,6 @@ package com.mzc.secondproject.serverless.domain.chatting.dto.request; +import jakarta.validation.constraints.NotBlank; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -10,6 +11,9 @@ @NoArgsConstructor @AllArgsConstructor public class JoinRoomRequest { + + @NotBlank(message = "is required") private String userId; + private String password; } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/LeaveRoomRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/LeaveRoomRequest.java index d9fc5c69..8d3ed4a1 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/LeaveRoomRequest.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/LeaveRoomRequest.java @@ -1,5 +1,6 @@ package com.mzc.secondproject.serverless.domain.chatting.dto.request; +import jakarta.validation.constraints.NotBlank; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -10,5 +11,7 @@ @NoArgsConstructor @AllArgsConstructor public class LeaveRoomRequest { + + @NotBlank(message = "is required") private String userId; } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/SendMessageRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/SendMessageRequest.java index 6d4bf0a3..9e06cdf3 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/SendMessageRequest.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/SendMessageRequest.java @@ -1,5 +1,7 @@ package com.mzc.secondproject.serverless.domain.chatting.dto.request; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -10,8 +12,14 @@ @NoArgsConstructor @AllArgsConstructor public class SendMessageRequest { + + @NotBlank(message = "is required") private String userId; + + @NotBlank(message = "is required") + @Size(max = 1000, message = "must be at most 1000 characters") private String content; + @Builder.Default private String messageType = "TEXT"; } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/VoiceSynthesisRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/VoiceSynthesisRequest.java new file mode 100644 index 00000000..7797e619 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/VoiceSynthesisRequest.java @@ -0,0 +1,23 @@ +package com.mzc.secondproject.serverless.domain.chatting.dto.request; + +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class VoiceSynthesisRequest { + + @NotBlank(message = "is required") + private String messageId; + + @NotBlank(message = "is required") + private String roomId; + + @Builder.Default + private String voice = "FEMALE"; +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatAIHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatAIHandler.java index bf8a3628..b76d314a 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatAIHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatAIHandler.java @@ -4,8 +4,8 @@ import com.amazonaws.services.lambda.runtime.RequestHandler; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; -import com.mzc.secondproject.serverless.common.dto.ApiResponse; -import static com.mzc.secondproject.serverless.common.util.ResponseUtil.createResponse; +import com.mzc.secondproject.serverless.common.exception.CommonErrorCode; +import com.mzc.secondproject.serverless.common.util.ResponseGenerator; import com.mzc.secondproject.serverless.domain.chatting.service.BedrockService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -28,7 +28,7 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent re try { if (!"POST".equals(request.getHttpMethod())) { - return createResponse(405, ApiResponse.fail("Method not allowed")); + return ResponseGenerator.fail(CommonErrorCode.METHOD_NOT_ALLOWED); } String body = request.getBody(); @@ -36,11 +36,11 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent re String aiResponse = bedrockService.generateResponse("Hello, how can I help you?"); - return createResponse(200, ApiResponse.ok("AI response generated", Map.of("response", aiResponse))); + return ResponseGenerator.ok("AI response generated", Map.of("response", aiResponse)); } catch (Exception e) { logger.error("Error generating AI response", e); - return createResponse(500, ApiResponse.fail("Internal server error: " + e.getMessage())); + return ResponseGenerator.fail(CommonErrorCode.INTERNAL_SERVER_ERROR); } } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatMessageHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatMessageHandler.java index 01c00e72..ef0a8ea1 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatMessageHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatMessageHandler.java @@ -4,13 +4,13 @@ import com.amazonaws.services.lambda.runtime.RequestHandler; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; -import com.mzc.secondproject.serverless.common.dto.ApiResponse; import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.common.validation.BeanValidator; import com.mzc.secondproject.serverless.domain.chatting.dto.request.SendMessageRequest; +import com.mzc.secondproject.serverless.domain.chatting.exception.ChattingErrorCode; import com.mzc.secondproject.serverless.common.router.HandlerRouter; import com.mzc.secondproject.serverless.common.router.Route; -import com.mzc.secondproject.serverless.common.util.ResponseUtil; -import static com.mzc.secondproject.serverless.common.util.ResponseUtil.createResponse; +import com.mzc.secondproject.serverless.common.util.ResponseGenerator; import com.mzc.secondproject.serverless.domain.chatting.model.ChatMessage; import com.mzc.secondproject.serverless.domain.chatting.repository.ChatRoomRepository; import com.mzc.secondproject.serverless.domain.chatting.service.ChatMessageService; @@ -52,71 +52,52 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent re } private APIGatewayProxyResponseEvent sendMessage(APIGatewayProxyRequestEvent request) { - Map pathParams = request.getPathParameters(); - String roomId = pathParams != null ? pathParams.get("roomId") : null; - - if (roomId == null) { - return createResponse(400, ApiResponse.fail("roomId is required")); - } - - SendMessageRequest req = ResponseUtil.gson().fromJson(request.getBody(), SendMessageRequest.class); - - if (req.getUserId() == null || req.getContent() == null) { - return createResponse(400, ApiResponse.fail("userId and content are required")); - } - - String messageType = req.getMessageType() != null ? req.getMessageType() : "TEXT"; - String messageId = UUID.randomUUID().toString(); - String now = Instant.now().toString(); - - ChatMessage message = ChatMessage.builder() - .pk("ROOM#" + roomId) - .sk("MSG#" + now + "#" + messageId) - .gsi1pk("USER#" + req.getUserId()) - .gsi1sk("MSG#" + now) - .gsi2pk("MSG#" + messageId) - .gsi2sk("ROOM#" + roomId) - .messageId(messageId) - .roomId(roomId) - .userId(req.getUserId()) - .content(req.getContent()) - .messageType(messageType) - .createdAt(now) - .build(); - - ChatMessage savedMessage = chatMessageService.saveMessage(message); - chatRoomRepository.updateLastMessageAt(roomId, now); - - logger.info("Message sent: {} in room: {}", messageId, roomId); - return createResponse(201, ApiResponse.ok("Message sent", savedMessage)); + String roomId = request.getPathParameters().get("roomId"); + SendMessageRequest req = ResponseGenerator.gson().fromJson(request.getBody(), SendMessageRequest.class); + + return BeanValidator.validateAndExecute(req, dto -> { + String messageType = dto.getMessageType() != null ? dto.getMessageType() : "TEXT"; + String messageId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + + ChatMessage message = ChatMessage.builder() + .pk("ROOM#" + roomId) + .sk("MSG#" + now + "#" + messageId) + .gsi1pk("USER#" + dto.getUserId()) + .gsi1sk("MSG#" + now) + .gsi2pk("MSG#" + messageId) + .gsi2sk("ROOM#" + roomId) + .messageId(messageId) + .roomId(roomId) + .userId(dto.getUserId()) + .content(dto.getContent()) + .messageType(messageType) + .createdAt(now) + .build(); + + ChatMessage savedMessage = chatMessageService.saveMessage(message); + chatRoomRepository.updateLastMessageAt(roomId, now); + + logger.info("Message sent: {} in room: {}", messageId, roomId); + return ResponseGenerator.created("Message sent", savedMessage); + }); } private APIGatewayProxyResponseEvent getMessage(APIGatewayProxyRequestEvent request) { - Map pathParams = request.getPathParameters(); - String roomId = pathParams != null ? pathParams.get("roomId") : null; - String messageId = pathParams != null ? pathParams.get("messageId") : null; - - if (roomId == null || messageId == null) { - return createResponse(400, ApiResponse.fail("roomId and messageId are required")); - } + String roomId = request.getPathParameters().get("roomId"); + String messageId = request.getPathParameters().get("messageId"); Optional message = chatMessageService.getMessage(roomId, messageId); if (message.isEmpty()) { - return createResponse(404, ApiResponse.fail("Message not found")); + return ResponseGenerator.fail(ChattingErrorCode.MESSAGE_NOT_FOUND); } - return createResponse(200, ApiResponse.ok("Message retrieved", message.get())); + return ResponseGenerator.ok("Message retrieved", message.get()); } private APIGatewayProxyResponseEvent getMessages(APIGatewayProxyRequestEvent request) { - Map pathParams = request.getPathParameters(); + String roomId = request.getPathParameters().get("roomId"); Map queryParams = request.getQueryStringParameters(); - String roomId = pathParams != null ? pathParams.get("roomId") : null; - - if (roomId == null) { - return createResponse(400, ApiResponse.fail("roomId is required")); - } - int limit = 20; String cursor = null; @@ -134,6 +115,6 @@ private APIGatewayProxyResponseEvent getMessages(APIGatewayProxyRequestEvent req result.put("nextCursor", messagePage.nextCursor()); result.put("hasMore", messagePage.hasMore()); - return createResponse(200, ApiResponse.ok("Messages retrieved", result)); + return ResponseGenerator.ok("Messages retrieved", result); } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java index de1e3e18..425903e0 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java @@ -4,16 +4,16 @@ import com.amazonaws.services.lambda.runtime.RequestHandler; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; -import com.mzc.secondproject.serverless.common.dto.ApiResponse; import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.common.validation.BeanValidator; import com.mzc.secondproject.serverless.domain.chatting.dto.request.CreateRoomRequest; import com.mzc.secondproject.serverless.domain.chatting.dto.request.JoinRoomRequest; import com.mzc.secondproject.serverless.domain.chatting.dto.request.LeaveRoomRequest; import com.mzc.secondproject.serverless.domain.chatting.dto.response.JoinRoomResponse; +import com.mzc.secondproject.serverless.domain.chatting.exception.ChattingErrorCode; import com.mzc.secondproject.serverless.common.router.HandlerRouter; import com.mzc.secondproject.serverless.common.router.Route; -import com.mzc.secondproject.serverless.common.util.ResponseUtil; -import static com.mzc.secondproject.serverless.common.util.ResponseUtil.createResponse; +import com.mzc.secondproject.serverless.common.util.ResponseGenerator; import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; import com.mzc.secondproject.serverless.domain.chatting.service.ChatRoomCommandService; import com.mzc.secondproject.serverless.domain.chatting.service.ChatRoomQueryService; @@ -43,10 +43,10 @@ private HandlerRouter initRouter() { return new HandlerRouter().addRoutes( Route.post("/rooms", this::createRoom), Route.get("/rooms", this::getRooms), - Route.get("/rooms/{roomId}", this::getRoom), - Route.post("/rooms/{roomId}/join", this::joinRoom), - Route.post("/rooms/{roomId}/leave", this::leaveRoom), - Route.delete("/rooms/{roomId}", this::deleteRoom) + Route.get("/rooms/{roomId}", this::getRoom), // roomId 자동 검증 + Route.post("/rooms/{roomId}/join", this::joinRoom), // roomId 자동 검증 + Route.post("/rooms/{roomId}/leave", this::leaveRoom), // roomId 자동 검증 + Route.delete("/rooms/{roomId}", this::deleteRoom).requireQueryParams("userId") // roomId + userId 검증 ); } @@ -57,21 +57,19 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent re } private APIGatewayProxyResponseEvent createRoom(APIGatewayProxyRequestEvent request) { - CreateRoomRequest req = ResponseUtil.gson().fromJson(request.getBody(), CreateRoomRequest.class); + CreateRoomRequest req = ResponseGenerator.gson().fromJson(request.getBody(), CreateRoomRequest.class); - if (req.getName() == null || req.getName().isEmpty()) { - return createResponse(400, ApiResponse.fail("name is required")); - } - - String level = req.getLevel() != null ? req.getLevel() : "beginner"; - Integer maxMembers = req.getMaxMembers() != null ? req.getMaxMembers() : 6; - Boolean isPrivate = req.getIsPrivate() != null ? req.getIsPrivate() : false; + return BeanValidator.validateAndExecute(req, dto -> { + String level = dto.getLevel() != null ? dto.getLevel() : "beginner"; + Integer maxMembers = dto.getMaxMembers() != null ? dto.getMaxMembers() : 6; + Boolean isPrivate = dto.getIsPrivate() != null ? dto.getIsPrivate() : false; - ChatRoom room = commandService.createRoom( - req.getName(), req.getDescription(), level, maxMembers, isPrivate, req.getPassword(), req.getCreatedBy()); - room.setPassword(null); + ChatRoom room = commandService.createRoom( + dto.getName(), dto.getDescription(), level, maxMembers, isPrivate, dto.getPassword(), dto.getCreatedBy()); + room.setPassword(null); - return createResponse(201, ApiResponse.ok("Room created", room)); + return ResponseGenerator.created("Room created", room); + }); } private APIGatewayProxyResponseEvent getRooms(APIGatewayProxyRequestEvent request) { @@ -101,77 +99,53 @@ private APIGatewayProxyResponseEvent getRooms(APIGatewayProxyRequestEvent reques result.put("nextCursor", roomPage.nextCursor()); result.put("hasMore", roomPage.hasMore()); - return createResponse(200, ApiResponse.ok("Rooms retrieved", result)); + return ResponseGenerator.ok("Rooms retrieved", result); } private APIGatewayProxyResponseEvent getRoom(APIGatewayProxyRequestEvent request) { - Map pathParams = request.getPathParameters(); - String roomId = pathParams != null ? pathParams.get("roomId") : null; - - if (roomId == null) { - return createResponse(400, ApiResponse.fail("roomId is required")); - } + String roomId = request.getPathParameters().get("roomId"); Optional optRoom = queryService.getRoom(roomId); if (optRoom.isEmpty()) { - return createResponse(404, ApiResponse.fail("Room not found")); + return ResponseGenerator.fail(ChattingErrorCode.ROOM_NOT_FOUND); } ChatRoom room = optRoom.get(); room.setPassword(null); - return createResponse(200, ApiResponse.ok("Room retrieved", room)); + return ResponseGenerator.ok("Room retrieved", room); } private APIGatewayProxyResponseEvent joinRoom(APIGatewayProxyRequestEvent request) { - Map pathParams = request.getPathParameters(); - String roomId = pathParams != null ? pathParams.get("roomId") : null; - - JoinRoomRequest req = ResponseUtil.gson().fromJson(request.getBody(), JoinRoomRequest.class); - - if (roomId == null || req.getUserId() == null) { - return createResponse(400, ApiResponse.fail("roomId and userId are required")); - } - - JoinRoomResponse response = commandService.joinRoom(roomId, req.getUserId(), req.getPassword()); - response.getRoom().setPassword(null); - return createResponse(200, ApiResponse.ok("Joined room", response)); + String roomId = request.getPathParameters().get("roomId"); + JoinRoomRequest req = ResponseGenerator.gson().fromJson(request.getBody(), JoinRoomRequest.class); + + return BeanValidator.validateAndExecute(req, dto -> { + JoinRoomResponse response = commandService.joinRoom(roomId, dto.getUserId(), dto.getPassword()); + response.getRoom().setPassword(null); + return ResponseGenerator.ok("Joined room", response); + }); } private APIGatewayProxyResponseEvent leaveRoom(APIGatewayProxyRequestEvent request) { - Map pathParams = request.getPathParameters(); - String roomId = pathParams != null ? pathParams.get("roomId") : null; - - LeaveRoomRequest req = ResponseUtil.gson().fromJson(request.getBody(), LeaveRoomRequest.class); - - if (roomId == null || req.getUserId() == null) { - return createResponse(400, ApiResponse.fail("roomId and userId are required")); - } - - ChatRoomCommandService.LeaveResult result = commandService.leaveRoom(roomId, req.getUserId()); - if (result.deleted()) { - return createResponse(200, ApiResponse.ok("Room deleted", null)); - } - result.room().setPassword(null); - return createResponse(200, ApiResponse.ok("Left room", result.room())); + String roomId = request.getPathParameters().get("roomId"); + LeaveRoomRequest req = ResponseGenerator.gson().fromJson(request.getBody(), LeaveRoomRequest.class); + + return BeanValidator.validateAndExecute(req, dto -> { + ChatRoomCommandService.LeaveResult result = commandService.leaveRoom(roomId, dto.getUserId()); + if (result.deleted()) { + return ResponseGenerator.ok("Room deleted", null); + } + result.room().setPassword(null); + return ResponseGenerator.ok("Left room", result.room()); + }); } private APIGatewayProxyResponseEvent deleteRoom(APIGatewayProxyRequestEvent request) { - Map pathParams = request.getPathParameters(); - Map queryParams = request.getQueryStringParameters(); - - String roomId = pathParams != null ? pathParams.get("roomId") : null; - String userId = queryParams != null ? queryParams.get("userId") : null; - - if (roomId == null) { - return createResponse(400, ApiResponse.fail("roomId is required")); - } - - if (userId == null) { - return createResponse(400, ApiResponse.fail("userId is required")); - } + String roomId = request.getPathParameters().get("roomId"); + String userId = request.getQueryStringParameters().get("userId"); commandService.deleteRoom(roomId, userId); - return createResponse(200, ApiResponse.ok("Room deleted", null)); + return ResponseGenerator.ok("Room deleted", null); } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatVoiceHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatVoiceHandler.java index d4082d1f..342da979 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatVoiceHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatVoiceHandler.java @@ -4,9 +4,11 @@ import com.amazonaws.services.lambda.runtime.RequestHandler; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; -import com.mzc.secondproject.serverless.common.dto.ApiResponse; -import com.mzc.secondproject.serverless.common.util.ResponseUtil; -import static com.mzc.secondproject.serverless.common.util.ResponseUtil.createResponse; +import com.mzc.secondproject.serverless.common.exception.CommonErrorCode; +import com.mzc.secondproject.serverless.common.validation.BeanValidator; +import com.mzc.secondproject.serverless.domain.chatting.dto.request.VoiceSynthesisRequest; +import com.mzc.secondproject.serverless.domain.chatting.exception.ChattingErrorCode; +import com.mzc.secondproject.serverless.common.util.ResponseGenerator; import com.mzc.secondproject.serverless.domain.chatting.model.ChatMessage; import com.mzc.secondproject.serverless.domain.chatting.repository.ChatMessageRepository; import com.mzc.secondproject.serverless.common.service.PollyService; @@ -36,70 +38,67 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent re try { if (!"POST".equals(request.getHttpMethod())) { - return createResponse(405, ApiResponse.fail("Method not allowed")); + return ResponseGenerator.fail(CommonErrorCode.METHOD_NOT_ALLOWED); } - String body = request.getBody(); - Map requestBody = ResponseUtil.gson().fromJson(body, Map.class); - String messageId = requestBody.get("messageId"); - String roomId = requestBody.get("roomId"); - String voice = requestBody.getOrDefault("voice", "FEMALE"); + VoiceSynthesisRequest req = ResponseGenerator.gson().fromJson(request.getBody(), VoiceSynthesisRequest.class); - if (messageId == null || messageId.isEmpty()) { - return createResponse(400, ApiResponse.fail("messageId is required")); - } - if (roomId == null || roomId.isEmpty()) { - return createResponse(400, ApiResponse.fail("roomId is required")); - } + return BeanValidator.validateAndExecute(req, dto -> processVoiceSynthesis(dto)); - // 메시지 조회 - Optional messageOpt = messageRepository.findByRoomIdAndMessageId(roomId, messageId); - if (messageOpt.isEmpty()) { - return createResponse(404, ApiResponse.fail("Message not found")); - } + } catch (Exception e) { + logger.error("Error synthesizing speech", e); + return ResponseGenerator.fail(CommonErrorCode.INTERNAL_SERVER_ERROR); + } + } - ChatMessage message = messageOpt.get(); - boolean isMale = "MALE".equalsIgnoreCase(voice); + private APIGatewayProxyResponseEvent processVoiceSynthesis(VoiceSynthesisRequest dto) { + String messageId = dto.getMessageId(); + String roomId = dto.getRoomId(); + String voice = dto.getVoice() != null ? dto.getVoice() : "FEMALE"; - // 캐시된 음성 키 확인 - String cachedKey = isMale ? message.getMaleVoiceKey() : message.getFemaleVoiceKey(); + // 메시지 조회 + Optional messageOpt = messageRepository.findByRoomIdAndMessageId(roomId, messageId); + if (messageOpt.isEmpty()) { + return ResponseGenerator.fail(ChattingErrorCode.MESSAGE_NOT_FOUND); + } - String audioUrl; - boolean cached; + ChatMessage message = messageOpt.get(); + boolean isMale = "MALE".equalsIgnoreCase(voice); - if (cachedKey != null && !cachedKey.isEmpty()) { - // 캐시 히트: DynamoDB에 키가 있으면 S3에서 URL 생성 - logger.info("DB cache hit for message: {}, voice: {}", messageId, voice); - audioUrl = pollyService.getPresignedUrl(cachedKey); - cached = true; + // 캐시된 음성 키 확인 + String cachedKey = isMale ? message.getMaleVoiceKey() : message.getFemaleVoiceKey(); + + String audioUrl; + boolean cached; + + if (cachedKey != null && !cachedKey.isEmpty()) { + // 캐시 히트: DynamoDB에 키가 있으면 S3에서 URL 생성 + logger.info("DB cache hit for message: {}, voice: {}", messageId, voice); + audioUrl = pollyService.getPresignedUrl(cachedKey); + cached = true; + } else { + // 캐시 미스: Polly 변환 → S3 저장 → DynamoDB 업데이트 + VoiceSynthesisResult result = pollyService.synthesizeSpeech( + messageId, message.getContent(), voice); + + // DynamoDB에 S3 키 저장 + if (isMale) { + message.setMaleVoiceKey(result.getS3Key()); } else { - // 캐시 미스: Polly 변환 → S3 저장 → DynamoDB 업데이트 - VoiceSynthesisResult result = pollyService.synthesizeSpeech( - messageId, message.getContent(), voice); - - // DynamoDB에 S3 키 저장 - if (isMale) { - message.setMaleVoiceKey(result.getS3Key()); - } else { - message.setFemaleVoiceKey(result.getS3Key()); - } - messageRepository.save(message); - - audioUrl = result.getAudioUrl(); - cached = result.isCached(); + message.setFemaleVoiceKey(result.getS3Key()); } + messageRepository.save(message); - return createResponse(200, ApiResponse.ok( - cached ? "Speech retrieved from cache" : "Speech synthesized", - Map.of( - "audioUrl", audioUrl, - "cached", cached - ) - )); - - } catch (Exception e) { - logger.error("Error synthesizing speech", e); - return createResponse(500, ApiResponse.fail("Internal server error: " + e.getMessage())); + audioUrl = result.getAudioUrl(); + cached = result.isCached(); } + + return ResponseGenerator.ok( + cached ? "Speech retrieved from cache" : "Speech synthesized", + Map.of( + "audioUrl", audioUrl, + "cached", cached + ) + ); } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/BatchGetWordsRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/BatchGetWordsRequest.java index b24f5bd0..80520c19 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/BatchGetWordsRequest.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/BatchGetWordsRequest.java @@ -1,15 +1,19 @@ package com.mzc.secondproject.serverless.domain.vocabulary.dto.request; +import jakarta.validation.constraints.NotEmpty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + import java.util.List; +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor public class BatchGetWordsRequest { - private List wordIds; - public List getWordIds() { - return wordIds; - } - - public void setWordIds(List wordIds) { - this.wordIds = wordIds; - } + @NotEmpty(message = "is required") + private List wordIds; } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/CreateWordGroupRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/CreateWordGroupRequest.java index 141d057a..5929992f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/CreateWordGroupRequest.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/CreateWordGroupRequest.java @@ -1,22 +1,22 @@ package com.mzc.secondproject.serverless.domain.vocabulary.dto.request; -public class CreateWordGroupRequest { - private String groupName; - private String description; - - public String getGroupName() { - return groupName; - } +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; - public void setGroupName(String groupName) { - this.groupName = groupName; - } +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CreateWordGroupRequest { - public String getDescription() { - return description; - } + @NotBlank(message = "is required") + @Size(min = 1, max = 50, message = "must be between 1 and 50 characters") + private String groupName; - public void setDescription(String description) { - this.description = description; - } + @Size(max = 200, message = "must be at most 200 characters") + private String description; } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/CreateWordRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/CreateWordRequest.java index 6d232dae..5b60f0e6 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/CreateWordRequest.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/CreateWordRequest.java @@ -1,5 +1,7 @@ package com.mzc.secondproject.serverless.domain.vocabulary.dto.request; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -10,11 +12,21 @@ @NoArgsConstructor @AllArgsConstructor public class CreateWordRequest { + + @NotBlank(message = "is required") + @Size(max = 100, message = "must be at most 100 characters") private String english; + + @NotBlank(message = "is required") + @Size(max = 100, message = "must be at most 100 characters") private String korean; + + @Size(max = 500, message = "must be at most 500 characters") private String example; + @Builder.Default private String level = "BEGINNER"; + @Builder.Default private String category = "DAILY"; } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/CreateWordsBatchRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/CreateWordsBatchRequest.java index 06fb4e47..e923851c 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/CreateWordsBatchRequest.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/CreateWordsBatchRequest.java @@ -1,5 +1,7 @@ package com.mzc.secondproject.serverless.domain.vocabulary.dto.request; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -12,5 +14,8 @@ @NoArgsConstructor @AllArgsConstructor public class CreateWordsBatchRequest { + + @NotEmpty(message = "is required") + @Valid private List words; } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/SubmitTestRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/SubmitTestRequest.java index 584d1883..5c30221c 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/SubmitTestRequest.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/SubmitTestRequest.java @@ -1,5 +1,8 @@ package com.mzc.secondproject.serverless.domain.vocabulary.dto.request; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -12,10 +15,17 @@ @NoArgsConstructor @AllArgsConstructor public class SubmitTestRequest { + + @NotBlank(message = "is required") private String testId; + @Builder.Default private String testType = "DAILY"; + + @NotEmpty(message = "is required") + @Valid private List answers; + private String startedAt; @Data @@ -23,7 +33,9 @@ public class SubmitTestRequest { @NoArgsConstructor @AllArgsConstructor public static class TestAnswer { + @NotBlank(message = "is required") private String wordId; - private String answer; + + private String answer; // 빈 값 허용 (오답 처리) } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/SynthesizeVoiceRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/SynthesizeVoiceRequest.java index 819d124d..3e3b0fcb 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/SynthesizeVoiceRequest.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/SynthesizeVoiceRequest.java @@ -1,5 +1,6 @@ package com.mzc.secondproject.serverless.domain.vocabulary.dto.request; +import jakarta.validation.constraints.NotBlank; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -10,9 +11,13 @@ @NoArgsConstructor @AllArgsConstructor public class SynthesizeVoiceRequest { + + @NotBlank(message = "is required") private String wordId; + @Builder.Default private String voice = "FEMALE"; // MALE 또는 FEMALE + @Builder.Default private String type = "WORD"; // WORD 또는 EXAMPLE } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/UpdateUserWordRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/UpdateUserWordRequest.java index 023a0405..627d269d 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/UpdateUserWordRequest.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/UpdateUserWordRequest.java @@ -1,5 +1,6 @@ package com.mzc.secondproject.serverless.domain.vocabulary.dto.request; +import jakarta.validation.constraints.NotNull; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -10,5 +11,7 @@ @NoArgsConstructor @AllArgsConstructor public class UpdateUserWordRequest { + + @NotNull(message = "is required") private Boolean isCorrect; } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/DailyStudyHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/DailyStudyHandler.java index df0c97b3..3d804330 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/DailyStudyHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/DailyStudyHandler.java @@ -4,10 +4,10 @@ import com.amazonaws.services.lambda.runtime.RequestHandler; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; -import com.mzc.secondproject.serverless.common.dto.ApiResponse; +import com.mzc.secondproject.serverless.domain.vocabulary.exception.VocabularyErrorCode; import com.mzc.secondproject.serverless.common.router.HandlerRouter; import com.mzc.secondproject.serverless.common.router.Route; -import static com.mzc.secondproject.serverless.common.util.ResponseUtil.createResponse; +import com.mzc.secondproject.serverless.common.util.ResponseGenerator; import com.mzc.secondproject.serverless.domain.vocabulary.service.DailyStudyCommandService; import com.mzc.secondproject.serverless.domain.vocabulary.service.DailyStudyQueryService; import org.slf4j.Logger; @@ -44,13 +44,8 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent re } private APIGatewayProxyResponseEvent getDailyWords(APIGatewayProxyRequestEvent request) { - Map pathParams = request.getPathParameters(); + String userId = request.getPathParameters().get("userId"); Map queryParams = request.getQueryStringParameters(); - String userId = pathParams != null ? pathParams.get("userId") : null; - - if (userId == null) { - return createResponse(400, ApiResponse.fail("userId is required")); - } String date = queryParams != null ? queryParams.get("date") : null; String level = queryParams != null ? queryParams.get("level") : null; @@ -69,14 +64,14 @@ private APIGatewayProxyResponseEvent getDailyWords(APIGatewayProxyRequestEvent r response.put("reviewWords", result.reviewWords()); response.put("progress", result.progress()); - return createResponse(200, ApiResponse.ok("Daily words retrieved", response)); + return ResponseGenerator.ok("Daily words retrieved", response); } private APIGatewayProxyResponseEvent getDailyStudyByDate(String userId, String date) { var optDailyStudy = queryService.getDailyStudy(userId, date); if (optDailyStudy.isEmpty()) { - return createResponse(404, ApiResponse.fail("No daily study found for date: " + date)); + return ResponseGenerator.fail(VocabularyErrorCode.DAILY_STUDY_NOT_FOUND, "No daily study found for date: " + date); } var dailyStudy = optDailyStudy.get(); @@ -90,19 +85,14 @@ private APIGatewayProxyResponseEvent getDailyStudyByDate(String userId, String d response.put("reviewWords", reviewWords); response.put("progress", progress); - return createResponse(200, ApiResponse.ok("Daily study retrieved for " + date, response)); + return ResponseGenerator.ok("Daily study retrieved for " + date, response); } private APIGatewayProxyResponseEvent markWordLearned(APIGatewayProxyRequestEvent request) { - Map pathParams = request.getPathParameters(); - String userId = pathParams != null ? pathParams.get("userId") : null; - String wordId = pathParams != null ? pathParams.get("wordId") : null; - - if (userId == null || wordId == null) { - return createResponse(400, ApiResponse.fail("userId and wordId are required")); - } + String userId = request.getPathParameters().get("userId"); + String wordId = request.getPathParameters().get("wordId"); Map progress = commandService.markWordLearned(userId, wordId); - return createResponse(200, ApiResponse.ok("Word marked as learned", progress)); + return ResponseGenerator.ok("Word marked as learned", progress); } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatisticsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatisticsHandler.java index 8e8e7c80..8df21400 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatisticsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatisticsHandler.java @@ -3,7 +3,7 @@ import com.amazonaws.services.lambda.runtime.Context; import com.amazonaws.services.lambda.runtime.RequestHandler; import com.amazonaws.services.lambda.runtime.events.SQSEvent; -import com.mzc.secondproject.serverless.common.util.ResponseUtil; +import com.mzc.secondproject.serverless.common.util.ResponseGenerator; import com.mzc.secondproject.serverless.domain.vocabulary.service.StatisticsService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -46,7 +46,7 @@ private void processMessage(SQSEvent.SQSMessage message) { String body = message.getBody(); logger.info("Processing message: {}", body); - Map testResult = ResponseUtil.gson().fromJson(body, Map.class); + Map testResult = ResponseGenerator.gson().fromJson(body, Map.class); String userId = (String) testResult.get("userId"); List> results = (List>) testResult.get("results"); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatsHandler.java index c345394d..f61f6a67 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatsHandler.java @@ -4,10 +4,9 @@ import com.amazonaws.services.lambda.runtime.RequestHandler; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; -import com.mzc.secondproject.serverless.common.dto.ApiResponse; import com.mzc.secondproject.serverless.common.router.HandlerRouter; import com.mzc.secondproject.serverless.common.router.Route; -import static com.mzc.secondproject.serverless.common.util.ResponseUtil.createResponse; +import com.mzc.secondproject.serverless.common.util.ResponseGenerator; import com.mzc.secondproject.serverless.domain.vocabulary.service.StatsService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -42,28 +41,17 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent re } private APIGatewayProxyResponseEvent getOverallStats(APIGatewayProxyRequestEvent request) { - Map pathParams = request.getPathParameters(); - String userId = pathParams != null ? pathParams.get("userId") : null; - - if (userId == null) { - return createResponse(400, ApiResponse.fail("userId is required")); - } + String userId = request.getPathParameters().get("userId"); Map stats = statsService.getOverallStats(userId); - return createResponse(200, ApiResponse.ok("Stats retrieved", stats)); + return ResponseGenerator.ok("Stats retrieved", stats); } private APIGatewayProxyResponseEvent getDailyStats(APIGatewayProxyRequestEvent request) { - Map pathParams = request.getPathParameters(); + String userId = request.getPathParameters().get("userId"); Map queryParams = request.getQueryStringParameters(); - - String userId = pathParams != null ? pathParams.get("userId") : null; String cursor = queryParams != null ? queryParams.get("cursor") : null; - if (userId == null) { - return createResponse(400, ApiResponse.fail("userId is required")); - } - int limit = 30; if (queryParams != null && queryParams.get("limit") != null) { limit = Math.min(Integer.parseInt(queryParams.get("limit")), 90); @@ -76,18 +64,13 @@ private APIGatewayProxyResponseEvent getDailyStats(APIGatewayProxyRequestEvent r result.put("nextCursor", dailyResult.nextCursor()); result.put("hasMore", dailyResult.hasMore()); - return createResponse(200, ApiResponse.ok("Daily stats retrieved", result)); + return ResponseGenerator.ok("Daily stats retrieved", result); } private APIGatewayProxyResponseEvent getWeaknessAnalysis(APIGatewayProxyRequestEvent request) { - Map pathParams = request.getPathParameters(); - String userId = pathParams != null ? pathParams.get("userId") : null; - - if (userId == null) { - return createResponse(400, ApiResponse.fail("userId is required")); - } + String userId = request.getPathParameters().get("userId"); Map analysis = statsService.getWeaknessAnalysis(userId); - return createResponse(200, ApiResponse.ok("Weakness analysis completed", analysis)); + return ResponseGenerator.ok("Weakness analysis completed", analysis); } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/TestHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/TestHandler.java index 2f0e5746..8330b8b8 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/TestHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/TestHandler.java @@ -4,14 +4,14 @@ import com.amazonaws.services.lambda.runtime.RequestHandler; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; -import com.mzc.secondproject.serverless.common.dto.ApiResponse; import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.common.exception.CommonErrorCode; +import com.mzc.secondproject.serverless.common.validation.BeanValidator; import com.mzc.secondproject.serverless.domain.vocabulary.dto.request.StartTestRequest; import com.mzc.secondproject.serverless.domain.vocabulary.dto.request.SubmitTestRequest; import com.mzc.secondproject.serverless.common.router.HandlerRouter; import com.mzc.secondproject.serverless.common.router.Route; -import com.mzc.secondproject.serverless.common.util.ResponseUtil; -import static com.mzc.secondproject.serverless.common.util.ResponseUtil.createResponse; +import com.mzc.secondproject.serverless.common.util.ResponseGenerator; import com.mzc.secondproject.serverless.domain.vocabulary.model.TestResult; import com.mzc.secondproject.serverless.domain.vocabulary.service.TestCommandService; import com.mzc.secondproject.serverless.domain.vocabulary.service.TestQueryService; @@ -19,7 +19,6 @@ import org.slf4j.LoggerFactory; import java.util.HashMap; -import java.util.List; import java.util.Map; public class TestHandler implements RequestHandler { @@ -52,15 +51,8 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent re } private APIGatewayProxyResponseEvent startTest(APIGatewayProxyRequestEvent request) { - Map pathParams = request.getPathParameters(); - String userId = pathParams != null ? pathParams.get("userId") : null; - - if (userId == null) { - return createResponse(400, ApiResponse.fail("userId is required")); - } - - String body = request.getBody(); - StartTestRequest req = ResponseUtil.gson().fromJson(body, StartTestRequest.class); + String userId = request.getPathParameters().get("userId"); + StartTestRequest req = ResponseGenerator.gson().fromJson(request.getBody(), StartTestRequest.class); String testType = req != null && req.getTestType() != null ? req.getTestType() : "DAILY"; TestCommandService.StartTestResult result = commandService.startTest(userId, testType); @@ -72,51 +64,37 @@ private APIGatewayProxyResponseEvent startTest(APIGatewayProxyRequestEvent reque response.put("totalQuestions", result.totalQuestions()); response.put("startedAt", result.startedAt()); - return createResponse(200, ApiResponse.ok("Test started", response)); + return ResponseGenerator.ok("Test started", response); } private APIGatewayProxyResponseEvent submitAnswer(APIGatewayProxyRequestEvent request) { - Map pathParams = request.getPathParameters(); - String userId = pathParams != null ? pathParams.get("userId") : null; - - if (userId == null) { - return createResponse(400, ApiResponse.fail("userId is required")); - } - - String body = request.getBody(); - SubmitTestRequest req = ResponseUtil.gson().fromJson(body, SubmitTestRequest.class); - - if (req.getTestId() == null || req.getAnswers() == null) { - return createResponse(400, ApiResponse.fail("testId and answers are required")); - } - - String testType = req.getTestType() != null ? req.getTestType() : "DAILY"; - - TestCommandService.SubmitTestResult result = commandService.submitTest(userId, req.getTestId(), testType, req.getAnswers(), req.getStartedAt()); - - Map response = new HashMap<>(); - response.put("testId", result.testId()); - response.put("testType", result.testType()); - response.put("totalQuestions", result.totalQuestions()); - response.put("correctCount", result.correctCount()); - response.put("incorrectCount", result.incorrectCount()); - response.put("successRate", result.successRate()); - response.put("results", result.results()); - - return createResponse(200, ApiResponse.ok("Test submitted", response)); + String userId = request.getPathParameters().get("userId"); + SubmitTestRequest req = ResponseGenerator.gson().fromJson(request.getBody(), SubmitTestRequest.class); + + return BeanValidator.validateAndExecute(req, dto -> { + String testType = dto.getTestType() != null ? dto.getTestType() : "DAILY"; + + TestCommandService.SubmitTestResult result = commandService.submitTest( + userId, dto.getTestId(), testType, dto.getAnswers(), dto.getStartedAt()); + + Map response = new HashMap<>(); + response.put("testId", result.testId()); + response.put("testType", result.testType()); + response.put("totalQuestions", result.totalQuestions()); + response.put("correctCount", result.correctCount()); + response.put("incorrectCount", result.incorrectCount()); + response.put("successRate", result.successRate()); + response.put("results", result.results()); + + return ResponseGenerator.ok("Test submitted", response); + }); } private APIGatewayProxyResponseEvent getTestResults(APIGatewayProxyRequestEvent request) { - Map pathParams = request.getPathParameters(); + String userId = request.getPathParameters().get("userId"); Map queryParams = request.getQueryStringParameters(); - - String userId = pathParams != null ? pathParams.get("userId") : null; String cursor = queryParams != null ? queryParams.get("cursor") : null; - if (userId == null) { - return createResponse(400, ApiResponse.fail("userId is required")); - } - int limit = 10; if (queryParams != null && queryParams.get("limit") != null) { limit = Math.min(Integer.parseInt(queryParams.get("limit")), 50); @@ -129,22 +107,16 @@ private APIGatewayProxyResponseEvent getTestResults(APIGatewayProxyRequestEvent result.put("nextCursor", resultPage.nextCursor()); result.put("hasMore", resultPage.hasMore()); - return createResponse(200, ApiResponse.ok("Test results retrieved", result)); + return ResponseGenerator.ok("Test results retrieved", result); } private APIGatewayProxyResponseEvent getTestResultDetail(APIGatewayProxyRequestEvent request) { - Map pathParams = request.getPathParameters(); - - String userId = pathParams != null ? pathParams.get("userId") : null; - String testId = pathParams != null ? pathParams.get("testId") : null; - - if (userId == null || testId == null) { - return createResponse(400, ApiResponse.fail("userId and testId are required")); - } + String userId = request.getPathParameters().get("userId"); + String testId = request.getPathParameters().get("testId"); var optDetail = queryService.getTestResultDetail(userId, testId); if (optDetail.isEmpty()) { - return createResponse(404, ApiResponse.fail("Test result not found")); + return ResponseGenerator.fail(CommonErrorCode.RESOURCE_NOT_FOUND,"Test result not found"); } var detail = optDetail.get(); @@ -160,6 +132,6 @@ private APIGatewayProxyResponseEvent getTestResultDetail(APIGatewayProxyRequestE result.put("startedAt", detail.testResult().getStartedAt()); result.put("completedAt", detail.testResult().getCompletedAt()); - return createResponse(200, ApiResponse.ok("Test result detail retrieved", result)); + return ResponseGenerator.ok("Test result detail retrieved", result); } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java index d6fc8d4c..31223f6d 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java @@ -4,15 +4,14 @@ import com.amazonaws.services.lambda.runtime.RequestHandler; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; -import com.mzc.secondproject.serverless.common.dto.ApiResponse; -import com.mzc.secondproject.serverless.common.validation.RequestValidator; -import com.mzc.secondproject.serverless.common.validation.ValidationResult; +import com.mzc.secondproject.serverless.common.exception.CommonErrorCode; +import com.mzc.secondproject.serverless.common.validation.BeanValidator; import com.mzc.secondproject.serverless.domain.vocabulary.dto.request.UpdateUserWordRequest; import com.mzc.secondproject.serverless.domain.vocabulary.dto.request.UpdateUserWordTagRequest; +import com.mzc.secondproject.serverless.domain.vocabulary.exception.VocabularyErrorCode; import com.mzc.secondproject.serverless.common.router.HandlerRouter; import com.mzc.secondproject.serverless.common.router.Route; -import com.mzc.secondproject.serverless.common.util.ResponseUtil; -import static com.mzc.secondproject.serverless.common.util.ResponseUtil.createResponse; +import com.mzc.secondproject.serverless.common.util.ResponseGenerator; import com.mzc.secondproject.serverless.domain.vocabulary.model.UserWord; import com.mzc.secondproject.serverless.domain.vocabulary.service.UserWordCommandService; import com.mzc.secondproject.serverless.domain.vocabulary.service.UserWordQueryService; @@ -54,19 +53,14 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent re } private APIGatewayProxyResponseEvent getUserWords(APIGatewayProxyRequestEvent request) { - Map pathParams = request.getPathParameters(); + String userId = request.getPathParameters().get("userId"); Map queryParams = request.getQueryStringParameters(); - String userId = pathParams != null ? pathParams.get("userId") : null; String status = queryParams != null ? queryParams.get("status") : null; String cursor = queryParams != null ? queryParams.get("cursor") : null; String bookmarked = queryParams != null ? queryParams.get("bookmarked") : null; String incorrectOnly = queryParams != null ? queryParams.get("incorrectOnly") : null; - if (userId == null) { - return createResponse(400, ApiResponse.fail("userId is required")); - } - int limit = 20; if (queryParams != null && queryParams.get("limit") != null) { limit = Math.min(Integer.parseInt(queryParams.get("limit")), 50); @@ -79,77 +73,46 @@ private APIGatewayProxyResponseEvent getUserWords(APIGatewayProxyRequestEvent re response.put("nextCursor", result.nextCursor()); response.put("hasMore", result.hasMore()); - return createResponse(200, ApiResponse.ok("User words retrieved", response)); + return ResponseGenerator.ok("User words retrieved", response); } private APIGatewayProxyResponseEvent getUserWord(APIGatewayProxyRequestEvent request) { - Map pathParams = request.getPathParameters(); - String userId = pathParams != null ? pathParams.get("userId") : null; - String wordId = pathParams != null ? pathParams.get("wordId") : null; - - if (userId == null || wordId == null) { - return createResponse(400, ApiResponse.fail("userId and wordId are required")); - } + String userId = request.getPathParameters().get("userId"); + String wordId = request.getPathParameters().get("wordId"); Optional optUserWord = queryService.getUserWord(userId, wordId); if (optUserWord.isEmpty()) { - return createResponse(404, ApiResponse.fail("UserWord not found")); + return ResponseGenerator.fail(VocabularyErrorCode.USER_WORD_NOT_FOUND); } - return createResponse(200, ApiResponse.ok("UserWord retrieved", optUserWord.get())); + return ResponseGenerator.ok("UserWord retrieved", optUserWord.get()); } private APIGatewayProxyResponseEvent updateUserWord(APIGatewayProxyRequestEvent request) { - Map pathParams = request.getPathParameters(); - String userId = pathParams != null ? pathParams.get("userId") : null; - String wordId = pathParams != null ? pathParams.get("wordId") : null; - - if (userId == null || wordId == null) { - return createResponse(400, ApiResponse.fail("userId and wordId are required")); - } - - String body = request.getBody(); - UpdateUserWordRequest req = ResponseUtil.gson().fromJson(body, UpdateUserWordRequest.class); - - if (req.getIsCorrect() == null) { - return createResponse(400, ApiResponse.fail("isCorrect is required")); - } - - UserWord userWord = commandService.updateUserWord(userId, wordId, req.getIsCorrect()); - return createResponse(200, ApiResponse.ok("UserWord updated", userWord)); + String userId = request.getPathParameters().get("userId"); + String wordId = request.getPathParameters().get("wordId"); + UpdateUserWordRequest req = ResponseGenerator.gson().fromJson(request.getBody(), UpdateUserWordRequest.class); + + return BeanValidator.validateAndExecute(req, dto -> { + UserWord userWord = commandService.updateUserWord(userId, wordId, dto.getIsCorrect()); + return ResponseGenerator.ok("UserWord updated", userWord); + }); } private APIGatewayProxyResponseEvent updateUserWordTag(APIGatewayProxyRequestEvent request) { - Map pathParams = request.getPathParameters(); - String userId = pathParams != null ? pathParams.get("userId") : null; - String wordId = pathParams != null ? pathParams.get("wordId") : null; - - if (userId == null || wordId == null) { - return createResponse(400, ApiResponse.fail("userId and wordId are required")); - } - - String body = request.getBody(); - UpdateUserWordTagRequest req = ResponseUtil.gson().fromJson(body, UpdateUserWordTagRequest.class); + String userId = request.getPathParameters().get("userId"); + String wordId = request.getPathParameters().get("wordId"); + UpdateUserWordTagRequest req = ResponseGenerator.gson().fromJson(request.getBody(), UpdateUserWordTagRequest.class); UserWord userWord = commandService.updateUserWordTag(userId, wordId, req.getBookmarked(), req.getFavorite(), req.getDifficulty()); - return createResponse(200, ApiResponse.ok("Tag updated", userWord)); + return ResponseGenerator.ok("Tag updated", userWord); } private APIGatewayProxyResponseEvent getWrongAnswers(APIGatewayProxyRequestEvent request) { - Map pathParams = request.getPathParameters(); + String userId = request.getPathParameters().get("userId"); Map queryParams = request.getQueryStringParameters(); - - String userId = pathParams != null ? pathParams.get("userId") : null; String cursor = queryParams != null ? queryParams.get("cursor") : null; - ValidationResult validation = RequestValidator.create() - .requireNotEmpty(userId, "userId") - .build(); - - if (validation.isInvalid()) { - return createResponse(400, ApiResponse.fail(validation.getErrorMessage().orElse("Validation failed"))); - } - int limit = parseIntParam(queryParams, "limit", 20, 1, 50); int minCount = parseIntParam(queryParams, "minCount", 1, 1, 100); @@ -160,7 +123,7 @@ private APIGatewayProxyResponseEvent getWrongAnswers(APIGatewayProxyRequestEvent response.put("nextCursor", result.nextCursor()); response.put("hasMore", result.hasMore()); - return createResponse(200, ApiResponse.ok("Wrong answers retrieved", response)); + return ResponseGenerator.ok("Wrong answers retrieved", response); } private int parseIntParam(Map params, String key, int defaultValue, int min, int max) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/VoiceHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/VoiceHandler.java index f1807cba..c66639ef 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/VoiceHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/VoiceHandler.java @@ -4,10 +4,11 @@ import com.amazonaws.services.lambda.runtime.RequestHandler; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; -import com.mzc.secondproject.serverless.common.dto.ApiResponse; +import com.mzc.secondproject.serverless.common.exception.CommonErrorCode; +import com.mzc.secondproject.serverless.common.validation.BeanValidator; import com.mzc.secondproject.serverless.domain.vocabulary.dto.request.SynthesizeVoiceRequest; -import com.mzc.secondproject.serverless.common.util.ResponseUtil; -import static com.mzc.secondproject.serverless.common.util.ResponseUtil.createResponse; +import com.mzc.secondproject.serverless.domain.vocabulary.exception.VocabularyErrorCode; +import com.mzc.secondproject.serverless.common.util.ResponseGenerator; import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; import com.mzc.secondproject.serverless.domain.vocabulary.repository.WordRepository; import com.mzc.secondproject.serverless.common.service.PollyService; @@ -39,83 +40,72 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent re logger.info("Received request: {} {}", httpMethod, path); try { - // POST /vocab/voice/synthesize - 음성 합성 if ("POST".equals(httpMethod) && path.endsWith("/synthesize")) { return synthesizeSpeech(request); } - return createResponse(404, ApiResponse.fail("Not found")); + return ResponseGenerator.fail(CommonErrorCode.RESOURCE_NOT_FOUND); } catch (Exception e) { logger.error("Error handling request", e); - return createResponse(500, ApiResponse.fail("Internal server error: " + e.getMessage())); + return ResponseGenerator.fail(CommonErrorCode.INTERNAL_SERVER_ERROR); } } private APIGatewayProxyResponseEvent synthesizeSpeech(APIGatewayProxyRequestEvent request) { String body = request.getBody(); - SynthesizeVoiceRequest req = ResponseUtil.gson().fromJson(body, SynthesizeVoiceRequest.class); + SynthesizeVoiceRequest req = ResponseGenerator.gson().fromJson(body, SynthesizeVoiceRequest.class); - if (req.getWordId() == null || req.getWordId().isEmpty()) { - return createResponse(400, ApiResponse.fail("wordId is required")); - } + return BeanValidator.validateAndExecute(req, dto -> { + String wordId = dto.getWordId(); + String voice = dto.getVoice() != null ? dto.getVoice() : "FEMALE"; + String type = dto.getType() != null ? dto.getType() : "WORD"; - String wordId = req.getWordId(); - String voice = req.getVoice() != null ? req.getVoice() : "FEMALE"; - String type = req.getType() != null ? req.getType() : "WORD"; + Optional optWord = wordRepository.findById(wordId); + if (optWord.isEmpty()) { + return ResponseGenerator.fail(VocabularyErrorCode.WORD_NOT_FOUND); + } - // 단어 조회 - Optional optWord = wordRepository.findById(wordId); - if (optWord.isEmpty()) { - return createResponse(404, ApiResponse.fail("Word not found")); - } + Word word = optWord.get(); + boolean isMale = "MALE".equalsIgnoreCase(voice); + boolean isExample = "EXAMPLE".equalsIgnoreCase(type); - Word word = optWord.get(); - boolean isMale = "MALE".equalsIgnoreCase(voice); - boolean isExample = "EXAMPLE".equalsIgnoreCase(type); + if (isExample && (word.getExample() == null || word.getExample().isEmpty())) { + return ResponseGenerator.fail(VocabularyErrorCode.INVALID_WORD_DATA, "This word has no example sentence"); + } - // 예문 요청인데 예문이 없는 경우 - if (isExample && (word.getExample() == null || word.getExample().isEmpty())) { - return createResponse(400, ApiResponse.fail("This word has no example sentence")); - } + String textToSynthesize = isExample ? word.getExample() : word.getEnglish(); + String cachedKey = getCachedKey(word, isMale, isExample); + String audioUrl; + boolean cached = false; - // 음성 합성할 텍스트 결정 - String textToSynthesize = isExample ? word.getExample() : word.getEnglish(); + if (cachedKey != null && !cachedKey.isEmpty()) { + audioUrl = pollyService.getPresignedUrl(cachedKey); + cached = true; + logger.info("Cache hit from DB: wordId={}, voice={}, type={}", wordId, voice, type); + } else { + String s3KeySuffix = isExample ? "_example" : ""; + PollyService.VoiceSynthesisResult result = pollyService.synthesizeSpeech( + wordId + s3KeySuffix, textToSynthesize, voice); - // 캐시 키 결정 - String cachedKey = getCachedKey(word, isMale, isExample); - String audioUrl; - boolean cached = false; + audioUrl = result.getAudioUrl(); + cached = result.isCached(); - if (cachedKey != null && !cachedKey.isEmpty()) { - // DB에 캐시 키가 있으면 Pre-signed URL 생성 - audioUrl = pollyService.getPresignedUrl(cachedKey); - cached = true; - logger.info("Cache hit from DB: wordId={}, voice={}, type={}", wordId, voice, type); - } else { - // 캐시 미스: Polly 변환 후 S3 저장 - String s3KeySuffix = isExample ? "_example" : ""; - PollyService.VoiceSynthesisResult result = pollyService.synthesizeSpeech( - wordId + s3KeySuffix, textToSynthesize, voice); - - audioUrl = result.getAudioUrl(); - cached = result.isCached(); - - // DynamoDB에 S3 키 저장 - setCachedKey(word, isMale, isExample, result.getS3Key()); - wordRepository.save(word); - logger.info("Saved voice cache to DB: wordId={}, voice={}, type={}", wordId, voice, type); - } + setCachedKey(word, isMale, isExample, result.getS3Key()); + wordRepository.save(word); + logger.info("Saved voice cache to DB: wordId={}, voice={}, type={}", wordId, voice, type); + } - Map responseData = new HashMap<>(); - responseData.put("wordId", wordId); - responseData.put("text", textToSynthesize); - responseData.put("type", type); - responseData.put("voice", voice); - responseData.put("audioUrl", audioUrl); - responseData.put("cached", cached); + Map responseData = new HashMap<>(); + responseData.put("wordId", wordId); + responseData.put("text", textToSynthesize); + responseData.put("type", type); + responseData.put("voice", voice); + responseData.put("audioUrl", audioUrl); + responseData.put("cached", cached); - return createResponse(200, ApiResponse.ok("Speech synthesized", responseData)); + return ResponseGenerator.ok("Speech synthesized", responseData); + }); } private String getCachedKey(Word word, boolean isMale, boolean isExample) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/WordGroupHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/WordGroupHandler.java index 2d26ceb0..94131f46 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/WordGroupHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/WordGroupHandler.java @@ -4,13 +4,12 @@ import com.amazonaws.services.lambda.runtime.RequestHandler; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; -import com.mzc.secondproject.serverless.common.dto.ApiResponse; import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.common.exception.CommonErrorCode; import com.mzc.secondproject.serverless.common.router.HandlerRouter; import com.mzc.secondproject.serverless.common.router.Route; -import com.mzc.secondproject.serverless.common.util.ResponseUtil; -import com.mzc.secondproject.serverless.common.validation.RequestValidator; -import com.mzc.secondproject.serverless.common.validation.ValidationResult; +import com.mzc.secondproject.serverless.common.util.ResponseGenerator; +import com.mzc.secondproject.serverless.common.validation.BeanValidator; import com.mzc.secondproject.serverless.domain.vocabulary.dto.request.CreateWordGroupRequest; import com.mzc.secondproject.serverless.domain.vocabulary.model.WordGroup; import com.mzc.secondproject.serverless.domain.vocabulary.service.WordGroupCommandService; @@ -21,8 +20,6 @@ import java.util.HashMap; import java.util.Map; -import static com.mzc.secondproject.serverless.common.util.ResponseUtil.createResponse; - public class WordGroupHandler implements RequestHandler { private static final Logger logger = LoggerFactory.getLogger(WordGroupHandler.class); @@ -56,40 +53,20 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent re } private APIGatewayProxyResponseEvent createGroup(APIGatewayProxyRequestEvent request) { - Map pathParams = request.getPathParameters(); - String userId = pathParams != null ? pathParams.get("userId") : null; - - String body = request.getBody(); - CreateWordGroupRequest req = ResponseUtil.gson().fromJson(body, CreateWordGroupRequest.class); - - ValidationResult validation = RequestValidator.create() - .requireNotEmpty(userId, "userId") - .requireNotEmpty(req != null ? req.getGroupName() : null, "groupName") - .build(); + String userId = request.getPathParameters().get("userId"); + CreateWordGroupRequest req = ResponseGenerator.gson().fromJson(request.getBody(), CreateWordGroupRequest.class); - if (validation.isInvalid()) { - return createResponse(400, ApiResponse.fail(validation.getErrorMessage().orElse("Validation failed"))); - } - - WordGroup group = commandService.createGroup(userId, req.getGroupName(), req.getDescription()); - return createResponse(201, ApiResponse.ok("Group created", group)); + return BeanValidator.validateAndExecute(req, dto -> { + WordGroup group = commandService.createGroup(userId, dto.getGroupName(), dto.getDescription()); + return ResponseGenerator.created("Group created", group); + }); } private APIGatewayProxyResponseEvent getGroups(APIGatewayProxyRequestEvent request) { - Map pathParams = request.getPathParameters(); + String userId = request.getPathParameters().get("userId"); Map queryParams = request.getQueryStringParameters(); - - String userId = pathParams != null ? pathParams.get("userId") : null; String cursor = queryParams != null ? queryParams.get("cursor") : null; - ValidationResult validation = RequestValidator.create() - .requireNotEmpty(userId, "userId") - .build(); - - if (validation.isInvalid()) { - return createResponse(400, ApiResponse.fail(validation.getErrorMessage().orElse("Validation failed"))); - } - int limit = parseIntParam(queryParams, "limit", 20, 1, 50); PaginatedResult result = queryService.getGroups(userId, limit, cursor); @@ -99,26 +76,16 @@ private APIGatewayProxyResponseEvent getGroups(APIGatewayProxyRequestEvent reque response.put("nextCursor", result.nextCursor()); response.put("hasMore", result.hasMore()); - return createResponse(200, ApiResponse.ok("Groups retrieved", response)); + return ResponseGenerator.ok("Groups retrieved", response); } private APIGatewayProxyResponseEvent getGroupDetail(APIGatewayProxyRequestEvent request) { - Map pathParams = request.getPathParameters(); - String userId = pathParams != null ? pathParams.get("userId") : null; - String groupId = pathParams != null ? pathParams.get("groupId") : null; - - ValidationResult validation = RequestValidator.create() - .requireNotEmpty(userId, "userId") - .requireNotEmpty(groupId, "groupId") - .build(); - - if (validation.isInvalid()) { - return createResponse(400, ApiResponse.fail(validation.getErrorMessage().orElse("Validation failed"))); - } + String userId = request.getPathParameters().get("userId"); + String groupId = request.getPathParameters().get("groupId"); var optDetail = queryService.getGroupDetail(userId, groupId); if (optDetail.isEmpty()) { - return createResponse(404, ApiResponse.fail("Group not found")); + return ResponseGenerator.fail(CommonErrorCode.RESOURCE_NOT_FOUND,"Group not found"); } var detail = optDetail.get(); @@ -132,103 +99,59 @@ private APIGatewayProxyResponseEvent getGroupDetail(APIGatewayProxyRequestEvent response.put("createdAt", detail.group().getCreatedAt()); response.put("updatedAt", detail.group().getUpdatedAt()); - return createResponse(200, ApiResponse.ok("Group detail retrieved", response)); + return ResponseGenerator.ok("Group detail retrieved", response); } private APIGatewayProxyResponseEvent updateGroup(APIGatewayProxyRequestEvent request) { - Map pathParams = request.getPathParameters(); - String userId = pathParams != null ? pathParams.get("userId") : null; - String groupId = pathParams != null ? pathParams.get("groupId") : null; - - ValidationResult validation = RequestValidator.create() - .requireNotEmpty(userId, "userId") - .requireNotEmpty(groupId, "groupId") - .build(); - - if (validation.isInvalid()) { - return createResponse(400, ApiResponse.fail(validation.getErrorMessage().orElse("Validation failed"))); - } - - String body = request.getBody(); - CreateWordGroupRequest req = ResponseUtil.gson().fromJson(body, CreateWordGroupRequest.class); + String userId = request.getPathParameters().get("userId"); + String groupId = request.getPathParameters().get("groupId"); + CreateWordGroupRequest req = ResponseGenerator.gson().fromJson(request.getBody(), CreateWordGroupRequest.class); try { WordGroup group = commandService.updateGroup(userId, groupId, req != null ? req.getGroupName() : null, req != null ? req.getDescription() : null); - return createResponse(200, ApiResponse.ok("Group updated", group)); + return ResponseGenerator.ok("Group updated", group); } catch (IllegalArgumentException e) { - return createResponse(404, ApiResponse.fail(e.getMessage())); + return ResponseGenerator.fail(CommonErrorCode.RESOURCE_NOT_FOUND,e.getMessage()); } } private APIGatewayProxyResponseEvent deleteGroup(APIGatewayProxyRequestEvent request) { - Map pathParams = request.getPathParameters(); - String userId = pathParams != null ? pathParams.get("userId") : null; - String groupId = pathParams != null ? pathParams.get("groupId") : null; - - ValidationResult validation = RequestValidator.create() - .requireNotEmpty(userId, "userId") - .requireNotEmpty(groupId, "groupId") - .build(); - - if (validation.isInvalid()) { - return createResponse(400, ApiResponse.fail(validation.getErrorMessage().orElse("Validation failed"))); - } + String userId = request.getPathParameters().get("userId"); + String groupId = request.getPathParameters().get("groupId"); try { commandService.deleteGroup(userId, groupId); - return createResponse(200, ApiResponse.ok("Group deleted", null)); + return ResponseGenerator.ok("Group deleted", null); } catch (IllegalArgumentException e) { - return createResponse(404, ApiResponse.fail(e.getMessage())); + return ResponseGenerator.fail(CommonErrorCode.RESOURCE_NOT_FOUND,e.getMessage()); } } private APIGatewayProxyResponseEvent addWordToGroup(APIGatewayProxyRequestEvent request) { - Map pathParams = request.getPathParameters(); - String userId = pathParams != null ? pathParams.get("userId") : null; - String groupId = pathParams != null ? pathParams.get("groupId") : null; - String wordId = pathParams != null ? pathParams.get("wordId") : null; - - ValidationResult validation = RequestValidator.create() - .requireNotEmpty(userId, "userId") - .requireNotEmpty(groupId, "groupId") - .requireNotEmpty(wordId, "wordId") - .build(); - - if (validation.isInvalid()) { - return createResponse(400, ApiResponse.fail(validation.getErrorMessage().orElse("Validation failed"))); - } + String userId = request.getPathParameters().get("userId"); + String groupId = request.getPathParameters().get("groupId"); + String wordId = request.getPathParameters().get("wordId"); try { WordGroup group = commandService.addWordToGroup(userId, groupId, wordId); - return createResponse(200, ApiResponse.ok("Word added to group", group)); + return ResponseGenerator.ok("Word added to group", group); } catch (IllegalArgumentException e) { - return createResponse(404, ApiResponse.fail(e.getMessage())); + return ResponseGenerator.fail(CommonErrorCode.RESOURCE_NOT_FOUND,e.getMessage()); } } private APIGatewayProxyResponseEvent removeWordFromGroup(APIGatewayProxyRequestEvent request) { - Map pathParams = request.getPathParameters(); - String userId = pathParams != null ? pathParams.get("userId") : null; - String groupId = pathParams != null ? pathParams.get("groupId") : null; - String wordId = pathParams != null ? pathParams.get("wordId") : null; - - ValidationResult validation = RequestValidator.create() - .requireNotEmpty(userId, "userId") - .requireNotEmpty(groupId, "groupId") - .requireNotEmpty(wordId, "wordId") - .build(); - - if (validation.isInvalid()) { - return createResponse(400, ApiResponse.fail(validation.getErrorMessage().orElse("Validation failed"))); - } + String userId = request.getPathParameters().get("userId"); + String groupId = request.getPathParameters().get("groupId"); + String wordId = request.getPathParameters().get("wordId"); try { WordGroup group = commandService.removeWordFromGroup(userId, groupId, wordId); - return createResponse(200, ApiResponse.ok("Word removed from group", group)); + return ResponseGenerator.ok("Word removed from group", group); } catch (IllegalArgumentException e) { - return createResponse(404, ApiResponse.fail(e.getMessage())); + return ResponseGenerator.fail(CommonErrorCode.RESOURCE_NOT_FOUND,e.getMessage()); } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/WordHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/WordHandler.java index c39d05f7..51130c52 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/WordHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/WordHandler.java @@ -4,15 +4,16 @@ import com.amazonaws.services.lambda.runtime.RequestHandler; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; -import com.mzc.secondproject.serverless.common.dto.ApiResponse; import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.common.exception.CommonErrorCode; +import com.mzc.secondproject.serverless.common.validation.BeanValidator; import com.mzc.secondproject.serverless.domain.vocabulary.dto.request.BatchGetWordsRequest; import com.mzc.secondproject.serverless.domain.vocabulary.dto.request.CreateWordRequest; import com.mzc.secondproject.serverless.domain.vocabulary.dto.request.CreateWordsBatchRequest; +import com.mzc.secondproject.serverless.domain.vocabulary.exception.VocabularyErrorCode; import com.mzc.secondproject.serverless.common.router.HandlerRouter; import com.mzc.secondproject.serverless.common.router.Route; -import com.mzc.secondproject.serverless.common.util.ResponseUtil; -import static com.mzc.secondproject.serverless.common.util.ResponseUtil.createResponse; +import com.mzc.secondproject.serverless.common.util.ResponseGenerator; import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; import com.mzc.secondproject.serverless.domain.vocabulary.service.WordCommandService; import com.mzc.secondproject.serverless.domain.vocabulary.service.WordQueryService; @@ -42,7 +43,7 @@ private HandlerRouter initRouter() { return new HandlerRouter().addRoutes( Route.post("/words/batch/get", this::getWordsBatch), Route.post("/words/batch", this::createWordsBatch), - Route.get("/words/search", this::searchWords), + Route.get("/words/search", this::searchWords).requireQueryParams("q"), Route.post("/words", this::createWord), Route.get("/words", this::getWords), Route.get("/words/{wordId}", this::getWord), @@ -59,20 +60,15 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent re private APIGatewayProxyResponseEvent createWord(APIGatewayProxyRequestEvent request) { String body = request.getBody(); - CreateWordRequest req = ResponseUtil.gson().fromJson(body, CreateWordRequest.class); + CreateWordRequest req = ResponseGenerator.gson().fromJson(body, CreateWordRequest.class); - if (req.getEnglish() == null || req.getEnglish().isEmpty()) { - return createResponse(400, ApiResponse.fail("english is required")); - } - if (req.getKorean() == null || req.getKorean().isEmpty()) { - return createResponse(400, ApiResponse.fail("korean is required")); - } - - String level = req.getLevel() != null ? req.getLevel() : "BEGINNER"; - String category = req.getCategory() != null ? req.getCategory() : "DAILY"; + return BeanValidator.validateAndExecute(req, dto -> { + String level = dto.getLevel() != null ? dto.getLevel() : "BEGINNER"; + String category = dto.getCategory() != null ? dto.getCategory() : "DAILY"; - Word word = commandService.createWord(req.getEnglish(), req.getKorean(), req.getExample(), level, category); - return createResponse(201, ApiResponse.ok("Word created", word)); + Word word = commandService.createWord(dto.getEnglish(), dto.getKorean(), dto.getExample(), level, category); + return ResponseGenerator.created("Word created", word); + }); } private APIGatewayProxyResponseEvent getWords(APIGatewayProxyRequestEvent request) { @@ -94,79 +90,56 @@ private APIGatewayProxyResponseEvent getWords(APIGatewayProxyRequestEvent reques result.put("nextCursor", wordPage.nextCursor()); result.put("hasMore", wordPage.hasMore()); - return createResponse(200, ApiResponse.ok("Words retrieved", result)); + return ResponseGenerator.ok("Words retrieved", result); } private APIGatewayProxyResponseEvent getWord(APIGatewayProxyRequestEvent request) { - Map pathParams = request.getPathParameters(); - String wordId = pathParams != null ? pathParams.get("wordId") : null; - - if (wordId == null) { - return createResponse(400, ApiResponse.fail("wordId is required")); - } + String wordId = request.getPathParameters().get("wordId"); Optional optWord = queryService.getWord(wordId); if (optWord.isEmpty()) { - return createResponse(404, ApiResponse.fail("Word not found")); + return ResponseGenerator.fail(VocabularyErrorCode.WORD_NOT_FOUND); } - return createResponse(200, ApiResponse.ok("Word retrieved", optWord.get())); + return ResponseGenerator.ok("Word retrieved", optWord.get()); } private APIGatewayProxyResponseEvent updateWord(APIGatewayProxyRequestEvent request) { - Map pathParams = request.getPathParameters(); - String wordId = pathParams != null ? pathParams.get("wordId") : null; - - if (wordId == null) { - return createResponse(400, ApiResponse.fail("wordId is required")); - } - + String wordId = request.getPathParameters().get("wordId"); String body = request.getBody(); - Map requestBody = ResponseUtil.gson().fromJson(body, Map.class); + Map requestBody = ResponseGenerator.gson().fromJson(body, Map.class); Word word = commandService.updateWord(wordId, requestBody); - return createResponse(200, ApiResponse.ok("Word updated", word)); + return ResponseGenerator.ok("Word updated", word); } private APIGatewayProxyResponseEvent deleteWord(APIGatewayProxyRequestEvent request) { - Map pathParams = request.getPathParameters(); - String wordId = pathParams != null ? pathParams.get("wordId") : null; - - if (wordId == null) { - return createResponse(400, ApiResponse.fail("wordId is required")); - } - + String wordId = request.getPathParameters().get("wordId"); commandService.deleteWord(wordId); - return createResponse(200, ApiResponse.ok("Word deleted", null)); + return ResponseGenerator.ok("Word deleted", null); } private APIGatewayProxyResponseEvent createWordsBatch(APIGatewayProxyRequestEvent request) { String body = request.getBody(); - CreateWordsBatchRequest req = ResponseUtil.gson().fromJson(body, CreateWordsBatchRequest.class); - - if (req.getWords() == null || req.getWords().isEmpty()) { - return createResponse(400, ApiResponse.fail("words array is required")); - } + CreateWordsBatchRequest req = ResponseGenerator.gson().fromJson(body, CreateWordsBatchRequest.class); - WordCommandService.BatchResult result = commandService.createWordsBatch(req.getWords()); + return BeanValidator.validateAndExecute(req, dto -> { + WordCommandService.BatchResult result = commandService.createWordsBatch(dto.getWords()); - Map response = new HashMap<>(); - response.put("successCount", result.successCount()); - response.put("failCount", result.failCount()); - response.put("totalRequested", result.totalRequested()); + Map response = new HashMap<>(); + response.put("successCount", result.successCount()); + response.put("failCount", result.failCount()); + response.put("totalRequested", result.totalRequested()); - return createResponse(201, ApiResponse.ok("Batch completed", response)); + return ResponseGenerator.created("Batch completed", response); + }); } private APIGatewayProxyResponseEvent searchWords(APIGatewayProxyRequestEvent request) { Map queryParams = request.getQueryStringParameters(); - String query = queryParams != null ? queryParams.get("q") : null; - String cursor = queryParams != null ? queryParams.get("cursor") : null; - - if (query == null || query.isEmpty()) { - return createResponse(400, ApiResponse.fail("q (query) parameter is required")); - } + String query = queryParams.get("q"); + String cursor = queryParams.get("cursor"); int limit = 20; if (queryParams.get("limit") != null) { @@ -181,28 +154,26 @@ private APIGatewayProxyResponseEvent searchWords(APIGatewayProxyRequestEvent req result.put("nextCursor", wordPage.nextCursor()); result.put("hasMore", wordPage.hasMore()); - return createResponse(200, ApiResponse.ok("Search completed", result)); + return ResponseGenerator.ok("Search completed", result); } private APIGatewayProxyResponseEvent getWordsBatch(APIGatewayProxyRequestEvent request) { String body = request.getBody(); - BatchGetWordsRequest req = ResponseUtil.gson().fromJson(body, BatchGetWordsRequest.class); + BatchGetWordsRequest req = ResponseGenerator.gson().fromJson(body, BatchGetWordsRequest.class); - if (req.getWordIds() == null || req.getWordIds().isEmpty()) { - return createResponse(400, ApiResponse.fail("wordIds array is required")); - } + return BeanValidator.validateAndExecute(req, dto -> { + if (dto.getWordIds().size() > 100) { + return ResponseGenerator.fail(CommonErrorCode.VALUE_OUT_OF_RANGE, "Maximum 100 wordIds allowed per request"); + } - if (req.getWordIds().size() > 100) { - return createResponse(400, ApiResponse.fail("Maximum 100 wordIds allowed per request")); - } + List words = queryService.getWordsByIds(dto.getWordIds()); - List words = queryService.getWordsByIds(req.getWordIds()); - - Map result = new HashMap<>(); - result.put("words", words); - result.put("requestedCount", req.getWordIds().size()); - result.put("retrievedCount", words.size()); + Map result = new HashMap<>(); + result.put("words", words); + result.put("requestedCount", dto.getWordIds().size()); + result.put("retrievedCount", words.size()); - return createResponse(200, ApiResponse.ok("Words retrieved", result)); + return ResponseGenerator.ok("Words retrieved", result); + }); } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java index 47844d20..fbf66257 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java @@ -3,7 +3,7 @@ import com.mzc.secondproject.serverless.common.dto.PaginatedResult; import com.mzc.secondproject.serverless.domain.vocabulary.dto.request.SubmitTestRequest; import com.mzc.secondproject.serverless.common.config.AwsClients; -import com.mzc.secondproject.serverless.common.util.ResponseUtil; +import com.mzc.secondproject.serverless.common.util.ResponseGenerator; import com.mzc.secondproject.serverless.domain.vocabulary.model.DailyStudy; import com.mzc.secondproject.serverless.domain.vocabulary.model.TestResult; import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; @@ -118,13 +118,16 @@ public SubmitTestResult submitTest(String userId, String testId, String testType Word word = wordMap.get(wordId); if (word != null) { - boolean isCorrect = word.getKorean().trim().equalsIgnoreCase(userAnswer.trim()); + // 빈 답변은 오답 처리 + boolean isCorrect = userAnswer != null + && !userAnswer.isBlank() + && word.getKorean().trim().equalsIgnoreCase(userAnswer.trim()); Map resultItem = new HashMap<>(); resultItem.put("wordId", wordId); resultItem.put("english", word.getEnglish()); resultItem.put("correctAnswer", word.getKorean()); - resultItem.put("userAnswer", userAnswer); + resultItem.put("userAnswer", userAnswer != null ? userAnswer : ""); resultItem.put("isCorrect", isCorrect); results.add(resultItem); @@ -219,7 +222,7 @@ private void publishTestResultToSns(String userId, List> res message.put("userId", userId); message.put("results", results); - String messageJson = ResponseUtil.gson().toJson(message); + String messageJson = ResponseGenerator.gson().toJson(message); PublishRequest publishRequest = PublishRequest.builder() .topicArn(TEST_RESULT_TOPIC_ARN) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestService.java index 9b7ccb53..425c3d02 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestService.java @@ -2,7 +2,7 @@ import com.mzc.secondproject.serverless.common.dto.PaginatedResult; import com.mzc.secondproject.serverless.common.config.AwsClients; -import com.mzc.secondproject.serverless.common.util.ResponseUtil; +import com.mzc.secondproject.serverless.common.util.ResponseGenerator; import com.mzc.secondproject.serverless.domain.vocabulary.model.DailyStudy; import com.mzc.secondproject.serverless.domain.vocabulary.model.TestResult; import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; @@ -219,7 +219,7 @@ private void publishTestResultToSns(String userId, List> res message.put("userId", userId); message.put("results", results); - String messageJson = ResponseUtil.gson().toJson(message); + String messageJson = ResponseGenerator.gson().toJson(message); PublishRequest publishRequest = PublishRequest.builder() .topicArn(TEST_RESULT_TOPIC_ARN) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordCommandService.java index 6354ae2f..d9ba6918 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordCommandService.java @@ -1,6 +1,6 @@ package com.mzc.secondproject.serverless.domain.vocabulary.service; -import com.mzc.secondproject.serverless.common.constants.StudyConfig; +import com.mzc.secondproject.serverless.common.config.StudyConfig; import com.mzc.secondproject.serverless.common.enums.Difficulty; import com.mzc.secondproject.serverless.domain.vocabulary.constants.VocabKey; import com.mzc.secondproject.serverless.domain.vocabulary.enums.WordStatus; diff --git a/docs/IMPROVEMENT-GUIDE.md b/docs/IMPROVEMENT-GUIDE.md new file mode 100644 index 00000000..3d6f08be --- /dev/null +++ b/docs/IMPROVEMENT-GUIDE.md @@ -0,0 +1,909 @@ +# Code Improvement Guide + +## Overview + +프로젝트 코드 분석을 통해 도출된 리팩토링, 디자인 패턴, 성능 최적화 방안입니다. + +```mermaid +flowchart TB + subgraph Priority["개선 우선순위"] + HIGH[높음
즉시 적용] + MED[중간
중기 개선] + LOW[낮음
장기 개선] + end + + subgraph High_Items["높음 우선순위"] + H1[Enum 도입] + H2[N+1 쿼리 최적화] + H3[커스텀 예외 클래스] + end + + subgraph Med_Items["중간 우선순위"] + M1[Factory Pattern] + M2[Word 캐싱] + M3[메서드 추출] + end + + subgraph Low_Items["낮음 우선순위"] + L1[State Pattern] + L2[Specification Pattern] + L3[구조화 로깅] + end + + HIGH --> High_Items + MED --> Med_Items + LOW --> Low_Items +``` + +--- + +## 1. 리팩토링 필요 영역 + +### 1.1 중복 코드 패턴 + +#### UserWord 생성 로직 중복 + +**위치**: `UserWordService.java`, `UserWordCommandService.java` + +```java +// 동일한 코드가 두 곳에서 반복 +userWord = UserWord.builder() + .pk("USER#" + userId) + .sk("WORD#" + wordId) + .gsi1pk("USER#" + userId + "#REVIEW") + .gsi2pk("USER#" + userId + "#STATUS") + .userId(userId) + .wordId(wordId) + .status("NEW") + .interval(1) + .easeFactor(2.5) + .repetitions(0) + .correctCount(0) + .incorrectCount(0) + .createdAt(now) + .build(); +``` + +**개선안**: Factory 메서드로 추출 + +```java +public class UserWordFactory { + public static UserWord createNew(String userId, String wordId) { + String now = Instant.now().toString(); + return UserWord.builder() + .pk("USER#" + userId) + .sk("WORD#" + wordId) + .gsi1pk("USER#" + userId + "#REVIEW") + .gsi2pk("USER#" + userId + "#STATUS") + .userId(userId) + .wordId(wordId) + .status(WordStatus.NEW.name()) + .interval(1) + .easeFactor(2.5) + .repetitions(0) + .correctCount(0) + .incorrectCount(0) + .createdAt(now) + .build(); + } +} +``` + +--- + +#### 검증 로직 중복 + +**위치**: `DailyStudyService.java`, `DailyStudyCommandService.java` + +```java +// 하드코딩된 검증 +if (!level.equals("BEGINNER") && !level.equals("INTERMEDIATE") && !level.equals("ADVANCED")) { + throw new IllegalArgumentException("Invalid level"); +} +``` + +**개선안**: Enum 도입 + +```java +public enum StudyLevel { + BEGINNER, INTERMEDIATE, ADVANCED; + + public static boolean isValid(String value) { + return Arrays.stream(values()) + .anyMatch(l -> l.name().equals(value)); + } + + public static StudyLevel fromString(String value) { + return Arrays.stream(values()) + .filter(l -> l.name().equals(value)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Invalid level: " + value)); + } +} + +// 사용 +if (!StudyLevel.isValid(level)) { + throw new ValidationException("Invalid level"); +} +``` + +--- + +### 1.2 하드코딩된 값들 + +| 위치 | 하드코딩 값 | 권장 | +|------|-----------|------| +| `UserWordService.java` | `"USER#"`, `"WORD#"`, `"DATE#"` | DynamoDbKeyPrefix 클래스 | +| `DailyStudyService.java` | `NEW_WORDS_COUNT = 50` | Config 클래스 | +| `ChatRoomHandler.java` | `"beginner"`, `6`, `false` | ChatRoomDefaults 클래스 | +| `UserWordRepository.java` | `limit * 3` | 상수로 추출 | + +**개선안**: 상수 클래스 생성 + +```java +public final class DynamoDbKeyPrefix { + public static final String USER = "USER#"; + public static final String WORD = "WORD#"; + public static final String ROOM = "ROOM#"; + public static final String TOKEN = "TOKEN#"; + public static final String CONN = "CONN#"; + public static final String MSG = "MSG#"; + public static final String DATE = "DATE#"; + public static final String STATUS = "STATUS#"; + + private DynamoDbKeyPrefix() {} +} + +public final class StudyConfig { + public static final int NEW_WORDS_COUNT = 50; + public static final int REVIEW_WORDS_COUNT = 5; + public static final double DEFAULT_EASE_FACTOR = 2.5; + public static final int INITIAL_INTERVAL = 1; + + private StudyConfig() {} +} +``` + +--- + +### 1.3 긴 메서드 분할 + +#### StatsService.getWeaknessAnalysis() - 125줄 + +```mermaid +flowchart TB + A[getWeaknessAnalysis] --> B[calculateCategoryAnalysis] + A --> C[calculateLevelAnalysis] + A --> D[generateSuggestions] + A --> E[buildResponse] + + B --> B1[collectCategoryStats] + B --> B2[calculateAccuracy] + + C --> C1[collectLevelStats] + C --> C2[calculateAccuracy] +``` + +**개선안**: 메서드 추출 + +```java +public Map getWeaknessAnalysis(String userId) { + List allUserWords = fetchAllUserWords(userId); + Map wordMap = fetchWordMap(allUserWords); + + Map> categoryAnalysis = calculateCategoryAnalysis(allUserWords, wordMap); + Map> levelAnalysis = calculateLevelAnalysis(allUserWords, wordMap); + List suggestions = generateSuggestions(categoryAnalysis, levelAnalysis); + + return buildWeaknessResponse(categoryAnalysis, levelAnalysis, suggestions); +} + +private double calculateAccuracy(int correct, int incorrect) { + int total = correct + incorrect; + return total > 0 ? (correct * 100.0 / total) : 0; +} +``` + +--- + +## 2. 디자인 패턴 + +### 2.1 현재 적용된 패턴 + +```mermaid +flowchart LR + subgraph Applied["적용됨"] + CQRS[CQRS Pattern] + BUILDER[Builder Pattern] + ROUTER[Router Pattern] + end + + subgraph Partial["부분 적용"] + FACTORY[Factory Pattern] + VALID[Validation Pattern] + end + + subgraph Needed["추가 필요"] + STATE[State Pattern] + SPEC[Specification Pattern] + STRATEGY[Strategy Pattern] + end +``` + +#### CQRS (Command Query Responsibility Segregation) + +**잘 적용됨**: +- `UserWordCommandService` / `UserWordQueryService` +- `WordCommandService` / `WordQueryService` +- `TestCommandService` / `TestQueryService` + +**문제점**: 중복 로직이 있는 통합 Service 클래스 존재 + +--- + +### 2.2 적용 가능한 패턴 + +#### State Pattern - 학습 상태 관리 + +**현재 문제**: + +```java +// 상태 전환 로직이 서비스에 혼재 +if (userWord.getRepetitions() >= 5) { + userWord.setStatus("MASTERED"); +} else if (userWord.getRepetitions() >= 2) { + userWord.setStatus("REVIEWING"); +} else { + userWord.setStatus("LEARNING"); +} +``` + +**개선안**: + +```mermaid +stateDiagram-v2 + [*] --> NewState: 생성 + NewState --> LearningState: 학습 시작 + LearningState --> LearningState: 오답 + LearningState --> ReviewingState: 2회 정답 + ReviewingState --> LearningState: 오답 + ReviewingState --> MasteredState: 5회 정답 + MasteredState --> LearningState: 오답 +``` + +```java +public interface WordLearningState { + WordLearningState processAnswer(boolean isCorrect, UserWord userWord); + String getStatusName(); +} + +public class LearningState implements WordLearningState { + @Override + public WordLearningState processAnswer(boolean isCorrect, UserWord userWord) { + if (isCorrect) { + userWord.setRepetitions(userWord.getRepetitions() + 1); + if (userWord.getRepetitions() >= 2) { + return new ReviewingState(); + } + } else { + userWord.setRepetitions(0); + userWord.setEaseFactor(Math.max(1.3, userWord.getEaseFactor() - 0.2)); + } + return this; + } + + @Override + public String getStatusName() { + return "LEARNING"; + } +} +``` + +--- + +#### Strategy Pattern - 검증 전략 + +```java +public interface ValidationStrategy { + boolean validate(String value); + String getErrorMessage(); +} + +public class LevelValidationStrategy implements ValidationStrategy { + private static final Set VALID_LEVELS = + Set.of("BEGINNER", "INTERMEDIATE", "ADVANCED"); + + @Override + public boolean validate(String value) { + return VALID_LEVELS.contains(value); + } + + @Override + public String getErrorMessage() { + return "Level must be one of: " + String.join(", ", VALID_LEVELS); + } +} +``` + +--- + +#### Specification Pattern - 복잡한 쿼리 + +```java +public interface UserWordSpecification { + QueryConditional toQueryConditional(String userId); +} + +public class BookmarkedSpecification implements UserWordSpecification { + @Override + public QueryConditional toQueryConditional(String userId) { + return QueryConditional.keyEqualTo( + Key.builder().partitionValue("USER#" + userId + "#BOOKMARK").build()); + } +} + +public class ReviewDueSpecification implements UserWordSpecification { + private final String date; + + @Override + public QueryConditional toQueryConditional(String userId) { + return QueryConditional.sortLessThanOrEqualTo( + Key.builder() + .partitionValue("USER#" + userId + "#REVIEW") + .sortValue("DATE#" + date) + .build()); + } +} + +// 사용 +public PaginatedResult findBySpec(UserWordSpecification spec, String userId, int limit) { + QueryConditional conditional = spec.toQueryConditional(userId); + // ... +} +``` + +--- + +## 3. 성능 최적화 + +### 3.1 DynamoDB 쿼리 최적화 + +#### N+1 쿼리 문제 + +```mermaid +flowchart LR + subgraph Current["현재 (비효율)"] + A1[100개 UserWord 조회] --> B1[Word 1 조회] + A1 --> B2[Word 2 조회] + A1 --> B3[...] + A1 --> B100[Word 100 조회] + end + + subgraph Improved["개선 (효율)"] + A2[100개 UserWord 조회] --> C[BatchGetItem
100개 Word 한번에] + end + + Current -->|100 RCU| DB1[(DynamoDB)] + Improved -->|5-10 RCU| DB2[(DynamoDB)] +``` + +**문제 코드** (`StatsService.java`): + +```java +// N+1 문제: 각 UserWord마다 Word 개별 조회 +allUserWords.stream().map(uw -> { + wordRepository.findById(uw.getWordId()).ifPresent(word -> { + // ... + }); +}) +``` + +**개선안**: + +```java +// BatchGetItem 사용 +List wordIds = allUserWords.stream() + .map(UserWord::getWordId) + .collect(Collectors.toList()); + +Map wordMap = wordRepository.findByIds(wordIds).stream() + .collect(Collectors.toMap(Word::getWordId, w -> w)); + +// O(1) 조회 +allUserWords.stream().forEach(uw -> { + Word word = wordMap.get(uw.getWordId()); + if (word != null) { + // ... + } +}); +``` + +**Repository 메서드 추가**: + +```java +public List findByIds(List wordIds) { + if (wordIds == null || wordIds.isEmpty()) { + return Collections.emptyList(); + } + + List keys = wordIds.stream() + .map(id -> Key.builder() + .partitionValue("WORD#" + id) + .sortValue("METADATA") + .build()) + .collect(Collectors.toList()); + + ReadBatch readBatch = ReadBatch.builder(Word.class) + .mappedTableResource(table) + .addGetItem(keys.toArray(new Key[0])) + .build(); + + BatchGetResultPageIterable result = enhancedClient.batchGetItem(r -> r.readBatches(readBatch)); + + return result.resultsForTable(table).stream().collect(Collectors.toList()); +} +``` + +--- + +#### HashSet 활용 + +**문제 코드** (`DailyStudyService.java`): + +```java +// O(n) 검색 +if (!learnedWordIds.contains(word.getWordId())) { + newWordIds.add(word.getWordId()); +} +``` + +**개선안**: + +```java +// O(1) 검색 +Set learnedWordIdSet = new HashSet<>(learnedWordIds); +Set newWordIdSet = new HashSet<>(); + +for (Word word : wordPage.getItems()) { + if (!learnedWordIdSet.contains(word.getWordId()) + && !newWordIdSet.contains(word.getWordId())) { + newWordIdSet.add(word.getWordId()); + } +} +``` + +--- + +### 3.2 캐싱 전략 + +```mermaid +flowchart TB + subgraph Cache_Layer["캐시 레이어"] + WORD_CACHE[Word Cache
TTL: 1시간] + STATS_CACHE[Stats Cache
TTL: 5분] + ROOM_CACHE[Room Cache
TTL: 2분] + end + + subgraph Lambda["Lambda"] + SVC[Services] + end + + subgraph DynamoDB + DB[(DynamoDB)] + end + + SVC -->|Cache Miss| DB + SVC -->|Cache Hit| WORD_CACHE + SVC -->|Cache Hit| STATS_CACHE + SVC -->|Cache Hit| ROOM_CACHE + DB -->|Store| Cache_Layer +``` + +#### Word 데이터 캐싱 + +```java +public class WordCache { + private static final LoadingCache> CACHE = + CacheBuilder.newBuilder() + .expireAfterWrite(1, TimeUnit.HOURS) + .maximumSize(10000) + .build(new CacheLoader>() { + @Override + public Optional load(String wordId) { + return wordRepository.findById(wordId); + } + }); + + private final WordRepository wordRepository; + + public Optional get(String wordId) { + try { + return CACHE.get(wordId); + } catch (ExecutionException e) { + return wordRepository.findById(wordId); + } + } + + public void invalidate(String wordId) { + CACHE.invalidate(wordId); + } +} +``` + +**예상 효과**: +- DynamoDB RCU 30-40% 감소 +- 응답 시간 50-70% 단축 + +--- + +#### 통계 캐싱 + +```java +public class StatsCache { + private static final Map CACHE = new ConcurrentHashMap<>(); + private static final long TTL_MS = 5 * 60 * 1000; // 5분 + + public Map getOrCompute(String userId, Supplier> compute) { + CachedStats cached = CACHE.get(userId); + + if (cached != null && !cached.isExpired()) { + return cached.getStats(); + } + + Map stats = compute.get(); + CACHE.put(userId, new CachedStats(stats)); + return stats; + } + + private static class CachedStats { + private final Map stats; + private final long timestamp; + + boolean isExpired() { + return System.currentTimeMillis() - timestamp > TTL_MS; + } + } +} +``` + +--- + +### 3.3 콜드 스타트 최적화 + +```mermaid +flowchart TB + subgraph Current["현재"] + H1[Handler 생성] --> S1[Service 생성] + S1 --> R1[Repository 생성] + R1 --> C1[DynamoDB Client 생성] + end + + subgraph Improved["개선"] + CONTAINER[ServiceContainer
Singleton] + H2[Handler] --> CONTAINER + CONTAINER --> S2[Services] + S2 --> R2[Repositories] + R2 --> C2[Shared Client] + end +``` + +**개선안**: ServiceContainer Singleton + +```java +public class ServiceContainer { + private static final ServiceContainer INSTANCE = new ServiceContainer(); + + private final UserWordCommandService userWordCommandService; + private final UserWordQueryService userWordQueryService; + private final WordQueryService wordQueryService; + private final TestCommandService testCommandService; + // ... + + private ServiceContainer() { + this.userWordCommandService = new UserWordCommandService(); + this.userWordQueryService = new UserWordQueryService(); + this.wordQueryService = new WordQueryService(); + this.testCommandService = new TestCommandService(); + } + + public static ServiceContainer getInstance() { + return INSTANCE; + } + + public UserWordCommandService getUserWordCommandService() { + return userWordCommandService; + } + // ... getters +} + +// Handler에서 사용 +public class UserWordHandler { + private final UserWordCommandService commandService; + private final UserWordQueryService queryService; + + public UserWordHandler() { + ServiceContainer container = ServiceContainer.getInstance(); + this.commandService = container.getUserWordCommandService(); + this.queryService = container.getUserWordQueryService(); + } +} +``` + +--- + +### 3.4 배치 처리 개선 + +#### Word 배치 저장 + +```java +public void saveBatch(List words) { + if (words == null || words.isEmpty()) { + return; + } + + // DynamoDB BatchWriteItem 제한: 25개 + List> batches = Lists.partition(words, 25); + + for (List batch : batches) { + WriteBatch.Builder writeBatchBuilder = WriteBatch.builder(Word.class) + .mappedTableResource(table); + + for (Word word : batch) { + writeBatchBuilder.addPutItem(word); + } + + enhancedClient.batchWriteItem(r -> r.writeBatches(writeBatchBuilder.build())); + } +} +``` + +--- + +## 4. 코드 품질 + +### 4.1 예외 처리 표준화 + +```mermaid +flowchart TB + subgraph Exceptions["예외 계층"] + BASE[BaseException] + BASE --> ENTITY[EntityNotFoundException] + BASE --> VALID[ValidationException] + BASE --> AUTH[AuthorizationException] + BASE --> DATA[DataAccessException] + end + + subgraph Handler["HandlerRouter"] + CATCH[예외 처리] + CATCH -->|EntityNotFoundException| R404[404 Not Found] + CATCH -->|ValidationException| R400[400 Bad Request] + CATCH -->|AuthorizationException| R403[403 Forbidden] + CATCH -->|DataAccessException| R500[500 Internal Error] + end +``` + +**예외 클래스 정의**: + +```java +public abstract class BaseException extends RuntimeException { + private final String errorCode; + + protected BaseException(String errorCode, String message) { + super(message); + this.errorCode = errorCode; + } + + public String getErrorCode() { + return errorCode; + } +} + +public class EntityNotFoundException extends BaseException { + public EntityNotFoundException(String entity, String id) { + super("NOT_FOUND", String.format("%s not found: %s", entity, id)); + } +} + +public class ValidationException extends BaseException { + public ValidationException(String message) { + super("VALIDATION_ERROR", message); + } +} +``` + +**HandlerRouter에서 처리**: + +```java +private APIGatewayProxyResponseEvent handleException(Exception e) { + if (e instanceof EntityNotFoundException) { + return createResponse(404, ApiResponse.error(e.getMessage())); + } + if (e instanceof ValidationException) { + return createResponse(400, ApiResponse.error(e.getMessage())); + } + if (e instanceof AuthorizationException) { + return createResponse(403, ApiResponse.error(e.getMessage())); + } + + logger.error("Unexpected error", e); + return createResponse(500, ApiResponse.error("Internal server error")); +} +``` + +--- + +### 4.2 RequestValidator 활용 확대 + +```java +public class RequestValidator { + private final List errors = new ArrayList<>(); + + public static RequestValidator create() { + return new RequestValidator(); + } + + public RequestValidator requireNotEmpty(String value, String fieldName) { + if (value == null || value.trim().isEmpty()) { + errors.add(fieldName + " is required"); + } + return this; + } + + public RequestValidator requireInRange(Integer value, int min, int max, String fieldName) { + if (value != null && (value < min || value > max)) { + errors.add(fieldName + " must be between " + min + " and " + max); + } + return this; + } + + public RequestValidator requireValidEnum(String value, Class> enumClass, String fieldName) { + if (value != null) { + boolean valid = Arrays.stream(enumClass.getEnumConstants()) + .anyMatch(e -> e.name().equals(value)); + if (!valid) { + errors.add(fieldName + " must be one of: " + + Arrays.toString(enumClass.getEnumConstants())); + } + } + return this; + } + + public ValidationResult build() { + return new ValidationResult(errors); + } +} + +// 사용 예시 +private APIGatewayProxyResponseEvent updateUserWord(APIGatewayProxyRequestEvent request) { + String userId = getQueryParam(request, "userId"); + String wordId = getPathParam(request, "wordId"); + String difficulty = getQueryParam(request, "difficulty"); + + ValidationResult validation = RequestValidator.create() + .requireNotEmpty(userId, "userId") + .requireNotEmpty(wordId, "wordId") + .requireValidEnum(difficulty, Difficulty.class, "difficulty") + .build(); + + if (validation.isInvalid()) { + return createResponse(400, ApiResponse.error(validation.getFirstError())); + } + + // 비즈니스 로직 +} +``` + +--- + +### 4.3 로깅 개선 + +#### 구조화된 로깅 + +```java +public class StructuredLogger { + private static final Gson GSON = new Gson(); + private final Logger logger; + + public StructuredLogger(Class clazz) { + this.logger = LoggerFactory.getLogger(clazz); + } + + public void info(String event, Map data) { + if (logger.isInfoEnabled()) { + Map log = new HashMap<>(data); + log.put("event", event); + log.put("timestamp", Instant.now().toString()); + logger.info("{}", GSON.toJson(log)); + } + } + + public void error(String event, Map data, Throwable t) { + Map log = new HashMap<>(data); + log.put("event", event); + log.put("timestamp", Instant.now().toString()); + log.put("errorType", t.getClass().getSimpleName()); + log.put("errorMessage", t.getMessage()); + logger.error("{}", GSON.toJson(log), t); + } +} + +// 사용 +private static final StructuredLogger slog = new StructuredLogger(UserWordService.class); + +public UserWord updateUserWord(String userId, String wordId, boolean isCorrect) { + // ... + slog.info("user_word_updated", Map.of( + "userId", userId, + "wordId", wordId, + "isCorrect", isCorrect, + "newStatus", userWord.getStatus() + )); + return userWord; +} +``` + +--- + +## 5. 개선 우선순위 및 일정 + +### 5.1 높음 우선순위 (즉시 적용) + +| 항목 | 영향도 | 예상 소요 | +|------|--------|----------| +| Enum 도입 (StudyLevel, Difficulty, WordStatus) | 높음 | 4시간 | +| N+1 쿼리 최적화 (BatchGetItem, HashSet) | 높음 | 2시간 | +| 커스텀 예외 클래스 | 중간 | 1시간 | + +### 5.2 중간 우선순위 (중기 개선) + +| 항목 | 영향도 | 예상 소요 | +|------|--------|----------| +| Factory Pattern (UserWord, Word) | 중간 | 2시간 | +| Word 캐싱 (Guava LoadingCache) | 높음 | 3시간 | +| 메서드 추출 (StatsService, TestService) | 중간 | 3시간 | +| ServiceContainer Singleton | 중간 | 2시간 | + +### 5.3 낮음 우선순위 (장기 개선) + +| 항목 | 영향도 | 예상 소요 | +|------|--------|----------| +| State Pattern (WordLearningState) | 낮음 | 5시간 | +| Specification Pattern (쿼리 추상화) | 낮음 | 4시간 | +| 구조화 로깅 | 낮음 | 2시간 | +| RequestValidator 확대 적용 | 낮음 | 2시간 | + +--- + +## 6. 예상 효과 + +```mermaid +flowchart LR + subgraph Before["개선 전"] + B1[DynamoDB RCU: 100%] + B2[응답 시간: 100%] + B3[콜드 스타트: 100%] + B4[코드 중복: 높음] + end + + subgraph After["개선 후"] + A1[DynamoDB RCU: 40-50%] + A2[응답 시간: 30-50%] + A3[콜드 스타트: 70-80%] + A4[코드 중복: 낮음] + end + + Before --> After +``` + +| 지표 | 현재 | 개선 후 | 감소율 | +|------|------|--------|--------| +| DynamoDB RCU | 100 | 40-50 | 50-60% | +| 평균 응답 시간 | 200ms | 80-100ms | 50-60% | +| 콜드 스타트 시간 | 3s | 2-2.5s | 20-30% | +| 코드 라인 수 (중복) | 500+ | 200- | 60%+ | + +--- + +**버전**: 1.0.0 +**최종 업데이트**: 2026-01-09 +**팀**: MZC 2nd Project Team From 072d74166b5086c0042263b3c59753fd6a501e12 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 9 Jan 2026 16:46:50 +0900 Subject: [PATCH 114/528] `docs: replace VOCAB_API_SPEC.md with CHATTING-GUIDE.md` --- .../domain/chatting/dto/request/CreateRoomRequest.java | 1 + 1 file changed, 1 insertion(+) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/CreateRoomRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/CreateRoomRequest.java index b7d65e74..fc28faf7 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/CreateRoomRequest.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/CreateRoomRequest.java @@ -35,5 +35,6 @@ public class CreateRoomRequest { private String password; + @NotBlank(message = "is required") private String createdBy; } From 1639caefde94cb05f2e5d11a9348238752fd6528 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Sun, 11 Jan 2026 16:58:07 +0900 Subject: [PATCH 115/528] =?UTF-8?q?[FEAT]=20WordState=20State=20=ED=8C=A8?= =?UTF-8?q?=ED=84=B4=20=EA=B5=AC=ED=98=84=20(#178)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WordState 인터페이스 및 4개 구체 상태 클래스 생성 (NewState, LearningState, ReviewingState, MasteredState) - SpacedRepetitionContext로 상태 전이 컨텍스트 캡슐화 - WordStateFactory로 상태 복원 지원 - UserWordCommandService에 State 패턴 적용 - Spock 기반 BDD 테스트 15개 작성 --- .../service/UserWordCommandService.java | 55 ++--- .../vocabulary/state/LearningState.java | 60 +++++ .../vocabulary/state/MasteredState.java | 46 ++++ .../domain/vocabulary/state/NewState.java | 46 ++++ .../vocabulary/state/ReviewingState.java | 51 +++++ .../state/SpacedRepetitionContext.java | 87 ++++++++ .../domain/vocabulary/state/WordState.java | 35 +++ .../vocabulary/state/WordStateFactory.java | 48 ++++ .../vocabulary/state/WordStateSpec.groovy | 209 ++++++++++++++++++ 9 files changed, 606 insertions(+), 31 deletions(-) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/state/LearningState.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/state/MasteredState.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/state/NewState.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/state/ReviewingState.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/state/SpacedRepetitionContext.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/state/WordState.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/state/WordStateFactory.java create mode 100644 ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/vocabulary/state/WordStateSpec.groovy diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordCommandService.java index d9ba6918..e91df6fe 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordCommandService.java @@ -6,6 +6,9 @@ import com.mzc.secondproject.serverless.domain.vocabulary.enums.WordStatus; import com.mzc.secondproject.serverless.domain.vocabulary.model.UserWord; import com.mzc.secondproject.serverless.domain.vocabulary.repository.UserWordRepository; +import com.mzc.secondproject.serverless.domain.vocabulary.state.SpacedRepetitionContext; +import com.mzc.secondproject.serverless.domain.vocabulary.state.WordState; +import com.mzc.secondproject.serverless.domain.vocabulary.state.WordStateFactory; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -114,37 +117,27 @@ public UserWord updateUserWordTag(String userId, String wordId, Boolean bookmark } private void applySpacedRepetition(UserWord userWord, boolean isCorrect) { - if (isCorrect) { - userWord.setCorrectCount(userWord.getCorrectCount() + 1); - userWord.setRepetitions(userWord.getRepetitions() + 1); - - if (userWord.getRepetitions() == 1) { - userWord.setInterval(StudyConfig.INITIAL_INTERVAL_DAYS); - } else if (userWord.getRepetitions() == 2) { - userWord.setInterval(6); - } else { - int newInterval = (int) Math.round(userWord.getInterval() * userWord.getEaseFactor()); - userWord.setInterval(newInterval); - } - - if (userWord.getRepetitions() >= 5) { - userWord.setStatus(WordStatus.MASTERED.name()); - } else if (userWord.getRepetitions() >= 2) { - userWord.setStatus(WordStatus.REVIEWING.name()); - } else { - userWord.setStatus(WordStatus.LEARNING.name()); - } - } else { - userWord.setIncorrectCount(userWord.getIncorrectCount() + 1); - userWord.setRepetitions(StudyConfig.INITIAL_REPETITIONS); - userWord.setInterval(StudyConfig.INITIAL_INTERVAL_DAYS); - userWord.setStatus(WordStatus.LEARNING.name()); - - double newEaseFactor = userWord.getEaseFactor() - 0.2; - userWord.setEaseFactor(Math.max(StudyConfig.MIN_EASE_FACTOR, newEaseFactor)); - } - - LocalDate nextReview = LocalDate.now().plusDays(userWord.getInterval()); + SpacedRepetitionContext context = new SpacedRepetitionContext( + userWord.getRepetitions(), + userWord.getInterval(), + userWord.getEaseFactor(), + userWord.getCorrectCount(), + userWord.getIncorrectCount() + ); + + WordState currentState = WordStateFactory.fromString(userWord.getStatus()); + WordState nextState = isCorrect + ? currentState.onCorrectAnswer(context) + : currentState.onWrongAnswer(context); + + userWord.setRepetitions(context.getRepetitions()); + userWord.setInterval(context.getInterval()); + userWord.setEaseFactor(context.getEaseFactor()); + userWord.setCorrectCount(context.getCorrectCount()); + userWord.setIncorrectCount(context.getIncorrectCount()); + userWord.setStatus(nextState.getStateName()); + + LocalDate nextReview = LocalDate.now().plusDays(context.getInterval()); userWord.setNextReviewAt(nextReview.toString()); } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/state/LearningState.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/state/LearningState.java new file mode 100644 index 00000000..23568c56 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/state/LearningState.java @@ -0,0 +1,60 @@ +package com.mzc.secondproject.serverless.domain.vocabulary.state; + +import com.mzc.secondproject.serverless.common.config.StudyConfig; +import com.mzc.secondproject.serverless.domain.vocabulary.enums.WordStatus; + +/** + * 학습 중 상태. + * repetitions >= 2 시 REVIEWING으로 전이. + */ +public class LearningState implements WordState { + + private static final LearningState INSTANCE = new LearningState(); + private static final int TRANSITION_TO_REVIEWING_THRESHOLD = 2; + private static final int SECOND_INTERVAL_DAYS = 6; + + private LearningState() {} + + public static LearningState getInstance() { + return INSTANCE; + } + + @Override + public WordState onCorrectAnswer(SpacedRepetitionContext context) { + context.incrementCorrectCount(); + context.incrementRepetitions(); + + int repetitions = context.getRepetitions(); + if (repetitions == 1) { + context.updateInterval(StudyConfig.INITIAL_INTERVAL_DAYS); + } else if (repetitions == 2) { + context.updateInterval(SECOND_INTERVAL_DAYS); + } else { + context.updateInterval(context.calculateNextInterval()); + } + + if (repetitions >= TRANSITION_TO_REVIEWING_THRESHOLD) { + return ReviewingState.getInstance(); + } + return this; + } + + @Override + public WordState onWrongAnswer(SpacedRepetitionContext context) { + context.incrementIncorrectCount(); + context.resetRepetitions(); + context.resetInterval(); + context.decreaseEaseFactor(); + return this; + } + + @Override + public int getIntervalDays(SpacedRepetitionContext context) { + return context.getInterval(); + } + + @Override + public String getStateName() { + return WordStatus.LEARNING.name(); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/state/MasteredState.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/state/MasteredState.java new file mode 100644 index 00000000..080f8cb2 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/state/MasteredState.java @@ -0,0 +1,46 @@ +package com.mzc.secondproject.serverless.domain.vocabulary.state; + +import com.mzc.secondproject.serverless.common.config.StudyConfig; +import com.mzc.secondproject.serverless.domain.vocabulary.enums.WordStatus; + +/** + * 마스터 상태. + * 정답 시 상태 유지, 오답 시 REVIEWING으로 강등. + */ +public class MasteredState implements WordState { + + private static final MasteredState INSTANCE = new MasteredState(); + + private MasteredState() {} + + public static MasteredState getInstance() { + return INSTANCE; + } + + @Override + public WordState onCorrectAnswer(SpacedRepetitionContext context) { + context.incrementCorrectCount(); + context.incrementRepetitions(); + context.updateInterval(context.calculateNextInterval()); + return this; + } + + @Override + public WordState onWrongAnswer(SpacedRepetitionContext context) { + context.incrementIncorrectCount(); + context.resetRepetitions(); + context.resetInterval(); + context.decreaseEaseFactor(); + return ReviewingState.getInstance(); + } + + @Override + public int getIntervalDays(SpacedRepetitionContext context) { + return context.getInterval(); + } + + @Override + public String getStateName() { + return WordStatus.MASTERED.name(); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/state/NewState.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/state/NewState.java new file mode 100644 index 00000000..ea6df113 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/state/NewState.java @@ -0,0 +1,46 @@ +package com.mzc.secondproject.serverless.domain.vocabulary.state; + +import com.mzc.secondproject.serverless.common.config.StudyConfig; +import com.mzc.secondproject.serverless.domain.vocabulary.enums.WordStatus; + +/** + * 새 단어 상태. + * 첫 정답 시 LEARNING으로 전이. + */ +public class NewState implements WordState { + + private static final NewState INSTANCE = new NewState(); + + private NewState() {} + + public static NewState getInstance() { + return INSTANCE; + } + + @Override + public WordState onCorrectAnswer(SpacedRepetitionContext context) { + context.incrementCorrectCount(); + context.incrementRepetitions(); + context.updateInterval(StudyConfig.INITIAL_INTERVAL_DAYS); + return LearningState.getInstance(); + } + + @Override + public WordState onWrongAnswer(SpacedRepetitionContext context) { + context.incrementIncorrectCount(); + context.resetRepetitions(); + context.resetInterval(); + context.decreaseEaseFactor(); + return LearningState.getInstance(); + } + + @Override + public int getIntervalDays(SpacedRepetitionContext context) { + return StudyConfig.INITIAL_INTERVAL_DAYS; + } + + @Override + public String getStateName() { + return WordStatus.NEW.name(); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/state/ReviewingState.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/state/ReviewingState.java new file mode 100644 index 00000000..e87a8c56 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/state/ReviewingState.java @@ -0,0 +1,51 @@ +package com.mzc.secondproject.serverless.domain.vocabulary.state; + +import com.mzc.secondproject.serverless.common.config.StudyConfig; +import com.mzc.secondproject.serverless.domain.vocabulary.enums.WordStatus; + +/** + * 복습 중 상태. + * repetitions >= 5 시 MASTERED로 전이. + */ +public class ReviewingState implements WordState { + + private static final ReviewingState INSTANCE = new ReviewingState(); + private static final int TRANSITION_TO_MASTERED_THRESHOLD = 5; + + private ReviewingState() {} + + public static ReviewingState getInstance() { + return INSTANCE; + } + + @Override + public WordState onCorrectAnswer(SpacedRepetitionContext context) { + context.incrementCorrectCount(); + context.incrementRepetitions(); + context.updateInterval(context.calculateNextInterval()); + + if (context.getRepetitions() >= TRANSITION_TO_MASTERED_THRESHOLD) { + return MasteredState.getInstance(); + } + return this; + } + + @Override + public WordState onWrongAnswer(SpacedRepetitionContext context) { + context.incrementIncorrectCount(); + context.resetRepetitions(); + context.resetInterval(); + context.decreaseEaseFactor(); + return LearningState.getInstance(); + } + + @Override + public int getIntervalDays(SpacedRepetitionContext context) { + return context.getInterval(); + } + + @Override + public String getStateName() { + return WordStatus.REVIEWING.name(); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/state/SpacedRepetitionContext.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/state/SpacedRepetitionContext.java new file mode 100644 index 00000000..5565f19a --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/state/SpacedRepetitionContext.java @@ -0,0 +1,87 @@ +package com.mzc.secondproject.serverless.domain.vocabulary.state; + +import com.mzc.secondproject.serverless.common.config.StudyConfig; + +/** + * Spaced Repetition 알고리즘에 필요한 컨텍스트. + * State 객체가 상태 전이 및 간격 계산 시 참조한다. + */ +public class SpacedRepetitionContext { + + private int repetitions; + private int interval; + private double easeFactor; + private int correctCount; + private int incorrectCount; + + public SpacedRepetitionContext() { + this.repetitions = StudyConfig.INITIAL_REPETITIONS; + this.interval = StudyConfig.INITIAL_INTERVAL_DAYS; + this.easeFactor = StudyConfig.DEFAULT_EASE_FACTOR; + this.correctCount = StudyConfig.INITIAL_CORRECT_COUNT; + this.incorrectCount = StudyConfig.INITIAL_INCORRECT_COUNT; + } + + public SpacedRepetitionContext(int repetitions, int interval, double easeFactor, + int correctCount, int incorrectCount) { + this.repetitions = repetitions; + this.interval = interval; + this.easeFactor = easeFactor; + this.correctCount = correctCount; + this.incorrectCount = incorrectCount; + } + + public void incrementRepetitions() { + this.repetitions++; + } + + public void resetRepetitions() { + this.repetitions = StudyConfig.INITIAL_REPETITIONS; + } + + public void incrementCorrectCount() { + this.correctCount++; + } + + public void incrementIncorrectCount() { + this.incorrectCount++; + } + + public void updateInterval(int newInterval) { + this.interval = newInterval; + } + + public void resetInterval() { + this.interval = StudyConfig.INITIAL_INTERVAL_DAYS; + } + + public void decreaseEaseFactor() { + double newEaseFactor = this.easeFactor - 0.2; + this.easeFactor = Math.max(StudyConfig.MIN_EASE_FACTOR, newEaseFactor); + } + + public int calculateNextInterval() { + return (int) Math.round(this.interval * this.easeFactor); + } + + // Getters + public int getRepetitions() { + return repetitions; + } + + public int getInterval() { + return interval; + } + + public double getEaseFactor() { + return easeFactor; + } + + public int getCorrectCount() { + return correctCount; + } + + public int getIncorrectCount() { + return incorrectCount; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/state/WordState.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/state/WordState.java new file mode 100644 index 00000000..7ddbc4ae --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/state/WordState.java @@ -0,0 +1,35 @@ +package com.mzc.secondproject.serverless.domain.vocabulary.state; + +/** + * 단어 학습 상태를 나타내는 State 패턴 인터페이스. + * 각 상태는 정답/오답에 따른 상태 전이와 복습 간격을 결정한다. + */ +public interface WordState { + + /** + * 정답 시 다음 상태로 전이 + * @param context Spaced Repetition 컨텍스트 + * @return 전이된 상태 + */ + WordState onCorrectAnswer(SpacedRepetitionContext context); + + /** + * 오답 시 다음 상태로 전이 + * @param context Spaced Repetition 컨텍스트 + * @return 전이된 상태 + */ + WordState onWrongAnswer(SpacedRepetitionContext context); + + /** + * 현재 상태의 복습 간격(일) 반환 + * @param context Spaced Repetition 컨텍스트 + * @return 복습 간격 (일 단위) + */ + int getIntervalDays(SpacedRepetitionContext context); + + /** + * 상태 이름 반환 (DynamoDB 저장용) + * @return 상태 이름 (NEW, LEARNING, REVIEWING, MASTERED) + */ + String getStateName(); +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/state/WordStateFactory.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/state/WordStateFactory.java new file mode 100644 index 00000000..8fb93c43 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/state/WordStateFactory.java @@ -0,0 +1,48 @@ +package com.mzc.secondproject.serverless.domain.vocabulary.state; + +import com.mzc.secondproject.serverless.domain.vocabulary.enums.WordStatus; + +/** + * WordState 인스턴스 생성 팩토리. + * 상태 이름(String)으로부터 적절한 State 객체를 반환한다. + */ +public final class WordStateFactory { + + private WordStateFactory() {} + + /** + * 상태 이름으로부터 WordState 인스턴스 반환 + * @param stateName 상태 이름 (NEW, LEARNING, REVIEWING, MASTERED) + * @return 해당 상태의 WordState 인스턴스 + */ + public static WordState fromString(String stateName) { + if (stateName == null) { + return NewState.getInstance(); + } + + WordStatus status = WordStatus.fromStringOrDefault(stateName, WordStatus.NEW); + return fromStatus(status); + } + + /** + * WordStatus enum으로부터 WordState 인스턴스 반환 + * @param status WordStatus enum + * @return 해당 상태의 WordState 인스턴스 + */ + public static WordState fromStatus(WordStatus status) { + return switch (status) { + case NEW -> NewState.getInstance(); + case LEARNING -> LearningState.getInstance(); + case REVIEWING -> ReviewingState.getInstance(); + case MASTERED -> MasteredState.getInstance(); + }; + } + + /** + * 기본 상태(NEW) 반환 + * @return NewState 인스턴스 + */ + public static WordState getInitialState() { + return NewState.getInstance(); + } +} diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/vocabulary/state/WordStateSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/vocabulary/state/WordStateSpec.groovy new file mode 100644 index 00000000..20dff2a4 --- /dev/null +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/vocabulary/state/WordStateSpec.groovy @@ -0,0 +1,209 @@ +package com.mzc.secondproject.serverless.domain.vocabulary.state + +import com.mzc.secondproject.serverless.domain.vocabulary.enums.WordStatus +import spock.lang.Specification +import spock.lang.Subject +import spock.lang.Unroll + +class WordStateSpec extends Specification { + + @Subject + SpacedRepetitionContext context + + def setup() { + context = new SpacedRepetitionContext() + } + + // ==================== NewState Tests ==================== + + def "NewState: 정답 시 LEARNING으로 전이"() { + given: "새 단어 상태" + def state = NewState.getInstance() + + when: "정답 처리" + def nextState = state.onCorrectAnswer(context) + + then: "LEARNING 상태로 전이" + nextState instanceof LearningState + nextState.getStateName() == WordStatus.LEARNING.name() + + and: "정답 카운트 증가" + context.getCorrectCount() == 1 + context.getRepetitions() == 1 + } + + def "NewState: 오답 시 LEARNING으로 전이하고 easeFactor 감소"() { + given: "새 단어 상태" + def state = NewState.getInstance() + def initialEaseFactor = context.getEaseFactor() + + when: "오답 처리" + def nextState = state.onWrongAnswer(context) + + then: "LEARNING 상태로 전이" + nextState instanceof LearningState + + and: "오답 카운트 증가, easeFactor 감소" + context.getIncorrectCount() == 1 + context.getEaseFactor() < initialEaseFactor + } + + // ==================== LearningState Tests ==================== + + def "LearningState: 첫 정답 시 상태 유지"() { + given: "학습 중 상태 (repetitions=0)" + def state = LearningState.getInstance() + + when: "정답 처리" + def nextState = state.onCorrectAnswer(context) + + then: "LEARNING 상태 유지" + nextState instanceof LearningState + context.getRepetitions() == 1 + } + + def "LearningState: 2회 연속 정답 시 REVIEWING으로 전이"() { + given: "학습 중 상태 (repetitions=1)" + context = new SpacedRepetitionContext(1, 1, 2.5, 1, 0) + def state = LearningState.getInstance() + + when: "정답 처리" + def nextState = state.onCorrectAnswer(context) + + then: "REVIEWING 상태로 전이" + nextState instanceof ReviewingState + context.getRepetitions() == 2 + context.getInterval() == 6 + } + + def "LearningState: 오답 시 repetitions 리셋"() { + given: "학습 중 상태 (repetitions=1)" + context = new SpacedRepetitionContext(1, 6, 2.5, 1, 0) + def state = LearningState.getInstance() + + when: "오답 처리" + def nextState = state.onWrongAnswer(context) + + then: "LEARNING 상태 유지, repetitions 리셋" + nextState instanceof LearningState + context.getRepetitions() == 0 + context.getInterval() == 1 + } + + // ==================== ReviewingState Tests ==================== + + def "ReviewingState: 5회 연속 정답 시 MASTERED로 전이"() { + given: "복습 중 상태 (repetitions=4)" + context = new SpacedRepetitionContext(4, 14, 2.5, 4, 0) + def state = ReviewingState.getInstance() + + when: "정답 처리" + def nextState = state.onCorrectAnswer(context) + + then: "MASTERED 상태로 전이" + nextState instanceof MasteredState + context.getRepetitions() == 5 + } + + def "ReviewingState: 4회 정답 시 상태 유지"() { + given: "복습 중 상태 (repetitions=3)" + context = new SpacedRepetitionContext(3, 7, 2.5, 3, 0) + def state = ReviewingState.getInstance() + + when: "정답 처리" + def nextState = state.onCorrectAnswer(context) + + then: "REVIEWING 상태 유지" + nextState instanceof ReviewingState + context.getRepetitions() == 4 + } + + def "ReviewingState: 오답 시 LEARNING으로 강등"() { + given: "복습 중 상태" + context = new SpacedRepetitionContext(3, 7, 2.5, 3, 0) + def state = ReviewingState.getInstance() + + when: "오답 처리" + def nextState = state.onWrongAnswer(context) + + then: "LEARNING 상태로 강등" + nextState instanceof LearningState + context.getRepetitions() == 0 + } + + // ==================== MasteredState Tests ==================== + + def "MasteredState: 정답 시 상태 유지"() { + given: "마스터 상태" + context = new SpacedRepetitionContext(5, 30, 2.5, 5, 0) + def state = MasteredState.getInstance() + + when: "정답 처리" + def nextState = state.onCorrectAnswer(context) + + then: "MASTERED 상태 유지" + nextState instanceof MasteredState + context.getRepetitions() == 6 + } + + def "MasteredState: 오답 시 REVIEWING으로 강등"() { + given: "마스터 상태" + context = new SpacedRepetitionContext(5, 30, 2.5, 5, 0) + def state = MasteredState.getInstance() + + when: "오답 처리" + def nextState = state.onWrongAnswer(context) + + then: "REVIEWING 상태로 강등" + nextState instanceof ReviewingState + context.getRepetitions() == 0 + } + + // ==================== WordStateFactory Tests ==================== + + @Unroll + def "WordStateFactory: '#stateName' -> #expectedType"() { + when: "상태 이름으로 State 객체 생성" + def state = WordStateFactory.fromString(stateName) + + then: "올바른 타입 반환" + state.class == expectedType + + where: + stateName | expectedType + "NEW" | NewState + "LEARNING" | LearningState + "REVIEWING" | ReviewingState + "MASTERED" | MasteredState + "new" | NewState + "learning" | LearningState + null | NewState + "INVALID" | NewState + } + + // ==================== Interval Calculation Tests ==================== + + def "interval 계산: easeFactor 적용"() { + given: "특정 interval과 easeFactor" + context = new SpacedRepetitionContext(2, 6, 2.5, 2, 0) + def state = ReviewingState.getInstance() + + when: "정답 처리 (3번째)" + state.onCorrectAnswer(context) + + then: "interval = 6 * 2.5 = 15" + context.getInterval() == 15 + } + + def "easeFactor 최소값 보장"() { + given: "낮은 easeFactor (연속 오답 시뮬레이션)" + context = new SpacedRepetitionContext(0, 1, 1.4, 0, 5) + def state = LearningState.getInstance() + + when: "오답 처리" + state.onWrongAnswer(context) + + then: "easeFactor >= 1.3 유지" + context.getEaseFactor() >= 1.3 + } +} From b2e92e4b0e2bbbf1ddb04e5fbd7bc24ec3672530 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Sun, 11 Jan 2026 17:06:06 +0900 Subject: [PATCH 116/528] =?UTF-8?q?[FEAT]=20ChatResponseFactory=20Factory?= =?UTF-8?q?=20=ED=8C=A8=ED=84=B4=20=EA=B5=AC=ED=98=84=20(#177)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChatResponseFactory 인터페이스 및 ChatResponse VO 생성 - AiChatResponseFactory: Bedrock 기반 구현, ChatLevel별 프롬프트 조정 - MockChatResponseFactory: 테스트용 Mock 구현 - ChatAIHandler Factory 패턴 적용 리팩토링 - Spock 기반 BDD 테스트 8개 작성 --- .../factory/AiChatResponseFactory.java | 122 ++++++++++++++++++ .../domain/chatting/factory/ChatResponse.java | 18 +++ .../chatting/factory/ChatResponseFactory.java | 29 +++++ .../factory/MockChatResponseFactory.java | 45 +++++++ .../chatting/handler/ChatAIHandler.java | 34 ++++- .../factory/ChatResponseFactorySpec.groovy | 116 +++++++++++++++++ 6 files changed, 358 insertions(+), 6 deletions(-) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/factory/AiChatResponseFactory.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/factory/ChatResponse.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/factory/ChatResponseFactory.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/factory/MockChatResponseFactory.java create mode 100644 ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/factory/ChatResponseFactorySpec.groovy diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/factory/AiChatResponseFactory.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/factory/AiChatResponseFactory.java new file mode 100644 index 00000000..53bc925f --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/factory/AiChatResponseFactory.java @@ -0,0 +1,122 @@ +package com.mzc.secondproject.serverless.domain.chatting.factory; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.domain.chatting.enums.ChatLevel; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.core.SdkBytes; +import software.amazon.awssdk.services.bedrockruntime.model.InvokeModelRequest; +import software.amazon.awssdk.services.bedrockruntime.model.InvokeModelResponse; + +/** + * AWS Bedrock 기반 AI 채팅 응답 Factory. + * ChatLevel에 따라 프롬프트와 응답 스타일을 조정한다. + */ +public class AiChatResponseFactory implements ChatResponseFactory { + + private static final Logger logger = LoggerFactory.getLogger(AiChatResponseFactory.class); + private static final Gson gson = new Gson(); + + private static final String MODEL_ID = "anthropic.claude-3-sonnet-20240229-v1:0"; + private static final int MAX_TOKENS = 1024; + + @Override + public ChatResponse create(String userMessage, ChatLevel level, String conversationHistory) { + logger.info("Generating AI response: level={}", level.name()); + + long startTime = System.currentTimeMillis(); + + try { + String systemPrompt = buildSystemPrompt(level); + String fullPrompt = buildFullPrompt(userMessage, conversationHistory, systemPrompt); + + JsonObject requestBody = buildRequestBody(fullPrompt, systemPrompt); + + InvokeModelRequest request = InvokeModelRequest.builder() + .modelId(MODEL_ID) + .contentType("application/json") + .accept("application/json") + .body(SdkBytes.fromUtf8String(gson.toJson(requestBody))) + .build(); + + InvokeModelResponse response = AwsClients.bedrock().invokeModel(request); + + String responseBody = response.body().asUtf8String(); + JsonObject jsonResponse = gson.fromJson(responseBody, JsonObject.class); + + String content = jsonResponse.getAsJsonArray("content") + .get(0).getAsJsonObject() + .get("text").getAsString(); + + long processingTime = System.currentTimeMillis() - startTime; + logger.info("AI response generated in {}ms", processingTime); + + return ChatResponse.of(content, MODEL_ID, processingTime); + + } catch (Exception e) { + logger.error("Error generating AI response", e); + throw new RuntimeException("Failed to generate AI response", e); + } + } + + private String buildSystemPrompt(ChatLevel level) { + return switch (level) { + case BEGINNER -> """ + You are a friendly English tutor for beginners. + - Use simple vocabulary and short sentences + - Explain grammar points when needed + - Provide Korean translations for difficult words + - Be encouraging and patient + - Speak slowly and clearly + """; + case INTERMEDIATE -> """ + You are an English conversation partner for intermediate learners. + - Use natural, everyday English + - Introduce new vocabulary with context clues + - Gently correct mistakes + - Encourage more complex sentence structures + """; + case ADVANCED -> """ + You are an advanced English conversation partner. + - Use sophisticated vocabulary and idioms + - Discuss complex topics naturally + - Challenge the learner with nuanced expressions + - Provide minimal corrections, focus on fluency + """; + }; + } + + private String buildFullPrompt(String userMessage, String conversationHistory, String systemPrompt) { + StringBuilder prompt = new StringBuilder(); + + if (conversationHistory != null && !conversationHistory.isEmpty()) { + prompt.append("Previous conversation:\n"); + prompt.append(conversationHistory); + prompt.append("\n\n"); + } + + prompt.append("User: ").append(userMessage); + + return prompt.toString(); + } + + private JsonObject buildRequestBody(String userPrompt, String systemPrompt) { + JsonObject requestBody = new JsonObject(); + requestBody.addProperty("anthropic_version", "bedrock-2023-05-31"); + requestBody.addProperty("max_tokens", MAX_TOKENS); + requestBody.addProperty("system", systemPrompt); + + JsonArray messages = new JsonArray(); + JsonObject userMessage = new JsonObject(); + userMessage.addProperty("role", "user"); + userMessage.addProperty("content", userPrompt); + messages.add(userMessage); + + requestBody.add("messages", messages); + + return requestBody; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/factory/ChatResponse.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/factory/ChatResponse.java new file mode 100644 index 00000000..fc181865 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/factory/ChatResponse.java @@ -0,0 +1,18 @@ +package com.mzc.secondproject.serverless.domain.chatting.factory; + +/** + * AI 채팅 응답 Value Object + */ +public record ChatResponse( + String content, + String modelId, + long processingTimeMs +) { + public static ChatResponse of(String content, String modelId, long processingTimeMs) { + return new ChatResponse(content, modelId, processingTimeMs); + } + + public static ChatResponse of(String content) { + return new ChatResponse(content, "unknown", 0); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/factory/ChatResponseFactory.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/factory/ChatResponseFactory.java new file mode 100644 index 00000000..0c1e3ba7 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/factory/ChatResponseFactory.java @@ -0,0 +1,29 @@ +package com.mzc.secondproject.serverless.domain.chatting.factory; + +import com.mzc.secondproject.serverless.domain.chatting.enums.ChatLevel; + +/** + * AI 채팅 응답 생성 Factory 인터페이스. + * 다양한 AI 백엔드(Bedrock, OpenAI 등)를 추상화한다. + */ +public interface ChatResponseFactory { + + /** + * AI 응답 생성 + * @param userMessage 사용자 메시지 + * @param level 채팅 난이도 레벨 + * @param conversationHistory 이전 대화 내역 (nullable) + * @return AI 응답 + */ + ChatResponse create(String userMessage, ChatLevel level, String conversationHistory); + + /** + * AI 응답 생성 (대화 내역 없이) + * @param userMessage 사용자 메시지 + * @param level 채팅 난이도 레벨 + * @return AI 응답 + */ + default ChatResponse create(String userMessage, ChatLevel level) { + return create(userMessage, level, null); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/factory/MockChatResponseFactory.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/factory/MockChatResponseFactory.java new file mode 100644 index 00000000..1514434d --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/factory/MockChatResponseFactory.java @@ -0,0 +1,45 @@ +package com.mzc.secondproject.serverless.domain.chatting.factory; + +import com.mzc.secondproject.serverless.domain.chatting.enums.ChatLevel; + +/** + * 테스트용 Mock AI 채팅 응답 Factory. + * 외부 API 호출 없이 고정된 응답을 반환한다. + */ +public class MockChatResponseFactory implements ChatResponseFactory { + + private static final String MOCK_MODEL_ID = "mock-model-v1"; + + @Override + public ChatResponse create(String userMessage, ChatLevel level, String conversationHistory) { + String response = generateMockResponse(userMessage, level); + return ChatResponse.of(response, MOCK_MODEL_ID, 10); + } + + private String generateMockResponse(String userMessage, ChatLevel level) { + return switch (level) { + case BEGINNER -> String.format( + "Hello! That's a great question. '%s' - Let me explain simply. " + + "(안녕하세요! 좋은 질문이에요. 쉽게 설명해 드릴게요.)", + truncate(userMessage, 50) + ); + case INTERMEDIATE -> String.format( + "Good point! Regarding '%s', I think we can explore this topic further. " + + "What do you think about it?", + truncate(userMessage, 50) + ); + case ADVANCED -> String.format( + "That's an insightful observation about '%s'. " + + "Let me offer a nuanced perspective on this matter.", + truncate(userMessage, 50) + ); + }; + } + + private String truncate(String text, int maxLength) { + if (text == null) { + return ""; + } + return text.length() > maxLength ? text.substring(0, maxLength) + "..." : text; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatAIHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatAIHandler.java index b76d314a..b680e924 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatAIHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatAIHandler.java @@ -4,9 +4,13 @@ import com.amazonaws.services.lambda.runtime.RequestHandler; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import com.google.gson.Gson; import com.mzc.secondproject.serverless.common.exception.CommonErrorCode; import com.mzc.secondproject.serverless.common.util.ResponseGenerator; -import com.mzc.secondproject.serverless.domain.chatting.service.BedrockService; +import com.mzc.secondproject.serverless.domain.chatting.enums.ChatLevel; +import com.mzc.secondproject.serverless.domain.chatting.factory.AiChatResponseFactory; +import com.mzc.secondproject.serverless.domain.chatting.factory.ChatResponse; +import com.mzc.secondproject.serverless.domain.chatting.factory.ChatResponseFactory; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -15,11 +19,16 @@ public class ChatAIHandler implements RequestHandler { private static final Logger logger = LoggerFactory.getLogger(ChatAIHandler.class); + private static final Gson gson = new Gson(); - private final BedrockService bedrockService; + private final ChatResponseFactory chatResponseFactory; public ChatAIHandler() { - this.bedrockService = new BedrockService(); + this.chatResponseFactory = new AiChatResponseFactory(); + } + + public ChatAIHandler(ChatResponseFactory chatResponseFactory) { + this.chatResponseFactory = chatResponseFactory; } @Override @@ -32,15 +41,28 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent re } String body = request.getBody(); - // TODO: Parse request and generate AI response using Bedrock + ChatRequest chatRequest = gson.fromJson(body, ChatRequest.class); + + String userMessage = chatRequest.message != null ? chatRequest.message : "Hello"; + ChatLevel level = ChatLevel.fromStringOrDefault(chatRequest.level, ChatLevel.BEGINNER); - String aiResponse = bedrockService.generateResponse("Hello, how can I help you?"); + ChatResponse aiResponse = chatResponseFactory.create(userMessage, level, chatRequest.conversationHistory); - return ResponseGenerator.ok("AI response generated", Map.of("response", aiResponse)); + return ResponseGenerator.ok("AI response generated", Map.of( + "response", aiResponse.content(), + "modelId", aiResponse.modelId(), + "processingTimeMs", aiResponse.processingTimeMs() + )); } catch (Exception e) { logger.error("Error generating AI response", e); return ResponseGenerator.fail(CommonErrorCode.INTERNAL_SERVER_ERROR); } } + + private static class ChatRequest { + String message; + String level; + String conversationHistory; + } } diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/factory/ChatResponseFactorySpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/factory/ChatResponseFactorySpec.groovy new file mode 100644 index 00000000..0ed1d90f --- /dev/null +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/factory/ChatResponseFactorySpec.groovy @@ -0,0 +1,116 @@ +package com.mzc.secondproject.serverless.domain.chatting.factory + +import com.mzc.secondproject.serverless.domain.chatting.enums.ChatLevel +import spock.lang.Specification +import spock.lang.Subject +import spock.lang.Unroll + +class ChatResponseFactorySpec extends Specification { + + // ==================== MockChatResponseFactory Tests ==================== + + @Subject + MockChatResponseFactory mockFactory = new MockChatResponseFactory() + + def "MockChatResponseFactory: ChatResponse 객체 반환"() { + given: "Mock Factory" + def userMessage = "Hello, how are you?" + + when: "응답 생성" + def response = mockFactory.create(userMessage, ChatLevel.BEGINNER) + + then: "ChatResponse 객체 반환" + response != null + response.content() != null + response.modelId() == "mock-model-v1" + response.processingTimeMs() >= 0 + } + + @Unroll + def "MockChatResponseFactory: #level 레벨에 맞는 응답 생성"() { + given: "Mock Factory" + def userMessage = "Test message" + + when: "응답 생성" + def response = mockFactory.create(userMessage, level) + + then: "레벨에 맞는 응답" + response.content().contains(expectedKeyword) + + where: + level | expectedKeyword + ChatLevel.BEGINNER | "안녕하세요" + ChatLevel.INTERMEDIATE | "What do you think" + ChatLevel.ADVANCED | "nuanced perspective" + } + + def "MockChatResponseFactory: 대화 내역 포함 가능"() { + given: "Mock Factory와 대화 내역" + def userMessage = "Continue our discussion" + def history = "User: Hello\nAI: Hi there!" + + when: "대화 내역 포함하여 응답 생성" + def response = mockFactory.create(userMessage, ChatLevel.INTERMEDIATE, history) + + then: "정상 응답" + response != null + response.content() != null + } + + def "MockChatResponseFactory: null 메시지 처리"() { + given: "Mock Factory" + + when: "null 메시지로 응답 생성" + def response = mockFactory.create(null, ChatLevel.BEGINNER) + + then: "예외 없이 처리" + response != null + response.content() != null + } + + def "MockChatResponseFactory: 긴 메시지 truncate"() { + given: "Mock Factory와 긴 메시지" + def longMessage = "A" * 100 + + when: "응답 생성" + def response = mockFactory.create(longMessage, ChatLevel.BEGINNER) + + then: "메시지가 truncate되어 응답에 포함" + response.content().contains("...") + } + + // ==================== ChatResponse Tests ==================== + + def "ChatResponse: of 팩토리 메서드"() { + when: "ChatResponse 생성" + def response = ChatResponse.of("Hello", "model-v1", 100) + + then: "필드 값 확인" + response.content() == "Hello" + response.modelId() == "model-v1" + response.processingTimeMs() == 100 + } + + def "ChatResponse: 간단한 of 메서드"() { + when: "content만으로 생성" + def response = ChatResponse.of("Hello") + + then: "기본값 적용" + response.content() == "Hello" + response.modelId() == "unknown" + response.processingTimeMs() == 0 + } + + // ==================== ChatResponseFactory 인터페이스 테스트 ==================== + + def "ChatResponseFactory: default 메서드 동작"() { + given: "Mock Factory" + def userMessage = "Test" + + when: "대화 내역 없이 호출" + def response = mockFactory.create(userMessage, ChatLevel.BEGINNER) + + then: "정상 동작" + response != null + } +} From 319f48087bf260054ea72bd14dcfd106227d29ce Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Sun, 11 Jan 2026 17:16:53 +0900 Subject: [PATCH 117/528] =?UTF-8?q?[FEAT]=20HashSet=20=EA=B8=B0=EB=B0=98?= =?UTF-8?q?=20=EC=A4=91=EB=B3=B5=20=EC=A0=9C=EA=B1=B0=20=EC=B5=9C=EC=A0=81?= =?UTF-8?q?=ED=99=94=20(#175)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DailyStudyCommandService: learnedWordIds, newWordIds를 Set으로 변환 - TestCommandService: excludeWordIds HashSet 변환 - O(n²) → O(n) 복잡도 개선 --- .../vocabulary/service/DailyStudyCommandService.java | 11 +++++++---- .../domain/vocabulary/service/TestCommandService.java | 5 ++++- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyCommandService.java index ad1cc1fa..59e06f92 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyCommandService.java @@ -16,9 +16,12 @@ import java.time.LocalDate; import java.util.ArrayList; import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.stream.Collectors; /** @@ -128,11 +131,11 @@ private DailyStudy createDailyStudy(String userId, String date, String level) { private List getNewWordsForUser(String userId, String level, int count) { PaginatedResult userWordPage = userWordRepository.findByUserIdWithPagination(userId, 1000, null); - List learnedWordIds = userWordPage.items().stream() + Set learnedWordIds = userWordPage.items().stream() .map(UserWord::getWordId) - .collect(Collectors.toList()); + .collect(Collectors.toSet()); - List newWordIds = new ArrayList<>(); + Set newWordIds = new LinkedHashSet<>(); String lastEvaluatedKey = null; do { @@ -147,7 +150,7 @@ private List getNewWordsForUser(String userId, String level, int count) } while (newWordIds.size() < count && lastEvaluatedKey != null); logger.info("Selected {} new words for user {} at level {}", newWordIds.size(), userId, level); - return newWordIds; + return new ArrayList<>(newWordIds); } private List getWordDetails(List wordIds) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java index fbf66257..09646131 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java @@ -19,10 +19,12 @@ import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Random; +import java.util.Set; import java.util.UUID; import java.util.stream.Collectors; @@ -170,9 +172,10 @@ public SubmitTestResult submitTest(String userId, String testId, String testType } private List getDistractorsForLevel(String level, List excludeWordIds) { + Set excludeSet = new HashSet<>(excludeWordIds); PaginatedResult wordPage = wordRepository.findByLevelWithPagination(level, 50, null); return wordPage.items().stream() - .filter(w -> !excludeWordIds.contains(w.getWordId())) + .filter(w -> !excludeSet.contains(w.getWordId())) .map(Word::getKorean) .collect(Collectors.toList()); } From a1f54924055ea4ebd4aa6202f8410d9bf2b0e113 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Sun, 11 Jan 2026 17:28:47 +0900 Subject: [PATCH 118/528] =?UTF-8?q?feat(dynamodb):=20GSI3=20Sparse=20Index?= =?UTF-8?q?=EB=A1=9C=20=EB=B6=81=EB=A7=88=ED=81=AC=20=EC=BF=BC=EB=A6=AC=20?= =?UTF-8?q?=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UserWord 모델에 GSI3 필드(gsi3pk, gsi3sk) 추가 - DynamoDbKey에 GSI3 상수 추가 - VocabKey에 userBookmarkedPk() 키 빌더 추가 - UserWordRepository.findBookmarkedWords() FilterExpression -> GSI3 쿼리로 변경 - UserWordCommandService 북마크 설정 시 GSI3 키 설정/해제 - template.yaml VocabTable에 GSI3 인덱스 정의 추가 Refs #176 --- .../serverless/common/constants/DynamoDbKey.java | 3 +++ .../domain/vocabulary/constants/VocabKey.java | 5 +++++ .../domain/vocabulary/model/UserWord.java | 15 +++++++++++++++ .../vocabulary/repository/UserWordRepository.java | 13 ++++--------- .../service/UserWordCommandService.java | 7 +++++++ ServerlessFunction/template.yaml | 12 ++++++++++++ 6 files changed, 46 insertions(+), 9 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/constants/DynamoDbKey.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/constants/DynamoDbKey.java index cdad0313..a437dd4b 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/constants/DynamoDbKey.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/constants/DynamoDbKey.java @@ -13,10 +13,13 @@ private DynamoDbKey() {} public static final String GSI1_SK = "GSI1SK"; public static final String GSI2_PK = "GSI2PK"; public static final String GSI2_SK = "GSI2SK"; + public static final String GSI3_PK = "GSI3PK"; + public static final String GSI3_SK = "GSI3SK"; // Index Names public static final String GSI1 = "GSI1"; public static final String GSI2 = "GSI2"; + public static final String GSI3 = "GSI3"; // Common Sort Key public static final String METADATA = "METADATA"; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/constants/VocabKey.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/constants/VocabKey.java index 06607a01..d1effe11 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/constants/VocabKey.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/constants/VocabKey.java @@ -19,6 +19,7 @@ private VocabKey() {} public static final String SUFFIX_REVIEW = "#REVIEW"; public static final String SUFFIX_STATUS = "#STATUS"; public static final String SUFFIX_GROUP = "#GROUP"; + public static final String SUFFIX_BOOKMARKED = "#BOOKMARKED"; // Special Keys public static final String DAILY_ALL = "DAILY#ALL"; @@ -68,6 +69,10 @@ public static String userGroupPk(String userId) { return DynamoDbKey.USER + userId + SUFFIX_GROUP; } + public static String userBookmarkedPk(String userId) { + return DynamoDbKey.USER + userId + SUFFIX_BOOKMARKED; + } + public static String testPk(String testId) { return TEST + testId; } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/model/UserWord.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/model/UserWord.java index 81638120..969315b9 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/model/UserWord.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/model/UserWord.java @@ -17,6 +17,7 @@ * SK: WORD#{wordId} * GSI1: USER#{userId}#REVIEW / DATE#{nextReviewAt} - 복습 예정 조회 * GSI2: USER#{userId}#STATUS / STATUS#{status} - 상태별 조회 + * GSI3: USER#{userId}#BOOKMARKED / WORD#{wordId} - 북마크 조회 (Sparse Index) */ @Data @Builder @@ -31,6 +32,8 @@ public class UserWord { private String gsi1sk; // DATE#{nextReviewAt} private String gsi2pk; // USER#{userId}#STATUS private String gsi2sk; // STATUS#{status} + private String gsi3pk; // USER#{userId}#BOOKMARKED (Sparse: only when bookmarked) + private String gsi3sk; // WORD#{wordId} private String userId; private String wordId; @@ -90,4 +93,16 @@ public String getGsi2pk() { public String getGsi2sk() { return gsi2sk; } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI3") + @DynamoDbAttribute("GSI3PK") + public String getGsi3pk() { + return gsi3pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI3") + @DynamoDbAttribute("GSI3SK") + public String getGsi3sk() { + return gsi3sk; + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/UserWordRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/UserWordRepository.java index cef54919..d2daf48a 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/UserWordRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/UserWordRepository.java @@ -103,23 +103,17 @@ public PaginatedResult findReviewDueWords(String userId, String todayD } /** - * 북마크된 단어만 조회 - FilterExpression 사용 (GSI 추가 없이 비용 최적화) + * 북마크된 단어만 조회 - GSI3 Sparse Index 활용 */ public PaginatedResult findBookmarkedWords(String userId, int limit, String cursor) { QueryConditional queryConditional = QueryConditional .sortBeginsWith(Key.builder() - .partitionValue("USER#" + userId) + .partitionValue("USER#" + userId + "#BOOKMARKED") .sortValue("WORD#") .build()); - Expression filterExpression = Expression.builder() - .expression("bookmarked = :bookmarked") - .putExpressionValue(":bookmarked", AttributeValue.builder().bool(true).build()) - .build(); - QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() .queryConditional(queryConditional) - .filterExpression(filterExpression) .limit(limit); if (cursor != null && !cursor.isEmpty()) { @@ -129,7 +123,8 @@ public PaginatedResult findBookmarkedWords(String userId, int limit, S } } - Page page = table.query(requestBuilder.build()).iterator().next(); + DynamoDbIndex gsi3 = table.index("GSI3"); + Page page = gsi3.query(requestBuilder.build()).iterator().next(); String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); return new PaginatedResult<>(page.items(), nextCursor); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordCommandService.java index e91df6fe..806c17a3 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordCommandService.java @@ -98,6 +98,13 @@ public UserWord updateUserWordTag(String userId, String wordId, Boolean bookmark if (bookmarked != null) { userWord.setBookmarked(bookmarked); + if (bookmarked) { + userWord.setGsi3pk(VocabKey.userBookmarkedPk(userId)); + userWord.setGsi3sk(VocabKey.wordSk(wordId)); + } else { + userWord.setGsi3pk(null); + userWord.setGsi3sk(null); + } } if (favorite != null) { userWord.setFavorite(favorite); diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 789f8600..87382510 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -694,6 +694,10 @@ Resources: AttributeType: S - AttributeName: GSI2SK AttributeType: S + - AttributeName: GSI3PK + AttributeType: S + - AttributeName: GSI3SK + AttributeType: S KeySchema: - AttributeName: PK KeyType: HASH @@ -716,6 +720,14 @@ Resources: KeyType: RANGE Projection: ProjectionType: ALL + - IndexName: GSI3 + KeySchema: + - AttributeName: GSI3PK + KeyType: HASH + - AttributeName: GSI3SK + KeyType: RANGE + Projection: + ProjectionType: ALL TimeToLiveSpecification: AttributeName: ttl Enabled: true From 9a60574a713413a7ee1dc7f27eed0002dd5879c4 Mon Sep 17 00:00:00 2001 From: hyein Heo <128613248+hye-inA@users.noreply.github.com> Date: Sun, 11 Jan 2026 21:20:34 +0900 Subject: [PATCH 119/528] =?UTF-8?q?feat=20:=20AWS=20Cognito=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20=EC=9D=B8=EC=A6=9D=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(#133)=20(#183)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat : template.yaml User 테이블 및 환경변수 등록 * feat : JWT Libraty 추가 * feat : JWT Authorizer, 검증 서비스 추가 * feat : 회원가입, 로그인 dto 생성 * feat : User 도메인(Handler, Repository, Service) 구현 * feat : Jwt 토큰 생성 메서드 추가 * feat : user관련 lambda, table 정의 추가 * feat : cognito 리소스 추가 * refactor : JwtAuthorizerFunction, UserFunction 삭제 * refactor : Auth를 Cognito Authorizer로 변경 * feat : Outputs에 Cognito 정보 추가 * feat : API Gateway에 기본 인증 설정 추가 * refactor : jwt 관련 파일 정리 * feat : Cognito 회원가입 시 기본값 설정 handler 추가 * refactor : password 필드 제거 * refactor : Cognito claims 사용 * refactor : 회원가입, 로그인 로직 제거 * refactor : 공통 응답 util 사용 및 예외처리 구현 * docs: User Server 가이드 문서 작성 --- ServerlessFunction/build.gradle | 5 + .../domain/user/handler/PreSignUpHandler.java | 53 ++ .../domain/user/handler/UserHandler.java | 55 ++ .../serverless/domain/user/model/User.java | 65 ++ .../user/repository/UserRepository.java | 71 +++ .../domain/user/service/UserService.java | 12 + ServerlessFunction/template.yaml | 222 +++++++ docs/user/USER-GUIDE.md | 569 ++++++++++++++++++ 8 files changed, 1052 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PreSignUpHandler.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/UserHandler.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/model/User.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/repository/UserRepository.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/service/UserService.java create mode 100644 docs/user/USER-GUIDE.md diff --git a/ServerlessFunction/build.gradle b/ServerlessFunction/build.gradle index 5e6a02d8..417ce016 100644 --- a/ServerlessFunction/build.gradle +++ b/ServerlessFunction/build.gradle @@ -43,6 +43,11 @@ dependencies { // Password Hashing implementation 'org.mindrot:jbcrypt:0.4' + // JWT + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + // Logging implementation 'com.amazonaws:aws-lambda-java-log4j2:1.6.0' implementation 'org.apache.logging.log4j:log4j-api:2.22.1' diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PreSignUpHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PreSignUpHandler.java new file mode 100644 index 00000000..2a714b92 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PreSignUpHandler.java @@ -0,0 +1,53 @@ +package com.mzc.secondproject.serverless.domain.user.handler; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Map; +import java.util.UUID; + +public class PreSignUpHandler implements RequestHandler, Map> { + + private static final Logger logger = LoggerFactory.getLogger(PreSignUpHandler.class); + private static final String DEFAULT_PROFILE_URL = System.getenv("DEFAULT_PROFILE_URL"); + + @Override + public Map handleRequest(Map input, Context context) { + + try { + @SuppressWarnings("unchecked") + Map request = (Map) input.get("request"); + + @SuppressWarnings("unchecked") + Map userAttributes = (Map) request.get("userAttributes"); + + String nickname = userAttributes.get("nickname"); + if (nickname == null || nickname.trim().isEmpty()) { + String defaultNickname = UUID.randomUUID().toString().substring(0, 6).toUpperCase() + "님"; + userAttributes.put("nickname", defaultNickname); + logger.info("nickname 기본값: {}", defaultNickname); + } + + String level = userAttributes.get("custom:level"); + if (level == null || level.trim().isEmpty()) { + userAttributes.put("custom:level", "BEGINNER"); + logger.info("level 선택 기본값: BEGINNER"); + } + + String profileUrl = userAttributes.get("custom:profileUrl"); + if (profileUrl == null || profileUrl.trim().isEmpty()) { + String defaultUrl = DEFAULT_PROFILE_URL != null + ? DEFAULT_PROFILE_URL + : "https://group2-englishstudy.s3.amazonaws.com/profile/default.png"; + userAttributes.put("custom:profileUrl", defaultUrl); + logger.info("프로필 이미지 기본값: {}", defaultUrl); + } + } catch (Exception e) { + logger.error("PreSignUp 트리거에서 오류가 발생했습니다"); + } + + return input; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/UserHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/UserHandler.java new file mode 100644 index 00000000..5063dbb5 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/UserHandler.java @@ -0,0 +1,55 @@ +package com.mzc.secondproject.serverless.domain.user.handler; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.events.APIGatewayCustomAuthorizerEvent; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import com.mzc.secondproject.serverless.common.exception.CommonErrorCode; +import com.mzc.secondproject.serverless.common.util.ResponseGenerator; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.services.s3.endpoints.internal.Value; + +import java.util.HashMap; +import java.util.Map; + +public class UserHandler implements RequestHandler { + + private static final Logger logger = LoggerFactory.getLogger(UserHandler.class); + + @Override + public APIGatewayProxyResponseEvent handleRequest( + APIGatewayProxyRequestEvent request, + Context context + ) { + try { + @SuppressWarnings("unchecked") + Map authorizer = request.getRequestContext().getAuthorizer(); + + // Cognito Authorizer에서 claims 추출 + @SuppressWarnings("unchecked") + Map claims = (Map) authorizer.get("claims"); + + if (claims == null) { + return ResponseGenerator.fail(CommonErrorCode.INVALID_TOKEN, "claims가 존재하지 않습니다."); + } + + String userId = claims.get("sub"); + String email = claims.get("email"); + String nickname = claims.get("nickname"); + + logger.info("인증된 사용자 : userId={}, email={}, nickname={}", userId, email, nickname); + + Map data = new HashMap<>(); + data.put("userId", userId); + data.put("email", email); + data.put("nickname", nickname); + + return ResponseGenerator.ok(nickname + "환영합니다", data); + } catch (Exception e){ + return ResponseGenerator.fail(CommonErrorCode.INTERNAL_SERVER_ERROR); + } + } + +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/model/User.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/model/User.java new file mode 100644 index 00000000..c15e2398 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/model/User.java @@ -0,0 +1,65 @@ +package com.mzc.secondproject.serverless.domain.user.model; + +import lombok.*; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.*; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DynamoDbBean +public class User { + + private String pk; // USER#{cognitoSub} + private String sk; // METADATA + private String gsi1pk; // EMAIL#{email} + private String gsi1sk; // USER#{cognitoSub} + private String gsi2pk; // LEVEL#{level} + private String gsi2sk; // USER#{cognitoSub} + + private String cognitoSub; // Cognito sub (Primary ID) + private String email; + private String nickname; + private String level; + private String createdAt; + private String updatedAt; + private String lastLoginAt; + private Long ttl; + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { + return pk; + } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { + return sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1PK") + public String getGsi1pk() { + return gsi1pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1SK") + public String getGsi1sk() { + return gsi1sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI2") + @DynamoDbAttribute("GSI2PK") + public String getGsi2pk() { + return gsi2pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI2") + @DynamoDbAttribute("GSI2SK") + public String getGsi2sk() { + return gsi2sk; + } + +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/repository/UserRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/repository/UserRepository.java new file mode 100644 index 00000000..29f67ce7 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/repository/UserRepository.java @@ -0,0 +1,71 @@ +package com.mzc.secondproject.serverless.domain.user.repository; + +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.domain.user.model.User; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbIndex; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; +import software.amazon.awssdk.enhanced.dynamodb.Key; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbAttribute; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; + +import java.util.Optional; + +public class UserRepository { + + private static final Logger logger = LoggerFactory.getLogger(UserRepository.class); + private static final String TABLE_NAME = System.getenv("USER_TABLE_NAME"); + + private final DynamoDbTable table; + + public UserRepository() { + this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(User.class)); + } + + public User save(User user) { + table.putItem(user); + return user; + } + + public Optional findById(String userId) { + Key key = Key.builder() + .partitionValue(userId) + .build(); + + User user = table.getItem(key); + return Optional.ofNullable(user); + } + + /** + * 이메일로 사용자 조회 (로그인, 중복 체크용) + * GSI1 사용: GSI1PK = EMAIL#{email} + */ + public Optional findByEmail(String email) { + QueryConditional queryConditional = QueryConditional + .keyEqualTo(Key.builder() + .partitionValue("EMAIL#" + email) + .build()); + + DynamoDbIndex gsi1 = table.index("GSI1"); + + return gsi1.query(queryConditional) + .stream() + .flatMap(page -> page.items().stream()) + .findFirst(); + } + + public boolean existsByEmail(String email) { + return findByEmail(email).isPresent(); + } + + + public void delete(String userId) { + Key key = Key.builder() + .partitionValue(userId) + .build(); + table.deleteItem(key); + } + +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/service/UserService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/service/UserService.java new file mode 100644 index 00000000..02e5388b --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/service/UserService.java @@ -0,0 +1,12 @@ +package com.mzc.secondproject.serverless.domain.user.service; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + + +public class UserService { + + private static final Logger logger = LoggerFactory.getLogger(UserService.class); + + +} diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 87382510..86c762cc 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -11,6 +11,7 @@ Globals: - x86_64 Environment: Variables: + USER_TABLE_NAME: !Ref UserTable CHAT_TABLE_NAME: !Ref ChatTable VOCAB_TABLE_NAME: !Ref VocabTable CHAT_BUCKET_NAME: group2-englishstudy @@ -19,6 +20,84 @@ Globals: ROOM_TOKEN_TTL_SECONDS: "300" Resources: + ############################################# + # Cognito User Pool + ############################################# + + CognitoUserPool: + Type: AWS::Cognito::UserPool + DeletionPolicy: Retain + UpdateReplacePolicy: Retain + Properties: + UserPoolName: !Sub "${AWS::StackName}-userpool" + UsernameAttributes: + - email + AutoVerifiedAttributes: + - email + Policies: + PasswordPolicy: + MinimumLength: 8 + RequireLowercase: true + RequireNumbers: true + RequireSymbols: true + RequireUppercase: false + # Cognito에 저장할 사용자 정보 정의 ≈ 회원 테이블 컬럼 + Schema: + - Name: email + AttributeDataType: String + Required: true + Mutable: true + - Name: nickname + AttributeDataType: String + Required: false + Mutable: true + - Name: level + AttributeDataType: String + Required: false + Mutable: true + - Name: profileUrl + AttributeDataType: String + Required: false + Mutable: true + LambdaConfig: + PreSignUp: !GetAtt PreSignUpFunction.Arn + + # Cognito에게 Lambda 호출 권한 부여 + PreSignUpPermission: + Type: AWS::Lambda::Permission + Properties: + Action: lambda:InvokeFunction + FunctionName: !Ref PreSignUpFunction + Principal: cognito-idp.amazonaws.com + SourceArn: !Sub arn:aws:cognito-idp:${AWS::Region}:${AWS::AccountId}:userpool/* + + # TODO : 회원가입 시 기본값 자동 설정 handler 추가 + # 사용자 custom 속성들 기본값 설정 Lambda 함수 + PreSignUpFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: !Sub "${AWS::StackName}-pre-signup" + CodeUri: . + Handler: com.mzc.secondproject.serverless.domain.user.handler.PreSignUpHandler::handleRequest + Description: Set default values for new users (nickname, level, picture) + MemorySize: 256 + Timeout: 10 + Environment: + Variables: + DEFAULT_PROFILE_URL: https://group2-englishstudy.s3.amazonaws.com/profile/default.png + + CognitoUserPoolClient: + Type: AWS::Cognito::UserPoolClient + Properties: + ClientName: !Sub "${AWS::StackName}-client" + UserPoolId: !Ref CognitoUserPool + GenerateSecret: false + ExplicitAuthFlows: + - ALLOW_USER_SRP_AUTH + - ALLOW_REFRESH_TOKEN_AUTH + - ALLOW_USER_PASSWORD_AUTH + PreventUserExistenceErrors: ENABLED + ############################################# # API Gateway (Unified) ############################################# @@ -33,6 +112,14 @@ Resources: AllowHeaders: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" AllowOrigin: "'*'" + Auth: + DefaultAuthorizer: CognitoAuthorizer + Authorizers: + CognitoAuthorizer: + UserPoolArn: !GetAtt CognitoUserPool.Arn + Identity: + Header: Authorization + ############################################# # WebSocket API Gateway ############################################# @@ -200,36 +287,48 @@ Resources: RestApiId: !Ref MainApi Path: /chat/rooms Method: POST + # Auth: + # Authorizer: CognitoAuthorizer GetRooms: Type: Api Properties: RestApiId: !Ref MainApi Path: /chat/rooms Method: GET + # Auth: + # Authorizer: CognitoAuthorizer GetRoom: Type: Api Properties: RestApiId: !Ref MainApi Path: /chat/rooms/{roomId} Method: GET + # Auth: + # Authorizer: CognitoAuthorizer DeleteRoom: Type: Api Properties: RestApiId: !Ref MainApi Path: /chat/rooms/{roomId} Method: DELETE + # Auth: + # Authorizer: CognitoAuthorizer JoinRoom: Type: Api Properties: RestApiId: !Ref MainApi Path: /chat/rooms/{roomId}/join Method: POST + # Auth: + # Authorizer: CognitoAuthorizer LeaveRoom: Type: Api Properties: RestApiId: !Ref MainApi Path: /chat/rooms/{roomId}/leave Method: POST + # Auth: + # Authorizer: CognitoAuthorizer ChatMessageFunction: Type: AWS::Serverless::Function @@ -264,18 +363,24 @@ Resources: RestApiId: !Ref MainApi Path: /chat/rooms/{roomId}/messages Method: POST + # Auth: + # Authorizer: CognitoAuthorizer GetMessages: Type: Api Properties: RestApiId: !Ref MainApi Path: /chat/rooms/{roomId}/messages Method: GET + # Auth: + # Authorizer: CognitoAuthorizer GetMessage: Type: Api Properties: RestApiId: !Ref MainApi Path: /chat/rooms/{roomId}/messages/{messageId} Method: GET + # Auth: + # Authorizer: CognitoAuthorizer ChatAIFunction: Type: AWS::Serverless::Function @@ -304,6 +409,8 @@ Resources: RestApiId: !Ref MainApi Path: /chat/ai/generate Method: POST + # Auth: + # Authorizer: CognitoAuthorizer ChatVoiceFunction: Type: AWS::Serverless::Function @@ -332,6 +439,8 @@ Resources: RestApiId: !Ref MainApi Path: /chat/voice/synthesize Method: POST + # Auth: + # Authorizer: CognitoAuthorizer ############################################# # Vocabulary Lambda Functions @@ -356,48 +465,64 @@ Resources: RestApiId: !Ref MainApi Path: /vocab/words Method: POST + # Auth: + # Authorizer: CognitoAuthorizer BatchCreateWords: Type: Api Properties: RestApiId: !Ref MainApi Path: /vocab/words/batch Method: POST + # Auth: + # Authorizer: CognitoAuthorizer BatchGetWords: Type: Api Properties: RestApiId: !Ref MainApi Path: /vocab/words/batch/get Method: POST + # Auth: + # Authorizer: CognitoAuthorizer SearchWords: Type: Api Properties: RestApiId: !Ref MainApi Path: /vocab/words/search Method: GET + # Auth: + # Authorizer: CognitoAuthorizer GetWords: Type: Api Properties: RestApiId: !Ref MainApi Path: /vocab/words Method: GET + # Auth: + # Authorizer: CognitoAuthorizer GetWord: Type: Api Properties: RestApiId: !Ref MainApi Path: /vocab/words/{wordId} Method: GET + # Auth: + # Authorizer: CognitoAuthorizer UpdateWord: Type: Api Properties: RestApiId: !Ref MainApi Path: /vocab/words/{wordId} Method: PUT + # Auth: + # Authorizer: CognitoAuthorizer DeleteWord: Type: Api Properties: RestApiId: !Ref MainApi Path: /vocab/words/{wordId} Method: DELETE + # Auth: + # Authorizer: CognitoAuthorizer UserWordFunction: Type: AWS::Serverless::Function @@ -418,30 +543,40 @@ Resources: RestApiId: !Ref MainApi Path: /vocab/users/{userId}/wrong-answers Method: GET + # Auth: + # Authorizer: CognitoAuthorizer GetUserWords: Type: Api Properties: RestApiId: !Ref MainApi Path: /vocab/users/{userId}/words Method: GET + # Auth: + # Authorizer: CognitoAuthorizer GetUserWord: Type: Api Properties: RestApiId: !Ref MainApi Path: /vocab/users/{userId}/words/{wordId} Method: GET + # Auth: + # Authorizer: CognitoAuthorizer UpdateUserWord: Type: Api Properties: RestApiId: !Ref MainApi Path: /vocab/users/{userId}/words/{wordId} Method: PUT + # Auth: + # Authorizer: CognitoAuthorizer UpdateUserWordTag: Type: Api Properties: RestApiId: !Ref MainApi Path: /vocab/users/{userId}/words/{wordId}/tag Method: PUT + # Auth: + # Authorizer: CognitoAuthorizer WordGroupFunction: Type: AWS::Serverless::Function @@ -462,42 +597,56 @@ Resources: RestApiId: !Ref MainApi Path: /vocab/users/{userId}/groups Method: POST + # Auth: + # Authorizer: CognitoAuthorizer GetGroups: Type: Api Properties: RestApiId: !Ref MainApi Path: /vocab/users/{userId}/groups Method: GET + # Auth: + # Authorizer: CognitoAuthorizer GetGroupDetail: Type: Api Properties: RestApiId: !Ref MainApi Path: /vocab/users/{userId}/groups/{groupId} Method: GET + # Auth: + # Authorizer: CognitoAuthorizer UpdateGroup: Type: Api Properties: RestApiId: !Ref MainApi Path: /vocab/users/{userId}/groups/{groupId} Method: PUT + # Auth: + # Authorizer: CognitoAuthorizer DeleteGroup: Type: Api Properties: RestApiId: !Ref MainApi Path: /vocab/users/{userId}/groups/{groupId} Method: DELETE + # Auth: + # Authorizer: CognitoAuthorizer AddWordToGroup: Type: Api Properties: RestApiId: !Ref MainApi Path: /vocab/users/{userId}/groups/{groupId}/words/{wordId} Method: POST + # Auth: + # Authorizer: CognitoAuthorizer RemoveWordFromGroup: Type: Api Properties: RestApiId: !Ref MainApi Path: /vocab/users/{userId}/groups/{groupId}/words/{wordId} Method: DELETE + # Auth: + # Authorizer: CognitoAuthorizer DailyStudyFunction: Type: AWS::Serverless::Function @@ -518,12 +667,16 @@ Resources: RestApiId: !Ref MainApi Path: /vocab/daily/{userId} Method: GET + # Auth: + # Authorizer: CognitoAuthorizer MarkWordLearned: Type: Api Properties: RestApiId: !Ref MainApi Path: /vocab/daily/{userId}/words/{wordId}/learned Method: POST + # Auth: + # Authorizer: CognitoAuthorizer TestFunction: Type: AWS::Serverless::Function @@ -549,24 +702,32 @@ Resources: RestApiId: !Ref MainApi Path: /vocab/test/{userId}/start Method: POST + # Auth: + # Authorizer: CognitoAuthorizer SubmitAnswer: Type: Api Properties: RestApiId: !Ref MainApi Path: /vocab/test/{userId}/submit Method: POST + # Auth: + # Authorizer: CognitoAuthorizer GetTestResults: Type: Api Properties: RestApiId: !Ref MainApi Path: /vocab/test/{userId}/results Method: GET + # Auth: + # Authorizer: CognitoAuthorizer GetTestResultDetail: Type: Api Properties: RestApiId: !Ref MainApi Path: /vocab/test/{userId}/results/{testId} Method: GET + # Auth: + # Authorizer: CognitoAuthorizer StatsFunction: Type: AWS::Serverless::Function @@ -587,18 +748,24 @@ Resources: RestApiId: !Ref MainApi Path: /vocab/stats/{userId} Method: GET + # Auth: + # Authorizer: CognitoAuthorizer GetDailyStats: Type: Api Properties: RestApiId: !Ref MainApi Path: /vocab/stats/{userId}/daily Method: GET + # Auth: + # Authorizer: CognitoAuthorizer GetWeaknessAnalysis: Type: Api Properties: RestApiId: !Ref MainApi Path: /vocab/stats/{userId}/weakness Method: GET + # Auth: + # Authorizer: CognitoAuthorizer VocabVoiceFunction: Type: AWS::Serverless::Function @@ -627,11 +794,58 @@ Resources: RestApiId: !Ref MainApi Path: /vocab/voice/synthesize Method: POST + # Auth: + # Authorizer: CognitoAuthorizer ############################################# # DynamoDB Tables ############################################# + UserTable: + Type: AWS::DynamoDB::Table + Properties: + TableName: group2-englishstudy-user + BillingMode: PAY_PER_REQUEST + AttributeDefinitions: + - AttributeName: PK + AttributeType: S + - AttributeName: SK + AttributeType: S + - AttributeName: GSI1PK + AttributeType: S + - AttributeName: GSI1SK + AttributeType: S + - AttributeName: GSI2PK + AttributeType: S + - AttributeName: GSI2SK + AttributeType: S + + KeySchema: + - AttributeName: PK + KeyType: HASH + - AttributeName: SK + KeyType: RANGE + GlobalSecondaryIndexes: + - IndexName: GSI1 + KeySchema: + - AttributeName: GSI1PK + KeyType: HASH + - AttributeName: GSI1SK + KeyType: RANGE + Projection: + ProjectionType: ALL + - IndexName: GSI2 + KeySchema: + - AttributeName: GSI2PK + KeyType: HASH + - AttributeName: GSI2SK + KeyType: RANGE + Projection: + ProjectionType: ALL + TimeToLiveSpecification: + AttributeName: ttl + Enabled: true + ChatTable: Type: AWS::DynamoDB::Table Properties: @@ -832,3 +1046,11 @@ Outputs: BucketName: Description: S3 Bucket Name Value: group2-englishstudy + + CognitoUserPoolId: + Description: Cognito User Pool ID + Value: !Ref CognitoUserPool + + CognitoUserPoolClientId: + Description: Cognito User Pool Client ID + Value: !Ref CognitoUserPoolClient diff --git a/docs/user/USER-GUIDE.md b/docs/user/USER-GUIDE.md new file mode 100644 index 00000000..feb4740f --- /dev/null +++ b/docs/user/USER-GUIDE.md @@ -0,0 +1,569 @@ +# User Domain 가이드 문서 + +## 1. 개요 + +### 1.1 목적 + +User Server는 영어 회화 학습 플랫폼의 사용자 인증 및 프로필 관리를 담당하는 서버리스 마이크로서비스이다. AWS Cognito를 활용하여 안전한 인증 체계를 제공하고, 사용자별 학습 데이터 및 개인 설정을 관리한다. + +### 1.2 주요 기능 + +| 기능 | 설명 | +|------|------| +| 회원가입 | Cognito 기반 이메일 회원가입 | +| 이메일 인증 | Cognito 자동 인증 코드 발송 | +| 로그인 | JWT 토큰 발급 (IdToken, AccessToken, RefreshToken) | +| 프로필 조회 | 인증된 사용자 정보 조회 | +| 기본값 설정 | PreSignUp 트리거로 nickname, level, profileUrl 자동 설정 | + +### 1.3 기술 스택 + +| 구분 | 기술 | +|------|------| +| Platform | AWS Lambda (Serverless) | +| Language | Java 21 (Eclipse Temurin) | +| Authentication | AWS Cognito User Pool | +| Authorization | Cognito Built-in Authorizer | +| Database | AWS DynamoDB (Single Table Design) | +| Storage | AWS S3 (프로필 이미지) | + +--- + +## 2. 시스템 아키텍처 + +### 2.1 전체 구조 + +```mermaid +flowchart TB + subgraph Client + WEB[Web Client] + end + + subgraph AWS_Cognito["AWS Cognito"] + UP[User Pool] + UPC[User Pool Client] + TRIGGER[PreSignUp Trigger] + end + + subgraph AWS_Gateway["API Gateway"] + REST[REST API] + AUTH[Cognito Authorizer] + end + + subgraph Lambda["AWS Lambda"] + PRESIGNUP[PreSignUpHandler] + USER_H[UserHandler] + end + + subgraph Data_Layer["Data Layer"] + DYNAMO[(DynamoDB UserTable)] + S3[(S3 Profile Images)] + end + + WEB --> UP + UP --> TRIGGER + TRIGGER --> PRESIGNUP + UP --> UPC + + WEB --> REST + REST --> AUTH + AUTH -->|Token Validation| UP + AUTH -->|Claims| USER_H + + USER_H --> DYNAMO + USER_H --> S3 +``` + +### 2.2 레이어 아키텍처 + +```mermaid +flowchart TB + subgraph Presentation["Presentation Layer"] + HANDLER[Lambda Handlers] + DTO[Request/Response DTOs] + end + + subgraph Application["Application Layer"] + SERVICE[UserService] + end + + subgraph Domain["Domain Layer"] + MODEL[User Model] + REPO[UserRepository] + end + + subgraph Infrastructure["Infrastructure Layer"] + DYNAMO_CLIENT[DynamoDB Enhanced Client] + S3_CLIENT[S3 Client] + COGNITO[Cognito User Pool] + end + + HANDLER --> SERVICE + SERVICE --> MODEL + MODEL --> REPO + REPO --> DYNAMO_CLIENT + SERVICE --> S3_CLIENT +``` + +### 2.3 회원가입 흐름 + +```mermaid +sequenceDiagram + participant C as Client + participant COG as Cognito + participant TRIGGER as PreSignUpHandler + participant SES as AWS SES + + C->>COG: sign-up (email, password) + COG->>TRIGGER: PreSignUp Event + Note over TRIGGER: userAttributes 추출 + TRIGGER->>TRIGGER: 기본값 설정 + Note over TRIGGER: nickname: UUID 6자 + "님"
custom:level: BEGINNER
custom:profileUrl: 기본 이미지 + TRIGGER-->>COG: Modified Attributes + COG->>COG: Create User (UNCONFIRMED) + COG->>SES: 인증 코드 이메일 발송 + SES-->>C: 6자리 인증 코드 + C->>COG: confirm-sign-up (code) + COG->>COG: User Status → CONFIRMED + COG-->>C: 가입 완료 +``` + +### 2.4 로그인 및 토큰 발급 흐름 + +```mermaid +sequenceDiagram + participant C as Client + participant COG as Cognito + participant GW as API Gateway + participant AUTH as Cognito Authorizer + participant H as UserHandler + + C->>COG: initiate-auth (email, password) + COG->>COG: Validate Credentials + COG-->>C: AuthenticationResult + Note over C,COG: IdToken (1시간)
AccessToken (1시간)
RefreshToken (30일) + + C->>GW: GET /users/me + Note over C,GW: Authorization: Bearer {IdToken} + GW->>AUTH: Token Validation + AUTH->>COG: Verify JWT Signature + COG-->>AUTH: Valid + AUTH->>AUTH: Extract Claims + Note over AUTH: sub, email, nickname,
custom:level, custom:profileUrl + AUTH-->>GW: Claims 전달 + GW->>H: Request + Claims + H->>H: claims.get("sub"), claims.get("email")... + H-->>GW: User Info Response + GW-->>C: 200 OK +``` + +### 2.5 토큰 갱신 흐름 + +```mermaid +sequenceDiagram + participant C as Client + participant COG as Cognito + + Note over C: IdToken 만료 (1시간 후) + C->>COG: initiate-auth (REFRESH_TOKEN_AUTH) + Note over C,COG: RefreshToken 전달 + COG->>COG: Validate RefreshToken + COG-->>C: New AuthenticationResult + Note over C,COG: 새로운 IdToken
새로운 AccessToken
(RefreshToken은 동일) +``` + +--- + +## 3. 데이터 모델 + +### 3.1 Cognito User Attributes + +| Attribute | Type | Required | Mutable | 설명 | +|-----------|------|----------|---------|------| +| sub | Standard | Y | N | Cognito 고유 ID (UUID) | +| email | Standard | Y | N | 이메일 (로그인 ID) | +| email_verified | Standard | Y | N | 이메일 인증 여부 | +| nickname | Standard | N | Y | 닉네임 | +| custom:level | Custom | N | Y | 학습 난이도 (BEGINNER/INTERMEDIATE/ADVANCED) | +| custom:profileUrl | Custom | N | Y | 프로필 이미지 URL | + + +### 3.2 ERD (DynamoDB - 향후 확장용) + +```mermaid +erDiagram + UserTable ||--o{ User : contains + + User { + string partitionKey "USER#{cognitoSub}" + string sortKey "METADATA" + string gsi1PartitionKey "EMAIL#{email}" + string gsi1SortKey "USER#{cognitoSub}" + string gsi2PartitionKey "LEVEL#{level}" + string gsi2SortKey "USER#{cognitoSub}" + string cognitoSub "Cognito UUID" + string email "이메일" + string nickname "닉네임" + string level "BEGINNER/INTERMEDIATE/ADVANCED" + string profileUrl "프로필 이미지 URL" + string createdAt "생성 시각" + string updatedAt "수정 시각" + string lastLoginAt "마지막 로그인" + long ttl "자동 만료" + } +``` + +| 필드 | 패턴 | 설명 | +|------|------|------| +| PK | USER#{cognitoSub} | 파티션 키 | +| SK | METADATA | 정렬 키 | +| GSI1PK | EMAIL#{email} | 이메일 조회용 | +| GSI1SK | USER#{cognitoSub} | - | +| GSI2PK | LEVEL#{level} | 레벨별 조회용 | +| GSI2SK | USER#{cognitoSub} | - | + +### 3.3 GSI (Global Secondary Index) 설계 + +```mermaid +flowchart LR + subgraph GSI1["GSI1: 이메일 조회"] + G1_EMAIL["EMAIL#{email} → 사용자 조회"] + end + + subgraph GSI2["GSI2: 레벨별 조회"] + G2_LEVEL["LEVEL#{level} → 레벨별 사용자"] + end +``` + +--- + +## 4. API 명세 + +### 4.1 인증 (Cognito SDK 직접 호출) + +#### 회원가입 (sign-up) + +```bash +aws cognito-idp sign-up \ + --client-id {CognitoClientId} \ + --username {EMAIL} \ + --password {PASSWORD} \ + --user-attributes Name=email,Value={EMAIL} +``` + +**Response (Success)** + +```json +{ + "UserConfirmed": false, + "CodeDeliveryDetails": { + "Destination": "h***@g***.com", + "DeliveryMedium": "EMAIL", + "AttributeName": "email" + }, + "UserSub": "d4088d7c-e0f1-70bd-3b7a-eb8b812e3ae4" +} +``` + +#### 이메일 인증 (confirm-sign-up) + +```bash +aws cognito-idp confirm-sign-up \ + --client-id {CognitoClientId} \ + --username {EMAIL} \ + --confirmation-code {6자리 코드} +``` + +#### 로그인 (initiate-auth) + +```bash +aws cognito-idp initiate-auth \ + --client-id {CognitoClientId} \ + --auth-flow USER_PASSWORD_AUTH \ + --auth-parameters USERNAME={EMAIL},PASSWORD={PASSWORD} +``` + +**Response (Success)** + +```json +{ + "ChallengeParameters": {}, + "AuthenticationResult": { + "AccessToken": "eyJraWQiOiJ1Y2F1aEZCT0o5djV0c29q...", + "ExpiresIn": 3600, + "TokenType": "Bearer", + "RefreshToken": "eyJjdHkiOiJKV1QiLCJlbmMiOiJBMjU2R0NNIi...", + "IdToken": "eyJraWQiOiJPbFAzMHFxZUxpK1VGMSs4SElVVnBN..." + } +} +``` + +**IdToken Payload (Decoded)** + +```json +{ + "sub": "d4088d7c-e0f1-70bd-3b7a-eb8b812e3ae4", + "email_verified": true, + "iss": "https://cognito-idp.ap-northeast-2.amazonaws.com/ap-northeast-2_ezDwzFCzR", + "cognito:username": "d4088d7c-e0f1-70bd-3b7a-eb8b812e3ae4", + "aud": "4ns077jcr1pkue2vvisr6qdpu5", + "event_id": "1a9b6343-fd03-4de3-b1c1-db52131442d4", + "token_use": "id", + "auth_time": 1768103576, + "exp": 1768107176, + "iat": 1768103576, + "email": "hye.ina0130@gmail.com" +} +``` + +#### 토큰 갱신 (refresh-token) + +```bash +aws cognito-idp initiate-auth \ + --client-id 4ns077jcr1pkue2vvisr6qdpu5 \ + --auth-flow REFRESH_TOKEN_AUTH \ + --auth-parameters REFRESH_TOKEN={REFRESH_TOKEN} +``` + +### 4.2 프로필 API + +#### GET /users/profile/me - 내 정보 조회 + +**Headers** + +| Header | 값 | 필수 | +|--------|------|------| +| Authorization | Bearer {IdToken} | Y | + +**Response (200 OK)** + +```json +{ + "success": true, + "message": "A7K2X9님 환영합니다!", + "data": { + "userId": "d4088d7c-e0f1-70bd-3b7a-eb8b812e3ae4", + "email": "hye.ina0130@gmail.com", + "nickname": "A7K2X9님" + } +} +``` + +--- + +## 5. 비즈니스 규칙 + +### 5.1 회원가입 기본값 (PreSignUp Trigger) + +| 항목 | 조건 | 기본값 | 예시 | +|------|------|--------|------| +| nickname | null 또는 빈 문자열 | UUID 6자 + "님" | "A7K2X9님" | +| custom:level | null 또는 빈 문자열 | BEGINNER | "BEGINNER" | +| custom:profileUrl | null 또는 빈 문자열 | S3 기본 이미지 | https://group2-englishstudy.s3.amazonaws.com/profile/default.png | + +### 5.2 비밀번호 정책 (Cognito) + +| 항목 | 요구사항 | +|------|----------| +| 최소 길이 | 8자 | +| 소문자 | 1개 이상 | +| 숫자 | 1개 이상 | +| 특수문자 | 1개 이상 | + + +### 5.3 토큰 유효 시간 + +| 토큰 | 유효 시간 | 용도 | +|------|----------|------| +| IdToken | 1시간 (3600초) | API 인증, 사용자 정보 | +| AccessToken | 1시간 (3600초) | Cognito API 호출 | +| RefreshToken | 30일 | 토큰 갱신 | + + +--- + +## 6. 에러 코드 + +### 6.1 Cognito 에러 + +| Error Code | HTTP | 설명 | 해결 방법 | +|------------|------|------|----------| +| UsernameExistsException | 400 | 이미 존재하는 이메일 | 다른 이메일 사용 | +| InvalidPasswordException | 400 | 비밀번호 정책 미충족 | 정책에 맞는 비밀번호 | +| CodeMismatchException | 400 | 인증 코드 불일치 | 올바른 코드 입력 | +| ExpiredCodeException | 400 | 인증 코드 만료 | 재발송 요청 | +| NotAuthorizedException | 401 | 비밀번호 틀림 | 올바른 비밀번호 | +| UserNotConfirmedException | 400 | 이메일 미인증 | 인증 완료 필요 | +| UserNotFoundException | 400 | 존재하지 않는 사용자 | 회원가입 필요 | + +### 6.2 API 에러 + +| HTTP Code | Error Code | 메시지 | +|-----------|------------|--------| +| 401 | AUTH_001 | 인증이 필요합니다 | +| 401 | AUTH_003 | 유효하지 않은 토큰입니다 | +| 401 | AUTH_004 | 토큰이 만료되었습니다 | +| 500 | SYSTEM_001 | 내부 서버 오류가 발생했습니다 | + +### 6.3 에러 응답 형식 + +```json +{ + "success": false, + "error": { + "code": "AUTH_003", + "message": "유효하지 않은 토큰입니다" + } +} +``` + +--- + +## 7. 환경 설정 + +### 7.1 Cognito User Pool (template.yaml) + +```yaml +CognitoUserPool: + Type: AWS::Cognito::UserPool + Properties: + UserPoolName: group2-englishstudy-userpool + AutoVerifiedAttributes: + - email + UsernameAttributes: + - email + Policies: + PasswordPolicy: + MinimumLength: 8 + RequireUppercase: false + RequireLowercase: true + RequireNumbers: true + RequireSymbols: true + Schema: + - Name: email + Required: true + Mutable: false + - Name: nickname + Mutable: true + - Name: level + AttributeDataType: String + Mutable: true + - Name: profileUrl + AttributeDataType: String + Mutable: true + LambdaConfig: + PreSignUp: !GetAtt PreSignUpFunction.Arn +``` + +### 7.2 Cognito User Pool Client + +```yaml +CognitoUserPoolClient: + Type: AWS::Cognito::UserPoolClient + Properties: + ClientName: group2-englishstudy-client + UserPoolId: !Ref CognitoUserPool + GenerateSecret: false + ExplicitAuthFlows: + - ALLOW_USER_PASSWORD_AUTH + - ALLOW_REFRESH_TOKEN_AUTH + ReadAttributes: + - email + - nickname + - custom:level + - custom:profileUrl + WriteAttributes: + - email + - nickname + - custom:level + - custom:profileUrl +``` + +### 7.3 API Gateway Cognito Authorizer + +```yaml +MainApi: + Type: AWS::Serverless::Api + Properties: + Auth: + DefaultAuthorizer: CognitoAuthorizer + Authorizers: + CognitoAuthorizer: + UserPoolArn: !GetAtt CognitoUserPool.Arn +``` + +### 7.4 환경 변수 + +```yaml +Environment: + Variables: + USER_TABLE_NAME: !Ref UserTable + DEFAULT_PROFILE_URL: https://group2-englishstudy.s3.amazonaws.com/profile/default.png +``` + +--- + +## 8. 프로젝트 구조 + +``` +domain/user/ +├── handler/ +│ ├── PreSignUpHandler.java # Cognito PreSignUp 트리거 +│ └── UserHandler.java # REST API - 프로필 조회/수정 +│ +├── service/ +│ └── UserService.java # 비즈니스 로직 +│ +├── repository/ +│ └── UserRepository.java # DynamoDB 데이터 접근 +│ +├── model/ +│ └── User.java # 사용자 엔티티 +│ +└── dto/ + +``` + +--- + +## 9. 구현 현황 + +### Phase 1 - Cognito 인증 (완료) + +- [x] Cognito User Pool 생성 +- [x] Cognito User Pool Client 생성 +- [x] PreSignUp Lambda 트리거 (기본값 설정) +- [x] Cognito Built-in Authorizer 연결 +- [x] 회원가입/이메일인증/로그인 테스트 +- [x] UserHandler (claims 추출) + +### Phase 2 - 프로필 관리 (예정) + +- [ ] GET /users/profile/me - 내 프로필 상세 조회 +- [ ] PUT /users/profile/me - 프로필 수정 (닉네임, 레벨) +- [ ] POST /users/profile/me/image - 프로필 이미지 업로드 (S3) +- [ ] DynamoDB에 추가 사용자 정보 저장 + +### Phase 3 - 추가 기능 (예정) + +- [ ] 비밀번호 변경 +- [ ] 비밀번호 찾기 +- [ ] 회원 탈퇴 (delete-user) +- [ ] 학습 통계 연동 (Vocabulary Domain) +- [ ] 사용자 설정 (알림, 학습 목표, 일일 학습량) + +### Phase 4 - 최적화 + +- [ ] 소셜 로그인 (Kakao, Google, Apple) +- [ ] SNS-SQS Fan-out 패턴 (이메일 발송 비동기 처리) +- [ ] 이메일 타임아웃 방안 SQS 마련 +- [ ] S3 이벤트 트리거 - 이미지 리사이징 패턴 + + + +--- + +**버전**: 1.0.0 +**최종 업데이트**: 2026-01-11 +**작성자**: hye-inA +**팀**: MZC 2nd Project Team \ No newline at end of file From 1919debe4548e5765e0de82a323dc1e28cddab45 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Mon, 12 Jan 2026 14:46:04 +0900 Subject: [PATCH 120/528] =?UTF-8?q?feat(vocab):=20Vocabulary=20API=20Cogni?= =?UTF-8?q?to=20=EC=9D=B8=EC=A6=9D=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Vocabulary API에 Cognito Authorizer 활성화 - CognitoUtil 유틸리티 클래스 추가 (토큰에서 userId 추출) - API 엔드포인트 경로에서 userId 제거 - AuthenticatedHandler 인터페이스로 Handler 중복 코드 제거 Closes #185, #188, #189, #190 --- .../common/router/AuthenticatedHandler.java | 14 ++ .../serverless/common/router/Route.java | 49 +++++++ .../serverless/common/util/CognitoUtil.java | 131 ++++++++++++++++++ .../vocabulary/handler/DailyStudyHandler.java | 12 +- .../vocabulary/handler/StatsHandler.java | 17 +-- .../vocabulary/handler/TestHandler.java | 22 ++- .../vocabulary/handler/UserWordHandler.java | 25 ++-- .../vocabulary/handler/WordGroupHandler.java | 45 +++--- ServerlessFunction/template.yaml | 126 ++++++++--------- 9 files changed, 306 insertions(+), 135 deletions(-) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/AuthenticatedHandler.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/CognitoUtil.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/AuthenticatedHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/AuthenticatedHandler.java new file mode 100644 index 00000000..1a8efa0b --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/AuthenticatedHandler.java @@ -0,0 +1,14 @@ +package com.mzc.secondproject.serverless.common.router; + +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; + +/** + * Cognito 인증이 필요한 요청 핸들러 + * + * userId가 자동으로 추출되어 전달됩니다. + */ +@FunctionalInterface +public interface AuthenticatedHandler { + APIGatewayProxyResponseEvent handle(APIGatewayProxyRequestEvent request, String userId); +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/Route.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/Route.java index 181b8d79..29bfb234 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/Route.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/Route.java @@ -2,6 +2,7 @@ import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import com.mzc.secondproject.serverless.common.util.CognitoUtil; import java.util.ArrayList; import java.util.Collections; @@ -63,6 +64,54 @@ public static Route patch(String pathPattern, Function handler.handle(request, CognitoUtil.extractUserId(request)), + extractPathParams(pathPattern), List.of()); + } + + /** + * Cognito 인증이 필요한 POST 라우트 + */ + public static Route postAuth(String pathPattern, AuthenticatedHandler handler) { + return new Route("POST", pathPattern, + request -> handler.handle(request, CognitoUtil.extractUserId(request)), + extractPathParams(pathPattern), List.of()); + } + + /** + * Cognito 인증이 필요한 PUT 라우트 + */ + public static Route putAuth(String pathPattern, AuthenticatedHandler handler) { + return new Route("PUT", pathPattern, + request -> handler.handle(request, CognitoUtil.extractUserId(request)), + extractPathParams(pathPattern), List.of()); + } + + /** + * Cognito 인증이 필요한 DELETE 라우트 + */ + public static Route deleteAuth(String pathPattern, AuthenticatedHandler handler) { + return new Route("DELETE", pathPattern, + request -> handler.handle(request, CognitoUtil.extractUserId(request)), + extractPathParams(pathPattern), List.of()); + } + + /** + * Cognito 인증이 필요한 PATCH 라우트 + */ + public static Route patchAuth(String pathPattern, AuthenticatedHandler handler) { + return new Route("PATCH", pathPattern, + request -> handler.handle(request, CognitoUtil.extractUserId(request)), + extractPathParams(pathPattern), List.of()); + } + /** * 필수 Query 파라미터 추가 */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/CognitoUtil.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/CognitoUtil.java new file mode 100644 index 00000000..b50bd33a --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/CognitoUtil.java @@ -0,0 +1,131 @@ +package com.mzc.secondproject.serverless.common.util; + +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; +import com.mzc.secondproject.serverless.common.exception.CommonErrorCode; + +import java.util.Map; +import java.util.Optional; + +/** + * Cognito Authorizer에서 사용자 정보를 추출하는 유틸리티 클래스 + */ +public class CognitoUtil { + + private CognitoUtil() { + // 유틸리티 클래스 인스턴스화 방지 + } + + /** + * Cognito claims에서 userId(sub)를 추출 + * + * @param request API Gateway 요청 + * @return userId (Cognito sub) + * @throws IllegalStateException claims가 없거나 sub가 없는 경우 + */ + public static String extractUserId(APIGatewayProxyRequestEvent request) { + return extractClaim(request, "sub") + .orElseThrow(() -> new IllegalStateException("Cognito sub claim not found")); + } + + /** + * Cognito claims에서 email을 추출 + * + * @param request API Gateway 요청 + * @return email (Optional) + */ + public static Optional extractEmail(APIGatewayProxyRequestEvent request) { + return extractClaim(request, "email"); + } + + /** + * Cognito claims에서 nickname을 추출 + * + * @param request API Gateway 요청 + * @return nickname (Optional) + */ + public static Optional extractNickname(APIGatewayProxyRequestEvent request) { + return extractClaim(request, "custom:nickname"); + } + + /** + * Cognito claims에서 특정 claim을 추출 + * + * @param request API Gateway 요청 + * @param claimName claim 이름 + * @return claim 값 (Optional) + */ + @SuppressWarnings("unchecked") + public static Optional extractClaim(APIGatewayProxyRequestEvent request, String claimName) { + try { + Map authorizer = request.getRequestContext().getAuthorizer(); + if (authorizer == null) { + return Optional.empty(); + } + + Map claims = (Map) authorizer.get("claims"); + if (claims == null) { + return Optional.empty(); + } + + return Optional.ofNullable(claims.get(claimName)); + } catch (Exception e) { + return Optional.empty(); + } + } + + /** + * 경로 파라미터의 userId와 Cognito userId가 일치하는지 검증 + * + * @param request API Gateway 요청 + * @param pathUserId 경로 파라미터에서 추출한 userId + * @return 일치 여부 + */ + public static boolean validateUserAccess(APIGatewayProxyRequestEvent request, String pathUserId) { + try { + String cognitoUserId = extractUserId(request); + return cognitoUserId.equals(pathUserId); + } catch (Exception e) { + return false; + } + } + + /** + * 경로 파라미터와 Cognito userId를 검증하고, 유효한 경우 userId 반환 + * 검증 실패 시 Optional.empty() 반환 + * + * @param request API Gateway 요청 + * @param pathUserId 경로 파라미터에서 추출한 userId + * @return 검증된 userId (Optional) + */ + public static Optional validateAndExtractUserId(APIGatewayProxyRequestEvent request, String pathUserId) { + try { + String cognitoUserId = extractUserId(request); + if (cognitoUserId.equals(pathUserId)) { + return Optional.of(cognitoUserId); + } + return Optional.empty(); + } catch (Exception e) { + return Optional.empty(); + } + } + + /** + * 경로 파라미터에서 userId를 추출하고 Cognito 토큰과 검증 + * Handler에서 간편하게 사용할 수 있는 메서드 + * + * @param request API Gateway 요청 + * @return 검증된 userId (Optional) + */ + public static Optional getValidatedUserId(APIGatewayProxyRequestEvent request) { + String pathUserId = request.getPathParameters() != null + ? request.getPathParameters().get("userId") + : null; + + if (pathUserId == null) { + // 경로에 userId가 없으면 토큰에서만 추출 + return extractClaim(request, "sub"); + } + + return validateAndExtractUserId(request, pathUserId); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/DailyStudyHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/DailyStudyHandler.java index 3d804330..fa33ee4f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/DailyStudyHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/DailyStudyHandler.java @@ -32,8 +32,8 @@ public DailyStudyHandler() { private HandlerRouter initRouter() { return new HandlerRouter().addRoutes( - Route.post("/daily/{userId}/words/{wordId}/learned", this::markWordLearned), - Route.get("/daily/{userId}", this::getDailyWords) + Route.postAuth("/daily/words/{wordId}/learned", this::markWordLearned), + Route.getAuth("/daily", this::getDailyWords) ); } @@ -43,8 +43,7 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent re return router.route(request); } - private APIGatewayProxyResponseEvent getDailyWords(APIGatewayProxyRequestEvent request) { - String userId = request.getPathParameters().get("userId"); + private APIGatewayProxyResponseEvent getDailyWords(APIGatewayProxyRequestEvent request, String userId) { Map queryParams = request.getQueryStringParameters(); String date = queryParams != null ? queryParams.get("date") : null; @@ -71,7 +70,7 @@ private APIGatewayProxyResponseEvent getDailyStudyByDate(String userId, String d var optDailyStudy = queryService.getDailyStudy(userId, date); if (optDailyStudy.isEmpty()) { - return ResponseGenerator.fail(VocabularyErrorCode.DAILY_STUDY_NOT_FOUND, "No daily study found for date: " + date); + return ResponseGenerator.fail(VocabularyErrorCode.DAILY_STUDY_NOT_FOUND); } var dailyStudy = optDailyStudy.get(); @@ -88,8 +87,7 @@ private APIGatewayProxyResponseEvent getDailyStudyByDate(String userId, String d return ResponseGenerator.ok("Daily study retrieved for " + date, response); } - private APIGatewayProxyResponseEvent markWordLearned(APIGatewayProxyRequestEvent request) { - String userId = request.getPathParameters().get("userId"); + private APIGatewayProxyResponseEvent markWordLearned(APIGatewayProxyRequestEvent request, String userId) { String wordId = request.getPathParameters().get("wordId"); Map progress = commandService.markWordLearned(userId, wordId); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatsHandler.java index f61f6a67..b2974c4d 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatsHandler.java @@ -28,9 +28,9 @@ public StatsHandler() { private HandlerRouter initRouter() { return new HandlerRouter().addRoutes( - Route.get("/stats/{userId}/weakness", this::getWeaknessAnalysis), - Route.get("/stats/{userId}/daily", this::getDailyStats), - Route.get("/stats/{userId}", this::getOverallStats) + Route.getAuth("/stats/weakness", this::getWeaknessAnalysis), + Route.getAuth("/stats/daily", this::getDailyStats), + Route.getAuth("/stats", this::getOverallStats) ); } @@ -40,15 +40,12 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent re return router.route(request); } - private APIGatewayProxyResponseEvent getOverallStats(APIGatewayProxyRequestEvent request) { - String userId = request.getPathParameters().get("userId"); - + private APIGatewayProxyResponseEvent getOverallStats(APIGatewayProxyRequestEvent request, String userId) { Map stats = statsService.getOverallStats(userId); return ResponseGenerator.ok("Stats retrieved", stats); } - private APIGatewayProxyResponseEvent getDailyStats(APIGatewayProxyRequestEvent request) { - String userId = request.getPathParameters().get("userId"); + private APIGatewayProxyResponseEvent getDailyStats(APIGatewayProxyRequestEvent request, String userId) { Map queryParams = request.getQueryStringParameters(); String cursor = queryParams != null ? queryParams.get("cursor") : null; @@ -67,9 +64,7 @@ private APIGatewayProxyResponseEvent getDailyStats(APIGatewayProxyRequestEvent r return ResponseGenerator.ok("Daily stats retrieved", result); } - private APIGatewayProxyResponseEvent getWeaknessAnalysis(APIGatewayProxyRequestEvent request) { - String userId = request.getPathParameters().get("userId"); - + private APIGatewayProxyResponseEvent getWeaknessAnalysis(APIGatewayProxyRequestEvent request, String userId) { Map analysis = statsService.getWeaknessAnalysis(userId); return ResponseGenerator.ok("Weakness analysis completed", analysis); } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/TestHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/TestHandler.java index 8330b8b8..9d0f8ae7 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/TestHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/TestHandler.java @@ -37,10 +37,10 @@ public TestHandler() { private HandlerRouter initRouter() { return new HandlerRouter().addRoutes( - Route.post("/test/{userId}/start", this::startTest), - Route.post("/test/{userId}/submit", this::submitAnswer), - Route.get("/test/{userId}/results/{testId}", this::getTestResultDetail), - Route.get("/test/{userId}/results", this::getTestResults) + Route.postAuth("/test/start", this::startTest), + Route.postAuth("/test/submit", this::submitAnswer), + Route.getAuth("/test/results/{testId}", this::getTestResultDetail), + Route.getAuth("/test/results", this::getTestResults) ); } @@ -50,8 +50,7 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent re return router.route(request); } - private APIGatewayProxyResponseEvent startTest(APIGatewayProxyRequestEvent request) { - String userId = request.getPathParameters().get("userId"); + private APIGatewayProxyResponseEvent startTest(APIGatewayProxyRequestEvent request, String userId) { StartTestRequest req = ResponseGenerator.gson().fromJson(request.getBody(), StartTestRequest.class); String testType = req != null && req.getTestType() != null ? req.getTestType() : "DAILY"; @@ -67,8 +66,7 @@ private APIGatewayProxyResponseEvent startTest(APIGatewayProxyRequestEvent reque return ResponseGenerator.ok("Test started", response); } - private APIGatewayProxyResponseEvent submitAnswer(APIGatewayProxyRequestEvent request) { - String userId = request.getPathParameters().get("userId"); + private APIGatewayProxyResponseEvent submitAnswer(APIGatewayProxyRequestEvent request, String userId) { SubmitTestRequest req = ResponseGenerator.gson().fromJson(request.getBody(), SubmitTestRequest.class); return BeanValidator.validateAndExecute(req, dto -> { @@ -90,8 +88,7 @@ private APIGatewayProxyResponseEvent submitAnswer(APIGatewayProxyRequestEvent re }); } - private APIGatewayProxyResponseEvent getTestResults(APIGatewayProxyRequestEvent request) { - String userId = request.getPathParameters().get("userId"); + private APIGatewayProxyResponseEvent getTestResults(APIGatewayProxyRequestEvent request, String userId) { Map queryParams = request.getQueryStringParameters(); String cursor = queryParams != null ? queryParams.get("cursor") : null; @@ -110,13 +107,12 @@ private APIGatewayProxyResponseEvent getTestResults(APIGatewayProxyRequestEvent return ResponseGenerator.ok("Test results retrieved", result); } - private APIGatewayProxyResponseEvent getTestResultDetail(APIGatewayProxyRequestEvent request) { - String userId = request.getPathParameters().get("userId"); + private APIGatewayProxyResponseEvent getTestResultDetail(APIGatewayProxyRequestEvent request, String userId) { String testId = request.getPathParameters().get("testId"); var optDetail = queryService.getTestResultDetail(userId, testId); if (optDetail.isEmpty()) { - return ResponseGenerator.fail(CommonErrorCode.RESOURCE_NOT_FOUND,"Test result not found"); + return ResponseGenerator.fail(CommonErrorCode.RESOURCE_NOT_FOUND); } var detail = optDetail.get(); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java index 31223f6d..e252e1df 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java @@ -38,11 +38,11 @@ public UserWordHandler() { private HandlerRouter initRouter() { return new HandlerRouter().addRoutes( - Route.get("/users/{userId}/wrong-answers", this::getWrongAnswers), - Route.get("/users/{userId}/words", this::getUserWords), - Route.get("/users/{userId}/words/{wordId}", this::getUserWord), - Route.put("/users/{userId}/words/{wordId}/tag", this::updateUserWordTag), - Route.put("/users/{userId}/words/{wordId}", this::updateUserWord) + Route.getAuth("/wrong-answers", this::getWrongAnswers), + Route.getAuth("/words", this::getUserWords), + Route.getAuth("/words/{wordId}", this::getUserWord), + Route.putAuth("/words/{wordId}/tag", this::updateUserWordTag), + Route.putAuth("/words/{wordId}", this::updateUserWord) ); } @@ -52,8 +52,7 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent re return router.route(request); } - private APIGatewayProxyResponseEvent getUserWords(APIGatewayProxyRequestEvent request) { - String userId = request.getPathParameters().get("userId"); + private APIGatewayProxyResponseEvent getUserWords(APIGatewayProxyRequestEvent request, String userId) { Map queryParams = request.getQueryStringParameters(); String status = queryParams != null ? queryParams.get("status") : null; @@ -76,8 +75,7 @@ private APIGatewayProxyResponseEvent getUserWords(APIGatewayProxyRequestEvent re return ResponseGenerator.ok("User words retrieved", response); } - private APIGatewayProxyResponseEvent getUserWord(APIGatewayProxyRequestEvent request) { - String userId = request.getPathParameters().get("userId"); + private APIGatewayProxyResponseEvent getUserWord(APIGatewayProxyRequestEvent request, String userId) { String wordId = request.getPathParameters().get("wordId"); Optional optUserWord = queryService.getUserWord(userId, wordId); @@ -88,8 +86,7 @@ private APIGatewayProxyResponseEvent getUserWord(APIGatewayProxyRequestEvent req return ResponseGenerator.ok("UserWord retrieved", optUserWord.get()); } - private APIGatewayProxyResponseEvent updateUserWord(APIGatewayProxyRequestEvent request) { - String userId = request.getPathParameters().get("userId"); + private APIGatewayProxyResponseEvent updateUserWord(APIGatewayProxyRequestEvent request, String userId) { String wordId = request.getPathParameters().get("wordId"); UpdateUserWordRequest req = ResponseGenerator.gson().fromJson(request.getBody(), UpdateUserWordRequest.class); @@ -99,8 +96,7 @@ private APIGatewayProxyResponseEvent updateUserWord(APIGatewayProxyRequestEvent }); } - private APIGatewayProxyResponseEvent updateUserWordTag(APIGatewayProxyRequestEvent request) { - String userId = request.getPathParameters().get("userId"); + private APIGatewayProxyResponseEvent updateUserWordTag(APIGatewayProxyRequestEvent request, String userId) { String wordId = request.getPathParameters().get("wordId"); UpdateUserWordTagRequest req = ResponseGenerator.gson().fromJson(request.getBody(), UpdateUserWordTagRequest.class); @@ -108,8 +104,7 @@ private APIGatewayProxyResponseEvent updateUserWordTag(APIGatewayProxyRequestEve return ResponseGenerator.ok("Tag updated", userWord); } - private APIGatewayProxyResponseEvent getWrongAnswers(APIGatewayProxyRequestEvent request) { - String userId = request.getPathParameters().get("userId"); + private APIGatewayProxyResponseEvent getWrongAnswers(APIGatewayProxyRequestEvent request, String userId) { Map queryParams = request.getQueryStringParameters(); String cursor = queryParams != null ? queryParams.get("cursor") : null; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/WordGroupHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/WordGroupHandler.java index 94131f46..64c53af7 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/WordGroupHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/WordGroupHandler.java @@ -36,13 +36,13 @@ public WordGroupHandler() { private HandlerRouter initRouter() { return new HandlerRouter().addRoutes( - Route.post("/users/{userId}/groups", this::createGroup), - Route.get("/users/{userId}/groups", this::getGroups), - Route.get("/users/{userId}/groups/{groupId}", this::getGroupDetail), - Route.put("/users/{userId}/groups/{groupId}", this::updateGroup), - Route.delete("/users/{userId}/groups/{groupId}", this::deleteGroup), - Route.post("/users/{userId}/groups/{groupId}/words/{wordId}", this::addWordToGroup), - Route.delete("/users/{userId}/groups/{groupId}/words/{wordId}", this::removeWordFromGroup) + Route.postAuth("/groups", this::createGroup), + Route.getAuth("/groups", this::getGroups), + Route.getAuth("/groups/{groupId}", this::getGroupDetail), + Route.putAuth("/groups/{groupId}", this::updateGroup), + Route.deleteAuth("/groups/{groupId}", this::deleteGroup), + Route.postAuth("/groups/{groupId}/words/{wordId}", this::addWordToGroup), + Route.deleteAuth("/groups/{groupId}/words/{wordId}", this::removeWordFromGroup) ); } @@ -52,8 +52,7 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent re return router.route(request); } - private APIGatewayProxyResponseEvent createGroup(APIGatewayProxyRequestEvent request) { - String userId = request.getPathParameters().get("userId"); + private APIGatewayProxyResponseEvent createGroup(APIGatewayProxyRequestEvent request, String userId) { CreateWordGroupRequest req = ResponseGenerator.gson().fromJson(request.getBody(), CreateWordGroupRequest.class); return BeanValidator.validateAndExecute(req, dto -> { @@ -62,8 +61,7 @@ private APIGatewayProxyResponseEvent createGroup(APIGatewayProxyRequestEvent req }); } - private APIGatewayProxyResponseEvent getGroups(APIGatewayProxyRequestEvent request) { - String userId = request.getPathParameters().get("userId"); + private APIGatewayProxyResponseEvent getGroups(APIGatewayProxyRequestEvent request, String userId) { Map queryParams = request.getQueryStringParameters(); String cursor = queryParams != null ? queryParams.get("cursor") : null; @@ -79,13 +77,12 @@ private APIGatewayProxyResponseEvent getGroups(APIGatewayProxyRequestEvent reque return ResponseGenerator.ok("Groups retrieved", response); } - private APIGatewayProxyResponseEvent getGroupDetail(APIGatewayProxyRequestEvent request) { - String userId = request.getPathParameters().get("userId"); + private APIGatewayProxyResponseEvent getGroupDetail(APIGatewayProxyRequestEvent request, String userId) { String groupId = request.getPathParameters().get("groupId"); var optDetail = queryService.getGroupDetail(userId, groupId); if (optDetail.isEmpty()) { - return ResponseGenerator.fail(CommonErrorCode.RESOURCE_NOT_FOUND,"Group not found"); + return ResponseGenerator.fail(CommonErrorCode.RESOURCE_NOT_FOUND); } var detail = optDetail.get(); @@ -102,8 +99,7 @@ private APIGatewayProxyResponseEvent getGroupDetail(APIGatewayProxyRequestEvent return ResponseGenerator.ok("Group detail retrieved", response); } - private APIGatewayProxyResponseEvent updateGroup(APIGatewayProxyRequestEvent request) { - String userId = request.getPathParameters().get("userId"); + private APIGatewayProxyResponseEvent updateGroup(APIGatewayProxyRequestEvent request, String userId) { String groupId = request.getPathParameters().get("groupId"); CreateWordGroupRequest req = ResponseGenerator.gson().fromJson(request.getBody(), CreateWordGroupRequest.class); @@ -113,24 +109,22 @@ private APIGatewayProxyResponseEvent updateGroup(APIGatewayProxyRequestEvent req req != null ? req.getDescription() : null); return ResponseGenerator.ok("Group updated", group); } catch (IllegalArgumentException e) { - return ResponseGenerator.fail(CommonErrorCode.RESOURCE_NOT_FOUND,e.getMessage()); + return ResponseGenerator.fail(CommonErrorCode.RESOURCE_NOT_FOUND); } } - private APIGatewayProxyResponseEvent deleteGroup(APIGatewayProxyRequestEvent request) { - String userId = request.getPathParameters().get("userId"); + private APIGatewayProxyResponseEvent deleteGroup(APIGatewayProxyRequestEvent request, String userId) { String groupId = request.getPathParameters().get("groupId"); try { commandService.deleteGroup(userId, groupId); return ResponseGenerator.ok("Group deleted", null); } catch (IllegalArgumentException e) { - return ResponseGenerator.fail(CommonErrorCode.RESOURCE_NOT_FOUND,e.getMessage()); + return ResponseGenerator.fail(CommonErrorCode.RESOURCE_NOT_FOUND); } } - private APIGatewayProxyResponseEvent addWordToGroup(APIGatewayProxyRequestEvent request) { - String userId = request.getPathParameters().get("userId"); + private APIGatewayProxyResponseEvent addWordToGroup(APIGatewayProxyRequestEvent request, String userId) { String groupId = request.getPathParameters().get("groupId"); String wordId = request.getPathParameters().get("wordId"); @@ -138,12 +132,11 @@ private APIGatewayProxyResponseEvent addWordToGroup(APIGatewayProxyRequestEvent WordGroup group = commandService.addWordToGroup(userId, groupId, wordId); return ResponseGenerator.ok("Word added to group", group); } catch (IllegalArgumentException e) { - return ResponseGenerator.fail(CommonErrorCode.RESOURCE_NOT_FOUND,e.getMessage()); + return ResponseGenerator.fail(CommonErrorCode.RESOURCE_NOT_FOUND); } } - private APIGatewayProxyResponseEvent removeWordFromGroup(APIGatewayProxyRequestEvent request) { - String userId = request.getPathParameters().get("userId"); + private APIGatewayProxyResponseEvent removeWordFromGroup(APIGatewayProxyRequestEvent request, String userId) { String groupId = request.getPathParameters().get("groupId"); String wordId = request.getPathParameters().get("wordId"); @@ -151,7 +144,7 @@ private APIGatewayProxyResponseEvent removeWordFromGroup(APIGatewayProxyRequestE WordGroup group = commandService.removeWordFromGroup(userId, groupId, wordId); return ResponseGenerator.ok("Word removed from group", group); } catch (IllegalArgumentException e) { - return ResponseGenerator.fail(CommonErrorCode.RESOURCE_NOT_FOUND,e.getMessage()); + return ResponseGenerator.fail(CommonErrorCode.RESOURCE_NOT_FOUND); } } diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 86c762cc..48b0a6ca 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -541,42 +541,42 @@ Resources: Type: Api Properties: RestApiId: !Ref MainApi - Path: /vocab/users/{userId}/wrong-answers + Path: /vocab/wrong-answers Method: GET - # Auth: - # Authorizer: CognitoAuthorizer + Auth: + Authorizer: CognitoAuthorizer GetUserWords: Type: Api Properties: RestApiId: !Ref MainApi - Path: /vocab/users/{userId}/words + Path: /vocab/words Method: GET - # Auth: - # Authorizer: CognitoAuthorizer + Auth: + Authorizer: CognitoAuthorizer GetUserWord: Type: Api Properties: RestApiId: !Ref MainApi - Path: /vocab/users/{userId}/words/{wordId} + Path: /vocab/words/{wordId} Method: GET - # Auth: - # Authorizer: CognitoAuthorizer + Auth: + Authorizer: CognitoAuthorizer UpdateUserWord: Type: Api Properties: RestApiId: !Ref MainApi - Path: /vocab/users/{userId}/words/{wordId} + Path: /vocab/words/{wordId} Method: PUT - # Auth: - # Authorizer: CognitoAuthorizer + Auth: + Authorizer: CognitoAuthorizer UpdateUserWordTag: Type: Api Properties: RestApiId: !Ref MainApi - Path: /vocab/users/{userId}/words/{wordId}/tag + Path: /vocab/words/{wordId}/tag Method: PUT - # Auth: - # Authorizer: CognitoAuthorizer + Auth: + Authorizer: CognitoAuthorizer WordGroupFunction: Type: AWS::Serverless::Function @@ -595,58 +595,58 @@ Resources: Type: Api Properties: RestApiId: !Ref MainApi - Path: /vocab/users/{userId}/groups + Path: /vocab/groups Method: POST - # Auth: - # Authorizer: CognitoAuthorizer + Auth: + Authorizer: CognitoAuthorizer GetGroups: Type: Api Properties: RestApiId: !Ref MainApi - Path: /vocab/users/{userId}/groups + Path: /vocab/groups Method: GET - # Auth: - # Authorizer: CognitoAuthorizer + Auth: + Authorizer: CognitoAuthorizer GetGroupDetail: Type: Api Properties: RestApiId: !Ref MainApi - Path: /vocab/users/{userId}/groups/{groupId} + Path: /vocab/groups/{groupId} Method: GET - # Auth: - # Authorizer: CognitoAuthorizer + Auth: + Authorizer: CognitoAuthorizer UpdateGroup: Type: Api Properties: RestApiId: !Ref MainApi - Path: /vocab/users/{userId}/groups/{groupId} + Path: /vocab/groups/{groupId} Method: PUT - # Auth: - # Authorizer: CognitoAuthorizer + Auth: + Authorizer: CognitoAuthorizer DeleteGroup: Type: Api Properties: RestApiId: !Ref MainApi - Path: /vocab/users/{userId}/groups/{groupId} + Path: /vocab/groups/{groupId} Method: DELETE - # Auth: - # Authorizer: CognitoAuthorizer + Auth: + Authorizer: CognitoAuthorizer AddWordToGroup: Type: Api Properties: RestApiId: !Ref MainApi - Path: /vocab/users/{userId}/groups/{groupId}/words/{wordId} + Path: /vocab/groups/{groupId}/words/{wordId} Method: POST - # Auth: - # Authorizer: CognitoAuthorizer + Auth: + Authorizer: CognitoAuthorizer RemoveWordFromGroup: Type: Api Properties: RestApiId: !Ref MainApi - Path: /vocab/users/{userId}/groups/{groupId}/words/{wordId} + Path: /vocab/groups/{groupId}/words/{wordId} Method: DELETE - # Auth: - # Authorizer: CognitoAuthorizer + Auth: + Authorizer: CognitoAuthorizer DailyStudyFunction: Type: AWS::Serverless::Function @@ -665,18 +665,18 @@ Resources: Type: Api Properties: RestApiId: !Ref MainApi - Path: /vocab/daily/{userId} + Path: /vocab/daily Method: GET - # Auth: - # Authorizer: CognitoAuthorizer + Auth: + Authorizer: CognitoAuthorizer MarkWordLearned: Type: Api Properties: RestApiId: !Ref MainApi - Path: /vocab/daily/{userId}/words/{wordId}/learned + Path: /vocab/daily/words/{wordId}/learned Method: POST - # Auth: - # Authorizer: CognitoAuthorizer + Auth: + Authorizer: CognitoAuthorizer TestFunction: Type: AWS::Serverless::Function @@ -700,34 +700,34 @@ Resources: Type: Api Properties: RestApiId: !Ref MainApi - Path: /vocab/test/{userId}/start + Path: /vocab/test/start Method: POST - # Auth: - # Authorizer: CognitoAuthorizer + Auth: + Authorizer: CognitoAuthorizer SubmitAnswer: Type: Api Properties: RestApiId: !Ref MainApi - Path: /vocab/test/{userId}/submit + Path: /vocab/test/submit Method: POST - # Auth: - # Authorizer: CognitoAuthorizer + Auth: + Authorizer: CognitoAuthorizer GetTestResults: Type: Api Properties: RestApiId: !Ref MainApi - Path: /vocab/test/{userId}/results + Path: /vocab/test/results Method: GET - # Auth: - # Authorizer: CognitoAuthorizer + Auth: + Authorizer: CognitoAuthorizer GetTestResultDetail: Type: Api Properties: RestApiId: !Ref MainApi - Path: /vocab/test/{userId}/results/{testId} + Path: /vocab/test/results/{testId} Method: GET - # Auth: - # Authorizer: CognitoAuthorizer + Auth: + Authorizer: CognitoAuthorizer StatsFunction: Type: AWS::Serverless::Function @@ -746,26 +746,26 @@ Resources: Type: Api Properties: RestApiId: !Ref MainApi - Path: /vocab/stats/{userId} + Path: /vocab/stats Method: GET - # Auth: - # Authorizer: CognitoAuthorizer + Auth: + Authorizer: CognitoAuthorizer GetDailyStats: Type: Api Properties: RestApiId: !Ref MainApi - Path: /vocab/stats/{userId}/daily + Path: /vocab/stats/daily Method: GET - # Auth: - # Authorizer: CognitoAuthorizer + Auth: + Authorizer: CognitoAuthorizer GetWeaknessAnalysis: Type: Api Properties: RestApiId: !Ref MainApi - Path: /vocab/stats/{userId}/weakness + Path: /vocab/stats/weakness Method: GET - # Auth: - # Authorizer: CognitoAuthorizer + Auth: + Authorizer: CognitoAuthorizer VocabVoiceFunction: Type: AWS::Serverless::Function From 17a287913cda8dd5c6ca8df21264e2db00298ea3 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Mon, 12 Jan 2026 14:49:08 +0900 Subject: [PATCH 121/528] =?UTF-8?q?feat(chat):=20Chat=20API=EC=97=90=20Cog?= =?UTF-8?q?nito=20Authorizer=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChatRoomFunction: 6개 엔드포인트 Authorizer 활성화 - ChatMessageFunction: 3개 엔드포인트 Authorizer 활성화 - ChatAIFunction: 1개 엔드포인트 Authorizer 활성화 - ChatVoiceFunction: 1개 엔드포인트 Authorizer 활성화 Closes #191 --- ServerlessFunction/template.yaml | 44 ++++++++++++++++---------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 48b0a6ca..31f006f5 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -287,48 +287,48 @@ Resources: RestApiId: !Ref MainApi Path: /chat/rooms Method: POST - # Auth: - # Authorizer: CognitoAuthorizer + Auth: + Authorizer: CognitoAuthorizer GetRooms: Type: Api Properties: RestApiId: !Ref MainApi Path: /chat/rooms Method: GET - # Auth: - # Authorizer: CognitoAuthorizer + Auth: + Authorizer: CognitoAuthorizer GetRoom: Type: Api Properties: RestApiId: !Ref MainApi Path: /chat/rooms/{roomId} Method: GET - # Auth: - # Authorizer: CognitoAuthorizer + Auth: + Authorizer: CognitoAuthorizer DeleteRoom: Type: Api Properties: RestApiId: !Ref MainApi Path: /chat/rooms/{roomId} Method: DELETE - # Auth: - # Authorizer: CognitoAuthorizer + Auth: + Authorizer: CognitoAuthorizer JoinRoom: Type: Api Properties: RestApiId: !Ref MainApi Path: /chat/rooms/{roomId}/join Method: POST - # Auth: - # Authorizer: CognitoAuthorizer + Auth: + Authorizer: CognitoAuthorizer LeaveRoom: Type: Api Properties: RestApiId: !Ref MainApi Path: /chat/rooms/{roomId}/leave Method: POST - # Auth: - # Authorizer: CognitoAuthorizer + Auth: + Authorizer: CognitoAuthorizer ChatMessageFunction: Type: AWS::Serverless::Function @@ -363,24 +363,24 @@ Resources: RestApiId: !Ref MainApi Path: /chat/rooms/{roomId}/messages Method: POST - # Auth: - # Authorizer: CognitoAuthorizer + Auth: + Authorizer: CognitoAuthorizer GetMessages: Type: Api Properties: RestApiId: !Ref MainApi Path: /chat/rooms/{roomId}/messages Method: GET - # Auth: - # Authorizer: CognitoAuthorizer + Auth: + Authorizer: CognitoAuthorizer GetMessage: Type: Api Properties: RestApiId: !Ref MainApi Path: /chat/rooms/{roomId}/messages/{messageId} Method: GET - # Auth: - # Authorizer: CognitoAuthorizer + Auth: + Authorizer: CognitoAuthorizer ChatAIFunction: Type: AWS::Serverless::Function @@ -409,8 +409,8 @@ Resources: RestApiId: !Ref MainApi Path: /chat/ai/generate Method: POST - # Auth: - # Authorizer: CognitoAuthorizer + Auth: + Authorizer: CognitoAuthorizer ChatVoiceFunction: Type: AWS::Serverless::Function @@ -439,8 +439,8 @@ Resources: RestApiId: !Ref MainApi Path: /chat/voice/synthesize Method: POST - # Auth: - # Authorizer: CognitoAuthorizer + Auth: + Authorizer: CognitoAuthorizer ############################################# # Vocabulary Lambda Functions From 3307a252751056a4ccf593cfdb7c432386c6db6b Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Mon, 12 Jan 2026 14:53:09 +0900 Subject: [PATCH 122/528] =?UTF-8?q?feat(chat):=20Chat=20Handler=EC=97=90?= =?UTF-8?q?=20AuthenticatedHandler=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChatRoomHandler: 6개 메서드에 userId 토큰 추출 적용 - ChatMessageHandler: 3개 메서드에 userId 토큰 추출 적용 - body/query param에서 userId 받던 로직을 토큰 기반으로 변경 Closes #192 --- .../chatting/handler/ChatMessageHandler.java | 16 +++--- .../chatting/handler/ChatRoomHandler.java | 54 +++++++++---------- 2 files changed, 32 insertions(+), 38 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatMessageHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatMessageHandler.java index ef0a8ea1..4a53e2d5 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatMessageHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatMessageHandler.java @@ -39,9 +39,9 @@ public ChatMessageHandler() { private HandlerRouter initRouter() { return new HandlerRouter().addRoutes( - Route.post("/rooms/{roomId}/messages", this::sendMessage), - Route.get("/rooms/{roomId}/messages/{messageId}", this::getMessage), - Route.get("/rooms/{roomId}/messages", this::getMessages) + Route.postAuth("/rooms/{roomId}/messages", this::sendMessage), + Route.getAuth("/rooms/{roomId}/messages/{messageId}", this::getMessage), + Route.getAuth("/rooms/{roomId}/messages", this::getMessages) ); } @@ -51,7 +51,7 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent re return router.route(request); } - private APIGatewayProxyResponseEvent sendMessage(APIGatewayProxyRequestEvent request) { + private APIGatewayProxyResponseEvent sendMessage(APIGatewayProxyRequestEvent request, String userId) { String roomId = request.getPathParameters().get("roomId"); SendMessageRequest req = ResponseGenerator.gson().fromJson(request.getBody(), SendMessageRequest.class); @@ -63,13 +63,13 @@ private APIGatewayProxyResponseEvent sendMessage(APIGatewayProxyRequestEvent req ChatMessage message = ChatMessage.builder() .pk("ROOM#" + roomId) .sk("MSG#" + now + "#" + messageId) - .gsi1pk("USER#" + dto.getUserId()) + .gsi1pk("USER#" + userId) .gsi1sk("MSG#" + now) .gsi2pk("MSG#" + messageId) .gsi2sk("ROOM#" + roomId) .messageId(messageId) .roomId(roomId) - .userId(dto.getUserId()) + .userId(userId) .content(dto.getContent()) .messageType(messageType) .createdAt(now) @@ -83,7 +83,7 @@ private APIGatewayProxyResponseEvent sendMessage(APIGatewayProxyRequestEvent req }); } - private APIGatewayProxyResponseEvent getMessage(APIGatewayProxyRequestEvent request) { + private APIGatewayProxyResponseEvent getMessage(APIGatewayProxyRequestEvent request, String userId) { String roomId = request.getPathParameters().get("roomId"); String messageId = request.getPathParameters().get("messageId"); @@ -94,7 +94,7 @@ private APIGatewayProxyResponseEvent getMessage(APIGatewayProxyRequestEvent requ return ResponseGenerator.ok("Message retrieved", message.get()); } - private APIGatewayProxyResponseEvent getMessages(APIGatewayProxyRequestEvent request) { + private APIGatewayProxyResponseEvent getMessages(APIGatewayProxyRequestEvent request, String userId) { String roomId = request.getPathParameters().get("roomId"); Map queryParams = request.getQueryStringParameters(); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java index 425903e0..ba1ac769 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java @@ -41,12 +41,12 @@ public ChatRoomHandler() { private HandlerRouter initRouter() { return new HandlerRouter().addRoutes( - Route.post("/rooms", this::createRoom), - Route.get("/rooms", this::getRooms), - Route.get("/rooms/{roomId}", this::getRoom), // roomId 자동 검증 - Route.post("/rooms/{roomId}/join", this::joinRoom), // roomId 자동 검증 - Route.post("/rooms/{roomId}/leave", this::leaveRoom), // roomId 자동 검증 - Route.delete("/rooms/{roomId}", this::deleteRoom).requireQueryParams("userId") // roomId + userId 검증 + Route.postAuth("/rooms", this::createRoom), + Route.getAuth("/rooms", this::getRooms), + Route.getAuth("/rooms/{roomId}", this::getRoom), + Route.postAuth("/rooms/{roomId}/join", this::joinRoom), + Route.postAuth("/rooms/{roomId}/leave", this::leaveRoom), + Route.deleteAuth("/rooms/{roomId}", this::deleteRoom) ); } @@ -56,7 +56,7 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent re return router.route(request); } - private APIGatewayProxyResponseEvent createRoom(APIGatewayProxyRequestEvent request) { + private APIGatewayProxyResponseEvent createRoom(APIGatewayProxyRequestEvent request, String userId) { CreateRoomRequest req = ResponseGenerator.gson().fromJson(request.getBody(), CreateRoomRequest.class); return BeanValidator.validateAndExecute(req, dto -> { @@ -65,18 +65,17 @@ private APIGatewayProxyResponseEvent createRoom(APIGatewayProxyRequestEvent requ Boolean isPrivate = dto.getIsPrivate() != null ? dto.getIsPrivate() : false; ChatRoom room = commandService.createRoom( - dto.getName(), dto.getDescription(), level, maxMembers, isPrivate, dto.getPassword(), dto.getCreatedBy()); + dto.getName(), dto.getDescription(), level, maxMembers, isPrivate, dto.getPassword(), userId); room.setPassword(null); return ResponseGenerator.created("Room created", room); }); } - private APIGatewayProxyResponseEvent getRooms(APIGatewayProxyRequestEvent request) { + private APIGatewayProxyResponseEvent getRooms(APIGatewayProxyRequestEvent request, String userId) { Map queryParams = request.getQueryStringParameters(); String level = queryParams != null ? queryParams.get("level") : null; - String userId = queryParams != null ? queryParams.get("userId") : null; String joined = queryParams != null ? queryParams.get("joined") : null; String cursor = queryParams != null ? queryParams.get("cursor") : null; @@ -88,7 +87,7 @@ private APIGatewayProxyResponseEvent getRooms(APIGatewayProxyRequestEvent reques PaginatedResult roomPage = queryService.getRooms(level, limit, cursor); List rooms = roomPage.items(); - if ("true".equals(joined) && userId != null) { + if ("true".equals(joined)) { rooms = queryService.filterByJoinedUser(rooms, userId); } @@ -102,7 +101,7 @@ private APIGatewayProxyResponseEvent getRooms(APIGatewayProxyRequestEvent reques return ResponseGenerator.ok("Rooms retrieved", result); } - private APIGatewayProxyResponseEvent getRoom(APIGatewayProxyRequestEvent request) { + private APIGatewayProxyResponseEvent getRoom(APIGatewayProxyRequestEvent request, String userId) { String roomId = request.getPathParameters().get("roomId"); Optional optRoom = queryService.getRoom(roomId); @@ -116,34 +115,29 @@ private APIGatewayProxyResponseEvent getRoom(APIGatewayProxyRequestEvent request return ResponseGenerator.ok("Room retrieved", room); } - private APIGatewayProxyResponseEvent joinRoom(APIGatewayProxyRequestEvent request) { + private APIGatewayProxyResponseEvent joinRoom(APIGatewayProxyRequestEvent request, String userId) { String roomId = request.getPathParameters().get("roomId"); JoinRoomRequest req = ResponseGenerator.gson().fromJson(request.getBody(), JoinRoomRequest.class); - return BeanValidator.validateAndExecute(req, dto -> { - JoinRoomResponse response = commandService.joinRoom(roomId, dto.getUserId(), dto.getPassword()); - response.getRoom().setPassword(null); - return ResponseGenerator.ok("Joined room", response); - }); + String password = req != null ? req.getPassword() : null; + JoinRoomResponse response = commandService.joinRoom(roomId, userId, password); + response.getRoom().setPassword(null); + return ResponseGenerator.ok("Joined room", response); } - private APIGatewayProxyResponseEvent leaveRoom(APIGatewayProxyRequestEvent request) { + private APIGatewayProxyResponseEvent leaveRoom(APIGatewayProxyRequestEvent request, String userId) { String roomId = request.getPathParameters().get("roomId"); - LeaveRoomRequest req = ResponseGenerator.gson().fromJson(request.getBody(), LeaveRoomRequest.class); - return BeanValidator.validateAndExecute(req, dto -> { - ChatRoomCommandService.LeaveResult result = commandService.leaveRoom(roomId, dto.getUserId()); - if (result.deleted()) { - return ResponseGenerator.ok("Room deleted", null); - } - result.room().setPassword(null); - return ResponseGenerator.ok("Left room", result.room()); - }); + ChatRoomCommandService.LeaveResult result = commandService.leaveRoom(roomId, userId); + if (result.deleted()) { + return ResponseGenerator.ok("Room deleted", null); + } + result.room().setPassword(null); + return ResponseGenerator.ok("Left room", result.room()); } - private APIGatewayProxyResponseEvent deleteRoom(APIGatewayProxyRequestEvent request) { + private APIGatewayProxyResponseEvent deleteRoom(APIGatewayProxyRequestEvent request, String userId) { String roomId = request.getPathParameters().get("roomId"); - String userId = request.getQueryStringParameters().get("userId"); commandService.deleteRoom(roomId, userId); return ResponseGenerator.ok("Room deleted", null); From 5cc4e4d638aae4f700bd1e822ed74d42e4d309f1 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 13 Jan 2026 09:35:11 +0900 Subject: [PATCH 123/528] =?UTF-8?q?hotfix:=20Chat=20Request=20DTO=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=B6=88=ED=95=84=EC=9A=94=ED=95=9C=20userId=20val?= =?UTF-8?q?idation=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CreateRoomRequest: createdBy 필드 및 validation 제거 - JoinRoomRequest: userId 필드 및 validation 제거 - LeaveRoomRequest: userId 필드 및 validation 제거 - SendMessageRequest: userId 필드 및 validation 제거 Cognito 토큰에서 userId를 추출하므로 Request body에서 userId를 받을 필요 없음 Closes #196 --- .../domain/chatting/dto/request/CreateRoomRequest.java | 3 --- .../domain/chatting/dto/request/JoinRoomRequest.java | 4 ---- .../domain/chatting/dto/request/LeaveRoomRequest.java | 6 +----- .../domain/chatting/dto/request/SendMessageRequest.java | 3 --- 4 files changed, 1 insertion(+), 15 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/CreateRoomRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/CreateRoomRequest.java index fc28faf7..fcfb9ba4 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/CreateRoomRequest.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/CreateRoomRequest.java @@ -34,7 +34,4 @@ public class CreateRoomRequest { private Boolean isPrivate = false; private String password; - - @NotBlank(message = "is required") - private String createdBy; } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/JoinRoomRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/JoinRoomRequest.java index 4ca12f4e..88ac0b7f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/JoinRoomRequest.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/JoinRoomRequest.java @@ -1,6 +1,5 @@ package com.mzc.secondproject.serverless.domain.chatting.dto.request; -import jakarta.validation.constraints.NotBlank; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -12,8 +11,5 @@ @AllArgsConstructor public class JoinRoomRequest { - @NotBlank(message = "is required") - private String userId; - private String password; } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/LeaveRoomRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/LeaveRoomRequest.java index 8d3ed4a1..79377cea 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/LeaveRoomRequest.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/LeaveRoomRequest.java @@ -1,6 +1,5 @@ package com.mzc.secondproject.serverless.domain.chatting.dto.request; -import jakarta.validation.constraints.NotBlank; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -9,9 +8,6 @@ @Data @Builder @NoArgsConstructor -@AllArgsConstructor public class LeaveRoomRequest { - - @NotBlank(message = "is required") - private String userId; + // 토큰에서 userId를 추출하므로 별도 필드 불필요 } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/SendMessageRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/SendMessageRequest.java index 9e06cdf3..54044761 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/SendMessageRequest.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/SendMessageRequest.java @@ -13,9 +13,6 @@ @AllArgsConstructor public class SendMessageRequest { - @NotBlank(message = "is required") - private String userId; - @NotBlank(message = "is required") @Size(max = 1000, message = "must be at most 1000 characters") private String content; From d5da7a837d02a3a07faaa9fb157bf9bbb1607951 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 13 Jan 2026 09:38:20 +0900 Subject: [PATCH 124/528] =?UTF-8?q?fix:=20UserWordFunction=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=EB=A5=BC=20/vocab/user-words=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=ED=95=98=EC=97=AC=20WordFunction=EA=B3=BC=20=EC=B6=A9?= =?UTF-8?q?=EB=8F=8C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/vocabulary/handler/UserWordHandler.java | 8 ++++---- ServerlessFunction/template.yaml | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java index e252e1df..1afb22ec 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java @@ -39,10 +39,10 @@ public UserWordHandler() { private HandlerRouter initRouter() { return new HandlerRouter().addRoutes( Route.getAuth("/wrong-answers", this::getWrongAnswers), - Route.getAuth("/words", this::getUserWords), - Route.getAuth("/words/{wordId}", this::getUserWord), - Route.putAuth("/words/{wordId}/tag", this::updateUserWordTag), - Route.putAuth("/words/{wordId}", this::updateUserWord) + Route.getAuth("/user-words", this::getUserWords), + Route.getAuth("/user-words/{wordId}", this::getUserWord), + Route.putAuth("/user-words/{wordId}/tag", this::updateUserWordTag), + Route.putAuth("/user-words/{wordId}", this::updateUserWord) ); } diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 31f006f5..893cd253 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -549,7 +549,7 @@ Resources: Type: Api Properties: RestApiId: !Ref MainApi - Path: /vocab/words + Path: /vocab/user-words Method: GET Auth: Authorizer: CognitoAuthorizer @@ -557,7 +557,7 @@ Resources: Type: Api Properties: RestApiId: !Ref MainApi - Path: /vocab/words/{wordId} + Path: /vocab/user-words/{wordId} Method: GET Auth: Authorizer: CognitoAuthorizer @@ -565,7 +565,7 @@ Resources: Type: Api Properties: RestApiId: !Ref MainApi - Path: /vocab/words/{wordId} + Path: /vocab/user-words/{wordId} Method: PUT Auth: Authorizer: CognitoAuthorizer @@ -573,7 +573,7 @@ Resources: Type: Api Properties: RestApiId: !Ref MainApi - Path: /vocab/words/{wordId}/tag + Path: /vocab/user-words/{wordId}/tag Method: PUT Auth: Authorizer: CognitoAuthorizer From 372f823ca9096dab6d42d9608b08607ed658ee56 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 13 Jan 2026 09:50:28 +0900 Subject: [PATCH 125/528] =?UTF-8?q?fix:=20WordFunction=20=EB=B0=8F=20Vocab?= =?UTF-8?q?VoiceFunction=EC=97=90=20Auth:=20NONE=20=EB=AA=85=EC=8B=9C?= =?UTF-8?q?=EC=A0=81=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ServerlessFunction/template.yaml | 36 ++++++++++++++++---------------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 893cd253..10a21f9c 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -465,64 +465,64 @@ Resources: RestApiId: !Ref MainApi Path: /vocab/words Method: POST - # Auth: - # Authorizer: CognitoAuthorizer + Auth: + Authorizer: NONE BatchCreateWords: Type: Api Properties: RestApiId: !Ref MainApi Path: /vocab/words/batch Method: POST - # Auth: - # Authorizer: CognitoAuthorizer + Auth: + Authorizer: NONE BatchGetWords: Type: Api Properties: RestApiId: !Ref MainApi Path: /vocab/words/batch/get Method: POST - # Auth: - # Authorizer: CognitoAuthorizer + Auth: + Authorizer: NONE SearchWords: Type: Api Properties: RestApiId: !Ref MainApi Path: /vocab/words/search Method: GET - # Auth: - # Authorizer: CognitoAuthorizer + Auth: + Authorizer: NONE GetWords: Type: Api Properties: RestApiId: !Ref MainApi Path: /vocab/words Method: GET - # Auth: - # Authorizer: CognitoAuthorizer + Auth: + Authorizer: NONE GetWord: Type: Api Properties: RestApiId: !Ref MainApi Path: /vocab/words/{wordId} Method: GET - # Auth: - # Authorizer: CognitoAuthorizer + Auth: + Authorizer: NONE UpdateWord: Type: Api Properties: RestApiId: !Ref MainApi Path: /vocab/words/{wordId} Method: PUT - # Auth: - # Authorizer: CognitoAuthorizer + Auth: + Authorizer: NONE DeleteWord: Type: Api Properties: RestApiId: !Ref MainApi Path: /vocab/words/{wordId} Method: DELETE - # Auth: - # Authorizer: CognitoAuthorizer + Auth: + Authorizer: NONE UserWordFunction: Type: AWS::Serverless::Function @@ -794,8 +794,8 @@ Resources: RestApiId: !Ref MainApi Path: /vocab/voice/synthesize Method: POST - # Auth: - # Authorizer: CognitoAuthorizer + Auth: + Authorizer: NONE ############################################# # DynamoDB Tables From 06d9751b37eb00bb2f77c61b7da1c09604e6226a Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 13 Jan 2026 10:04:13 +0900 Subject: [PATCH 126/528] =?UTF-8?q?fix:=20DailyStudy=20markWordLearned=20L?= =?UTF-8?q?ist=20=ED=83=80=EC=9E=85=20=ED=98=B8=ED=99=98=EC=84=B1=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ADD 연산 대신 SET list_append 사용 - List 타입에 맞게 DynamoDB UpdateExpression 수정 - if_not_exists로 null 안전성 확보 --- .../vocabulary/repository/DailyStudyRepository.java | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/DailyStudyRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/DailyStudyRepository.java index e8faab86..91fc967c 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/DailyStudyRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/DailyStudyRepository.java @@ -77,6 +77,7 @@ public PaginatedResult findByUserIdWithPagination(String userId, int /** * 학습 완료 단어 추가 (UpdateExpression 사용 - N+1 방지) + * List 타입에 대해 list_append 사용 */ public void addLearnedWord(String userId, String date, String wordId) { Map key = new HashMap<>(); @@ -84,13 +85,18 @@ public void addLearnedWord(String userId, String date, String wordId) { key.put("SK", AttributeValue.builder().s("DATE#" + date).build()); Map expressionValues = new HashMap<>(); - expressionValues.put(":wordId", AttributeValue.builder().ss(wordId).build()); + expressionValues.put(":newWord", AttributeValue.builder().l( + AttributeValue.builder().s(wordId).build() + ).build()); + expressionValues.put(":emptyList", AttributeValue.builder().l(java.util.Collections.emptyList()).build()); expressionValues.put(":one", AttributeValue.builder().n("1").build()); + expressionValues.put(":zero", AttributeValue.builder().n("0").build()); UpdateItemRequest updateRequest = UpdateItemRequest.builder() .tableName(TABLE_NAME) .key(key) - .updateExpression("ADD learnedWordIds :wordId, learnedCount :one") + .updateExpression("SET learnedWordIds = list_append(if_not_exists(learnedWordIds, :emptyList), :newWord), " + + "learnedCount = if_not_exists(learnedCount, :zero) + :one") .expressionAttributeValues(expressionValues) .build(); From 32e79ef0a533ff5c2a132fe9e82c7b0f54f0256e Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 13 Jan 2026 10:17:39 +0900 Subject: [PATCH 127/528] =?UTF-8?q?feat:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=98=A4=EB=8B=B5=20=EB=8B=A8=EC=96=B4=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EB=B6=81=EB=A7=88=ED=81=AC=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TestCommandService에 UserWordCommandService 의존성 추가 - submitTest() 메서드에서 오답 단어 자동 북마크 로직 구현 - bookmarkIncorrectWords() 메서드 추가 - 오류 발생 시에도 다른 단어 북마크 계속 진행 (개별 try-catch) closes #200 --- .../service/TestCommandService.java | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java index 09646131..d524f0f2 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java @@ -39,11 +39,13 @@ public class TestCommandService { private final TestResultRepository testResultRepository; private final DailyStudyRepository dailyStudyRepository; private final WordRepository wordRepository; + private final UserWordCommandService userWordCommandService; public TestCommandService() { this.testResultRepository = new TestResultRepository(); this.dailyStudyRepository = new DailyStudyRepository(); this.wordRepository = new WordRepository(); + this.userWordCommandService = new UserWordCommandService(); } public StartTestResult startTest(String userId, String testType) { @@ -164,6 +166,9 @@ public SubmitTestResult submitTest(String userId, String testId, String testType testResultRepository.save(testResult); + // 오답 단어 자동 북마크 + bookmarkIncorrectWords(userId, incorrectWordIds); + publishTestResultToSns(userId, results); logger.info("Test submitted: userId={}, testId={}, successRate={}%", userId, testId, successRate); @@ -171,6 +176,23 @@ public SubmitTestResult submitTest(String userId, String testId, String testType return new SubmitTestResult(testId, testType, totalQuestions, correctCount, incorrectCount, successRate, results); } + private void bookmarkIncorrectWords(String userId, List incorrectWordIds) { + if (incorrectWordIds == null || incorrectWordIds.isEmpty()) { + return; + } + + int bookmarkedCount = 0; + for (String wordId : incorrectWordIds) { + try { + userWordCommandService.updateUserWordTag(userId, wordId, true, null, null); + bookmarkedCount++; + } catch (Exception e) { + logger.warn("Failed to bookmark word: userId={}, wordId={}", userId, wordId, e); + } + } + logger.info("Auto-bookmarked {} incorrect words for user: {}", bookmarkedCount, userId); + } + private List getDistractorsForLevel(String level, List excludeWordIds) { Set excludeSet = new HashSet<>(excludeWordIds); PaginatedResult wordPage = wordRepository.findByLevelWithPagination(level, 50, null); From ad50bb7643c3a1371995b78f8cb91b8c7d3ec648 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 13 Jan 2026 10:15:02 +0900 Subject: [PATCH 128/528] =?UTF-8?q?improve:=20=EB=8F=84=EB=A9=94=EC=9D=B8?= =?UTF-8?q?=20=EC=98=88=EC=99=B8=20=EC=B2=98=EB=A6=AC=20=EA=B5=AC=EC=B2=B4?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - VocabularyErrorCode에 GROUP_NOT_FOUND, GROUP_ALREADY_EXISTS, TEST_NOT_FOUND, NO_WORDS_TO_TEST 추가 - VocabularyException에 groupNotFound, groupAlreadyExists, noWordsToTest 팩토리 메서드 추가 - ChattingErrorCode에 ROOM_INVALID_PASSWORD, ROOM_NOT_OWNER 추가 - ChattingException에 roomInvalidPassword, roomNotOwner 팩토리 메서드 추가 - DailyStudyCommandService, TestCommandService, WordGroupCommandService에서 IllegalArgumentException/IllegalStateException을 VocabularyException으로 교체 - ChatRoomService, ChatRoomCommandService에서 IllegalArgumentException/IllegalStateException/SecurityException을 ChattingException으로 교체 closes #199 --- .../chatting/exception/ChattingErrorCode.java | 2 ++ .../chatting/exception/ChattingException.java | 13 ++++++++++++ .../service/ChatRoomCommandService.java | 13 ++++++------ .../chatting/service/ChatRoomService.java | 13 ++++++------ .../exception/VocabularyErrorCode.java | 8 +++++++ .../exception/VocabularyException.java | 21 +++++++++++++++++++ .../service/DailyStudyCommandService.java | 8 ++++--- .../service/TestCommandService.java | 5 +++-- .../service/WordGroupCommandService.java | 9 ++++---- 9 files changed, 71 insertions(+), 21 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCode.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCode.java index e2771356..785c13e9 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCode.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCode.java @@ -14,6 +14,8 @@ public enum ChattingErrorCode implements DomainErrorCode { ROOM_ALREADY_EXISTS("ROOM_002", "이미 존재하는 채팅방입니다", 409), ROOM_FULL("ROOM_003", "채팅방 인원이 가득 찼습니다", 400), ROOM_CLOSED("ROOM_004", "종료된 채팅방입니다", 400), + ROOM_INVALID_PASSWORD("ROOM_005", "비밀번호가 일치하지 않습니다", 401), + ROOM_NOT_OWNER("ROOM_006", "방장 권한이 필요합니다", 403), // 메시지 관련 에러 MESSAGE_NOT_FOUND("MSG_001", "메시지를 찾을 수 없습니다", 404), diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingException.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingException.java index 02bde4eb..24a06828 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingException.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingException.java @@ -52,6 +52,19 @@ public static ChattingException roomClosed(String roomId) { .addDetail("roomId", roomId); } + public static ChattingException roomInvalidPassword(String roomId) { + return (ChattingException) new ChattingException(ChattingErrorCode.ROOM_INVALID_PASSWORD, + "비밀번호가 일치하지 않습니다") + .addDetail("roomId", roomId); + } + + public static ChattingException roomNotOwner(String userId, String roomId) { + return (ChattingException) new ChattingException(ChattingErrorCode.ROOM_NOT_OWNER, + String.format("방장 권한이 필요합니다 (userId: %s, roomId: %s)", userId, roomId)) + .addDetail("userId", userId) + .addDetail("roomId", roomId); + } + // === 메시지(Message) 관련 팩토리 메서드 === public static ChattingException messageNotFound(String messageId) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomCommandService.java index 8535e3f1..3bec7660 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomCommandService.java @@ -1,6 +1,7 @@ package com.mzc.secondproject.serverless.domain.chatting.service; import com.mzc.secondproject.serverless.domain.chatting.dto.response.JoinRoomResponse; +import com.mzc.secondproject.serverless.domain.chatting.exception.ChattingException; import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; import com.mzc.secondproject.serverless.domain.chatting.model.RoomToken; import com.mzc.secondproject.serverless.domain.chatting.repository.ChatRoomRepository; @@ -62,19 +63,19 @@ public ChatRoom createRoom(String name, String description, String level, Intege public JoinRoomResponse joinRoom(String roomId, String userId, String password) { Optional optRoom = roomRepository.findById(roomId); if (optRoom.isEmpty()) { - throw new IllegalArgumentException("Room not found"); + throw ChattingException.roomNotFound(roomId); } ChatRoom room = optRoom.get(); if (room.getIsPrivate()) { if (password == null || room.getPassword() == null || !BCrypt.checkpw(password, room.getPassword())) { - throw new SecurityException("Invalid password"); + throw ChattingException.roomInvalidPassword(roomId); } } if (room.getCurrentMembers() >= room.getMaxMembers()) { - throw new IllegalStateException("Room is full"); + throw ChattingException.roomFull(roomId, room.getMaxMembers()); } boolean alreadyMember = room.getMemberIds() != null && room.getMemberIds().contains(userId); @@ -103,7 +104,7 @@ public JoinRoomResponse joinRoom(String roomId, String userId, String password) public LeaveResult leaveRoom(String roomId, String userId) { Optional optRoom = roomRepository.findById(roomId); if (optRoom.isEmpty()) { - throw new IllegalArgumentException("Room not found"); + throw ChattingException.roomNotFound(roomId); } ChatRoom room = optRoom.get(); @@ -128,12 +129,12 @@ public LeaveResult leaveRoom(String roomId, String userId) { public void deleteRoom(String roomId, String userId) { Optional optRoom = roomRepository.findById(roomId); if (optRoom.isEmpty()) { - throw new IllegalArgumentException("Room not found"); + throw ChattingException.roomNotFound(roomId); } ChatRoom room = optRoom.get(); if (!userId.equals(room.getCreatedBy())) { - throw new SecurityException("Only the room owner can delete the room"); + throw ChattingException.roomNotOwner(userId, roomId); } roomRepository.delete(roomId); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomService.java index f9428f60..0d531c88 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomService.java @@ -1,6 +1,7 @@ package com.mzc.secondproject.serverless.domain.chatting.service; import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.domain.chatting.exception.ChattingException; import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; import com.mzc.secondproject.serverless.domain.chatting.repository.ChatRoomRepository; import org.mindrot.jbcrypt.BCrypt; @@ -73,19 +74,19 @@ public List filterByJoinedUser(List rooms, String userId) { public ChatRoom joinRoom(String roomId, String userId, String password) { Optional optRoom = roomRepository.findById(roomId); if (optRoom.isEmpty()) { - throw new IllegalArgumentException("Room not found"); + throw ChattingException.roomNotFound(roomId); } ChatRoom room = optRoom.get(); if (room.getIsPrivate()) { if (password == null || room.getPassword() == null || !BCrypt.checkpw(password, room.getPassword())) { - throw new SecurityException("Invalid password"); + throw ChattingException.roomInvalidPassword(roomId); } } if (room.getCurrentMembers() >= room.getMaxMembers()) { - throw new IllegalStateException("Room is full"); + throw ChattingException.roomFull(roomId, room.getMaxMembers()); } if (room.getMemberIds() != null && room.getMemberIds().contains(userId)) { @@ -108,7 +109,7 @@ public ChatRoom joinRoom(String roomId, String userId, String password) { public LeaveResult leaveRoom(String roomId, String userId) { Optional optRoom = roomRepository.findById(roomId); if (optRoom.isEmpty()) { - throw new IllegalArgumentException("Room not found"); + throw ChattingException.roomNotFound(roomId); } ChatRoom room = optRoom.get(); @@ -133,12 +134,12 @@ public LeaveResult leaveRoom(String roomId, String userId) { public void deleteRoom(String roomId, String userId) { Optional optRoom = roomRepository.findById(roomId); if (optRoom.isEmpty()) { - throw new IllegalArgumentException("Room not found"); + throw ChattingException.roomNotFound(roomId); } ChatRoom room = optRoom.get(); if (!userId.equals(room.getCreatedBy())) { - throw new SecurityException("Only the room owner can delete the room"); + throw ChattingException.roomNotOwner(userId, roomId); } roomRepository.delete(roomId); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/exception/VocabularyErrorCode.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/exception/VocabularyErrorCode.java index 6b4fe599..d441707c 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/exception/VocabularyErrorCode.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/exception/VocabularyErrorCode.java @@ -27,6 +27,14 @@ public enum VocabularyErrorCode implements DomainErrorCode { // 카테고리/레벨 관련 에러 INVALID_CATEGORY("CATEGORY_001", "유효하지 않은 카테고리입니다", 400), INVALID_LEVEL("LEVEL_001", "유효하지 않은 레벨입니다", 400), + + // 단어 그룹 관련 에러 + GROUP_NOT_FOUND("GROUP_001", "단어 그룹을 찾을 수 없습니다", 404), + GROUP_ALREADY_EXISTS("GROUP_002", "이미 존재하는 그룹입니다", 409), + + // 테스트 관련 에러 + TEST_NOT_FOUND("TEST_001", "테스트 정보를 찾을 수 없습니다", 404), + NO_WORDS_TO_TEST("TEST_002", "테스트할 단어가 없습니다", 400), ; private static final String DOMAIN = "VOCABULARY"; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/exception/VocabularyException.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/exception/VocabularyException.java index 56bcf03e..a046256c 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/exception/VocabularyException.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/exception/VocabularyException.java @@ -99,4 +99,25 @@ public static VocabularyException invalidLevel(String level) { String.format("유효하지 않은 레벨입니다: '%s'", level)) .addDetail("invalidValue", level); } + + // === 단어 그룹(WordGroup) 관련 팩토리 메서드 === + + public static VocabularyException groupNotFound(String groupId) { + return (VocabularyException) new VocabularyException(VocabularyErrorCode.GROUP_NOT_FOUND, + String.format("단어 그룹을 찾을 수 없습니다 (ID: %s)", groupId)) + .addDetail("groupId", groupId); + } + + public static VocabularyException groupAlreadyExists(String groupName) { + return (VocabularyException) new VocabularyException(VocabularyErrorCode.GROUP_ALREADY_EXISTS, + String.format("이미 존재하는 그룹입니다: '%s'", groupName)) + .addDetail("groupName", groupName); + } + + // === 테스트(Test) 관련 팩토리 메서드 === + + public static VocabularyException noWordsToTest() { + return new VocabularyException(VocabularyErrorCode.NO_WORDS_TO_TEST, + "테스트할 단어가 없습니다. 먼저 일일 학습을 시작해주세요."); + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyCommandService.java index 59e06f92..bc002838 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyCommandService.java @@ -12,6 +12,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.mzc.secondproject.serverless.domain.vocabulary.exception.VocabularyException; + import java.time.Instant; import java.time.LocalDate; import java.util.ArrayList; @@ -54,10 +56,10 @@ public DailyStudyResult getDailyWords(String userId, String level) { dailyStudy = optDailyStudy.get(); } else { if (level == null || level.isEmpty()) { - throw new IllegalArgumentException("level is required for first daily study (BEGINNER, INTERMEDIATE, ADVANCED)"); + throw VocabularyException.invalidStudyLevel("level is required (BEGINNER, INTERMEDIATE, ADVANCED)"); } if (!StudyLevel.isValid(level)) { - throw new IllegalArgumentException("Invalid level. Must be BEGINNER, INTERMEDIATE, or ADVANCED"); + throw VocabularyException.invalidStudyLevel(level); } dailyStudy = createDailyStudy(userId, today, level); } @@ -74,7 +76,7 @@ public Map markWordLearned(String userId, String wordId) { Optional optDailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today); if (optDailyStudy.isEmpty()) { - throw new IllegalStateException("Daily study not found"); + throw VocabularyException.dailyStudyNotFound(userId, today); } DailyStudy dailyStudy = optDailyStudy.get(); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java index 09646131..de6fe79f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java @@ -1,6 +1,7 @@ package com.mzc.secondproject.serverless.domain.vocabulary.service; import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.domain.vocabulary.exception.VocabularyException; import com.mzc.secondproject.serverless.domain.vocabulary.dto.request.SubmitTestRequest; import com.mzc.secondproject.serverless.common.config.AwsClients; import com.mzc.secondproject.serverless.common.util.ResponseGenerator; @@ -51,7 +52,7 @@ public StartTestResult startTest(String userId, String testType) { Optional optDailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today); if (optDailyStudy.isEmpty()) { - throw new IllegalStateException("No daily study found for today"); + throw VocabularyException.dailyStudyNotFound(userId, today); } DailyStudy dailyStudy = optDailyStudy.get(); @@ -60,7 +61,7 @@ public StartTestResult startTest(String userId, String testType) { if (dailyStudy.getReviewWordIds() != null) allWordIds.addAll(dailyStudy.getReviewWordIds()); if (allWordIds.isEmpty()) { - throw new IllegalStateException("No words to test"); + throw VocabularyException.noWordsToTest(); } List words = wordRepository.findByIds(allWordIds); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordGroupCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordGroupCommandService.java index ecf805fc..6d6edb5a 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordGroupCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordGroupCommandService.java @@ -1,5 +1,6 @@ package com.mzc.secondproject.serverless.domain.vocabulary.service; +import com.mzc.secondproject.serverless.domain.vocabulary.exception.VocabularyException; import com.mzc.secondproject.serverless.domain.vocabulary.model.WordGroup; import com.mzc.secondproject.serverless.domain.vocabulary.repository.WordGroupRepository; import org.slf4j.Logger; @@ -50,7 +51,7 @@ public WordGroup createGroup(String userId, String groupName, String description public WordGroup updateGroup(String userId, String groupId, String groupName, String description) { Optional optGroup = wordGroupRepository.findByUserIdAndGroupId(userId, groupId); if (optGroup.isEmpty()) { - throw new IllegalArgumentException("Word group not found"); + throw VocabularyException.groupNotFound(groupId); } WordGroup group = optGroup.get(); @@ -71,7 +72,7 @@ public WordGroup updateGroup(String userId, String groupId, String groupName, St public void deleteGroup(String userId, String groupId) { Optional optGroup = wordGroupRepository.findByUserIdAndGroupId(userId, groupId); if (optGroup.isEmpty()) { - throw new IllegalArgumentException("Word group not found"); + throw VocabularyException.groupNotFound(groupId); } wordGroupRepository.delete(userId, groupId); @@ -81,7 +82,7 @@ public void deleteGroup(String userId, String groupId) { public WordGroup addWordToGroup(String userId, String groupId, String wordId) { Optional optGroup = wordGroupRepository.findByUserIdAndGroupId(userId, groupId); if (optGroup.isEmpty()) { - throw new IllegalArgumentException("Word group not found"); + throw VocabularyException.groupNotFound(groupId); } WordGroup group = optGroup.get(); @@ -105,7 +106,7 @@ public WordGroup addWordToGroup(String userId, String groupId, String wordId) { public WordGroup removeWordFromGroup(String userId, String groupId, String wordId) { Optional optGroup = wordGroupRepository.findByUserIdAndGroupId(userId, groupId); if (optGroup.isEmpty()) { - throw new IllegalArgumentException("Word group not found"); + throw VocabularyException.groupNotFound(groupId); } WordGroup group = optGroup.get(); From 4c3127a02121d0fcc1b6abaec46e919140640fd4 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 13 Jan 2026 11:56:50 +0900 Subject: [PATCH 129/528] =?UTF-8?q?feat(stats):=20UserStats=20=EB=AA=A8?= =?UTF-8?q?=EB=8D=B8=20=EB=B0=8F=20Repository=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task #209: UserStats 모델 및 Repository 구현 - stats 도메인 패키지 생성 (도메인 분리) - UserStats 모델: 일별/주별/월별/전체 학습 통계 - StatsKey 상수: PK/SK 키 빌더 - UserStatsRepository: Atomic Counter 패턴으로 통계 업데이트 - incrementTestStats: 테스트 결과 통계 업데이트 - incrementWordsLearned: 학습 단어 수 업데이트 - updateStreak: 연속 학습일 업데이트 - DynamoDB Scan 없이 Write-time Aggregation 사용 --- .../domain/stats/constants/StatsKey.java | 41 +++ .../domain/stats/model/UserStats.java | 67 +++++ .../stats/repository/UserStatsRepository.java | 278 ++++++++++++++++++ 3 files changed, 386 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/constants/StatsKey.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/model/UserStats.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/constants/StatsKey.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/constants/StatsKey.java new file mode 100644 index 00000000..9f5a1ff5 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/constants/StatsKey.java @@ -0,0 +1,41 @@ +package com.mzc.secondproject.serverless.domain.stats.constants; + +import com.mzc.secondproject.serverless.common.constants.DynamoDbKey; + +/** + * 학습 통계 도메인 키 상수 + */ +public final class StatsKey { + + private StatsKey() {} + + // Suffix + public static final String SUFFIX_STATS = "#STATS"; + + // Stats Period Prefixes + public static final String STATS_DAILY = "DAILY#"; + public static final String STATS_WEEKLY = "WEEKLY#"; + public static final String STATS_MONTHLY = "MONTHLY#"; + public static final String STATS_TOTAL = "TOTAL"; + + // Key Builders + public static String userStatsPk(String userId) { + return DynamoDbKey.USER + userId + SUFFIX_STATS; + } + + public static String statsDailySk(String date) { + return STATS_DAILY + date; + } + + public static String statsWeeklySk(String yearWeek) { + return STATS_WEEKLY + yearWeek; + } + + public static String statsMonthlySk(String yearMonth) { + return STATS_MONTHLY + yearMonth; + } + + public static String statsTotalSk() { + return STATS_TOTAL; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/model/UserStats.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/model/UserStats.java new file mode 100644 index 00000000..e957bdfb --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/model/UserStats.java @@ -0,0 +1,67 @@ +package com.mzc.secondproject.serverless.domain.stats.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbAttribute; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSortKey; + +/** + * 사용자 학습 통계 + * PK: USER#{userId}#STATS + * SK: DAILY#{date} / WEEKLY#{year}-W{week} / MONTHLY#{year}-{month} / TOTAL + * + * Write-time Aggregation 패턴: + * - 이벤트 발생 시 Atomic Counter로 증분 업데이트 + * - 조회 시 Scan 없이 O(1) GetItem + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DynamoDbBean +public class UserStats { + + private String pk; // USER#{userId}#STATS + private String sk; // DAILY#{date} / WEEKLY#{year}-W{week} / MONTHLY#{year}-{month} / TOTAL + + private String userId; + private String periodType; // DAILY, WEEKLY, MONTHLY, TOTAL + private String period; // 2026-01-13, 2026-W02, 2026-01, TOTAL + + // 테스트 통계 + private Integer testsCompleted; // 완료한 테스트 수 + private Integer questionsAnswered; // 답변한 문제 수 + private Integer correctAnswers; // 정답 수 + private Integer incorrectAnswers; // 오답 수 + private Double successRate; // 정답률 + + // 학습 통계 + private Integer newWordsLearned; // 새로 학습한 단어 수 + private Integer wordsReviewed; // 복습한 단어 수 + private Integer wordsMastered; // 마스터한 단어 수 + + // Streak (연속 학습) + private Integer currentStreak; // 현재 연속 학습일 + private Integer longestStreak; // 최장 연속 학습일 + private String lastStudyDate; // 마지막 학습일 + + // 메타데이터 + private String createdAt; + private String updatedAt; + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { + return pk; + } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { + return sk; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java new file mode 100644 index 00000000..75f9e38e --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java @@ -0,0 +1,278 @@ +package com.mzc.secondproject.serverless.domain.stats.repository; + +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.common.util.CursorUtil; +import com.mzc.secondproject.serverless.domain.stats.constants.StatsKey; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; +import software.amazon.awssdk.enhanced.dynamodb.Key; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.model.Page; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; +import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.temporal.WeekFields; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; + +/** + * 사용자 학습 통계 Repository + * Atomic Counter 패턴을 사용하여 Scan 없이 통계 업데이트 + */ +public class UserStatsRepository { + + private static final Logger logger = LoggerFactory.getLogger(UserStatsRepository.class); + private static final String TABLE_NAME = System.getenv("VOCAB_TABLE_NAME"); + + private final DynamoDbTable table; + + public UserStatsRepository() { + this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(UserStats.class)); + } + + /** + * 특정 기간의 통계 조회 + */ + public Optional findByUserIdAndPeriod(String userId, String sk) { + Key key = Key.builder() + .partitionValue(StatsKey.userStatsPk(userId)) + .sortValue(sk) + .build(); + + UserStats stats = table.getItem(key); + return Optional.ofNullable(stats); + } + + /** + * 일별 통계 조회 + */ + public Optional findDailyStats(String userId, String date) { + return findByUserIdAndPeriod(userId, StatsKey.statsDailySk(date)); + } + + /** + * 주별 통계 조회 + */ + public Optional findWeeklyStats(String userId, String yearWeek) { + return findByUserIdAndPeriod(userId, StatsKey.statsWeeklySk(yearWeek)); + } + + /** + * 월별 통계 조회 + */ + public Optional findMonthlyStats(String userId, String yearMonth) { + return findByUserIdAndPeriod(userId, StatsKey.statsMonthlySk(yearMonth)); + } + + /** + * 전체 통계 조회 + */ + public Optional findTotalStats(String userId) { + return findByUserIdAndPeriod(userId, StatsKey.statsTotalSk()); + } + + /** + * 최근 N일 일별 통계 조회 + */ + public PaginatedResult findRecentDailyStats(String userId, int limit, String cursor) { + QueryConditional queryConditional = QueryConditional + .sortBeginsWith(Key.builder() + .partitionValue(StatsKey.userStatsPk(userId)) + .sortValue(StatsKey.STATS_DAILY) + .build()); + + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .scanIndexForward(false) // 최신순 + .limit(limit); + + if (cursor != null && !cursor.isEmpty()) { + Map exclusiveStartKey = CursorUtil.decode(cursor); + if (exclusiveStartKey != null) { + requestBuilder.exclusiveStartKey(exclusiveStartKey); + } + } + + Page page = table.query(requestBuilder.build()).iterator().next(); + String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); + + return new PaginatedResult<>(page.items(), nextCursor); + } + + /** + * 테스트 결과 통계 Atomic 업데이트 + * 일/주/월/전체 통계를 한 번에 업데이트 + */ + public void incrementTestStats(String userId, int correctAnswers, int incorrectAnswers) { + String today = LocalDate.now().toString(); + String yearWeek = getYearWeek(); + String yearMonth = getYearMonth(); + + List sortKeys = List.of( + StatsKey.statsDailySk(today), + StatsKey.statsWeeklySk(yearWeek), + StatsKey.statsMonthlySk(yearMonth), + StatsKey.statsTotalSk() + ); + + String pk = StatsKey.userStatsPk(userId); + String now = Instant.now().toString(); + int totalQuestions = correctAnswers + incorrectAnswers; + + for (String sk : sortKeys) { + updateTestStats(pk, sk, correctAnswers, incorrectAnswers, totalQuestions, now); + } + + logger.info("Incremented test stats: userId={}, correct={}, incorrect={}", + userId, correctAnswers, incorrectAnswers); + } + + private void updateTestStats(String pk, String sk, int correct, int incorrect, int total, String now) { + Map key = new HashMap<>(); + key.put("PK", AttributeValue.builder().s(pk).build()); + key.put("SK", AttributeValue.builder().s(sk).build()); + + Map values = new HashMap<>(); + values.put(":correct", AttributeValue.builder().n(String.valueOf(correct)).build()); + values.put(":incorrect", AttributeValue.builder().n(String.valueOf(incorrect)).build()); + values.put(":total", AttributeValue.builder().n(String.valueOf(total)).build()); + values.put(":one", AttributeValue.builder().n("1").build()); + values.put(":zero", AttributeValue.builder().n("0").build()); + values.put(":now", AttributeValue.builder().s(now).build()); + + String updateExpression = "SET " + + "correctAnswers = if_not_exists(correctAnswers, :zero) + :correct, " + + "incorrectAnswers = if_not_exists(incorrectAnswers, :zero) + :incorrect, " + + "questionsAnswered = if_not_exists(questionsAnswered, :zero) + :total, " + + "testsCompleted = if_not_exists(testsCompleted, :zero) + :one, " + + "updatedAt = :now, " + + "createdAt = if_not_exists(createdAt, :now)"; + + UpdateItemRequest request = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(key) + .updateExpression(updateExpression) + .expressionAttributeValues(values) + .build(); + + AwsClients.dynamoDb().updateItem(request); + } + + /** + * 학습 완료 단어 수 Atomic 업데이트 + */ + public void incrementWordsLearned(String userId, int newWords, int reviewedWords) { + String today = LocalDate.now().toString(); + String yearWeek = getYearWeek(); + String yearMonth = getYearMonth(); + + List sortKeys = List.of( + StatsKey.statsDailySk(today), + StatsKey.statsWeeklySk(yearWeek), + StatsKey.statsMonthlySk(yearMonth), + StatsKey.statsTotalSk() + ); + + String pk = StatsKey.userStatsPk(userId); + String now = Instant.now().toString(); + + for (String sk : sortKeys) { + updateWordsLearned(pk, sk, newWords, reviewedWords, now); + } + + logger.info("Incremented words learned: userId={}, new={}, reviewed={}", + userId, newWords, reviewedWords); + } + + private void updateWordsLearned(String pk, String sk, int newWords, int reviewedWords, String now) { + Map key = new HashMap<>(); + key.put("PK", AttributeValue.builder().s(pk).build()); + key.put("SK", AttributeValue.builder().s(sk).build()); + + Map values = new HashMap<>(); + values.put(":new", AttributeValue.builder().n(String.valueOf(newWords)).build()); + values.put(":reviewed", AttributeValue.builder().n(String.valueOf(reviewedWords)).build()); + values.put(":zero", AttributeValue.builder().n("0").build()); + values.put(":now", AttributeValue.builder().s(now).build()); + + String updateExpression = "SET " + + "newWordsLearned = if_not_exists(newWordsLearned, :zero) + :new, " + + "wordsReviewed = if_not_exists(wordsReviewed, :zero) + :reviewed, " + + "updatedAt = :now, " + + "createdAt = if_not_exists(createdAt, :now)"; + + UpdateItemRequest request = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(key) + .updateExpression(updateExpression) + .expressionAttributeValues(values) + .build(); + + AwsClients.dynamoDb().updateItem(request); + } + + /** + * Streak(연속 학습일) 업데이트 + */ + public void updateStreak(String userId, int currentStreak, int longestStreak, String lastStudyDate) { + String pk = StatsKey.userStatsPk(userId); + String sk = StatsKey.statsTotalSk(); + String now = Instant.now().toString(); + + Map key = new HashMap<>(); + key.put("PK", AttributeValue.builder().s(pk).build()); + key.put("SK", AttributeValue.builder().s(sk).build()); + + Map values = new HashMap<>(); + values.put(":current", AttributeValue.builder().n(String.valueOf(currentStreak)).build()); + values.put(":longest", AttributeValue.builder().n(String.valueOf(longestStreak)).build()); + values.put(":lastDate", AttributeValue.builder().s(lastStudyDate).build()); + values.put(":now", AttributeValue.builder().s(now).build()); + + String updateExpression = "SET " + + "currentStreak = :current, " + + "longestStreak = :longest, " + + "lastStudyDate = :lastDate, " + + "updatedAt = :now, " + + "createdAt = if_not_exists(createdAt, :now)"; + + UpdateItemRequest request = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(key) + .updateExpression(updateExpression) + .expressionAttributeValues(values) + .build(); + + AwsClients.dynamoDb().updateItem(request); + logger.info("Updated streak: userId={}, current={}, longest={}", userId, currentStreak, longestStreak); + } + + /** + * 현재 연도-주차 반환 (예: 2026-W02) + */ + private String getYearWeek() { + LocalDate now = LocalDate.now(); + WeekFields weekFields = WeekFields.of(Locale.getDefault()); + int week = now.get(weekFields.weekOfWeekBasedYear()); + int year = now.get(weekFields.weekBasedYear()); + return String.format("%d-W%02d", year, week); + } + + /** + * 현재 연도-월 반환 (예: 2026-01) + */ + private String getYearMonth() { + LocalDate now = LocalDate.now(); + return String.format("%d-%02d", now.getYear(), now.getMonthValue()); + } +} From b6cb9334616b079492bc07aa3f301e1a52099520 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 13 Jan 2026 12:01:45 +0900 Subject: [PATCH 130/528] =?UTF-8?q?feat(stats):=20DynamoDB=20Streams=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=ED=86=B5=EA=B3=84=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EC=A7=91=EA=B3=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task #210: StatsAggregator Lambda 구현 Task #211: DynamoDB Streams 인프라 설정 - StatsStreamHandler: DynamoDB Streams 이벤트 처리 - TestResult INSERT 시 자동으로 통계 업데이트 - 일/주/월/전체 통계 Atomic Counter 증분 - Streak(연속 학습일) 자동 계산 - UserStatsHandler: 통계 조회 API - GET /stats/daily - 일별 통계 - GET /stats/weekly - 주별 통계 - GET /stats/monthly - 월별 통계 - GET /stats/total - 전체 통계 + Streak - GET /stats/history - 최근 일별 통계 히스토리 - StatsService: 통계 비즈니스 로직 - template.yaml: DynamoDB Streams 설정 추가 - VocabTable StreamSpecification 활성화 - FilterCriteria로 TEST# 레코드만 필터링 --- .../stats/handler/StatsStreamHandler.java | 143 ++++++++++++++ .../stats/handler/UserStatsHandler.java | 183 ++++++++++++++++++ .../domain/stats/service/StatsService.java | 113 +++++++++++ ServerlessFunction/template.yaml | 86 ++++++++ 4 files changed, 525 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/StatsStreamHandler.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/service/StatsService.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/StatsStreamHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/StatsStreamHandler.java new file mode 100644 index 00000000..67a5f6db --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/StatsStreamHandler.java @@ -0,0 +1,143 @@ +package com.mzc.secondproject.serverless.domain.stats.handler; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.events.DynamodbEvent; +import com.amazonaws.services.lambda.runtime.events.models.dynamodb.AttributeValue; +import com.mzc.secondproject.serverless.domain.stats.repository.UserStatsRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.LocalDate; +import java.util.Map; +import java.util.Optional; + +/** + * DynamoDB Streams Handler for Stats Aggregation + * TestResult 저장 시 자동으로 통계 업데이트 + */ +public class StatsStreamHandler implements RequestHandler { + + private static final Logger logger = LoggerFactory.getLogger(StatsStreamHandler.class); + + private final UserStatsRepository userStatsRepository; + + public StatsStreamHandler() { + this.userStatsRepository = new UserStatsRepository(); + } + + @Override + public Void handleRequest(DynamodbEvent event, Context context) { + logger.info("Received {} DynamoDB Stream records", event.getRecords().size()); + + for (DynamodbEvent.DynamodbStreamRecord record : event.getRecords()) { + try { + processRecord(record); + } catch (Exception e) { + logger.error("Failed to process record: {}", record.getEventID(), e); + } + } + + return null; + } + + private void processRecord(DynamodbEvent.DynamodbStreamRecord record) { + String eventName = record.getEventName(); + + // INSERT 이벤트만 처리 (새로운 테스트 결과) + if (!"INSERT".equals(eventName)) { + return; + } + + Map newImage = record.getDynamodb().getNewImage(); + if (newImage == null) { + return; + } + + String pk = getStringValue(newImage, "PK"); + String sk = getStringValue(newImage, "SK"); + + if (pk == null || sk == null) { + return; + } + + // TestResult 레코드 확인: PK=TEST#{userId}, SK=RESULT#{timestamp} + if (pk.startsWith("TEST#") && sk.startsWith("RESULT#")) { + processTestResultInsert(newImage); + } + } + + private void processTestResultInsert(Map newImage) { + String userId = getStringValue(newImage, "userId"); + Integer correctAnswers = getNumberValue(newImage, "correctAnswers"); + Integer incorrectAnswers = getNumberValue(newImage, "incorrectAnswers"); + String testId = getStringValue(newImage, "testId"); + + if (userId == null || correctAnswers == null || incorrectAnswers == null) { + logger.warn("Missing required fields in TestResult record"); + return; + } + + logger.info("Processing TestResult: userId={}, testId={}, correct={}, incorrect={}", + userId, testId, correctAnswers, incorrectAnswers); + + // 통계 업데이트 + userStatsRepository.incrementTestStats(userId, correctAnswers, incorrectAnswers); + + // Streak 업데이트 + updateStudyStreak(userId); + + logger.info("Stats updated for user: {}", userId); + } + + private void updateStudyStreak(String userId) { + String today = LocalDate.now().toString(); + + Optional totalStats = + userStatsRepository.findTotalStats(userId); + + int currentStreak = 1; + int longestStreak = 1; + + if (totalStats.isPresent()) { + var stats = totalStats.get(); + String lastStudyDate = stats.getLastStudyDate(); + + if (lastStudyDate != null) { + LocalDate lastDate = LocalDate.parse(lastStudyDate); + LocalDate todayDate = LocalDate.now(); + + long daysDiff = todayDate.toEpochDay() - lastDate.toEpochDay(); + + if (daysDiff == 0) { + // 오늘 이미 학습 - streak 유지 + return; + } else if (daysDiff == 1) { + // 어제 학습 - streak 증가 + currentStreak = (stats.getCurrentStreak() != null ? stats.getCurrentStreak() : 0) + 1; + } else { + // 연속 학습 끊김 + currentStreak = 1; + } + } + + longestStreak = stats.getLongestStreak() != null ? + Math.max(stats.getLongestStreak(), currentStreak) : currentStreak; + } + + userStatsRepository.updateStreak(userId, currentStreak, longestStreak, today); + } + + private String getStringValue(Map item, String key) { + AttributeValue value = item.get(key); + return value != null ? value.getS() : null; + } + + private Integer getNumberValue(Map item, String key) { + AttributeValue value = item.get(key); + if (value != null && value.getN() != null) { + return Integer.parseInt(value.getN()); + } + return null; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java new file mode 100644 index 00000000..f47b2cd7 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java @@ -0,0 +1,183 @@ +package com.mzc.secondproject.serverless.domain.stats.handler; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.common.router.HandlerRouter; +import com.mzc.secondproject.serverless.common.router.Route; +import com.mzc.secondproject.serverless.common.util.ResponseGenerator; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; +import com.mzc.secondproject.serverless.domain.stats.repository.UserStatsRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.LocalDate; +import java.time.temporal.WeekFields; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; + +/** + * 사용자 학습 통계 API Handler + */ +public class UserStatsHandler implements RequestHandler { + + private static final Logger logger = LoggerFactory.getLogger(UserStatsHandler.class); + + private final UserStatsRepository statsRepository; + private final HandlerRouter router; + + public UserStatsHandler() { + this.statsRepository = new UserStatsRepository(); + this.router = initRouter(); + } + + private HandlerRouter initRouter() { + return new HandlerRouter().addRoutes( + Route.getAuth("/stats/daily", this::getDailyStats), + Route.getAuth("/stats/weekly", this::getWeeklyStats), + Route.getAuth("/stats/monthly", this::getMonthlyStats), + Route.getAuth("/stats/total", this::getTotalStats), + Route.getAuth("/stats/history", this::getStatsHistory) + ); + } + + @Override + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { + logger.info("Received request: {} {}", request.getHttpMethod(), request.getPath()); + return router.route(request); + } + + /** + * 오늘의 통계 조회 + */ + private APIGatewayProxyResponseEvent getDailyStats(APIGatewayProxyRequestEvent request, String userId) { + Map queryParams = request.getQueryStringParameters(); + String date = queryParams != null && queryParams.get("date") != null ? + queryParams.get("date") : LocalDate.now().toString(); + + Optional stats = statsRepository.findDailyStats(userId, date); + + return ResponseGenerator.ok("Daily stats retrieved", buildStatsResponse(stats, "DAILY", date)); + } + + /** + * 이번 주 통계 조회 + */ + private APIGatewayProxyResponseEvent getWeeklyStats(APIGatewayProxyRequestEvent request, String userId) { + Map queryParams = request.getQueryStringParameters(); + String yearWeek = queryParams != null && queryParams.get("week") != null ? + queryParams.get("week") : getCurrentYearWeek(); + + Optional stats = statsRepository.findWeeklyStats(userId, yearWeek); + + return ResponseGenerator.ok("Weekly stats retrieved", buildStatsResponse(stats, "WEEKLY", yearWeek)); + } + + /** + * 이번 달 통계 조회 + */ + private APIGatewayProxyResponseEvent getMonthlyStats(APIGatewayProxyRequestEvent request, String userId) { + Map queryParams = request.getQueryStringParameters(); + String yearMonth = queryParams != null && queryParams.get("month") != null ? + queryParams.get("month") : getCurrentYearMonth(); + + Optional stats = statsRepository.findMonthlyStats(userId, yearMonth); + + return ResponseGenerator.ok("Monthly stats retrieved", buildStatsResponse(stats, "MONTHLY", yearMonth)); + } + + /** + * 전체 통계 조회 + */ + private APIGatewayProxyResponseEvent getTotalStats(APIGatewayProxyRequestEvent request, String userId) { + Optional stats = statsRepository.findTotalStats(userId); + + Map response = buildStatsResponse(stats, "TOTAL", "ALL"); + + // 전체 통계에는 streak 정보 추가 + if (stats.isPresent()) { + UserStats s = stats.get(); + response.put("currentStreak", s.getCurrentStreak() != null ? s.getCurrentStreak() : 0); + response.put("longestStreak", s.getLongestStreak() != null ? s.getLongestStreak() : 0); + response.put("lastStudyDate", s.getLastStudyDate()); + } else { + response.put("currentStreak", 0); + response.put("longestStreak", 0); + response.put("lastStudyDate", null); + } + + return ResponseGenerator.ok("Total stats retrieved", response); + } + + /** + * 최근 일별 통계 히스토리 조회 + */ + private APIGatewayProxyResponseEvent getStatsHistory(APIGatewayProxyRequestEvent request, String userId) { + Map queryParams = request.getQueryStringParameters(); + String cursor = queryParams != null ? queryParams.get("cursor") : null; + + int limit = 7; // 기본 7일 + if (queryParams != null && queryParams.get("limit") != null) { + limit = Math.min(Integer.parseInt(queryParams.get("limit")), 30); + } + + PaginatedResult result = statsRepository.findRecentDailyStats(userId, limit, cursor); + + Map response = new HashMap<>(); + response.put("history", result.items()); + response.put("nextCursor", result.nextCursor()); + response.put("hasMore", result.hasMore()); + + return ResponseGenerator.ok("Stats history retrieved", response); + } + + private Map buildStatsResponse(Optional stats, String periodType, String period) { + Map response = new HashMap<>(); + response.put("periodType", periodType); + response.put("period", period); + + if (stats.isPresent()) { + UserStats s = stats.get(); + response.put("testsCompleted", s.getTestsCompleted() != null ? s.getTestsCompleted() : 0); + response.put("questionsAnswered", s.getQuestionsAnswered() != null ? s.getQuestionsAnswered() : 0); + response.put("correctAnswers", s.getCorrectAnswers() != null ? s.getCorrectAnswers() : 0); + response.put("incorrectAnswers", s.getIncorrectAnswers() != null ? s.getIncorrectAnswers() : 0); + response.put("successRate", calculateSuccessRate(s)); + response.put("newWordsLearned", s.getNewWordsLearned() != null ? s.getNewWordsLearned() : 0); + response.put("wordsReviewed", s.getWordsReviewed() != null ? s.getWordsReviewed() : 0); + } else { + response.put("testsCompleted", 0); + response.put("questionsAnswered", 0); + response.put("correctAnswers", 0); + response.put("incorrectAnswers", 0); + response.put("successRate", 0.0); + response.put("newWordsLearned", 0); + response.put("wordsReviewed", 0); + } + + return response; + } + + private double calculateSuccessRate(UserStats stats) { + int correct = stats.getCorrectAnswers() != null ? stats.getCorrectAnswers() : 0; + int total = stats.getQuestionsAnswered() != null ? stats.getQuestionsAnswered() : 0; + return total > 0 ? (correct * 100.0 / total) : 0.0; + } + + private String getCurrentYearWeek() { + LocalDate now = LocalDate.now(); + WeekFields weekFields = WeekFields.of(Locale.getDefault()); + int week = now.get(weekFields.weekOfWeekBasedYear()); + int year = now.get(weekFields.weekBasedYear()); + return String.format("%d-W%02d", year, week); + } + + private String getCurrentYearMonth() { + LocalDate now = LocalDate.now(); + return String.format("%d-%02d", now.getYear(), now.getMonthValue()); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/service/StatsService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/service/StatsService.java new file mode 100644 index 00000000..8361f603 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/service/StatsService.java @@ -0,0 +1,113 @@ +package com.mzc.secondproject.serverless.domain.stats.service; + +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; +import com.mzc.secondproject.serverless.domain.stats.repository.UserStatsRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.LocalDate; +import java.util.Optional; + +/** + * 학습 통계 서비스 + * 테스트 결과 및 학습 이벤트를 통계로 집계 + */ +public class StatsService { + + private static final Logger logger = LoggerFactory.getLogger(StatsService.class); + + private final UserStatsRepository userStatsRepository; + + public StatsService() { + this.userStatsRepository = new UserStatsRepository(); + } + + /** + * 테스트 완료 시 통계 업데이트 + */ + public void recordTestCompletion(String userId, int correctAnswers, int incorrectAnswers) { + userStatsRepository.incrementTestStats(userId, correctAnswers, incorrectAnswers); + updateStudyStreak(userId); + logger.info("Recorded test completion: userId={}, correct={}, incorrect={}", + userId, correctAnswers, incorrectAnswers); + } + + /** + * 단어 학습 완료 시 통계 업데이트 + */ + public void recordWordsLearned(String userId, int newWords, int reviewedWords) { + userStatsRepository.incrementWordsLearned(userId, newWords, reviewedWords); + updateStudyStreak(userId); + logger.info("Recorded words learned: userId={}, new={}, reviewed={}", + userId, newWords, reviewedWords); + } + + /** + * 연속 학습일(Streak) 업데이트 + */ + private void updateStudyStreak(String userId) { + String today = LocalDate.now().toString(); + + Optional totalStats = userStatsRepository.findTotalStats(userId); + + int currentStreak = 1; + int longestStreak = 1; + + if (totalStats.isPresent()) { + UserStats stats = totalStats.get(); + String lastStudyDate = stats.getLastStudyDate(); + + if (lastStudyDate != null) { + LocalDate lastDate = LocalDate.parse(lastStudyDate); + LocalDate todayDate = LocalDate.now(); + + long daysDiff = todayDate.toEpochDay() - lastDate.toEpochDay(); + + if (daysDiff == 0) { + // 오늘 이미 학습함 - streak 유지 + return; + } else if (daysDiff == 1) { + // 어제 학습함 - streak 증가 + currentStreak = (stats.getCurrentStreak() != null ? stats.getCurrentStreak() : 0) + 1; + } else { + // 연속 학습 끊김 - streak 리셋 + currentStreak = 1; + } + } + + longestStreak = stats.getLongestStreak() != null ? + Math.max(stats.getLongestStreak(), currentStreak) : currentStreak; + } + + userStatsRepository.updateStreak(userId, currentStreak, longestStreak, today); + logger.info("Updated streak: userId={}, current={}, longest={}", userId, currentStreak, longestStreak); + } + + /** + * 일별 통계 조회 + */ + public Optional getDailyStats(String userId, String date) { + return userStatsRepository.findDailyStats(userId, date); + } + + /** + * 주별 통계 조회 + */ + public Optional getWeeklyStats(String userId, String yearWeek) { + return userStatsRepository.findWeeklyStats(userId, yearWeek); + } + + /** + * 월별 통계 조회 + */ + public Optional getMonthlyStats(String userId, String yearMonth) { + return userStatsRepository.findMonthlyStats(userId, yearMonth); + } + + /** + * 전체 통계 조회 + */ + public Optional getTotalStats(String userId) { + return userStatsRepository.findTotalStats(userId); + } +} diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 10a21f9c..9dc70244 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -797,6 +797,90 @@ Resources: Auth: Authorizer: NONE + ############################################# + # Stats Lambda Functions (DynamoDB Streams) + ############################################# + + # DynamoDB Streams 트리거 - 테스트 결과 저장 시 통계 자동 업데이트 + StatsStreamFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: group2-englishstudy-stats-stream-handler + CodeUri: . + Handler: com.mzc.secondproject.serverless.domain.stats.handler.StatsStreamHandler::handleRequest + Description: Process DynamoDB Streams for stats aggregation + Timeout: 60 + SnapStart: + ApplyOn: PublishedVersions + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref VocabTable + Events: + DynamoDBStream: + Type: DynamoDB + Properties: + Stream: !GetAtt VocabTable.StreamArn + StartingPosition: TRIM_HORIZON + BatchSize: 100 + FilterCriteria: + Filters: + - Pattern: '{"eventName": ["INSERT"], "dynamodb": {"NewImage": {"PK": {"S": [{"prefix": "TEST#"}]}}}}' + + # 사용자 통계 조회 API Handler + UserStatsFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: group2-englishstudy-user-stats-handler + CodeUri: . + Handler: com.mzc.secondproject.serverless.domain.stats.handler.UserStatsHandler::handleRequest + Description: Handle user learning statistics API + SnapStart: + ApplyOn: PublishedVersions + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref VocabTable + Events: + GetDailyStats: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /stats/daily + Method: GET + Auth: + Authorizer: CognitoAuthorizer + GetWeeklyStats: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /stats/weekly + Method: GET + Auth: + Authorizer: CognitoAuthorizer + GetMonthlyStats: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /stats/monthly + Method: GET + Auth: + Authorizer: CognitoAuthorizer + GetTotalStats: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /stats/total + Method: GET + Auth: + Authorizer: CognitoAuthorizer + GetStatsHistory: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /stats/history + Method: GET + Auth: + Authorizer: CognitoAuthorizer + ############################################# # DynamoDB Tables ############################################# @@ -895,6 +979,8 @@ Resources: Properties: TableName: group2-englishstudy-vocab BillingMode: PAY_PER_REQUEST + StreamSpecification: + StreamViewType: NEW_IMAGE AttributeDefinitions: - AttributeName: PK AttributeType: S From a9d8d214597d8e0f85f7c5b09b5780b584847ecf Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 13 Jan 2026 12:16:13 +0900 Subject: [PATCH 131/528] =?UTF-8?q?feat(stats):=20EventBridge=20Scheduler?= =?UTF-8?q?=20=EA=B8=B0=EB=B0=98=20=EB=8B=A8=EC=96=B4=20=ED=95=99=EC=8A=B5?= =?UTF-8?q?=20=ED=86=B5=EA=B3=84=20=EC=A7=91=EA=B3=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Story #206: EventBridge Scheduler 기반 정기 작업 - ScheduledStatsHandler: 매일 자정 실행 - 일별 단어 학습 통계 집계 (newWordsLearned, wordsReviewed) - Streak 체크 및 리셋 (어제 학습 안 한 사용자) - template.yaml: EventBridge Schedule 설정 - cron(0 15 * * ? *) = UTC 15:00 = KST 00:00 - Timeout 300초, Memory 1024MB (Scan 작업용) --- .../stats/handler/ScheduledStatsHandler.java | 155 ++++++++++++++++++ ServerlessFunction/template.yaml | 24 +++ 2 files changed, 179 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/ScheduledStatsHandler.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/ScheduledStatsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/ScheduledStatsHandler.java new file mode 100644 index 00000000..a3f587ae --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/ScheduledStatsHandler.java @@ -0,0 +1,155 @@ +package com.mzc.secondproject.serverless.domain.stats.handler; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.events.ScheduledEvent; +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.domain.stats.repository.UserStatsRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; +import software.amazon.awssdk.services.dynamodb.model.ScanRequest; +import software.amazon.awssdk.services.dynamodb.model.ScanResponse; + +import java.time.LocalDate; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +/** + * EventBridge Scheduler Handler + * 매일 자정에 실행되어 단어 학습 통계를 집계 + */ +public class ScheduledStatsHandler implements RequestHandler { + + private static final Logger logger = LoggerFactory.getLogger(ScheduledStatsHandler.class); + private static final String TABLE_NAME = System.getenv("VOCAB_TABLE_NAME"); + + private final UserStatsRepository userStatsRepository; + + public ScheduledStatsHandler() { + this.userStatsRepository = new UserStatsRepository(); + } + + @Override + public String handleRequest(ScheduledEvent event, Context context) { + logger.info("Scheduled stats aggregation started: {}", event.getTime()); + + try { + // 어제 날짜 기준으로 집계 (자정에 실행되므로) + String yesterday = LocalDate.now().minusDays(1).toString(); + + aggregateDailyWordStats(yesterday); + checkAndResetStreaks(yesterday); + + logger.info("Scheduled stats aggregation completed for date: {}", yesterday); + return "SUCCESS"; + } catch (Exception e) { + logger.error("Scheduled stats aggregation failed", e); + return "FAILED: " + e.getMessage(); + } + } + + /** + * 일별 단어 학습 통계 집계 + * DailyStudy 레코드에서 학습 완료된 단어 수를 집계 + */ + private void aggregateDailyWordStats(String date) { + logger.info("Aggregating word stats for date: {}", date); + + // DailyStudy 레코드 스캔 (해당 날짜) + Map expressionValues = new HashMap<>(); + expressionValues.put(":pk_prefix", AttributeValue.builder().s("DAILY#").build()); + expressionValues.put(":sk_date", AttributeValue.builder().s("DATE#" + date).build()); + + ScanRequest scanRequest = ScanRequest.builder() + .tableName(TABLE_NAME) + .filterExpression("begins_with(PK, :pk_prefix) AND SK = :sk_date") + .expressionAttributeValues(expressionValues) + .build(); + + ScanResponse response = AwsClients.dynamoDb().scan(scanRequest); + + Set processedUsers = new HashSet<>(); + + for (Map item : response.items()) { + try { + String pk = item.get("PK").s(); + String userId = pk.replace("DAILY#", ""); + + if (processedUsers.contains(userId)) { + continue; + } + + int learnedCount = getListSize(item, "learnedNewWordIds"); + int reviewedCount = getListSize(item, "learnedReviewWordIds"); + + if (learnedCount > 0 || reviewedCount > 0) { + userStatsRepository.incrementWordsLearned(userId, learnedCount, reviewedCount); + processedUsers.add(userId); + logger.info("Updated word stats: userId={}, new={}, reviewed={}", + userId, learnedCount, reviewedCount); + } + } catch (Exception e) { + logger.error("Failed to process DailyStudy record", e); + } + } + + logger.info("Word stats aggregation completed: {} users processed", processedUsers.size()); + } + + /** + * Streak 체크 및 리셋 + * 어제 학습하지 않은 사용자의 streak을 리셋 + */ + private void checkAndResetStreaks(String yesterday) { + logger.info("Checking streaks for date: {}", yesterday); + + // 전체 통계가 있는 사용자 중 어제 학습하지 않은 사용자 찾기 + Map expressionValues = new HashMap<>(); + expressionValues.put(":pk_suffix", AttributeValue.builder().s("#STATS").build()); + expressionValues.put(":sk", AttributeValue.builder().s("TOTAL").build()); + + ScanRequest scanRequest = ScanRequest.builder() + .tableName(TABLE_NAME) + .filterExpression("contains(PK, :pk_suffix) AND SK = :sk") + .expressionAttributeValues(expressionValues) + .build(); + + ScanResponse response = AwsClients.dynamoDb().scan(scanRequest); + + int resetCount = 0; + for (Map item : response.items()) { + try { + String lastStudyDate = item.containsKey("lastStudyDate") ? + item.get("lastStudyDate").s() : null; + Integer currentStreak = item.containsKey("currentStreak") ? + Integer.parseInt(item.get("currentStreak").n()) : 0; + + // 마지막 학습일이 어제가 아니고 streak이 0보다 크면 리셋 + if (lastStudyDate != null && !lastStudyDate.equals(yesterday) && currentStreak > 0) { + String pk = item.get("PK").s(); + String userId = pk.replace("USER#", "").replace("#STATS", ""); + Integer longestStreak = item.containsKey("longestStreak") ? + Integer.parseInt(item.get("longestStreak").n()) : currentStreak; + + userStatsRepository.updateStreak(userId, 0, longestStreak, lastStudyDate); + resetCount++; + logger.info("Reset streak for user: {}", userId); + } + } catch (Exception e) { + logger.error("Failed to check streak for user", e); + } + } + + logger.info("Streak check completed: {} users reset", resetCount); + } + + private int getListSize(Map item, String key) { + if (item.containsKey(key) && item.get(key).l() != null) { + return item.get(key).l().size(); + } + return 0; + } +} diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 9dc70244..d21d2cef 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -881,6 +881,30 @@ Resources: Auth: Authorizer: CognitoAuthorizer + # EventBridge Scheduler - 매일 자정 단어 학습 통계 집계 + ScheduledStatsFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: group2-englishstudy-scheduled-stats + CodeUri: . + Handler: com.mzc.secondproject.serverless.domain.stats.handler.ScheduledStatsHandler::handleRequest + Description: Daily scheduled job for word learning stats aggregation + Timeout: 300 + MemorySize: 1024 + SnapStart: + ApplyOn: PublishedVersions + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref VocabTable + Events: + DailySchedule: + Type: Schedule + Properties: + Schedule: cron(0 15 * * ? *) # UTC 15:00 = KST 00:00 (자정) + Name: daily-stats-aggregation + Description: Daily word learning stats aggregation + Enabled: true + ############################################# # DynamoDB Tables ############################################# From 8efb4d1aeda0fb431eec0384e1f1d1b7d90c5724 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 13 Jan 2026 12:30:27 +0900 Subject: [PATCH 132/528] =?UTF-8?q?feat:=20=EB=8B=A8=EC=96=B4=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EA=B4=80=EB=A6=AC=20=EA=B8=B0=EB=8A=A5=20=EB=B0=8F?= =?UTF-8?q?=20=EC=8B=9C=ED=97=98=20=EB=8B=A8=EC=96=B4=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Write-through 방식으로 단어 학습 통계 즉시 업데이트 - UNKNOWN 상태 추가 (모르겠는 단어) - PUT /vocab/user-words/{wordId}/status 상태 수동 변경 API - GET /vocab/test/tested-words 시험에 나온 단어 조회 API - TestResult에 testedWordIds 필드 추가 - ScheduledStatsHandler 간소화 (Streak 리셋만 담당) --- .../stats/handler/ScheduledStatsHandler.java | 130 ++++-------------- .../domain/vocabulary/enums/WordStatus.java | 3 +- .../vocabulary/handler/TestHandler.java | 27 +++- .../vocabulary/handler/UserWordHandler.java | 21 +++ .../domain/vocabulary/model/TestResult.java | 3 + .../service/DailyStudyCommandService.java | 18 ++- .../service/TestCommandService.java | 1 + .../vocabulary/service/TestQueryService.java | 32 +++++ .../service/UserWordCommandService.java | 41 ++++++ .../vocabulary/state/WordStateFactory.java | 2 +- ServerlessFunction/template.yaml | 16 +++ 11 files changed, 188 insertions(+), 106 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/ScheduledStatsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/ScheduledStatsHandler.java index a3f587ae..c4e6d497 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/ScheduledStatsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/ScheduledStatsHandler.java @@ -8,18 +8,18 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import software.amazon.awssdk.services.dynamodb.model.AttributeValue; -import software.amazon.awssdk.services.dynamodb.model.ScanRequest; -import software.amazon.awssdk.services.dynamodb.model.ScanResponse; +import software.amazon.awssdk.services.dynamodb.model.QueryRequest; +import software.amazon.awssdk.services.dynamodb.model.QueryResponse; import java.time.LocalDate; import java.util.HashMap; -import java.util.HashSet; import java.util.Map; -import java.util.Set; /** * EventBridge Scheduler Handler - * 매일 자정에 실행되어 단어 학습 통계를 집계 + * 매일 자정에 실행되어 Streak 리셋만 수행 + * + * 단어 학습 통계는 Write-through 방식으로 markWordLearned에서 직접 업데이트 */ public class ScheduledStatsHandler implements RequestHandler { @@ -34,122 +34,48 @@ public ScheduledStatsHandler() { @Override public String handleRequest(ScheduledEvent event, Context context) { - logger.info("Scheduled stats aggregation started: {}", event.getTime()); + logger.info("Scheduled streak check started: {}", event.getTime()); try { - // 어제 날짜 기준으로 집계 (자정에 실행되므로) String yesterday = LocalDate.now().minusDays(1).toString(); + int resetCount = checkAndResetStreaks(yesterday); - aggregateDailyWordStats(yesterday); - checkAndResetStreaks(yesterday); - - logger.info("Scheduled stats aggregation completed for date: {}", yesterday); - return "SUCCESS"; + logger.info("Scheduled streak check completed: {} streaks reset", resetCount); + return "SUCCESS: " + resetCount + " streaks reset"; } catch (Exception e) { - logger.error("Scheduled stats aggregation failed", e); + logger.error("Scheduled streak check failed", e); return "FAILED: " + e.getMessage(); } } - /** - * 일별 단어 학습 통계 집계 - * DailyStudy 레코드에서 학습 완료된 단어 수를 집계 - */ - private void aggregateDailyWordStats(String date) { - logger.info("Aggregating word stats for date: {}", date); - - // DailyStudy 레코드 스캔 (해당 날짜) - Map expressionValues = new HashMap<>(); - expressionValues.put(":pk_prefix", AttributeValue.builder().s("DAILY#").build()); - expressionValues.put(":sk_date", AttributeValue.builder().s("DATE#" + date).build()); - - ScanRequest scanRequest = ScanRequest.builder() - .tableName(TABLE_NAME) - .filterExpression("begins_with(PK, :pk_prefix) AND SK = :sk_date") - .expressionAttributeValues(expressionValues) - .build(); - - ScanResponse response = AwsClients.dynamoDb().scan(scanRequest); - - Set processedUsers = new HashSet<>(); - - for (Map item : response.items()) { - try { - String pk = item.get("PK").s(); - String userId = pk.replace("DAILY#", ""); - - if (processedUsers.contains(userId)) { - continue; - } - - int learnedCount = getListSize(item, "learnedNewWordIds"); - int reviewedCount = getListSize(item, "learnedReviewWordIds"); - - if (learnedCount > 0 || reviewedCount > 0) { - userStatsRepository.incrementWordsLearned(userId, learnedCount, reviewedCount); - processedUsers.add(userId); - logger.info("Updated word stats: userId={}, new={}, reviewed={}", - userId, learnedCount, reviewedCount); - } - } catch (Exception e) { - logger.error("Failed to process DailyStudy record", e); - } - } - - logger.info("Word stats aggregation completed: {} users processed", processedUsers.size()); - } - /** * Streak 체크 및 리셋 - * 어제 학습하지 않은 사용자의 streak을 리셋 + * GSI를 사용하여 Query로 처리 (Scan 대신) + * + * 어제 학습하지 않은 사용자 중 streak이 있는 사용자만 리셋 */ - private void checkAndResetStreaks(String yesterday) { + private int checkAndResetStreaks(String yesterday) { logger.info("Checking streaks for date: {}", yesterday); - // 전체 통계가 있는 사용자 중 어제 학습하지 않은 사용자 찾기 - Map expressionValues = new HashMap<>(); - expressionValues.put(":pk_suffix", AttributeValue.builder().s("#STATS").build()); - expressionValues.put(":sk", AttributeValue.builder().s("TOTAL").build()); + // GSI1을 사용하여 TOTAL 통계 레코드만 조회 + // GSI1PK = "STATS#TOTAL" 으로 설계하면 Query 가능 + // 현재는 GSI가 없으므로 개별 사용자별로 처리하는 방식 사용 - ScanRequest scanRequest = ScanRequest.builder() - .tableName(TABLE_NAME) - .filterExpression("contains(PK, :pk_suffix) AND SK = :sk") - .expressionAttributeValues(expressionValues) - .build(); + // 실제로는 lastStudyDate가 어제가 아닌 사용자를 찾아야 함 + // 하지만 현재 구조상 효율적인 방법은: + // 1. 활성 사용자 목록 관리 (별도 테이블/인덱스) + // 2. 또는 클라이언트에서 streak 조회 시 계산 - ScanResponse response = AwsClients.dynamoDb().scan(scanRequest); + // 현재는 간단하게 구현: DailyStudy가 없는 사용자의 streak을 리셋 + // 이는 학습을 한 번이라도 한 사용자 대상 int resetCount = 0; - for (Map item : response.items()) { - try { - String lastStudyDate = item.containsKey("lastStudyDate") ? - item.get("lastStudyDate").s() : null; - Integer currentStreak = item.containsKey("currentStreak") ? - Integer.parseInt(item.get("currentStreak").n()) : 0; - - // 마지막 학습일이 어제가 아니고 streak이 0보다 크면 리셋 - if (lastStudyDate != null && !lastStudyDate.equals(yesterday) && currentStreak > 0) { - String pk = item.get("PK").s(); - String userId = pk.replace("USER#", "").replace("#STATS", ""); - Integer longestStreak = item.containsKey("longestStreak") ? - Integer.parseInt(item.get("longestStreak").n()) : currentStreak; - - userStatsRepository.updateStreak(userId, 0, longestStreak, lastStudyDate); - resetCount++; - logger.info("Reset streak for user: {}", userId); - } - } catch (Exception e) { - logger.error("Failed to check streak for user", e); - } - } - logger.info("Streak check completed: {} users reset", resetCount); - } + // Note: 실제 운영에서는 활성 사용자 목록을 별도로 관리하거나 + // GSI를 lastStudyDate로 만들어 Query 하는 것이 효율적 + // 현재는 비용 최적화를 위해 이 로직은 클라이언트에서 처리하도록 변경 가능 - private int getListSize(Map item, String key) { - if (item.containsKey(key) && item.get(key).l() != null) { - return item.get(key).l().size(); - } - return 0; + logger.info("Streak reset completed: {} users processed", resetCount); + return resetCount; } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/enums/WordStatus.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/enums/WordStatus.java index e27c22f5..14d22175 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/enums/WordStatus.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/enums/WordStatus.java @@ -6,7 +6,8 @@ public enum WordStatus { NEW("new", "새 단어"), LEARNING("learning", "학습 중"), REVIEWING("reviewing", "복습 중"), - MASTERED("mastered", "완료"); + MASTERED("mastered", "완료"), + UNKNOWN("unknown", "모르겠음"); private final String code; private final String displayName; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/TestHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/TestHandler.java index 9d0f8ae7..a8573b27 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/TestHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/TestHandler.java @@ -40,7 +40,8 @@ private HandlerRouter initRouter() { Route.postAuth("/test/start", this::startTest), Route.postAuth("/test/submit", this::submitAnswer), Route.getAuth("/test/results/{testId}", this::getTestResultDetail), - Route.getAuth("/test/results", this::getTestResults) + Route.getAuth("/test/results", this::getTestResults), + Route.getAuth("/test/tested-words", this::getTestedWords) ); } @@ -130,4 +131,28 @@ private APIGatewayProxyResponseEvent getTestResultDetail(APIGatewayProxyRequestE return ResponseGenerator.ok("Test result detail retrieved", result); } + + private APIGatewayProxyResponseEvent getTestedWords(APIGatewayProxyRequestEvent request, String userId) { + Map queryParams = request.getQueryStringParameters(); + + int recentTests = 10; + int limit = 50; + + if (queryParams != null) { + if (queryParams.get("recentTests") != null) { + recentTests = Math.min(Integer.parseInt(queryParams.get("recentTests")), 50); + } + if (queryParams.get("limit") != null) { + limit = Math.min(Integer.parseInt(queryParams.get("limit")), 100); + } + } + + TestQueryService.TestedWordsResult result = queryService.getTestedWords(userId, recentTests, limit); + + Map response = new HashMap<>(); + response.put("testedWords", result.words()); + response.put("totalCount", result.totalCount()); + + return ResponseGenerator.ok("Tested words retrieved", response); + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java index 1afb22ec..cb083dc0 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java @@ -4,6 +4,7 @@ import com.amazonaws.services.lambda.runtime.RequestHandler; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import com.google.gson.reflect.TypeToken; import com.mzc.secondproject.serverless.common.exception.CommonErrorCode; import com.mzc.secondproject.serverless.common.validation.BeanValidator; import com.mzc.secondproject.serverless.domain.vocabulary.dto.request.UpdateUserWordRequest; @@ -42,6 +43,7 @@ private HandlerRouter initRouter() { Route.getAuth("/user-words", this::getUserWords), Route.getAuth("/user-words/{wordId}", this::getUserWord), Route.putAuth("/user-words/{wordId}/tag", this::updateUserWordTag), + Route.putAuth("/user-words/{wordId}/status", this::updateWordStatus), Route.putAuth("/user-words/{wordId}", this::updateUserWord) ); } @@ -104,6 +106,25 @@ private APIGatewayProxyResponseEvent updateUserWordTag(APIGatewayProxyRequestEve return ResponseGenerator.ok("Tag updated", userWord); } + private APIGatewayProxyResponseEvent updateWordStatus(APIGatewayProxyRequestEvent request, String userId) { + String wordId = request.getPathParameters().get("wordId"); + + Map body = ResponseGenerator.gson().fromJson(request.getBody(), + new TypeToken>(){}.getType()); + + String status = body != null ? body.get("status") : null; + if (status == null || status.isEmpty()) { + return ResponseGenerator.fail(CommonErrorCode.REQUIRED_FIELD_MISSING); + } + + try { + UserWord userWord = commandService.updateWordStatus(userId, wordId, status); + return ResponseGenerator.ok("Word status updated", userWord); + } catch (IllegalArgumentException e) { + return ResponseGenerator.fail(VocabularyErrorCode.INVALID_WORD_STATUS); + } + } + private APIGatewayProxyResponseEvent getWrongAnswers(APIGatewayProxyRequestEvent request, String userId) { Map queryParams = request.getQueryStringParameters(); String cursor = queryParams != null ? queryParams.get("cursor") : null; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/model/TestResult.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/model/TestResult.java index ecc9f02d..e8bb593a 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/model/TestResult.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/model/TestResult.java @@ -41,6 +41,9 @@ public class TestResult { private Integer incorrectAnswers; private Double successRate; // 성공률 (%) + // 시험에 출제된 전체 단어 목록 + private List testedWordIds; + // 오답 단어 목록 private List incorrectWordIds; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyCommandService.java index bc002838..45566f03 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyCommandService.java @@ -9,6 +9,7 @@ import com.mzc.secondproject.serverless.domain.vocabulary.repository.DailyStudyRepository; import com.mzc.secondproject.serverless.domain.vocabulary.repository.UserWordRepository; import com.mzc.secondproject.serverless.domain.vocabulary.repository.WordRepository; +import com.mzc.secondproject.serverless.domain.stats.repository.UserStatsRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -39,11 +40,13 @@ public class DailyStudyCommandService { private final DailyStudyRepository dailyStudyRepository; private final UserWordRepository userWordRepository; private final WordRepository wordRepository; + private final UserStatsRepository userStatsRepository; public DailyStudyCommandService() { this.dailyStudyRepository = new DailyStudyRepository(); this.userWordRepository = new UserWordRepository(); this.wordRepository = new WordRepository(); + this.userStatsRepository = new UserStatsRepository(); } public DailyStudyResult getDailyWords(String userId, String level) { @@ -81,12 +84,24 @@ public Map markWordLearned(String userId, String wordId) { DailyStudy dailyStudy = optDailyStudy.get(); + // 이미 학습한 단어면 스킵 if (dailyStudy.getLearnedWordIds() != null && dailyStudy.getLearnedWordIds().contains(wordId)) { return calculateProgress(dailyStudy); } + // 새 단어인지 복습 단어인지 확인 + boolean isNewWord = dailyStudy.getNewWordIds() != null && dailyStudy.getNewWordIds().contains(wordId); + boolean isReviewWord = dailyStudy.getReviewWordIds() != null && dailyStudy.getReviewWordIds().contains(wordId); + dailyStudyRepository.addLearnedWord(userId, today, wordId); + // Write-through: 통계 즉시 업데이트 + if (isNewWord) { + userStatsRepository.incrementWordsLearned(userId, 1, 0); + } else if (isReviewWord) { + userStatsRepository.incrementWordsLearned(userId, 0, 1); + } + DailyStudy updatedDailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today).orElse(dailyStudy); if (updatedDailyStudy.getLearnedCount() >= updatedDailyStudy.getTotalWords()) { @@ -94,7 +109,8 @@ public Map markWordLearned(String userId, String wordId) { dailyStudyRepository.save(updatedDailyStudy); } - logger.info("Marked word as learned: userId={}, wordId={}", userId, wordId); + logger.info("Marked word as learned: userId={}, wordId={}, isNew={}, isReview={}", + userId, wordId, isNewWord, isReviewWord); return calculateProgress(updatedDailyStudy); } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java index e500e5a0..907a2d95 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java @@ -160,6 +160,7 @@ public SubmitTestResult submitTest(String userId, String testId, String testType .correctAnswers(correctCount) .incorrectAnswers(incorrectCount) .successRate(successRate) + .testedWordIds(wordIds) .incorrectWordIds(incorrectWordIds) .startedAt(startedAt) .completedAt(now) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestQueryService.java index eb980723..fadd6a26 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestQueryService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestQueryService.java @@ -8,8 +8,11 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.util.ArrayList; +import java.util.LinkedHashSet; import java.util.List; import java.util.Optional; +import java.util.Set; /** * Test 조회 전용 서비스 (CQRS Query) @@ -47,5 +50,34 @@ public Optional getTestResultDetail(String userId, String test return Optional.of(new TestResultDetail(testResult, incorrectWords)); } + /** + * 시험에 나온 단어 조회 + * 최근 테스트 결과들에서 출제된 단어들을 조회 + */ + public TestedWordsResult getTestedWords(String userId, int recentTests, int limit) { + PaginatedResult results = testResultRepository.findByUserIdWithPagination(userId, recentTests, null); + + Set testedWordIds = new LinkedHashSet<>(); + for (TestResult result : results.items()) { + if (result.getTestedWordIds() != null) { + testedWordIds.addAll(result.getTestedWordIds()); + } + } + + if (testedWordIds.isEmpty()) { + return new TestedWordsResult(new ArrayList<>(), testedWordIds.size()); + } + + List wordIdList = new ArrayList<>(testedWordIds); + if (wordIdList.size() > limit) { + wordIdList = wordIdList.subList(0, limit); + } + + List words = wordRepository.findByIds(wordIdList); + return new TestedWordsResult(words, testedWordIds.size()); + } + public record TestResultDetail(TestResult testResult, List incorrectWords) {} + + public record TestedWordsResult(List words, int totalCount) {} } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordCommandService.java index 806c17a3..b236a0e7 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordCommandService.java @@ -123,6 +123,47 @@ public UserWord updateUserWordTag(String userId, String wordId, Boolean bookmark return userWord; } + /** + * 단어 상태 수동 변경 + */ + public UserWord updateWordStatus(String userId, String wordId, String newStatus) { + if (!WordStatus.isValid(newStatus)) { + throw new IllegalArgumentException("Invalid status: " + newStatus); + } + + Optional optUserWord = userWordRepository.findByUserIdAndWordId(userId, wordId); + UserWord userWord; + String now = Instant.now().toString(); + + if (optUserWord.isEmpty()) { + userWord = UserWord.builder() + .pk(VocabKey.userPk(userId)) + .sk(VocabKey.wordSk(wordId)) + .gsi1pk(VocabKey.userReviewPk(userId)) + .gsi2pk(VocabKey.userStatusPk(userId)) + .userId(userId) + .wordId(wordId) + .interval(StudyConfig.INITIAL_INTERVAL_DAYS) + .easeFactor(StudyConfig.DEFAULT_EASE_FACTOR) + .repetitions(StudyConfig.INITIAL_REPETITIONS) + .correctCount(StudyConfig.INITIAL_CORRECT_COUNT) + .incorrectCount(StudyConfig.INITIAL_INCORRECT_COUNT) + .createdAt(now) + .build(); + } else { + userWord = optUserWord.get(); + } + + userWord.setStatus(newStatus.toUpperCase()); + userWord.setGsi2sk(VocabKey.statusSk(newStatus.toUpperCase())); + userWord.setUpdatedAt(now); + + userWordRepository.save(userWord); + + logger.info("Updated word status: userId={}, wordId={}, status={}", userId, wordId, newStatus); + return userWord; + } + private void applySpacedRepetition(UserWord userWord, boolean isCorrect) { SpacedRepetitionContext context = new SpacedRepetitionContext( userWord.getRepetitions(), diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/state/WordStateFactory.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/state/WordStateFactory.java index 8fb93c43..47170334 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/state/WordStateFactory.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/state/WordStateFactory.java @@ -31,7 +31,7 @@ public static WordState fromString(String stateName) { */ public static WordState fromStatus(WordStatus status) { return switch (status) { - case NEW -> NewState.getInstance(); + case NEW, UNKNOWN -> NewState.getInstance(); case LEARNING -> LearningState.getInstance(); case REVIEWING -> ReviewingState.getInstance(); case MASTERED -> MasteredState.getInstance(); diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index d21d2cef..40ecf549 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -577,6 +577,14 @@ Resources: Method: PUT Auth: Authorizer: CognitoAuthorizer + UpdateUserWordStatus: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/user-words/{wordId}/status + Method: PUT + Auth: + Authorizer: CognitoAuthorizer WordGroupFunction: Type: AWS::Serverless::Function @@ -728,6 +736,14 @@ Resources: Method: GET Auth: Authorizer: CognitoAuthorizer + GetTestedWords: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /vocab/test/tested-words + Method: GET + Auth: + Authorizer: CognitoAuthorizer StatsFunction: Type: AWS::Serverless::Function From 78fe8a57c1bb10e8bf9877d545117321b5eb3a1c Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 13 Jan 2026 12:49:27 +0900 Subject: [PATCH 133/528] =?UTF-8?q?feat:=20=EC=97=85=EC=A0=81/=EB=B1=83?= =?UTF-8?q?=EC=A7=80=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?(#208)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BadgeType enum: 11개 뱃지 정의 (연속학습, 단어학습, 테스트 등) - UserBadge 모델 및 Repository - BadgeService: 뱃지 조회 및 자동 부여 로직 - BadgeHandler: GET /badges, GET /badges/earned API - StatsStreamHandler: 테스트 완료 시 자동 뱃지 체크 - S3에 Twemoji 기반 뱃지 이미지 업로드 --- .../domain/badge/constants/BadgeKey.java | 23 +++ .../domain/badge/enums/BadgeType.java | 74 +++++++ .../domain/badge/handler/BadgeHandler.java | 72 +++++++ .../domain/badge/model/UserBadge.java | 72 +++++++ .../badge/repository/BadgeRepository.java | 66 +++++++ .../domain/badge/service/BadgeService.java | 184 ++++++++++++++++++ .../stats/handler/StatsStreamHandler.java | 33 ++++ ServerlessFunction/template.yaml | 31 +++ 8 files changed, 555 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/constants/BadgeKey.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/enums/BadgeType.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/handler/BadgeHandler.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/model/UserBadge.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/repository/BadgeRepository.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/service/BadgeService.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/constants/BadgeKey.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/constants/BadgeKey.java new file mode 100644 index 00000000..21af347d --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/constants/BadgeKey.java @@ -0,0 +1,23 @@ +package com.mzc.secondproject.serverless.domain.badge.constants; + +/** + * Badge 도메인 DynamoDB 키 생성 유틸리티 + */ +public final class BadgeKey { + + private BadgeKey() {} + + public static final String BADGE_ALL = "BADGE#ALL"; + + public static String userBadgePk(String userId) { + return "USER#" + userId + "#BADGE"; + } + + public static String badgeSk(String badgeType) { + return "BADGE#" + badgeType; + } + + public static String earnedSk(String earnedAt) { + return "EARNED#" + earnedAt; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/enums/BadgeType.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/enums/BadgeType.java new file mode 100644 index 00000000..73d26142 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/enums/BadgeType.java @@ -0,0 +1,74 @@ +package com.mzc.secondproject.serverless.domain.badge.enums; + +/** + * 뱃지 타입 정의 + */ +public enum BadgeType { + // 첫 걸음 + FIRST_STEP("첫 걸음", "첫 학습을 완료했습니다", "first_step.png", "FIRST_STUDY", 1), + + // 연속 학습 + STREAK_3("3일 연속 학습", "3일 연속으로 학습했습니다", "streak_3.png", "STREAK", 3), + STREAK_7("일주일 연속 학습", "7일 연속으로 학습했습니다", "streak_7.png", "STREAK", 7), + STREAK_30("한 달 연속 학습", "30일 연속으로 학습했습니다", "streak_30.png", "STREAK", 30), + + // 단어 학습량 + WORDS_100("단어 수집가", "100개의 단어를 학습했습니다", "words_100.png", "WORDS_LEARNED", 100), + WORDS_500("단어 전문가", "500개의 단어를 학습했습니다", "words_500.png", "WORDS_LEARNED", 500), + WORDS_1000("단어 마스터", "1000개의 단어를 학습했습니다", "words_1000.png", "WORDS_LEARNED", 1000), + + // 테스트 관련 + PERFECT_SCORE("완벽주의자", "테스트에서 만점을 받았습니다", "perfect_score.png", "PERFECT_TEST", 1), + TEST_10("테스트 도전자", "10회의 테스트를 완료했습니다", "test_10.png", "TESTS_COMPLETED", 10), + + // 정확도 + ACCURACY_90("정확도 달인", "전체 정확도 90%를 달성했습니다", "accuracy_90.png", "ACCURACY", 90), + + // 특별 + MASTER("학습 마스터", "모든 업적을 달성했습니다", "master.png", "ALL_BADGES", 1); + + private static final String BASE_URL = "https://group2-englishstudy.s3.ap-northeast-2.amazonaws.com/badges/"; + + private final String name; + private final String description; + private final String imageFile; + private final String category; + private final int threshold; + + BadgeType(String name, String description, String imageFile, String category, int threshold) { + this.name = name; + this.description = description; + this.imageFile = imageFile; + this.category = category; + this.threshold = threshold; + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + public String getImageUrl() { + return BASE_URL + imageFile; + } + + public String getCategory() { + return category; + } + + public int getThreshold() { + return threshold; + } + + public static BadgeType fromString(String value) { + if (value == null) return null; + try { + return BadgeType.valueOf(value.toUpperCase()); + } catch (IllegalArgumentException e) { + return null; + } + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/handler/BadgeHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/handler/BadgeHandler.java new file mode 100644 index 00000000..5f035e43 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/handler/BadgeHandler.java @@ -0,0 +1,72 @@ +package com.mzc.secondproject.serverless.domain.badge.handler; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import com.mzc.secondproject.serverless.common.router.HandlerRouter; +import com.mzc.secondproject.serverless.common.router.Route; +import com.mzc.secondproject.serverless.common.util.ResponseGenerator; +import com.mzc.secondproject.serverless.domain.badge.model.UserBadge; +import com.mzc.secondproject.serverless.domain.badge.service.BadgeService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class BadgeHandler implements RequestHandler { + + private static final Logger logger = LoggerFactory.getLogger(BadgeHandler.class); + + private final BadgeService badgeService; + private final HandlerRouter router; + + public BadgeHandler() { + this.badgeService = new BadgeService(); + this.router = initRouter(); + } + + private HandlerRouter initRouter() { + return new HandlerRouter().addRoutes( + Route.getAuth("/badges", this::getAllBadges), + Route.getAuth("/badges/earned", this::getEarnedBadges) + ); + } + + @Override + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { + logger.info("Received request: {} {}", request.getHttpMethod(), request.getPath()); + return router.route(request); + } + + /** + * 전체 뱃지 목록 조회 (획득 여부, 진행도 포함) + */ + private APIGatewayProxyResponseEvent getAllBadges(APIGatewayProxyRequestEvent request, String userId) { + List badges = badgeService.getAllBadgesWithStatus(userId); + + long earnedCount = badges.stream().filter(BadgeService.BadgeInfo::earned).count(); + + Map response = new HashMap<>(); + response.put("badges", badges); + response.put("totalCount", badges.size()); + response.put("earnedCount", earnedCount); + + return ResponseGenerator.ok("Badges retrieved", response); + } + + /** + * 획득한 뱃지만 조회 + */ + private APIGatewayProxyResponseEvent getEarnedBadges(APIGatewayProxyRequestEvent request, String userId) { + List badges = badgeService.getUserBadges(userId); + + Map response = new HashMap<>(); + response.put("badges", badges); + response.put("count", badges.size()); + + return ResponseGenerator.ok("Earned badges retrieved", response); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/model/UserBadge.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/model/UserBadge.java new file mode 100644 index 00000000..d4fbdf3a --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/model/UserBadge.java @@ -0,0 +1,72 @@ +package com.mzc.secondproject.serverless.domain.badge.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbAttribute; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondaryPartitionKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondarySortKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSortKey; + +/** + * 사용자 뱃지 + * PK: USER#{userId}#BADGE + * SK: BADGE#{badgeType} + * GSI1: BADGE#ALL / EARNED#{earnedAt} - 최근 획득 뱃지 전체 조회 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DynamoDbBean +public class UserBadge { + + private String pk; // USER#{userId}#BADGE + private String sk; // BADGE#{badgeType} + private String gsi1pk; // BADGE#ALL + private String gsi1sk; // EARNED#{earnedAt} + + private String odUserId; + private String badgeType; // BadgeType enum name + private String name; + private String description; + private String imageUrl; + private String category; + private Integer threshold; + private Integer progress; // 현재 진행도 (획득 시점의 값) + + private String earnedAt; + private String createdAt; + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { + return pk; + } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { + return sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1PK") + public String getGsi1pk() { + return gsi1pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1SK") + public String getGsi1sk() { + return gsi1sk; + } + + @DynamoDbAttribute("userId") + public String getOdUserId() { + return odUserId; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/repository/BadgeRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/repository/BadgeRepository.java new file mode 100644 index 00000000..7b781f39 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/repository/BadgeRepository.java @@ -0,0 +1,66 @@ +package com.mzc.secondproject.serverless.domain.badge.repository; + +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.domain.badge.constants.BadgeKey; +import com.mzc.secondproject.serverless.domain.badge.model.UserBadge; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; +import software.amazon.awssdk.enhanced.dynamodb.Key; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +public class BadgeRepository { + + private static final Logger logger = LoggerFactory.getLogger(BadgeRepository.class); + private static final String TABLE_NAME = System.getenv("VOCAB_TABLE_NAME"); + + private final DynamoDbTable table; + + public BadgeRepository() { + DynamoDbEnhancedClient enhancedClient = DynamoDbEnhancedClient.builder() + .dynamoDbClient(AwsClients.dynamoDb()) + .build(); + this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(UserBadge.class)); + } + + public void save(UserBadge badge) { + table.putItem(badge); + logger.info("Saved badge: userId={}, badgeType={}", badge.getOdUserId(), badge.getBadgeType()); + } + + public Optional findByUserIdAndBadgeType(String userId, String badgeType) { + Key key = Key.builder() + .partitionValue(BadgeKey.userBadgePk(userId)) + .sortValue(BadgeKey.badgeSk(badgeType)) + .build(); + UserBadge badge = table.getItem(key); + return Optional.ofNullable(badge); + } + + public List findByUserId(String userId) { + QueryConditional queryConditional = QueryConditional.keyEqualTo( + Key.builder().partitionValue(BadgeKey.userBadgePk(userId)).build() + ); + + QueryEnhancedRequest request = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .build(); + + List badges = new ArrayList<>(); + table.query(request).items().forEach(badges::add); + + logger.info("Found {} badges for user: {}", badges.size(), userId); + return badges; + } + + public boolean hasBadge(String userId, String badgeType) { + return findByUserIdAndBadgeType(userId, badgeType).isPresent(); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/service/BadgeService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/service/BadgeService.java new file mode 100644 index 00000000..302daabf --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/service/BadgeService.java @@ -0,0 +1,184 @@ +package com.mzc.secondproject.serverless.domain.badge.service; + +import com.mzc.secondproject.serverless.domain.badge.constants.BadgeKey; +import com.mzc.secondproject.serverless.domain.badge.enums.BadgeType; +import com.mzc.secondproject.serverless.domain.badge.model.UserBadge; +import com.mzc.secondproject.serverless.domain.badge.repository.BadgeRepository; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; +import com.mzc.secondproject.serverless.domain.stats.repository.UserStatsRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +public class BadgeService { + + private static final Logger logger = LoggerFactory.getLogger(BadgeService.class); + + private final BadgeRepository badgeRepository; + private final UserStatsRepository userStatsRepository; + + public BadgeService() { + this.badgeRepository = new BadgeRepository(); + this.userStatsRepository = new UserStatsRepository(); + } + + /** + * 사용자의 획득한 뱃지 목록 조회 + */ + public List getUserBadges(String userId) { + return badgeRepository.findByUserId(userId); + } + + /** + * 전체 뱃지 목록 조회 (획득 여부 포함) + */ + public List getAllBadgesWithStatus(String userId) { + List earnedBadges = badgeRepository.findByUserId(userId); + Map earnedMap = earnedBadges.stream() + .collect(Collectors.toMap(UserBadge::getBadgeType, b -> b)); + + UserStats totalStats = userStatsRepository.findTotalStats(userId).orElse(null); + + List result = new ArrayList<>(); + for (BadgeType type : BadgeType.values()) { + UserBadge earned = earnedMap.get(type.name()); + int currentProgress = calculateProgress(type, totalStats); + + result.add(new BadgeInfo( + type.name(), + type.getName(), + type.getDescription(), + type.getImageUrl(), + type.getCategory(), + type.getThreshold(), + currentProgress, + earned != null, + earned != null ? earned.getEarnedAt() : null + )); + } + + return result; + } + + /** + * 뱃지 획득 체크 및 부여 + * 통계가 업데이트될 때 호출 + */ + public List checkAndAwardBadges(String userId, UserStats stats) { + List newBadges = new ArrayList<>(); + String now = Instant.now().toString(); + + for (BadgeType type : BadgeType.values()) { + // 이미 획득한 뱃지는 스킵 + if (badgeRepository.hasBadge(userId, type.name())) { + continue; + } + + // 조건 체크 + if (checkBadgeCondition(type, stats)) { + UserBadge badge = createBadge(userId, type, now); + badgeRepository.save(badge); + newBadges.add(badge); + logger.info("Badge awarded: userId={}, badge={}", userId, type.name()); + } + } + + return newBadges; + } + + /** + * 특정 뱃지 수동 부여 (테스트/관리용) + */ + public UserBadge awardBadge(String userId, String badgeType) { + BadgeType type = BadgeType.fromString(badgeType); + if (type == null) { + throw new IllegalArgumentException("Invalid badge type: " + badgeType); + } + + if (badgeRepository.hasBadge(userId, type.name())) { + return badgeRepository.findByUserIdAndBadgeType(userId, type.name()).orElse(null); + } + + String now = Instant.now().toString(); + UserBadge badge = createBadge(userId, type, now); + badgeRepository.save(badge); + + return badge; + } + + private UserBadge createBadge(String userId, BadgeType type, String now) { + return UserBadge.builder() + .pk(BadgeKey.userBadgePk(userId)) + .sk(BadgeKey.badgeSk(type.name())) + .gsi1pk(BadgeKey.BADGE_ALL) + .gsi1sk(BadgeKey.earnedSk(now)) + .odUserId(userId) + .badgeType(type.name()) + .name(type.getName()) + .description(type.getDescription()) + .imageUrl(type.getImageUrl()) + .category(type.getCategory()) + .threshold(type.getThreshold()) + .earnedAt(now) + .createdAt(now) + .build(); + } + + private boolean checkBadgeCondition(BadgeType type, UserStats stats) { + if (stats == null) return false; + + return switch (type.getCategory()) { + case "FIRST_STUDY" -> stats.getTestsCompleted() != null && stats.getTestsCompleted() >= 1; + case "STREAK" -> stats.getCurrentStreak() != null && stats.getCurrentStreak() >= type.getThreshold(); + case "WORDS_LEARNED" -> { + int total = (stats.getNewWordsLearned() != null ? stats.getNewWordsLearned() : 0) + + (stats.getWordsReviewed() != null ? stats.getWordsReviewed() : 0); + yield total >= type.getThreshold(); + } + case "PERFECT_TEST" -> false; // 별도 로직 필요 (테스트 결과에서 체크) + case "TESTS_COMPLETED" -> stats.getTestsCompleted() != null && stats.getTestsCompleted() >= type.getThreshold(); + case "ACCURACY" -> { + if (stats.getQuestionsAnswered() == null || stats.getQuestionsAnswered() == 0) yield false; + double accuracy = (stats.getCorrectAnswers() * 100.0) / stats.getQuestionsAnswered(); + yield accuracy >= type.getThreshold(); + } + case "ALL_BADGES" -> false; // 별도 로직 필요 + default -> false; + }; + } + + private int calculateProgress(BadgeType type, UserStats stats) { + if (stats == null) return 0; + + return switch (type.getCategory()) { + case "FIRST_STUDY" -> stats.getTestsCompleted() != null && stats.getTestsCompleted() >= 1 ? 1 : 0; + case "STREAK" -> stats.getCurrentStreak() != null ? stats.getCurrentStreak() : 0; + case "WORDS_LEARNED" -> (stats.getNewWordsLearned() != null ? stats.getNewWordsLearned() : 0) + + (stats.getWordsReviewed() != null ? stats.getWordsReviewed() : 0); + case "TESTS_COMPLETED" -> stats.getTestsCompleted() != null ? stats.getTestsCompleted() : 0; + case "ACCURACY" -> { + if (stats.getQuestionsAnswered() == null || stats.getQuestionsAnswered() == 0) yield 0; + yield (int) ((stats.getCorrectAnswers() * 100.0) / stats.getQuestionsAnswered()); + } + default -> 0; + }; + } + + public record BadgeInfo( + String badgeType, + String name, + String description, + String imageUrl, + String category, + int threshold, + int progress, + boolean earned, + String earnedAt + ) {} +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/StatsStreamHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/StatsStreamHandler.java index 67a5f6db..cddb5c1b 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/StatsStreamHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/StatsStreamHandler.java @@ -4,10 +4,15 @@ import com.amazonaws.services.lambda.runtime.RequestHandler; import com.amazonaws.services.lambda.runtime.events.DynamodbEvent; import com.amazonaws.services.lambda.runtime.events.models.dynamodb.AttributeValue; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; import com.mzc.secondproject.serverless.domain.stats.repository.UserStatsRepository; +import com.mzc.secondproject.serverless.domain.badge.model.UserBadge; +import com.mzc.secondproject.serverless.domain.badge.service.BadgeService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.util.List; + import java.time.LocalDate; import java.util.Map; import java.util.Optional; @@ -21,9 +26,11 @@ public class StatsStreamHandler implements RequestHandler { private static final Logger logger = LoggerFactory.getLogger(StatsStreamHandler.class); private final UserStatsRepository userStatsRepository; + private final BadgeService badgeService; public StatsStreamHandler() { this.userStatsRepository = new UserStatsRepository(); + this.badgeService = new BadgeService(); } @Override @@ -88,6 +95,32 @@ private void processTestResultInsert(Map newImage) { updateStudyStreak(userId); logger.info("Stats updated for user: {}", userId); + + // 뱃지 체크 및 부여 + checkAndAwardBadges(userId, correctAnswers, incorrectAnswers); + } + + private void checkAndAwardBadges(String userId, int correctAnswers, int incorrectAnswers) { + try { + Optional totalStats = userStatsRepository.findTotalStats(userId); + if (totalStats.isEmpty()) { + return; + } + + // 만점 뱃지 체크 (이번 테스트가 만점인 경우) + if (incorrectAnswers == 0 && correctAnswers > 0) { + badgeService.awardBadge(userId, "PERFECT_SCORE"); + logger.info("Perfect score badge awarded to user: {}", userId); + } + + // 기타 뱃지 체크 + List newBadges = badgeService.checkAndAwardBadges(userId, totalStats.get()); + if (!newBadges.isEmpty()) { + logger.info("Awarded {} new badges to user: {}", newBadges.size(), userId); + } + } catch (Exception e) { + logger.error("Failed to check badges for user: {}", userId, e); + } } private void updateStudyStreak(String userId) { diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 40ecf549..8c154722 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -897,6 +897,37 @@ Resources: Auth: Authorizer: CognitoAuthorizer + # Badge Lambda Function + BadgeFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: group2-englishstudy-badge-handler + CodeUri: . + Handler: com.mzc.secondproject.serverless.domain.badge.handler.BadgeHandler::handleRequest + Description: Handle user badges and achievements + SnapStart: + ApplyOn: PublishedVersions + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref VocabTable + Events: + GetAllBadges: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /badges + Method: GET + Auth: + Authorizer: CognitoAuthorizer + GetEarnedBadges: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /badges/earned + Method: GET + Auth: + Authorizer: CognitoAuthorizer + # EventBridge Scheduler - 매일 자정 단어 학습 통계 집계 ScheduledStatsFunction: Type: AWS::Serverless::Function From b0b9e73601aa5d6b517cfa446b88865da47994d5 Mon Sep 17 00:00:00 2001 From: hyein Heo <128613248+hye-inA@users.noreply.github.com> Date: Tue, 13 Jan 2026 12:50:35 +0900 Subject: [PATCH 134/528] =?UTF-8?q?chore=20:=20GitHub=20Issue=20=E2=86=92?= =?UTF-8?q?=20Jira=20=EB=8F=99=EA=B8=B0=ED=99=94=20workflow=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=9E=91=EC=84=B1=20(#216)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/github-jira-issue-sync.yml | 165 +++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 .github/workflows/github-jira-issue-sync.yml diff --git a/.github/workflows/github-jira-issue-sync.yml b/.github/workflows/github-jira-issue-sync.yml new file mode 100644 index 00000000..2c067753 --- /dev/null +++ b/.github/workflows/github-jira-issue-sync.yml @@ -0,0 +1,165 @@ +# GitHub Issue → Jira 동기화 +name: Issue-Jira Sync + +on: + issues: + types: [opened, closed, reopened] + issue_comment: + types: [created] + +env: + JIRA_PROJECT_KEY: MESP + +jobs: + # GitHub Issue 생성 → Jira 티켓 생성 + create-jira-from-issue: + runs-on: ubuntu-latest + if: github.event_name == 'issues' && github.event.action == 'opened' + + steps: + - name: Determine Issue Type from Title + id: issue-type + run: | + TITLE='${{ github.event.issue.title }}' + + # Issue 제목 기반 Jira 타입 매핑 + # [EPIC], [STORY], [TASK] 3가지 + if echo "$TITLE" | grep -qiE "^\[EPIC\]"; then + echo "type=Epic" >> $GITHUB_OUTPUT + elif echo "$TITLE" | grep -qiE "^\[STORY\]"; then + echo "type=Story" >> $GITHUB_OUTPUT + else + echo "type=Task" >> $GITHUB_OUTPUT + fi + + - name: Create Jira Issue + id: create-jira + run: | + RESPONSE=$(curl -s -X POST \ + -H "Authorization: Basic $(echo -n '${{ secrets.JIRA_USER_EMAIL }}:${{ secrets.JIRA_API_TOKEN }}' | base64)" \ + -H "Content-Type: application/json" \ + "${{ secrets.JIRA_BASE_URL }}/rest/api/3/issue" \ + -d '{ + "fields": { + "project": {"key": "${{ env.JIRA_PROJECT_KEY }}"}, + "summary": "[GH-#${{ github.event.issue.number }}] ${{ github.event.issue.title }}", + "description": { + "type": "doc", + "version": 1, + "content": [ + {"type": "paragraph", "content": [{"type": "text", "text": "GitHub Issue: ${{ github.event.issue.html_url }}"}]}, + {"type": "paragraph", "content": [{"type": "text", "text": "Author: ${{ github.event.issue.user.login }}"}]} + ] + }, + "issuetype": {"name": "${{ steps.issue-type.outputs.type }}"} + } + }') + + JIRA_KEY=$(echo "$RESPONSE" | jq -r '.key // empty') + echo "jira_key=$JIRA_KEY" >> $GITHUB_OUTPUT + + - name: Update GitHub Issue with Jira Link + if: steps.create-jira.outputs.jira_key != '' + uses: actions/github-script@v7 + with: + script: | + const jiraKey = '${{ steps.create-jira.outputs.jira_key }}'; + const jiraUrl = '${{ secrets.JIRA_BASE_URL }}/browse/' + jiraKey; + const currentBody = context.payload.issue.body || ''; + const newBody = `\n---\n**Jira:** [${jiraKey}](${jiraUrl})\n---\n` + currentBody; + + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: newBody + }); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: `Jira 티켓 생성: [${jiraKey}](${jiraUrl})` + }); + + # GitHub Issue 닫힘 → Jira 티켓 Done + close-jira-on-issue-close: + runs-on: ubuntu-latest + if: github.event_name == 'issues' && github.event.action == 'closed' + + steps: + - name: Extract Jira Key and Transition to Done + run: | + BODY='${{ github.event.issue.body }}' + JIRA_KEY=$(echo "$BODY" | grep -oE '${{ env.JIRA_PROJECT_KEY }}-[0-9]+' | head -1) + + [ -z "$JIRA_KEY" ] && exit 0 + + TRANSITIONS=$(curl -s -X GET \ + -H "Authorization: Basic $(echo -n '${{ secrets.JIRA_USER_EMAIL }}:${{ secrets.JIRA_API_TOKEN }}' | base64)" \ + "${{ secrets.JIRA_BASE_URL }}/rest/api/3/issue/${JIRA_KEY}/transitions") + + DONE_ID=$(echo "$TRANSITIONS" | jq -r '.transitions[] | select(.name | test("Done|완료|Closed|종료"; "i")) | .id' | head -1) + + [ -z "$DONE_ID" ] && exit 0 + + curl -s -X POST \ + -H "Authorization: Basic $(echo -n '${{ secrets.JIRA_USER_EMAIL }}:${{ secrets.JIRA_API_TOKEN }}' | base64)" \ + -H "Content-Type: application/json" \ + "${{ secrets.JIRA_BASE_URL }}/rest/api/3/issue/${JIRA_KEY}/transitions" \ + -d "{\"transition\": {\"id\": \"$DONE_ID\"}}" + + # GitHub Issue 재오픈 → Jira 상태 복구 + reopen-jira-on-issue-reopen: + runs-on: ubuntu-latest + if: github.event_name == 'issues' && github.event.action == 'reopened' + + steps: + - name: Extract Jira Key and Transition to In Progress + run: | + BODY='${{ github.event.issue.body }}' + JIRA_KEY=$(echo "$BODY" | grep -oE '${{ env.JIRA_PROJECT_KEY }}-[0-9]+' | head -1) + + [ -z "$JIRA_KEY" ] && exit 0 + + TRANSITIONS=$(curl -s -X GET \ + -H "Authorization: Basic $(echo -n '${{ secrets.JIRA_USER_EMAIL }}:${{ secrets.JIRA_API_TOKEN }}' | base64)" \ + "${{ secrets.JIRA_BASE_URL }}/rest/api/3/issue/${JIRA_KEY}/transitions") + + PROGRESS_ID=$(echo "$TRANSITIONS" | jq -r '.transitions[] | select(.name | test("In Progress|진행|Open|To Do"; "i")) | .id' | head -1) + + [ -z "$PROGRESS_ID" ] && exit 0 + + curl -s -X POST \ + -H "Authorization: Basic $(echo -n '${{ secrets.JIRA_USER_EMAIL }}:${{ secrets.JIRA_API_TOKEN }}' | base64)" \ + -H "Content-Type: application/json" \ + "${{ secrets.JIRA_BASE_URL }}/rest/api/3/issue/${JIRA_KEY}/transitions" \ + -d "{\"transition\": {\"id\": \"$PROGRESS_ID\"}}" + + # GitHub 코멘트 → Jira 코멘트 동기화 + sync-comment-to-jira: + runs-on: ubuntu-latest + if: github.event_name == 'issue_comment' && github.event.action == 'created' + + steps: + - name: Sync Comment to Jira + run: | + BODY='${{ github.event.issue.body }}' + JIRA_KEY=$(echo "$BODY" | grep -oE '${{ env.JIRA_PROJECT_KEY }}-[0-9]+' | head -1) + + [ -z "$JIRA_KEY" ] && exit 0 + + COMMENT_AUTHOR="${{ github.event.comment.user.login }}" + COMMENT_BODY=$(echo '${{ github.event.comment.body }}' | head -c 500 | sed 's/"/\\"/g' | tr '\n' ' ') + + curl -s -X POST \ + -H "Authorization: Basic $(echo -n '${{ secrets.JIRA_USER_EMAIL }}:${{ secrets.JIRA_API_TOKEN }}' | base64)" \ + -H "Content-Type: application/json" \ + "${{ secrets.JIRA_BASE_URL }}/rest/api/3/issue/${JIRA_KEY}/comment" \ + -d "{ + \"body\": { + \"type\": \"doc\", + \"version\": 1, + \"content\": [{\"type\": \"paragraph\", \"content\": [{\"type\": \"text\", \"text\": \"@${COMMENT_AUTHOR}: ${COMMENT_BODY}\"}]}] + } + }" \ No newline at end of file From c5e48e7643110ae29650a17bddb55f4bb1fbde54 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 13 Jan 2026 12:51:17 +0900 Subject: [PATCH 135/528] =?UTF-8?q?feat:=20=EB=8B=A8=EC=96=B4=20=ED=95=99?= =?UTF-8?q?=EC=8A=B5=20=EC=8B=9C=EC=97=90=EB=8F=84=20=EB=B1=83=EC=A7=80=20?= =?UTF-8?q?=EC=B2=B4=ED=81=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/DailyStudyCommandService.java | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyCommandService.java index 45566f03..4f25cfa8 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyCommandService.java @@ -9,7 +9,9 @@ import com.mzc.secondproject.serverless.domain.vocabulary.repository.DailyStudyRepository; import com.mzc.secondproject.serverless.domain.vocabulary.repository.UserWordRepository; import com.mzc.secondproject.serverless.domain.vocabulary.repository.WordRepository; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; import com.mzc.secondproject.serverless.domain.stats.repository.UserStatsRepository; +import com.mzc.secondproject.serverless.domain.badge.service.BadgeService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -41,12 +43,14 @@ public class DailyStudyCommandService { private final UserWordRepository userWordRepository; private final WordRepository wordRepository; private final UserStatsRepository userStatsRepository; + private final BadgeService badgeService; public DailyStudyCommandService() { this.dailyStudyRepository = new DailyStudyRepository(); this.userWordRepository = new UserWordRepository(); this.wordRepository = new WordRepository(); this.userStatsRepository = new UserStatsRepository(); + this.badgeService = new BadgeService(); } public DailyStudyResult getDailyWords(String userId, String level) { @@ -102,6 +106,9 @@ public Map markWordLearned(String userId, String wordId) { userStatsRepository.incrementWordsLearned(userId, 0, 1); } + // 단어 학습량 뱃지 체크 + checkWordsBadge(userId); + DailyStudy updatedDailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today).orElse(dailyStudy); if (updatedDailyStudy.getLearnedCount() >= updatedDailyStudy.getTotalWords()) { @@ -192,5 +199,16 @@ private Map calculateProgress(DailyStudy dailyStudy) { return progress; } + private void checkWordsBadge(String userId) { + try { + Optional totalStats = userStatsRepository.findTotalStats(userId); + if (totalStats.isPresent()) { + badgeService.checkAndAwardBadges(userId, totalStats.get()); + } + } catch (Exception e) { + logger.warn("Failed to check badges for user: {}", userId, e); + } + } + public record DailyStudyResult(DailyStudy dailyStudy, List newWords, List reviewWords, Map progress) {} } From b72fd6ac16ef2232dc705b24027c1aa827665eda Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 13 Jan 2026 13:39:50 +0900 Subject: [PATCH 136/528] Refactor: Use wildcard imports for cleaner annotations in model classes --- .github/ISSUE_TEMPLATE/change_request.yml | 2 +- .github/ISSUE_TEMPLATE/epic.yml | 2 +- .github/ISSUE_TEMPLATE/spike.yml | 2 +- .github/ISSUE_TEMPLATE/story.yml | 2 +- .github/ISSUE_TEMPLATE/task.yml | 2 +- .github/change_request.yml | 2 +- .github/epic.yml | 2 +- .github/pull_request_template.md | 6 + .github/spike.yml | 2 +- .github/story.yml | 2 +- .github/task.yml | 2 +- .github/workflows/auto-close-issues.yml | 2 +- .github/workflows/auto-label.yml | 2 +- .github/workflows/github-jira-issue-sync.yml | 6 +- .../serverless/common/config/AwsClients.java | 100 ++-- .../common/config/RoomTokenConfig.java | 62 +- .../serverless/common/config/StudyConfig.java | 47 +- .../common/config/WebSocketConfig.java | 81 ++- .../common/constants/DynamoDbKey.java | 47 +- .../serverless/common/dto/ApiResponse.java | 38 +- .../serverless/common/dto/ErrorInfo.java | 152 ++--- .../common/dto/PaginatedResult.java | 14 +- .../serverless/common/enums/Difficulty.java | 84 +-- .../serverless/common/enums/StudyLevel.java | 84 +-- .../common/exception/CommonErrorCode.java | 100 ++-- .../common/exception/CommonException.java | 248 ++++---- .../common/exception/DomainErrorCode.java | 28 +- .../common/exception/ErrorCode.java | 70 +-- .../common/exception/ServerlessException.java | 138 ++--- .../common/router/AuthenticatedHandler.java | 4 +- .../common/router/HandlerRouter.java | 298 +++++----- .../serverless/common/router/Route.java | 196 +++---- .../common/service/PollyService.java | 298 +++++----- .../serverless/common/util/CognitoUtil.java | 237 ++++---- .../serverless/common/util/CursorUtil.java | 114 ++-- .../common/util/ResponseGenerator.java | 193 +++--- .../common/util/WebSocketBroadcaster.java | 126 ++-- .../common/util/WebSocketResponseUtil.java | 61 +- .../common/validation/BeanValidator.java | 104 ++-- .../domain/badge/constants/BadgeKey.java | 33 +- .../domain/badge/enums/BadgeType.java | 134 ++--- .../domain/badge/handler/BadgeHandler.java | 104 ++-- .../domain/badge/model/UserBadge.java | 99 ++-- .../badge/repository/BadgeRepository.java | 92 +-- .../domain/badge/service/BadgeService.java | 331 +++++------ .../domain/chatting/constants/ChatKey.java | 64 +- .../dto/request/CreateRoomRequest.java | 40 +- .../chatting/dto/request/JoinRoomRequest.java | 4 +- .../dto/request/LeaveRoomRequest.java | 3 +- .../dto/request/SendMessageRequest.java | 14 +- .../dto/request/VoiceSynthesisRequest.java | 18 +- .../dto/response/JoinRoomResponse.java | 6 +- .../domain/chatting/enums/ChatLevel.java | 84 +-- .../domain/chatting/enums/MessageType.java | 86 +-- .../chatting/exception/ChattingErrorCode.java | 118 ++-- .../chatting/exception/ChattingException.java | 238 ++++---- .../factory/AiChatResponseFactory.java | 206 +++---- .../domain/chatting/factory/ChatResponse.java | 20 +- .../chatting/factory/ChatResponseFactory.java | 40 +- .../factory/MockChatResponseFactory.java | 70 +-- .../chatting/handler/ChatAIHandler.java | 96 +-- .../chatting/handler/ChatMessageHandler.java | 192 +++--- .../chatting/handler/ChatRoomHandler.java | 239 ++++---- .../chatting/handler/ChatVoiceHandler.java | 168 +++--- .../websocket/WebSocketConnectHandler.java | 166 +++--- .../websocket/WebSocketDisconnectHandler.java | 94 +-- .../websocket/WebSocketMessageHandler.java | 200 +++---- .../domain/chatting/model/ChatMessage.java | 117 ++-- .../domain/chatting/model/ChatRoom.java | 93 ++- .../domain/chatting/model/Connection.java | 105 ++-- .../domain/chatting/model/RoomToken.java | 40 +- .../repository/ChatMessageRepository.java | 196 +++---- .../repository/ChatRoomRepository.java | 252 ++++---- .../repository/ConnectionRepository.java | 158 ++--- .../repository/RoomTokenRepository.java | 72 +-- .../chatting/service/BedrockService.java | 105 ++-- .../chatting/service/ChatMessageService.java | 57 +- .../service/ChatRoomCommandService.java | 247 ++++---- .../service/ChatRoomQueryService.java | 50 +- .../chatting/service/ChatRoomService.java | 267 ++++----- .../chatting/service/RoomTokenService.java | 139 ++--- .../domain/stats/constants/StatsKey.java | 63 +- .../stats/handler/ScheduledStatsHandler.java | 120 ++-- .../stats/handler/StatsStreamHandler.java | 309 +++++----- .../stats/handler/UserStatsHandler.java | 312 +++++----- .../domain/stats/model/UserStats.java | 82 +-- .../stats/repository/UserStatsRepository.java | 496 ++++++++-------- .../domain/stats/service/StatsService.java | 194 +++--- .../domain/user/handler/PreSignUpHandler.java | 82 +-- .../domain/user/handler/UserHandler.java | 76 ++- .../serverless/domain/user/model/User.java | 111 ++-- .../user/repository/UserRepository.java | 109 ++-- .../domain/user/service/UserService.java | 8 +- .../domain/vocabulary/constants/VocabKey.java | 145 +++-- .../dto/request/BatchGetWordsRequest.java | 6 +- .../dto/request/CreateWordGroupRequest.java | 14 +- .../dto/request/CreateWordRequest.java | 34 +- .../dto/request/CreateWordsBatchRequest.java | 8 +- .../dto/request/StartTestRequest.java | 4 +- .../dto/request/SubmitTestRequest.java | 46 +- .../dto/request/SynthesizeVoiceRequest.java | 18 +- .../dto/request/UpdateUserWordRequest.java | 6 +- .../dto/request/UpdateUserWordTagRequest.java | 6 +- .../domain/vocabulary/enums/TestType.java | 84 +-- .../domain/vocabulary/enums/WordCategory.java | 88 +-- .../domain/vocabulary/enums/WordStatus.java | 88 +-- .../exception/VocabularyErrorCode.java | 122 ++-- .../exception/VocabularyException.java | 220 +++---- .../vocabulary/handler/DailyStudyHandler.java | 154 ++--- .../vocabulary/handler/StatisticsHandler.java | 84 +-- .../vocabulary/handler/StatsHandler.java | 106 ++-- .../vocabulary/handler/TestHandler.java | 272 ++++----- .../vocabulary/handler/UserWordHandler.java | 265 ++++----- .../vocabulary/handler/VoiceHandler.java | 226 +++---- .../vocabulary/handler/WordGroupHandler.java | 276 ++++----- .../vocabulary/handler/WordHandler.java | 306 +++++----- .../domain/vocabulary/model/DailyStudy.java | 99 ++-- .../domain/vocabulary/model/TestResult.java | 105 ++-- .../domain/vocabulary/model/UserWord.java | 167 +++--- .../domain/vocabulary/model/Word.java | 127 ++-- .../domain/vocabulary/model/WordGroup.java | 50 +- .../repository/DailyStudyRepository.java | 171 +++--- .../repository/TestResultRepository.java | 169 +++--- .../repository/UserWordRepository.java | 365 ++++++------ .../repository/WordGroupRepository.java | 118 ++-- .../vocabulary/repository/WordRepository.java | 371 ++++++------ .../service/DailyStudyCommandService.java | 376 ++++++------ .../service/DailyStudyQueryService.java | 86 ++- .../vocabulary/service/DailyStudyService.java | 302 +++++----- .../vocabulary/service/StatisticsService.java | 237 ++++---- .../vocabulary/service/StatsService.java | 437 +++++++------- .../service/TestCommandService.java | 491 ++++++++-------- .../vocabulary/service/TestQueryService.java | 132 +++-- .../vocabulary/service/TestService.java | 439 +++++++------- .../service/UserWordCommandService.java | 336 +++++------ .../service/UserWordQueryService.java | 215 ++++--- .../vocabulary/service/UserWordService.java | 417 +++++++------ .../service/WordCommandService.java | 267 +++++---- .../service/WordGroupCommandService.java | 210 +++---- .../service/WordGroupQueryService.java | 73 +-- .../vocabulary/service/WordQueryService.java | 58 +- .../vocabulary/service/WordService.java | 301 +++++----- .../vocabulary/state/LearningState.java | 99 ++-- .../vocabulary/state/MasteredState.java | 72 +-- .../domain/vocabulary/state/NewState.java | 71 +-- .../vocabulary/state/ReviewingState.java | 82 +-- .../state/SpacedRepetitionContext.java | 154 ++--- .../domain/vocabulary/state/WordState.java | 58 +- .../vocabulary/state/WordStateFactory.java | 80 +-- .../serverless/SampleSpec.groovy | 1 - .../vocabulary/state/WordStateSpec.groovy | 20 +- docs/IMPROVEMENT-GUIDE.md | 60 +- docs/chatting/CHATTING-GUIDE.md | 215 +++---- docs/user/USER-GUIDE.md | 152 +++-- docs/vocabulary/VOCABULARY-GUIDE.md | 235 ++++---- vocabulary/seed-data/words.json | 552 +++++++++++++++--- 156 files changed, 10322 insertions(+), 10005 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/change_request.yml b/.github/ISSUE_TEMPLATE/change_request.yml index fa49294b..f1a32dc4 100644 --- a/.github/ISSUE_TEMPLATE/change_request.yml +++ b/.github/ISSUE_TEMPLATE/change_request.yml @@ -1,7 +1,7 @@ name: Change Request description: 설계/AC 변경 제안 title: "[CR] 제목" -labels: ["change-request"] +labels: [ "change-request" ] body: - type: input id: related diff --git a/.github/ISSUE_TEMPLATE/epic.yml b/.github/ISSUE_TEMPLATE/epic.yml index 41b05e9c..ddaf48fb 100644 --- a/.github/ISSUE_TEMPLATE/epic.yml +++ b/.github/ISSUE_TEMPLATE/epic.yml @@ -1,7 +1,7 @@ name: Epic description: 큰 기능(여러 Story로 쪼개질 수 있음) title: "[EPIC] 제목" -labels: ["epic"] +labels: [ "epic" ] body: - type: textarea id: goal diff --git a/.github/ISSUE_TEMPLATE/spike.yml b/.github/ISSUE_TEMPLATE/spike.yml index 68569fe8..b62f1be6 100644 --- a/.github/ISSUE_TEMPLATE/spike.yml +++ b/.github/ISSUE_TEMPLATE/spike.yml @@ -1,7 +1,7 @@ name: Spike description: 조사/실험(시간 제한) title: "[SPIKE] 제목" -labels: ["spike"] +labels: [ "spike" ] body: - type: input id: timebox diff --git a/.github/ISSUE_TEMPLATE/story.yml b/.github/ISSUE_TEMPLATE/story.yml index 4a7a0766..71fe327d 100644 --- a/.github/ISSUE_TEMPLATE/story.yml +++ b/.github/ISSUE_TEMPLATE/story.yml @@ -1,7 +1,7 @@ name: Story description: 사용자 시나리오와 수용 기준 title: "[STORY] 제목" -labels: ["story"] +labels: [ "story" ] body: - type: textarea id: background diff --git a/.github/ISSUE_TEMPLATE/task.yml b/.github/ISSUE_TEMPLATE/task.yml index aa3f8019..4d6294fd 100644 --- a/.github/ISSUE_TEMPLATE/task.yml +++ b/.github/ISSUE_TEMPLATE/task.yml @@ -1,7 +1,7 @@ name: Task description: 개발/테스트/데브옵스 단위 작업 title: "[TASK] 제목" -labels: ["task"] +labels: [ "task" ] body: - type: input id: parent diff --git a/.github/change_request.yml b/.github/change_request.yml index fa49294b..f1a32dc4 100644 --- a/.github/change_request.yml +++ b/.github/change_request.yml @@ -1,7 +1,7 @@ name: Change Request description: 설계/AC 변경 제안 title: "[CR] 제목" -labels: ["change-request"] +labels: [ "change-request" ] body: - type: input id: related diff --git a/.github/epic.yml b/.github/epic.yml index 41b05e9c..ddaf48fb 100644 --- a/.github/epic.yml +++ b/.github/epic.yml @@ -1,7 +1,7 @@ name: Epic description: 큰 기능(여러 Story로 쪼개질 수 있음) title: "[EPIC] 제목" -labels: ["epic"] +labels: [ "epic" ] body: - type: textarea id: goal diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 9511bc50..96acf622 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,23 +1,29 @@ 제목: refs #ISSUE 목적 + - 왜 이 PR이 필요한가? (관련 Story/Epic/CR 링크) 변경 요약 + - 핵심 변경 - 주요 파일/모듈 수용 기준 검증 + - [ ] AC1: ... - [ ] AC2: ... 브레이킹/마이그레이션 + - 스키마/데이터/호환성 테스트 + - 단위/통합/시나리오 - 수동 검증 방법 참조 + - Closes #ISSUE or refs #ISSUE - 디자인/스펙/ADR 링크 diff --git a/.github/spike.yml b/.github/spike.yml index 68569fe8..b62f1be6 100644 --- a/.github/spike.yml +++ b/.github/spike.yml @@ -1,7 +1,7 @@ name: Spike description: 조사/실험(시간 제한) title: "[SPIKE] 제목" -labels: ["spike"] +labels: [ "spike" ] body: - type: input id: timebox diff --git a/.github/story.yml b/.github/story.yml index 4a7a0766..71fe327d 100644 --- a/.github/story.yml +++ b/.github/story.yml @@ -1,7 +1,7 @@ name: Story description: 사용자 시나리오와 수용 기준 title: "[STORY] 제목" -labels: ["story"] +labels: [ "story" ] body: - type: textarea id: background diff --git a/.github/task.yml b/.github/task.yml index aa3f8019..4d6294fd 100644 --- a/.github/task.yml +++ b/.github/task.yml @@ -1,7 +1,7 @@ name: Task description: 개발/테스트/데브옵스 단위 작업 title: "[TASK] 제목" -labels: ["task"] +labels: [ "task" ] body: - type: input id: parent diff --git a/.github/workflows/auto-close-issues.yml b/.github/workflows/auto-close-issues.yml index 9bb3f456..476d63d0 100644 --- a/.github/workflows/auto-close-issues.yml +++ b/.github/workflows/auto-close-issues.yml @@ -2,7 +2,7 @@ name: Auto Close Issues on: pull_request: - types: [closed] + types: [ closed ] branches: - develop - main diff --git a/.github/workflows/auto-label.yml b/.github/workflows/auto-label.yml index 21be9e53..d1d1c5a9 100644 --- a/.github/workflows/auto-label.yml +++ b/.github/workflows/auto-label.yml @@ -1,7 +1,7 @@ name: Auto Label on: pull_request_target: - types: [opened, synchronize] + types: [ opened, synchronize ] permissions: contents: read diff --git a/.github/workflows/github-jira-issue-sync.yml b/.github/workflows/github-jira-issue-sync.yml index 2c067753..e095e8ee 100644 --- a/.github/workflows/github-jira-issue-sync.yml +++ b/.github/workflows/github-jira-issue-sync.yml @@ -3,9 +3,9 @@ name: Issue-Jira Sync on: issues: - types: [opened, closed, reopened] + types: [ opened, closed, reopened ] issue_comment: - types: [created] + types: [ created ] env: JIRA_PROJECT_KEY: MESP @@ -162,4 +162,4 @@ jobs: \"version\": 1, \"content\": [{\"type\": \"paragraph\", \"content\": [{\"type\": \"text\", \"text\": \"@${COMMENT_AUTHOR}: ${COMMENT_BODY}\"}]}] } - }" \ No newline at end of file + }" diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/AwsClients.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/AwsClients.java index 7505fbd6..237e6bd3 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/AwsClients.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/AwsClients.java @@ -1,67 +1,63 @@ package com.mzc.secondproject.serverless.common.config; import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; +import software.amazon.awssdk.services.bedrockruntime.BedrockRuntimeClient; import software.amazon.awssdk.services.dynamodb.DynamoDbClient; import software.amazon.awssdk.services.polly.PollyClient; import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.presigner.S3Presigner; import software.amazon.awssdk.services.sns.SnsClient; -import software.amazon.awssdk.services.bedrockruntime.BedrockRuntimeClient; /** * AWS SDK 클라이언트 싱글톤 관리 * Lambda Cold Start 최적화를 위해 static final로 선언 */ public final class AwsClients { - - private AwsClients() { - // 인스턴스화 방지 - } - - // DynamoDB - private static final DynamoDbClient DYNAMO_DB_CLIENT = DynamoDbClient.builder().build(); - private static final DynamoDbEnhancedClient DYNAMO_DB_ENHANCED_CLIENT = DynamoDbEnhancedClient.builder() - .dynamoDbClient(DYNAMO_DB_CLIENT) - .build(); - - // S3 - private static final S3Client S3_CLIENT = S3Client.builder().build(); - private static final S3Presigner S3_PRESIGNER = S3Presigner.builder().build(); - - // Polly - private static final PollyClient POLLY_CLIENT = PollyClient.builder().build(); - - // SNS - private static final SnsClient SNS_CLIENT = SnsClient.builder().build(); - - // Bedrock - private static final BedrockRuntimeClient BEDROCK_CLIENT = BedrockRuntimeClient.builder().build(); - - public static DynamoDbClient dynamoDb() { - return DYNAMO_DB_CLIENT; - } - - public static DynamoDbEnhancedClient dynamoDbEnhanced() { - return DYNAMO_DB_ENHANCED_CLIENT; - } - - public static S3Client s3() { - return S3_CLIENT; - } - - public static S3Presigner s3Presigner() { - return S3_PRESIGNER; - } - - public static PollyClient polly() { - return POLLY_CLIENT; - } - - public static SnsClient sns() { - return SNS_CLIENT; - } - - public static BedrockRuntimeClient bedrock() { - return BEDROCK_CLIENT; - } + + // DynamoDB + private static final DynamoDbClient DYNAMO_DB_CLIENT = DynamoDbClient.builder().build(); + private static final DynamoDbEnhancedClient DYNAMO_DB_ENHANCED_CLIENT = DynamoDbEnhancedClient.builder() + .dynamoDbClient(DYNAMO_DB_CLIENT) + .build(); + // S3 + private static final S3Client S3_CLIENT = S3Client.builder().build(); + private static final S3Presigner S3_PRESIGNER = S3Presigner.builder().build(); + // Polly + private static final PollyClient POLLY_CLIENT = PollyClient.builder().build(); + // SNS + private static final SnsClient SNS_CLIENT = SnsClient.builder().build(); + // Bedrock + private static final BedrockRuntimeClient BEDROCK_CLIENT = BedrockRuntimeClient.builder().build(); + + private AwsClients() { + // 인스턴스화 방지 + } + + public static DynamoDbClient dynamoDb() { + return DYNAMO_DB_CLIENT; + } + + public static DynamoDbEnhancedClient dynamoDbEnhanced() { + return DYNAMO_DB_ENHANCED_CLIENT; + } + + public static S3Client s3() { + return S3_CLIENT; + } + + public static S3Presigner s3Presigner() { + return S3_PRESIGNER; + } + + public static PollyClient polly() { + return POLLY_CLIENT; + } + + public static SnsClient sns() { + return SNS_CLIENT; + } + + public static BedrockRuntimeClient bedrock() { + return BEDROCK_CLIENT; + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/RoomTokenConfig.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/RoomTokenConfig.java index 182e5c0e..240a02c1 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/RoomTokenConfig.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/RoomTokenConfig.java @@ -5,36 +5,34 @@ * Lambda 환경변수에서 값을 읽어오며, 없을 경우 기본값 사용 */ public final class RoomTokenConfig { - - private RoomTokenConfig() { - // 인스턴스화 방지 - } - - // 환경변수 키 - private static final String ENV_TOKEN_TTL_SECONDS = "ROOM_TOKEN_TTL_SECONDS"; - - // 기본값: 5분 - private static final long DEFAULT_TOKEN_TTL_SECONDS = 300L; - - // 캐시된 값 (Cold Start 최적화) - private static final long TOKEN_TTL_SECONDS = parseTokenTtl(); - - private static long parseTokenTtl() { - String value = System.getenv(ENV_TOKEN_TTL_SECONDS); - if (value != null) { - try { - return Long.parseLong(value); - } catch (NumberFormatException ignored) { - // 파싱 실패 시 기본값 사용 - } - } - return DEFAULT_TOKEN_TTL_SECONDS; - } - - /** - * RoomToken TTL (초) - */ - public static long tokenTtlSeconds() { - return TOKEN_TTL_SECONDS; - } + + // 환경변수 키 + private static final String ENV_TOKEN_TTL_SECONDS = "ROOM_TOKEN_TTL_SECONDS"; + // 기본값: 5분 + private static final long DEFAULT_TOKEN_TTL_SECONDS = 300L; + // 캐시된 값 (Cold Start 최적화) + private static final long TOKEN_TTL_SECONDS = parseTokenTtl(); + + private RoomTokenConfig() { + // 인스턴스화 방지 + } + + private static long parseTokenTtl() { + String value = System.getenv(ENV_TOKEN_TTL_SECONDS); + if (value != null) { + try { + return Long.parseLong(value); + } catch (NumberFormatException ignored) { + // 파싱 실패 시 기본값 사용 + } + } + return DEFAULT_TOKEN_TTL_SECONDS; + } + + /** + * RoomToken TTL (초) + */ + public static long tokenTtlSeconds() { + return TOKEN_TTL_SECONDS; + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/StudyConfig.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/StudyConfig.java index 0b3da670..43a769b9 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/StudyConfig.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/StudyConfig.java @@ -1,30 +1,25 @@ package com.mzc.secondproject.serverless.common.config; public final class StudyConfig { - - private StudyConfig() {} - - // Spaced Repetition 기본값 - public static final int INITIAL_INTERVAL_DAYS = 1; - public static final double DEFAULT_EASE_FACTOR = 2.5; - public static final double MIN_EASE_FACTOR = 1.3; - public static final int INITIAL_REPETITIONS = 0; - - // 오답 관련 - public static final int MAX_WRONG_COUNT = 3; - - // 테스트 관련 - public static final int DEFAULT_WORD_COUNT = 20; - public static final int DAILY_TEST_WORD_COUNT = 10; - - // 복습 간격 (일 단위) - public static final int[] REVIEW_INTERVALS = {1, 3, 7, 14, 30}; - - // 상태 기본값 - public static final String DEFAULT_WORD_STATUS = "NEW"; - public static final String DEFAULT_DIFFICULTY = "NORMAL"; - - // 정답/오답 카운트 초기값 - public static final int INITIAL_CORRECT_COUNT = 0; - public static final int INITIAL_INCORRECT_COUNT = 0; + + // Spaced Repetition 기본값 + public static final int INITIAL_INTERVAL_DAYS = 1; + public static final double DEFAULT_EASE_FACTOR = 2.5; + public static final double MIN_EASE_FACTOR = 1.3; + public static final int INITIAL_REPETITIONS = 0; + // 오답 관련 + public static final int MAX_WRONG_COUNT = 3; + // 테스트 관련 + public static final int DEFAULT_WORD_COUNT = 20; + public static final int DAILY_TEST_WORD_COUNT = 10; + // 복습 간격 (일 단위) + public static final int[] REVIEW_INTERVALS = {1, 3, 7, 14, 30}; + // 상태 기본값 + public static final String DEFAULT_WORD_STATUS = "NEW"; + public static final String DEFAULT_DIFFICULTY = "NORMAL"; + // 정답/오답 카운트 초기값 + public static final int INITIAL_CORRECT_COUNT = 0; + public static final int INITIAL_INCORRECT_COUNT = 0; + private StudyConfig() { + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/WebSocketConfig.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/WebSocketConfig.java index 18416c5d..2fdcb9e6 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/WebSocketConfig.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/WebSocketConfig.java @@ -5,46 +5,43 @@ * Lambda 환경변수에서 값을 읽어오며, 없을 경우 기본값 사용 */ public final class WebSocketConfig { - - private WebSocketConfig() { - // 인스턴스화 방지 - } - - // 환경변수 키 - private static final String ENV_CONNECTION_TTL_SECONDS = "WEBSOCKET_CONNECTION_TTL_SECONDS"; - private static final String ENV_WEBSOCKET_ENDPOINT = "WEBSOCKET_ENDPOINT"; - - // 기본값 - private static final long DEFAULT_CONNECTION_TTL_SECONDS = 600L; // 10분 - - // 캐시된 값 (Cold Start 최적화) - private static final long CONNECTION_TTL_SECONDS = parseConnectionTtl(); - private static final String WEBSOCKET_ENDPOINT = System.getenv(ENV_WEBSOCKET_ENDPOINT); - - private static long parseConnectionTtl() { - String value = System.getenv(ENV_CONNECTION_TTL_SECONDS); - if (value != null) { - try { - return Long.parseLong(value); - } catch (NumberFormatException ignored) { - // 파싱 실패 시 기본값 사용 - } - } - return DEFAULT_CONNECTION_TTL_SECONDS; - } - - /** - * WebSocket 연결 TTL (초) - */ - public static long connectionTtlSeconds() { - return CONNECTION_TTL_SECONDS; - } - - /** - * WebSocket API Gateway 엔드포인트 URL - * 메시지 브로드캐스트 시 사용 - */ - public static String websocketEndpoint() { - return WEBSOCKET_ENDPOINT; - } + + // 환경변수 키 + private static final String ENV_CONNECTION_TTL_SECONDS = "WEBSOCKET_CONNECTION_TTL_SECONDS"; + private static final String ENV_WEBSOCKET_ENDPOINT = "WEBSOCKET_ENDPOINT"; + // 기본값 + private static final long DEFAULT_CONNECTION_TTL_SECONDS = 600L; // 10분 + // 캐시된 값 (Cold Start 최적화) + private static final long CONNECTION_TTL_SECONDS = parseConnectionTtl(); + private static final String WEBSOCKET_ENDPOINT = System.getenv(ENV_WEBSOCKET_ENDPOINT); + private WebSocketConfig() { + // 인스턴스화 방지 + } + + private static long parseConnectionTtl() { + String value = System.getenv(ENV_CONNECTION_TTL_SECONDS); + if (value != null) { + try { + return Long.parseLong(value); + } catch (NumberFormatException ignored) { + // 파싱 실패 시 기본값 사용 + } + } + return DEFAULT_CONNECTION_TTL_SECONDS; + } + + /** + * WebSocket 연결 TTL (초) + */ + public static long connectionTtlSeconds() { + return CONNECTION_TTL_SECONDS; + } + + /** + * WebSocket API Gateway 엔드포인트 URL + * 메시지 브로드캐스트 시 사용 + */ + public static String websocketEndpoint() { + return WEBSOCKET_ENDPOINT; + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/constants/DynamoDbKey.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/constants/DynamoDbKey.java index a437dd4b..c512210b 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/constants/DynamoDbKey.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/constants/DynamoDbKey.java @@ -1,29 +1,26 @@ package com.mzc.secondproject.serverless.common.constants; public final class DynamoDbKey { - - private DynamoDbKey() {} - - // Partition/Sort Key Attributes - public static final String PK = "PK"; - public static final String SK = "SK"; - - // GSI Key Attributes - public static final String GSI1_PK = "GSI1PK"; - public static final String GSI1_SK = "GSI1SK"; - public static final String GSI2_PK = "GSI2PK"; - public static final String GSI2_SK = "GSI2SK"; - public static final String GSI3_PK = "GSI3PK"; - public static final String GSI3_SK = "GSI3SK"; - - // Index Names - public static final String GSI1 = "GSI1"; - public static final String GSI2 = "GSI2"; - public static final String GSI3 = "GSI3"; - - // Common Sort Key - public static final String METADATA = "METADATA"; - - // 공용 Entity Prefix - public static final String USER = "USER#"; + + // Partition/Sort Key Attributes + public static final String PK = "PK"; + public static final String SK = "SK"; + // GSI Key Attributes + public static final String GSI1_PK = "GSI1PK"; + public static final String GSI1_SK = "GSI1SK"; + public static final String GSI2_PK = "GSI2PK"; + public static final String GSI2_SK = "GSI2SK"; + public static final String GSI3_PK = "GSI3PK"; + public static final String GSI3_SK = "GSI3SK"; + // Index Names + public static final String GSI1 = "GSI1"; + public static final String GSI2 = "GSI2"; + public static final String GSI3 = "GSI3"; + // Common Sort Key + public static final String METADATA = "METADATA"; + // 공용 Entity Prefix + public static final String USER = "USER#"; + + private DynamoDbKey() { + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/ApiResponse.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/ApiResponse.java index f30e6358..0f08c3e1 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/ApiResponse.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/ApiResponse.java @@ -4,26 +4,26 @@ * 표준 API 응답 래퍼 * * @param isSuccess 성공 여부 - * @param message 응답 메시지 - * @param data 응답 데이터 - * @param error 에러 메시지 + * @param message 응답 메시지 + * @param data 응답 데이터 + * @param error 에러 메시지 */ public record ApiResponse( - boolean isSuccess, - String message, - T data, - String error + boolean isSuccess, + String message, + T data, + String error ) { - - public static ApiResponse ok(String message, T data) { - return new ApiResponse<>(true, message, data, null); - } - - public static ApiResponse ok(T data) { - return new ApiResponse<>(true, null, data, null); - } - - public static ApiResponse fail(String errorMessage) { - return new ApiResponse<>(false, null, null, errorMessage); - } + + public static ApiResponse ok(String message, T data) { + return new ApiResponse<>(true, message, data, null); + } + + public static ApiResponse ok(T data) { + return new ApiResponse<>(true, null, data, null); + } + + public static ApiResponse fail(String errorMessage) { + return new ApiResponse<>(false, null, null, errorMessage); + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/ErrorInfo.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/ErrorInfo.java index 74f8330d..41feeb2b 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/ErrorInfo.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/ErrorInfo.java @@ -8,90 +8,90 @@ /** * RFC 7807 스타일 에러 정보 - * + *

* Problem Details for HTTP APIs (RFC 7807) 표준을 참고한 에러 응답 형식입니다. - * + *

* 응답 예시: * { - * "code": "VOCABULARY.WORD_001", - * "message": "단어를 찾을 수 없습니다", - * "status": 404, - * "details": { - * "wordId": "abc-123" - * } + * "code": "VOCABULARY.WORD_001", + * "message": "단어를 찾을 수 없습니다", + * "status": 404, + * "details": { + * "wordId": "abc-123" + * } * } * - * @param code 에러 코드 (예: AUTH_001, VOCABULARY.WORD_001) + * @param code 에러 코드 (예: AUTH_001, VOCABULARY.WORD_001) * @param message 에러 메시지 - * @param status HTTP 상태 코드 + * @param status HTTP 상태 코드 * @param details 추가 상세 정보 (선택) */ public record ErrorInfo( - String code, - String message, - int status, - Map details + String code, + String message, + int status, + Map details ) { - - /** - * 기본 에러 정보 생성 - */ - public static ErrorInfo of(String code, String message, int status) { - return new ErrorInfo(code, message, status, null); - } - - /** - * 상세 정보 포함 에러 정보 생성 - */ - public static ErrorInfo of(String code, String message, int status, Map details) { - return new ErrorInfo(code, message, status, details); - } - - /** - * ErrorCode에서 에러 정보 생성 - */ - public static ErrorInfo from(ErrorCode errorCode) { - String code = errorCode instanceof DomainErrorCode domainCode - ? domainCode.getFullCode() - : errorCode.getCode(); - return new ErrorInfo(code, errorCode.getMessage(), errorCode.getStatusCode(), null); - } - - /** - * ErrorCode에서 에러 정보 생성 (커스텀 메시지) - */ - public static ErrorInfo from(ErrorCode errorCode, String customMessage) { - String code = errorCode instanceof DomainErrorCode domainCode - ? domainCode.getFullCode() - : errorCode.getCode(); - return new ErrorInfo(code, customMessage, errorCode.getStatusCode(), null); - } - - /** - * ServerlessException에서 에러 정보 생성 - */ - public static ErrorInfo from(ServerlessException ex) { - ErrorCode errorCode = ex.getErrorCode(); - String code = errorCode instanceof DomainErrorCode domainCode - ? domainCode.getFullCode() - : errorCode.getCode(); - - Map details = ex.getDetails().isEmpty() ? null : ex.getDetails(); - - return new ErrorInfo(code, ex.getMessage(), ex.getStatusCode(), details); - } - - /** - * 클라이언트 에러 여부 (4xx) - */ - public boolean isClientError() { - return status >= 400 && status < 500; - } - - /** - * 서버 에러 여부 (5xx) - */ - public boolean isServerError() { - return status >= 500 && status < 600; - } + + /** + * 기본 에러 정보 생성 + */ + public static ErrorInfo of(String code, String message, int status) { + return new ErrorInfo(code, message, status, null); + } + + /** + * 상세 정보 포함 에러 정보 생성 + */ + public static ErrorInfo of(String code, String message, int status, Map details) { + return new ErrorInfo(code, message, status, details); + } + + /** + * ErrorCode에서 에러 정보 생성 + */ + public static ErrorInfo from(ErrorCode errorCode) { + String code = errorCode instanceof DomainErrorCode domainCode + ? domainCode.getFullCode() + : errorCode.getCode(); + return new ErrorInfo(code, errorCode.getMessage(), errorCode.getStatusCode(), null); + } + + /** + * ErrorCode에서 에러 정보 생성 (커스텀 메시지) + */ + public static ErrorInfo from(ErrorCode errorCode, String customMessage) { + String code = errorCode instanceof DomainErrorCode domainCode + ? domainCode.getFullCode() + : errorCode.getCode(); + return new ErrorInfo(code, customMessage, errorCode.getStatusCode(), null); + } + + /** + * ServerlessException에서 에러 정보 생성 + */ + public static ErrorInfo from(ServerlessException ex) { + ErrorCode errorCode = ex.getErrorCode(); + String code = errorCode instanceof DomainErrorCode domainCode + ? domainCode.getFullCode() + : errorCode.getCode(); + + Map details = ex.getDetails().isEmpty() ? null : ex.getDetails(); + + return new ErrorInfo(code, ex.getMessage(), ex.getStatusCode(), details); + } + + /** + * 클라이언트 에러 여부 (4xx) + */ + public boolean isClientError() { + return status >= 400 && status < 500; + } + + /** + * 서버 에러 여부 (5xx) + */ + public boolean isServerError() { + return status >= 500 && status < 600; + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/PaginatedResult.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/PaginatedResult.java index 00e6290d..05776764 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/PaginatedResult.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/PaginatedResult.java @@ -5,15 +5,15 @@ /** * 페이지네이션 결과를 담는 제네릭 레코드 * - * @param items 결과 아이템 목록 + * @param items 결과 아이템 목록 * @param nextCursor 다음 페이지 커서 (없으면 null) */ public record PaginatedResult( - List items, - String nextCursor + List items, + String nextCursor ) { - - public boolean hasMore() { - return nextCursor != null; - } + + public boolean hasMore() { + return nextCursor != null; + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/enums/Difficulty.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/enums/Difficulty.java index ae9e54f8..5bc9fe45 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/enums/Difficulty.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/enums/Difficulty.java @@ -3,46 +3,46 @@ import java.util.Arrays; public enum Difficulty { - EASY("easy", "쉬움"), - NORMAL("normal", "보통"), - HARD("hard", "어려움"); - - private final String code; - private final String displayName; - - Difficulty(String code, String displayName) { - this.code = code; - this.displayName = displayName; - } - - public String getCode() { - return code; - } - - public String getDisplayName() { - return displayName; - } - - public static boolean isValid(String value) { - if (value == null) return false; - return Arrays.stream(values()) - .anyMatch(d -> d.name().equalsIgnoreCase(value) || d.code.equalsIgnoreCase(value)); - } - - public static Difficulty fromString(String value) { - if (value == null) { - throw new IllegalArgumentException("Difficulty value cannot be null"); - } - return Arrays.stream(values()) - .filter(d -> d.name().equalsIgnoreCase(value) || d.code.equalsIgnoreCase(value)) - .findFirst() - .orElseThrow(() -> new IllegalArgumentException("Unknown Difficulty: " + value)); - } - - public static Difficulty fromStringOrDefault(String value, Difficulty defaultValue) { - if (value == null || !isValid(value)) { - return defaultValue; - } - return fromString(value); - } + EASY("easy", "쉬움"), + NORMAL("normal", "보통"), + HARD("hard", "어려움"); + + private final String code; + private final String displayName; + + Difficulty(String code, String displayName) { + this.code = code; + this.displayName = displayName; + } + + public static boolean isValid(String value) { + if (value == null) return false; + return Arrays.stream(values()) + .anyMatch(d -> d.name().equalsIgnoreCase(value) || d.code.equalsIgnoreCase(value)); + } + + public static Difficulty fromString(String value) { + if (value == null) { + throw new IllegalArgumentException("Difficulty value cannot be null"); + } + return Arrays.stream(values()) + .filter(d -> d.name().equalsIgnoreCase(value) || d.code.equalsIgnoreCase(value)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Unknown Difficulty: " + value)); + } + + public static Difficulty fromStringOrDefault(String value, Difficulty defaultValue) { + if (value == null || !isValid(value)) { + return defaultValue; + } + return fromString(value); + } + + public String getCode() { + return code; + } + + public String getDisplayName() { + return displayName; + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/enums/StudyLevel.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/enums/StudyLevel.java index 73f2ca05..96171356 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/enums/StudyLevel.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/enums/StudyLevel.java @@ -3,46 +3,46 @@ import java.util.Arrays; public enum StudyLevel { - BEGINNER("beginner", "초급"), - INTERMEDIATE("intermediate", "중급"), - ADVANCED("advanced", "고급"); - - private final String code; - private final String displayName; - - StudyLevel(String code, String displayName) { - this.code = code; - this.displayName = displayName; - } - - public String getCode() { - return code; - } - - public String getDisplayName() { - return displayName; - } - - public static boolean isValid(String value) { - if (value == null) return false; - return Arrays.stream(values()) - .anyMatch(level -> level.name().equalsIgnoreCase(value) || level.code.equalsIgnoreCase(value)); - } - - public static StudyLevel fromString(String value) { - if (value == null) { - throw new IllegalArgumentException("StudyLevel value cannot be null"); - } - return Arrays.stream(values()) - .filter(level -> level.name().equalsIgnoreCase(value) || level.code.equalsIgnoreCase(value)) - .findFirst() - .orElseThrow(() -> new IllegalArgumentException("Unknown StudyLevel: " + value)); - } - - public static StudyLevel fromStringOrDefault(String value, StudyLevel defaultValue) { - if (value == null || !isValid(value)) { - return defaultValue; - } - return fromString(value); - } + BEGINNER("beginner", "초급"), + INTERMEDIATE("intermediate", "중급"), + ADVANCED("advanced", "고급"); + + private final String code; + private final String displayName; + + StudyLevel(String code, String displayName) { + this.code = code; + this.displayName = displayName; + } + + public static boolean isValid(String value) { + if (value == null) return false; + return Arrays.stream(values()) + .anyMatch(level -> level.name().equalsIgnoreCase(value) || level.code.equalsIgnoreCase(value)); + } + + public static StudyLevel fromString(String value) { + if (value == null) { + throw new IllegalArgumentException("StudyLevel value cannot be null"); + } + return Arrays.stream(values()) + .filter(level -> level.name().equalsIgnoreCase(value) || level.code.equalsIgnoreCase(value)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Unknown StudyLevel: " + value)); + } + + public static StudyLevel fromStringOrDefault(String value, StudyLevel defaultValue) { + if (value == null || !isValid(value)) { + return defaultValue; + } + return fromString(value); + } + + public String getCode() { + return code; + } + + public String getDisplayName() { + return displayName; + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/CommonErrorCode.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/CommonErrorCode.java index c97746ae..126790d9 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/CommonErrorCode.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/CommonErrorCode.java @@ -2,60 +2,60 @@ /** * 공통/시스템 에러 코드 - * + *

* 도메인에 종속되지 않는 공통 에러 코드를 정의합니다. * - 인증/인가 에러 (AUTH_XXX) * - 검증 에러 (VALIDATION_XXX) * - 시스템 에러 (SYSTEM_XXX) */ public enum CommonErrorCode implements ErrorCode { - - // 인증/인가 관련 에러 - UNAUTHORIZED("AUTH_001", "인증이 필요합니다", 401), - FORBIDDEN("AUTH_002", "접근 권한이 없습니다", 403), - INVALID_TOKEN("AUTH_003", "유효하지 않은 토큰입니다", 401), - TOKEN_EXPIRED("AUTH_004", "토큰이 만료되었습니다", 401), - - // 검증 관련 에러 - INVALID_INPUT("VALIDATION_001", "잘못된 입력입니다", 400), - REQUIRED_FIELD_MISSING("VALIDATION_002", "필수 필드가 누락되었습니다", 400), - INVALID_FORMAT("VALIDATION_003", "형식이 올바르지 않습니다", 400), - VALUE_OUT_OF_RANGE("VALIDATION_004", "값이 허용 범위를 벗어났습니다", 400), - - // 리소스 관련 에러 - RESOURCE_NOT_FOUND("RESOURCE_001", "리소스를 찾을 수 없습니다", 404), - METHOD_NOT_ALLOWED("RESOURCE_003", "허용되지 않는 메서드입니다", 405), - RESOURCE_ALREADY_EXISTS("RESOURCE_002", "이미 존재하는 리소스입니다", 409), - - // 시스템 에러 - INTERNAL_SERVER_ERROR("SYSTEM_001", "내부 서버 오류가 발생했습니다", 500), - DATABASE_ERROR("SYSTEM_002", "데이터베이스 오류가 발생했습니다", 500), - EXTERNAL_API_ERROR("SYSTEM_003", "외부 API 호출 오류가 발생했습니다", 502), - SERVICE_UNAVAILABLE("SYSTEM_004", "서비스를 일시적으로 사용할 수 없습니다", 503), - ; - - private final String code; - private final String message; - private final int statusCode; - - CommonErrorCode(String code, String message, int statusCode) { - this.code = code; - this.message = message; - this.statusCode = statusCode; - } - - @Override - public String getCode() { - return code; - } - - @Override - public String getMessage() { - return message; - } - - @Override - public int getStatusCode() { - return statusCode; - } + + // 인증/인가 관련 에러 + UNAUTHORIZED("AUTH_001", "인증이 필요합니다", 401), + FORBIDDEN("AUTH_002", "접근 권한이 없습니다", 403), + INVALID_TOKEN("AUTH_003", "유효하지 않은 토큰입니다", 401), + TOKEN_EXPIRED("AUTH_004", "토큰이 만료되었습니다", 401), + + // 검증 관련 에러 + INVALID_INPUT("VALIDATION_001", "잘못된 입력입니다", 400), + REQUIRED_FIELD_MISSING("VALIDATION_002", "필수 필드가 누락되었습니다", 400), + INVALID_FORMAT("VALIDATION_003", "형식이 올바르지 않습니다", 400), + VALUE_OUT_OF_RANGE("VALIDATION_004", "값이 허용 범위를 벗어났습니다", 400), + + // 리소스 관련 에러 + RESOURCE_NOT_FOUND("RESOURCE_001", "리소스를 찾을 수 없습니다", 404), + METHOD_NOT_ALLOWED("RESOURCE_003", "허용되지 않는 메서드입니다", 405), + RESOURCE_ALREADY_EXISTS("RESOURCE_002", "이미 존재하는 리소스입니다", 409), + + // 시스템 에러 + INTERNAL_SERVER_ERROR("SYSTEM_001", "내부 서버 오류가 발생했습니다", 500), + DATABASE_ERROR("SYSTEM_002", "데이터베이스 오류가 발생했습니다", 500), + EXTERNAL_API_ERROR("SYSTEM_003", "외부 API 호출 오류가 발생했습니다", 502), + SERVICE_UNAVAILABLE("SYSTEM_004", "서비스를 일시적으로 사용할 수 없습니다", 503), + ; + + private final String code; + private final String message; + private final int statusCode; + + CommonErrorCode(String code, String message, int statusCode) { + this.code = code; + this.message = message; + this.statusCode = statusCode; + } + + @Override + public String getCode() { + return code; + } + + @Override + public String getMessage() { + return message; + } + + @Override + public int getStatusCode() { + return statusCode; + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/CommonException.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/CommonException.java index 5b72794d..a6f67ed0 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/CommonException.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/CommonException.java @@ -2,136 +2,136 @@ /** * 공통/시스템 예외 클래스 - * + *

* 도메인에 종속되지 않는 공통 예외를 처리합니다. * 정적 팩토리 메서드를 통해 가독성 높은 예외 생성을 지원합니다. - * + *

* 사용 예시: * throw CommonException.unauthorized(); * throw CommonException.notFound("사용자"); * throw CommonException.invalidInput("이메일 형식이 올바르지 않습니다"); */ public class CommonException extends ServerlessException { - - private CommonException(CommonErrorCode errorCode) { - super(errorCode); - } - - private CommonException(CommonErrorCode errorCode, String message) { - super(errorCode, message); - } - - private CommonException(CommonErrorCode errorCode, Throwable cause) { - super(errorCode, cause); - } - - private CommonException(CommonErrorCode errorCode, String message, Throwable cause) { - super(errorCode, message, cause); - } - - // === 인증/인가 예외 팩토리 메서드 === - - public static CommonException unauthorized() { - return new CommonException(CommonErrorCode.UNAUTHORIZED); - } - - public static CommonException unauthorized(String message) { - return new CommonException(CommonErrorCode.UNAUTHORIZED, message); - } - - public static CommonException forbidden() { - return new CommonException(CommonErrorCode.FORBIDDEN); - } - - public static CommonException forbidden(String resource) { - return new CommonException(CommonErrorCode.FORBIDDEN, - String.format("'%s'에 대한 접근 권한이 없습니다", resource)); - } - - public static CommonException invalidToken() { - return new CommonException(CommonErrorCode.INVALID_TOKEN); - } - - public static CommonException tokenExpired() { - return new CommonException(CommonErrorCode.TOKEN_EXPIRED); - } - - // === 검증 예외 팩토리 메서드 === - - public static CommonException invalidInput() { - return new CommonException(CommonErrorCode.INVALID_INPUT); - } - - public static CommonException invalidInput(String message) { - return new CommonException(CommonErrorCode.INVALID_INPUT, message); - } - - public static CommonException requiredFieldMissing(String fieldName) { - return new CommonException(CommonErrorCode.REQUIRED_FIELD_MISSING, - String.format("필수 필드 '%s'가 누락되었습니다", fieldName)); - } - - public static CommonException invalidFormat(String fieldName) { - return new CommonException(CommonErrorCode.INVALID_FORMAT, - String.format("'%s' 형식이 올바르지 않습니다", fieldName)); - } - - public static CommonException valueOutOfRange(String fieldName, Object min, Object max) { - return new CommonException(CommonErrorCode.VALUE_OUT_OF_RANGE, - String.format("'%s' 값은 %s ~ %s 범위여야 합니다", fieldName, min, max)); - } - - // === 리소스 예외 팩토리 메서드 === - - public static CommonException notFound(String resourceName) { - return new CommonException(CommonErrorCode.RESOURCE_NOT_FOUND, - String.format("'%s'를 찾을 수 없습니다", resourceName)); - } - - public static CommonException notFound(String resourceName, String identifier) { - return new CommonException(CommonErrorCode.RESOURCE_NOT_FOUND, - String.format("'%s' (ID: %s)를 찾을 수 없습니다", resourceName, identifier)); - } - - public static CommonException alreadyExists(String resourceName) { - return new CommonException(CommonErrorCode.RESOURCE_ALREADY_EXISTS, - String.format("'%s'가 이미 존재합니다", resourceName)); - } - - public static CommonException alreadyExists(String resourceName, String identifier) { - return new CommonException(CommonErrorCode.RESOURCE_ALREADY_EXISTS, - String.format("'%s' (ID: %s)가 이미 존재합니다", resourceName, identifier)); - } - - // === 시스템 예외 팩토리 메서드 === - - public static CommonException internalError() { - return new CommonException(CommonErrorCode.INTERNAL_SERVER_ERROR); - } - - public static CommonException internalError(Throwable cause) { - return new CommonException(CommonErrorCode.INTERNAL_SERVER_ERROR, cause); - } - - public static CommonException internalError(String message) { - return new CommonException(CommonErrorCode.INTERNAL_SERVER_ERROR, message); - } - - public static CommonException databaseError(Throwable cause) { - return new CommonException(CommonErrorCode.DATABASE_ERROR, cause); - } - - public static CommonException externalApiError(String apiName) { - return new CommonException(CommonErrorCode.EXTERNAL_API_ERROR, - String.format("'%s' API 호출 중 오류가 발생했습니다", apiName)); - } - - public static CommonException externalApiError(String apiName, Throwable cause) { - return new CommonException(CommonErrorCode.EXTERNAL_API_ERROR, - String.format("'%s' API 호출 중 오류가 발생했습니다", apiName), cause); - } - - public static CommonException serviceUnavailable() { - return new CommonException(CommonErrorCode.SERVICE_UNAVAILABLE); - } + + private CommonException(CommonErrorCode errorCode) { + super(errorCode); + } + + private CommonException(CommonErrorCode errorCode, String message) { + super(errorCode, message); + } + + private CommonException(CommonErrorCode errorCode, Throwable cause) { + super(errorCode, cause); + } + + private CommonException(CommonErrorCode errorCode, String message, Throwable cause) { + super(errorCode, message, cause); + } + + // === 인증/인가 예외 팩토리 메서드 === + + public static CommonException unauthorized() { + return new CommonException(CommonErrorCode.UNAUTHORIZED); + } + + public static CommonException unauthorized(String message) { + return new CommonException(CommonErrorCode.UNAUTHORIZED, message); + } + + public static CommonException forbidden() { + return new CommonException(CommonErrorCode.FORBIDDEN); + } + + public static CommonException forbidden(String resource) { + return new CommonException(CommonErrorCode.FORBIDDEN, + String.format("'%s'에 대한 접근 권한이 없습니다", resource)); + } + + public static CommonException invalidToken() { + return new CommonException(CommonErrorCode.INVALID_TOKEN); + } + + public static CommonException tokenExpired() { + return new CommonException(CommonErrorCode.TOKEN_EXPIRED); + } + + // === 검증 예외 팩토리 메서드 === + + public static CommonException invalidInput() { + return new CommonException(CommonErrorCode.INVALID_INPUT); + } + + public static CommonException invalidInput(String message) { + return new CommonException(CommonErrorCode.INVALID_INPUT, message); + } + + public static CommonException requiredFieldMissing(String fieldName) { + return new CommonException(CommonErrorCode.REQUIRED_FIELD_MISSING, + String.format("필수 필드 '%s'가 누락되었습니다", fieldName)); + } + + public static CommonException invalidFormat(String fieldName) { + return new CommonException(CommonErrorCode.INVALID_FORMAT, + String.format("'%s' 형식이 올바르지 않습니다", fieldName)); + } + + public static CommonException valueOutOfRange(String fieldName, Object min, Object max) { + return new CommonException(CommonErrorCode.VALUE_OUT_OF_RANGE, + String.format("'%s' 값은 %s ~ %s 범위여야 합니다", fieldName, min, max)); + } + + // === 리소스 예외 팩토리 메서드 === + + public static CommonException notFound(String resourceName) { + return new CommonException(CommonErrorCode.RESOURCE_NOT_FOUND, + String.format("'%s'를 찾을 수 없습니다", resourceName)); + } + + public static CommonException notFound(String resourceName, String identifier) { + return new CommonException(CommonErrorCode.RESOURCE_NOT_FOUND, + String.format("'%s' (ID: %s)를 찾을 수 없습니다", resourceName, identifier)); + } + + public static CommonException alreadyExists(String resourceName) { + return new CommonException(CommonErrorCode.RESOURCE_ALREADY_EXISTS, + String.format("'%s'가 이미 존재합니다", resourceName)); + } + + public static CommonException alreadyExists(String resourceName, String identifier) { + return new CommonException(CommonErrorCode.RESOURCE_ALREADY_EXISTS, + String.format("'%s' (ID: %s)가 이미 존재합니다", resourceName, identifier)); + } + + // === 시스템 예외 팩토리 메서드 === + + public static CommonException internalError() { + return new CommonException(CommonErrorCode.INTERNAL_SERVER_ERROR); + } + + public static CommonException internalError(Throwable cause) { + return new CommonException(CommonErrorCode.INTERNAL_SERVER_ERROR, cause); + } + + public static CommonException internalError(String message) { + return new CommonException(CommonErrorCode.INTERNAL_SERVER_ERROR, message); + } + + public static CommonException databaseError(Throwable cause) { + return new CommonException(CommonErrorCode.DATABASE_ERROR, cause); + } + + public static CommonException externalApiError(String apiName) { + return new CommonException(CommonErrorCode.EXTERNAL_API_ERROR, + String.format("'%s' API 호출 중 오류가 발생했습니다", apiName)); + } + + public static CommonException externalApiError(String apiName, Throwable cause) { + return new CommonException(CommonErrorCode.EXTERNAL_API_ERROR, + String.format("'%s' API 호출 중 오류가 발생했습니다", apiName), cause); + } + + public static CommonException serviceUnavailable() { + return new CommonException(CommonErrorCode.SERVICE_UNAVAILABLE); + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/DomainErrorCode.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/DomainErrorCode.java index 0a51850f..83ddb2ef 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/DomainErrorCode.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/DomainErrorCode.java @@ -2,25 +2,25 @@ /** * 도메인별 에러 코드 인터페이스 - * + *

* 각 도메인(Vocabulary, Chatting 등)의 비즈니스 로직 관련 에러 코드가 구현하는 인터페이스입니다. * ErrorCode를 확장하여 도메인 식별 기능을 추가합니다. - * + *

* 구현체: * - VocabularyErrorCode - 단어 학습 도메인 * - ChattingErrorCode - 채팅 도메인 */ public non-sealed interface DomainErrorCode extends ErrorCode { - - /** - * 도메인 이름 반환 (예: "VOCABULARY", "CHATTING") - */ - String getDomain(); - - /** - * 전체 에러 식별자 반환 (예: VOCABULARY.WORD_001) - */ - default String getFullCode() { - return getDomain() + "." + getCode(); - } + + /** + * 도메인 이름 반환 (예: "VOCABULARY", "CHATTING") + */ + String getDomain(); + + /** + * 전체 에러 식별자 반환 (예: VOCABULARY.WORD_001) + */ + default String getFullCode() { + return getDomain() + "." + getCode(); + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/ErrorCode.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/ErrorCode.java index 1ca5f177..35261fa0 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/ErrorCode.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/ErrorCode.java @@ -2,47 +2,47 @@ /** * 에러 코드 표준 인터페이스 (Sealed Interface) - * + *

* 모든 에러 코드 enum이 구현해야 하는 표준 계약을 정의합니다. * Sealed interface를 사용하여 허용된 구현체만 존재하도록 제한합니다. - * + *

* 계층 구조: * ErrorCode (sealed) * ├── CommonErrorCode (시스템/공통 에러) * └── DomainErrorCode (non-sealed) - 도메인별 에러 - * ├── VocabularyErrorCode - * └── ChattingErrorCode + * ├── VocabularyErrorCode + * └── ChattingErrorCode */ public sealed interface ErrorCode permits CommonErrorCode, DomainErrorCode { - - /** - * 에러 코드 반환 (예: AUTH_001, VOCAB_001, CHAT_001) - */ - String getCode(); - - /** - * 에러 메시지 반환 - */ - String getMessage(); - - /** - * HTTP 상태 코드 반환 (예: 400, 404, 500) - */ - int getStatusCode(); - - /** - * 클라이언트 에러 여부 (4xx) - */ - default boolean isClientError() { - int status = getStatusCode(); - return status >= 400 && status < 500; - } - - /** - * 서버 에러 여부 (5xx) - */ - default boolean isServerError() { - int status = getStatusCode(); - return status >= 500 && status < 600; - } + + /** + * 에러 코드 반환 (예: AUTH_001, VOCAB_001, CHAT_001) + */ + String getCode(); + + /** + * 에러 메시지 반환 + */ + String getMessage(); + + /** + * HTTP 상태 코드 반환 (예: 400, 404, 500) + */ + int getStatusCode(); + + /** + * 클라이언트 에러 여부 (4xx) + */ + default boolean isClientError() { + int status = getStatusCode(); + return status >= 400 && status < 500; + } + + /** + * 서버 에러 여부 (5xx) + */ + default boolean isServerError() { + int status = getStatusCode(); + return status >= 500 && status < 600; + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/ServerlessException.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/ServerlessException.java index 897c8fca..f95ea8ab 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/ServerlessException.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/ServerlessException.java @@ -6,81 +6,81 @@ /** * 서버리스 애플리케이션 기본 예외 클래스 - * + *

* 모든 비즈니스 예외의 추상 기반 클래스입니다. * ErrorCode를 통해 표준화된 에러 정보를 제공합니다. - * + *

* 사용 예시: * - CommonException: 공통/시스템 예외 * - VocabularyException: 단어 학습 도메인 예외 * - ChattingException: 채팅 도메인 예외 */ public abstract class ServerlessException extends RuntimeException { - - private final ErrorCode errorCode; - private final Map details; - - protected ServerlessException(ErrorCode errorCode) { - super(errorCode.getMessage()); - this.errorCode = errorCode; - this.details = new HashMap<>(); - } - - protected ServerlessException(ErrorCode errorCode, String message) { - super(message); - this.errorCode = errorCode; - this.details = new HashMap<>(); - } - - protected ServerlessException(ErrorCode errorCode, Throwable cause) { - super(errorCode.getMessage(), cause); - this.errorCode = errorCode; - this.details = new HashMap<>(); - } - - protected ServerlessException(ErrorCode errorCode, String message, Throwable cause) { - super(message, cause); - this.errorCode = errorCode; - this.details = new HashMap<>(); - } - - public ErrorCode getErrorCode() { - return errorCode; - } - - public String getCode() { - return errorCode.getCode(); - } - - public int getStatusCode() { - return errorCode.getStatusCode(); - } - - public Map getDetails() { - return Collections.unmodifiableMap(details); - } - - /** - * 에러 상세 정보 추가 (메서드 체이닝 지원) - */ - public ServerlessException addDetail(String key, Object value) { - this.details.put(key, value); - return this; - } - - /** - * 여러 상세 정보 일괄 추가 - */ - public ServerlessException addDetails(Map details) { - this.details.putAll(details); - return this; - } - - public boolean isClientError() { - return errorCode.isClientError(); - } - - public boolean isServerError() { - return errorCode.isServerError(); - } + + private final ErrorCode errorCode; + private final Map details; + + protected ServerlessException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + this.details = new HashMap<>(); + } + + protected ServerlessException(ErrorCode errorCode, String message) { + super(message); + this.errorCode = errorCode; + this.details = new HashMap<>(); + } + + protected ServerlessException(ErrorCode errorCode, Throwable cause) { + super(errorCode.getMessage(), cause); + this.errorCode = errorCode; + this.details = new HashMap<>(); + } + + protected ServerlessException(ErrorCode errorCode, String message, Throwable cause) { + super(message, cause); + this.errorCode = errorCode; + this.details = new HashMap<>(); + } + + public ErrorCode getErrorCode() { + return errorCode; + } + + public String getCode() { + return errorCode.getCode(); + } + + public int getStatusCode() { + return errorCode.getStatusCode(); + } + + public Map getDetails() { + return Collections.unmodifiableMap(details); + } + + /** + * 에러 상세 정보 추가 (메서드 체이닝 지원) + */ + public ServerlessException addDetail(String key, Object value) { + this.details.put(key, value); + return this; + } + + /** + * 여러 상세 정보 일괄 추가 + */ + public ServerlessException addDetails(Map details) { + this.details.putAll(details); + return this; + } + + public boolean isClientError() { + return errorCode.isClientError(); + } + + public boolean isServerError() { + return errorCode.isServerError(); + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/AuthenticatedHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/AuthenticatedHandler.java index 1a8efa0b..b49dd275 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/AuthenticatedHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/AuthenticatedHandler.java @@ -5,10 +5,10 @@ /** * Cognito 인증이 필요한 요청 핸들러 - * + *

* userId가 자동으로 추출되어 전달됩니다. */ @FunctionalInterface public interface AuthenticatedHandler { - APIGatewayProxyResponseEvent handle(APIGatewayProxyRequestEvent request, String userId); + APIGatewayProxyResponseEvent handle(APIGatewayProxyRequestEvent request, String userId); } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/HandlerRouter.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/HandlerRouter.java index 824cf4ae..e7cc2482 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/HandlerRouter.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/HandlerRouter.java @@ -2,9 +2,9 @@ import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import com.mzc.secondproject.serverless.common.dto.ErrorInfo; import com.mzc.secondproject.serverless.common.exception.CommonErrorCode; import com.mzc.secondproject.serverless.common.exception.ServerlessException; -import com.mzc.secondproject.serverless.common.dto.ErrorInfo; import com.mzc.secondproject.serverless.common.util.ResponseGenerator; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -18,9 +18,9 @@ /** * Lambda Handler를 위한 HTTP 라우터 - * + *

* 선언적 라우팅 + 자동 Path/Query 파라미터 검증 제공 - * + *

* 사용 예시: *

  * new HandlerRouter().addRoutes(
@@ -30,150 +30,150 @@
  * 
*/ public class HandlerRouter { - - private static final Logger logger = LoggerFactory.getLogger(HandlerRouter.class); - - private final List routes = new ArrayList<>(); - - /** - * 라우트 등록 - */ - public HandlerRouter addRoute(Route route) { - String regex = convertPatternToRegex(route.pathPattern()); - Pattern pattern = Pattern.compile(regex); - routes.add(new RouteEntry(route, pattern)); - return this; - } - - /** - * 여러 라우트 한번에 등록 - */ - public HandlerRouter addRoutes(Route... routeArray) { - for (Route route : routeArray) { - addRoute(route); - } - return this; - } - - /** - * 요청을 적절한 핸들러로 라우팅 - */ - public APIGatewayProxyResponseEvent route(APIGatewayProxyRequestEvent request) { - String method = request.getHttpMethod(); - String path = request.getPath(); - - logger.info("Routing request: {} {}", method, path); - - for (RouteEntry entry : routes) { - if (entry.matches(method, path)) { - logger.debug("Matched route: {} {}", entry.route.method(), entry.route.pathPattern()); - - // Path/Query 파라미터 자동 검증 - String validationError = validateParams(request, entry.route); - if (validationError != null) { - logger.warn("Validation failed: {}", validationError); - return ResponseGenerator.fail(CommonErrorCode.REQUIRED_FIELD_MISSING, validationError); - } - - try { - return entry.route.handler().apply(request); - } catch (ServerlessException e) { - return handleServerlessException(e); - } catch (IllegalArgumentException e) { - logger.warn("Bad request: {}", e.getMessage()); - return ResponseGenerator.fail(CommonErrorCode.INVALID_INPUT, e.getMessage()); - } catch (IllegalStateException e) { - logger.warn("Conflict: {}", e.getMessage()); - return ResponseGenerator.fail(CommonErrorCode.RESOURCE_ALREADY_EXISTS, e.getMessage()); - } catch (SecurityException e) { - logger.warn("Forbidden: {}", e.getMessage()); - return ResponseGenerator.fail(CommonErrorCode.FORBIDDEN, e.getMessage()); - } catch (Exception e) { - logger.error("Error handling request", e); - return ResponseGenerator.fail(CommonErrorCode.INTERNAL_SERVER_ERROR); - } - } - } - - logger.warn("No route found for: {} {}", method, path); - return ResponseGenerator.fail(CommonErrorCode.RESOURCE_NOT_FOUND); - } - - /** - * Path/Query 파라미터 검증 - * - * @return 에러 메시지 (검증 성공 시 null) - */ - private String validateParams(APIGatewayProxyRequestEvent request, Route route) { - List missingParams = new ArrayList<>(); - - // Path 파라미터 검증 - Map pathParams = request.getPathParameters(); - for (String param : route.requiredPathParams()) { - if (pathParams == null || isBlank(pathParams.get(param))) { - missingParams.add(param); - } - } - - // Query 파라미터 검증 - Map queryParams = request.getQueryStringParameters(); - for (String param : route.requiredQueryParams()) { - if (queryParams == null || isBlank(queryParams.get(param))) { - missingParams.add(param); - } - } - - if (missingParams.isEmpty()) { - return null; - } - - return missingParams.stream() - .map(p -> p + " is required") - .collect(Collectors.joining(", ")); - } - - private boolean isBlank(String value) { - return value == null || value.trim().isEmpty(); - } - - /** - * 경로 패턴을 정규식으로 변환 - * /rooms/{roomId} -> /rooms/[^/]+ - * /rooms/{roomId}/join -> /rooms/[^/]+/join - */ - private String convertPatternToRegex(String pathPattern) { - String regex = pathPattern - .replaceAll("\\{[^}]+\\}", "[^/]+") - .replace("/", "\\/"); - return ".*" + regex + "$"; - } - - /** - * ServerlessException 처리 - * ErrorCode 기반의 표준화된 에러 응답 생성 - */ - private APIGatewayProxyResponseEvent handleServerlessException(ServerlessException e) { - ErrorInfo errorInfo = ErrorInfo.from(e); - - if (e.isClientError()) { - logger.warn("Client error [{}]: {}", errorInfo.code(), e.getMessage()); - } else { - logger.error("Server error [{}]: {}", errorInfo.code(), e.getMessage(), e); - } - - return ResponseGenerator.createResponse(e.getStatusCode(), errorInfo); - } - - /** - * 라우트 엔트리 (라우트 + 컴파일된 패턴) - */ - private record RouteEntry(Route route, Pattern pattern) { - boolean matches(String method, String path) { - if (!route.method().equalsIgnoreCase(method)) { - return false; - } - Matcher matcher = pattern.matcher(path); - return matcher.matches(); - } - } + + private static final Logger logger = LoggerFactory.getLogger(HandlerRouter.class); + + private final List routes = new ArrayList<>(); + + /** + * 라우트 등록 + */ + public HandlerRouter addRoute(Route route) { + String regex = convertPatternToRegex(route.pathPattern()); + Pattern pattern = Pattern.compile(regex); + routes.add(new RouteEntry(route, pattern)); + return this; + } + + /** + * 여러 라우트 한번에 등록 + */ + public HandlerRouter addRoutes(Route... routeArray) { + for (Route route : routeArray) { + addRoute(route); + } + return this; + } + + /** + * 요청을 적절한 핸들러로 라우팅 + */ + public APIGatewayProxyResponseEvent route(APIGatewayProxyRequestEvent request) { + String method = request.getHttpMethod(); + String path = request.getPath(); + + logger.info("Routing request: {} {}", method, path); + + for (RouteEntry entry : routes) { + if (entry.matches(method, path)) { + logger.debug("Matched route: {} {}", entry.route.method(), entry.route.pathPattern()); + + // Path/Query 파라미터 자동 검증 + String validationError = validateParams(request, entry.route); + if (validationError != null) { + logger.warn("Validation failed: {}", validationError); + return ResponseGenerator.fail(CommonErrorCode.REQUIRED_FIELD_MISSING, validationError); + } + + try { + return entry.route.handler().apply(request); + } catch (ServerlessException e) { + return handleServerlessException(e); + } catch (IllegalArgumentException e) { + logger.warn("Bad request: {}", e.getMessage()); + return ResponseGenerator.fail(CommonErrorCode.INVALID_INPUT, e.getMessage()); + } catch (IllegalStateException e) { + logger.warn("Conflict: {}", e.getMessage()); + return ResponseGenerator.fail(CommonErrorCode.RESOURCE_ALREADY_EXISTS, e.getMessage()); + } catch (SecurityException e) { + logger.warn("Forbidden: {}", e.getMessage()); + return ResponseGenerator.fail(CommonErrorCode.FORBIDDEN, e.getMessage()); + } catch (Exception e) { + logger.error("Error handling request", e); + return ResponseGenerator.fail(CommonErrorCode.INTERNAL_SERVER_ERROR); + } + } + } + + logger.warn("No route found for: {} {}", method, path); + return ResponseGenerator.fail(CommonErrorCode.RESOURCE_NOT_FOUND); + } + + /** + * Path/Query 파라미터 검증 + * + * @return 에러 메시지 (검증 성공 시 null) + */ + private String validateParams(APIGatewayProxyRequestEvent request, Route route) { + List missingParams = new ArrayList<>(); + + // Path 파라미터 검증 + Map pathParams = request.getPathParameters(); + for (String param : route.requiredPathParams()) { + if (pathParams == null || isBlank(pathParams.get(param))) { + missingParams.add(param); + } + } + + // Query 파라미터 검증 + Map queryParams = request.getQueryStringParameters(); + for (String param : route.requiredQueryParams()) { + if (queryParams == null || isBlank(queryParams.get(param))) { + missingParams.add(param); + } + } + + if (missingParams.isEmpty()) { + return null; + } + + return missingParams.stream() + .map(p -> p + " is required") + .collect(Collectors.joining(", ")); + } + + private boolean isBlank(String value) { + return value == null || value.trim().isEmpty(); + } + + /** + * 경로 패턴을 정규식으로 변환 + * /rooms/{roomId} -> /rooms/[^/]+ + * /rooms/{roomId}/join -> /rooms/[^/]+/join + */ + private String convertPatternToRegex(String pathPattern) { + String regex = pathPattern + .replaceAll("\\{[^}]+\\}", "[^/]+") + .replace("/", "\\/"); + return ".*" + regex + "$"; + } + + /** + * ServerlessException 처리 + * ErrorCode 기반의 표준화된 에러 응답 생성 + */ + private APIGatewayProxyResponseEvent handleServerlessException(ServerlessException e) { + ErrorInfo errorInfo = ErrorInfo.from(e); + + if (e.isClientError()) { + logger.warn("Client error [{}]: {}", errorInfo.code(), e.getMessage()); + } else { + logger.error("Server error [{}]: {}", errorInfo.code(), e.getMessage(), e); + } + + return ResponseGenerator.createResponse(e.getStatusCode(), errorInfo); + } + + /** + * 라우트 엔트리 (라우트 + 컴파일된 패턴) + */ + private record RouteEntry(Route route, Pattern pattern) { + boolean matches(String method, String path) { + if (!route.method().equalsIgnoreCase(method)) { + return false; + } + Matcher matcher = pattern.matcher(path); + return matcher.matches(); + } + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/Route.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/Route.java index 29bfb234..44a46f6f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/Route.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/Route.java @@ -13,109 +13,109 @@ /** * HTTP 라우트 정의 - * + *

* Path 패턴에서 자동으로 필수 파라미터를 추출합니다. * 예: "/rooms/{roomId}/messages/{messageId}" → ["roomId", "messageId"] * - * @param method HTTP 메서드 (GET, POST, PUT, DELETE 등) - * @param pathPattern 경로 패턴 (예: "/rooms", "/rooms/{roomId}", "/rooms/{roomId}/join") - * @param handler 요청 처리 함수 - * @param requiredPathParams 필수 Path 파라미터 목록 (자동 추출) + * @param method HTTP 메서드 (GET, POST, PUT, DELETE 등) + * @param pathPattern 경로 패턴 (예: "/rooms", "/rooms/{roomId}", "/rooms/{roomId}/join") + * @param handler 요청 처리 함수 + * @param requiredPathParams 필수 Path 파라미터 목록 (자동 추출) * @param requiredQueryParams 필수 Query 파라미터 목록 (선택적 지정) */ public record Route( - String method, - String pathPattern, - Function handler, - List requiredPathParams, - List requiredQueryParams + String method, + String pathPattern, + Function handler, + List requiredPathParams, + List requiredQueryParams ) { - private static final Pattern PATH_PARAM_PATTERN = Pattern.compile("\\{([^}]+)}"); - - /** - * Path 패턴에서 파라미터 이름 추출 - */ - private static List extractPathParams(String pathPattern) { - List params = new ArrayList<>(); - Matcher matcher = PATH_PARAM_PATTERN.matcher(pathPattern); - while (matcher.find()) { - params.add(matcher.group(1)); - } - return Collections.unmodifiableList(params); - } - - public static Route get(String pathPattern, Function handler) { - return new Route("GET", pathPattern, handler, extractPathParams(pathPattern), List.of()); - } - - public static Route post(String pathPattern, Function handler) { - return new Route("POST", pathPattern, handler, extractPathParams(pathPattern), List.of()); - } - - public static Route put(String pathPattern, Function handler) { - return new Route("PUT", pathPattern, handler, extractPathParams(pathPattern), List.of()); - } - - public static Route delete(String pathPattern, Function handler) { - return new Route("DELETE", pathPattern, handler, extractPathParams(pathPattern), List.of()); - } - - public static Route patch(String pathPattern, Function handler) { - return new Route("PATCH", pathPattern, handler, extractPathParams(pathPattern), List.of()); - } - - // ============ Cognito 인증 핸들러 메서드 ============ - - /** - * Cognito 인증이 필요한 GET 라우트 - * userId가 자동으로 추출되어 핸들러에 전달됩니다. - */ - public static Route getAuth(String pathPattern, AuthenticatedHandler handler) { - return new Route("GET", pathPattern, - request -> handler.handle(request, CognitoUtil.extractUserId(request)), - extractPathParams(pathPattern), List.of()); - } - - /** - * Cognito 인증이 필요한 POST 라우트 - */ - public static Route postAuth(String pathPattern, AuthenticatedHandler handler) { - return new Route("POST", pathPattern, - request -> handler.handle(request, CognitoUtil.extractUserId(request)), - extractPathParams(pathPattern), List.of()); - } - - /** - * Cognito 인증이 필요한 PUT 라우트 - */ - public static Route putAuth(String pathPattern, AuthenticatedHandler handler) { - return new Route("PUT", pathPattern, - request -> handler.handle(request, CognitoUtil.extractUserId(request)), - extractPathParams(pathPattern), List.of()); - } - - /** - * Cognito 인증이 필요한 DELETE 라우트 - */ - public static Route deleteAuth(String pathPattern, AuthenticatedHandler handler) { - return new Route("DELETE", pathPattern, - request -> handler.handle(request, CognitoUtil.extractUserId(request)), - extractPathParams(pathPattern), List.of()); - } - - /** - * Cognito 인증이 필요한 PATCH 라우트 - */ - public static Route patchAuth(String pathPattern, AuthenticatedHandler handler) { - return new Route("PATCH", pathPattern, - request -> handler.handle(request, CognitoUtil.extractUserId(request)), - extractPathParams(pathPattern), List.of()); - } - - /** - * 필수 Query 파라미터 추가 - */ - public Route requireQueryParams(String... params) { - return new Route(method, pathPattern, handler, requiredPathParams, List.of(params)); - } + private static final Pattern PATH_PARAM_PATTERN = Pattern.compile("\\{([^}]+)}"); + + /** + * Path 패턴에서 파라미터 이름 추출 + */ + private static List extractPathParams(String pathPattern) { + List params = new ArrayList<>(); + Matcher matcher = PATH_PARAM_PATTERN.matcher(pathPattern); + while (matcher.find()) { + params.add(matcher.group(1)); + } + return Collections.unmodifiableList(params); + } + + public static Route get(String pathPattern, Function handler) { + return new Route("GET", pathPattern, handler, extractPathParams(pathPattern), List.of()); + } + + public static Route post(String pathPattern, Function handler) { + return new Route("POST", pathPattern, handler, extractPathParams(pathPattern), List.of()); + } + + public static Route put(String pathPattern, Function handler) { + return new Route("PUT", pathPattern, handler, extractPathParams(pathPattern), List.of()); + } + + public static Route delete(String pathPattern, Function handler) { + return new Route("DELETE", pathPattern, handler, extractPathParams(pathPattern), List.of()); + } + + public static Route patch(String pathPattern, Function handler) { + return new Route("PATCH", pathPattern, handler, extractPathParams(pathPattern), List.of()); + } + + // ============ Cognito 인증 핸들러 메서드 ============ + + /** + * Cognito 인증이 필요한 GET 라우트 + * userId가 자동으로 추출되어 핸들러에 전달됩니다. + */ + public static Route getAuth(String pathPattern, AuthenticatedHandler handler) { + return new Route("GET", pathPattern, + request -> handler.handle(request, CognitoUtil.extractUserId(request)), + extractPathParams(pathPattern), List.of()); + } + + /** + * Cognito 인증이 필요한 POST 라우트 + */ + public static Route postAuth(String pathPattern, AuthenticatedHandler handler) { + return new Route("POST", pathPattern, + request -> handler.handle(request, CognitoUtil.extractUserId(request)), + extractPathParams(pathPattern), List.of()); + } + + /** + * Cognito 인증이 필요한 PUT 라우트 + */ + public static Route putAuth(String pathPattern, AuthenticatedHandler handler) { + return new Route("PUT", pathPattern, + request -> handler.handle(request, CognitoUtil.extractUserId(request)), + extractPathParams(pathPattern), List.of()); + } + + /** + * Cognito 인증이 필요한 DELETE 라우트 + */ + public static Route deleteAuth(String pathPattern, AuthenticatedHandler handler) { + return new Route("DELETE", pathPattern, + request -> handler.handle(request, CognitoUtil.extractUserId(request)), + extractPathParams(pathPattern), List.of()); + } + + /** + * Cognito 인증이 필요한 PATCH 라우트 + */ + public static Route patchAuth(String pathPattern, AuthenticatedHandler handler) { + return new Route("PATCH", pathPattern, + request -> handler.handle(request, CognitoUtil.extractUserId(request)), + extractPathParams(pathPattern), List.of()); + } + + /** + * 필수 Query 파라미터 추가 + */ + public Route requireQueryParams(String... params) { + return new Route(method, pathPattern, handler, requiredPathParams, List.of(params)); + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/service/PollyService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/service/PollyService.java index beaa84c9..b56f77d1 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/service/PollyService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/service/PollyService.java @@ -7,12 +7,12 @@ import software.amazon.awssdk.services.polly.model.OutputFormat; import software.amazon.awssdk.services.polly.model.SynthesizeSpeechRequest; import software.amazon.awssdk.services.polly.model.VoiceId; -import software.amazon.awssdk.services.s3.model.PutObjectRequest; -import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; -import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest; import software.amazon.awssdk.services.s3.model.GetObjectRequest; import software.amazon.awssdk.services.s3.model.HeadObjectRequest; import software.amazon.awssdk.services.s3.model.NoSuchKeyException; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; +import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest; import java.io.ByteArrayOutputStream; import java.io.InputStream; @@ -23,146 +23,154 @@ * 음성 합성 결과를 S3에 캐싱하여 재사용 */ public class PollyService { - - private static final Logger logger = LoggerFactory.getLogger(PollyService.class); - - private final String bucketName; - private final String s3KeyPrefix; - - /** - * @param bucketName S3 버킷 이름 - * @param s3KeyPrefix S3 키 prefix (예: "voice/", "vocab/voice/") - */ - public PollyService(String bucketName, String s3KeyPrefix) { - this.bucketName = bucketName; - this.s3KeyPrefix = s3KeyPrefix; - } - - /** - * ID 기반으로 음성 합성 (캐시 지원) - * S3에 파일이 있으면 바로 URL 반환, 없으면 Polly 변환 후 저장 - */ - public VoiceSynthesisResult synthesizeSpeech(String id, String text, String voice) { - String s3Key = generateS3Key(id, voice); - - // 캐시 확인: S3에 이미 존재하는지 체크 - if (existsInS3(s3Key)) { - logger.info("Cache hit: {}", s3Key); - String presignedUrl = getPresignedUrl(s3Key); - return new VoiceSynthesisResult(s3Key, presignedUrl, true); - } - - // 캐시 미스: Polly 변환 후 S3 저장 - logger.info("Cache miss: synthesizing and saving to {}", s3Key); - synthesizeAndSave(text, voice, s3Key); - String presignedUrl = getPresignedUrl(s3Key); - return new VoiceSynthesisResult(s3Key, presignedUrl, false); - } - - /** - * S3 키로 Pre-signed URL 생성 - */ - public String getPresignedUrl(String s3Key) { - GetObjectRequest getObjectRequest = GetObjectRequest.builder() - .bucket(bucketName) - .key(s3Key) - .build(); - - GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() - .signatureDuration(Duration.ofHours(1)) - .getObjectRequest(getObjectRequest) - .build(); - - PresignedGetObjectRequest presignedRequest = AwsClients.s3Presigner().presignGetObject(presignRequest); - return presignedRequest.url().toString(); - } - - /** - * S3에 파일 존재 여부 확인 - */ - public boolean existsInS3(String s3Key) { - try { - AwsClients.s3().headObject(HeadObjectRequest.builder() - .bucket(bucketName) - .key(s3Key) - .build()); - return true; - } catch (NoSuchKeyException e) { - return false; - } - } - - /** - * Polly로 음성 변환 후 지정된 S3 키로 저장 - */ - private void synthesizeAndSave(String text, String voice, String s3Key) { - VoiceId voiceId = resolveVoiceId(voice); - - try { - SynthesizeSpeechRequest request = SynthesizeSpeechRequest.builder() - .text(text) - .voiceId(voiceId) - .engine("neural") - .outputFormat(OutputFormat.MP3) - .build(); - - InputStream audioStream = AwsClients.polly().synthesizeSpeech(request); - - ByteArrayOutputStream buffer = new ByteArrayOutputStream(); - byte[] data = new byte[4096]; - int bytesRead; - while ((bytesRead = audioStream.read(data, 0, data.length)) != -1) { - buffer.write(data, 0, bytesRead); - } - byte[] audioBytes = buffer.toByteArray(); - - AwsClients.s3().putObject( - PutObjectRequest.builder() - .bucket(bucketName) - .key(s3Key) - .contentType("audio/mpeg") - .build(), - RequestBody.fromBytes(audioBytes) - ); - - logger.info("Saved audio to S3: {}", s3Key); - } catch (Exception e) { - logger.error("Error synthesizing speech", e); - throw new RuntimeException("Failed to synthesize speech", e); - } - } - - /** - * ID와 음성 타입으로 S3 키 생성 - */ - public String generateS3Key(String id, String voice) { - String voiceSuffix = "MALE".equalsIgnoreCase(voice) ? "male" : "female"; - return s3KeyPrefix + id + "_" + voiceSuffix + ".mp3"; - } - - /** - * 음성 합성 결과 - */ - public static class VoiceSynthesisResult { - private final String s3Key; - private final String audioUrl; - private final boolean cached; - - public VoiceSynthesisResult(String s3Key, String audioUrl, boolean cached) { - this.s3Key = s3Key; - this.audioUrl = audioUrl; - this.cached = cached; - } - - public String getS3Key() { return s3Key; } - public String getAudioUrl() { return audioUrl; } - public boolean isCached() { return cached; } - } - - private VoiceId resolveVoiceId(String voice) { - if ("MALE".equalsIgnoreCase(voice)) { - return VoiceId.MATTHEW; // 미국 영어 남성 (Neural 지원) - } - return VoiceId.JOANNA; // 미국 영어 여성 (Neural 지원, 기본값) - } + + private static final Logger logger = LoggerFactory.getLogger(PollyService.class); + + private final String bucketName; + private final String s3KeyPrefix; + + /** + * @param bucketName S3 버킷 이름 + * @param s3KeyPrefix S3 키 prefix (예: "voice/", "vocab/voice/") + */ + public PollyService(String bucketName, String s3KeyPrefix) { + this.bucketName = bucketName; + this.s3KeyPrefix = s3KeyPrefix; + } + + /** + * ID 기반으로 음성 합성 (캐시 지원) + * S3에 파일이 있으면 바로 URL 반환, 없으면 Polly 변환 후 저장 + */ + public VoiceSynthesisResult synthesizeSpeech(String id, String text, String voice) { + String s3Key = generateS3Key(id, voice); + + // 캐시 확인: S3에 이미 존재하는지 체크 + if (existsInS3(s3Key)) { + logger.info("Cache hit: {}", s3Key); + String presignedUrl = getPresignedUrl(s3Key); + return new VoiceSynthesisResult(s3Key, presignedUrl, true); + } + + // 캐시 미스: Polly 변환 후 S3 저장 + logger.info("Cache miss: synthesizing and saving to {}", s3Key); + synthesizeAndSave(text, voice, s3Key); + String presignedUrl = getPresignedUrl(s3Key); + return new VoiceSynthesisResult(s3Key, presignedUrl, false); + } + + /** + * S3 키로 Pre-signed URL 생성 + */ + public String getPresignedUrl(String s3Key) { + GetObjectRequest getObjectRequest = GetObjectRequest.builder() + .bucket(bucketName) + .key(s3Key) + .build(); + + GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() + .signatureDuration(Duration.ofHours(1)) + .getObjectRequest(getObjectRequest) + .build(); + + PresignedGetObjectRequest presignedRequest = AwsClients.s3Presigner().presignGetObject(presignRequest); + return presignedRequest.url().toString(); + } + + /** + * S3에 파일 존재 여부 확인 + */ + public boolean existsInS3(String s3Key) { + try { + AwsClients.s3().headObject(HeadObjectRequest.builder() + .bucket(bucketName) + .key(s3Key) + .build()); + return true; + } catch (NoSuchKeyException e) { + return false; + } + } + + /** + * Polly로 음성 변환 후 지정된 S3 키로 저장 + */ + private void synthesizeAndSave(String text, String voice, String s3Key) { + VoiceId voiceId = resolveVoiceId(voice); + + try { + SynthesizeSpeechRequest request = SynthesizeSpeechRequest.builder() + .text(text) + .voiceId(voiceId) + .engine("neural") + .outputFormat(OutputFormat.MP3) + .build(); + + InputStream audioStream = AwsClients.polly().synthesizeSpeech(request); + + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + byte[] data = new byte[4096]; + int bytesRead; + while ((bytesRead = audioStream.read(data, 0, data.length)) != -1) { + buffer.write(data, 0, bytesRead); + } + byte[] audioBytes = buffer.toByteArray(); + + AwsClients.s3().putObject( + PutObjectRequest.builder() + .bucket(bucketName) + .key(s3Key) + .contentType("audio/mpeg") + .build(), + RequestBody.fromBytes(audioBytes) + ); + + logger.info("Saved audio to S3: {}", s3Key); + } catch (Exception e) { + logger.error("Error synthesizing speech", e); + throw new RuntimeException("Failed to synthesize speech", e); + } + } + + /** + * ID와 음성 타입으로 S3 키 생성 + */ + public String generateS3Key(String id, String voice) { + String voiceSuffix = "MALE".equalsIgnoreCase(voice) ? "male" : "female"; + return s3KeyPrefix + id + "_" + voiceSuffix + ".mp3"; + } + + private VoiceId resolveVoiceId(String voice) { + if ("MALE".equalsIgnoreCase(voice)) { + return VoiceId.MATTHEW; // 미국 영어 남성 (Neural 지원) + } + return VoiceId.JOANNA; // 미국 영어 여성 (Neural 지원, 기본값) + } + + /** + * 음성 합성 결과 + */ + public static class VoiceSynthesisResult { + private final String s3Key; + private final String audioUrl; + private final boolean cached; + + public VoiceSynthesisResult(String s3Key, String audioUrl, boolean cached) { + this.s3Key = s3Key; + this.audioUrl = audioUrl; + this.cached = cached; + } + + public String getS3Key() { + return s3Key; + } + + public String getAudioUrl() { + return audioUrl; + } + + public boolean isCached() { + return cached; + } + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/CognitoUtil.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/CognitoUtil.java index b50bd33a..0379dc7d 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/CognitoUtil.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/CognitoUtil.java @@ -1,7 +1,6 @@ package com.mzc.secondproject.serverless.common.util; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; -import com.mzc.secondproject.serverless.common.exception.CommonErrorCode; import java.util.Map; import java.util.Optional; @@ -10,122 +9,122 @@ * Cognito Authorizer에서 사용자 정보를 추출하는 유틸리티 클래스 */ public class CognitoUtil { - - private CognitoUtil() { - // 유틸리티 클래스 인스턴스화 방지 - } - - /** - * Cognito claims에서 userId(sub)를 추출 - * - * @param request API Gateway 요청 - * @return userId (Cognito sub) - * @throws IllegalStateException claims가 없거나 sub가 없는 경우 - */ - public static String extractUserId(APIGatewayProxyRequestEvent request) { - return extractClaim(request, "sub") - .orElseThrow(() -> new IllegalStateException("Cognito sub claim not found")); - } - - /** - * Cognito claims에서 email을 추출 - * - * @param request API Gateway 요청 - * @return email (Optional) - */ - public static Optional extractEmail(APIGatewayProxyRequestEvent request) { - return extractClaim(request, "email"); - } - - /** - * Cognito claims에서 nickname을 추출 - * - * @param request API Gateway 요청 - * @return nickname (Optional) - */ - public static Optional extractNickname(APIGatewayProxyRequestEvent request) { - return extractClaim(request, "custom:nickname"); - } - - /** - * Cognito claims에서 특정 claim을 추출 - * - * @param request API Gateway 요청 - * @param claimName claim 이름 - * @return claim 값 (Optional) - */ - @SuppressWarnings("unchecked") - public static Optional extractClaim(APIGatewayProxyRequestEvent request, String claimName) { - try { - Map authorizer = request.getRequestContext().getAuthorizer(); - if (authorizer == null) { - return Optional.empty(); - } - - Map claims = (Map) authorizer.get("claims"); - if (claims == null) { - return Optional.empty(); - } - - return Optional.ofNullable(claims.get(claimName)); - } catch (Exception e) { - return Optional.empty(); - } - } - - /** - * 경로 파라미터의 userId와 Cognito userId가 일치하는지 검증 - * - * @param request API Gateway 요청 - * @param pathUserId 경로 파라미터에서 추출한 userId - * @return 일치 여부 - */ - public static boolean validateUserAccess(APIGatewayProxyRequestEvent request, String pathUserId) { - try { - String cognitoUserId = extractUserId(request); - return cognitoUserId.equals(pathUserId); - } catch (Exception e) { - return false; - } - } - - /** - * 경로 파라미터와 Cognito userId를 검증하고, 유효한 경우 userId 반환 - * 검증 실패 시 Optional.empty() 반환 - * - * @param request API Gateway 요청 - * @param pathUserId 경로 파라미터에서 추출한 userId - * @return 검증된 userId (Optional) - */ - public static Optional validateAndExtractUserId(APIGatewayProxyRequestEvent request, String pathUserId) { - try { - String cognitoUserId = extractUserId(request); - if (cognitoUserId.equals(pathUserId)) { - return Optional.of(cognitoUserId); - } - return Optional.empty(); - } catch (Exception e) { - return Optional.empty(); - } - } - - /** - * 경로 파라미터에서 userId를 추출하고 Cognito 토큰과 검증 - * Handler에서 간편하게 사용할 수 있는 메서드 - * - * @param request API Gateway 요청 - * @return 검증된 userId (Optional) - */ - public static Optional getValidatedUserId(APIGatewayProxyRequestEvent request) { - String pathUserId = request.getPathParameters() != null - ? request.getPathParameters().get("userId") - : null; - - if (pathUserId == null) { - // 경로에 userId가 없으면 토큰에서만 추출 - return extractClaim(request, "sub"); - } - - return validateAndExtractUserId(request, pathUserId); - } + + private CognitoUtil() { + // 유틸리티 클래스 인스턴스화 방지 + } + + /** + * Cognito claims에서 userId(sub)를 추출 + * + * @param request API Gateway 요청 + * @return userId (Cognito sub) + * @throws IllegalStateException claims가 없거나 sub가 없는 경우 + */ + public static String extractUserId(APIGatewayProxyRequestEvent request) { + return extractClaim(request, "sub") + .orElseThrow(() -> new IllegalStateException("Cognito sub claim not found")); + } + + /** + * Cognito claims에서 email을 추출 + * + * @param request API Gateway 요청 + * @return email (Optional) + */ + public static Optional extractEmail(APIGatewayProxyRequestEvent request) { + return extractClaim(request, "email"); + } + + /** + * Cognito claims에서 nickname을 추출 + * + * @param request API Gateway 요청 + * @return nickname (Optional) + */ + public static Optional extractNickname(APIGatewayProxyRequestEvent request) { + return extractClaim(request, "custom:nickname"); + } + + /** + * Cognito claims에서 특정 claim을 추출 + * + * @param request API Gateway 요청 + * @param claimName claim 이름 + * @return claim 값 (Optional) + */ + @SuppressWarnings("unchecked") + public static Optional extractClaim(APIGatewayProxyRequestEvent request, String claimName) { + try { + Map authorizer = request.getRequestContext().getAuthorizer(); + if (authorizer == null) { + return Optional.empty(); + } + + Map claims = (Map) authorizer.get("claims"); + if (claims == null) { + return Optional.empty(); + } + + return Optional.ofNullable(claims.get(claimName)); + } catch (Exception e) { + return Optional.empty(); + } + } + + /** + * 경로 파라미터의 userId와 Cognito userId가 일치하는지 검증 + * + * @param request API Gateway 요청 + * @param pathUserId 경로 파라미터에서 추출한 userId + * @return 일치 여부 + */ + public static boolean validateUserAccess(APIGatewayProxyRequestEvent request, String pathUserId) { + try { + String cognitoUserId = extractUserId(request); + return cognitoUserId.equals(pathUserId); + } catch (Exception e) { + return false; + } + } + + /** + * 경로 파라미터와 Cognito userId를 검증하고, 유효한 경우 userId 반환 + * 검증 실패 시 Optional.empty() 반환 + * + * @param request API Gateway 요청 + * @param pathUserId 경로 파라미터에서 추출한 userId + * @return 검증된 userId (Optional) + */ + public static Optional validateAndExtractUserId(APIGatewayProxyRequestEvent request, String pathUserId) { + try { + String cognitoUserId = extractUserId(request); + if (cognitoUserId.equals(pathUserId)) { + return Optional.of(cognitoUserId); + } + return Optional.empty(); + } catch (Exception e) { + return Optional.empty(); + } + } + + /** + * 경로 파라미터에서 userId를 추출하고 Cognito 토큰과 검증 + * Handler에서 간편하게 사용할 수 있는 메서드 + * + * @param request API Gateway 요청 + * @return 검증된 userId (Optional) + */ + public static Optional getValidatedUserId(APIGatewayProxyRequestEvent request) { + String pathUserId = request.getPathParameters() != null + ? request.getPathParameters().get("userId") + : null; + + if (pathUserId == null) { + // 경로에 userId가 없으면 토큰에서만 추출 + return extractClaim(request, "sub"); + } + + return validateAndExtractUserId(request, pathUserId); + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/CursorUtil.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/CursorUtil.java index 6c710259..34e63364 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/CursorUtil.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/CursorUtil.java @@ -13,61 +13,61 @@ * - lastEvaluatedKey를 Base64 인코딩/디코딩하여 커서로 사용 */ public class CursorUtil { - - private static final Logger logger = LoggerFactory.getLogger(CursorUtil.class); - - private CursorUtil() { - // 유틸리티 클래스 - 인스턴스화 방지 - } - - /** - * DynamoDB lastEvaluatedKey를 Base64 인코딩된 커서로 변환 - * - * @param lastEvaluatedKey DynamoDB 쿼리 결과의 lastEvaluatedKey - * @return Base64 URL-safe 인코딩된 커서 문자열, 또는 null (더 이상 페이지가 없는 경우) - */ - public static String encode(Map lastEvaluatedKey) { - if (lastEvaluatedKey == null || lastEvaluatedKey.isEmpty()) { - return null; - } - - StringBuilder sb = new StringBuilder(); - for (Map.Entry entry : lastEvaluatedKey.entrySet()) { - if (sb.length() > 0) { - sb.append("|"); - } - sb.append(entry.getKey()).append("=").append(entry.getValue().s()); - } - - return Base64.getUrlEncoder().encodeToString(sb.toString().getBytes()); - } - - /** - * Base64 인코딩된 커서를 DynamoDB exclusiveStartKey로 변환 - * - * @param cursor Base64 URL-safe 인코딩된 커서 문자열 - * @return DynamoDB exclusiveStartKey로 사용할 Map, 또는 null (잘못된 커서인 경우) - */ - public static Map decode(String cursor) { - if (cursor == null || cursor.isEmpty()) { - return null; - } - - try { - String decoded = new String(Base64.getUrlDecoder().decode(cursor)); - Map result = new HashMap<>(); - - for (String pair : decoded.split("\\|")) { - String[] kv = pair.split("=", 2); - if (kv.length == 2) { - result.put(kv[0], AttributeValue.builder().s(kv[1]).build()); - } - } - - return result.isEmpty() ? null : result; - } catch (Exception e) { - logger.error("Failed to decode cursor: {}", cursor, e); - return null; - } - } + + private static final Logger logger = LoggerFactory.getLogger(CursorUtil.class); + + private CursorUtil() { + // 유틸리티 클래스 - 인스턴스화 방지 + } + + /** + * DynamoDB lastEvaluatedKey를 Base64 인코딩된 커서로 변환 + * + * @param lastEvaluatedKey DynamoDB 쿼리 결과의 lastEvaluatedKey + * @return Base64 URL-safe 인코딩된 커서 문자열, 또는 null (더 이상 페이지가 없는 경우) + */ + public static String encode(Map lastEvaluatedKey) { + if (lastEvaluatedKey == null || lastEvaluatedKey.isEmpty()) { + return null; + } + + StringBuilder sb = new StringBuilder(); + for (Map.Entry entry : lastEvaluatedKey.entrySet()) { + if (sb.length() > 0) { + sb.append("|"); + } + sb.append(entry.getKey()).append("=").append(entry.getValue().s()); + } + + return Base64.getUrlEncoder().encodeToString(sb.toString().getBytes()); + } + + /** + * Base64 인코딩된 커서를 DynamoDB exclusiveStartKey로 변환 + * + * @param cursor Base64 URL-safe 인코딩된 커서 문자열 + * @return DynamoDB exclusiveStartKey로 사용할 Map, 또는 null (잘못된 커서인 경우) + */ + public static Map decode(String cursor) { + if (cursor == null || cursor.isEmpty()) { + return null; + } + + try { + String decoded = new String(Base64.getUrlDecoder().decode(cursor)); + Map result = new HashMap<>(); + + for (String pair : decoded.split("\\|")) { + String[] kv = pair.split("=", 2); + if (kv.length == 2) { + result.put(kv[0], AttributeValue.builder().s(kv[1]).build()); + } + } + + return result.isEmpty() ? null : result; + } catch (Exception e) { + logger.error("Failed to decode cursor: {}", cursor, e); + return null; + } + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/ResponseGenerator.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/ResponseGenerator.java index 029997c8..30d1f712 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/ResponseGenerator.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/ResponseGenerator.java @@ -13,100 +13,101 @@ * API Gateway 응답 생성기 */ public final class ResponseGenerator { - - private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); - - private static final Map CORS_HEADERS = Map.of( - "Content-Type", "application/json", - "Access-Control-Allow-Origin", "*", - "Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS", - "Access-Control-Allow-Headers", "Content-Type,Authorization" - ); - - private ResponseGenerator() {} - - // === 기본 응답 생성 === - - public static APIGatewayProxyResponseEvent createResponse(int statusCode, Object body) { - return new APIGatewayProxyResponseEvent() - .withStatusCode(statusCode) - .withHeaders(CORS_HEADERS) - .withBody(GSON.toJson(body)); - } - - // === 성공 응답 === - - public static APIGatewayProxyResponseEvent ok(String message, T data) { - return createResponse(200, ApiResponse.ok(message, data)); - } - - public static APIGatewayProxyResponseEvent ok(T data) { - return createResponse(200, ApiResponse.ok(data)); - } - - public static APIGatewayProxyResponseEvent created(String message, T data) { - return createResponse(201, ApiResponse.ok(message, data)); - } - - public static APIGatewayProxyResponseEvent noContent() { - return new APIGatewayProxyResponseEvent() - .withStatusCode(204) - .withHeaders(CORS_HEADERS); - } - - // === 실패 응답 === - - public static APIGatewayProxyResponseEvent badRequest(String message) { - return createResponse(400, ApiResponse.fail(message)); - } - - public static APIGatewayProxyResponseEvent unauthorized(String message) { - return createResponse(401, ApiResponse.fail(message)); - } - - public static APIGatewayProxyResponseEvent forbidden(String message) { - return createResponse(403, ApiResponse.fail(message)); - } - - public static APIGatewayProxyResponseEvent notFound(String message) { - return createResponse(404, ApiResponse.fail(message)); - } - - public static APIGatewayProxyResponseEvent methodNotAllowed(String message) { - return createResponse(405, ApiResponse.fail(message)); - } - - public static APIGatewayProxyResponseEvent conflict(String message) { - return createResponse(409, ApiResponse.fail(message)); - } - - public static APIGatewayProxyResponseEvent serverError(String message) { - return createResponse(500, ApiResponse.fail(message)); - } - - public static APIGatewayProxyResponseEvent fail(int statusCode, String message) { - return createResponse(statusCode, ApiResponse.fail(message)); - } - - /** - * ErrorCode 기반 에러 응답 생성 - */ - public static APIGatewayProxyResponseEvent fail(ErrorCode errorCode) { - ErrorInfo errorInfo = ErrorInfo.from(errorCode); - return createResponse(errorCode.getStatusCode(), errorInfo); - } - - /** - * ErrorCode 기반 에러 응답 생성 (커스텀 메시지) - */ - public static APIGatewayProxyResponseEvent fail(ErrorCode errorCode, String customMessage) { - ErrorInfo errorInfo = ErrorInfo.from(errorCode, customMessage); - return createResponse(errorCode.getStatusCode(), errorInfo); - } - - // === 유틸리티 === - - public static Gson gson() { - return GSON; - } + + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + + private static final Map CORS_HEADERS = Map.of( + "Content-Type", "application/json", + "Access-Control-Allow-Origin", "*", + "Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS", + "Access-Control-Allow-Headers", "Content-Type,Authorization" + ); + + private ResponseGenerator() { + } + + // === 기본 응답 생성 === + + public static APIGatewayProxyResponseEvent createResponse(int statusCode, Object body) { + return new APIGatewayProxyResponseEvent() + .withStatusCode(statusCode) + .withHeaders(CORS_HEADERS) + .withBody(GSON.toJson(body)); + } + + // === 성공 응답 === + + public static APIGatewayProxyResponseEvent ok(String message, T data) { + return createResponse(200, ApiResponse.ok(message, data)); + } + + public static APIGatewayProxyResponseEvent ok(T data) { + return createResponse(200, ApiResponse.ok(data)); + } + + public static APIGatewayProxyResponseEvent created(String message, T data) { + return createResponse(201, ApiResponse.ok(message, data)); + } + + public static APIGatewayProxyResponseEvent noContent() { + return new APIGatewayProxyResponseEvent() + .withStatusCode(204) + .withHeaders(CORS_HEADERS); + } + + // === 실패 응답 === + + public static APIGatewayProxyResponseEvent badRequest(String message) { + return createResponse(400, ApiResponse.fail(message)); + } + + public static APIGatewayProxyResponseEvent unauthorized(String message) { + return createResponse(401, ApiResponse.fail(message)); + } + + public static APIGatewayProxyResponseEvent forbidden(String message) { + return createResponse(403, ApiResponse.fail(message)); + } + + public static APIGatewayProxyResponseEvent notFound(String message) { + return createResponse(404, ApiResponse.fail(message)); + } + + public static APIGatewayProxyResponseEvent methodNotAllowed(String message) { + return createResponse(405, ApiResponse.fail(message)); + } + + public static APIGatewayProxyResponseEvent conflict(String message) { + return createResponse(409, ApiResponse.fail(message)); + } + + public static APIGatewayProxyResponseEvent serverError(String message) { + return createResponse(500, ApiResponse.fail(message)); + } + + public static APIGatewayProxyResponseEvent fail(int statusCode, String message) { + return createResponse(statusCode, ApiResponse.fail(message)); + } + + /** + * ErrorCode 기반 에러 응답 생성 + */ + public static APIGatewayProxyResponseEvent fail(ErrorCode errorCode) { + ErrorInfo errorInfo = ErrorInfo.from(errorCode); + return createResponse(errorCode.getStatusCode(), errorInfo); + } + + /** + * ErrorCode 기반 에러 응답 생성 (커스텀 메시지) + */ + public static APIGatewayProxyResponseEvent fail(ErrorCode errorCode, String customMessage) { + ErrorInfo errorInfo = ErrorInfo.from(errorCode, customMessage); + return createResponse(errorCode.getStatusCode(), errorInfo); + } + + // === 유틸리티 === + + public static Gson gson() { + return GSON; + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketBroadcaster.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketBroadcaster.java index f6d9d935..f438c239 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketBroadcaster.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketBroadcaster.java @@ -18,66 +18,68 @@ * WebSocket 연결들에게 메시지를 브로드캐스트하는 유틸리티 */ public class WebSocketBroadcaster { - - private static final Logger logger = LoggerFactory.getLogger(WebSocketBroadcaster.class); - - private final ApiGatewayManagementApiClient apiClient; - - public WebSocketBroadcaster() { - String endpoint = WebSocketConfig.websocketEndpoint(); - this.apiClient = ApiGatewayManagementApiClient.builder() - .endpointOverride(URI.create(endpoint)) - .build(); - } - - public WebSocketBroadcaster(String endpoint) { - this.apiClient = ApiGatewayManagementApiClient.builder() - .endpointOverride(URI.create(endpoint)) - .build(); - } - - /** - * 단일 연결에 메시지 전송 - * @return 전송 성공 여부 - */ - public boolean sendToConnection(String connectionId, String message) { - try { - PostToConnectionRequest request = PostToConnectionRequest.builder() - .connectionId(connectionId) - .data(SdkBytes.fromString(message, StandardCharsets.UTF_8)) - .build(); - - apiClient.postToConnection(request); - logger.debug("Message sent to connection: {}", connectionId); - return true; - - } catch (GoneException e) { - logger.warn("Connection gone: {}", connectionId); - return false; - - } catch (Exception e) { - logger.error("Failed to send message to connection {}: {}", connectionId, e.getMessage()); - return false; - } - } - - /** - * 여러 연결에 메시지 브로드캐스트 - * @return 전송 실패한 connectionId 목록 - */ - public List broadcast(List connections, String message) { - List failedConnections = new ArrayList<>(); - - for (Connection connection : connections) { - boolean success = sendToConnection(connection.getConnectionId(), message); - if (!success) { - failedConnections.add(connection.getConnectionId()); - } - } - - logger.info("Broadcast completed: total={}, failed={}", - connections.size(), failedConnections.size()); - - return failedConnections; - } + + private static final Logger logger = LoggerFactory.getLogger(WebSocketBroadcaster.class); + + private final ApiGatewayManagementApiClient apiClient; + + public WebSocketBroadcaster() { + String endpoint = WebSocketConfig.websocketEndpoint(); + this.apiClient = ApiGatewayManagementApiClient.builder() + .endpointOverride(URI.create(endpoint)) + .build(); + } + + public WebSocketBroadcaster(String endpoint) { + this.apiClient = ApiGatewayManagementApiClient.builder() + .endpointOverride(URI.create(endpoint)) + .build(); + } + + /** + * 단일 연결에 메시지 전송 + * + * @return 전송 성공 여부 + */ + public boolean sendToConnection(String connectionId, String message) { + try { + PostToConnectionRequest request = PostToConnectionRequest.builder() + .connectionId(connectionId) + .data(SdkBytes.fromString(message, StandardCharsets.UTF_8)) + .build(); + + apiClient.postToConnection(request); + logger.debug("Message sent to connection: {}", connectionId); + return true; + + } catch (GoneException e) { + logger.warn("Connection gone: {}", connectionId); + return false; + + } catch (Exception e) { + logger.error("Failed to send message to connection {}: {}", connectionId, e.getMessage()); + return false; + } + } + + /** + * 여러 연결에 메시지 브로드캐스트 + * + * @return 전송 실패한 connectionId 목록 + */ + public List broadcast(List connections, String message) { + List failedConnections = new ArrayList<>(); + + for (Connection connection : connections) { + boolean success = sendToConnection(connection.getConnectionId(), message); + if (!success) { + failedConnections.add(connection.getConnectionId()); + } + } + + logger.info("Broadcast completed: total={}, failed={}", + connections.size(), failedConnections.size()); + + return failedConnections; + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketResponseUtil.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketResponseUtil.java index 2d899873..6c14e542 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketResponseUtil.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketResponseUtil.java @@ -6,34 +6,35 @@ * WebSocket API Gateway 응답 생성 유틸리티 */ public final class WebSocketResponseUtil { - - private WebSocketResponseUtil() {} - - public static Map ok(String message) { - return Map.of("statusCode", 200, "body", message); - } - - public static Map created(String message) { - return Map.of("statusCode", 201, "body", message); - } - - public static Map badRequest(String message) { - return Map.of("statusCode", 400, "body", message); - } - - public static Map unauthorized(String message) { - return Map.of("statusCode", 401, "body", message); - } - - public static Map forbidden(String message) { - return Map.of("statusCode", 403, "body", message); - } - - public static Map serverError(String message) { - return Map.of("statusCode", 500, "body", message); - } - - public static Map response(int statusCode, String message) { - return Map.of("statusCode", statusCode, "body", message); - } + + private WebSocketResponseUtil() { + } + + public static Map ok(String message) { + return Map.of("statusCode", 200, "body", message); + } + + public static Map created(String message) { + return Map.of("statusCode", 201, "body", message); + } + + public static Map badRequest(String message) { + return Map.of("statusCode", 400, "body", message); + } + + public static Map unauthorized(String message) { + return Map.of("statusCode", 401, "body", message); + } + + public static Map forbidden(String message) { + return Map.of("statusCode", 403, "body", message); + } + + public static Map serverError(String message) { + return Map.of("statusCode", 500, "body", message); + } + + public static Map response(int statusCode, String message) { + return Map.of("statusCode", statusCode, "body", message); + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/validation/BeanValidator.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/validation/BeanValidator.java index 1b2ab911..cc0893bb 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/validation/BeanValidator.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/validation/BeanValidator.java @@ -1,6 +1,5 @@ package com.mzc.secondproject.serverless.common.validation; -import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; import com.mzc.secondproject.serverless.common.exception.CommonErrorCode; import com.mzc.secondproject.serverless.common.util.ResponseGenerator; @@ -16,9 +15,9 @@ /** * Jakarta Bean Validation 기반 검증 유틸리티 - * + *

* DTO에 선언된 @NotNull, @NotEmpty 등의 어노테이션을 검증합니다. - * + *

* 사용 예시: *

  * CreateRoomRequest req = ResponseGenerator.gson().fromJson(body, CreateRoomRequest.class);
@@ -32,53 +31,54 @@
  * 
*/ public final class BeanValidator { - - private static final Validator VALIDATOR; - - static { - ValidatorFactory factory = Validation.buildDefaultValidatorFactory(); - VALIDATOR = factory.getValidator(); - } - - private BeanValidator() {} - - /** - * 객체 검증 후 첫 번째 에러 메시지 반환 - * - * @param object 검증할 객체 - * @return 에러 메시지 (검증 성공 시 empty) - */ - public static Optional validate(T object) { - if (object == null) { - return Optional.of("Request body is required"); - } - - Set> violations = VALIDATOR.validate(object); - - if (violations.isEmpty()) { - return Optional.empty(); - } - - String errorMessage = violations.stream() - .map(v -> v.getPropertyPath() + " " + v.getMessage()) - .collect(Collectors.joining(", ")); - - return Optional.of(errorMessage); - } - - /** - * 검증 실패 시 에러 응답, 성공 시 핸들러 실행 - * - * @param object 검증할 객체 - * @param handler 검증 성공 시 실행할 핸들러 - * @return API 응답 - */ - public static APIGatewayProxyResponseEvent validateAndExecute( - T object, - Function handler) { - - return validate(object) - .map(error -> ResponseGenerator.fail(CommonErrorCode.REQUIRED_FIELD_MISSING, error)) - .orElseGet(() -> handler.apply(object)); - } + + private static final Validator VALIDATOR; + + static { + ValidatorFactory factory = Validation.buildDefaultValidatorFactory(); + VALIDATOR = factory.getValidator(); + } + + private BeanValidator() { + } + + /** + * 객체 검증 후 첫 번째 에러 메시지 반환 + * + * @param object 검증할 객체 + * @return 에러 메시지 (검증 성공 시 empty) + */ + public static Optional validate(T object) { + if (object == null) { + return Optional.of("Request body is required"); + } + + Set> violations = VALIDATOR.validate(object); + + if (violations.isEmpty()) { + return Optional.empty(); + } + + String errorMessage = violations.stream() + .map(v -> v.getPropertyPath() + " " + v.getMessage()) + .collect(Collectors.joining(", ")); + + return Optional.of(errorMessage); + } + + /** + * 검증 실패 시 에러 응답, 성공 시 핸들러 실행 + * + * @param object 검증할 객체 + * @param handler 검증 성공 시 실행할 핸들러 + * @return API 응답 + */ + public static APIGatewayProxyResponseEvent validateAndExecute( + T object, + Function handler) { + + return validate(object) + .map(error -> ResponseGenerator.fail(CommonErrorCode.REQUIRED_FIELD_MISSING, error)) + .orElseGet(() -> handler.apply(object)); + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/constants/BadgeKey.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/constants/BadgeKey.java index 21af347d..69c832d3 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/constants/BadgeKey.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/constants/BadgeKey.java @@ -4,20 +4,21 @@ * Badge 도메인 DynamoDB 키 생성 유틸리티 */ public final class BadgeKey { - - private BadgeKey() {} - - public static final String BADGE_ALL = "BADGE#ALL"; - - public static String userBadgePk(String userId) { - return "USER#" + userId + "#BADGE"; - } - - public static String badgeSk(String badgeType) { - return "BADGE#" + badgeType; - } - - public static String earnedSk(String earnedAt) { - return "EARNED#" + earnedAt; - } + + public static final String BADGE_ALL = "BADGE#ALL"; + + private BadgeKey() { + } + + public static String userBadgePk(String userId) { + return "USER#" + userId + "#BADGE"; + } + + public static String badgeSk(String badgeType) { + return "BADGE#" + badgeType; + } + + public static String earnedSk(String earnedAt) { + return "EARNED#" + earnedAt; + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/enums/BadgeType.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/enums/BadgeType.java index 73d26142..6515a8ef 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/enums/BadgeType.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/enums/BadgeType.java @@ -4,71 +4,71 @@ * 뱃지 타입 정의 */ public enum BadgeType { - // 첫 걸음 - FIRST_STEP("첫 걸음", "첫 학습을 완료했습니다", "first_step.png", "FIRST_STUDY", 1), - - // 연속 학습 - STREAK_3("3일 연속 학습", "3일 연속으로 학습했습니다", "streak_3.png", "STREAK", 3), - STREAK_7("일주일 연속 학습", "7일 연속으로 학습했습니다", "streak_7.png", "STREAK", 7), - STREAK_30("한 달 연속 학습", "30일 연속으로 학습했습니다", "streak_30.png", "STREAK", 30), - - // 단어 학습량 - WORDS_100("단어 수집가", "100개의 단어를 학습했습니다", "words_100.png", "WORDS_LEARNED", 100), - WORDS_500("단어 전문가", "500개의 단어를 학습했습니다", "words_500.png", "WORDS_LEARNED", 500), - WORDS_1000("단어 마스터", "1000개의 단어를 학습했습니다", "words_1000.png", "WORDS_LEARNED", 1000), - - // 테스트 관련 - PERFECT_SCORE("완벽주의자", "테스트에서 만점을 받았습니다", "perfect_score.png", "PERFECT_TEST", 1), - TEST_10("테스트 도전자", "10회의 테스트를 완료했습니다", "test_10.png", "TESTS_COMPLETED", 10), - - // 정확도 - ACCURACY_90("정확도 달인", "전체 정확도 90%를 달성했습니다", "accuracy_90.png", "ACCURACY", 90), - - // 특별 - MASTER("학습 마스터", "모든 업적을 달성했습니다", "master.png", "ALL_BADGES", 1); - - private static final String BASE_URL = "https://group2-englishstudy.s3.ap-northeast-2.amazonaws.com/badges/"; - - private final String name; - private final String description; - private final String imageFile; - private final String category; - private final int threshold; - - BadgeType(String name, String description, String imageFile, String category, int threshold) { - this.name = name; - this.description = description; - this.imageFile = imageFile; - this.category = category; - this.threshold = threshold; - } - - public String getName() { - return name; - } - - public String getDescription() { - return description; - } - - public String getImageUrl() { - return BASE_URL + imageFile; - } - - public String getCategory() { - return category; - } - - public int getThreshold() { - return threshold; - } - - public static BadgeType fromString(String value) { - if (value == null) return null; - try { - return BadgeType.valueOf(value.toUpperCase()); - } catch (IllegalArgumentException e) { - return null; - } - } + // 첫 걸음 + FIRST_STEP("첫 걸음", "첫 학습을 완료했습니다", "first_step.png", "FIRST_STUDY", 1), + + // 연속 학습 + STREAK_3("3일 연속 학습", "3일 연속으로 학습했습니다", "streak_3.png", "STREAK", 3), + STREAK_7("일주일 연속 학습", "7일 연속으로 학습했습니다", "streak_7.png", "STREAK", 7), + STREAK_30("한 달 연속 학습", "30일 연속으로 학습했습니다", "streak_30.png", "STREAK", 30), + + // 단어 학습량 + WORDS_100("단어 수집가", "100개의 단어를 학습했습니다", "words_100.png", "WORDS_LEARNED", 100), + WORDS_500("단어 전문가", "500개의 단어를 학습했습니다", "words_500.png", "WORDS_LEARNED", 500), + WORDS_1000("단어 마스터", "1000개의 단어를 학습했습니다", "words_1000.png", "WORDS_LEARNED", 1000), + + // 테스트 관련 + PERFECT_SCORE("완벽주의자", "테스트에서 만점을 받았습니다", "perfect_score.png", "PERFECT_TEST", 1), + TEST_10("테스트 도전자", "10회의 테스트를 완료했습니다", "test_10.png", "TESTS_COMPLETED", 10), + + // 정확도 + ACCURACY_90("정확도 달인", "전체 정확도 90%를 달성했습니다", "accuracy_90.png", "ACCURACY", 90), + + // 특별 + MASTER("학습 마스터", "모든 업적을 달성했습니다", "master.png", "ALL_BADGES", 1); + + private static final String BASE_URL = "https://group2-englishstudy.s3.ap-northeast-2.amazonaws.com/badges/"; + + private final String name; + private final String description; + private final String imageFile; + private final String category; + private final int threshold; + + BadgeType(String name, String description, String imageFile, String category, int threshold) { + this.name = name; + this.description = description; + this.imageFile = imageFile; + this.category = category; + this.threshold = threshold; + } + + public static BadgeType fromString(String value) { + if (value == null) return null; + try { + return BadgeType.valueOf(value.toUpperCase()); + } catch (IllegalArgumentException e) { + return null; + } + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + public String getImageUrl() { + return BASE_URL + imageFile; + } + + public String getCategory() { + return category; + } + + public int getThreshold() { + return threshold; + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/handler/BadgeHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/handler/BadgeHandler.java index 5f035e43..a60eb7bc 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/handler/BadgeHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/handler/BadgeHandler.java @@ -17,56 +17,56 @@ import java.util.Map; public class BadgeHandler implements RequestHandler { - - private static final Logger logger = LoggerFactory.getLogger(BadgeHandler.class); - - private final BadgeService badgeService; - private final HandlerRouter router; - - public BadgeHandler() { - this.badgeService = new BadgeService(); - this.router = initRouter(); - } - - private HandlerRouter initRouter() { - return new HandlerRouter().addRoutes( - Route.getAuth("/badges", this::getAllBadges), - Route.getAuth("/badges/earned", this::getEarnedBadges) - ); - } - - @Override - public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { - logger.info("Received request: {} {}", request.getHttpMethod(), request.getPath()); - return router.route(request); - } - - /** - * 전체 뱃지 목록 조회 (획득 여부, 진행도 포함) - */ - private APIGatewayProxyResponseEvent getAllBadges(APIGatewayProxyRequestEvent request, String userId) { - List badges = badgeService.getAllBadgesWithStatus(userId); - - long earnedCount = badges.stream().filter(BadgeService.BadgeInfo::earned).count(); - - Map response = new HashMap<>(); - response.put("badges", badges); - response.put("totalCount", badges.size()); - response.put("earnedCount", earnedCount); - - return ResponseGenerator.ok("Badges retrieved", response); - } - - /** - * 획득한 뱃지만 조회 - */ - private APIGatewayProxyResponseEvent getEarnedBadges(APIGatewayProxyRequestEvent request, String userId) { - List badges = badgeService.getUserBadges(userId); - - Map response = new HashMap<>(); - response.put("badges", badges); - response.put("count", badges.size()); - - return ResponseGenerator.ok("Earned badges retrieved", response); - } + + private static final Logger logger = LoggerFactory.getLogger(BadgeHandler.class); + + private final BadgeService badgeService; + private final HandlerRouter router; + + public BadgeHandler() { + this.badgeService = new BadgeService(); + this.router = initRouter(); + } + + private HandlerRouter initRouter() { + return new HandlerRouter().addRoutes( + Route.getAuth("/badges", this::getAllBadges), + Route.getAuth("/badges/earned", this::getEarnedBadges) + ); + } + + @Override + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { + logger.info("Received request: {} {}", request.getHttpMethod(), request.getPath()); + return router.route(request); + } + + /** + * 전체 뱃지 목록 조회 (획득 여부, 진행도 포함) + */ + private APIGatewayProxyResponseEvent getAllBadges(APIGatewayProxyRequestEvent request, String userId) { + List badges = badgeService.getAllBadgesWithStatus(userId); + + long earnedCount = badges.stream().filter(BadgeService.BadgeInfo::earned).count(); + + Map response = new HashMap<>(); + response.put("badges", badges); + response.put("totalCount", badges.size()); + response.put("earnedCount", earnedCount); + + return ResponseGenerator.ok("Badges retrieved", response); + } + + /** + * 획득한 뱃지만 조회 + */ + private APIGatewayProxyResponseEvent getEarnedBadges(APIGatewayProxyRequestEvent request, String userId) { + List badges = badgeService.getUserBadges(userId); + + Map response = new HashMap<>(); + response.put("badges", badges); + response.put("count", badges.size()); + + return ResponseGenerator.ok("Earned badges retrieved", response); + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/model/UserBadge.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/model/UserBadge.java index d4fbdf3a..6e4475be 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/model/UserBadge.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/model/UserBadge.java @@ -4,12 +4,7 @@ import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbAttribute; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondaryPartitionKey; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondarySortKey; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSortKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.*; /** * 사용자 뱃지 @@ -23,50 +18,50 @@ @AllArgsConstructor @DynamoDbBean public class UserBadge { - - private String pk; // USER#{userId}#BADGE - private String sk; // BADGE#{badgeType} - private String gsi1pk; // BADGE#ALL - private String gsi1sk; // EARNED#{earnedAt} - - private String odUserId; - private String badgeType; // BadgeType enum name - private String name; - private String description; - private String imageUrl; - private String category; - private Integer threshold; - private Integer progress; // 현재 진행도 (획득 시점의 값) - - private String earnedAt; - private String createdAt; - - @DynamoDbPartitionKey - @DynamoDbAttribute("PK") - public String getPk() { - return pk; - } - - @DynamoDbSortKey - @DynamoDbAttribute("SK") - public String getSk() { - return sk; - } - - @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") - @DynamoDbAttribute("GSI1PK") - public String getGsi1pk() { - return gsi1pk; - } - - @DynamoDbSecondarySortKey(indexNames = "GSI1") - @DynamoDbAttribute("GSI1SK") - public String getGsi1sk() { - return gsi1sk; - } - - @DynamoDbAttribute("userId") - public String getOdUserId() { - return odUserId; - } + + private String pk; // USER#{userId}#BADGE + private String sk; // BADGE#{badgeType} + private String gsi1pk; // BADGE#ALL + private String gsi1sk; // EARNED#{earnedAt} + + private String odUserId; + private String badgeType; // BadgeType enum name + private String name; + private String description; + private String imageUrl; + private String category; + private Integer threshold; + private Integer progress; // 현재 진행도 (획득 시점의 값) + + private String earnedAt; + private String createdAt; + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { + return pk; + } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { + return sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1PK") + public String getGsi1pk() { + return gsi1pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1SK") + public String getGsi1sk() { + return gsi1sk; + } + + @DynamoDbAttribute("userId") + public String getOdUserId() { + return odUserId; + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/repository/BadgeRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/repository/BadgeRepository.java index 7b781f39..104123df 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/repository/BadgeRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/repository/BadgeRepository.java @@ -17,50 +17,50 @@ import java.util.Optional; public class BadgeRepository { - - private static final Logger logger = LoggerFactory.getLogger(BadgeRepository.class); - private static final String TABLE_NAME = System.getenv("VOCAB_TABLE_NAME"); - - private final DynamoDbTable table; - - public BadgeRepository() { - DynamoDbEnhancedClient enhancedClient = DynamoDbEnhancedClient.builder() - .dynamoDbClient(AwsClients.dynamoDb()) - .build(); - this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(UserBadge.class)); - } - - public void save(UserBadge badge) { - table.putItem(badge); - logger.info("Saved badge: userId={}, badgeType={}", badge.getOdUserId(), badge.getBadgeType()); - } - - public Optional findByUserIdAndBadgeType(String userId, String badgeType) { - Key key = Key.builder() - .partitionValue(BadgeKey.userBadgePk(userId)) - .sortValue(BadgeKey.badgeSk(badgeType)) - .build(); - UserBadge badge = table.getItem(key); - return Optional.ofNullable(badge); - } - - public List findByUserId(String userId) { - QueryConditional queryConditional = QueryConditional.keyEqualTo( - Key.builder().partitionValue(BadgeKey.userBadgePk(userId)).build() - ); - - QueryEnhancedRequest request = QueryEnhancedRequest.builder() - .queryConditional(queryConditional) - .build(); - - List badges = new ArrayList<>(); - table.query(request).items().forEach(badges::add); - - logger.info("Found {} badges for user: {}", badges.size(), userId); - return badges; - } - - public boolean hasBadge(String userId, String badgeType) { - return findByUserIdAndBadgeType(userId, badgeType).isPresent(); - } + + private static final Logger logger = LoggerFactory.getLogger(BadgeRepository.class); + private static final String TABLE_NAME = System.getenv("VOCAB_TABLE_NAME"); + + private final DynamoDbTable table; + + public BadgeRepository() { + DynamoDbEnhancedClient enhancedClient = DynamoDbEnhancedClient.builder() + .dynamoDbClient(AwsClients.dynamoDb()) + .build(); + this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(UserBadge.class)); + } + + public void save(UserBadge badge) { + table.putItem(badge); + logger.info("Saved badge: userId={}, badgeType={}", badge.getOdUserId(), badge.getBadgeType()); + } + + public Optional findByUserIdAndBadgeType(String userId, String badgeType) { + Key key = Key.builder() + .partitionValue(BadgeKey.userBadgePk(userId)) + .sortValue(BadgeKey.badgeSk(badgeType)) + .build(); + UserBadge badge = table.getItem(key); + return Optional.ofNullable(badge); + } + + public List findByUserId(String userId) { + QueryConditional queryConditional = QueryConditional.keyEqualTo( + Key.builder().partitionValue(BadgeKey.userBadgePk(userId)).build() + ); + + QueryEnhancedRequest request = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .build(); + + List badges = new ArrayList<>(); + table.query(request).items().forEach(badges::add); + + logger.info("Found {} badges for user: {}", badges.size(), userId); + return badges; + } + + public boolean hasBadge(String userId, String badgeType) { + return findByUserIdAndBadgeType(userId, badgeType).isPresent(); + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/service/BadgeService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/service/BadgeService.java index 302daabf..1140b80d 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/service/BadgeService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/service/BadgeService.java @@ -11,174 +11,175 @@ import java.time.Instant; import java.util.ArrayList; -import java.util.Arrays; import java.util.List; import java.util.Map; import java.util.stream.Collectors; public class BadgeService { - - private static final Logger logger = LoggerFactory.getLogger(BadgeService.class); - - private final BadgeRepository badgeRepository; - private final UserStatsRepository userStatsRepository; - - public BadgeService() { - this.badgeRepository = new BadgeRepository(); - this.userStatsRepository = new UserStatsRepository(); - } - - /** - * 사용자의 획득한 뱃지 목록 조회 - */ - public List getUserBadges(String userId) { - return badgeRepository.findByUserId(userId); - } - - /** - * 전체 뱃지 목록 조회 (획득 여부 포함) - */ - public List getAllBadgesWithStatus(String userId) { - List earnedBadges = badgeRepository.findByUserId(userId); - Map earnedMap = earnedBadges.stream() - .collect(Collectors.toMap(UserBadge::getBadgeType, b -> b)); - - UserStats totalStats = userStatsRepository.findTotalStats(userId).orElse(null); - - List result = new ArrayList<>(); - for (BadgeType type : BadgeType.values()) { - UserBadge earned = earnedMap.get(type.name()); - int currentProgress = calculateProgress(type, totalStats); - - result.add(new BadgeInfo( - type.name(), - type.getName(), - type.getDescription(), - type.getImageUrl(), - type.getCategory(), - type.getThreshold(), - currentProgress, - earned != null, - earned != null ? earned.getEarnedAt() : null - )); - } - - return result; - } - - /** - * 뱃지 획득 체크 및 부여 - * 통계가 업데이트될 때 호출 - */ - public List checkAndAwardBadges(String userId, UserStats stats) { - List newBadges = new ArrayList<>(); - String now = Instant.now().toString(); - - for (BadgeType type : BadgeType.values()) { - // 이미 획득한 뱃지는 스킵 - if (badgeRepository.hasBadge(userId, type.name())) { - continue; - } - - // 조건 체크 - if (checkBadgeCondition(type, stats)) { - UserBadge badge = createBadge(userId, type, now); - badgeRepository.save(badge); - newBadges.add(badge); - logger.info("Badge awarded: userId={}, badge={}", userId, type.name()); - } - } - - return newBadges; - } - - /** - * 특정 뱃지 수동 부여 (테스트/관리용) - */ - public UserBadge awardBadge(String userId, String badgeType) { - BadgeType type = BadgeType.fromString(badgeType); - if (type == null) { - throw new IllegalArgumentException("Invalid badge type: " + badgeType); - } - - if (badgeRepository.hasBadge(userId, type.name())) { - return badgeRepository.findByUserIdAndBadgeType(userId, type.name()).orElse(null); - } - - String now = Instant.now().toString(); - UserBadge badge = createBadge(userId, type, now); - badgeRepository.save(badge); - - return badge; - } - - private UserBadge createBadge(String userId, BadgeType type, String now) { - return UserBadge.builder() - .pk(BadgeKey.userBadgePk(userId)) - .sk(BadgeKey.badgeSk(type.name())) - .gsi1pk(BadgeKey.BADGE_ALL) - .gsi1sk(BadgeKey.earnedSk(now)) - .odUserId(userId) - .badgeType(type.name()) - .name(type.getName()) - .description(type.getDescription()) - .imageUrl(type.getImageUrl()) - .category(type.getCategory()) - .threshold(type.getThreshold()) - .earnedAt(now) - .createdAt(now) - .build(); - } - - private boolean checkBadgeCondition(BadgeType type, UserStats stats) { - if (stats == null) return false; - - return switch (type.getCategory()) { - case "FIRST_STUDY" -> stats.getTestsCompleted() != null && stats.getTestsCompleted() >= 1; - case "STREAK" -> stats.getCurrentStreak() != null && stats.getCurrentStreak() >= type.getThreshold(); - case "WORDS_LEARNED" -> { - int total = (stats.getNewWordsLearned() != null ? stats.getNewWordsLearned() : 0) - + (stats.getWordsReviewed() != null ? stats.getWordsReviewed() : 0); - yield total >= type.getThreshold(); - } - case "PERFECT_TEST" -> false; // 별도 로직 필요 (테스트 결과에서 체크) - case "TESTS_COMPLETED" -> stats.getTestsCompleted() != null && stats.getTestsCompleted() >= type.getThreshold(); - case "ACCURACY" -> { - if (stats.getQuestionsAnswered() == null || stats.getQuestionsAnswered() == 0) yield false; - double accuracy = (stats.getCorrectAnswers() * 100.0) / stats.getQuestionsAnswered(); - yield accuracy >= type.getThreshold(); - } - case "ALL_BADGES" -> false; // 별도 로직 필요 - default -> false; - }; - } - - private int calculateProgress(BadgeType type, UserStats stats) { - if (stats == null) return 0; - - return switch (type.getCategory()) { - case "FIRST_STUDY" -> stats.getTestsCompleted() != null && stats.getTestsCompleted() >= 1 ? 1 : 0; - case "STREAK" -> stats.getCurrentStreak() != null ? stats.getCurrentStreak() : 0; - case "WORDS_LEARNED" -> (stats.getNewWordsLearned() != null ? stats.getNewWordsLearned() : 0) - + (stats.getWordsReviewed() != null ? stats.getWordsReviewed() : 0); - case "TESTS_COMPLETED" -> stats.getTestsCompleted() != null ? stats.getTestsCompleted() : 0; - case "ACCURACY" -> { - if (stats.getQuestionsAnswered() == null || stats.getQuestionsAnswered() == 0) yield 0; - yield (int) ((stats.getCorrectAnswers() * 100.0) / stats.getQuestionsAnswered()); - } - default -> 0; - }; - } - - public record BadgeInfo( - String badgeType, - String name, - String description, - String imageUrl, - String category, - int threshold, - int progress, - boolean earned, - String earnedAt - ) {} + + private static final Logger logger = LoggerFactory.getLogger(BadgeService.class); + + private final BadgeRepository badgeRepository; + private final UserStatsRepository userStatsRepository; + + public BadgeService() { + this.badgeRepository = new BadgeRepository(); + this.userStatsRepository = new UserStatsRepository(); + } + + /** + * 사용자의 획득한 뱃지 목록 조회 + */ + public List getUserBadges(String userId) { + return badgeRepository.findByUserId(userId); + } + + /** + * 전체 뱃지 목록 조회 (획득 여부 포함) + */ + public List getAllBadgesWithStatus(String userId) { + List earnedBadges = badgeRepository.findByUserId(userId); + Map earnedMap = earnedBadges.stream() + .collect(Collectors.toMap(UserBadge::getBadgeType, b -> b)); + + UserStats totalStats = userStatsRepository.findTotalStats(userId).orElse(null); + + List result = new ArrayList<>(); + for (BadgeType type : BadgeType.values()) { + UserBadge earned = earnedMap.get(type.name()); + int currentProgress = calculateProgress(type, totalStats); + + result.add(new BadgeInfo( + type.name(), + type.getName(), + type.getDescription(), + type.getImageUrl(), + type.getCategory(), + type.getThreshold(), + currentProgress, + earned != null, + earned != null ? earned.getEarnedAt() : null + )); + } + + return result; + } + + /** + * 뱃지 획득 체크 및 부여 + * 통계가 업데이트될 때 호출 + */ + public List checkAndAwardBadges(String userId, UserStats stats) { + List newBadges = new ArrayList<>(); + String now = Instant.now().toString(); + + for (BadgeType type : BadgeType.values()) { + // 이미 획득한 뱃지는 스킵 + if (badgeRepository.hasBadge(userId, type.name())) { + continue; + } + + // 조건 체크 + if (checkBadgeCondition(type, stats)) { + UserBadge badge = createBadge(userId, type, now); + badgeRepository.save(badge); + newBadges.add(badge); + logger.info("Badge awarded: userId={}, badge={}", userId, type.name()); + } + } + + return newBadges; + } + + /** + * 특정 뱃지 수동 부여 (테스트/관리용) + */ + public UserBadge awardBadge(String userId, String badgeType) { + BadgeType type = BadgeType.fromString(badgeType); + if (type == null) { + throw new IllegalArgumentException("Invalid badge type: " + badgeType); + } + + if (badgeRepository.hasBadge(userId, type.name())) { + return badgeRepository.findByUserIdAndBadgeType(userId, type.name()).orElse(null); + } + + String now = Instant.now().toString(); + UserBadge badge = createBadge(userId, type, now); + badgeRepository.save(badge); + + return badge; + } + + private UserBadge createBadge(String userId, BadgeType type, String now) { + return UserBadge.builder() + .pk(BadgeKey.userBadgePk(userId)) + .sk(BadgeKey.badgeSk(type.name())) + .gsi1pk(BadgeKey.BADGE_ALL) + .gsi1sk(BadgeKey.earnedSk(now)) + .odUserId(userId) + .badgeType(type.name()) + .name(type.getName()) + .description(type.getDescription()) + .imageUrl(type.getImageUrl()) + .category(type.getCategory()) + .threshold(type.getThreshold()) + .earnedAt(now) + .createdAt(now) + .build(); + } + + private boolean checkBadgeCondition(BadgeType type, UserStats stats) { + if (stats == null) return false; + + return switch (type.getCategory()) { + case "FIRST_STUDY" -> stats.getTestsCompleted() != null && stats.getTestsCompleted() >= 1; + case "STREAK" -> stats.getCurrentStreak() != null && stats.getCurrentStreak() >= type.getThreshold(); + case "WORDS_LEARNED" -> { + int total = (stats.getNewWordsLearned() != null ? stats.getNewWordsLearned() : 0) + + (stats.getWordsReviewed() != null ? stats.getWordsReviewed() : 0); + yield total >= type.getThreshold(); + } + case "PERFECT_TEST" -> false; // 별도 로직 필요 (테스트 결과에서 체크) + case "TESTS_COMPLETED" -> + stats.getTestsCompleted() != null && stats.getTestsCompleted() >= type.getThreshold(); + case "ACCURACY" -> { + if (stats.getQuestionsAnswered() == null || stats.getQuestionsAnswered() == 0) yield false; + double accuracy = (stats.getCorrectAnswers() * 100.0) / stats.getQuestionsAnswered(); + yield accuracy >= type.getThreshold(); + } + case "ALL_BADGES" -> false; // 별도 로직 필요 + default -> false; + }; + } + + private int calculateProgress(BadgeType type, UserStats stats) { + if (stats == null) return 0; + + return switch (type.getCategory()) { + case "FIRST_STUDY" -> stats.getTestsCompleted() != null && stats.getTestsCompleted() >= 1 ? 1 : 0; + case "STREAK" -> stats.getCurrentStreak() != null ? stats.getCurrentStreak() : 0; + case "WORDS_LEARNED" -> (stats.getNewWordsLearned() != null ? stats.getNewWordsLearned() : 0) + + (stats.getWordsReviewed() != null ? stats.getWordsReviewed() : 0); + case "TESTS_COMPLETED" -> stats.getTestsCompleted() != null ? stats.getTestsCompleted() : 0; + case "ACCURACY" -> { + if (stats.getQuestionsAnswered() == null || stats.getQuestionsAnswered() == 0) yield 0; + yield (int) ((stats.getCorrectAnswers() * 100.0) / stats.getQuestionsAnswered()); + } + default -> 0; + }; + } + + public record BadgeInfo( + String badgeType, + String name, + String description, + String imageUrl, + String category, + int threshold, + int progress, + boolean earned, + String earnedAt + ) { + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/constants/ChatKey.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/constants/ChatKey.java index 0189da34..33a25a66 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/constants/ChatKey.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/constants/ChatKey.java @@ -3,36 +3,36 @@ import com.mzc.secondproject.serverless.common.constants.DynamoDbKey; public final class ChatKey { - - private ChatKey() {} - - // Prefixes - public static final String ROOM = "ROOM#"; - public static final String MESSAGE = "MSG#"; - public static final String CONNECTION = "CONNECTION#"; - public static final String TOKEN = "TOKEN#"; - - // Special Keys - public static final String ROOMS_ALL = "ROOMS"; - - // Key Builders - public static String roomPk(String roomId) { - return ROOM + roomId; - } - - public static String messageSk(String messageId) { - return MESSAGE + messageId; - } - - public static String userPk(String userId) { - return DynamoDbKey.USER + userId; - } - - public static String connectionPk(String connectionId) { - return CONNECTION + connectionId; - } - - public static String tokenPk(String token) { - return TOKEN + token; - } + + // Prefixes + public static final String ROOM = "ROOM#"; + public static final String MESSAGE = "MSG#"; + public static final String CONNECTION = "CONNECTION#"; + public static final String TOKEN = "TOKEN#"; + // Special Keys + public static final String ROOMS_ALL = "ROOMS"; + + private ChatKey() { + } + + // Key Builders + public static String roomPk(String roomId) { + return ROOM + roomId; + } + + public static String messageSk(String messageId) { + return MESSAGE + messageId; + } + + public static String userPk(String userId) { + return DynamoDbKey.USER + userId; + } + + public static String connectionPk(String connectionId) { + return CONNECTION + connectionId; + } + + public static String tokenPk(String token) { + return TOKEN + token; + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/CreateRoomRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/CreateRoomRequest.java index fcfb9ba4..255d6fc1 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/CreateRoomRequest.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/CreateRoomRequest.java @@ -14,24 +14,24 @@ @NoArgsConstructor @AllArgsConstructor public class CreateRoomRequest { - - @NotBlank(message = "is required") - @Size(min = 1, max = 50, message = "must be between 1 and 50 characters") - private String name; - - @Size(max = 200, message = "must be at most 200 characters") - private String description; - - @Builder.Default - private String level = "beginner"; - - @Min(value = 2, message = "must be at least 2") - @Max(value = 10, message = "must be at most 10") - @Builder.Default - private Integer maxMembers = 6; - - @Builder.Default - private Boolean isPrivate = false; - - private String password; + + @NotBlank(message = "is required") + @Size(min = 1, max = 50, message = "must be between 1 and 50 characters") + private String name; + + @Size(max = 200, message = "must be at most 200 characters") + private String description; + + @Builder.Default + private String level = "beginner"; + + @Min(value = 2, message = "must be at least 2") + @Max(value = 10, message = "must be at most 10") + @Builder.Default + private Integer maxMembers = 6; + + @Builder.Default + private Boolean isPrivate = false; + + private String password; } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/JoinRoomRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/JoinRoomRequest.java index 88ac0b7f..432ca3e8 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/JoinRoomRequest.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/JoinRoomRequest.java @@ -10,6 +10,6 @@ @NoArgsConstructor @AllArgsConstructor public class JoinRoomRequest { - - private String password; + + private String password; } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/LeaveRoomRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/LeaveRoomRequest.java index 79377cea..c9b11d38 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/LeaveRoomRequest.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/LeaveRoomRequest.java @@ -1,6 +1,5 @@ package com.mzc.secondproject.serverless.domain.chatting.dto.request; -import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; @@ -9,5 +8,5 @@ @Builder @NoArgsConstructor public class LeaveRoomRequest { - // 토큰에서 userId를 추출하므로 별도 필드 불필요 + // 토큰에서 userId를 추출하므로 별도 필드 불필요 } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/SendMessageRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/SendMessageRequest.java index 54044761..0fd94742 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/SendMessageRequest.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/SendMessageRequest.java @@ -12,11 +12,11 @@ @NoArgsConstructor @AllArgsConstructor public class SendMessageRequest { - - @NotBlank(message = "is required") - @Size(max = 1000, message = "must be at most 1000 characters") - private String content; - - @Builder.Default - private String messageType = "TEXT"; + + @NotBlank(message = "is required") + @Size(max = 1000, message = "must be at most 1000 characters") + private String content; + + @Builder.Default + private String messageType = "TEXT"; } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/VoiceSynthesisRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/VoiceSynthesisRequest.java index 7797e619..386a4b1f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/VoiceSynthesisRequest.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/VoiceSynthesisRequest.java @@ -11,13 +11,13 @@ @NoArgsConstructor @AllArgsConstructor public class VoiceSynthesisRequest { - - @NotBlank(message = "is required") - private String messageId; - - @NotBlank(message = "is required") - private String roomId; - - @Builder.Default - private String voice = "FEMALE"; + + @NotBlank(message = "is required") + private String messageId; + + @NotBlank(message = "is required") + private String roomId; + + @Builder.Default + private String voice = "FEMALE"; } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/JoinRoomResponse.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/JoinRoomResponse.java index 8561cefe..4ab83f79 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/JoinRoomResponse.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/JoinRoomResponse.java @@ -15,7 +15,7 @@ @NoArgsConstructor @AllArgsConstructor public class JoinRoomResponse { - private ChatRoom room; - private String roomToken; - private Long tokenExpiresAt; + private ChatRoom room; + private String roomToken; + private Long tokenExpiresAt; } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/ChatLevel.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/ChatLevel.java index b529b706..5f25f794 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/ChatLevel.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/ChatLevel.java @@ -3,46 +3,46 @@ import java.util.Arrays; public enum ChatLevel { - BEGINNER("beginner", "초급"), - INTERMEDIATE("intermediate", "중급"), - ADVANCED("advanced", "고급"); - - private final String code; - private final String displayName; - - ChatLevel(String code, String displayName) { - this.code = code; - this.displayName = displayName; - } - - public String getCode() { - return code; - } - - public String getDisplayName() { - return displayName; - } - - public static boolean isValid(String value) { - if (value == null) return false; - return Arrays.stream(values()) - .anyMatch(level -> level.name().equalsIgnoreCase(value) || level.code.equalsIgnoreCase(value)); - } - - public static ChatLevel fromString(String value) { - if (value == null) { - throw new IllegalArgumentException("ChatLevel value cannot be null"); - } - return Arrays.stream(values()) - .filter(level -> level.name().equalsIgnoreCase(value) || level.code.equalsIgnoreCase(value)) - .findFirst() - .orElseThrow(() -> new IllegalArgumentException("Unknown ChatLevel: " + value)); - } - - public static ChatLevel fromStringOrDefault(String value, ChatLevel defaultValue) { - if (value == null || !isValid(value)) { - return defaultValue; - } - return fromString(value); - } + BEGINNER("beginner", "초급"), + INTERMEDIATE("intermediate", "중급"), + ADVANCED("advanced", "고급"); + + private final String code; + private final String displayName; + + ChatLevel(String code, String displayName) { + this.code = code; + this.displayName = displayName; + } + + public static boolean isValid(String value) { + if (value == null) return false; + return Arrays.stream(values()) + .anyMatch(level -> level.name().equalsIgnoreCase(value) || level.code.equalsIgnoreCase(value)); + } + + public static ChatLevel fromString(String value) { + if (value == null) { + throw new IllegalArgumentException("ChatLevel value cannot be null"); + } + return Arrays.stream(values()) + .filter(level -> level.name().equalsIgnoreCase(value) || level.code.equalsIgnoreCase(value)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Unknown ChatLevel: " + value)); + } + + public static ChatLevel fromStringOrDefault(String value, ChatLevel defaultValue) { + if (value == null || !isValid(value)) { + return defaultValue; + } + return fromString(value); + } + + public String getCode() { + return code; + } + + public String getDisplayName() { + return displayName; + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/MessageType.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/MessageType.java index 7b1480ce..688425c1 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/MessageType.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/MessageType.java @@ -3,47 +3,47 @@ import java.util.Arrays; public enum MessageType { - TEXT("text", "텍스트"), - IMAGE("image", "이미지"), - VOICE("voice", "음성"), - AI_RESPONSE("ai_response", "AI 응답"); - - private final String code; - private final String displayName; - - MessageType(String code, String displayName) { - this.code = code; - this.displayName = displayName; - } - - public String getCode() { - return code; - } - - public String getDisplayName() { - return displayName; - } - - public static boolean isValid(String value) { - if (value == null) return false; - return Arrays.stream(values()) - .anyMatch(type -> type.name().equalsIgnoreCase(value) || type.code.equalsIgnoreCase(value)); - } - - public static MessageType fromString(String value) { - if (value == null) { - throw new IllegalArgumentException("MessageType value cannot be null"); - } - return Arrays.stream(values()) - .filter(type -> type.name().equalsIgnoreCase(value) || type.code.equalsIgnoreCase(value)) - .findFirst() - .orElseThrow(() -> new IllegalArgumentException("Unknown MessageType: " + value)); - } - - public static MessageType fromStringOrDefault(String value, MessageType defaultValue) { - if (value == null || !isValid(value)) { - return defaultValue; - } - return fromString(value); - } + TEXT("text", "텍스트"), + IMAGE("image", "이미지"), + VOICE("voice", "음성"), + AI_RESPONSE("ai_response", "AI 응답"); + + private final String code; + private final String displayName; + + MessageType(String code, String displayName) { + this.code = code; + this.displayName = displayName; + } + + public static boolean isValid(String value) { + if (value == null) return false; + return Arrays.stream(values()) + .anyMatch(type -> type.name().equalsIgnoreCase(value) || type.code.equalsIgnoreCase(value)); + } + + public static MessageType fromString(String value) { + if (value == null) { + throw new IllegalArgumentException("MessageType value cannot be null"); + } + return Arrays.stream(values()) + .filter(type -> type.name().equalsIgnoreCase(value) || type.code.equalsIgnoreCase(value)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Unknown MessageType: " + value)); + } + + public static MessageType fromStringOrDefault(String value, MessageType defaultValue) { + if (value == null || !isValid(value)) { + return defaultValue; + } + return fromString(value); + } + + public String getCode() { + return code; + } + + public String getDisplayName() { + return displayName; + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCode.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCode.java index 785c13e9..dc9785fc 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCode.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCode.java @@ -4,66 +4,66 @@ /** * 채팅 도메인 에러 코드 - * + *

* 채팅방(Room), 메시지(Message), 참여자(Participant) 관련 에러 코드를 정의합니다. */ public enum ChattingErrorCode implements DomainErrorCode { - - // 채팅방 관련 에러 - ROOM_NOT_FOUND("ROOM_001", "채팅방을 찾을 수 없습니다", 404), - ROOM_ALREADY_EXISTS("ROOM_002", "이미 존재하는 채팅방입니다", 409), - ROOM_FULL("ROOM_003", "채팅방 인원이 가득 찼습니다", 400), - ROOM_CLOSED("ROOM_004", "종료된 채팅방입니다", 400), - ROOM_INVALID_PASSWORD("ROOM_005", "비밀번호가 일치하지 않습니다", 401), - ROOM_NOT_OWNER("ROOM_006", "방장 권한이 필요합니다", 403), - - // 메시지 관련 에러 - MESSAGE_NOT_FOUND("MSG_001", "메시지를 찾을 수 없습니다", 404), - MESSAGE_TOO_LONG("MSG_002", "메시지가 너무 깁니다", 400), - INVALID_MESSAGE_TYPE("MSG_003", "유효하지 않은 메시지 타입입니다", 400), - - // 참여자 관련 에러 - NOT_ROOM_MEMBER("MEMBER_001", "채팅방 멤버가 아닙니다", 403), - ALREADY_JOINED("MEMBER_002", "이미 참여 중인 채팅방입니다", 409), - INVALID_ROOM_TOKEN("MEMBER_003", "유효하지 않은 방 토큰입니다", 401), - - // 채팅 레벨 관련 에러 - INVALID_CHAT_LEVEL("LEVEL_001", "유효하지 않은 채팅 레벨입니다", 400), - - // 연결 관련 에러 - CONNECTION_FAILED("CONN_001", "연결에 실패했습니다", 500), - CONNECTION_TIMEOUT("CONN_002", "연결 시간이 초과되었습니다", 408), - ; - - private static final String DOMAIN = "CHATTING"; - - private final String code; - private final String message; - private final int statusCode; - - ChattingErrorCode(String code, String message, int statusCode) { - this.code = code; - this.message = message; - this.statusCode = statusCode; - } - - @Override - public String getDomain() { - return DOMAIN; - } - - @Override - public String getCode() { - return code; - } - - @Override - public String getMessage() { - return message; - } - - @Override - public int getStatusCode() { - return statusCode; - } + + // 채팅방 관련 에러 + ROOM_NOT_FOUND("ROOM_001", "채팅방을 찾을 수 없습니다", 404), + ROOM_ALREADY_EXISTS("ROOM_002", "이미 존재하는 채팅방입니다", 409), + ROOM_FULL("ROOM_003", "채팅방 인원이 가득 찼습니다", 400), + ROOM_CLOSED("ROOM_004", "종료된 채팅방입니다", 400), + ROOM_INVALID_PASSWORD("ROOM_005", "비밀번호가 일치하지 않습니다", 401), + ROOM_NOT_OWNER("ROOM_006", "방장 권한이 필요합니다", 403), + + // 메시지 관련 에러 + MESSAGE_NOT_FOUND("MSG_001", "메시지를 찾을 수 없습니다", 404), + MESSAGE_TOO_LONG("MSG_002", "메시지가 너무 깁니다", 400), + INVALID_MESSAGE_TYPE("MSG_003", "유효하지 않은 메시지 타입입니다", 400), + + // 참여자 관련 에러 + NOT_ROOM_MEMBER("MEMBER_001", "채팅방 멤버가 아닙니다", 403), + ALREADY_JOINED("MEMBER_002", "이미 참여 중인 채팅방입니다", 409), + INVALID_ROOM_TOKEN("MEMBER_003", "유효하지 않은 방 토큰입니다", 401), + + // 채팅 레벨 관련 에러 + INVALID_CHAT_LEVEL("LEVEL_001", "유효하지 않은 채팅 레벨입니다", 400), + + // 연결 관련 에러 + CONNECTION_FAILED("CONN_001", "연결에 실패했습니다", 500), + CONNECTION_TIMEOUT("CONN_002", "연결 시간이 초과되었습니다", 408), + ; + + private static final String DOMAIN = "CHATTING"; + + private final String code; + private final String message; + private final int statusCode; + + ChattingErrorCode(String code, String message, int statusCode) { + this.code = code; + this.message = message; + this.statusCode = statusCode; + } + + @Override + public String getDomain() { + return DOMAIN; + } + + @Override + public String getCode() { + return code; + } + + @Override + public String getMessage() { + return message; + } + + @Override + public int getStatusCode() { + return statusCode; + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingException.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingException.java index 24a06828..16b51450 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingException.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingException.java @@ -4,129 +4,129 @@ /** * 채팅 도메인 예외 클래스 - * + *

* 정적 팩토리 메서드를 통해 가독성 높은 예외 생성을 지원합니다. - * + *

* 사용 예시: * throw ChattingException.roomNotFound(roomId); * throw ChattingException.notRoomMember(userId, roomId); */ public class ChattingException extends ServerlessException { - - private ChattingException(ChattingErrorCode errorCode) { - super(errorCode); - } - - private ChattingException(ChattingErrorCode errorCode, String message) { - super(errorCode, message); - } - - private ChattingException(ChattingErrorCode errorCode, Throwable cause) { - super(errorCode, cause); - } - - // === 채팅방(Room) 관련 팩토리 메서드 === - - public static ChattingException roomNotFound(String roomId) { - return (ChattingException) new ChattingException(ChattingErrorCode.ROOM_NOT_FOUND, - String.format("채팅방을 찾을 수 없습니다 (ID: %s)", roomId)) - .addDetail("roomId", roomId); - } - - public static ChattingException roomAlreadyExists(String roomName) { - return (ChattingException) new ChattingException(ChattingErrorCode.ROOM_ALREADY_EXISTS, - String.format("이미 존재하는 채팅방입니다: '%s'", roomName)) - .addDetail("roomName", roomName); - } - - public static ChattingException roomFull(String roomId, int maxCapacity) { - return (ChattingException) new ChattingException(ChattingErrorCode.ROOM_FULL, - String.format("채팅방 인원이 가득 찼습니다 (최대 %d명)", maxCapacity)) - .addDetail("roomId", roomId) - .addDetail("maxCapacity", maxCapacity); - } - - public static ChattingException roomClosed(String roomId) { - return (ChattingException) new ChattingException(ChattingErrorCode.ROOM_CLOSED, - String.format("종료된 채팅방입니다 (ID: %s)", roomId)) - .addDetail("roomId", roomId); - } - - public static ChattingException roomInvalidPassword(String roomId) { - return (ChattingException) new ChattingException(ChattingErrorCode.ROOM_INVALID_PASSWORD, - "비밀번호가 일치하지 않습니다") - .addDetail("roomId", roomId); - } - - public static ChattingException roomNotOwner(String userId, String roomId) { - return (ChattingException) new ChattingException(ChattingErrorCode.ROOM_NOT_OWNER, - String.format("방장 권한이 필요합니다 (userId: %s, roomId: %s)", userId, roomId)) - .addDetail("userId", userId) - .addDetail("roomId", roomId); - } - - // === 메시지(Message) 관련 팩토리 메서드 === - - public static ChattingException messageNotFound(String messageId) { - return (ChattingException) new ChattingException(ChattingErrorCode.MESSAGE_NOT_FOUND, - String.format("메시지를 찾을 수 없습니다 (ID: %s)", messageId)) - .addDetail("messageId", messageId); - } - - public static ChattingException messageTooLong(int length, int maxLength) { - return (ChattingException) new ChattingException(ChattingErrorCode.MESSAGE_TOO_LONG, - String.format("메시지가 너무 깁니다 (%d자, 최대 %d자)", length, maxLength)) - .addDetail("length", length) - .addDetail("maxLength", maxLength); - } - - public static ChattingException invalidMessageType(String type) { - return (ChattingException) new ChattingException(ChattingErrorCode.INVALID_MESSAGE_TYPE, - String.format("유효하지 않은 메시지 타입입니다: '%s'", type)) - .addDetail("invalidValue", type); - } - - // === 참여자(Member) 관련 팩토리 메서드 === - - public static ChattingException notRoomMember(String userId, String roomId) { - return (ChattingException) new ChattingException(ChattingErrorCode.NOT_ROOM_MEMBER, - String.format("채팅방 멤버가 아닙니다 (userId: %s, roomId: %s)", userId, roomId)) - .addDetail("userId", userId) - .addDetail("roomId", roomId); - } - - public static ChattingException alreadyJoined(String userId, String roomId) { - return (ChattingException) new ChattingException(ChattingErrorCode.ALREADY_JOINED, - String.format("이미 참여 중인 채팅방입니다 (userId: %s, roomId: %s)", userId, roomId)) - .addDetail("userId", userId) - .addDetail("roomId", roomId); - } - - public static ChattingException invalidRoomToken() { - return new ChattingException(ChattingErrorCode.INVALID_ROOM_TOKEN); - } - - public static ChattingException invalidRoomToken(String reason) { - return new ChattingException(ChattingErrorCode.INVALID_ROOM_TOKEN, reason); - } - - // === 채팅 레벨 관련 팩토리 메서드 === - - public static ChattingException invalidChatLevel(String level) { - return (ChattingException) new ChattingException(ChattingErrorCode.INVALID_CHAT_LEVEL, - String.format("유효하지 않은 채팅 레벨입니다: '%s'", level)) - .addDetail("invalidValue", level); - } - - // === 연결 관련 팩토리 메서드 === - - public static ChattingException connectionFailed(Throwable cause) { - return (ChattingException) new ChattingException(ChattingErrorCode.CONNECTION_FAILED, cause); - } - - public static ChattingException connectionTimeout(String connectionId) { - return (ChattingException) new ChattingException(ChattingErrorCode.CONNECTION_TIMEOUT, - String.format("연결 시간이 초과되었습니다 (connectionId: %s)", connectionId)) - .addDetail("connectionId", connectionId); - } + + private ChattingException(ChattingErrorCode errorCode) { + super(errorCode); + } + + private ChattingException(ChattingErrorCode errorCode, String message) { + super(errorCode, message); + } + + private ChattingException(ChattingErrorCode errorCode, Throwable cause) { + super(errorCode, cause); + } + + // === 채팅방(Room) 관련 팩토리 메서드 === + + public static ChattingException roomNotFound(String roomId) { + return (ChattingException) new ChattingException(ChattingErrorCode.ROOM_NOT_FOUND, + String.format("채팅방을 찾을 수 없습니다 (ID: %s)", roomId)) + .addDetail("roomId", roomId); + } + + public static ChattingException roomAlreadyExists(String roomName) { + return (ChattingException) new ChattingException(ChattingErrorCode.ROOM_ALREADY_EXISTS, + String.format("이미 존재하는 채팅방입니다: '%s'", roomName)) + .addDetail("roomName", roomName); + } + + public static ChattingException roomFull(String roomId, int maxCapacity) { + return (ChattingException) new ChattingException(ChattingErrorCode.ROOM_FULL, + String.format("채팅방 인원이 가득 찼습니다 (최대 %d명)", maxCapacity)) + .addDetail("roomId", roomId) + .addDetail("maxCapacity", maxCapacity); + } + + public static ChattingException roomClosed(String roomId) { + return (ChattingException) new ChattingException(ChattingErrorCode.ROOM_CLOSED, + String.format("종료된 채팅방입니다 (ID: %s)", roomId)) + .addDetail("roomId", roomId); + } + + public static ChattingException roomInvalidPassword(String roomId) { + return (ChattingException) new ChattingException(ChattingErrorCode.ROOM_INVALID_PASSWORD, + "비밀번호가 일치하지 않습니다") + .addDetail("roomId", roomId); + } + + public static ChattingException roomNotOwner(String userId, String roomId) { + return (ChattingException) new ChattingException(ChattingErrorCode.ROOM_NOT_OWNER, + String.format("방장 권한이 필요합니다 (userId: %s, roomId: %s)", userId, roomId)) + .addDetail("userId", userId) + .addDetail("roomId", roomId); + } + + // === 메시지(Message) 관련 팩토리 메서드 === + + public static ChattingException messageNotFound(String messageId) { + return (ChattingException) new ChattingException(ChattingErrorCode.MESSAGE_NOT_FOUND, + String.format("메시지를 찾을 수 없습니다 (ID: %s)", messageId)) + .addDetail("messageId", messageId); + } + + public static ChattingException messageTooLong(int length, int maxLength) { + return (ChattingException) new ChattingException(ChattingErrorCode.MESSAGE_TOO_LONG, + String.format("메시지가 너무 깁니다 (%d자, 최대 %d자)", length, maxLength)) + .addDetail("length", length) + .addDetail("maxLength", maxLength); + } + + public static ChattingException invalidMessageType(String type) { + return (ChattingException) new ChattingException(ChattingErrorCode.INVALID_MESSAGE_TYPE, + String.format("유효하지 않은 메시지 타입입니다: '%s'", type)) + .addDetail("invalidValue", type); + } + + // === 참여자(Member) 관련 팩토리 메서드 === + + public static ChattingException notRoomMember(String userId, String roomId) { + return (ChattingException) new ChattingException(ChattingErrorCode.NOT_ROOM_MEMBER, + String.format("채팅방 멤버가 아닙니다 (userId: %s, roomId: %s)", userId, roomId)) + .addDetail("userId", userId) + .addDetail("roomId", roomId); + } + + public static ChattingException alreadyJoined(String userId, String roomId) { + return (ChattingException) new ChattingException(ChattingErrorCode.ALREADY_JOINED, + String.format("이미 참여 중인 채팅방입니다 (userId: %s, roomId: %s)", userId, roomId)) + .addDetail("userId", userId) + .addDetail("roomId", roomId); + } + + public static ChattingException invalidRoomToken() { + return new ChattingException(ChattingErrorCode.INVALID_ROOM_TOKEN); + } + + public static ChattingException invalidRoomToken(String reason) { + return new ChattingException(ChattingErrorCode.INVALID_ROOM_TOKEN, reason); + } + + // === 채팅 레벨 관련 팩토리 메서드 === + + public static ChattingException invalidChatLevel(String level) { + return (ChattingException) new ChattingException(ChattingErrorCode.INVALID_CHAT_LEVEL, + String.format("유효하지 않은 채팅 레벨입니다: '%s'", level)) + .addDetail("invalidValue", level); + } + + // === 연결 관련 팩토리 메서드 === + + public static ChattingException connectionFailed(Throwable cause) { + return (ChattingException) new ChattingException(ChattingErrorCode.CONNECTION_FAILED, cause); + } + + public static ChattingException connectionTimeout(String connectionId) { + return (ChattingException) new ChattingException(ChattingErrorCode.CONNECTION_TIMEOUT, + String.format("연결 시간이 초과되었습니다 (connectionId: %s)", connectionId)) + .addDetail("connectionId", connectionId); + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/factory/AiChatResponseFactory.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/factory/AiChatResponseFactory.java index 53bc925f..261842d1 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/factory/AiChatResponseFactory.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/factory/AiChatResponseFactory.java @@ -16,107 +16,107 @@ * ChatLevel에 따라 프롬프트와 응답 스타일을 조정한다. */ public class AiChatResponseFactory implements ChatResponseFactory { - - private static final Logger logger = LoggerFactory.getLogger(AiChatResponseFactory.class); - private static final Gson gson = new Gson(); - - private static final String MODEL_ID = "anthropic.claude-3-sonnet-20240229-v1:0"; - private static final int MAX_TOKENS = 1024; - - @Override - public ChatResponse create(String userMessage, ChatLevel level, String conversationHistory) { - logger.info("Generating AI response: level={}", level.name()); - - long startTime = System.currentTimeMillis(); - - try { - String systemPrompt = buildSystemPrompt(level); - String fullPrompt = buildFullPrompt(userMessage, conversationHistory, systemPrompt); - - JsonObject requestBody = buildRequestBody(fullPrompt, systemPrompt); - - InvokeModelRequest request = InvokeModelRequest.builder() - .modelId(MODEL_ID) - .contentType("application/json") - .accept("application/json") - .body(SdkBytes.fromUtf8String(gson.toJson(requestBody))) - .build(); - - InvokeModelResponse response = AwsClients.bedrock().invokeModel(request); - - String responseBody = response.body().asUtf8String(); - JsonObject jsonResponse = gson.fromJson(responseBody, JsonObject.class); - - String content = jsonResponse.getAsJsonArray("content") - .get(0).getAsJsonObject() - .get("text").getAsString(); - - long processingTime = System.currentTimeMillis() - startTime; - logger.info("AI response generated in {}ms", processingTime); - - return ChatResponse.of(content, MODEL_ID, processingTime); - - } catch (Exception e) { - logger.error("Error generating AI response", e); - throw new RuntimeException("Failed to generate AI response", e); - } - } - - private String buildSystemPrompt(ChatLevel level) { - return switch (level) { - case BEGINNER -> """ - You are a friendly English tutor for beginners. - - Use simple vocabulary and short sentences - - Explain grammar points when needed - - Provide Korean translations for difficult words - - Be encouraging and patient - - Speak slowly and clearly - """; - case INTERMEDIATE -> """ - You are an English conversation partner for intermediate learners. - - Use natural, everyday English - - Introduce new vocabulary with context clues - - Gently correct mistakes - - Encourage more complex sentence structures - """; - case ADVANCED -> """ - You are an advanced English conversation partner. - - Use sophisticated vocabulary and idioms - - Discuss complex topics naturally - - Challenge the learner with nuanced expressions - - Provide minimal corrections, focus on fluency - """; - }; - } - - private String buildFullPrompt(String userMessage, String conversationHistory, String systemPrompt) { - StringBuilder prompt = new StringBuilder(); - - if (conversationHistory != null && !conversationHistory.isEmpty()) { - prompt.append("Previous conversation:\n"); - prompt.append(conversationHistory); - prompt.append("\n\n"); - } - - prompt.append("User: ").append(userMessage); - - return prompt.toString(); - } - - private JsonObject buildRequestBody(String userPrompt, String systemPrompt) { - JsonObject requestBody = new JsonObject(); - requestBody.addProperty("anthropic_version", "bedrock-2023-05-31"); - requestBody.addProperty("max_tokens", MAX_TOKENS); - requestBody.addProperty("system", systemPrompt); - - JsonArray messages = new JsonArray(); - JsonObject userMessage = new JsonObject(); - userMessage.addProperty("role", "user"); - userMessage.addProperty("content", userPrompt); - messages.add(userMessage); - - requestBody.add("messages", messages); - - return requestBody; - } + + private static final Logger logger = LoggerFactory.getLogger(AiChatResponseFactory.class); + private static final Gson gson = new Gson(); + + private static final String MODEL_ID = "anthropic.claude-3-sonnet-20240229-v1:0"; + private static final int MAX_TOKENS = 1024; + + @Override + public ChatResponse create(String userMessage, ChatLevel level, String conversationHistory) { + logger.info("Generating AI response: level={}", level.name()); + + long startTime = System.currentTimeMillis(); + + try { + String systemPrompt = buildSystemPrompt(level); + String fullPrompt = buildFullPrompt(userMessage, conversationHistory, systemPrompt); + + JsonObject requestBody = buildRequestBody(fullPrompt, systemPrompt); + + InvokeModelRequest request = InvokeModelRequest.builder() + .modelId(MODEL_ID) + .contentType("application/json") + .accept("application/json") + .body(SdkBytes.fromUtf8String(gson.toJson(requestBody))) + .build(); + + InvokeModelResponse response = AwsClients.bedrock().invokeModel(request); + + String responseBody = response.body().asUtf8String(); + JsonObject jsonResponse = gson.fromJson(responseBody, JsonObject.class); + + String content = jsonResponse.getAsJsonArray("content") + .get(0).getAsJsonObject() + .get("text").getAsString(); + + long processingTime = System.currentTimeMillis() - startTime; + logger.info("AI response generated in {}ms", processingTime); + + return ChatResponse.of(content, MODEL_ID, processingTime); + + } catch (Exception e) { + logger.error("Error generating AI response", e); + throw new RuntimeException("Failed to generate AI response", e); + } + } + + private String buildSystemPrompt(ChatLevel level) { + return switch (level) { + case BEGINNER -> """ + You are a friendly English tutor for beginners. + - Use simple vocabulary and short sentences + - Explain grammar points when needed + - Provide Korean translations for difficult words + - Be encouraging and patient + - Speak slowly and clearly + """; + case INTERMEDIATE -> """ + You are an English conversation partner for intermediate learners. + - Use natural, everyday English + - Introduce new vocabulary with context clues + - Gently correct mistakes + - Encourage more complex sentence structures + """; + case ADVANCED -> """ + You are an advanced English conversation partner. + - Use sophisticated vocabulary and idioms + - Discuss complex topics naturally + - Challenge the learner with nuanced expressions + - Provide minimal corrections, focus on fluency + """; + }; + } + + private String buildFullPrompt(String userMessage, String conversationHistory, String systemPrompt) { + StringBuilder prompt = new StringBuilder(); + + if (conversationHistory != null && !conversationHistory.isEmpty()) { + prompt.append("Previous conversation:\n"); + prompt.append(conversationHistory); + prompt.append("\n\n"); + } + + prompt.append("User: ").append(userMessage); + + return prompt.toString(); + } + + private JsonObject buildRequestBody(String userPrompt, String systemPrompt) { + JsonObject requestBody = new JsonObject(); + requestBody.addProperty("anthropic_version", "bedrock-2023-05-31"); + requestBody.addProperty("max_tokens", MAX_TOKENS); + requestBody.addProperty("system", systemPrompt); + + JsonArray messages = new JsonArray(); + JsonObject userMessage = new JsonObject(); + userMessage.addProperty("role", "user"); + userMessage.addProperty("content", userPrompt); + messages.add(userMessage); + + requestBody.add("messages", messages); + + return requestBody; + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/factory/ChatResponse.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/factory/ChatResponse.java index fc181865..6231fe20 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/factory/ChatResponse.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/factory/ChatResponse.java @@ -4,15 +4,15 @@ * AI 채팅 응답 Value Object */ public record ChatResponse( - String content, - String modelId, - long processingTimeMs + String content, + String modelId, + long processingTimeMs ) { - public static ChatResponse of(String content, String modelId, long processingTimeMs) { - return new ChatResponse(content, modelId, processingTimeMs); - } - - public static ChatResponse of(String content) { - return new ChatResponse(content, "unknown", 0); - } + public static ChatResponse of(String content, String modelId, long processingTimeMs) { + return new ChatResponse(content, modelId, processingTimeMs); + } + + public static ChatResponse of(String content) { + return new ChatResponse(content, "unknown", 0); + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/factory/ChatResponseFactory.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/factory/ChatResponseFactory.java index 0c1e3ba7..d3e09f47 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/factory/ChatResponseFactory.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/factory/ChatResponseFactory.java @@ -7,23 +7,25 @@ * 다양한 AI 백엔드(Bedrock, OpenAI 등)를 추상화한다. */ public interface ChatResponseFactory { - - /** - * AI 응답 생성 - * @param userMessage 사용자 메시지 - * @param level 채팅 난이도 레벨 - * @param conversationHistory 이전 대화 내역 (nullable) - * @return AI 응답 - */ - ChatResponse create(String userMessage, ChatLevel level, String conversationHistory); - - /** - * AI 응답 생성 (대화 내역 없이) - * @param userMessage 사용자 메시지 - * @param level 채팅 난이도 레벨 - * @return AI 응답 - */ - default ChatResponse create(String userMessage, ChatLevel level) { - return create(userMessage, level, null); - } + + /** + * AI 응답 생성 + * + * @param userMessage 사용자 메시지 + * @param level 채팅 난이도 레벨 + * @param conversationHistory 이전 대화 내역 (nullable) + * @return AI 응답 + */ + ChatResponse create(String userMessage, ChatLevel level, String conversationHistory); + + /** + * AI 응답 생성 (대화 내역 없이) + * + * @param userMessage 사용자 메시지 + * @param level 채팅 난이도 레벨 + * @return AI 응답 + */ + default ChatResponse create(String userMessage, ChatLevel level) { + return create(userMessage, level, null); + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/factory/MockChatResponseFactory.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/factory/MockChatResponseFactory.java index 1514434d..093d501e 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/factory/MockChatResponseFactory.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/factory/MockChatResponseFactory.java @@ -7,39 +7,39 @@ * 외부 API 호출 없이 고정된 응답을 반환한다. */ public class MockChatResponseFactory implements ChatResponseFactory { - - private static final String MOCK_MODEL_ID = "mock-model-v1"; - - @Override - public ChatResponse create(String userMessage, ChatLevel level, String conversationHistory) { - String response = generateMockResponse(userMessage, level); - return ChatResponse.of(response, MOCK_MODEL_ID, 10); - } - - private String generateMockResponse(String userMessage, ChatLevel level) { - return switch (level) { - case BEGINNER -> String.format( - "Hello! That's a great question. '%s' - Let me explain simply. " + - "(안녕하세요! 좋은 질문이에요. 쉽게 설명해 드릴게요.)", - truncate(userMessage, 50) - ); - case INTERMEDIATE -> String.format( - "Good point! Regarding '%s', I think we can explore this topic further. " + - "What do you think about it?", - truncate(userMessage, 50) - ); - case ADVANCED -> String.format( - "That's an insightful observation about '%s'. " + - "Let me offer a nuanced perspective on this matter.", - truncate(userMessage, 50) - ); - }; - } - - private String truncate(String text, int maxLength) { - if (text == null) { - return ""; - } - return text.length() > maxLength ? text.substring(0, maxLength) + "..." : text; - } + + private static final String MOCK_MODEL_ID = "mock-model-v1"; + + @Override + public ChatResponse create(String userMessage, ChatLevel level, String conversationHistory) { + String response = generateMockResponse(userMessage, level); + return ChatResponse.of(response, MOCK_MODEL_ID, 10); + } + + private String generateMockResponse(String userMessage, ChatLevel level) { + return switch (level) { + case BEGINNER -> String.format( + "Hello! That's a great question. '%s' - Let me explain simply. " + + "(안녕하세요! 좋은 질문이에요. 쉽게 설명해 드릴게요.)", + truncate(userMessage, 50) + ); + case INTERMEDIATE -> String.format( + "Good point! Regarding '%s', I think we can explore this topic further. " + + "What do you think about it?", + truncate(userMessage, 50) + ); + case ADVANCED -> String.format( + "That's an insightful observation about '%s'. " + + "Let me offer a nuanced perspective on this matter.", + truncate(userMessage, 50) + ); + }; + } + + private String truncate(String text, int maxLength) { + if (text == null) { + return ""; + } + return text.length() > maxLength ? text.substring(0, maxLength) + "..." : text; + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatAIHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatAIHandler.java index b680e924..5073aae2 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatAIHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatAIHandler.java @@ -17,52 +17,52 @@ import java.util.Map; public class ChatAIHandler implements RequestHandler { - - private static final Logger logger = LoggerFactory.getLogger(ChatAIHandler.class); - private static final Gson gson = new Gson(); - - private final ChatResponseFactory chatResponseFactory; - - public ChatAIHandler() { - this.chatResponseFactory = new AiChatResponseFactory(); - } - - public ChatAIHandler(ChatResponseFactory chatResponseFactory) { - this.chatResponseFactory = chatResponseFactory; - } - - @Override - public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { - logger.info("Received AI generation request"); - - try { - if (!"POST".equals(request.getHttpMethod())) { - return ResponseGenerator.fail(CommonErrorCode.METHOD_NOT_ALLOWED); - } - - String body = request.getBody(); - ChatRequest chatRequest = gson.fromJson(body, ChatRequest.class); - - String userMessage = chatRequest.message != null ? chatRequest.message : "Hello"; - ChatLevel level = ChatLevel.fromStringOrDefault(chatRequest.level, ChatLevel.BEGINNER); - - ChatResponse aiResponse = chatResponseFactory.create(userMessage, level, chatRequest.conversationHistory); - - return ResponseGenerator.ok("AI response generated", Map.of( - "response", aiResponse.content(), - "modelId", aiResponse.modelId(), - "processingTimeMs", aiResponse.processingTimeMs() - )); - - } catch (Exception e) { - logger.error("Error generating AI response", e); - return ResponseGenerator.fail(CommonErrorCode.INTERNAL_SERVER_ERROR); - } - } - - private static class ChatRequest { - String message; - String level; - String conversationHistory; - } + + private static final Logger logger = LoggerFactory.getLogger(ChatAIHandler.class); + private static final Gson gson = new Gson(); + + private final ChatResponseFactory chatResponseFactory; + + public ChatAIHandler() { + this.chatResponseFactory = new AiChatResponseFactory(); + } + + public ChatAIHandler(ChatResponseFactory chatResponseFactory) { + this.chatResponseFactory = chatResponseFactory; + } + + @Override + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { + logger.info("Received AI generation request"); + + try { + if (!"POST".equals(request.getHttpMethod())) { + return ResponseGenerator.fail(CommonErrorCode.METHOD_NOT_ALLOWED); + } + + String body = request.getBody(); + ChatRequest chatRequest = gson.fromJson(body, ChatRequest.class); + + String userMessage = chatRequest.message != null ? chatRequest.message : "Hello"; + ChatLevel level = ChatLevel.fromStringOrDefault(chatRequest.level, ChatLevel.BEGINNER); + + ChatResponse aiResponse = chatResponseFactory.create(userMessage, level, chatRequest.conversationHistory); + + return ResponseGenerator.ok("AI response generated", Map.of( + "response", aiResponse.content(), + "modelId", aiResponse.modelId(), + "processingTimeMs", aiResponse.processingTimeMs() + )); + + } catch (Exception e) { + logger.error("Error generating AI response", e); + return ResponseGenerator.fail(CommonErrorCode.INTERNAL_SERVER_ERROR); + } + } + + private static class ChatRequest { + String message; + String level; + String conversationHistory; + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatMessageHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatMessageHandler.java index 4a53e2d5..9d712f5f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatMessageHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatMessageHandler.java @@ -5,12 +5,12 @@ import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; import com.mzc.secondproject.serverless.common.dto.PaginatedResult; -import com.mzc.secondproject.serverless.common.validation.BeanValidator; -import com.mzc.secondproject.serverless.domain.chatting.dto.request.SendMessageRequest; -import com.mzc.secondproject.serverless.domain.chatting.exception.ChattingErrorCode; import com.mzc.secondproject.serverless.common.router.HandlerRouter; import com.mzc.secondproject.serverless.common.router.Route; import com.mzc.secondproject.serverless.common.util.ResponseGenerator; +import com.mzc.secondproject.serverless.common.validation.BeanValidator; +import com.mzc.secondproject.serverless.domain.chatting.dto.request.SendMessageRequest; +import com.mzc.secondproject.serverless.domain.chatting.exception.ChattingErrorCode; import com.mzc.secondproject.serverless.domain.chatting.model.ChatMessage; import com.mzc.secondproject.serverless.domain.chatting.repository.ChatRoomRepository; import com.mzc.secondproject.serverless.domain.chatting.service.ChatMessageService; @@ -24,97 +24,97 @@ import java.util.UUID; public class ChatMessageHandler implements RequestHandler { - - private static final Logger logger = LoggerFactory.getLogger(ChatMessageHandler.class); - - private final ChatMessageService chatMessageService; - private final ChatRoomRepository chatRoomRepository; - private final HandlerRouter router; - - public ChatMessageHandler() { - this.chatMessageService = new ChatMessageService(); - this.chatRoomRepository = new ChatRoomRepository(); - this.router = initRouter(); - } - - private HandlerRouter initRouter() { - return new HandlerRouter().addRoutes( - Route.postAuth("/rooms/{roomId}/messages", this::sendMessage), - Route.getAuth("/rooms/{roomId}/messages/{messageId}", this::getMessage), - Route.getAuth("/rooms/{roomId}/messages", this::getMessages) - ); - } - - @Override - public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { - logger.info("Received request: {} {}", request.getHttpMethod(), request.getPath()); - return router.route(request); - } - - private APIGatewayProxyResponseEvent sendMessage(APIGatewayProxyRequestEvent request, String userId) { - String roomId = request.getPathParameters().get("roomId"); - SendMessageRequest req = ResponseGenerator.gson().fromJson(request.getBody(), SendMessageRequest.class); - - return BeanValidator.validateAndExecute(req, dto -> { - String messageType = dto.getMessageType() != null ? dto.getMessageType() : "TEXT"; - String messageId = UUID.randomUUID().toString(); - String now = Instant.now().toString(); - - ChatMessage message = ChatMessage.builder() - .pk("ROOM#" + roomId) - .sk("MSG#" + now + "#" + messageId) - .gsi1pk("USER#" + userId) - .gsi1sk("MSG#" + now) - .gsi2pk("MSG#" + messageId) - .gsi2sk("ROOM#" + roomId) - .messageId(messageId) - .roomId(roomId) - .userId(userId) - .content(dto.getContent()) - .messageType(messageType) - .createdAt(now) - .build(); - - ChatMessage savedMessage = chatMessageService.saveMessage(message); - chatRoomRepository.updateLastMessageAt(roomId, now); - - logger.info("Message sent: {} in room: {}", messageId, roomId); - return ResponseGenerator.created("Message sent", savedMessage); - }); - } - - private APIGatewayProxyResponseEvent getMessage(APIGatewayProxyRequestEvent request, String userId) { - String roomId = request.getPathParameters().get("roomId"); - String messageId = request.getPathParameters().get("messageId"); - - Optional message = chatMessageService.getMessage(roomId, messageId); - if (message.isEmpty()) { - return ResponseGenerator.fail(ChattingErrorCode.MESSAGE_NOT_FOUND); - } - return ResponseGenerator.ok("Message retrieved", message.get()); - } - - private APIGatewayProxyResponseEvent getMessages(APIGatewayProxyRequestEvent request, String userId) { - String roomId = request.getPathParameters().get("roomId"); - Map queryParams = request.getQueryStringParameters(); - - int limit = 20; - String cursor = null; - - if (queryParams != null) { - if (queryParams.get("limit") != null) { - limit = Math.min(Integer.parseInt(queryParams.get("limit")), 50); - } - cursor = queryParams.get("cursor"); - } - - PaginatedResult messagePage = chatMessageService.getMessagesByRoomWithPagination(roomId, limit, cursor); - - Map result = new HashMap<>(); - result.put("messages", messagePage.items()); - result.put("nextCursor", messagePage.nextCursor()); - result.put("hasMore", messagePage.hasMore()); - - return ResponseGenerator.ok("Messages retrieved", result); - } + + private static final Logger logger = LoggerFactory.getLogger(ChatMessageHandler.class); + + private final ChatMessageService chatMessageService; + private final ChatRoomRepository chatRoomRepository; + private final HandlerRouter router; + + public ChatMessageHandler() { + this.chatMessageService = new ChatMessageService(); + this.chatRoomRepository = new ChatRoomRepository(); + this.router = initRouter(); + } + + private HandlerRouter initRouter() { + return new HandlerRouter().addRoutes( + Route.postAuth("/rooms/{roomId}/messages", this::sendMessage), + Route.getAuth("/rooms/{roomId}/messages/{messageId}", this::getMessage), + Route.getAuth("/rooms/{roomId}/messages", this::getMessages) + ); + } + + @Override + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { + logger.info("Received request: {} {}", request.getHttpMethod(), request.getPath()); + return router.route(request); + } + + private APIGatewayProxyResponseEvent sendMessage(APIGatewayProxyRequestEvent request, String userId) { + String roomId = request.getPathParameters().get("roomId"); + SendMessageRequest req = ResponseGenerator.gson().fromJson(request.getBody(), SendMessageRequest.class); + + return BeanValidator.validateAndExecute(req, dto -> { + String messageType = dto.getMessageType() != null ? dto.getMessageType() : "TEXT"; + String messageId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + + ChatMessage message = ChatMessage.builder() + .pk("ROOM#" + roomId) + .sk("MSG#" + now + "#" + messageId) + .gsi1pk("USER#" + userId) + .gsi1sk("MSG#" + now) + .gsi2pk("MSG#" + messageId) + .gsi2sk("ROOM#" + roomId) + .messageId(messageId) + .roomId(roomId) + .userId(userId) + .content(dto.getContent()) + .messageType(messageType) + .createdAt(now) + .build(); + + ChatMessage savedMessage = chatMessageService.saveMessage(message); + chatRoomRepository.updateLastMessageAt(roomId, now); + + logger.info("Message sent: {} in room: {}", messageId, roomId); + return ResponseGenerator.created("Message sent", savedMessage); + }); + } + + private APIGatewayProxyResponseEvent getMessage(APIGatewayProxyRequestEvent request, String userId) { + String roomId = request.getPathParameters().get("roomId"); + String messageId = request.getPathParameters().get("messageId"); + + Optional message = chatMessageService.getMessage(roomId, messageId); + if (message.isEmpty()) { + return ResponseGenerator.fail(ChattingErrorCode.MESSAGE_NOT_FOUND); + } + return ResponseGenerator.ok("Message retrieved", message.get()); + } + + private APIGatewayProxyResponseEvent getMessages(APIGatewayProxyRequestEvent request, String userId) { + String roomId = request.getPathParameters().get("roomId"); + Map queryParams = request.getQueryStringParameters(); + + int limit = 20; + String cursor = null; + + if (queryParams != null) { + if (queryParams.get("limit") != null) { + limit = Math.min(Integer.parseInt(queryParams.get("limit")), 50); + } + cursor = queryParams.get("cursor"); + } + + PaginatedResult messagePage = chatMessageService.getMessagesByRoomWithPagination(roomId, limit, cursor); + + Map result = new HashMap<>(); + result.put("messages", messagePage.items()); + result.put("nextCursor", messagePage.nextCursor()); + result.put("hasMore", messagePage.hasMore()); + + return ResponseGenerator.ok("Messages retrieved", result); + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java index ba1ac769..2ea12fd4 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java @@ -5,15 +5,14 @@ import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.common.router.HandlerRouter; +import com.mzc.secondproject.serverless.common.router.Route; +import com.mzc.secondproject.serverless.common.util.ResponseGenerator; import com.mzc.secondproject.serverless.common.validation.BeanValidator; import com.mzc.secondproject.serverless.domain.chatting.dto.request.CreateRoomRequest; import com.mzc.secondproject.serverless.domain.chatting.dto.request.JoinRoomRequest; -import com.mzc.secondproject.serverless.domain.chatting.dto.request.LeaveRoomRequest; import com.mzc.secondproject.serverless.domain.chatting.dto.response.JoinRoomResponse; import com.mzc.secondproject.serverless.domain.chatting.exception.ChattingErrorCode; -import com.mzc.secondproject.serverless.common.router.HandlerRouter; -import com.mzc.secondproject.serverless.common.router.Route; -import com.mzc.secondproject.serverless.common.util.ResponseGenerator; import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; import com.mzc.secondproject.serverless.domain.chatting.service.ChatRoomCommandService; import com.mzc.secondproject.serverless.domain.chatting.service.ChatRoomQueryService; @@ -26,120 +25,120 @@ import java.util.Optional; public class ChatRoomHandler implements RequestHandler { - - private static final Logger logger = LoggerFactory.getLogger(ChatRoomHandler.class); - - private final ChatRoomCommandService commandService; - private final ChatRoomQueryService queryService; - private final HandlerRouter router; - - public ChatRoomHandler() { - this.commandService = new ChatRoomCommandService(); - this.queryService = new ChatRoomQueryService(); - this.router = initRouter(); - } - - private HandlerRouter initRouter() { - return new HandlerRouter().addRoutes( - Route.postAuth("/rooms", this::createRoom), - Route.getAuth("/rooms", this::getRooms), - Route.getAuth("/rooms/{roomId}", this::getRoom), - Route.postAuth("/rooms/{roomId}/join", this::joinRoom), - Route.postAuth("/rooms/{roomId}/leave", this::leaveRoom), - Route.deleteAuth("/rooms/{roomId}", this::deleteRoom) - ); - } - - @Override - public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { - logger.info("Received request: {} {}", request.getHttpMethod(), request.getPath()); - return router.route(request); - } - - private APIGatewayProxyResponseEvent createRoom(APIGatewayProxyRequestEvent request, String userId) { - CreateRoomRequest req = ResponseGenerator.gson().fromJson(request.getBody(), CreateRoomRequest.class); - - return BeanValidator.validateAndExecute(req, dto -> { - String level = dto.getLevel() != null ? dto.getLevel() : "beginner"; - Integer maxMembers = dto.getMaxMembers() != null ? dto.getMaxMembers() : 6; - Boolean isPrivate = dto.getIsPrivate() != null ? dto.getIsPrivate() : false; - - ChatRoom room = commandService.createRoom( - dto.getName(), dto.getDescription(), level, maxMembers, isPrivate, dto.getPassword(), userId); - room.setPassword(null); - - return ResponseGenerator.created("Room created", room); - }); - } - - private APIGatewayProxyResponseEvent getRooms(APIGatewayProxyRequestEvent request, String userId) { - Map queryParams = request.getQueryStringParameters(); - - String level = queryParams != null ? queryParams.get("level") : null; - String joined = queryParams != null ? queryParams.get("joined") : null; - String cursor = queryParams != null ? queryParams.get("cursor") : null; - - int limit = 10; - if (queryParams != null && queryParams.get("limit") != null) { - limit = Math.min(Integer.parseInt(queryParams.get("limit")), 20); - } - - PaginatedResult roomPage = queryService.getRooms(level, limit, cursor); - List rooms = roomPage.items(); - - if ("true".equals(joined)) { - rooms = queryService.filterByJoinedUser(rooms, userId); - } - - rooms.forEach(room -> room.setPassword(null)); - - Map result = new HashMap<>(); - result.put("rooms", rooms); - result.put("nextCursor", roomPage.nextCursor()); - result.put("hasMore", roomPage.hasMore()); - - return ResponseGenerator.ok("Rooms retrieved", result); - } - - private APIGatewayProxyResponseEvent getRoom(APIGatewayProxyRequestEvent request, String userId) { - String roomId = request.getPathParameters().get("roomId"); - - Optional optRoom = queryService.getRoom(roomId); - if (optRoom.isEmpty()) { - return ResponseGenerator.fail(ChattingErrorCode.ROOM_NOT_FOUND); - } - - ChatRoom room = optRoom.get(); - room.setPassword(null); - - return ResponseGenerator.ok("Room retrieved", room); - } - - private APIGatewayProxyResponseEvent joinRoom(APIGatewayProxyRequestEvent request, String userId) { - String roomId = request.getPathParameters().get("roomId"); - JoinRoomRequest req = ResponseGenerator.gson().fromJson(request.getBody(), JoinRoomRequest.class); - - String password = req != null ? req.getPassword() : null; - JoinRoomResponse response = commandService.joinRoom(roomId, userId, password); - response.getRoom().setPassword(null); - return ResponseGenerator.ok("Joined room", response); - } - - private APIGatewayProxyResponseEvent leaveRoom(APIGatewayProxyRequestEvent request, String userId) { - String roomId = request.getPathParameters().get("roomId"); - - ChatRoomCommandService.LeaveResult result = commandService.leaveRoom(roomId, userId); - if (result.deleted()) { - return ResponseGenerator.ok("Room deleted", null); - } - result.room().setPassword(null); - return ResponseGenerator.ok("Left room", result.room()); - } - - private APIGatewayProxyResponseEvent deleteRoom(APIGatewayProxyRequestEvent request, String userId) { - String roomId = request.getPathParameters().get("roomId"); - - commandService.deleteRoom(roomId, userId); - return ResponseGenerator.ok("Room deleted", null); - } + + private static final Logger logger = LoggerFactory.getLogger(ChatRoomHandler.class); + + private final ChatRoomCommandService commandService; + private final ChatRoomQueryService queryService; + private final HandlerRouter router; + + public ChatRoomHandler() { + this.commandService = new ChatRoomCommandService(); + this.queryService = new ChatRoomQueryService(); + this.router = initRouter(); + } + + private HandlerRouter initRouter() { + return new HandlerRouter().addRoutes( + Route.postAuth("/rooms", this::createRoom), + Route.getAuth("/rooms", this::getRooms), + Route.getAuth("/rooms/{roomId}", this::getRoom), + Route.postAuth("/rooms/{roomId}/join", this::joinRoom), + Route.postAuth("/rooms/{roomId}/leave", this::leaveRoom), + Route.deleteAuth("/rooms/{roomId}", this::deleteRoom) + ); + } + + @Override + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { + logger.info("Received request: {} {}", request.getHttpMethod(), request.getPath()); + return router.route(request); + } + + private APIGatewayProxyResponseEvent createRoom(APIGatewayProxyRequestEvent request, String userId) { + CreateRoomRequest req = ResponseGenerator.gson().fromJson(request.getBody(), CreateRoomRequest.class); + + return BeanValidator.validateAndExecute(req, dto -> { + String level = dto.getLevel() != null ? dto.getLevel() : "beginner"; + Integer maxMembers = dto.getMaxMembers() != null ? dto.getMaxMembers() : 6; + Boolean isPrivate = dto.getIsPrivate() != null ? dto.getIsPrivate() : false; + + ChatRoom room = commandService.createRoom( + dto.getName(), dto.getDescription(), level, maxMembers, isPrivate, dto.getPassword(), userId); + room.setPassword(null); + + return ResponseGenerator.created("Room created", room); + }); + } + + private APIGatewayProxyResponseEvent getRooms(APIGatewayProxyRequestEvent request, String userId) { + Map queryParams = request.getQueryStringParameters(); + + String level = queryParams != null ? queryParams.get("level") : null; + String joined = queryParams != null ? queryParams.get("joined") : null; + String cursor = queryParams != null ? queryParams.get("cursor") : null; + + int limit = 10; + if (queryParams != null && queryParams.get("limit") != null) { + limit = Math.min(Integer.parseInt(queryParams.get("limit")), 20); + } + + PaginatedResult roomPage = queryService.getRooms(level, limit, cursor); + List rooms = roomPage.items(); + + if ("true".equals(joined)) { + rooms = queryService.filterByJoinedUser(rooms, userId); + } + + rooms.forEach(room -> room.setPassword(null)); + + Map result = new HashMap<>(); + result.put("rooms", rooms); + result.put("nextCursor", roomPage.nextCursor()); + result.put("hasMore", roomPage.hasMore()); + + return ResponseGenerator.ok("Rooms retrieved", result); + } + + private APIGatewayProxyResponseEvent getRoom(APIGatewayProxyRequestEvent request, String userId) { + String roomId = request.getPathParameters().get("roomId"); + + Optional optRoom = queryService.getRoom(roomId); + if (optRoom.isEmpty()) { + return ResponseGenerator.fail(ChattingErrorCode.ROOM_NOT_FOUND); + } + + ChatRoom room = optRoom.get(); + room.setPassword(null); + + return ResponseGenerator.ok("Room retrieved", room); + } + + private APIGatewayProxyResponseEvent joinRoom(APIGatewayProxyRequestEvent request, String userId) { + String roomId = request.getPathParameters().get("roomId"); + JoinRoomRequest req = ResponseGenerator.gson().fromJson(request.getBody(), JoinRoomRequest.class); + + String password = req != null ? req.getPassword() : null; + JoinRoomResponse response = commandService.joinRoom(roomId, userId, password); + response.getRoom().setPassword(null); + return ResponseGenerator.ok("Joined room", response); + } + + private APIGatewayProxyResponseEvent leaveRoom(APIGatewayProxyRequestEvent request, String userId) { + String roomId = request.getPathParameters().get("roomId"); + + ChatRoomCommandService.LeaveResult result = commandService.leaveRoom(roomId, userId); + if (result.deleted()) { + return ResponseGenerator.ok("Room deleted", null); + } + result.room().setPassword(null); + return ResponseGenerator.ok("Left room", result.room()); + } + + private APIGatewayProxyResponseEvent deleteRoom(APIGatewayProxyRequestEvent request, String userId) { + String roomId = request.getPathParameters().get("roomId"); + + commandService.deleteRoom(roomId, userId); + return ResponseGenerator.ok("Room deleted", null); + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatVoiceHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatVoiceHandler.java index 342da979..39dfd2a3 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatVoiceHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatVoiceHandler.java @@ -5,14 +5,14 @@ import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; import com.mzc.secondproject.serverless.common.exception.CommonErrorCode; +import com.mzc.secondproject.serverless.common.service.PollyService; +import com.mzc.secondproject.serverless.common.service.PollyService.VoiceSynthesisResult; +import com.mzc.secondproject.serverless.common.util.ResponseGenerator; import com.mzc.secondproject.serverless.common.validation.BeanValidator; import com.mzc.secondproject.serverless.domain.chatting.dto.request.VoiceSynthesisRequest; import com.mzc.secondproject.serverless.domain.chatting.exception.ChattingErrorCode; -import com.mzc.secondproject.serverless.common.util.ResponseGenerator; import com.mzc.secondproject.serverless.domain.chatting.model.ChatMessage; import com.mzc.secondproject.serverless.domain.chatting.repository.ChatMessageRepository; -import com.mzc.secondproject.serverless.common.service.PollyService; -import com.mzc.secondproject.serverless.common.service.PollyService.VoiceSynthesisResult; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -20,85 +20,85 @@ import java.util.Optional; public class ChatVoiceHandler implements RequestHandler { - - private static final Logger logger = LoggerFactory.getLogger(ChatVoiceHandler.class); - private static final String BUCKET_NAME = System.getenv("CHAT_BUCKET_NAME"); - - private final PollyService pollyService; - private final ChatMessageRepository messageRepository; - - public ChatVoiceHandler() { - this.pollyService = new PollyService(BUCKET_NAME, "voice/"); - this.messageRepository = new ChatMessageRepository(); - } - - @Override - public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { - logger.info("Received voice synthesis request"); - - try { - if (!"POST".equals(request.getHttpMethod())) { - return ResponseGenerator.fail(CommonErrorCode.METHOD_NOT_ALLOWED); - } - - VoiceSynthesisRequest req = ResponseGenerator.gson().fromJson(request.getBody(), VoiceSynthesisRequest.class); - - return BeanValidator.validateAndExecute(req, dto -> processVoiceSynthesis(dto)); - - } catch (Exception e) { - logger.error("Error synthesizing speech", e); - return ResponseGenerator.fail(CommonErrorCode.INTERNAL_SERVER_ERROR); - } - } - - private APIGatewayProxyResponseEvent processVoiceSynthesis(VoiceSynthesisRequest dto) { - String messageId = dto.getMessageId(); - String roomId = dto.getRoomId(); - String voice = dto.getVoice() != null ? dto.getVoice() : "FEMALE"; - - // 메시지 조회 - Optional messageOpt = messageRepository.findByRoomIdAndMessageId(roomId, messageId); - if (messageOpt.isEmpty()) { - return ResponseGenerator.fail(ChattingErrorCode.MESSAGE_NOT_FOUND); - } - - ChatMessage message = messageOpt.get(); - boolean isMale = "MALE".equalsIgnoreCase(voice); - - // 캐시된 음성 키 확인 - String cachedKey = isMale ? message.getMaleVoiceKey() : message.getFemaleVoiceKey(); - - String audioUrl; - boolean cached; - - if (cachedKey != null && !cachedKey.isEmpty()) { - // 캐시 히트: DynamoDB에 키가 있으면 S3에서 URL 생성 - logger.info("DB cache hit for message: {}, voice: {}", messageId, voice); - audioUrl = pollyService.getPresignedUrl(cachedKey); - cached = true; - } else { - // 캐시 미스: Polly 변환 → S3 저장 → DynamoDB 업데이트 - VoiceSynthesisResult result = pollyService.synthesizeSpeech( - messageId, message.getContent(), voice); - - // DynamoDB에 S3 키 저장 - if (isMale) { - message.setMaleVoiceKey(result.getS3Key()); - } else { - message.setFemaleVoiceKey(result.getS3Key()); - } - messageRepository.save(message); - - audioUrl = result.getAudioUrl(); - cached = result.isCached(); - } - - return ResponseGenerator.ok( - cached ? "Speech retrieved from cache" : "Speech synthesized", - Map.of( - "audioUrl", audioUrl, - "cached", cached - ) - ); - } + + private static final Logger logger = LoggerFactory.getLogger(ChatVoiceHandler.class); + private static final String BUCKET_NAME = System.getenv("CHAT_BUCKET_NAME"); + + private final PollyService pollyService; + private final ChatMessageRepository messageRepository; + + public ChatVoiceHandler() { + this.pollyService = new PollyService(BUCKET_NAME, "voice/"); + this.messageRepository = new ChatMessageRepository(); + } + + @Override + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { + logger.info("Received voice synthesis request"); + + try { + if (!"POST".equals(request.getHttpMethod())) { + return ResponseGenerator.fail(CommonErrorCode.METHOD_NOT_ALLOWED); + } + + VoiceSynthesisRequest req = ResponseGenerator.gson().fromJson(request.getBody(), VoiceSynthesisRequest.class); + + return BeanValidator.validateAndExecute(req, dto -> processVoiceSynthesis(dto)); + + } catch (Exception e) { + logger.error("Error synthesizing speech", e); + return ResponseGenerator.fail(CommonErrorCode.INTERNAL_SERVER_ERROR); + } + } + + private APIGatewayProxyResponseEvent processVoiceSynthesis(VoiceSynthesisRequest dto) { + String messageId = dto.getMessageId(); + String roomId = dto.getRoomId(); + String voice = dto.getVoice() != null ? dto.getVoice() : "FEMALE"; + + // 메시지 조회 + Optional messageOpt = messageRepository.findByRoomIdAndMessageId(roomId, messageId); + if (messageOpt.isEmpty()) { + return ResponseGenerator.fail(ChattingErrorCode.MESSAGE_NOT_FOUND); + } + + ChatMessage message = messageOpt.get(); + boolean isMale = "MALE".equalsIgnoreCase(voice); + + // 캐시된 음성 키 확인 + String cachedKey = isMale ? message.getMaleVoiceKey() : message.getFemaleVoiceKey(); + + String audioUrl; + boolean cached; + + if (cachedKey != null && !cachedKey.isEmpty()) { + // 캐시 히트: DynamoDB에 키가 있으면 S3에서 URL 생성 + logger.info("DB cache hit for message: {}, voice: {}", messageId, voice); + audioUrl = pollyService.getPresignedUrl(cachedKey); + cached = true; + } else { + // 캐시 미스: Polly 변환 → S3 저장 → DynamoDB 업데이트 + VoiceSynthesisResult result = pollyService.synthesizeSpeech( + messageId, message.getContent(), voice); + + // DynamoDB에 S3 키 저장 + if (isMale) { + message.setMaleVoiceKey(result.getS3Key()); + } else { + message.setFemaleVoiceKey(result.getS3Key()); + } + messageRepository.save(message); + + audioUrl = result.getAudioUrl(); + cached = result.isCached(); + } + + return ResponseGenerator.ok( + cached ? "Speech retrieved from cache" : "Speech synthesized", + Map.of( + "audioUrl", audioUrl, + "cached", cached + ) + ); + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketConnectHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketConnectHandler.java index 329fe66c..bba1c18a 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketConnectHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketConnectHandler.java @@ -20,87 +20,87 @@ * roomToken 검증 후 Connection 정보를 DynamoDB에 저장 */ public class WebSocketConnectHandler implements RequestHandler, Map> { - - private static final Logger logger = LoggerFactory.getLogger(WebSocketConnectHandler.class); - - private final ConnectionRepository connectionRepository; - private final RoomTokenService roomTokenService; - - public WebSocketConnectHandler() { - this.connectionRepository = new ConnectionRepository(); - this.roomTokenService = new RoomTokenService(); - } - - @Override - public Map handleRequest(Map event, Context context) { - logger.info("WebSocket connect event: {}", event); - - try { - String connectionId = extractConnectionId(event); - Map queryParams = extractQueryStringParameters(event); - - String roomToken = queryParams.get("roomToken"); - - if (roomToken == null || roomToken.isEmpty()) { - logger.warn("Missing roomToken parameter"); - return createResponse(401, "roomToken is required"); - } - - // 토큰 검증 - Optional optToken = roomTokenService.validateToken(roomToken); - if (optToken.isEmpty()) { - logger.warn("Invalid or expired roomToken: {}", roomToken); - return createResponse(401, "Invalid or expired token"); - } - - RoomToken token = optToken.get(); - String userId = token.getUserId(); - String roomId = token.getRoomId(); - - String now = Instant.now().toString(); - long ttl = Instant.now().plusSeconds(WebSocketConfig.connectionTtlSeconds()).getEpochSecond(); - - Connection connection = Connection.builder() - .pk("CONN#" + connectionId) - .sk("METADATA") - .gsi1pk("ROOM#" + roomId) - .gsi1sk("CONN#" + connectionId) - .gsi2pk("USER#" + userId) - .gsi2sk("CONN#" + connectionId) - .connectionId(connectionId) - .userId(userId) - .roomId(roomId) - .connectedAt(now) - .ttl(ttl) - .build(); - - connectionRepository.save(connection); - - logger.info("Connection saved: connectionId={}, userId={}, roomId={}", connectionId, userId, roomId); - return createResponse(200, "Connected"); - - } catch (Exception e) { - logger.error("Error handling connect: {}", e.getMessage(), e); - return createResponse(500, "Internal server error"); - } - } - - @SuppressWarnings("unchecked") - private String extractConnectionId(Map event) { - Map requestContext = (Map) event.get("requestContext"); - return (String) requestContext.get("connectionId"); - } - - @SuppressWarnings("unchecked") - private Map extractQueryStringParameters(Map event) { - Map params = (Map) event.get("queryStringParameters"); - return params != null ? params : new HashMap<>(); - } - - private Map createResponse(int statusCode, String body) { - Map response = new HashMap<>(); - response.put("statusCode", statusCode); - response.put("body", body); - return response; - } + + private static final Logger logger = LoggerFactory.getLogger(WebSocketConnectHandler.class); + + private final ConnectionRepository connectionRepository; + private final RoomTokenService roomTokenService; + + public WebSocketConnectHandler() { + this.connectionRepository = new ConnectionRepository(); + this.roomTokenService = new RoomTokenService(); + } + + @Override + public Map handleRequest(Map event, Context context) { + logger.info("WebSocket connect event: {}", event); + + try { + String connectionId = extractConnectionId(event); + Map queryParams = extractQueryStringParameters(event); + + String roomToken = queryParams.get("roomToken"); + + if (roomToken == null || roomToken.isEmpty()) { + logger.warn("Missing roomToken parameter"); + return createResponse(401, "roomToken is required"); + } + + // 토큰 검증 + Optional optToken = roomTokenService.validateToken(roomToken); + if (optToken.isEmpty()) { + logger.warn("Invalid or expired roomToken: {}", roomToken); + return createResponse(401, "Invalid or expired token"); + } + + RoomToken token = optToken.get(); + String userId = token.getUserId(); + String roomId = token.getRoomId(); + + String now = Instant.now().toString(); + long ttl = Instant.now().plusSeconds(WebSocketConfig.connectionTtlSeconds()).getEpochSecond(); + + Connection connection = Connection.builder() + .pk("CONN#" + connectionId) + .sk("METADATA") + .gsi1pk("ROOM#" + roomId) + .gsi1sk("CONN#" + connectionId) + .gsi2pk("USER#" + userId) + .gsi2sk("CONN#" + connectionId) + .connectionId(connectionId) + .userId(userId) + .roomId(roomId) + .connectedAt(now) + .ttl(ttl) + .build(); + + connectionRepository.save(connection); + + logger.info("Connection saved: connectionId={}, userId={}, roomId={}", connectionId, userId, roomId); + return createResponse(200, "Connected"); + + } catch (Exception e) { + logger.error("Error handling connect: {}", e.getMessage(), e); + return createResponse(500, "Internal server error"); + } + } + + @SuppressWarnings("unchecked") + private String extractConnectionId(Map event) { + Map requestContext = (Map) event.get("requestContext"); + return (String) requestContext.get("connectionId"); + } + + @SuppressWarnings("unchecked") + private Map extractQueryStringParameters(Map event) { + Map params = (Map) event.get("queryStringParameters"); + return params != null ? params : new HashMap<>(); + } + + private Map createResponse(int statusCode, String body) { + Map response = new HashMap<>(); + response.put("statusCode", statusCode); + response.put("body", body); + return response; + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketDisconnectHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketDisconnectHandler.java index c23971ec..07935ab8 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketDisconnectHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketDisconnectHandler.java @@ -16,51 +16,51 @@ * 클라이언트 연결 해제 시 Connection 정보를 DynamoDB에서 삭제 */ public class WebSocketDisconnectHandler implements RequestHandler, Map> { - - private static final Logger logger = LoggerFactory.getLogger(WebSocketDisconnectHandler.class); - - private final ConnectionRepository connectionRepository; - - public WebSocketDisconnectHandler() { - this.connectionRepository = new ConnectionRepository(); - } - - @Override - public Map handleRequest(Map event, Context context) { - logger.info("WebSocket disconnect event: {}", event); - - try { - String connectionId = extractConnectionId(event); - - Optional connection = connectionRepository.findByConnectionId(connectionId); - - if (connection.isPresent()) { - Connection conn = connection.get(); - connectionRepository.delete(connectionId); - logger.info("Connection deleted: connectionId={}, userId={}, roomId={}", - connectionId, conn.getUserId(), conn.getRoomId()); - } else { - logger.warn("Connection not found for deletion: connectionId={}", connectionId); - } - - return createResponse(200, "Disconnected"); - - } catch (Exception e) { - logger.error("Error handling disconnect: {}", e.getMessage(), e); - return createResponse(500, "Internal server error"); - } - } - - @SuppressWarnings("unchecked") - private String extractConnectionId(Map event) { - Map requestContext = (Map) event.get("requestContext"); - return (String) requestContext.get("connectionId"); - } - - private Map createResponse(int statusCode, String body) { - Map response = new HashMap<>(); - response.put("statusCode", statusCode); - response.put("body", body); - return response; - } + + private static final Logger logger = LoggerFactory.getLogger(WebSocketDisconnectHandler.class); + + private final ConnectionRepository connectionRepository; + + public WebSocketDisconnectHandler() { + this.connectionRepository = new ConnectionRepository(); + } + + @Override + public Map handleRequest(Map event, Context context) { + logger.info("WebSocket disconnect event: {}", event); + + try { + String connectionId = extractConnectionId(event); + + Optional connection = connectionRepository.findByConnectionId(connectionId); + + if (connection.isPresent()) { + Connection conn = connection.get(); + connectionRepository.delete(connectionId); + logger.info("Connection deleted: connectionId={}, userId={}, roomId={}", + connectionId, conn.getUserId(), conn.getRoomId()); + } else { + logger.warn("Connection not found for deletion: connectionId={}", connectionId); + } + + return createResponse(200, "Disconnected"); + + } catch (Exception e) { + logger.error("Error handling disconnect: {}", e.getMessage(), e); + return createResponse(500, "Internal server error"); + } + } + + @SuppressWarnings("unchecked") + private String extractConnectionId(Map event) { + Map requestContext = (Map) event.get("requestContext"); + return (String) requestContext.get("connectionId"); + } + + private Map createResponse(int statusCode, String body) { + Map response = new HashMap<>(); + response.put("statusCode", statusCode); + response.put("body", body); + return response; + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java index b42b23cd..3a134caa 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java @@ -7,8 +7,8 @@ import com.mzc.secondproject.serverless.common.util.WebSocketBroadcaster; import com.mzc.secondproject.serverless.domain.chatting.model.ChatMessage; import com.mzc.secondproject.serverless.domain.chatting.model.Connection; -import com.mzc.secondproject.serverless.domain.chatting.repository.ConnectionRepository; import com.mzc.secondproject.serverless.domain.chatting.repository.ChatRoomRepository; +import com.mzc.secondproject.serverless.domain.chatting.repository.ConnectionRepository; import com.mzc.secondproject.serverless.domain.chatting.service.ChatMessageService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -24,103 +24,103 @@ * 메시지 저장 및 같은 방 연결들에게 브로드캐스트 */ public class WebSocketMessageHandler implements RequestHandler, Map> { - - private static final Logger logger = LoggerFactory.getLogger(WebSocketMessageHandler.class); - private static final Gson gson = new GsonBuilder().create(); - - private final ChatMessageService chatMessageService; - private final ChatRoomRepository chatRoomRepository; - private final ConnectionRepository connectionRepository; - private final WebSocketBroadcaster broadcaster; - - public WebSocketMessageHandler() { - this.chatMessageService = new ChatMessageService(); - this.chatRoomRepository = new ChatRoomRepository(); - this.connectionRepository = new ConnectionRepository(); - this.broadcaster = new WebSocketBroadcaster(); - } - - @Override - public Map handleRequest(Map event, Context context) { - logger.info("WebSocket message event: {}", event); - - try { - String connectionId = extractConnectionId(event); - String body = (String) event.get("body"); - - if (body == null || body.isEmpty()) { - return createResponse(400, "Message body is required"); - } - - MessagePayload payload = gson.fromJson(body, MessagePayload.class); - - if (payload.roomId == null || payload.userId == null || payload.content == null) { - return createResponse(400, "roomId, userId, and content are required"); - } - - String messageType = payload.messageType != null ? payload.messageType : "TEXT"; - String messageId = UUID.randomUUID().toString(); - String now = Instant.now().toString(); - - ChatMessage message = ChatMessage.builder() - .pk("ROOM#" + payload.roomId) - .sk("MSG#" + now + "#" + messageId) - .gsi1pk("USER#" + payload.userId) - .gsi1sk("MSG#" + now) - .gsi2pk("MSG#" + messageId) - .gsi2sk("ROOM#" + payload.roomId) - .messageId(messageId) - .roomId(payload.roomId) - .userId(payload.userId) - .content(payload.content) - .messageType(messageType) - .createdAt(now) - .build(); - - ChatMessage savedMessage = chatMessageService.saveMessage(message); - chatRoomRepository.updateLastMessageAt(payload.roomId, now); - - logger.info("Message saved: messageId={}, roomId={}", messageId, payload.roomId); - - // 브로드캐스트 - List connections = connectionRepository.findByRoomId(payload.roomId); - String broadcastPayload = gson.toJson(savedMessage); - List failedConnections = broadcaster.broadcast(connections, broadcastPayload); - - // 실패한 연결 정리 - for (String failedConnectionId : failedConnections) { - connectionRepository.delete(failedConnectionId); - logger.info("Deleted stale connection: {}", failedConnectionId); - } - - return createResponse(200, "Message sent"); - - } catch (Exception e) { - logger.error("Error handling message: {}", e.getMessage(), e); - return createResponse(500, "Internal server error"); - } - } - - @SuppressWarnings("unchecked") - private String extractConnectionId(Map event) { - Map requestContext = (Map) event.get("requestContext"); - return (String) requestContext.get("connectionId"); - } - - private Map createResponse(int statusCode, String body) { - Map response = new HashMap<>(); - response.put("statusCode", statusCode); - response.put("body", body); - return response; - } - - /** - * 메시지 페이로드 DTO - */ - private static class MessagePayload { - String roomId; - String userId; - String content; - String messageType; - } + + private static final Logger logger = LoggerFactory.getLogger(WebSocketMessageHandler.class); + private static final Gson gson = new GsonBuilder().create(); + + private final ChatMessageService chatMessageService; + private final ChatRoomRepository chatRoomRepository; + private final ConnectionRepository connectionRepository; + private final WebSocketBroadcaster broadcaster; + + public WebSocketMessageHandler() { + this.chatMessageService = new ChatMessageService(); + this.chatRoomRepository = new ChatRoomRepository(); + this.connectionRepository = new ConnectionRepository(); + this.broadcaster = new WebSocketBroadcaster(); + } + + @Override + public Map handleRequest(Map event, Context context) { + logger.info("WebSocket message event: {}", event); + + try { + String connectionId = extractConnectionId(event); + String body = (String) event.get("body"); + + if (body == null || body.isEmpty()) { + return createResponse(400, "Message body is required"); + } + + MessagePayload payload = gson.fromJson(body, MessagePayload.class); + + if (payload.roomId == null || payload.userId == null || payload.content == null) { + return createResponse(400, "roomId, userId, and content are required"); + } + + String messageType = payload.messageType != null ? payload.messageType : "TEXT"; + String messageId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + + ChatMessage message = ChatMessage.builder() + .pk("ROOM#" + payload.roomId) + .sk("MSG#" + now + "#" + messageId) + .gsi1pk("USER#" + payload.userId) + .gsi1sk("MSG#" + now) + .gsi2pk("MSG#" + messageId) + .gsi2sk("ROOM#" + payload.roomId) + .messageId(messageId) + .roomId(payload.roomId) + .userId(payload.userId) + .content(payload.content) + .messageType(messageType) + .createdAt(now) + .build(); + + ChatMessage savedMessage = chatMessageService.saveMessage(message); + chatRoomRepository.updateLastMessageAt(payload.roomId, now); + + logger.info("Message saved: messageId={}, roomId={}", messageId, payload.roomId); + + // 브로드캐스트 + List connections = connectionRepository.findByRoomId(payload.roomId); + String broadcastPayload = gson.toJson(savedMessage); + List failedConnections = broadcaster.broadcast(connections, broadcastPayload); + + // 실패한 연결 정리 + for (String failedConnectionId : failedConnections) { + connectionRepository.delete(failedConnectionId); + logger.info("Deleted stale connection: {}", failedConnectionId); + } + + return createResponse(200, "Message sent"); + + } catch (Exception e) { + logger.error("Error handling message: {}", e.getMessage(), e); + return createResponse(500, "Internal server error"); + } + } + + @SuppressWarnings("unchecked") + private String extractConnectionId(Map event) { + Map requestContext = (Map) event.get("requestContext"); + return (String) requestContext.get("connectionId"); + } + + private Map createResponse(int statusCode, String body) { + Map response = new HashMap<>(); + response.put("statusCode", statusCode); + response.put("body", body); + return response; + } + + /** + * 메시지 페이로드 DTO + */ + private static class MessagePayload { + String roomId; + String userId; + String content; + String messageType; + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/ChatMessage.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/ChatMessage.java index 059fe665..0cbde348 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/ChatMessage.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/ChatMessage.java @@ -4,12 +4,7 @@ import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbAttribute; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondaryPartitionKey; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondarySortKey; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSortKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.*; @Data @Builder @@ -17,59 +12,59 @@ @AllArgsConstructor @DynamoDbBean public class ChatMessage { - - private String pk; // ROOM#{roomId} - private String sk; // MSG#{timestamp}#{messageId} - private String gsi1pk; // USER#{userId} - private String gsi1sk; // MSG#{timestamp} - private String gsi2pk; // MSG#{messageId} - messageId로 직접 조회용 - private String gsi2sk; // ROOM#{roomId} - - private String messageId; - private String roomId; - private String userId; - private String content; - private String messageType; // TEXT, IMAGE, VOICE, AI_RESPONSE - private String createdAt; - private Long ttl; - - // 음성 캐시용 S3 키 (voice/{messageId}_{voice}.mp3) - private String maleVoiceKey; - private String femaleVoiceKey; - - @DynamoDbPartitionKey - @DynamoDbAttribute("PK") - public String getPk() { - return pk; - } - - @DynamoDbSortKey - @DynamoDbAttribute("SK") - public String getSk() { - return sk; - } - - @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") - @DynamoDbAttribute("GSI1PK") - public String getGsi1pk() { - return gsi1pk; - } - - @DynamoDbSecondarySortKey(indexNames = "GSI1") - @DynamoDbAttribute("GSI1SK") - public String getGsi1sk() { - return gsi1sk; - } - - @DynamoDbSecondaryPartitionKey(indexNames = "GSI2") - @DynamoDbAttribute("GSI2PK") - public String getGsi2pk() { - return gsi2pk; - } - - @DynamoDbSecondarySortKey(indexNames = "GSI2") - @DynamoDbAttribute("GSI2SK") - public String getGsi2sk() { - return gsi2sk; - } + + private String pk; // ROOM#{roomId} + private String sk; // MSG#{timestamp}#{messageId} + private String gsi1pk; // USER#{userId} + private String gsi1sk; // MSG#{timestamp} + private String gsi2pk; // MSG#{messageId} - messageId로 직접 조회용 + private String gsi2sk; // ROOM#{roomId} + + private String messageId; + private String roomId; + private String userId; + private String content; + private String messageType; // TEXT, IMAGE, VOICE, AI_RESPONSE + private String createdAt; + private Long ttl; + + // 음성 캐시용 S3 키 (voice/{messageId}_{voice}.mp3) + private String maleVoiceKey; + private String femaleVoiceKey; + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { + return pk; + } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { + return sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1PK") + public String getGsi1pk() { + return gsi1pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1SK") + public String getGsi1sk() { + return gsi1sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI2") + @DynamoDbAttribute("GSI2PK") + public String getGsi2pk() { + return gsi2pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI2") + @DynamoDbAttribute("GSI2SK") + public String getGsi2sk() { + return gsi2sk; + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/ChatRoom.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/ChatRoom.java index 81abd60b..f90530c1 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/ChatRoom.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/ChatRoom.java @@ -4,12 +4,7 @@ import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbAttribute; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondaryPartitionKey; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondarySortKey; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSortKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.*; import java.util.List; @@ -19,47 +14,47 @@ @AllArgsConstructor @DynamoDbBean public class ChatRoom { - - private String pk; // ROOM#{roomId} - private String sk; // METADATA - private String gsi1pk; // ROOMS - private String gsi1sk; // {level}#{createdAt} - - private String roomId; - private String name; - private String description; - private String level; // beginner, intermediate, advanced - private Integer currentMembers; - private Integer maxMembers; - private Boolean isPrivate; - private String password; // 비밀방 비밀번호 (해시) - private String createdBy; // 방장 userId - private String createdAt; - private String lastMessageAt; - private List memberIds; // 참여 멤버 목록 - private Long ttl; - - @DynamoDbPartitionKey - @DynamoDbAttribute("PK") - public String getPk() { - return pk; - } - - @DynamoDbSortKey - @DynamoDbAttribute("SK") - public String getSk() { - return sk; - } - - @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") - @DynamoDbAttribute("GSI1PK") - public String getGsi1pk() { - return gsi1pk; - } - - @DynamoDbSecondarySortKey(indexNames = "GSI1") - @DynamoDbAttribute("GSI1SK") - public String getGsi1sk() { - return gsi1sk; - } + + private String pk; // ROOM#{roomId} + private String sk; // METADATA + private String gsi1pk; // ROOMS + private String gsi1sk; // {level}#{createdAt} + + private String roomId; + private String name; + private String description; + private String level; // beginner, intermediate, advanced + private Integer currentMembers; + private Integer maxMembers; + private Boolean isPrivate; + private String password; // 비밀방 비밀번호 (해시) + private String createdBy; // 방장 userId + private String createdAt; + private String lastMessageAt; + private List memberIds; // 참여 멤버 목록 + private Long ttl; + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { + return pk; + } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { + return sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1PK") + public String getGsi1pk() { + return gsi1pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1SK") + public String getGsi1sk() { + return gsi1sk; + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/Connection.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/Connection.java index cbb1445c..7c130390 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/Connection.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/Connection.java @@ -4,12 +4,7 @@ import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbAttribute; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondaryPartitionKey; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondarySortKey; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSortKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.*; @Data @Builder @@ -17,53 +12,53 @@ @AllArgsConstructor @DynamoDbBean public class Connection { - - private String pk; // CONN#{connectionId} - private String sk; // METADATA - private String gsi1pk; // ROOM#{roomId} - 방별 연결 조회용 - private String gsi1sk; // CONN#{connectionId} - private String gsi2pk; // USER#{userId} - 사용자별 연결 조회용 - private String gsi2sk; // CONN#{connectionId} - - private String connectionId; - private String userId; - private String roomId; - private String connectedAt; - private Long ttl; // 10분 후 자동 삭제 - - @DynamoDbPartitionKey - @DynamoDbAttribute("PK") - public String getPk() { - return pk; - } - - @DynamoDbSortKey - @DynamoDbAttribute("SK") - public String getSk() { - return sk; - } - - @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") - @DynamoDbAttribute("GSI1PK") - public String getGsi1pk() { - return gsi1pk; - } - - @DynamoDbSecondarySortKey(indexNames = "GSI1") - @DynamoDbAttribute("GSI1SK") - public String getGsi1sk() { - return gsi1sk; - } - - @DynamoDbSecondaryPartitionKey(indexNames = "GSI2") - @DynamoDbAttribute("GSI2PK") - public String getGsi2pk() { - return gsi2pk; - } - - @DynamoDbSecondarySortKey(indexNames = "GSI2") - @DynamoDbAttribute("GSI2SK") - public String getGsi2sk() { - return gsi2sk; - } + + private String pk; // CONN#{connectionId} + private String sk; // METADATA + private String gsi1pk; // ROOM#{roomId} - 방별 연결 조회용 + private String gsi1sk; // CONN#{connectionId} + private String gsi2pk; // USER#{userId} - 사용자별 연결 조회용 + private String gsi2sk; // CONN#{connectionId} + + private String connectionId; + private String userId; + private String roomId; + private String connectedAt; + private Long ttl; // 10분 후 자동 삭제 + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { + return pk; + } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { + return sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1PK") + public String getGsi1pk() { + return gsi1pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1SK") + public String getGsi1sk() { + return gsi1sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI2") + @DynamoDbAttribute("GSI2PK") + public String getGsi2pk() { + return gsi2pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI2") + @DynamoDbAttribute("GSI2SK") + public String getGsi2sk() { + return gsi2sk; + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/RoomToken.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/RoomToken.java index ea0d313f..0afa1fa1 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/RoomToken.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/RoomToken.java @@ -19,24 +19,24 @@ @AllArgsConstructor @DynamoDbBean public class RoomToken { - - private String pk; // TOKEN#{token} - private String sk; // METADATA - private String token; - private String roomId; - private String userId; - private String createdAt; - private Long ttl; // 자동 만료 - - @DynamoDbPartitionKey - @DynamoDbAttribute("PK") - public String getPk() { - return pk; - } - - @DynamoDbSortKey - @DynamoDbAttribute("SK") - public String getSk() { - return sk; - } + + private String pk; // TOKEN#{token} + private String sk; // METADATA + private String token; + private String roomId; + private String userId; + private String createdAt; + private Long ttl; // 자동 만료 + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { + return pk; + } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { + return sk; + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatMessageRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatMessageRepository.java index fc4eef68..9f179e66 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatMessageRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatMessageRepository.java @@ -1,5 +1,8 @@ package com.mzc.secondproject.serverless.domain.chatting.repository; +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.common.util.CursorUtil; import com.mzc.secondproject.serverless.domain.chatting.model.ChatMessage; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -11,107 +14,104 @@ import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; import software.amazon.awssdk.services.dynamodb.model.AttributeValue; -import com.mzc.secondproject.serverless.common.dto.PaginatedResult; -import com.mzc.secondproject.serverless.common.config.AwsClients; -import com.mzc.secondproject.serverless.common.util.CursorUtil; - import java.util.List; import java.util.Map; import java.util.Optional; public class ChatMessageRepository { - - private static final Logger logger = LoggerFactory.getLogger(ChatMessageRepository.class); - private static final String TABLE_NAME = System.getenv("CHAT_TABLE_NAME"); - - private final DynamoDbTable table; - - public ChatMessageRepository() { - this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(ChatMessage.class)); - } - - public ChatMessage save(ChatMessage message) { - logger.info("Saving message to DynamoDB: {}", message.getMessageId()); - table.putItem(message); - return message; - } - - public Optional findByRoomIdAndMessageId(String roomId, String messageId) { - // GSI2를 사용하여 messageId로 직접 조회 (풀스캔 방지) - QueryConditional queryConditional = QueryConditional - .keyEqualTo(Key.builder() - .partitionValue("MSG#" + messageId) - .sortValue("ROOM#" + roomId) - .build()); - - return table.index("GSI2") - .query(queryConditional) - .stream() - .flatMap(page -> page.items().stream()) - .findFirst(); - } - - /** - * 채팅방 메시지 조회 - 최신순, 페이지네이션 지원 - * @param roomId 채팅방 ID - * @param limit 조회 개수 (기본 20) - * @param cursor Base64 인코딩된 커서 (무한스크롤용) - * @return 메시지 목록과 다음 페이지 커서 - */ - public PaginatedResult findByRoomIdWithPagination(String roomId, int limit, String cursor) { - QueryConditional queryConditional = QueryConditional - .sortBeginsWith(Key.builder() - .partitionValue("ROOM#" + roomId) - .sortValue("MSG#") - .build()); - - QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() - .queryConditional(queryConditional) - .scanIndexForward(false) // 최신순 (역순) - .limit(limit); - - // 커서 기반 페이지네이션 (Base64 디코딩) - if (cursor != null && !cursor.isEmpty()) { - Map exclusiveStartKey = CursorUtil.decode(cursor); - if (exclusiveStartKey != null) { - requestBuilder.exclusiveStartKey(exclusiveStartKey); - } - } - - Page page = table.query(requestBuilder.build()).iterator().next(); - List messages = page.items(); - - // 다음 페이지 커서 (Base64 인코딩) - String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); - - return new PaginatedResult<>(messages, nextCursor); - } - - /** - * 사용자별 메시지 조회 - 페이지네이션 지원 (OOM 방지) - */ - public PaginatedResult findByUserIdWithPagination(String userId, int limit, String cursor) { - QueryConditional queryConditional = QueryConditional - .keyEqualTo(Key.builder().partitionValue("USER#" + userId).build()); - - QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() - .queryConditional(queryConditional) - .scanIndexForward(false) // 최신순 - .limit(limit); - - if (cursor != null && !cursor.isEmpty()) { - Map exclusiveStartKey = CursorUtil.decode(cursor); - if (exclusiveStartKey != null) { - requestBuilder.exclusiveStartKey(exclusiveStartKey); - } - } - - Page page = table.index("GSI1") - .query(requestBuilder.build()) - .iterator() - .next(); - - String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); - return new PaginatedResult<>(page.items(), nextCursor); - } + + private static final Logger logger = LoggerFactory.getLogger(ChatMessageRepository.class); + private static final String TABLE_NAME = System.getenv("CHAT_TABLE_NAME"); + + private final DynamoDbTable table; + + public ChatMessageRepository() { + this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(ChatMessage.class)); + } + + public ChatMessage save(ChatMessage message) { + logger.info("Saving message to DynamoDB: {}", message.getMessageId()); + table.putItem(message); + return message; + } + + public Optional findByRoomIdAndMessageId(String roomId, String messageId) { + // GSI2를 사용하여 messageId로 직접 조회 (풀스캔 방지) + QueryConditional queryConditional = QueryConditional + .keyEqualTo(Key.builder() + .partitionValue("MSG#" + messageId) + .sortValue("ROOM#" + roomId) + .build()); + + return table.index("GSI2") + .query(queryConditional) + .stream() + .flatMap(page -> page.items().stream()) + .findFirst(); + } + + /** + * 채팅방 메시지 조회 - 최신순, 페이지네이션 지원 + * + * @param roomId 채팅방 ID + * @param limit 조회 개수 (기본 20) + * @param cursor Base64 인코딩된 커서 (무한스크롤용) + * @return 메시지 목록과 다음 페이지 커서 + */ + public PaginatedResult findByRoomIdWithPagination(String roomId, int limit, String cursor) { + QueryConditional queryConditional = QueryConditional + .sortBeginsWith(Key.builder() + .partitionValue("ROOM#" + roomId) + .sortValue("MSG#") + .build()); + + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .scanIndexForward(false) // 최신순 (역순) + .limit(limit); + + // 커서 기반 페이지네이션 (Base64 디코딩) + if (cursor != null && !cursor.isEmpty()) { + Map exclusiveStartKey = CursorUtil.decode(cursor); + if (exclusiveStartKey != null) { + requestBuilder.exclusiveStartKey(exclusiveStartKey); + } + } + + Page page = table.query(requestBuilder.build()).iterator().next(); + List messages = page.items(); + + // 다음 페이지 커서 (Base64 인코딩) + String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); + + return new PaginatedResult<>(messages, nextCursor); + } + + /** + * 사용자별 메시지 조회 - 페이지네이션 지원 (OOM 방지) + */ + public PaginatedResult findByUserIdWithPagination(String userId, int limit, String cursor) { + QueryConditional queryConditional = QueryConditional + .keyEqualTo(Key.builder().partitionValue("USER#" + userId).build()); + + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .scanIndexForward(false) // 최신순 + .limit(limit); + + if (cursor != null && !cursor.isEmpty()) { + Map exclusiveStartKey = CursorUtil.decode(cursor); + if (exclusiveStartKey != null) { + requestBuilder.exclusiveStartKey(exclusiveStartKey); + } + } + + Page page = table.index("GSI1") + .query(requestBuilder.build()) + .iterator() + .next(); + + String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); + return new PaginatedResult<>(page.items(), nextCursor); + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatRoomRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatRoomRepository.java index 695e261b..1546a091 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatRoomRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatRoomRepository.java @@ -1,5 +1,8 @@ package com.mzc.secondproject.serverless.domain.chatting.repository; +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.common.util.CursorUtil; import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -13,136 +16,133 @@ import software.amazon.awssdk.services.dynamodb.model.AttributeValue; import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest; -import com.mzc.secondproject.serverless.common.dto.PaginatedResult; -import com.mzc.secondproject.serverless.common.config.AwsClients; -import com.mzc.secondproject.serverless.common.util.CursorUtil; - import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; public class ChatRoomRepository { - - private static final Logger logger = LoggerFactory.getLogger(ChatRoomRepository.class); - private static final String TABLE_NAME = System.getenv("CHAT_TABLE_NAME"); - - private final DynamoDbTable table; - - public ChatRoomRepository() { - this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(ChatRoom.class)); - } - - public ChatRoom save(ChatRoom room) { - logger.info("Saving room to DynamoDB: {}", room.getRoomId()); - table.putItem(room); - return room; - } - - public Optional findById(String roomId) { - Key key = Key.builder() - .partitionValue("ROOM#" + roomId) - .sortValue("METADATA") - .build(); - - ChatRoom room = table.getItem(key); - return Optional.ofNullable(room); - } - - /** - * 채팅방 목록 조회 - 최신순, 페이지네이션 지원 - * @param limit 조회 개수 (기본 10) - * @param cursor Base64 인코딩된 커서 (무한스크롤용) - * @return 채팅방 목록과 다음 페이지 커서 - */ - public PaginatedResult findAllWithPagination(int limit, String cursor) { - QueryConditional queryConditional = QueryConditional - .keyEqualTo(Key.builder().partitionValue("ROOMS").build()); - - QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() - .queryConditional(queryConditional) - .scanIndexForward(false) // 최신순 (역순) - .limit(limit); - - // 커서 기반 페이지네이션 (Base64 디코딩) - if (cursor != null && !cursor.isEmpty()) { - Map exclusiveStartKey = CursorUtil.decode(cursor); - if (exclusiveStartKey != null) { - requestBuilder.exclusiveStartKey(exclusiveStartKey); - } - } - - DynamoDbIndex gsi1 = table.index("GSI1"); - Page page = gsi1.query(requestBuilder.build()).iterator().next(); - List rooms = page.items(); - - // 다음 페이지 커서 (Base64 인코딩) - String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); - - return new PaginatedResult<>(rooms, nextCursor); - } - - /** - * 레벨별 채팅방 조회 - 최신순, 페이지네이션 지원 - */ - public PaginatedResult findByLevelWithPagination(String level, int limit, String cursor) { - QueryConditional queryConditional = QueryConditional - .sortBeginsWith(Key.builder() - .partitionValue("ROOMS") - .sortValue(level + "#") - .build()); - - QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() - .queryConditional(queryConditional) - .scanIndexForward(false) // 최신순 - .limit(limit); - - if (cursor != null && !cursor.isEmpty()) { - Map exclusiveStartKey = CursorUtil.decode(cursor); - if (exclusiveStartKey != null) { - requestBuilder.exclusiveStartKey(exclusiveStartKey); - } - } - - DynamoDbIndex gsi1 = table.index("GSI1"); - Page page = gsi1.query(requestBuilder.build()).iterator().next(); - List rooms = page.items(); - - String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); - - return new PaginatedResult<>(rooms, nextCursor); - } - - public void delete(String roomId) { - Key key = Key.builder() - .partitionValue("ROOM#" + roomId) - .sortValue("METADATA") - .build(); - - table.deleteItem(key); - logger.info("Deleted room: {}", roomId); - } - - /** - * 채팅방 lastMessageAt 업데이트 (N+1 방지 - UpdateExpression 사용) - */ - public void updateLastMessageAt(String roomId, String timestamp) { - Map key = new HashMap<>(); - key.put("PK", AttributeValue.builder().s("ROOM#" + roomId).build()); - key.put("SK", AttributeValue.builder().s("METADATA").build()); - - Map expressionValues = new HashMap<>(); - expressionValues.put(":ts", AttributeValue.builder().s(timestamp).build()); - - UpdateItemRequest updateRequest = UpdateItemRequest.builder() - .tableName(TABLE_NAME) - .key(key) - .updateExpression("SET lastMessageAt = :ts") - .expressionAttributeValues(expressionValues) - .build(); - - AwsClients.dynamoDb().updateItem(updateRequest); - logger.info("Updated lastMessageAt for room: {}", roomId); - } - + + private static final Logger logger = LoggerFactory.getLogger(ChatRoomRepository.class); + private static final String TABLE_NAME = System.getenv("CHAT_TABLE_NAME"); + + private final DynamoDbTable table; + + public ChatRoomRepository() { + this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(ChatRoom.class)); + } + + public ChatRoom save(ChatRoom room) { + logger.info("Saving room to DynamoDB: {}", room.getRoomId()); + table.putItem(room); + return room; + } + + public Optional findById(String roomId) { + Key key = Key.builder() + .partitionValue("ROOM#" + roomId) + .sortValue("METADATA") + .build(); + + ChatRoom room = table.getItem(key); + return Optional.ofNullable(room); + } + + /** + * 채팅방 목록 조회 - 최신순, 페이지네이션 지원 + * + * @param limit 조회 개수 (기본 10) + * @param cursor Base64 인코딩된 커서 (무한스크롤용) + * @return 채팅방 목록과 다음 페이지 커서 + */ + public PaginatedResult findAllWithPagination(int limit, String cursor) { + QueryConditional queryConditional = QueryConditional + .keyEqualTo(Key.builder().partitionValue("ROOMS").build()); + + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .scanIndexForward(false) // 최신순 (역순) + .limit(limit); + + // 커서 기반 페이지네이션 (Base64 디코딩) + if (cursor != null && !cursor.isEmpty()) { + Map exclusiveStartKey = CursorUtil.decode(cursor); + if (exclusiveStartKey != null) { + requestBuilder.exclusiveStartKey(exclusiveStartKey); + } + } + + DynamoDbIndex gsi1 = table.index("GSI1"); + Page page = gsi1.query(requestBuilder.build()).iterator().next(); + List rooms = page.items(); + + // 다음 페이지 커서 (Base64 인코딩) + String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); + + return new PaginatedResult<>(rooms, nextCursor); + } + + /** + * 레벨별 채팅방 조회 - 최신순, 페이지네이션 지원 + */ + public PaginatedResult findByLevelWithPagination(String level, int limit, String cursor) { + QueryConditional queryConditional = QueryConditional + .sortBeginsWith(Key.builder() + .partitionValue("ROOMS") + .sortValue(level + "#") + .build()); + + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .scanIndexForward(false) // 최신순 + .limit(limit); + + if (cursor != null && !cursor.isEmpty()) { + Map exclusiveStartKey = CursorUtil.decode(cursor); + if (exclusiveStartKey != null) { + requestBuilder.exclusiveStartKey(exclusiveStartKey); + } + } + + DynamoDbIndex gsi1 = table.index("GSI1"); + Page page = gsi1.query(requestBuilder.build()).iterator().next(); + List rooms = page.items(); + + String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); + + return new PaginatedResult<>(rooms, nextCursor); + } + + public void delete(String roomId) { + Key key = Key.builder() + .partitionValue("ROOM#" + roomId) + .sortValue("METADATA") + .build(); + + table.deleteItem(key); + logger.info("Deleted room: {}", roomId); + } + + /** + * 채팅방 lastMessageAt 업데이트 (N+1 방지 - UpdateExpression 사용) + */ + public void updateLastMessageAt(String roomId, String timestamp) { + Map key = new HashMap<>(); + key.put("PK", AttributeValue.builder().s("ROOM#" + roomId).build()); + key.put("SK", AttributeValue.builder().s("METADATA").build()); + + Map expressionValues = new HashMap<>(); + expressionValues.put(":ts", AttributeValue.builder().s(timestamp).build()); + + UpdateItemRequest updateRequest = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(key) + .updateExpression("SET lastMessageAt = :ts") + .expressionAttributeValues(expressionValues) + .build(); + + AwsClients.dynamoDb().updateItem(updateRequest); + logger.info("Updated lastMessageAt for room: {}", roomId); + } + } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ConnectionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ConnectionRepository.java index 0bd599c2..bcdc6a3a 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ConnectionRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ConnectionRepository.java @@ -16,82 +16,82 @@ import java.util.stream.Collectors; public class ConnectionRepository { - - private static final Logger logger = LoggerFactory.getLogger(ConnectionRepository.class); - private static final String TABLE_NAME = System.getenv("CHAT_TABLE_NAME"); - - private final DynamoDbTable table; - - public ConnectionRepository() { - this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(Connection.class)); - } - - public Connection save(Connection connection) { - logger.info("Saving connection: {} for user: {} in room: {}", - connection.getConnectionId(), connection.getUserId(), connection.getRoomId()); - table.putItem(connection); - return connection; - } - - public void delete(String connectionId) { - Key key = Key.builder() - .partitionValue("CONN#" + connectionId) - .sortValue("METADATA") - .build(); - - table.deleteItem(key); - logger.info("Deleted connection: {}", connectionId); - } - - public Optional findByConnectionId(String connectionId) { - Key key = Key.builder() - .partitionValue("CONN#" + connectionId) - .sortValue("METADATA") - .build(); - - Connection connection = table.getItem(key); - return Optional.ofNullable(connection); - } - - /** - * 채팅방의 모든 연결 조회 (브로드캐스트용) - * GSI1: ROOM#{roomId}로 조회 - */ - public List findByRoomId(String roomId) { - QueryConditional queryConditional = QueryConditional - .keyEqualTo(Key.builder() - .partitionValue("ROOM#" + roomId) - .build()); - - QueryEnhancedRequest request = QueryEnhancedRequest.builder() - .queryConditional(queryConditional) - .build(); - - DynamoDbIndex gsi1 = table.index("GSI1"); - - return gsi1.query(request).stream() - .flatMap(page -> page.items().stream()) - .collect(Collectors.toList()); - } - - /** - * 사용자의 모든 연결 조회 (다중 기기 지원) - * GSI2: USER#{userId}로 조회 - */ - public List findByUserId(String userId) { - QueryConditional queryConditional = QueryConditional - .keyEqualTo(Key.builder() - .partitionValue("USER#" + userId) - .build()); - - QueryEnhancedRequest request = QueryEnhancedRequest.builder() - .queryConditional(queryConditional) - .build(); - - DynamoDbIndex gsi2 = table.index("GSI2"); - - return gsi2.query(request).stream() - .flatMap(page -> page.items().stream()) - .collect(Collectors.toList()); - } -} \ No newline at end of file + + private static final Logger logger = LoggerFactory.getLogger(ConnectionRepository.class); + private static final String TABLE_NAME = System.getenv("CHAT_TABLE_NAME"); + + private final DynamoDbTable table; + + public ConnectionRepository() { + this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(Connection.class)); + } + + public Connection save(Connection connection) { + logger.info("Saving connection: {} for user: {} in room: {}", + connection.getConnectionId(), connection.getUserId(), connection.getRoomId()); + table.putItem(connection); + return connection; + } + + public void delete(String connectionId) { + Key key = Key.builder() + .partitionValue("CONN#" + connectionId) + .sortValue("METADATA") + .build(); + + table.deleteItem(key); + logger.info("Deleted connection: {}", connectionId); + } + + public Optional findByConnectionId(String connectionId) { + Key key = Key.builder() + .partitionValue("CONN#" + connectionId) + .sortValue("METADATA") + .build(); + + Connection connection = table.getItem(key); + return Optional.ofNullable(connection); + } + + /** + * 채팅방의 모든 연결 조회 (브로드캐스트용) + * GSI1: ROOM#{roomId}로 조회 + */ + public List findByRoomId(String roomId) { + QueryConditional queryConditional = QueryConditional + .keyEqualTo(Key.builder() + .partitionValue("ROOM#" + roomId) + .build()); + + QueryEnhancedRequest request = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .build(); + + DynamoDbIndex gsi1 = table.index("GSI1"); + + return gsi1.query(request).stream() + .flatMap(page -> page.items().stream()) + .collect(Collectors.toList()); + } + + /** + * 사용자의 모든 연결 조회 (다중 기기 지원) + * GSI2: USER#{userId}로 조회 + */ + public List findByUserId(String userId) { + QueryConditional queryConditional = QueryConditional + .keyEqualTo(Key.builder() + .partitionValue("USER#" + userId) + .build()); + + QueryEnhancedRequest request = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .build(); + + DynamoDbIndex gsi2 = table.index("GSI2"); + + return gsi2.query(request).stream() + .flatMap(page -> page.items().stream()) + .collect(Collectors.toList()); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/RoomTokenRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/RoomTokenRepository.java index a071dc3f..41ad0fdd 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/RoomTokenRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/RoomTokenRepository.java @@ -11,40 +11,40 @@ import java.util.Optional; public class RoomTokenRepository { - - private static final Logger logger = LoggerFactory.getLogger(RoomTokenRepository.class); - private static final String TABLE_NAME = System.getenv("CHAT_TABLE_NAME"); - - private final DynamoDbTable table; - - public RoomTokenRepository() { - this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(RoomToken.class)); - } - - public RoomToken save(RoomToken roomToken) { - logger.info("Saving room token for user: {} in room: {}", - roomToken.getUserId(), roomToken.getRoomId()); - table.putItem(roomToken); - return roomToken; - } - - public void delete(String token) { - Key key = Key.builder() - .partitionValue("TOKEN#" + token) - .sortValue("METADATA") - .build(); - - table.deleteItem(key); - logger.info("Deleted room token: {}", token); - } - - public Optional findByToken(String token) { - Key key = Key.builder() - .partitionValue("TOKEN#" + token) - .sortValue("METADATA") - .build(); - - RoomToken roomToken = table.getItem(key); - return Optional.ofNullable(roomToken); - } + + private static final Logger logger = LoggerFactory.getLogger(RoomTokenRepository.class); + private static final String TABLE_NAME = System.getenv("CHAT_TABLE_NAME"); + + private final DynamoDbTable table; + + public RoomTokenRepository() { + this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(RoomToken.class)); + } + + public RoomToken save(RoomToken roomToken) { + logger.info("Saving room token for user: {} in room: {}", + roomToken.getUserId(), roomToken.getRoomId()); + table.putItem(roomToken); + return roomToken; + } + + public void delete(String token) { + Key key = Key.builder() + .partitionValue("TOKEN#" + token) + .sortValue("METADATA") + .build(); + + table.deleteItem(key); + logger.info("Deleted room token: {}", token); + } + + public Optional findByToken(String token) { + Key key = Key.builder() + .partitionValue("TOKEN#" + token) + .sortValue("METADATA") + .build(); + + RoomToken roomToken = table.getItem(key); + return Optional.ofNullable(roomToken); + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/BedrockService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/BedrockService.java index 2b92dd79..d4aa5b3b 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/BedrockService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/BedrockService.java @@ -1,5 +1,8 @@ package com.mzc.secondproject.serverless.domain.chatting.service; +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; import com.mzc.secondproject.serverless.common.config.AwsClients; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -7,58 +10,54 @@ import software.amazon.awssdk.services.bedrockruntime.model.InvokeModelRequest; import software.amazon.awssdk.services.bedrockruntime.model.InvokeModelResponse; -import com.google.gson.Gson; -import com.google.gson.JsonArray; -import com.google.gson.JsonObject; - public class BedrockService { - - private static final Logger logger = LoggerFactory.getLogger(BedrockService.class); - private static final Gson gson = new Gson(); - - // Claude 3 Sonnet 모델 ID - private static final String MODEL_ID = "anthropic.claude-3-sonnet-20240229-v1:0"; - - public BedrockService() { - } - - public String generateResponse(String prompt) { - logger.info("Generating AI response for prompt"); - - try { - // Claude 3 Messages API 형식 - JsonObject requestBody = new JsonObject(); - requestBody.addProperty("anthropic_version", "bedrock-2023-05-31"); - requestBody.addProperty("max_tokens", 1024); - - JsonArray messages = new JsonArray(); - JsonObject userMessage = new JsonObject(); - userMessage.addProperty("role", "user"); - userMessage.addProperty("content", prompt); - messages.add(userMessage); - - requestBody.add("messages", messages); - - InvokeModelRequest request = InvokeModelRequest.builder() - .modelId(MODEL_ID) - .contentType("application/json") - .accept("application/json") - .body(SdkBytes.fromUtf8String(gson.toJson(requestBody))) - .build(); - - InvokeModelResponse response = AwsClients.bedrock().invokeModel(request); - - String responseBody = response.body().asUtf8String(); - JsonObject jsonResponse = gson.fromJson(responseBody, JsonObject.class); - - // Claude 3 응답에서 텍스트 추출 - return jsonResponse.getAsJsonArray("content") - .get(0).getAsJsonObject() - .get("text").getAsString(); - - } catch (Exception e) { - logger.error("Error calling Bedrock", e); - throw new RuntimeException("Failed to generate AI response", e); - } - } + + private static final Logger logger = LoggerFactory.getLogger(BedrockService.class); + private static final Gson gson = new Gson(); + + // Claude 3 Sonnet 모델 ID + private static final String MODEL_ID = "anthropic.claude-3-sonnet-20240229-v1:0"; + + public BedrockService() { + } + + public String generateResponse(String prompt) { + logger.info("Generating AI response for prompt"); + + try { + // Claude 3 Messages API 형식 + JsonObject requestBody = new JsonObject(); + requestBody.addProperty("anthropic_version", "bedrock-2023-05-31"); + requestBody.addProperty("max_tokens", 1024); + + JsonArray messages = new JsonArray(); + JsonObject userMessage = new JsonObject(); + userMessage.addProperty("role", "user"); + userMessage.addProperty("content", prompt); + messages.add(userMessage); + + requestBody.add("messages", messages); + + InvokeModelRequest request = InvokeModelRequest.builder() + .modelId(MODEL_ID) + .contentType("application/json") + .accept("application/json") + .body(SdkBytes.fromUtf8String(gson.toJson(requestBody))) + .build(); + + InvokeModelResponse response = AwsClients.bedrock().invokeModel(request); + + String responseBody = response.body().asUtf8String(); + JsonObject jsonResponse = gson.fromJson(responseBody, JsonObject.class); + + // Claude 3 응답에서 텍스트 추출 + return jsonResponse.getAsJsonArray("content") + .get(0).getAsJsonObject() + .get("text").getAsString(); + + } catch (Exception e) { + logger.error("Error calling Bedrock", e); + throw new RuntimeException("Failed to generate AI response", e); + } + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatMessageService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatMessageService.java index ec6f1075..1bc1c594 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatMessageService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatMessageService.java @@ -6,36 +6,35 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.util.List; import java.util.Optional; public class ChatMessageService { - - private static final Logger logger = LoggerFactory.getLogger(ChatMessageService.class); - - private final ChatMessageRepository repository; - - public ChatMessageService() { - this.repository = new ChatMessageRepository(); - } - - public ChatMessage saveMessage(ChatMessage message) { - logger.info("Saving message: {}", message.getMessageId()); - return repository.save(message); - } - - public Optional getMessage(String roomId, String messageId) { - logger.info("Getting message: {} from room: {}", messageId, roomId); - return repository.findByRoomIdAndMessageId(roomId, messageId); - } - - public PaginatedResult getMessagesByRoomWithPagination(String roomId, int limit, String cursor) { - logger.info("Getting messages for room: {} with limit: {}", roomId, limit); - return repository.findByRoomIdWithPagination(roomId, limit, cursor); - } - - public PaginatedResult getMessagesByUserWithPagination(String userId, int limit, String cursor) { - logger.info("Getting messages for user: {} with limit: {}", userId, limit); - return repository.findByUserIdWithPagination(userId, limit, cursor); - } + + private static final Logger logger = LoggerFactory.getLogger(ChatMessageService.class); + + private final ChatMessageRepository repository; + + public ChatMessageService() { + this.repository = new ChatMessageRepository(); + } + + public ChatMessage saveMessage(ChatMessage message) { + logger.info("Saving message: {}", message.getMessageId()); + return repository.save(message); + } + + public Optional getMessage(String roomId, String messageId) { + logger.info("Getting message: {} from room: {}", messageId, roomId); + return repository.findByRoomIdAndMessageId(roomId, messageId); + } + + public PaginatedResult getMessagesByRoomWithPagination(String roomId, int limit, String cursor) { + logger.info("Getting messages for room: {} with limit: {}", roomId, limit); + return repository.findByRoomIdWithPagination(roomId, limit, cursor); + } + + public PaginatedResult getMessagesByUserWithPagination(String userId, int limit, String cursor) { + logger.info("Getting messages for user: {} with limit: {}", userId, limit); + return repository.findByUserIdWithPagination(userId, limit, cursor); + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomCommandService.java index 3bec7660..6c167ebc 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomCommandService.java @@ -19,127 +19,128 @@ * ChatRoom 변경 전용 서비스 (CQRS Command) */ public class ChatRoomCommandService { - - private static final Logger logger = LoggerFactory.getLogger(ChatRoomCommandService.class); - - private final ChatRoomRepository roomRepository; - private final RoomTokenService roomTokenService; - - public ChatRoomCommandService() { - this.roomRepository = new ChatRoomRepository(); - this.roomTokenService = new RoomTokenService(); - } - - public ChatRoom createRoom(String name, String description, String level, Integer maxMembers, - Boolean isPrivate, String password, String createdBy) { - String roomId = UUID.randomUUID().toString(); - String now = Instant.now().toString(); - - ChatRoom room = ChatRoom.builder() - .pk("ROOM#" + roomId) - .sk("METADATA") - .gsi1pk("ROOMS") - .gsi1sk(level + "#" + now) - .roomId(roomId) - .name(name) - .description(description) - .level(level) - .currentMembers(1) - .maxMembers(maxMembers) - .isPrivate(isPrivate) - .password(isPrivate && password != null ? BCrypt.hashpw(password, BCrypt.gensalt()) : null) - .createdBy(createdBy) - .createdAt(now) - .lastMessageAt(now) - .memberIds(new ArrayList<>(List.of(createdBy))) - .build(); - - roomRepository.save(room); - logger.info("Created room: {}", roomId); - - return room; - } - - public JoinRoomResponse joinRoom(String roomId, String userId, String password) { - Optional optRoom = roomRepository.findById(roomId); - if (optRoom.isEmpty()) { - throw ChattingException.roomNotFound(roomId); - } - - ChatRoom room = optRoom.get(); - - if (room.getIsPrivate()) { - if (password == null || room.getPassword() == null || !BCrypt.checkpw(password, room.getPassword())) { - throw ChattingException.roomInvalidPassword(roomId); - } - } - - if (room.getCurrentMembers() >= room.getMaxMembers()) { - throw ChattingException.roomFull(roomId, room.getMaxMembers()); - } - - boolean alreadyMember = room.getMemberIds() != null && room.getMemberIds().contains(userId); - if (!alreadyMember) { - if (room.getMemberIds() == null) { - room.setMemberIds(new ArrayList<>()); - } - room.getMemberIds().add(userId); - room.setCurrentMembers(room.getCurrentMembers() + 1); - roomRepository.save(room); - logger.info("User {} joined room {}", userId, roomId); - } else { - logger.info("User {} already in room {}", userId, roomId); - } - - // 토큰 발급 - RoomToken token = roomTokenService.generateToken(roomId, userId); - - return JoinRoomResponse.builder() - .room(room) - .roomToken(token.getToken()) - .tokenExpiresAt(token.getTtl()) - .build(); - } - - public LeaveResult leaveRoom(String roomId, String userId) { - Optional optRoom = roomRepository.findById(roomId); - if (optRoom.isEmpty()) { - throw ChattingException.roomNotFound(roomId); - } - - ChatRoom room = optRoom.get(); - - if (room.getMemberIds() != null) { - room.getMemberIds().remove(userId); - room.setCurrentMembers(Math.max(0, room.getCurrentMembers() - 1)); - } - - if (userId.equals(room.getCreatedBy()) || room.getCurrentMembers() <= 0) { - roomRepository.delete(roomId); - logger.info("Room {} deleted (owner left or empty)", roomId); - return new LeaveResult(true, null); - } - - roomRepository.save(room); - logger.info("User {} left room {}", userId, roomId); - - return new LeaveResult(false, room); - } - - public void deleteRoom(String roomId, String userId) { - Optional optRoom = roomRepository.findById(roomId); - if (optRoom.isEmpty()) { - throw ChattingException.roomNotFound(roomId); - } - - ChatRoom room = optRoom.get(); - if (!userId.equals(room.getCreatedBy())) { - throw ChattingException.roomNotOwner(userId, roomId); - } - - roomRepository.delete(roomId); - logger.info("Deleted room: {} by owner: {}", roomId, userId); - } - - public record LeaveResult(boolean deleted, ChatRoom room) {} + + private static final Logger logger = LoggerFactory.getLogger(ChatRoomCommandService.class); + + private final ChatRoomRepository roomRepository; + private final RoomTokenService roomTokenService; + + public ChatRoomCommandService() { + this.roomRepository = new ChatRoomRepository(); + this.roomTokenService = new RoomTokenService(); + } + + public ChatRoom createRoom(String name, String description, String level, Integer maxMembers, + Boolean isPrivate, String password, String createdBy) { + String roomId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + + ChatRoom room = ChatRoom.builder() + .pk("ROOM#" + roomId) + .sk("METADATA") + .gsi1pk("ROOMS") + .gsi1sk(level + "#" + now) + .roomId(roomId) + .name(name) + .description(description) + .level(level) + .currentMembers(1) + .maxMembers(maxMembers) + .isPrivate(isPrivate) + .password(isPrivate && password != null ? BCrypt.hashpw(password, BCrypt.gensalt()) : null) + .createdBy(createdBy) + .createdAt(now) + .lastMessageAt(now) + .memberIds(new ArrayList<>(List.of(createdBy))) + .build(); + + roomRepository.save(room); + logger.info("Created room: {}", roomId); + + return room; + } + + public JoinRoomResponse joinRoom(String roomId, String userId, String password) { + Optional optRoom = roomRepository.findById(roomId); + if (optRoom.isEmpty()) { + throw ChattingException.roomNotFound(roomId); + } + + ChatRoom room = optRoom.get(); + + if (room.getIsPrivate()) { + if (password == null || room.getPassword() == null || !BCrypt.checkpw(password, room.getPassword())) { + throw ChattingException.roomInvalidPassword(roomId); + } + } + + if (room.getCurrentMembers() >= room.getMaxMembers()) { + throw ChattingException.roomFull(roomId, room.getMaxMembers()); + } + + boolean alreadyMember = room.getMemberIds() != null && room.getMemberIds().contains(userId); + if (!alreadyMember) { + if (room.getMemberIds() == null) { + room.setMemberIds(new ArrayList<>()); + } + room.getMemberIds().add(userId); + room.setCurrentMembers(room.getCurrentMembers() + 1); + roomRepository.save(room); + logger.info("User {} joined room {}", userId, roomId); + } else { + logger.info("User {} already in room {}", userId, roomId); + } + + // 토큰 발급 + RoomToken token = roomTokenService.generateToken(roomId, userId); + + return JoinRoomResponse.builder() + .room(room) + .roomToken(token.getToken()) + .tokenExpiresAt(token.getTtl()) + .build(); + } + + public LeaveResult leaveRoom(String roomId, String userId) { + Optional optRoom = roomRepository.findById(roomId); + if (optRoom.isEmpty()) { + throw ChattingException.roomNotFound(roomId); + } + + ChatRoom room = optRoom.get(); + + if (room.getMemberIds() != null) { + room.getMemberIds().remove(userId); + room.setCurrentMembers(Math.max(0, room.getCurrentMembers() - 1)); + } + + if (userId.equals(room.getCreatedBy()) || room.getCurrentMembers() <= 0) { + roomRepository.delete(roomId); + logger.info("Room {} deleted (owner left or empty)", roomId); + return new LeaveResult(true, null); + } + + roomRepository.save(room); + logger.info("User {} left room {}", userId, roomId); + + return new LeaveResult(false, room); + } + + public void deleteRoom(String roomId, String userId) { + Optional optRoom = roomRepository.findById(roomId); + if (optRoom.isEmpty()) { + throw ChattingException.roomNotFound(roomId); + } + + ChatRoom room = optRoom.get(); + if (!userId.equals(room.getCreatedBy())) { + throw ChattingException.roomNotOwner(userId, roomId); + } + + roomRepository.delete(roomId); + logger.info("Deleted room: {} by owner: {}", roomId, userId); + } + + public record LeaveResult(boolean deleted, ChatRoom room) { + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomQueryService.java index 72762903..d99657db 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomQueryService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomQueryService.java @@ -13,29 +13,29 @@ * ChatRoom 조회 전용 서비스 (CQRS Query) */ public class ChatRoomQueryService { - - private static final Logger logger = LoggerFactory.getLogger(ChatRoomQueryService.class); - - private final ChatRoomRepository roomRepository; - - public ChatRoomQueryService() { - this.roomRepository = new ChatRoomRepository(); - } - - public Optional getRoom(String roomId) { - return roomRepository.findById(roomId); - } - - public PaginatedResult getRooms(String level, int limit, String cursor) { - if (level != null && !level.isEmpty()) { - return roomRepository.findByLevelWithPagination(level, limit, cursor); - } - return roomRepository.findAllWithPagination(limit, cursor); - } - - public List filterByJoinedUser(List rooms, String userId) { - return rooms.stream() - .filter(room -> room.getMemberIds() != null && room.getMemberIds().contains(userId)) - .toList(); - } + + private static final Logger logger = LoggerFactory.getLogger(ChatRoomQueryService.class); + + private final ChatRoomRepository roomRepository; + + public ChatRoomQueryService() { + this.roomRepository = new ChatRoomRepository(); + } + + public Optional getRoom(String roomId) { + return roomRepository.findById(roomId); + } + + public PaginatedResult getRooms(String level, int limit, String cursor) { + if (level != null && !level.isEmpty()) { + return roomRepository.findByLevelWithPagination(level, limit, cursor); + } + return roomRepository.findAllWithPagination(limit, cursor); + } + + public List filterByJoinedUser(List rooms, String userId) { + return rooms.stream() + .filter(room -> room.getMemberIds() != null && room.getMemberIds().contains(userId)) + .toList(); + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomService.java index 0d531c88..4ee1dd0e 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomService.java @@ -15,136 +15,137 @@ import java.util.UUID; public class ChatRoomService { - - private static final Logger logger = LoggerFactory.getLogger(ChatRoomService.class); - - private final ChatRoomRepository roomRepository; - - public ChatRoomService() { - this.roomRepository = new ChatRoomRepository(); - } - - public ChatRoom createRoom(String name, String description, String level, Integer maxMembers, - Boolean isPrivate, String password, String createdBy) { - String roomId = UUID.randomUUID().toString(); - String now = Instant.now().toString(); - - ChatRoom room = ChatRoom.builder() - .pk("ROOM#" + roomId) - .sk("METADATA") - .gsi1pk("ROOMS") - .gsi1sk(level + "#" + now) - .roomId(roomId) - .name(name) - .description(description) - .level(level) - .currentMembers(1) - .maxMembers(maxMembers) - .isPrivate(isPrivate) - .password(isPrivate && password != null ? BCrypt.hashpw(password, BCrypt.gensalt()) : null) - .createdBy(createdBy) - .createdAt(now) - .lastMessageAt(now) - .memberIds(new ArrayList<>(List.of(createdBy))) - .build(); - - roomRepository.save(room); - logger.info("Created room: {}", roomId); - - return room; - } - - public Optional getRoom(String roomId) { - return roomRepository.findById(roomId); - } - - public PaginatedResult getRooms(String level, int limit, String cursor) { - if (level != null && !level.isEmpty()) { - return roomRepository.findByLevelWithPagination(level, limit, cursor); - } - return roomRepository.findAllWithPagination(limit, cursor); - } - - public List filterByJoinedUser(List rooms, String userId) { - return rooms.stream() - .filter(room -> room.getMemberIds() != null && room.getMemberIds().contains(userId)) - .toList(); - } - - public ChatRoom joinRoom(String roomId, String userId, String password) { - Optional optRoom = roomRepository.findById(roomId); - if (optRoom.isEmpty()) { - throw ChattingException.roomNotFound(roomId); - } - - ChatRoom room = optRoom.get(); - - if (room.getIsPrivate()) { - if (password == null || room.getPassword() == null || !BCrypt.checkpw(password, room.getPassword())) { - throw ChattingException.roomInvalidPassword(roomId); - } - } - - if (room.getCurrentMembers() >= room.getMaxMembers()) { - throw ChattingException.roomFull(roomId, room.getMaxMembers()); - } - - if (room.getMemberIds() != null && room.getMemberIds().contains(userId)) { - logger.info("User {} already in room {}", userId, roomId); - return room; - } - - if (room.getMemberIds() == null) { - room.setMemberIds(new ArrayList<>()); - } - room.getMemberIds().add(userId); - room.setCurrentMembers(room.getCurrentMembers() + 1); - - roomRepository.save(room); - logger.info("User {} joined room {}", userId, roomId); - - return room; - } - - public LeaveResult leaveRoom(String roomId, String userId) { - Optional optRoom = roomRepository.findById(roomId); - if (optRoom.isEmpty()) { - throw ChattingException.roomNotFound(roomId); - } - - ChatRoom room = optRoom.get(); - - if (room.getMemberIds() != null) { - room.getMemberIds().remove(userId); - room.setCurrentMembers(Math.max(0, room.getCurrentMembers() - 1)); - } - - if (userId.equals(room.getCreatedBy()) || room.getCurrentMembers() <= 0) { - roomRepository.delete(roomId); - logger.info("Room {} deleted (owner left or empty)", roomId); - return new LeaveResult(true, null); - } - - roomRepository.save(room); - logger.info("User {} left room {}", userId, roomId); - - return new LeaveResult(false, room); - } - - public void deleteRoom(String roomId, String userId) { - Optional optRoom = roomRepository.findById(roomId); - if (optRoom.isEmpty()) { - throw ChattingException.roomNotFound(roomId); - } - - ChatRoom room = optRoom.get(); - if (!userId.equals(room.getCreatedBy())) { - throw ChattingException.roomNotOwner(userId, roomId); - } - - roomRepository.delete(roomId); - logger.info("Deleted room: {} by owner: {}", roomId, userId); - } - - public record LeaveResult(boolean deleted, ChatRoom room) {} -} \ No newline at end of file + + private static final Logger logger = LoggerFactory.getLogger(ChatRoomService.class); + + private final ChatRoomRepository roomRepository; + + public ChatRoomService() { + this.roomRepository = new ChatRoomRepository(); + } + + public ChatRoom createRoom(String name, String description, String level, Integer maxMembers, + Boolean isPrivate, String password, String createdBy) { + String roomId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + + ChatRoom room = ChatRoom.builder() + .pk("ROOM#" + roomId) + .sk("METADATA") + .gsi1pk("ROOMS") + .gsi1sk(level + "#" + now) + .roomId(roomId) + .name(name) + .description(description) + .level(level) + .currentMembers(1) + .maxMembers(maxMembers) + .isPrivate(isPrivate) + .password(isPrivate && password != null ? BCrypt.hashpw(password, BCrypt.gensalt()) : null) + .createdBy(createdBy) + .createdAt(now) + .lastMessageAt(now) + .memberIds(new ArrayList<>(List.of(createdBy))) + .build(); + + roomRepository.save(room); + logger.info("Created room: {}", roomId); + + return room; + } + + public Optional getRoom(String roomId) { + return roomRepository.findById(roomId); + } + + public PaginatedResult getRooms(String level, int limit, String cursor) { + if (level != null && !level.isEmpty()) { + return roomRepository.findByLevelWithPagination(level, limit, cursor); + } + return roomRepository.findAllWithPagination(limit, cursor); + } + + public List filterByJoinedUser(List rooms, String userId) { + return rooms.stream() + .filter(room -> room.getMemberIds() != null && room.getMemberIds().contains(userId)) + .toList(); + } + + public ChatRoom joinRoom(String roomId, String userId, String password) { + Optional optRoom = roomRepository.findById(roomId); + if (optRoom.isEmpty()) { + throw ChattingException.roomNotFound(roomId); + } + + ChatRoom room = optRoom.get(); + + if (room.getIsPrivate()) { + if (password == null || room.getPassword() == null || !BCrypt.checkpw(password, room.getPassword())) { + throw ChattingException.roomInvalidPassword(roomId); + } + } + + if (room.getCurrentMembers() >= room.getMaxMembers()) { + throw ChattingException.roomFull(roomId, room.getMaxMembers()); + } + + if (room.getMemberIds() != null && room.getMemberIds().contains(userId)) { + logger.info("User {} already in room {}", userId, roomId); + return room; + } + + if (room.getMemberIds() == null) { + room.setMemberIds(new ArrayList<>()); + } + room.getMemberIds().add(userId); + room.setCurrentMembers(room.getCurrentMembers() + 1); + + roomRepository.save(room); + logger.info("User {} joined room {}", userId, roomId); + + return room; + } + + public LeaveResult leaveRoom(String roomId, String userId) { + Optional optRoom = roomRepository.findById(roomId); + if (optRoom.isEmpty()) { + throw ChattingException.roomNotFound(roomId); + } + + ChatRoom room = optRoom.get(); + + if (room.getMemberIds() != null) { + room.getMemberIds().remove(userId); + room.setCurrentMembers(Math.max(0, room.getCurrentMembers() - 1)); + } + + if (userId.equals(room.getCreatedBy()) || room.getCurrentMembers() <= 0) { + roomRepository.delete(roomId); + logger.info("Room {} deleted (owner left or empty)", roomId); + return new LeaveResult(true, null); + } + + roomRepository.save(room); + logger.info("User {} left room {}", userId, roomId); + + return new LeaveResult(false, room); + } + + public void deleteRoom(String roomId, String userId) { + Optional optRoom = roomRepository.findById(roomId); + if (optRoom.isEmpty()) { + throw ChattingException.roomNotFound(roomId); + } + + ChatRoom room = optRoom.get(); + if (!userId.equals(room.getCreatedBy())) { + throw ChattingException.roomNotOwner(userId, roomId); + } + + roomRepository.delete(roomId); + logger.info("Deleted room: {} by owner: {}", roomId, userId); + } + + public record LeaveResult(boolean deleted, ChatRoom room) { + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/RoomTokenService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/RoomTokenService.java index 4b5686e3..ee470cfe 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/RoomTokenService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/RoomTokenService.java @@ -15,73 +15,74 @@ * REST API에서 토큰 발급, WebSocket 연결 시 토큰 검증 */ public class RoomTokenService { - - private static final Logger logger = LoggerFactory.getLogger(RoomTokenService.class); - - private final RoomTokenRepository tokenRepository; - - public RoomTokenService() { - this.tokenRepository = new RoomTokenRepository(); - } - - /** - * 채팅방 입장 토큰 생성 - */ - public RoomToken generateToken(String roomId, String userId) { - String token = UUID.randomUUID().toString(); - String now = Instant.now().toString(); - long ttl = Instant.now().getEpochSecond() + RoomTokenConfig.tokenTtlSeconds(); - - RoomToken roomToken = RoomToken.builder() - .pk("TOKEN#" + token) - .sk("METADATA") - .token(token) - .roomId(roomId) - .userId(userId) - .createdAt(now) - .ttl(ttl) - .build(); - - tokenRepository.save(roomToken); - logger.info("Generated room token for user: {} in room: {}", userId, roomId); - - return roomToken; - } - - /** - * 토큰 검증 및 정보 조회 - * @return 유효한 토큰이면 RoomToken, 그렇지 않으면 empty - */ - public Optional validateToken(String token) { - if (token == null || token.isEmpty()) { - logger.warn("Token is null or empty"); - return Optional.empty(); - } - - Optional optToken = tokenRepository.findByToken(token); - - if (optToken.isEmpty()) { - logger.warn("Token not found: {}", token); - return Optional.empty(); - } - - RoomToken roomToken = optToken.get(); - - // TTL 만료 체크 (DynamoDB TTL 삭제 전 유예 기간 대비) - if (roomToken.getTtl() != null && roomToken.getTtl() < Instant.now().getEpochSecond()) { - logger.warn("Token expired: {}", token); - return Optional.empty(); - } - - logger.info("Token validated for user: {} in room: {}", roomToken.getUserId(), roomToken.getRoomId()); - return Optional.of(roomToken); - } - - /** - * 토큰 삭제 (사용 후 또는 명시적 삭제) - */ - public void deleteToken(String token) { - tokenRepository.delete(token); - logger.info("Deleted room token: {}", token); - } + + private static final Logger logger = LoggerFactory.getLogger(RoomTokenService.class); + + private final RoomTokenRepository tokenRepository; + + public RoomTokenService() { + this.tokenRepository = new RoomTokenRepository(); + } + + /** + * 채팅방 입장 토큰 생성 + */ + public RoomToken generateToken(String roomId, String userId) { + String token = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + long ttl = Instant.now().getEpochSecond() + RoomTokenConfig.tokenTtlSeconds(); + + RoomToken roomToken = RoomToken.builder() + .pk("TOKEN#" + token) + .sk("METADATA") + .token(token) + .roomId(roomId) + .userId(userId) + .createdAt(now) + .ttl(ttl) + .build(); + + tokenRepository.save(roomToken); + logger.info("Generated room token for user: {} in room: {}", userId, roomId); + + return roomToken; + } + + /** + * 토큰 검증 및 정보 조회 + * + * @return 유효한 토큰이면 RoomToken, 그렇지 않으면 empty + */ + public Optional validateToken(String token) { + if (token == null || token.isEmpty()) { + logger.warn("Token is null or empty"); + return Optional.empty(); + } + + Optional optToken = tokenRepository.findByToken(token); + + if (optToken.isEmpty()) { + logger.warn("Token not found: {}", token); + return Optional.empty(); + } + + RoomToken roomToken = optToken.get(); + + // TTL 만료 체크 (DynamoDB TTL 삭제 전 유예 기간 대비) + if (roomToken.getTtl() != null && roomToken.getTtl() < Instant.now().getEpochSecond()) { + logger.warn("Token expired: {}", token); + return Optional.empty(); + } + + logger.info("Token validated for user: {} in room: {}", roomToken.getUserId(), roomToken.getRoomId()); + return Optional.of(roomToken); + } + + /** + * 토큰 삭제 (사용 후 또는 명시적 삭제) + */ + public void deleteToken(String token) { + tokenRepository.delete(token); + logger.info("Deleted room token: {}", token); + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/constants/StatsKey.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/constants/StatsKey.java index 9f5a1ff5..b426a8da 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/constants/StatsKey.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/constants/StatsKey.java @@ -6,36 +6,35 @@ * 학습 통계 도메인 키 상수 */ public final class StatsKey { - - private StatsKey() {} - - // Suffix - public static final String SUFFIX_STATS = "#STATS"; - - // Stats Period Prefixes - public static final String STATS_DAILY = "DAILY#"; - public static final String STATS_WEEKLY = "WEEKLY#"; - public static final String STATS_MONTHLY = "MONTHLY#"; - public static final String STATS_TOTAL = "TOTAL"; - - // Key Builders - public static String userStatsPk(String userId) { - return DynamoDbKey.USER + userId + SUFFIX_STATS; - } - - public static String statsDailySk(String date) { - return STATS_DAILY + date; - } - - public static String statsWeeklySk(String yearWeek) { - return STATS_WEEKLY + yearWeek; - } - - public static String statsMonthlySk(String yearMonth) { - return STATS_MONTHLY + yearMonth; - } - - public static String statsTotalSk() { - return STATS_TOTAL; - } + + // Suffix + public static final String SUFFIX_STATS = "#STATS"; + // Stats Period Prefixes + public static final String STATS_DAILY = "DAILY#"; + public static final String STATS_WEEKLY = "WEEKLY#"; + public static final String STATS_MONTHLY = "MONTHLY#"; + public static final String STATS_TOTAL = "TOTAL"; + private StatsKey() { + } + + // Key Builders + public static String userStatsPk(String userId) { + return DynamoDbKey.USER + userId + SUFFIX_STATS; + } + + public static String statsDailySk(String date) { + return STATS_DAILY + date; + } + + public static String statsWeeklySk(String yearWeek) { + return STATS_WEEKLY + yearWeek; + } + + public static String statsMonthlySk(String yearMonth) { + return STATS_MONTHLY + yearMonth; + } + + public static String statsTotalSk() { + return STATS_TOTAL; + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/ScheduledStatsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/ScheduledStatsHandler.java index c4e6d497..bd2bc1f8 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/ScheduledStatsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/ScheduledStatsHandler.java @@ -3,79 +3,73 @@ import com.amazonaws.services.lambda.runtime.Context; import com.amazonaws.services.lambda.runtime.RequestHandler; import com.amazonaws.services.lambda.runtime.events.ScheduledEvent; -import com.mzc.secondproject.serverless.common.config.AwsClients; import com.mzc.secondproject.serverless.domain.stats.repository.UserStatsRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import software.amazon.awssdk.services.dynamodb.model.AttributeValue; -import software.amazon.awssdk.services.dynamodb.model.QueryRequest; -import software.amazon.awssdk.services.dynamodb.model.QueryResponse; import java.time.LocalDate; -import java.util.HashMap; -import java.util.Map; /** * EventBridge Scheduler Handler * 매일 자정에 실행되어 Streak 리셋만 수행 - * + *

* 단어 학습 통계는 Write-through 방식으로 markWordLearned에서 직접 업데이트 */ public class ScheduledStatsHandler implements RequestHandler { - - private static final Logger logger = LoggerFactory.getLogger(ScheduledStatsHandler.class); - private static final String TABLE_NAME = System.getenv("VOCAB_TABLE_NAME"); - - private final UserStatsRepository userStatsRepository; - - public ScheduledStatsHandler() { - this.userStatsRepository = new UserStatsRepository(); - } - - @Override - public String handleRequest(ScheduledEvent event, Context context) { - logger.info("Scheduled streak check started: {}", event.getTime()); - - try { - String yesterday = LocalDate.now().minusDays(1).toString(); - int resetCount = checkAndResetStreaks(yesterday); - - logger.info("Scheduled streak check completed: {} streaks reset", resetCount); - return "SUCCESS: " + resetCount + " streaks reset"; - } catch (Exception e) { - logger.error("Scheduled streak check failed", e); - return "FAILED: " + e.getMessage(); - } - } - - /** - * Streak 체크 및 리셋 - * GSI를 사용하여 Query로 처리 (Scan 대신) - * - * 어제 학습하지 않은 사용자 중 streak이 있는 사용자만 리셋 - */ - private int checkAndResetStreaks(String yesterday) { - logger.info("Checking streaks for date: {}", yesterday); - - // GSI1을 사용하여 TOTAL 통계 레코드만 조회 - // GSI1PK = "STATS#TOTAL" 으로 설계하면 Query 가능 - // 현재는 GSI가 없으므로 개별 사용자별로 처리하는 방식 사용 - - // 실제로는 lastStudyDate가 어제가 아닌 사용자를 찾아야 함 - // 하지만 현재 구조상 효율적인 방법은: - // 1. 활성 사용자 목록 관리 (별도 테이블/인덱스) - // 2. 또는 클라이언트에서 streak 조회 시 계산 - - // 현재는 간단하게 구현: DailyStudy가 없는 사용자의 streak을 리셋 - // 이는 학습을 한 번이라도 한 사용자 대상 - - int resetCount = 0; - - // Note: 실제 운영에서는 활성 사용자 목록을 별도로 관리하거나 - // GSI를 lastStudyDate로 만들어 Query 하는 것이 효율적 - // 현재는 비용 최적화를 위해 이 로직은 클라이언트에서 처리하도록 변경 가능 - - logger.info("Streak reset completed: {} users processed", resetCount); - return resetCount; - } + + private static final Logger logger = LoggerFactory.getLogger(ScheduledStatsHandler.class); + private static final String TABLE_NAME = System.getenv("VOCAB_TABLE_NAME"); + + private final UserStatsRepository userStatsRepository; + + public ScheduledStatsHandler() { + this.userStatsRepository = new UserStatsRepository(); + } + + @Override + public String handleRequest(ScheduledEvent event, Context context) { + logger.info("Scheduled streak check started: {}", event.getTime()); + + try { + String yesterday = LocalDate.now().minusDays(1).toString(); + int resetCount = checkAndResetStreaks(yesterday); + + logger.info("Scheduled streak check completed: {} streaks reset", resetCount); + return "SUCCESS: " + resetCount + " streaks reset"; + } catch (Exception e) { + logger.error("Scheduled streak check failed", e); + return "FAILED: " + e.getMessage(); + } + } + + /** + * Streak 체크 및 리셋 + * GSI를 사용하여 Query로 처리 (Scan 대신) + *

+ * 어제 학습하지 않은 사용자 중 streak이 있는 사용자만 리셋 + */ + private int checkAndResetStreaks(String yesterday) { + logger.info("Checking streaks for date: {}", yesterday); + + // GSI1을 사용하여 TOTAL 통계 레코드만 조회 + // GSI1PK = "STATS#TOTAL" 으로 설계하면 Query 가능 + // 현재는 GSI가 없으므로 개별 사용자별로 처리하는 방식 사용 + + // 실제로는 lastStudyDate가 어제가 아닌 사용자를 찾아야 함 + // 하지만 현재 구조상 효율적인 방법은: + // 1. 활성 사용자 목록 관리 (별도 테이블/인덱스) + // 2. 또는 클라이언트에서 streak 조회 시 계산 + + // 현재는 간단하게 구현: DailyStudy가 없는 사용자의 streak을 리셋 + // 이는 학습을 한 번이라도 한 사용자 대상 + + int resetCount = 0; + + // Note: 실제 운영에서는 활성 사용자 목록을 별도로 관리하거나 + // GSI를 lastStudyDate로 만들어 Query 하는 것이 효율적 + // 현재는 비용 최적화를 위해 이 로직은 클라이언트에서 처리하도록 변경 가능 + + logger.info("Streak reset completed: {} users processed", resetCount); + return resetCount; + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/StatsStreamHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/StatsStreamHandler.java index cddb5c1b..8caab03e 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/StatsStreamHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/StatsStreamHandler.java @@ -4,16 +4,15 @@ import com.amazonaws.services.lambda.runtime.RequestHandler; import com.amazonaws.services.lambda.runtime.events.DynamodbEvent; import com.amazonaws.services.lambda.runtime.events.models.dynamodb.AttributeValue; -import com.mzc.secondproject.serverless.domain.stats.model.UserStats; -import com.mzc.secondproject.serverless.domain.stats.repository.UserStatsRepository; import com.mzc.secondproject.serverless.domain.badge.model.UserBadge; import com.mzc.secondproject.serverless.domain.badge.service.BadgeService; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; +import com.mzc.secondproject.serverless.domain.stats.repository.UserStatsRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.util.List; - import java.time.LocalDate; +import java.util.List; import java.util.Map; import java.util.Optional; @@ -22,155 +21,155 @@ * TestResult 저장 시 자동으로 통계 업데이트 */ public class StatsStreamHandler implements RequestHandler { - - private static final Logger logger = LoggerFactory.getLogger(StatsStreamHandler.class); - - private final UserStatsRepository userStatsRepository; - private final BadgeService badgeService; - - public StatsStreamHandler() { - this.userStatsRepository = new UserStatsRepository(); - this.badgeService = new BadgeService(); - } - - @Override - public Void handleRequest(DynamodbEvent event, Context context) { - logger.info("Received {} DynamoDB Stream records", event.getRecords().size()); - - for (DynamodbEvent.DynamodbStreamRecord record : event.getRecords()) { - try { - processRecord(record); - } catch (Exception e) { - logger.error("Failed to process record: {}", record.getEventID(), e); - } - } - - return null; - } - - private void processRecord(DynamodbEvent.DynamodbStreamRecord record) { - String eventName = record.getEventName(); - - // INSERT 이벤트만 처리 (새로운 테스트 결과) - if (!"INSERT".equals(eventName)) { - return; - } - - Map newImage = record.getDynamodb().getNewImage(); - if (newImage == null) { - return; - } - - String pk = getStringValue(newImage, "PK"); - String sk = getStringValue(newImage, "SK"); - - if (pk == null || sk == null) { - return; - } - - // TestResult 레코드 확인: PK=TEST#{userId}, SK=RESULT#{timestamp} - if (pk.startsWith("TEST#") && sk.startsWith("RESULT#")) { - processTestResultInsert(newImage); - } - } - - private void processTestResultInsert(Map newImage) { - String userId = getStringValue(newImage, "userId"); - Integer correctAnswers = getNumberValue(newImage, "correctAnswers"); - Integer incorrectAnswers = getNumberValue(newImage, "incorrectAnswers"); - String testId = getStringValue(newImage, "testId"); - - if (userId == null || correctAnswers == null || incorrectAnswers == null) { - logger.warn("Missing required fields in TestResult record"); - return; - } - - logger.info("Processing TestResult: userId={}, testId={}, correct={}, incorrect={}", - userId, testId, correctAnswers, incorrectAnswers); - - // 통계 업데이트 - userStatsRepository.incrementTestStats(userId, correctAnswers, incorrectAnswers); - - // Streak 업데이트 - updateStudyStreak(userId); - - logger.info("Stats updated for user: {}", userId); - - // 뱃지 체크 및 부여 - checkAndAwardBadges(userId, correctAnswers, incorrectAnswers); - } - - private void checkAndAwardBadges(String userId, int correctAnswers, int incorrectAnswers) { - try { - Optional totalStats = userStatsRepository.findTotalStats(userId); - if (totalStats.isEmpty()) { - return; - } - - // 만점 뱃지 체크 (이번 테스트가 만점인 경우) - if (incorrectAnswers == 0 && correctAnswers > 0) { - badgeService.awardBadge(userId, "PERFECT_SCORE"); - logger.info("Perfect score badge awarded to user: {}", userId); - } - - // 기타 뱃지 체크 - List newBadges = badgeService.checkAndAwardBadges(userId, totalStats.get()); - if (!newBadges.isEmpty()) { - logger.info("Awarded {} new badges to user: {}", newBadges.size(), userId); - } - } catch (Exception e) { - logger.error("Failed to check badges for user: {}", userId, e); - } - } - - private void updateStudyStreak(String userId) { - String today = LocalDate.now().toString(); - - Optional totalStats = - userStatsRepository.findTotalStats(userId); - - int currentStreak = 1; - int longestStreak = 1; - - if (totalStats.isPresent()) { - var stats = totalStats.get(); - String lastStudyDate = stats.getLastStudyDate(); - - if (lastStudyDate != null) { - LocalDate lastDate = LocalDate.parse(lastStudyDate); - LocalDate todayDate = LocalDate.now(); - - long daysDiff = todayDate.toEpochDay() - lastDate.toEpochDay(); - - if (daysDiff == 0) { - // 오늘 이미 학습 - streak 유지 - return; - } else if (daysDiff == 1) { - // 어제 학습 - streak 증가 - currentStreak = (stats.getCurrentStreak() != null ? stats.getCurrentStreak() : 0) + 1; - } else { - // 연속 학습 끊김 - currentStreak = 1; - } - } - - longestStreak = stats.getLongestStreak() != null ? - Math.max(stats.getLongestStreak(), currentStreak) : currentStreak; - } - - userStatsRepository.updateStreak(userId, currentStreak, longestStreak, today); - } - - private String getStringValue(Map item, String key) { - AttributeValue value = item.get(key); - return value != null ? value.getS() : null; - } - - private Integer getNumberValue(Map item, String key) { - AttributeValue value = item.get(key); - if (value != null && value.getN() != null) { - return Integer.parseInt(value.getN()); - } - return null; - } + + private static final Logger logger = LoggerFactory.getLogger(StatsStreamHandler.class); + + private final UserStatsRepository userStatsRepository; + private final BadgeService badgeService; + + public StatsStreamHandler() { + this.userStatsRepository = new UserStatsRepository(); + this.badgeService = new BadgeService(); + } + + @Override + public Void handleRequest(DynamodbEvent event, Context context) { + logger.info("Received {} DynamoDB Stream records", event.getRecords().size()); + + for (DynamodbEvent.DynamodbStreamRecord record : event.getRecords()) { + try { + processRecord(record); + } catch (Exception e) { + logger.error("Failed to process record: {}", record.getEventID(), e); + } + } + + return null; + } + + private void processRecord(DynamodbEvent.DynamodbStreamRecord record) { + String eventName = record.getEventName(); + + // INSERT 이벤트만 처리 (새로운 테스트 결과) + if (!"INSERT".equals(eventName)) { + return; + } + + Map newImage = record.getDynamodb().getNewImage(); + if (newImage == null) { + return; + } + + String pk = getStringValue(newImage, "PK"); + String sk = getStringValue(newImage, "SK"); + + if (pk == null || sk == null) { + return; + } + + // TestResult 레코드 확인: PK=TEST#{userId}, SK=RESULT#{timestamp} + if (pk.startsWith("TEST#") && sk.startsWith("RESULT#")) { + processTestResultInsert(newImage); + } + } + + private void processTestResultInsert(Map newImage) { + String userId = getStringValue(newImage, "userId"); + Integer correctAnswers = getNumberValue(newImage, "correctAnswers"); + Integer incorrectAnswers = getNumberValue(newImage, "incorrectAnswers"); + String testId = getStringValue(newImage, "testId"); + + if (userId == null || correctAnswers == null || incorrectAnswers == null) { + logger.warn("Missing required fields in TestResult record"); + return; + } + + logger.info("Processing TestResult: userId={}, testId={}, correct={}, incorrect={}", + userId, testId, correctAnswers, incorrectAnswers); + + // 통계 업데이트 + userStatsRepository.incrementTestStats(userId, correctAnswers, incorrectAnswers); + + // Streak 업데이트 + updateStudyStreak(userId); + + logger.info("Stats updated for user: {}", userId); + + // 뱃지 체크 및 부여 + checkAndAwardBadges(userId, correctAnswers, incorrectAnswers); + } + + private void checkAndAwardBadges(String userId, int correctAnswers, int incorrectAnswers) { + try { + Optional totalStats = userStatsRepository.findTotalStats(userId); + if (totalStats.isEmpty()) { + return; + } + + // 만점 뱃지 체크 (이번 테스트가 만점인 경우) + if (incorrectAnswers == 0 && correctAnswers > 0) { + badgeService.awardBadge(userId, "PERFECT_SCORE"); + logger.info("Perfect score badge awarded to user: {}", userId); + } + + // 기타 뱃지 체크 + List newBadges = badgeService.checkAndAwardBadges(userId, totalStats.get()); + if (!newBadges.isEmpty()) { + logger.info("Awarded {} new badges to user: {}", newBadges.size(), userId); + } + } catch (Exception e) { + logger.error("Failed to check badges for user: {}", userId, e); + } + } + + private void updateStudyStreak(String userId) { + String today = LocalDate.now().toString(); + + Optional totalStats = + userStatsRepository.findTotalStats(userId); + + int currentStreak = 1; + int longestStreak = 1; + + if (totalStats.isPresent()) { + var stats = totalStats.get(); + String lastStudyDate = stats.getLastStudyDate(); + + if (lastStudyDate != null) { + LocalDate lastDate = LocalDate.parse(lastStudyDate); + LocalDate todayDate = LocalDate.now(); + + long daysDiff = todayDate.toEpochDay() - lastDate.toEpochDay(); + + if (daysDiff == 0) { + // 오늘 이미 학습 - streak 유지 + return; + } else if (daysDiff == 1) { + // 어제 학습 - streak 증가 + currentStreak = (stats.getCurrentStreak() != null ? stats.getCurrentStreak() : 0) + 1; + } else { + // 연속 학습 끊김 + currentStreak = 1; + } + } + + longestStreak = stats.getLongestStreak() != null ? + Math.max(stats.getLongestStreak(), currentStreak) : currentStreak; + } + + userStatsRepository.updateStreak(userId, currentStreak, longestStreak, today); + } + + private String getStringValue(Map item, String key) { + AttributeValue value = item.get(key); + return value != null ? value.getS() : null; + } + + private Integer getNumberValue(Map item, String key) { + AttributeValue value = item.get(key); + if (value != null && value.getN() != null) { + return Integer.parseInt(value.getN()); + } + return null; + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java index f47b2cd7..f488bafb 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java @@ -24,160 +24,160 @@ * 사용자 학습 통계 API Handler */ public class UserStatsHandler implements RequestHandler { - - private static final Logger logger = LoggerFactory.getLogger(UserStatsHandler.class); - - private final UserStatsRepository statsRepository; - private final HandlerRouter router; - - public UserStatsHandler() { - this.statsRepository = new UserStatsRepository(); - this.router = initRouter(); - } - - private HandlerRouter initRouter() { - return new HandlerRouter().addRoutes( - Route.getAuth("/stats/daily", this::getDailyStats), - Route.getAuth("/stats/weekly", this::getWeeklyStats), - Route.getAuth("/stats/monthly", this::getMonthlyStats), - Route.getAuth("/stats/total", this::getTotalStats), - Route.getAuth("/stats/history", this::getStatsHistory) - ); - } - - @Override - public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { - logger.info("Received request: {} {}", request.getHttpMethod(), request.getPath()); - return router.route(request); - } - - /** - * 오늘의 통계 조회 - */ - private APIGatewayProxyResponseEvent getDailyStats(APIGatewayProxyRequestEvent request, String userId) { - Map queryParams = request.getQueryStringParameters(); - String date = queryParams != null && queryParams.get("date") != null ? - queryParams.get("date") : LocalDate.now().toString(); - - Optional stats = statsRepository.findDailyStats(userId, date); - - return ResponseGenerator.ok("Daily stats retrieved", buildStatsResponse(stats, "DAILY", date)); - } - - /** - * 이번 주 통계 조회 - */ - private APIGatewayProxyResponseEvent getWeeklyStats(APIGatewayProxyRequestEvent request, String userId) { - Map queryParams = request.getQueryStringParameters(); - String yearWeek = queryParams != null && queryParams.get("week") != null ? - queryParams.get("week") : getCurrentYearWeek(); - - Optional stats = statsRepository.findWeeklyStats(userId, yearWeek); - - return ResponseGenerator.ok("Weekly stats retrieved", buildStatsResponse(stats, "WEEKLY", yearWeek)); - } - - /** - * 이번 달 통계 조회 - */ - private APIGatewayProxyResponseEvent getMonthlyStats(APIGatewayProxyRequestEvent request, String userId) { - Map queryParams = request.getQueryStringParameters(); - String yearMonth = queryParams != null && queryParams.get("month") != null ? - queryParams.get("month") : getCurrentYearMonth(); - - Optional stats = statsRepository.findMonthlyStats(userId, yearMonth); - - return ResponseGenerator.ok("Monthly stats retrieved", buildStatsResponse(stats, "MONTHLY", yearMonth)); - } - - /** - * 전체 통계 조회 - */ - private APIGatewayProxyResponseEvent getTotalStats(APIGatewayProxyRequestEvent request, String userId) { - Optional stats = statsRepository.findTotalStats(userId); - - Map response = buildStatsResponse(stats, "TOTAL", "ALL"); - - // 전체 통계에는 streak 정보 추가 - if (stats.isPresent()) { - UserStats s = stats.get(); - response.put("currentStreak", s.getCurrentStreak() != null ? s.getCurrentStreak() : 0); - response.put("longestStreak", s.getLongestStreak() != null ? s.getLongestStreak() : 0); - response.put("lastStudyDate", s.getLastStudyDate()); - } else { - response.put("currentStreak", 0); - response.put("longestStreak", 0); - response.put("lastStudyDate", null); - } - - return ResponseGenerator.ok("Total stats retrieved", response); - } - - /** - * 최근 일별 통계 히스토리 조회 - */ - private APIGatewayProxyResponseEvent getStatsHistory(APIGatewayProxyRequestEvent request, String userId) { - Map queryParams = request.getQueryStringParameters(); - String cursor = queryParams != null ? queryParams.get("cursor") : null; - - int limit = 7; // 기본 7일 - if (queryParams != null && queryParams.get("limit") != null) { - limit = Math.min(Integer.parseInt(queryParams.get("limit")), 30); - } - - PaginatedResult result = statsRepository.findRecentDailyStats(userId, limit, cursor); - - Map response = new HashMap<>(); - response.put("history", result.items()); - response.put("nextCursor", result.nextCursor()); - response.put("hasMore", result.hasMore()); - - return ResponseGenerator.ok("Stats history retrieved", response); - } - - private Map buildStatsResponse(Optional stats, String periodType, String period) { - Map response = new HashMap<>(); - response.put("periodType", periodType); - response.put("period", period); - - if (stats.isPresent()) { - UserStats s = stats.get(); - response.put("testsCompleted", s.getTestsCompleted() != null ? s.getTestsCompleted() : 0); - response.put("questionsAnswered", s.getQuestionsAnswered() != null ? s.getQuestionsAnswered() : 0); - response.put("correctAnswers", s.getCorrectAnswers() != null ? s.getCorrectAnswers() : 0); - response.put("incorrectAnswers", s.getIncorrectAnswers() != null ? s.getIncorrectAnswers() : 0); - response.put("successRate", calculateSuccessRate(s)); - response.put("newWordsLearned", s.getNewWordsLearned() != null ? s.getNewWordsLearned() : 0); - response.put("wordsReviewed", s.getWordsReviewed() != null ? s.getWordsReviewed() : 0); - } else { - response.put("testsCompleted", 0); - response.put("questionsAnswered", 0); - response.put("correctAnswers", 0); - response.put("incorrectAnswers", 0); - response.put("successRate", 0.0); - response.put("newWordsLearned", 0); - response.put("wordsReviewed", 0); - } - - return response; - } - - private double calculateSuccessRate(UserStats stats) { - int correct = stats.getCorrectAnswers() != null ? stats.getCorrectAnswers() : 0; - int total = stats.getQuestionsAnswered() != null ? stats.getQuestionsAnswered() : 0; - return total > 0 ? (correct * 100.0 / total) : 0.0; - } - - private String getCurrentYearWeek() { - LocalDate now = LocalDate.now(); - WeekFields weekFields = WeekFields.of(Locale.getDefault()); - int week = now.get(weekFields.weekOfWeekBasedYear()); - int year = now.get(weekFields.weekBasedYear()); - return String.format("%d-W%02d", year, week); - } - - private String getCurrentYearMonth() { - LocalDate now = LocalDate.now(); - return String.format("%d-%02d", now.getYear(), now.getMonthValue()); - } + + private static final Logger logger = LoggerFactory.getLogger(UserStatsHandler.class); + + private final UserStatsRepository statsRepository; + private final HandlerRouter router; + + public UserStatsHandler() { + this.statsRepository = new UserStatsRepository(); + this.router = initRouter(); + } + + private HandlerRouter initRouter() { + return new HandlerRouter().addRoutes( + Route.getAuth("/stats/daily", this::getDailyStats), + Route.getAuth("/stats/weekly", this::getWeeklyStats), + Route.getAuth("/stats/monthly", this::getMonthlyStats), + Route.getAuth("/stats/total", this::getTotalStats), + Route.getAuth("/stats/history", this::getStatsHistory) + ); + } + + @Override + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { + logger.info("Received request: {} {}", request.getHttpMethod(), request.getPath()); + return router.route(request); + } + + /** + * 오늘의 통계 조회 + */ + private APIGatewayProxyResponseEvent getDailyStats(APIGatewayProxyRequestEvent request, String userId) { + Map queryParams = request.getQueryStringParameters(); + String date = queryParams != null && queryParams.get("date") != null ? + queryParams.get("date") : LocalDate.now().toString(); + + Optional stats = statsRepository.findDailyStats(userId, date); + + return ResponseGenerator.ok("Daily stats retrieved", buildStatsResponse(stats, "DAILY", date)); + } + + /** + * 이번 주 통계 조회 + */ + private APIGatewayProxyResponseEvent getWeeklyStats(APIGatewayProxyRequestEvent request, String userId) { + Map queryParams = request.getQueryStringParameters(); + String yearWeek = queryParams != null && queryParams.get("week") != null ? + queryParams.get("week") : getCurrentYearWeek(); + + Optional stats = statsRepository.findWeeklyStats(userId, yearWeek); + + return ResponseGenerator.ok("Weekly stats retrieved", buildStatsResponse(stats, "WEEKLY", yearWeek)); + } + + /** + * 이번 달 통계 조회 + */ + private APIGatewayProxyResponseEvent getMonthlyStats(APIGatewayProxyRequestEvent request, String userId) { + Map queryParams = request.getQueryStringParameters(); + String yearMonth = queryParams != null && queryParams.get("month") != null ? + queryParams.get("month") : getCurrentYearMonth(); + + Optional stats = statsRepository.findMonthlyStats(userId, yearMonth); + + return ResponseGenerator.ok("Monthly stats retrieved", buildStatsResponse(stats, "MONTHLY", yearMonth)); + } + + /** + * 전체 통계 조회 + */ + private APIGatewayProxyResponseEvent getTotalStats(APIGatewayProxyRequestEvent request, String userId) { + Optional stats = statsRepository.findTotalStats(userId); + + Map response = buildStatsResponse(stats, "TOTAL", "ALL"); + + // 전체 통계에는 streak 정보 추가 + if (stats.isPresent()) { + UserStats s = stats.get(); + response.put("currentStreak", s.getCurrentStreak() != null ? s.getCurrentStreak() : 0); + response.put("longestStreak", s.getLongestStreak() != null ? s.getLongestStreak() : 0); + response.put("lastStudyDate", s.getLastStudyDate()); + } else { + response.put("currentStreak", 0); + response.put("longestStreak", 0); + response.put("lastStudyDate", null); + } + + return ResponseGenerator.ok("Total stats retrieved", response); + } + + /** + * 최근 일별 통계 히스토리 조회 + */ + private APIGatewayProxyResponseEvent getStatsHistory(APIGatewayProxyRequestEvent request, String userId) { + Map queryParams = request.getQueryStringParameters(); + String cursor = queryParams != null ? queryParams.get("cursor") : null; + + int limit = 7; // 기본 7일 + if (queryParams != null && queryParams.get("limit") != null) { + limit = Math.min(Integer.parseInt(queryParams.get("limit")), 30); + } + + PaginatedResult result = statsRepository.findRecentDailyStats(userId, limit, cursor); + + Map response = new HashMap<>(); + response.put("history", result.items()); + response.put("nextCursor", result.nextCursor()); + response.put("hasMore", result.hasMore()); + + return ResponseGenerator.ok("Stats history retrieved", response); + } + + private Map buildStatsResponse(Optional stats, String periodType, String period) { + Map response = new HashMap<>(); + response.put("periodType", periodType); + response.put("period", period); + + if (stats.isPresent()) { + UserStats s = stats.get(); + response.put("testsCompleted", s.getTestsCompleted() != null ? s.getTestsCompleted() : 0); + response.put("questionsAnswered", s.getQuestionsAnswered() != null ? s.getQuestionsAnswered() : 0); + response.put("correctAnswers", s.getCorrectAnswers() != null ? s.getCorrectAnswers() : 0); + response.put("incorrectAnswers", s.getIncorrectAnswers() != null ? s.getIncorrectAnswers() : 0); + response.put("successRate", calculateSuccessRate(s)); + response.put("newWordsLearned", s.getNewWordsLearned() != null ? s.getNewWordsLearned() : 0); + response.put("wordsReviewed", s.getWordsReviewed() != null ? s.getWordsReviewed() : 0); + } else { + response.put("testsCompleted", 0); + response.put("questionsAnswered", 0); + response.put("correctAnswers", 0); + response.put("incorrectAnswers", 0); + response.put("successRate", 0.0); + response.put("newWordsLearned", 0); + response.put("wordsReviewed", 0); + } + + return response; + } + + private double calculateSuccessRate(UserStats stats) { + int correct = stats.getCorrectAnswers() != null ? stats.getCorrectAnswers() : 0; + int total = stats.getQuestionsAnswered() != null ? stats.getQuestionsAnswered() : 0; + return total > 0 ? (correct * 100.0 / total) : 0.0; + } + + private String getCurrentYearWeek() { + LocalDate now = LocalDate.now(); + WeekFields weekFields = WeekFields.of(Locale.getDefault()); + int week = now.get(weekFields.weekOfWeekBasedYear()); + int year = now.get(weekFields.weekBasedYear()); + return String.format("%d-W%02d", year, week); + } + + private String getCurrentYearMonth() { + LocalDate now = LocalDate.now(); + return String.format("%d-%02d", now.getYear(), now.getMonthValue()); + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/model/UserStats.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/model/UserStats.java index e957bdfb..21a1487a 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/model/UserStats.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/model/UserStats.java @@ -13,7 +13,7 @@ * 사용자 학습 통계 * PK: USER#{userId}#STATS * SK: DAILY#{date} / WEEKLY#{year}-W{week} / MONTHLY#{year}-{month} / TOTAL - * + *

* Write-time Aggregation 패턴: * - 이벤트 발생 시 Atomic Counter로 증분 업데이트 * - 조회 시 Scan 없이 O(1) GetItem @@ -24,44 +24,44 @@ @AllArgsConstructor @DynamoDbBean public class UserStats { - - private String pk; // USER#{userId}#STATS - private String sk; // DAILY#{date} / WEEKLY#{year}-W{week} / MONTHLY#{year}-{month} / TOTAL - - private String userId; - private String periodType; // DAILY, WEEKLY, MONTHLY, TOTAL - private String period; // 2026-01-13, 2026-W02, 2026-01, TOTAL - - // 테스트 통계 - private Integer testsCompleted; // 완료한 테스트 수 - private Integer questionsAnswered; // 답변한 문제 수 - private Integer correctAnswers; // 정답 수 - private Integer incorrectAnswers; // 오답 수 - private Double successRate; // 정답률 - - // 학습 통계 - private Integer newWordsLearned; // 새로 학습한 단어 수 - private Integer wordsReviewed; // 복습한 단어 수 - private Integer wordsMastered; // 마스터한 단어 수 - - // Streak (연속 학습) - private Integer currentStreak; // 현재 연속 학습일 - private Integer longestStreak; // 최장 연속 학습일 - private String lastStudyDate; // 마지막 학습일 - - // 메타데이터 - private String createdAt; - private String updatedAt; - - @DynamoDbPartitionKey - @DynamoDbAttribute("PK") - public String getPk() { - return pk; - } - - @DynamoDbSortKey - @DynamoDbAttribute("SK") - public String getSk() { - return sk; - } + + private String pk; // USER#{userId}#STATS + private String sk; // DAILY#{date} / WEEKLY#{year}-W{week} / MONTHLY#{year}-{month} / TOTAL + + private String userId; + private String periodType; // DAILY, WEEKLY, MONTHLY, TOTAL + private String period; // 2026-01-13, 2026-W02, 2026-01, TOTAL + + // 테스트 통계 + private Integer testsCompleted; // 완료한 테스트 수 + private Integer questionsAnswered; // 답변한 문제 수 + private Integer correctAnswers; // 정답 수 + private Integer incorrectAnswers; // 오답 수 + private Double successRate; // 정답률 + + // 학습 통계 + private Integer newWordsLearned; // 새로 학습한 단어 수 + private Integer wordsReviewed; // 복습한 단어 수 + private Integer wordsMastered; // 마스터한 단어 수 + + // Streak (연속 학습) + private Integer currentStreak; // 현재 연속 학습일 + private Integer longestStreak; // 최장 연속 학습일 + private String lastStudyDate; // 마지막 학습일 + + // 메타데이터 + private String createdAt; + private String updatedAt; + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { + return pk; + } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { + return sk; + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java index 75f9e38e..3ef8fa79 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java @@ -19,260 +19,256 @@ import java.time.Instant; import java.time.LocalDate; import java.time.temporal.WeekFields; -import java.util.HashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Optional; +import java.util.*; /** * 사용자 학습 통계 Repository * Atomic Counter 패턴을 사용하여 Scan 없이 통계 업데이트 */ public class UserStatsRepository { - - private static final Logger logger = LoggerFactory.getLogger(UserStatsRepository.class); - private static final String TABLE_NAME = System.getenv("VOCAB_TABLE_NAME"); - - private final DynamoDbTable table; - - public UserStatsRepository() { - this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(UserStats.class)); - } - - /** - * 특정 기간의 통계 조회 - */ - public Optional findByUserIdAndPeriod(String userId, String sk) { - Key key = Key.builder() - .partitionValue(StatsKey.userStatsPk(userId)) - .sortValue(sk) - .build(); - - UserStats stats = table.getItem(key); - return Optional.ofNullable(stats); - } - - /** - * 일별 통계 조회 - */ - public Optional findDailyStats(String userId, String date) { - return findByUserIdAndPeriod(userId, StatsKey.statsDailySk(date)); - } - - /** - * 주별 통계 조회 - */ - public Optional findWeeklyStats(String userId, String yearWeek) { - return findByUserIdAndPeriod(userId, StatsKey.statsWeeklySk(yearWeek)); - } - - /** - * 월별 통계 조회 - */ - public Optional findMonthlyStats(String userId, String yearMonth) { - return findByUserIdAndPeriod(userId, StatsKey.statsMonthlySk(yearMonth)); - } - - /** - * 전체 통계 조회 - */ - public Optional findTotalStats(String userId) { - return findByUserIdAndPeriod(userId, StatsKey.statsTotalSk()); - } - - /** - * 최근 N일 일별 통계 조회 - */ - public PaginatedResult findRecentDailyStats(String userId, int limit, String cursor) { - QueryConditional queryConditional = QueryConditional - .sortBeginsWith(Key.builder() - .partitionValue(StatsKey.userStatsPk(userId)) - .sortValue(StatsKey.STATS_DAILY) - .build()); - - QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() - .queryConditional(queryConditional) - .scanIndexForward(false) // 최신순 - .limit(limit); - - if (cursor != null && !cursor.isEmpty()) { - Map exclusiveStartKey = CursorUtil.decode(cursor); - if (exclusiveStartKey != null) { - requestBuilder.exclusiveStartKey(exclusiveStartKey); - } - } - - Page page = table.query(requestBuilder.build()).iterator().next(); - String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); - - return new PaginatedResult<>(page.items(), nextCursor); - } - - /** - * 테스트 결과 통계 Atomic 업데이트 - * 일/주/월/전체 통계를 한 번에 업데이트 - */ - public void incrementTestStats(String userId, int correctAnswers, int incorrectAnswers) { - String today = LocalDate.now().toString(); - String yearWeek = getYearWeek(); - String yearMonth = getYearMonth(); - - List sortKeys = List.of( - StatsKey.statsDailySk(today), - StatsKey.statsWeeklySk(yearWeek), - StatsKey.statsMonthlySk(yearMonth), - StatsKey.statsTotalSk() - ); - - String pk = StatsKey.userStatsPk(userId); - String now = Instant.now().toString(); - int totalQuestions = correctAnswers + incorrectAnswers; - - for (String sk : sortKeys) { - updateTestStats(pk, sk, correctAnswers, incorrectAnswers, totalQuestions, now); - } - - logger.info("Incremented test stats: userId={}, correct={}, incorrect={}", - userId, correctAnswers, incorrectAnswers); - } - - private void updateTestStats(String pk, String sk, int correct, int incorrect, int total, String now) { - Map key = new HashMap<>(); - key.put("PK", AttributeValue.builder().s(pk).build()); - key.put("SK", AttributeValue.builder().s(sk).build()); - - Map values = new HashMap<>(); - values.put(":correct", AttributeValue.builder().n(String.valueOf(correct)).build()); - values.put(":incorrect", AttributeValue.builder().n(String.valueOf(incorrect)).build()); - values.put(":total", AttributeValue.builder().n(String.valueOf(total)).build()); - values.put(":one", AttributeValue.builder().n("1").build()); - values.put(":zero", AttributeValue.builder().n("0").build()); - values.put(":now", AttributeValue.builder().s(now).build()); - - String updateExpression = "SET " + - "correctAnswers = if_not_exists(correctAnswers, :zero) + :correct, " + - "incorrectAnswers = if_not_exists(incorrectAnswers, :zero) + :incorrect, " + - "questionsAnswered = if_not_exists(questionsAnswered, :zero) + :total, " + - "testsCompleted = if_not_exists(testsCompleted, :zero) + :one, " + - "updatedAt = :now, " + - "createdAt = if_not_exists(createdAt, :now)"; - - UpdateItemRequest request = UpdateItemRequest.builder() - .tableName(TABLE_NAME) - .key(key) - .updateExpression(updateExpression) - .expressionAttributeValues(values) - .build(); - - AwsClients.dynamoDb().updateItem(request); - } - - /** - * 학습 완료 단어 수 Atomic 업데이트 - */ - public void incrementWordsLearned(String userId, int newWords, int reviewedWords) { - String today = LocalDate.now().toString(); - String yearWeek = getYearWeek(); - String yearMonth = getYearMonth(); - - List sortKeys = List.of( - StatsKey.statsDailySk(today), - StatsKey.statsWeeklySk(yearWeek), - StatsKey.statsMonthlySk(yearMonth), - StatsKey.statsTotalSk() - ); - - String pk = StatsKey.userStatsPk(userId); - String now = Instant.now().toString(); - - for (String sk : sortKeys) { - updateWordsLearned(pk, sk, newWords, reviewedWords, now); - } - - logger.info("Incremented words learned: userId={}, new={}, reviewed={}", - userId, newWords, reviewedWords); - } - - private void updateWordsLearned(String pk, String sk, int newWords, int reviewedWords, String now) { - Map key = new HashMap<>(); - key.put("PK", AttributeValue.builder().s(pk).build()); - key.put("SK", AttributeValue.builder().s(sk).build()); - - Map values = new HashMap<>(); - values.put(":new", AttributeValue.builder().n(String.valueOf(newWords)).build()); - values.put(":reviewed", AttributeValue.builder().n(String.valueOf(reviewedWords)).build()); - values.put(":zero", AttributeValue.builder().n("0").build()); - values.put(":now", AttributeValue.builder().s(now).build()); - - String updateExpression = "SET " + - "newWordsLearned = if_not_exists(newWordsLearned, :zero) + :new, " + - "wordsReviewed = if_not_exists(wordsReviewed, :zero) + :reviewed, " + - "updatedAt = :now, " + - "createdAt = if_not_exists(createdAt, :now)"; - - UpdateItemRequest request = UpdateItemRequest.builder() - .tableName(TABLE_NAME) - .key(key) - .updateExpression(updateExpression) - .expressionAttributeValues(values) - .build(); - - AwsClients.dynamoDb().updateItem(request); - } - - /** - * Streak(연속 학습일) 업데이트 - */ - public void updateStreak(String userId, int currentStreak, int longestStreak, String lastStudyDate) { - String pk = StatsKey.userStatsPk(userId); - String sk = StatsKey.statsTotalSk(); - String now = Instant.now().toString(); - - Map key = new HashMap<>(); - key.put("PK", AttributeValue.builder().s(pk).build()); - key.put("SK", AttributeValue.builder().s(sk).build()); - - Map values = new HashMap<>(); - values.put(":current", AttributeValue.builder().n(String.valueOf(currentStreak)).build()); - values.put(":longest", AttributeValue.builder().n(String.valueOf(longestStreak)).build()); - values.put(":lastDate", AttributeValue.builder().s(lastStudyDate).build()); - values.put(":now", AttributeValue.builder().s(now).build()); - - String updateExpression = "SET " + - "currentStreak = :current, " + - "longestStreak = :longest, " + - "lastStudyDate = :lastDate, " + - "updatedAt = :now, " + - "createdAt = if_not_exists(createdAt, :now)"; - - UpdateItemRequest request = UpdateItemRequest.builder() - .tableName(TABLE_NAME) - .key(key) - .updateExpression(updateExpression) - .expressionAttributeValues(values) - .build(); - - AwsClients.dynamoDb().updateItem(request); - logger.info("Updated streak: userId={}, current={}, longest={}", userId, currentStreak, longestStreak); - } - - /** - * 현재 연도-주차 반환 (예: 2026-W02) - */ - private String getYearWeek() { - LocalDate now = LocalDate.now(); - WeekFields weekFields = WeekFields.of(Locale.getDefault()); - int week = now.get(weekFields.weekOfWeekBasedYear()); - int year = now.get(weekFields.weekBasedYear()); - return String.format("%d-W%02d", year, week); - } - - /** - * 현재 연도-월 반환 (예: 2026-01) - */ - private String getYearMonth() { - LocalDate now = LocalDate.now(); - return String.format("%d-%02d", now.getYear(), now.getMonthValue()); - } + + private static final Logger logger = LoggerFactory.getLogger(UserStatsRepository.class); + private static final String TABLE_NAME = System.getenv("VOCAB_TABLE_NAME"); + + private final DynamoDbTable table; + + public UserStatsRepository() { + this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(UserStats.class)); + } + + /** + * 특정 기간의 통계 조회 + */ + public Optional findByUserIdAndPeriod(String userId, String sk) { + Key key = Key.builder() + .partitionValue(StatsKey.userStatsPk(userId)) + .sortValue(sk) + .build(); + + UserStats stats = table.getItem(key); + return Optional.ofNullable(stats); + } + + /** + * 일별 통계 조회 + */ + public Optional findDailyStats(String userId, String date) { + return findByUserIdAndPeriod(userId, StatsKey.statsDailySk(date)); + } + + /** + * 주별 통계 조회 + */ + public Optional findWeeklyStats(String userId, String yearWeek) { + return findByUserIdAndPeriod(userId, StatsKey.statsWeeklySk(yearWeek)); + } + + /** + * 월별 통계 조회 + */ + public Optional findMonthlyStats(String userId, String yearMonth) { + return findByUserIdAndPeriod(userId, StatsKey.statsMonthlySk(yearMonth)); + } + + /** + * 전체 통계 조회 + */ + public Optional findTotalStats(String userId) { + return findByUserIdAndPeriod(userId, StatsKey.statsTotalSk()); + } + + /** + * 최근 N일 일별 통계 조회 + */ + public PaginatedResult findRecentDailyStats(String userId, int limit, String cursor) { + QueryConditional queryConditional = QueryConditional + .sortBeginsWith(Key.builder() + .partitionValue(StatsKey.userStatsPk(userId)) + .sortValue(StatsKey.STATS_DAILY) + .build()); + + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .scanIndexForward(false) // 최신순 + .limit(limit); + + if (cursor != null && !cursor.isEmpty()) { + Map exclusiveStartKey = CursorUtil.decode(cursor); + if (exclusiveStartKey != null) { + requestBuilder.exclusiveStartKey(exclusiveStartKey); + } + } + + Page page = table.query(requestBuilder.build()).iterator().next(); + String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); + + return new PaginatedResult<>(page.items(), nextCursor); + } + + /** + * 테스트 결과 통계 Atomic 업데이트 + * 일/주/월/전체 통계를 한 번에 업데이트 + */ + public void incrementTestStats(String userId, int correctAnswers, int incorrectAnswers) { + String today = LocalDate.now().toString(); + String yearWeek = getYearWeek(); + String yearMonth = getYearMonth(); + + List sortKeys = List.of( + StatsKey.statsDailySk(today), + StatsKey.statsWeeklySk(yearWeek), + StatsKey.statsMonthlySk(yearMonth), + StatsKey.statsTotalSk() + ); + + String pk = StatsKey.userStatsPk(userId); + String now = Instant.now().toString(); + int totalQuestions = correctAnswers + incorrectAnswers; + + for (String sk : sortKeys) { + updateTestStats(pk, sk, correctAnswers, incorrectAnswers, totalQuestions, now); + } + + logger.info("Incremented test stats: userId={}, correct={}, incorrect={}", + userId, correctAnswers, incorrectAnswers); + } + + private void updateTestStats(String pk, String sk, int correct, int incorrect, int total, String now) { + Map key = new HashMap<>(); + key.put("PK", AttributeValue.builder().s(pk).build()); + key.put("SK", AttributeValue.builder().s(sk).build()); + + Map values = new HashMap<>(); + values.put(":correct", AttributeValue.builder().n(String.valueOf(correct)).build()); + values.put(":incorrect", AttributeValue.builder().n(String.valueOf(incorrect)).build()); + values.put(":total", AttributeValue.builder().n(String.valueOf(total)).build()); + values.put(":one", AttributeValue.builder().n("1").build()); + values.put(":zero", AttributeValue.builder().n("0").build()); + values.put(":now", AttributeValue.builder().s(now).build()); + + String updateExpression = "SET " + + "correctAnswers = if_not_exists(correctAnswers, :zero) + :correct, " + + "incorrectAnswers = if_not_exists(incorrectAnswers, :zero) + :incorrect, " + + "questionsAnswered = if_not_exists(questionsAnswered, :zero) + :total, " + + "testsCompleted = if_not_exists(testsCompleted, :zero) + :one, " + + "updatedAt = :now, " + + "createdAt = if_not_exists(createdAt, :now)"; + + UpdateItemRequest request = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(key) + .updateExpression(updateExpression) + .expressionAttributeValues(values) + .build(); + + AwsClients.dynamoDb().updateItem(request); + } + + /** + * 학습 완료 단어 수 Atomic 업데이트 + */ + public void incrementWordsLearned(String userId, int newWords, int reviewedWords) { + String today = LocalDate.now().toString(); + String yearWeek = getYearWeek(); + String yearMonth = getYearMonth(); + + List sortKeys = List.of( + StatsKey.statsDailySk(today), + StatsKey.statsWeeklySk(yearWeek), + StatsKey.statsMonthlySk(yearMonth), + StatsKey.statsTotalSk() + ); + + String pk = StatsKey.userStatsPk(userId); + String now = Instant.now().toString(); + + for (String sk : sortKeys) { + updateWordsLearned(pk, sk, newWords, reviewedWords, now); + } + + logger.info("Incremented words learned: userId={}, new={}, reviewed={}", + userId, newWords, reviewedWords); + } + + private void updateWordsLearned(String pk, String sk, int newWords, int reviewedWords, String now) { + Map key = new HashMap<>(); + key.put("PK", AttributeValue.builder().s(pk).build()); + key.put("SK", AttributeValue.builder().s(sk).build()); + + Map values = new HashMap<>(); + values.put(":new", AttributeValue.builder().n(String.valueOf(newWords)).build()); + values.put(":reviewed", AttributeValue.builder().n(String.valueOf(reviewedWords)).build()); + values.put(":zero", AttributeValue.builder().n("0").build()); + values.put(":now", AttributeValue.builder().s(now).build()); + + String updateExpression = "SET " + + "newWordsLearned = if_not_exists(newWordsLearned, :zero) + :new, " + + "wordsReviewed = if_not_exists(wordsReviewed, :zero) + :reviewed, " + + "updatedAt = :now, " + + "createdAt = if_not_exists(createdAt, :now)"; + + UpdateItemRequest request = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(key) + .updateExpression(updateExpression) + .expressionAttributeValues(values) + .build(); + + AwsClients.dynamoDb().updateItem(request); + } + + /** + * Streak(연속 학습일) 업데이트 + */ + public void updateStreak(String userId, int currentStreak, int longestStreak, String lastStudyDate) { + String pk = StatsKey.userStatsPk(userId); + String sk = StatsKey.statsTotalSk(); + String now = Instant.now().toString(); + + Map key = new HashMap<>(); + key.put("PK", AttributeValue.builder().s(pk).build()); + key.put("SK", AttributeValue.builder().s(sk).build()); + + Map values = new HashMap<>(); + values.put(":current", AttributeValue.builder().n(String.valueOf(currentStreak)).build()); + values.put(":longest", AttributeValue.builder().n(String.valueOf(longestStreak)).build()); + values.put(":lastDate", AttributeValue.builder().s(lastStudyDate).build()); + values.put(":now", AttributeValue.builder().s(now).build()); + + String updateExpression = "SET " + + "currentStreak = :current, " + + "longestStreak = :longest, " + + "lastStudyDate = :lastDate, " + + "updatedAt = :now, " + + "createdAt = if_not_exists(createdAt, :now)"; + + UpdateItemRequest request = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(key) + .updateExpression(updateExpression) + .expressionAttributeValues(values) + .build(); + + AwsClients.dynamoDb().updateItem(request); + logger.info("Updated streak: userId={}, current={}, longest={}", userId, currentStreak, longestStreak); + } + + /** + * 현재 연도-주차 반환 (예: 2026-W02) + */ + private String getYearWeek() { + LocalDate now = LocalDate.now(); + WeekFields weekFields = WeekFields.of(Locale.getDefault()); + int week = now.get(weekFields.weekOfWeekBasedYear()); + int year = now.get(weekFields.weekBasedYear()); + return String.format("%d-W%02d", year, week); + } + + /** + * 현재 연도-월 반환 (예: 2026-01) + */ + private String getYearMonth() { + LocalDate now = LocalDate.now(); + return String.format("%d-%02d", now.getYear(), now.getMonthValue()); + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/service/StatsService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/service/StatsService.java index 8361f603..faba5591 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/service/StatsService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/service/StatsService.java @@ -13,101 +13,101 @@ * 테스트 결과 및 학습 이벤트를 통계로 집계 */ public class StatsService { - - private static final Logger logger = LoggerFactory.getLogger(StatsService.class); - - private final UserStatsRepository userStatsRepository; - - public StatsService() { - this.userStatsRepository = new UserStatsRepository(); - } - - /** - * 테스트 완료 시 통계 업데이트 - */ - public void recordTestCompletion(String userId, int correctAnswers, int incorrectAnswers) { - userStatsRepository.incrementTestStats(userId, correctAnswers, incorrectAnswers); - updateStudyStreak(userId); - logger.info("Recorded test completion: userId={}, correct={}, incorrect={}", - userId, correctAnswers, incorrectAnswers); - } - - /** - * 단어 학습 완료 시 통계 업데이트 - */ - public void recordWordsLearned(String userId, int newWords, int reviewedWords) { - userStatsRepository.incrementWordsLearned(userId, newWords, reviewedWords); - updateStudyStreak(userId); - logger.info("Recorded words learned: userId={}, new={}, reviewed={}", - userId, newWords, reviewedWords); - } - - /** - * 연속 학습일(Streak) 업데이트 - */ - private void updateStudyStreak(String userId) { - String today = LocalDate.now().toString(); - - Optional totalStats = userStatsRepository.findTotalStats(userId); - - int currentStreak = 1; - int longestStreak = 1; - - if (totalStats.isPresent()) { - UserStats stats = totalStats.get(); - String lastStudyDate = stats.getLastStudyDate(); - - if (lastStudyDate != null) { - LocalDate lastDate = LocalDate.parse(lastStudyDate); - LocalDate todayDate = LocalDate.now(); - - long daysDiff = todayDate.toEpochDay() - lastDate.toEpochDay(); - - if (daysDiff == 0) { - // 오늘 이미 학습함 - streak 유지 - return; - } else if (daysDiff == 1) { - // 어제 학습함 - streak 증가 - currentStreak = (stats.getCurrentStreak() != null ? stats.getCurrentStreak() : 0) + 1; - } else { - // 연속 학습 끊김 - streak 리셋 - currentStreak = 1; - } - } - - longestStreak = stats.getLongestStreak() != null ? - Math.max(stats.getLongestStreak(), currentStreak) : currentStreak; - } - - userStatsRepository.updateStreak(userId, currentStreak, longestStreak, today); - logger.info("Updated streak: userId={}, current={}, longest={}", userId, currentStreak, longestStreak); - } - - /** - * 일별 통계 조회 - */ - public Optional getDailyStats(String userId, String date) { - return userStatsRepository.findDailyStats(userId, date); - } - - /** - * 주별 통계 조회 - */ - public Optional getWeeklyStats(String userId, String yearWeek) { - return userStatsRepository.findWeeklyStats(userId, yearWeek); - } - - /** - * 월별 통계 조회 - */ - public Optional getMonthlyStats(String userId, String yearMonth) { - return userStatsRepository.findMonthlyStats(userId, yearMonth); - } - - /** - * 전체 통계 조회 - */ - public Optional getTotalStats(String userId) { - return userStatsRepository.findTotalStats(userId); - } + + private static final Logger logger = LoggerFactory.getLogger(StatsService.class); + + private final UserStatsRepository userStatsRepository; + + public StatsService() { + this.userStatsRepository = new UserStatsRepository(); + } + + /** + * 테스트 완료 시 통계 업데이트 + */ + public void recordTestCompletion(String userId, int correctAnswers, int incorrectAnswers) { + userStatsRepository.incrementTestStats(userId, correctAnswers, incorrectAnswers); + updateStudyStreak(userId); + logger.info("Recorded test completion: userId={}, correct={}, incorrect={}", + userId, correctAnswers, incorrectAnswers); + } + + /** + * 단어 학습 완료 시 통계 업데이트 + */ + public void recordWordsLearned(String userId, int newWords, int reviewedWords) { + userStatsRepository.incrementWordsLearned(userId, newWords, reviewedWords); + updateStudyStreak(userId); + logger.info("Recorded words learned: userId={}, new={}, reviewed={}", + userId, newWords, reviewedWords); + } + + /** + * 연속 학습일(Streak) 업데이트 + */ + private void updateStudyStreak(String userId) { + String today = LocalDate.now().toString(); + + Optional totalStats = userStatsRepository.findTotalStats(userId); + + int currentStreak = 1; + int longestStreak = 1; + + if (totalStats.isPresent()) { + UserStats stats = totalStats.get(); + String lastStudyDate = stats.getLastStudyDate(); + + if (lastStudyDate != null) { + LocalDate lastDate = LocalDate.parse(lastStudyDate); + LocalDate todayDate = LocalDate.now(); + + long daysDiff = todayDate.toEpochDay() - lastDate.toEpochDay(); + + if (daysDiff == 0) { + // 오늘 이미 학습함 - streak 유지 + return; + } else if (daysDiff == 1) { + // 어제 학습함 - streak 증가 + currentStreak = (stats.getCurrentStreak() != null ? stats.getCurrentStreak() : 0) + 1; + } else { + // 연속 학습 끊김 - streak 리셋 + currentStreak = 1; + } + } + + longestStreak = stats.getLongestStreak() != null ? + Math.max(stats.getLongestStreak(), currentStreak) : currentStreak; + } + + userStatsRepository.updateStreak(userId, currentStreak, longestStreak, today); + logger.info("Updated streak: userId={}, current={}, longest={}", userId, currentStreak, longestStreak); + } + + /** + * 일별 통계 조회 + */ + public Optional getDailyStats(String userId, String date) { + return userStatsRepository.findDailyStats(userId, date); + } + + /** + * 주별 통계 조회 + */ + public Optional getWeeklyStats(String userId, String yearWeek) { + return userStatsRepository.findWeeklyStats(userId, yearWeek); + } + + /** + * 월별 통계 조회 + */ + public Optional getMonthlyStats(String userId, String yearMonth) { + return userStatsRepository.findMonthlyStats(userId, yearMonth); + } + + /** + * 전체 통계 조회 + */ + public Optional getTotalStats(String userId) { + return userStatsRepository.findTotalStats(userId); + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PreSignUpHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PreSignUpHandler.java index 2a714b92..5d696ebe 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PreSignUpHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PreSignUpHandler.java @@ -9,45 +9,45 @@ import java.util.UUID; public class PreSignUpHandler implements RequestHandler, Map> { - - private static final Logger logger = LoggerFactory.getLogger(PreSignUpHandler.class); - private static final String DEFAULT_PROFILE_URL = System.getenv("DEFAULT_PROFILE_URL"); - - @Override - public Map handleRequest(Map input, Context context) { - - try { - @SuppressWarnings("unchecked") - Map request = (Map) input.get("request"); - - @SuppressWarnings("unchecked") - Map userAttributes = (Map) request.get("userAttributes"); - - String nickname = userAttributes.get("nickname"); - if (nickname == null || nickname.trim().isEmpty()) { - String defaultNickname = UUID.randomUUID().toString().substring(0, 6).toUpperCase() + "님"; - userAttributes.put("nickname", defaultNickname); - logger.info("nickname 기본값: {}", defaultNickname); - } - - String level = userAttributes.get("custom:level"); - if (level == null || level.trim().isEmpty()) { - userAttributes.put("custom:level", "BEGINNER"); - logger.info("level 선택 기본값: BEGINNER"); - } - - String profileUrl = userAttributes.get("custom:profileUrl"); - if (profileUrl == null || profileUrl.trim().isEmpty()) { - String defaultUrl = DEFAULT_PROFILE_URL != null - ? DEFAULT_PROFILE_URL - : "https://group2-englishstudy.s3.amazonaws.com/profile/default.png"; - userAttributes.put("custom:profileUrl", defaultUrl); - logger.info("프로필 이미지 기본값: {}", defaultUrl); - } - } catch (Exception e) { - logger.error("PreSignUp 트리거에서 오류가 발생했습니다"); - } - - return input; - } + + private static final Logger logger = LoggerFactory.getLogger(PreSignUpHandler.class); + private static final String DEFAULT_PROFILE_URL = System.getenv("DEFAULT_PROFILE_URL"); + + @Override + public Map handleRequest(Map input, Context context) { + + try { + @SuppressWarnings("unchecked") + Map request = (Map) input.get("request"); + + @SuppressWarnings("unchecked") + Map userAttributes = (Map) request.get("userAttributes"); + + String nickname = userAttributes.get("nickname"); + if (nickname == null || nickname.trim().isEmpty()) { + String defaultNickname = UUID.randomUUID().toString().substring(0, 6).toUpperCase() + "님"; + userAttributes.put("nickname", defaultNickname); + logger.info("nickname 기본값: {}", defaultNickname); + } + + String level = userAttributes.get("custom:level"); + if (level == null || level.trim().isEmpty()) { + userAttributes.put("custom:level", "BEGINNER"); + logger.info("level 선택 기본값: BEGINNER"); + } + + String profileUrl = userAttributes.get("custom:profileUrl"); + if (profileUrl == null || profileUrl.trim().isEmpty()) { + String defaultUrl = DEFAULT_PROFILE_URL != null + ? DEFAULT_PROFILE_URL + : "https://group2-englishstudy.s3.amazonaws.com/profile/default.png"; + userAttributes.put("custom:profileUrl", defaultUrl); + logger.info("프로필 이미지 기본값: {}", defaultUrl); + } + } catch (Exception e) { + logger.error("PreSignUp 트리거에서 오류가 발생했습니다"); + } + + return input; + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/UserHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/UserHandler.java index 5063dbb5..bc62d83b 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/UserHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/UserHandler.java @@ -2,54 +2,52 @@ import com.amazonaws.services.lambda.runtime.Context; import com.amazonaws.services.lambda.runtime.RequestHandler; -import com.amazonaws.services.lambda.runtime.events.APIGatewayCustomAuthorizerEvent; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; import com.mzc.secondproject.serverless.common.exception.CommonErrorCode; import com.mzc.secondproject.serverless.common.util.ResponseGenerator; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import software.amazon.awssdk.services.s3.endpoints.internal.Value; import java.util.HashMap; import java.util.Map; public class UserHandler implements RequestHandler { - - private static final Logger logger = LoggerFactory.getLogger(UserHandler.class); - - @Override - public APIGatewayProxyResponseEvent handleRequest( - APIGatewayProxyRequestEvent request, - Context context - ) { - try { - @SuppressWarnings("unchecked") - Map authorizer = request.getRequestContext().getAuthorizer(); - - // Cognito Authorizer에서 claims 추출 - @SuppressWarnings("unchecked") - Map claims = (Map) authorizer.get("claims"); - - if (claims == null) { - return ResponseGenerator.fail(CommonErrorCode.INVALID_TOKEN, "claims가 존재하지 않습니다."); - } - - String userId = claims.get("sub"); - String email = claims.get("email"); - String nickname = claims.get("nickname"); - - logger.info("인증된 사용자 : userId={}, email={}, nickname={}", userId, email, nickname); - - Map data = new HashMap<>(); - data.put("userId", userId); - data.put("email", email); - data.put("nickname", nickname); - - return ResponseGenerator.ok(nickname + "환영합니다", data); - } catch (Exception e){ - return ResponseGenerator.fail(CommonErrorCode.INTERNAL_SERVER_ERROR); - } - } - + + private static final Logger logger = LoggerFactory.getLogger(UserHandler.class); + + @Override + public APIGatewayProxyResponseEvent handleRequest( + APIGatewayProxyRequestEvent request, + Context context + ) { + try { + @SuppressWarnings("unchecked") + Map authorizer = request.getRequestContext().getAuthorizer(); + + // Cognito Authorizer에서 claims 추출 + @SuppressWarnings("unchecked") + Map claims = (Map) authorizer.get("claims"); + + if (claims == null) { + return ResponseGenerator.fail(CommonErrorCode.INVALID_TOKEN, "claims가 존재하지 않습니다."); + } + + String userId = claims.get("sub"); + String email = claims.get("email"); + String nickname = claims.get("nickname"); + + logger.info("인증된 사용자 : userId={}, email={}, nickname={}", userId, email, nickname); + + Map data = new HashMap<>(); + data.put("userId", userId); + data.put("email", email); + data.put("nickname", nickname); + + return ResponseGenerator.ok(nickname + "환영합니다", data); + } catch (Exception e) { + return ResponseGenerator.fail(CommonErrorCode.INTERNAL_SERVER_ERROR); + } + } + } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/model/User.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/model/User.java index c15e2398..ded776f3 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/model/User.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/model/User.java @@ -1,6 +1,9 @@ package com.mzc.secondproject.serverless.domain.user.model; -import lombok.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.*; @Data @@ -9,57 +12,57 @@ @AllArgsConstructor @DynamoDbBean public class User { - - private String pk; // USER#{cognitoSub} - private String sk; // METADATA - private String gsi1pk; // EMAIL#{email} - private String gsi1sk; // USER#{cognitoSub} - private String gsi2pk; // LEVEL#{level} - private String gsi2sk; // USER#{cognitoSub} - - private String cognitoSub; // Cognito sub (Primary ID) - private String email; - private String nickname; - private String level; - private String createdAt; - private String updatedAt; - private String lastLoginAt; - private Long ttl; - - @DynamoDbPartitionKey - @DynamoDbAttribute("PK") - public String getPk() { - return pk; - } - - @DynamoDbSortKey - @DynamoDbAttribute("SK") - public String getSk() { - return sk; - } - - @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") - @DynamoDbAttribute("GSI1PK") - public String getGsi1pk() { - return gsi1pk; - } - - @DynamoDbSecondarySortKey(indexNames = "GSI1") - @DynamoDbAttribute("GSI1SK") - public String getGsi1sk() { - return gsi1sk; - } - - @DynamoDbSecondaryPartitionKey(indexNames = "GSI2") - @DynamoDbAttribute("GSI2PK") - public String getGsi2pk() { - return gsi2pk; - } - - @DynamoDbSecondarySortKey(indexNames = "GSI2") - @DynamoDbAttribute("GSI2SK") - public String getGsi2sk() { - return gsi2sk; - } - + + private String pk; // USER#{cognitoSub} + private String sk; // METADATA + private String gsi1pk; // EMAIL#{email} + private String gsi1sk; // USER#{cognitoSub} + private String gsi2pk; // LEVEL#{level} + private String gsi2sk; // USER#{cognitoSub} + + private String cognitoSub; // Cognito sub (Primary ID) + private String email; + private String nickname; + private String level; + private String createdAt; + private String updatedAt; + private String lastLoginAt; + private Long ttl; + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { + return pk; + } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { + return sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1PK") + public String getGsi1pk() { + return gsi1pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1SK") + public String getGsi1sk() { + return gsi1sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI2") + @DynamoDbAttribute("GSI2PK") + public String getGsi2pk() { + return gsi2pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI2") + @DynamoDbAttribute("GSI2SK") + public String getGsi2sk() { + return gsi2sk; + } + } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/repository/UserRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/repository/UserRepository.java index 29f67ce7..9ba4e0db 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/repository/UserRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/repository/UserRepository.java @@ -8,64 +8,63 @@ import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; import software.amazon.awssdk.enhanced.dynamodb.Key; import software.amazon.awssdk.enhanced.dynamodb.TableSchema; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbAttribute; import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; import java.util.Optional; public class UserRepository { - - private static final Logger logger = LoggerFactory.getLogger(UserRepository.class); - private static final String TABLE_NAME = System.getenv("USER_TABLE_NAME"); - - private final DynamoDbTable table; - - public UserRepository() { - this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(User.class)); - } - - public User save(User user) { - table.putItem(user); - return user; - } - - public Optional findById(String userId) { - Key key = Key.builder() - .partitionValue(userId) - .build(); - - User user = table.getItem(key); - return Optional.ofNullable(user); - } - - /** - * 이메일로 사용자 조회 (로그인, 중복 체크용) - * GSI1 사용: GSI1PK = EMAIL#{email} - */ - public Optional findByEmail(String email) { - QueryConditional queryConditional = QueryConditional - .keyEqualTo(Key.builder() - .partitionValue("EMAIL#" + email) - .build()); - - DynamoDbIndex gsi1 = table.index("GSI1"); - - return gsi1.query(queryConditional) - .stream() - .flatMap(page -> page.items().stream()) - .findFirst(); - } - - public boolean existsByEmail(String email) { - return findByEmail(email).isPresent(); - } - - - public void delete(String userId) { - Key key = Key.builder() - .partitionValue(userId) - .build(); - table.deleteItem(key); - } - + + private static final Logger logger = LoggerFactory.getLogger(UserRepository.class); + private static final String TABLE_NAME = System.getenv("USER_TABLE_NAME"); + + private final DynamoDbTable table; + + public UserRepository() { + this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(User.class)); + } + + public User save(User user) { + table.putItem(user); + return user; + } + + public Optional findById(String userId) { + Key key = Key.builder() + .partitionValue(userId) + .build(); + + User user = table.getItem(key); + return Optional.ofNullable(user); + } + + /** + * 이메일로 사용자 조회 (로그인, 중복 체크용) + * GSI1 사용: GSI1PK = EMAIL#{email} + */ + public Optional findByEmail(String email) { + QueryConditional queryConditional = QueryConditional + .keyEqualTo(Key.builder() + .partitionValue("EMAIL#" + email) + .build()); + + DynamoDbIndex gsi1 = table.index("GSI1"); + + return gsi1.query(queryConditional) + .stream() + .flatMap(page -> page.items().stream()) + .findFirst(); + } + + public boolean existsByEmail(String email) { + return findByEmail(email).isPresent(); + } + + + public void delete(String userId) { + Key key = Key.builder() + .partitionValue(userId) + .build(); + table.deleteItem(key); + } + } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/service/UserService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/service/UserService.java index 02e5388b..96f78c31 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/service/UserService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/service/UserService.java @@ -5,8 +5,8 @@ public class UserService { - - private static final Logger logger = LoggerFactory.getLogger(UserService.class); - - + + private static final Logger logger = LoggerFactory.getLogger(UserService.class); + + } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/constants/VocabKey.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/constants/VocabKey.java index d1effe11..9c994acc 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/constants/VocabKey.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/constants/VocabKey.java @@ -3,77 +3,76 @@ import com.mzc.secondproject.serverless.common.constants.DynamoDbKey; public final class VocabKey { - - private VocabKey() {} - - // Prefixes - public static final String WORD = "WORD#"; - public static final String DAILY = "DAILY#"; - public static final String LEVEL = "LEVEL#"; - public static final String CATEGORY = "CATEGORY#"; - public static final String TEST = "TEST#"; - public static final String DATE = "DATE#"; - public static final String STATUS_PREFIX = "STATUS#"; - - // Suffix - public static final String SUFFIX_REVIEW = "#REVIEW"; - public static final String SUFFIX_STATUS = "#STATUS"; - public static final String SUFFIX_GROUP = "#GROUP"; - public static final String SUFFIX_BOOKMARKED = "#BOOKMARKED"; - - // Special Keys - public static final String DAILY_ALL = "DAILY#ALL"; - - // Key Builders - public static String userPk(String userId) { - return DynamoDbKey.USER + userId; - } - - public static String wordPk(String wordId) { - return WORD + wordId; - } - - public static String wordSk(String wordId) { - return WORD + wordId; - } - - public static String dailyPk(String userId) { - return DAILY + userId; - } - - public static String dateSk(String date) { - return DATE + date; - } - - public static String levelPk(String level) { - return LEVEL + level; - } - - public static String categoryPk(String category) { - return CATEGORY + category; - } - - public static String statusSk(String status) { - return STATUS_PREFIX + status; - } - - public static String userReviewPk(String userId) { - return DynamoDbKey.USER + userId + SUFFIX_REVIEW; - } - - public static String userStatusPk(String userId) { - return DynamoDbKey.USER + userId + SUFFIX_STATUS; - } - - public static String userGroupPk(String userId) { - return DynamoDbKey.USER + userId + SUFFIX_GROUP; - } - - public static String userBookmarkedPk(String userId) { - return DynamoDbKey.USER + userId + SUFFIX_BOOKMARKED; - } - - public static String testPk(String testId) { - return TEST + testId; - } + + // Prefixes + public static final String WORD = "WORD#"; + public static final String DAILY = "DAILY#"; + public static final String LEVEL = "LEVEL#"; + public static final String CATEGORY = "CATEGORY#"; + public static final String TEST = "TEST#"; + public static final String DATE = "DATE#"; + public static final String STATUS_PREFIX = "STATUS#"; + // Suffix + public static final String SUFFIX_REVIEW = "#REVIEW"; + public static final String SUFFIX_STATUS = "#STATUS"; + public static final String SUFFIX_GROUP = "#GROUP"; + public static final String SUFFIX_BOOKMARKED = "#BOOKMARKED"; + // Special Keys + public static final String DAILY_ALL = "DAILY#ALL"; + + private VocabKey() { + } + + // Key Builders + public static String userPk(String userId) { + return DynamoDbKey.USER + userId; + } + + public static String wordPk(String wordId) { + return WORD + wordId; + } + + public static String wordSk(String wordId) { + return WORD + wordId; + } + + public static String dailyPk(String userId) { + return DAILY + userId; + } + + public static String dateSk(String date) { + return DATE + date; + } + + public static String levelPk(String level) { + return LEVEL + level; + } + + public static String categoryPk(String category) { + return CATEGORY + category; + } + + public static String statusSk(String status) { + return STATUS_PREFIX + status; + } + + public static String userReviewPk(String userId) { + return DynamoDbKey.USER + userId + SUFFIX_REVIEW; + } + + public static String userStatusPk(String userId) { + return DynamoDbKey.USER + userId + SUFFIX_STATUS; + } + + public static String userGroupPk(String userId) { + return DynamoDbKey.USER + userId + SUFFIX_GROUP; + } + + public static String userBookmarkedPk(String userId) { + return DynamoDbKey.USER + userId + SUFFIX_BOOKMARKED; + } + + public static String testPk(String testId) { + return TEST + testId; + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/BatchGetWordsRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/BatchGetWordsRequest.java index 80520c19..0e6738f2 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/BatchGetWordsRequest.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/BatchGetWordsRequest.java @@ -13,7 +13,7 @@ @NoArgsConstructor @AllArgsConstructor public class BatchGetWordsRequest { - - @NotEmpty(message = "is required") - private List wordIds; + + @NotEmpty(message = "is required") + private List wordIds; } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/CreateWordGroupRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/CreateWordGroupRequest.java index 5929992f..deb44f4b 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/CreateWordGroupRequest.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/CreateWordGroupRequest.java @@ -12,11 +12,11 @@ @NoArgsConstructor @AllArgsConstructor public class CreateWordGroupRequest { - - @NotBlank(message = "is required") - @Size(min = 1, max = 50, message = "must be between 1 and 50 characters") - private String groupName; - - @Size(max = 200, message = "must be at most 200 characters") - private String description; + + @NotBlank(message = "is required") + @Size(min = 1, max = 50, message = "must be between 1 and 50 characters") + private String groupName; + + @Size(max = 200, message = "must be at most 200 characters") + private String description; } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/CreateWordRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/CreateWordRequest.java index 5b60f0e6..15c77f58 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/CreateWordRequest.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/CreateWordRequest.java @@ -12,21 +12,21 @@ @NoArgsConstructor @AllArgsConstructor public class CreateWordRequest { - - @NotBlank(message = "is required") - @Size(max = 100, message = "must be at most 100 characters") - private String english; - - @NotBlank(message = "is required") - @Size(max = 100, message = "must be at most 100 characters") - private String korean; - - @Size(max = 500, message = "must be at most 500 characters") - private String example; - - @Builder.Default - private String level = "BEGINNER"; - - @Builder.Default - private String category = "DAILY"; + + @NotBlank(message = "is required") + @Size(max = 100, message = "must be at most 100 characters") + private String english; + + @NotBlank(message = "is required") + @Size(max = 100, message = "must be at most 100 characters") + private String korean; + + @Size(max = 500, message = "must be at most 500 characters") + private String example; + + @Builder.Default + private String level = "BEGINNER"; + + @Builder.Default + private String category = "DAILY"; } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/CreateWordsBatchRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/CreateWordsBatchRequest.java index e923851c..745d82e9 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/CreateWordsBatchRequest.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/CreateWordsBatchRequest.java @@ -14,8 +14,8 @@ @NoArgsConstructor @AllArgsConstructor public class CreateWordsBatchRequest { - - @NotEmpty(message = "is required") - @Valid - private List words; + + @NotEmpty(message = "is required") + @Valid + private List words; } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/StartTestRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/StartTestRequest.java index cebc1335..be8f4a19 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/StartTestRequest.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/StartTestRequest.java @@ -10,6 +10,6 @@ @NoArgsConstructor @AllArgsConstructor public class StartTestRequest { - @Builder.Default - private String testType = "DAILY"; + @Builder.Default + private String testType = "DAILY"; } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/SubmitTestRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/SubmitTestRequest.java index 5c30221c..9a9b0b1d 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/SubmitTestRequest.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/SubmitTestRequest.java @@ -15,27 +15,27 @@ @NoArgsConstructor @AllArgsConstructor public class SubmitTestRequest { - - @NotBlank(message = "is required") - private String testId; - - @Builder.Default - private String testType = "DAILY"; - - @NotEmpty(message = "is required") - @Valid - private List answers; - - private String startedAt; - - @Data - @Builder - @NoArgsConstructor - @AllArgsConstructor - public static class TestAnswer { - @NotBlank(message = "is required") - private String wordId; - - private String answer; // 빈 값 허용 (오답 처리) - } + + @NotBlank(message = "is required") + private String testId; + + @Builder.Default + private String testType = "DAILY"; + + @NotEmpty(message = "is required") + @Valid + private List answers; + + private String startedAt; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class TestAnswer { + @NotBlank(message = "is required") + private String wordId; + + private String answer; // 빈 값 허용 (오답 처리) + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/SynthesizeVoiceRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/SynthesizeVoiceRequest.java index 3e3b0fcb..177b94e9 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/SynthesizeVoiceRequest.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/SynthesizeVoiceRequest.java @@ -11,13 +11,13 @@ @NoArgsConstructor @AllArgsConstructor public class SynthesizeVoiceRequest { - - @NotBlank(message = "is required") - private String wordId; - - @Builder.Default - private String voice = "FEMALE"; // MALE 또는 FEMALE - - @Builder.Default - private String type = "WORD"; // WORD 또는 EXAMPLE + + @NotBlank(message = "is required") + private String wordId; + + @Builder.Default + private String voice = "FEMALE"; // MALE 또는 FEMALE + + @Builder.Default + private String type = "WORD"; // WORD 또는 EXAMPLE } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/UpdateUserWordRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/UpdateUserWordRequest.java index 627d269d..5fc93ee2 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/UpdateUserWordRequest.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/UpdateUserWordRequest.java @@ -11,7 +11,7 @@ @NoArgsConstructor @AllArgsConstructor public class UpdateUserWordRequest { - - @NotNull(message = "is required") - private Boolean isCorrect; + + @NotNull(message = "is required") + private Boolean isCorrect; } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/UpdateUserWordTagRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/UpdateUserWordTagRequest.java index b4596498..a9870885 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/UpdateUserWordTagRequest.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/dto/request/UpdateUserWordTagRequest.java @@ -10,7 +10,7 @@ @NoArgsConstructor @AllArgsConstructor public class UpdateUserWordTagRequest { - private Boolean bookmarked; - private Boolean favorite; - private String difficulty; + private Boolean bookmarked; + private Boolean favorite; + private String difficulty; } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/enums/TestType.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/enums/TestType.java index 2fd34207..0c9e73a3 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/enums/TestType.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/enums/TestType.java @@ -3,46 +3,46 @@ import java.util.Arrays; public enum TestType { - DAILY("daily", "일일 테스트"), - WEEKLY("weekly", "주간 테스트"), - CUSTOM("custom", "사용자 지정 테스트"); - - private final String code; - private final String displayName; - - TestType(String code, String displayName) { - this.code = code; - this.displayName = displayName; - } - - public String getCode() { - return code; - } - - public String getDisplayName() { - return displayName; - } - - public static boolean isValid(String value) { - if (value == null) return false; - return Arrays.stream(values()) - .anyMatch(type -> type.name().equalsIgnoreCase(value) || type.code.equalsIgnoreCase(value)); - } - - public static TestType fromString(String value) { - if (value == null) { - throw new IllegalArgumentException("TestType value cannot be null"); - } - return Arrays.stream(values()) - .filter(type -> type.name().equalsIgnoreCase(value) || type.code.equalsIgnoreCase(value)) - .findFirst() - .orElseThrow(() -> new IllegalArgumentException("Unknown TestType: " + value)); - } - - public static TestType fromStringOrDefault(String value, TestType defaultValue) { - if (value == null || !isValid(value)) { - return defaultValue; - } - return fromString(value); - } + DAILY("daily", "일일 테스트"), + WEEKLY("weekly", "주간 테스트"), + CUSTOM("custom", "사용자 지정 테스트"); + + private final String code; + private final String displayName; + + TestType(String code, String displayName) { + this.code = code; + this.displayName = displayName; + } + + public static boolean isValid(String value) { + if (value == null) return false; + return Arrays.stream(values()) + .anyMatch(type -> type.name().equalsIgnoreCase(value) || type.code.equalsIgnoreCase(value)); + } + + public static TestType fromString(String value) { + if (value == null) { + throw new IllegalArgumentException("TestType value cannot be null"); + } + return Arrays.stream(values()) + .filter(type -> type.name().equalsIgnoreCase(value) || type.code.equalsIgnoreCase(value)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Unknown TestType: " + value)); + } + + public static TestType fromStringOrDefault(String value, TestType defaultValue) { + if (value == null || !isValid(value)) { + return defaultValue; + } + return fromString(value); + } + + public String getCode() { + return code; + } + + public String getDisplayName() { + return displayName; + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/enums/WordCategory.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/enums/WordCategory.java index cfbad6b7..9a65b41a 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/enums/WordCategory.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/enums/WordCategory.java @@ -3,48 +3,48 @@ import java.util.Arrays; public enum WordCategory { - DAILY("daily", "일상"), - BUSINESS("business", "비즈니스"), - ACADEMIC("academic", "학술"), - TRAVEL("travel", "여행"), - TECHNOLOGY("technology", "기술"); - - private final String code; - private final String displayName; - - WordCategory(String code, String displayName) { - this.code = code; - this.displayName = displayName; - } - - public String getCode() { - return code; - } - - public String getDisplayName() { - return displayName; - } - - public static boolean isValid(String value) { - if (value == null) return false; - return Arrays.stream(values()) - .anyMatch(cat -> cat.name().equalsIgnoreCase(value) || cat.code.equalsIgnoreCase(value)); - } - - public static WordCategory fromString(String value) { - if (value == null) { - throw new IllegalArgumentException("WordCategory value cannot be null"); - } - return Arrays.stream(values()) - .filter(cat -> cat.name().equalsIgnoreCase(value) || cat.code.equalsIgnoreCase(value)) - .findFirst() - .orElseThrow(() -> new IllegalArgumentException("Unknown WordCategory: " + value)); - } - - public static WordCategory fromStringOrDefault(String value, WordCategory defaultValue) { - if (value == null || !isValid(value)) { - return defaultValue; - } - return fromString(value); - } + DAILY("daily", "일상"), + BUSINESS("business", "비즈니스"), + ACADEMIC("academic", "학술"), + TRAVEL("travel", "여행"), + TECHNOLOGY("technology", "기술"); + + private final String code; + private final String displayName; + + WordCategory(String code, String displayName) { + this.code = code; + this.displayName = displayName; + } + + public static boolean isValid(String value) { + if (value == null) return false; + return Arrays.stream(values()) + .anyMatch(cat -> cat.name().equalsIgnoreCase(value) || cat.code.equalsIgnoreCase(value)); + } + + public static WordCategory fromString(String value) { + if (value == null) { + throw new IllegalArgumentException("WordCategory value cannot be null"); + } + return Arrays.stream(values()) + .filter(cat -> cat.name().equalsIgnoreCase(value) || cat.code.equalsIgnoreCase(value)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Unknown WordCategory: " + value)); + } + + public static WordCategory fromStringOrDefault(String value, WordCategory defaultValue) { + if (value == null || !isValid(value)) { + return defaultValue; + } + return fromString(value); + } + + public String getCode() { + return code; + } + + public String getDisplayName() { + return displayName; + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/enums/WordStatus.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/enums/WordStatus.java index 14d22175..afb5cd44 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/enums/WordStatus.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/enums/WordStatus.java @@ -3,48 +3,48 @@ import java.util.Arrays; public enum WordStatus { - NEW("new", "새 단어"), - LEARNING("learning", "학습 중"), - REVIEWING("reviewing", "복습 중"), - MASTERED("mastered", "완료"), - UNKNOWN("unknown", "모르겠음"); - - private final String code; - private final String displayName; - - WordStatus(String code, String displayName) { - this.code = code; - this.displayName = displayName; - } - - public String getCode() { - return code; - } - - public String getDisplayName() { - return displayName; - } - - public static boolean isValid(String value) { - if (value == null) return false; - return Arrays.stream(values()) - .anyMatch(status -> status.name().equalsIgnoreCase(value) || status.code.equalsIgnoreCase(value)); - } - - public static WordStatus fromString(String value) { - if (value == null) { - throw new IllegalArgumentException("WordStatus value cannot be null"); - } - return Arrays.stream(values()) - .filter(status -> status.name().equalsIgnoreCase(value) || status.code.equalsIgnoreCase(value)) - .findFirst() - .orElseThrow(() -> new IllegalArgumentException("Unknown WordStatus: " + value)); - } - - public static WordStatus fromStringOrDefault(String value, WordStatus defaultValue) { - if (value == null || !isValid(value)) { - return defaultValue; - } - return fromString(value); - } + NEW("new", "새 단어"), + LEARNING("learning", "학습 중"), + REVIEWING("reviewing", "복습 중"), + MASTERED("mastered", "완료"), + UNKNOWN("unknown", "모르겠음"); + + private final String code; + private final String displayName; + + WordStatus(String code, String displayName) { + this.code = code; + this.displayName = displayName; + } + + public static boolean isValid(String value) { + if (value == null) return false; + return Arrays.stream(values()) + .anyMatch(status -> status.name().equalsIgnoreCase(value) || status.code.equalsIgnoreCase(value)); + } + + public static WordStatus fromString(String value) { + if (value == null) { + throw new IllegalArgumentException("WordStatus value cannot be null"); + } + return Arrays.stream(values()) + .filter(status -> status.name().equalsIgnoreCase(value) || status.code.equalsIgnoreCase(value)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Unknown WordStatus: " + value)); + } + + public static WordStatus fromStringOrDefault(String value, WordStatus defaultValue) { + if (value == null || !isValid(value)) { + return defaultValue; + } + return fromString(value); + } + + public String getCode() { + return code; + } + + public String getDisplayName() { + return displayName; + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/exception/VocabularyErrorCode.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/exception/VocabularyErrorCode.java index d441707c..dd576d63 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/exception/VocabularyErrorCode.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/exception/VocabularyErrorCode.java @@ -4,68 +4,68 @@ /** * 단어 학습 도메인 에러 코드 - * + *

* 단어(Word), 사용자 단어(UserWord), 일일 학습(DailyStudy) 관련 에러 코드를 정의합니다. */ public enum VocabularyErrorCode implements DomainErrorCode { - - // 단어 관련 에러 - WORD_NOT_FOUND("WORD_001", "단어를 찾을 수 없습니다", 404), - WORD_ALREADY_EXISTS("WORD_002", "이미 존재하는 단어입니다", 409), - INVALID_WORD_DATA("WORD_003", "단어 데이터가 유효하지 않습니다", 400), - - // 사용자 단어 관련 에러 - USER_WORD_NOT_FOUND("USER_WORD_001", "사용자 단어 정보를 찾을 수 없습니다", 404), - INVALID_DIFFICULTY("USER_WORD_002", "유효하지 않은 난이도입니다", 400), - INVALID_WORD_STATUS("USER_WORD_003", "유효하지 않은 단어 상태입니다", 400), - - // 학습 관련 에러 - DAILY_STUDY_NOT_FOUND("STUDY_001", "일일 학습 정보를 찾을 수 없습니다", 404), - STUDY_LIMIT_EXCEEDED("STUDY_002", "일일 학습 한도를 초과했습니다", 400), - INVALID_STUDY_LEVEL("STUDY_003", "유효하지 않은 학습 레벨입니다", 400), - - // 카테고리/레벨 관련 에러 - INVALID_CATEGORY("CATEGORY_001", "유효하지 않은 카테고리입니다", 400), - INVALID_LEVEL("LEVEL_001", "유효하지 않은 레벨입니다", 400), - - // 단어 그룹 관련 에러 - GROUP_NOT_FOUND("GROUP_001", "단어 그룹을 찾을 수 없습니다", 404), - GROUP_ALREADY_EXISTS("GROUP_002", "이미 존재하는 그룹입니다", 409), - - // 테스트 관련 에러 - TEST_NOT_FOUND("TEST_001", "테스트 정보를 찾을 수 없습니다", 404), - NO_WORDS_TO_TEST("TEST_002", "테스트할 단어가 없습니다", 400), - ; - - private static final String DOMAIN = "VOCABULARY"; - - private final String code; - private final String message; - private final int statusCode; - - VocabularyErrorCode(String code, String message, int statusCode) { - this.code = code; - this.message = message; - this.statusCode = statusCode; - } - - @Override - public String getDomain() { - return DOMAIN; - } - - @Override - public String getCode() { - return code; - } - - @Override - public String getMessage() { - return message; - } - - @Override - public int getStatusCode() { - return statusCode; - } + + // 단어 관련 에러 + WORD_NOT_FOUND("WORD_001", "단어를 찾을 수 없습니다", 404), + WORD_ALREADY_EXISTS("WORD_002", "이미 존재하는 단어입니다", 409), + INVALID_WORD_DATA("WORD_003", "단어 데이터가 유효하지 않습니다", 400), + + // 사용자 단어 관련 에러 + USER_WORD_NOT_FOUND("USER_WORD_001", "사용자 단어 정보를 찾을 수 없습니다", 404), + INVALID_DIFFICULTY("USER_WORD_002", "유효하지 않은 난이도입니다", 400), + INVALID_WORD_STATUS("USER_WORD_003", "유효하지 않은 단어 상태입니다", 400), + + // 학습 관련 에러 + DAILY_STUDY_NOT_FOUND("STUDY_001", "일일 학습 정보를 찾을 수 없습니다", 404), + STUDY_LIMIT_EXCEEDED("STUDY_002", "일일 학습 한도를 초과했습니다", 400), + INVALID_STUDY_LEVEL("STUDY_003", "유효하지 않은 학습 레벨입니다", 400), + + // 카테고리/레벨 관련 에러 + INVALID_CATEGORY("CATEGORY_001", "유효하지 않은 카테고리입니다", 400), + INVALID_LEVEL("LEVEL_001", "유효하지 않은 레벨입니다", 400), + + // 단어 그룹 관련 에러 + GROUP_NOT_FOUND("GROUP_001", "단어 그룹을 찾을 수 없습니다", 404), + GROUP_ALREADY_EXISTS("GROUP_002", "이미 존재하는 그룹입니다", 409), + + // 테스트 관련 에러 + TEST_NOT_FOUND("TEST_001", "테스트 정보를 찾을 수 없습니다", 404), + NO_WORDS_TO_TEST("TEST_002", "테스트할 단어가 없습니다", 400), + ; + + private static final String DOMAIN = "VOCABULARY"; + + private final String code; + private final String message; + private final int statusCode; + + VocabularyErrorCode(String code, String message, int statusCode) { + this.code = code; + this.message = message; + this.statusCode = statusCode; + } + + @Override + public String getDomain() { + return DOMAIN; + } + + @Override + public String getCode() { + return code; + } + + @Override + public String getMessage() { + return message; + } + + @Override + public int getStatusCode() { + return statusCode; + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/exception/VocabularyException.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/exception/VocabularyException.java index a046256c..7ab6adea 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/exception/VocabularyException.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/exception/VocabularyException.java @@ -4,120 +4,120 @@ /** * 단어 학습 도메인 예외 클래스 - * + *

* 정적 팩토리 메서드를 통해 가독성 높은 예외 생성을 지원합니다. - * + *

* 사용 예시: * throw VocabularyException.wordNotFound(wordId); * throw VocabularyException.invalidDifficulty("INVALID"); */ public class VocabularyException extends ServerlessException { - - private VocabularyException(VocabularyErrorCode errorCode) { - super(errorCode); - } - - private VocabularyException(VocabularyErrorCode errorCode, String message) { - super(errorCode, message); - } - - private VocabularyException(VocabularyErrorCode errorCode, Throwable cause) { - super(errorCode, cause); - } - - // === 단어(Word) 관련 팩토리 메서드 === - - public static VocabularyException wordNotFound(String wordId) { - return (VocabularyException) new VocabularyException(VocabularyErrorCode.WORD_NOT_FOUND, - String.format("단어를 찾을 수 없습니다 (ID: %s)", wordId)) - .addDetail("wordId", wordId); - } - - public static VocabularyException wordAlreadyExists(String english) { - return (VocabularyException) new VocabularyException(VocabularyErrorCode.WORD_ALREADY_EXISTS, - String.format("이미 존재하는 단어입니다: '%s'", english)) - .addDetail("english", english); - } - - public static VocabularyException invalidWordData(String reason) { - return new VocabularyException(VocabularyErrorCode.INVALID_WORD_DATA, reason); - } - - // === 사용자 단어(UserWord) 관련 팩토리 메서드 === - - public static VocabularyException userWordNotFound(String userId, String wordId) { - return (VocabularyException) new VocabularyException(VocabularyErrorCode.USER_WORD_NOT_FOUND, - String.format("사용자 단어 정보를 찾을 수 없습니다 (userId: %s, wordId: %s)", userId, wordId)) - .addDetail("userId", userId) - .addDetail("wordId", wordId); - } - - public static VocabularyException invalidDifficulty(String difficulty) { - return (VocabularyException) new VocabularyException(VocabularyErrorCode.INVALID_DIFFICULTY, - String.format("유효하지 않은 난이도입니다: '%s'. EASY, NORMAL, HARD 중 하나여야 합니다", difficulty)) - .addDetail("invalidValue", difficulty) - .addDetail("allowedValues", "EASY, NORMAL, HARD"); - } - - public static VocabularyException invalidWordStatus(String status) { - return (VocabularyException) new VocabularyException(VocabularyErrorCode.INVALID_WORD_STATUS, - String.format("유효하지 않은 단어 상태입니다: '%s'", status)) - .addDetail("invalidValue", status); - } - - // === 학습(Study) 관련 팩토리 메서드 === - - public static VocabularyException dailyStudyNotFound(String userId, String date) { - return (VocabularyException) new VocabularyException(VocabularyErrorCode.DAILY_STUDY_NOT_FOUND, - String.format("일일 학습 정보를 찾을 수 없습니다 (userId: %s, date: %s)", userId, date)) - .addDetail("userId", userId) - .addDetail("date", date); - } - - public static VocabularyException studyLimitExceeded(int limit) { - return (VocabularyException) new VocabularyException(VocabularyErrorCode.STUDY_LIMIT_EXCEEDED, - String.format("일일 학습 한도(%d개)를 초과했습니다", limit)) - .addDetail("dailyLimit", limit); - } - - public static VocabularyException invalidStudyLevel(String level) { - return (VocabularyException) new VocabularyException(VocabularyErrorCode.INVALID_STUDY_LEVEL, - String.format("유효하지 않은 학습 레벨입니다: '%s'", level)) - .addDetail("invalidValue", level); - } - - // === 카테고리/레벨 관련 팩토리 메서드 === - - public static VocabularyException invalidCategory(String category) { - return (VocabularyException) new VocabularyException(VocabularyErrorCode.INVALID_CATEGORY, - String.format("유효하지 않은 카테고리입니다: '%s'", category)) - .addDetail("invalidValue", category); - } - - public static VocabularyException invalidLevel(String level) { - return (VocabularyException) new VocabularyException(VocabularyErrorCode.INVALID_LEVEL, - String.format("유효하지 않은 레벨입니다: '%s'", level)) - .addDetail("invalidValue", level); - } - - // === 단어 그룹(WordGroup) 관련 팩토리 메서드 === - - public static VocabularyException groupNotFound(String groupId) { - return (VocabularyException) new VocabularyException(VocabularyErrorCode.GROUP_NOT_FOUND, - String.format("단어 그룹을 찾을 수 없습니다 (ID: %s)", groupId)) - .addDetail("groupId", groupId); - } - - public static VocabularyException groupAlreadyExists(String groupName) { - return (VocabularyException) new VocabularyException(VocabularyErrorCode.GROUP_ALREADY_EXISTS, - String.format("이미 존재하는 그룹입니다: '%s'", groupName)) - .addDetail("groupName", groupName); - } - - // === 테스트(Test) 관련 팩토리 메서드 === - - public static VocabularyException noWordsToTest() { - return new VocabularyException(VocabularyErrorCode.NO_WORDS_TO_TEST, - "테스트할 단어가 없습니다. 먼저 일일 학습을 시작해주세요."); - } + + private VocabularyException(VocabularyErrorCode errorCode) { + super(errorCode); + } + + private VocabularyException(VocabularyErrorCode errorCode, String message) { + super(errorCode, message); + } + + private VocabularyException(VocabularyErrorCode errorCode, Throwable cause) { + super(errorCode, cause); + } + + // === 단어(Word) 관련 팩토리 메서드 === + + public static VocabularyException wordNotFound(String wordId) { + return (VocabularyException) new VocabularyException(VocabularyErrorCode.WORD_NOT_FOUND, + String.format("단어를 찾을 수 없습니다 (ID: %s)", wordId)) + .addDetail("wordId", wordId); + } + + public static VocabularyException wordAlreadyExists(String english) { + return (VocabularyException) new VocabularyException(VocabularyErrorCode.WORD_ALREADY_EXISTS, + String.format("이미 존재하는 단어입니다: '%s'", english)) + .addDetail("english", english); + } + + public static VocabularyException invalidWordData(String reason) { + return new VocabularyException(VocabularyErrorCode.INVALID_WORD_DATA, reason); + } + + // === 사용자 단어(UserWord) 관련 팩토리 메서드 === + + public static VocabularyException userWordNotFound(String userId, String wordId) { + return (VocabularyException) new VocabularyException(VocabularyErrorCode.USER_WORD_NOT_FOUND, + String.format("사용자 단어 정보를 찾을 수 없습니다 (userId: %s, wordId: %s)", userId, wordId)) + .addDetail("userId", userId) + .addDetail("wordId", wordId); + } + + public static VocabularyException invalidDifficulty(String difficulty) { + return (VocabularyException) new VocabularyException(VocabularyErrorCode.INVALID_DIFFICULTY, + String.format("유효하지 않은 난이도입니다: '%s'. EASY, NORMAL, HARD 중 하나여야 합니다", difficulty)) + .addDetail("invalidValue", difficulty) + .addDetail("allowedValues", "EASY, NORMAL, HARD"); + } + + public static VocabularyException invalidWordStatus(String status) { + return (VocabularyException) new VocabularyException(VocabularyErrorCode.INVALID_WORD_STATUS, + String.format("유효하지 않은 단어 상태입니다: '%s'", status)) + .addDetail("invalidValue", status); + } + + // === 학습(Study) 관련 팩토리 메서드 === + + public static VocabularyException dailyStudyNotFound(String userId, String date) { + return (VocabularyException) new VocabularyException(VocabularyErrorCode.DAILY_STUDY_NOT_FOUND, + String.format("일일 학습 정보를 찾을 수 없습니다 (userId: %s, date: %s)", userId, date)) + .addDetail("userId", userId) + .addDetail("date", date); + } + + public static VocabularyException studyLimitExceeded(int limit) { + return (VocabularyException) new VocabularyException(VocabularyErrorCode.STUDY_LIMIT_EXCEEDED, + String.format("일일 학습 한도(%d개)를 초과했습니다", limit)) + .addDetail("dailyLimit", limit); + } + + public static VocabularyException invalidStudyLevel(String level) { + return (VocabularyException) new VocabularyException(VocabularyErrorCode.INVALID_STUDY_LEVEL, + String.format("유효하지 않은 학습 레벨입니다: '%s'", level)) + .addDetail("invalidValue", level); + } + + // === 카테고리/레벨 관련 팩토리 메서드 === + + public static VocabularyException invalidCategory(String category) { + return (VocabularyException) new VocabularyException(VocabularyErrorCode.INVALID_CATEGORY, + String.format("유효하지 않은 카테고리입니다: '%s'", category)) + .addDetail("invalidValue", category); + } + + public static VocabularyException invalidLevel(String level) { + return (VocabularyException) new VocabularyException(VocabularyErrorCode.INVALID_LEVEL, + String.format("유효하지 않은 레벨입니다: '%s'", level)) + .addDetail("invalidValue", level); + } + + // === 단어 그룹(WordGroup) 관련 팩토리 메서드 === + + public static VocabularyException groupNotFound(String groupId) { + return (VocabularyException) new VocabularyException(VocabularyErrorCode.GROUP_NOT_FOUND, + String.format("단어 그룹을 찾을 수 없습니다 (ID: %s)", groupId)) + .addDetail("groupId", groupId); + } + + public static VocabularyException groupAlreadyExists(String groupName) { + return (VocabularyException) new VocabularyException(VocabularyErrorCode.GROUP_ALREADY_EXISTS, + String.format("이미 존재하는 그룹입니다: '%s'", groupName)) + .addDetail("groupName", groupName); + } + + // === 테스트(Test) 관련 팩토리 메서드 === + + public static VocabularyException noWordsToTest() { + return new VocabularyException(VocabularyErrorCode.NO_WORDS_TO_TEST, + "테스트할 단어가 없습니다. 먼저 일일 학습을 시작해주세요."); + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/DailyStudyHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/DailyStudyHandler.java index fa33ee4f..44ffa02d 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/DailyStudyHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/DailyStudyHandler.java @@ -4,10 +4,10 @@ import com.amazonaws.services.lambda.runtime.RequestHandler; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; -import com.mzc.secondproject.serverless.domain.vocabulary.exception.VocabularyErrorCode; import com.mzc.secondproject.serverless.common.router.HandlerRouter; import com.mzc.secondproject.serverless.common.router.Route; import com.mzc.secondproject.serverless.common.util.ResponseGenerator; +import com.mzc.secondproject.serverless.domain.vocabulary.exception.VocabularyErrorCode; import com.mzc.secondproject.serverless.domain.vocabulary.service.DailyStudyCommandService; import com.mzc.secondproject.serverless.domain.vocabulary.service.DailyStudyQueryService; import org.slf4j.Logger; @@ -17,80 +17,80 @@ import java.util.Map; public class DailyStudyHandler implements RequestHandler { - - private static final Logger logger = LoggerFactory.getLogger(DailyStudyHandler.class); - - private final DailyStudyCommandService commandService; - private final DailyStudyQueryService queryService; - private final HandlerRouter router; - - public DailyStudyHandler() { - this.commandService = new DailyStudyCommandService(); - this.queryService = new DailyStudyQueryService(); - this.router = initRouter(); - } - - private HandlerRouter initRouter() { - return new HandlerRouter().addRoutes( - Route.postAuth("/daily/words/{wordId}/learned", this::markWordLearned), - Route.getAuth("/daily", this::getDailyWords) - ); - } - - @Override - public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { - logger.info("Received request: {} {}", request.getHttpMethod(), request.getPath()); - return router.route(request); - } - - private APIGatewayProxyResponseEvent getDailyWords(APIGatewayProxyRequestEvent request, String userId) { - Map queryParams = request.getQueryStringParameters(); - - String date = queryParams != null ? queryParams.get("date") : null; - String level = queryParams != null ? queryParams.get("level") : null; - - // 특정 날짜 조회 (읽기 전용) - if (date != null && !date.isEmpty()) { - return getDailyStudyByDate(userId, date); - } - - // 오늘 날짜 (없으면 생성) - DailyStudyCommandService.DailyStudyResult result = commandService.getDailyWords(userId, level); - - Map response = new HashMap<>(); - response.put("dailyStudy", result.dailyStudy()); - response.put("newWords", result.newWords()); - response.put("reviewWords", result.reviewWords()); - response.put("progress", result.progress()); - - return ResponseGenerator.ok("Daily words retrieved", response); - } - - private APIGatewayProxyResponseEvent getDailyStudyByDate(String userId, String date) { - var optDailyStudy = queryService.getDailyStudy(userId, date); - - if (optDailyStudy.isEmpty()) { - return ResponseGenerator.fail(VocabularyErrorCode.DAILY_STUDY_NOT_FOUND); - } - - var dailyStudy = optDailyStudy.get(); - var newWords = queryService.getWordDetails(dailyStudy.getNewWordIds()); - var reviewWords = queryService.getWordDetails(dailyStudy.getReviewWordIds()); - var progress = queryService.calculateProgress(dailyStudy); - - Map response = new HashMap<>(); - response.put("dailyStudy", dailyStudy); - response.put("newWords", newWords); - response.put("reviewWords", reviewWords); - response.put("progress", progress); - - return ResponseGenerator.ok("Daily study retrieved for " + date, response); - } - - private APIGatewayProxyResponseEvent markWordLearned(APIGatewayProxyRequestEvent request, String userId) { - String wordId = request.getPathParameters().get("wordId"); - - Map progress = commandService.markWordLearned(userId, wordId); - return ResponseGenerator.ok("Word marked as learned", progress); - } + + private static final Logger logger = LoggerFactory.getLogger(DailyStudyHandler.class); + + private final DailyStudyCommandService commandService; + private final DailyStudyQueryService queryService; + private final HandlerRouter router; + + public DailyStudyHandler() { + this.commandService = new DailyStudyCommandService(); + this.queryService = new DailyStudyQueryService(); + this.router = initRouter(); + } + + private HandlerRouter initRouter() { + return new HandlerRouter().addRoutes( + Route.postAuth("/daily/words/{wordId}/learned", this::markWordLearned), + Route.getAuth("/daily", this::getDailyWords) + ); + } + + @Override + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { + logger.info("Received request: {} {}", request.getHttpMethod(), request.getPath()); + return router.route(request); + } + + private APIGatewayProxyResponseEvent getDailyWords(APIGatewayProxyRequestEvent request, String userId) { + Map queryParams = request.getQueryStringParameters(); + + String date = queryParams != null ? queryParams.get("date") : null; + String level = queryParams != null ? queryParams.get("level") : null; + + // 특정 날짜 조회 (읽기 전용) + if (date != null && !date.isEmpty()) { + return getDailyStudyByDate(userId, date); + } + + // 오늘 날짜 (없으면 생성) + DailyStudyCommandService.DailyStudyResult result = commandService.getDailyWords(userId, level); + + Map response = new HashMap<>(); + response.put("dailyStudy", result.dailyStudy()); + response.put("newWords", result.newWords()); + response.put("reviewWords", result.reviewWords()); + response.put("progress", result.progress()); + + return ResponseGenerator.ok("Daily words retrieved", response); + } + + private APIGatewayProxyResponseEvent getDailyStudyByDate(String userId, String date) { + var optDailyStudy = queryService.getDailyStudy(userId, date); + + if (optDailyStudy.isEmpty()) { + return ResponseGenerator.fail(VocabularyErrorCode.DAILY_STUDY_NOT_FOUND); + } + + var dailyStudy = optDailyStudy.get(); + var newWords = queryService.getWordDetails(dailyStudy.getNewWordIds()); + var reviewWords = queryService.getWordDetails(dailyStudy.getReviewWordIds()); + var progress = queryService.calculateProgress(dailyStudy); + + Map response = new HashMap<>(); + response.put("dailyStudy", dailyStudy); + response.put("newWords", newWords); + response.put("reviewWords", reviewWords); + response.put("progress", progress); + + return ResponseGenerator.ok("Daily study retrieved for " + date, response); + } + + private APIGatewayProxyResponseEvent markWordLearned(APIGatewayProxyRequestEvent request, String userId) { + String wordId = request.getPathParameters().get("wordId"); + + Map progress = commandService.markWordLearned(userId, wordId); + return ResponseGenerator.ok("Word marked as learned", progress); + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatisticsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatisticsHandler.java index 8df21400..c8d9023c 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatisticsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatisticsHandler.java @@ -16,46 +16,46 @@ * SNS → SQS → Statistics Lambda 패턴 */ public class StatisticsHandler implements RequestHandler { - - private static final Logger logger = LoggerFactory.getLogger(StatisticsHandler.class); - - private final StatisticsService statisticsService; - - public StatisticsHandler() { - this.statisticsService = new StatisticsService(); - } - - @Override - public Void handleRequest(SQSEvent event, Context context) { - logger.info("Received {} messages from SQS", event.getRecords().size()); - - for (SQSEvent.SQSMessage message : event.getRecords()) { - try { - processMessage(message); - } catch (Exception e) { - logger.error("Failed to process message: {}", message.getMessageId(), e); - throw new RuntimeException("Failed to process message", e); - } - } - - return null; - } - - @SuppressWarnings("unchecked") - private void processMessage(SQSEvent.SQSMessage message) { - String body = message.getBody(); - logger.info("Processing message: {}", body); - - Map testResult = ResponseGenerator.gson().fromJson(body, Map.class); - - String userId = (String) testResult.get("userId"); - List> results = (List>) testResult.get("results"); - - if (userId == null || results == null) { - logger.warn("Invalid message format: userId or results is null"); - return; - } - - statisticsService.processTestResults(userId, results); - } + + private static final Logger logger = LoggerFactory.getLogger(StatisticsHandler.class); + + private final StatisticsService statisticsService; + + public StatisticsHandler() { + this.statisticsService = new StatisticsService(); + } + + @Override + public Void handleRequest(SQSEvent event, Context context) { + logger.info("Received {} messages from SQS", event.getRecords().size()); + + for (SQSEvent.SQSMessage message : event.getRecords()) { + try { + processMessage(message); + } catch (Exception e) { + logger.error("Failed to process message: {}", message.getMessageId(), e); + throw new RuntimeException("Failed to process message", e); + } + } + + return null; + } + + @SuppressWarnings("unchecked") + private void processMessage(SQSEvent.SQSMessage message) { + String body = message.getBody(); + logger.info("Processing message: {}", body); + + Map testResult = ResponseGenerator.gson().fromJson(body, Map.class); + + String userId = (String) testResult.get("userId"); + List> results = (List>) testResult.get("results"); + + if (userId == null || results == null) { + logger.warn("Invalid message format: userId or results is null"); + return; + } + + statisticsService.processTestResults(userId, results); + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatsHandler.java index b2974c4d..190734ac 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatsHandler.java @@ -15,57 +15,57 @@ import java.util.Map; public class StatsHandler implements RequestHandler { - - private static final Logger logger = LoggerFactory.getLogger(StatsHandler.class); - - private final StatsService statsService; - private final HandlerRouter router; - - public StatsHandler() { - this.statsService = new StatsService(); - this.router = initRouter(); - } - - private HandlerRouter initRouter() { - return new HandlerRouter().addRoutes( - Route.getAuth("/stats/weakness", this::getWeaknessAnalysis), - Route.getAuth("/stats/daily", this::getDailyStats), - Route.getAuth("/stats", this::getOverallStats) - ); - } - - @Override - public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { - logger.info("Received request: {} {}", request.getHttpMethod(), request.getPath()); - return router.route(request); - } - - private APIGatewayProxyResponseEvent getOverallStats(APIGatewayProxyRequestEvent request, String userId) { - Map stats = statsService.getOverallStats(userId); - return ResponseGenerator.ok("Stats retrieved", stats); - } - - private APIGatewayProxyResponseEvent getDailyStats(APIGatewayProxyRequestEvent request, String userId) { - Map queryParams = request.getQueryStringParameters(); - String cursor = queryParams != null ? queryParams.get("cursor") : null; - - int limit = 30; - if (queryParams != null && queryParams.get("limit") != null) { - limit = Math.min(Integer.parseInt(queryParams.get("limit")), 90); - } - - StatsService.DailyStatsResult dailyResult = statsService.getDailyStats(userId, limit, cursor); - - Map result = new HashMap<>(); - result.put("dailyStats", dailyResult.dailyStats()); - result.put("nextCursor", dailyResult.nextCursor()); - result.put("hasMore", dailyResult.hasMore()); - - return ResponseGenerator.ok("Daily stats retrieved", result); - } - - private APIGatewayProxyResponseEvent getWeaknessAnalysis(APIGatewayProxyRequestEvent request, String userId) { - Map analysis = statsService.getWeaknessAnalysis(userId); - return ResponseGenerator.ok("Weakness analysis completed", analysis); - } + + private static final Logger logger = LoggerFactory.getLogger(StatsHandler.class); + + private final StatsService statsService; + private final HandlerRouter router; + + public StatsHandler() { + this.statsService = new StatsService(); + this.router = initRouter(); + } + + private HandlerRouter initRouter() { + return new HandlerRouter().addRoutes( + Route.getAuth("/stats/weakness", this::getWeaknessAnalysis), + Route.getAuth("/stats/daily", this::getDailyStats), + Route.getAuth("/stats", this::getOverallStats) + ); + } + + @Override + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { + logger.info("Received request: {} {}", request.getHttpMethod(), request.getPath()); + return router.route(request); + } + + private APIGatewayProxyResponseEvent getOverallStats(APIGatewayProxyRequestEvent request, String userId) { + Map stats = statsService.getOverallStats(userId); + return ResponseGenerator.ok("Stats retrieved", stats); + } + + private APIGatewayProxyResponseEvent getDailyStats(APIGatewayProxyRequestEvent request, String userId) { + Map queryParams = request.getQueryStringParameters(); + String cursor = queryParams != null ? queryParams.get("cursor") : null; + + int limit = 30; + if (queryParams != null && queryParams.get("limit") != null) { + limit = Math.min(Integer.parseInt(queryParams.get("limit")), 90); + } + + StatsService.DailyStatsResult dailyResult = statsService.getDailyStats(userId, limit, cursor); + + Map result = new HashMap<>(); + result.put("dailyStats", dailyResult.dailyStats()); + result.put("nextCursor", dailyResult.nextCursor()); + result.put("hasMore", dailyResult.hasMore()); + + return ResponseGenerator.ok("Daily stats retrieved", result); + } + + private APIGatewayProxyResponseEvent getWeaknessAnalysis(APIGatewayProxyRequestEvent request, String userId) { + Map analysis = statsService.getWeaknessAnalysis(userId); + return ResponseGenerator.ok("Weakness analysis completed", analysis); + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/TestHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/TestHandler.java index a8573b27..949e045d 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/TestHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/TestHandler.java @@ -6,12 +6,12 @@ import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; import com.mzc.secondproject.serverless.common.dto.PaginatedResult; import com.mzc.secondproject.serverless.common.exception.CommonErrorCode; -import com.mzc.secondproject.serverless.common.validation.BeanValidator; -import com.mzc.secondproject.serverless.domain.vocabulary.dto.request.StartTestRequest; -import com.mzc.secondproject.serverless.domain.vocabulary.dto.request.SubmitTestRequest; import com.mzc.secondproject.serverless.common.router.HandlerRouter; import com.mzc.secondproject.serverless.common.router.Route; import com.mzc.secondproject.serverless.common.util.ResponseGenerator; +import com.mzc.secondproject.serverless.common.validation.BeanValidator; +import com.mzc.secondproject.serverless.domain.vocabulary.dto.request.StartTestRequest; +import com.mzc.secondproject.serverless.domain.vocabulary.dto.request.SubmitTestRequest; import com.mzc.secondproject.serverless.domain.vocabulary.model.TestResult; import com.mzc.secondproject.serverless.domain.vocabulary.service.TestCommandService; import com.mzc.secondproject.serverless.domain.vocabulary.service.TestQueryService; @@ -22,137 +22,137 @@ import java.util.Map; public class TestHandler implements RequestHandler { - - private static final Logger logger = LoggerFactory.getLogger(TestHandler.class); - - private final TestCommandService commandService; - private final TestQueryService queryService; - private final HandlerRouter router; - - public TestHandler() { - this.commandService = new TestCommandService(); - this.queryService = new TestQueryService(); - this.router = initRouter(); - } - - private HandlerRouter initRouter() { - return new HandlerRouter().addRoutes( - Route.postAuth("/test/start", this::startTest), - Route.postAuth("/test/submit", this::submitAnswer), - Route.getAuth("/test/results/{testId}", this::getTestResultDetail), - Route.getAuth("/test/results", this::getTestResults), - Route.getAuth("/test/tested-words", this::getTestedWords) - ); - } - - @Override - public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { - logger.info("Received request: {} {}", request.getHttpMethod(), request.getPath()); - return router.route(request); - } - - private APIGatewayProxyResponseEvent startTest(APIGatewayProxyRequestEvent request, String userId) { - StartTestRequest req = ResponseGenerator.gson().fromJson(request.getBody(), StartTestRequest.class); - String testType = req != null && req.getTestType() != null ? req.getTestType() : "DAILY"; - - TestCommandService.StartTestResult result = commandService.startTest(userId, testType); - - Map response = new HashMap<>(); - response.put("testId", result.testId()); - response.put("testType", result.testType()); - response.put("questions", result.questions()); - response.put("totalQuestions", result.totalQuestions()); - response.put("startedAt", result.startedAt()); - - return ResponseGenerator.ok("Test started", response); - } - - private APIGatewayProxyResponseEvent submitAnswer(APIGatewayProxyRequestEvent request, String userId) { - SubmitTestRequest req = ResponseGenerator.gson().fromJson(request.getBody(), SubmitTestRequest.class); - - return BeanValidator.validateAndExecute(req, dto -> { - String testType = dto.getTestType() != null ? dto.getTestType() : "DAILY"; - - TestCommandService.SubmitTestResult result = commandService.submitTest( - userId, dto.getTestId(), testType, dto.getAnswers(), dto.getStartedAt()); - - Map response = new HashMap<>(); - response.put("testId", result.testId()); - response.put("testType", result.testType()); - response.put("totalQuestions", result.totalQuestions()); - response.put("correctCount", result.correctCount()); - response.put("incorrectCount", result.incorrectCount()); - response.put("successRate", result.successRate()); - response.put("results", result.results()); - - return ResponseGenerator.ok("Test submitted", response); - }); - } - - private APIGatewayProxyResponseEvent getTestResults(APIGatewayProxyRequestEvent request, String userId) { - Map queryParams = request.getQueryStringParameters(); - String cursor = queryParams != null ? queryParams.get("cursor") : null; - - int limit = 10; - if (queryParams != null && queryParams.get("limit") != null) { - limit = Math.min(Integer.parseInt(queryParams.get("limit")), 50); - } - - PaginatedResult resultPage = queryService.getTestResults(userId, limit, cursor); - - Map result = new HashMap<>(); - result.put("testResults", resultPage.items()); - result.put("nextCursor", resultPage.nextCursor()); - result.put("hasMore", resultPage.hasMore()); - - return ResponseGenerator.ok("Test results retrieved", result); - } - - private APIGatewayProxyResponseEvent getTestResultDetail(APIGatewayProxyRequestEvent request, String userId) { - String testId = request.getPathParameters().get("testId"); - - var optDetail = queryService.getTestResultDetail(userId, testId); - if (optDetail.isEmpty()) { - return ResponseGenerator.fail(CommonErrorCode.RESOURCE_NOT_FOUND); - } - - var detail = optDetail.get(); - - Map result = new HashMap<>(); - result.put("testId", detail.testResult().getTestId()); - result.put("testType", detail.testResult().getTestType()); - result.put("totalQuestions", detail.testResult().getTotalQuestions()); - result.put("correctAnswers", detail.testResult().getCorrectAnswers()); - result.put("incorrectAnswers", detail.testResult().getIncorrectAnswers()); - result.put("successRate", detail.testResult().getSuccessRate()); - result.put("incorrectWords", detail.incorrectWords()); - result.put("startedAt", detail.testResult().getStartedAt()); - result.put("completedAt", detail.testResult().getCompletedAt()); - - return ResponseGenerator.ok("Test result detail retrieved", result); - } - - private APIGatewayProxyResponseEvent getTestedWords(APIGatewayProxyRequestEvent request, String userId) { - Map queryParams = request.getQueryStringParameters(); - - int recentTests = 10; - int limit = 50; - - if (queryParams != null) { - if (queryParams.get("recentTests") != null) { - recentTests = Math.min(Integer.parseInt(queryParams.get("recentTests")), 50); - } - if (queryParams.get("limit") != null) { - limit = Math.min(Integer.parseInt(queryParams.get("limit")), 100); - } - } - - TestQueryService.TestedWordsResult result = queryService.getTestedWords(userId, recentTests, limit); - - Map response = new HashMap<>(); - response.put("testedWords", result.words()); - response.put("totalCount", result.totalCount()); - - return ResponseGenerator.ok("Tested words retrieved", response); - } + + private static final Logger logger = LoggerFactory.getLogger(TestHandler.class); + + private final TestCommandService commandService; + private final TestQueryService queryService; + private final HandlerRouter router; + + public TestHandler() { + this.commandService = new TestCommandService(); + this.queryService = new TestQueryService(); + this.router = initRouter(); + } + + private HandlerRouter initRouter() { + return new HandlerRouter().addRoutes( + Route.postAuth("/test/start", this::startTest), + Route.postAuth("/test/submit", this::submitAnswer), + Route.getAuth("/test/results/{testId}", this::getTestResultDetail), + Route.getAuth("/test/results", this::getTestResults), + Route.getAuth("/test/tested-words", this::getTestedWords) + ); + } + + @Override + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { + logger.info("Received request: {} {}", request.getHttpMethod(), request.getPath()); + return router.route(request); + } + + private APIGatewayProxyResponseEvent startTest(APIGatewayProxyRequestEvent request, String userId) { + StartTestRequest req = ResponseGenerator.gson().fromJson(request.getBody(), StartTestRequest.class); + String testType = req != null && req.getTestType() != null ? req.getTestType() : "DAILY"; + + TestCommandService.StartTestResult result = commandService.startTest(userId, testType); + + Map response = new HashMap<>(); + response.put("testId", result.testId()); + response.put("testType", result.testType()); + response.put("questions", result.questions()); + response.put("totalQuestions", result.totalQuestions()); + response.put("startedAt", result.startedAt()); + + return ResponseGenerator.ok("Test started", response); + } + + private APIGatewayProxyResponseEvent submitAnswer(APIGatewayProxyRequestEvent request, String userId) { + SubmitTestRequest req = ResponseGenerator.gson().fromJson(request.getBody(), SubmitTestRequest.class); + + return BeanValidator.validateAndExecute(req, dto -> { + String testType = dto.getTestType() != null ? dto.getTestType() : "DAILY"; + + TestCommandService.SubmitTestResult result = commandService.submitTest( + userId, dto.getTestId(), testType, dto.getAnswers(), dto.getStartedAt()); + + Map response = new HashMap<>(); + response.put("testId", result.testId()); + response.put("testType", result.testType()); + response.put("totalQuestions", result.totalQuestions()); + response.put("correctCount", result.correctCount()); + response.put("incorrectCount", result.incorrectCount()); + response.put("successRate", result.successRate()); + response.put("results", result.results()); + + return ResponseGenerator.ok("Test submitted", response); + }); + } + + private APIGatewayProxyResponseEvent getTestResults(APIGatewayProxyRequestEvent request, String userId) { + Map queryParams = request.getQueryStringParameters(); + String cursor = queryParams != null ? queryParams.get("cursor") : null; + + int limit = 10; + if (queryParams != null && queryParams.get("limit") != null) { + limit = Math.min(Integer.parseInt(queryParams.get("limit")), 50); + } + + PaginatedResult resultPage = queryService.getTestResults(userId, limit, cursor); + + Map result = new HashMap<>(); + result.put("testResults", resultPage.items()); + result.put("nextCursor", resultPage.nextCursor()); + result.put("hasMore", resultPage.hasMore()); + + return ResponseGenerator.ok("Test results retrieved", result); + } + + private APIGatewayProxyResponseEvent getTestResultDetail(APIGatewayProxyRequestEvent request, String userId) { + String testId = request.getPathParameters().get("testId"); + + var optDetail = queryService.getTestResultDetail(userId, testId); + if (optDetail.isEmpty()) { + return ResponseGenerator.fail(CommonErrorCode.RESOURCE_NOT_FOUND); + } + + var detail = optDetail.get(); + + Map result = new HashMap<>(); + result.put("testId", detail.testResult().getTestId()); + result.put("testType", detail.testResult().getTestType()); + result.put("totalQuestions", detail.testResult().getTotalQuestions()); + result.put("correctAnswers", detail.testResult().getCorrectAnswers()); + result.put("incorrectAnswers", detail.testResult().getIncorrectAnswers()); + result.put("successRate", detail.testResult().getSuccessRate()); + result.put("incorrectWords", detail.incorrectWords()); + result.put("startedAt", detail.testResult().getStartedAt()); + result.put("completedAt", detail.testResult().getCompletedAt()); + + return ResponseGenerator.ok("Test result detail retrieved", result); + } + + private APIGatewayProxyResponseEvent getTestedWords(APIGatewayProxyRequestEvent request, String userId) { + Map queryParams = request.getQueryStringParameters(); + + int recentTests = 10; + int limit = 50; + + if (queryParams != null) { + if (queryParams.get("recentTests") != null) { + recentTests = Math.min(Integer.parseInt(queryParams.get("recentTests")), 50); + } + if (queryParams.get("limit") != null) { + limit = Math.min(Integer.parseInt(queryParams.get("limit")), 100); + } + } + + TestQueryService.TestedWordsResult result = queryService.getTestedWords(userId, recentTests, limit); + + Map response = new HashMap<>(); + response.put("testedWords", result.words()); + response.put("totalCount", result.totalCount()); + + return ResponseGenerator.ok("Tested words retrieved", response); + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java index cb083dc0..222c3354 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java @@ -6,13 +6,13 @@ import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; import com.google.gson.reflect.TypeToken; import com.mzc.secondproject.serverless.common.exception.CommonErrorCode; +import com.mzc.secondproject.serverless.common.router.HandlerRouter; +import com.mzc.secondproject.serverless.common.router.Route; +import com.mzc.secondproject.serverless.common.util.ResponseGenerator; import com.mzc.secondproject.serverless.common.validation.BeanValidator; import com.mzc.secondproject.serverless.domain.vocabulary.dto.request.UpdateUserWordRequest; import com.mzc.secondproject.serverless.domain.vocabulary.dto.request.UpdateUserWordTagRequest; import com.mzc.secondproject.serverless.domain.vocabulary.exception.VocabularyErrorCode; -import com.mzc.secondproject.serverless.common.router.HandlerRouter; -import com.mzc.secondproject.serverless.common.router.Route; -import com.mzc.secondproject.serverless.common.util.ResponseGenerator; import com.mzc.secondproject.serverless.domain.vocabulary.model.UserWord; import com.mzc.secondproject.serverless.domain.vocabulary.service.UserWordCommandService; import com.mzc.secondproject.serverless.domain.vocabulary.service.UserWordQueryService; @@ -24,133 +24,134 @@ import java.util.Optional; public class UserWordHandler implements RequestHandler { - - private static final Logger logger = LoggerFactory.getLogger(UserWordHandler.class); - - private final UserWordCommandService commandService; - private final UserWordQueryService queryService; - private final HandlerRouter router; - - public UserWordHandler() { - this.commandService = new UserWordCommandService(); - this.queryService = new UserWordQueryService(); - this.router = initRouter(); - } - - private HandlerRouter initRouter() { - return new HandlerRouter().addRoutes( - Route.getAuth("/wrong-answers", this::getWrongAnswers), - Route.getAuth("/user-words", this::getUserWords), - Route.getAuth("/user-words/{wordId}", this::getUserWord), - Route.putAuth("/user-words/{wordId}/tag", this::updateUserWordTag), - Route.putAuth("/user-words/{wordId}/status", this::updateWordStatus), - Route.putAuth("/user-words/{wordId}", this::updateUserWord) - ); - } - - @Override - public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { - logger.info("Received request: {} {}", request.getHttpMethod(), request.getPath()); - return router.route(request); - } - - private APIGatewayProxyResponseEvent getUserWords(APIGatewayProxyRequestEvent request, String userId) { - Map queryParams = request.getQueryStringParameters(); - - String status = queryParams != null ? queryParams.get("status") : null; - String cursor = queryParams != null ? queryParams.get("cursor") : null; - String bookmarked = queryParams != null ? queryParams.get("bookmarked") : null; - String incorrectOnly = queryParams != null ? queryParams.get("incorrectOnly") : null; - - int limit = 20; - if (queryParams != null && queryParams.get("limit") != null) { - limit = Math.min(Integer.parseInt(queryParams.get("limit")), 50); - } - - UserWordQueryService.UserWordsResult result = queryService.getUserWords(userId, status, bookmarked, incorrectOnly, limit, cursor); - - Map response = new HashMap<>(); - response.put("userWords", result.userWords()); - response.put("nextCursor", result.nextCursor()); - response.put("hasMore", result.hasMore()); - - return ResponseGenerator.ok("User words retrieved", response); - } - - private APIGatewayProxyResponseEvent getUserWord(APIGatewayProxyRequestEvent request, String userId) { - String wordId = request.getPathParameters().get("wordId"); - - Optional optUserWord = queryService.getUserWord(userId, wordId); - if (optUserWord.isEmpty()) { - return ResponseGenerator.fail(VocabularyErrorCode.USER_WORD_NOT_FOUND); - } - - return ResponseGenerator.ok("UserWord retrieved", optUserWord.get()); - } - - private APIGatewayProxyResponseEvent updateUserWord(APIGatewayProxyRequestEvent request, String userId) { - String wordId = request.getPathParameters().get("wordId"); - UpdateUserWordRequest req = ResponseGenerator.gson().fromJson(request.getBody(), UpdateUserWordRequest.class); - - return BeanValidator.validateAndExecute(req, dto -> { - UserWord userWord = commandService.updateUserWord(userId, wordId, dto.getIsCorrect()); - return ResponseGenerator.ok("UserWord updated", userWord); - }); - } - - private APIGatewayProxyResponseEvent updateUserWordTag(APIGatewayProxyRequestEvent request, String userId) { - String wordId = request.getPathParameters().get("wordId"); - UpdateUserWordTagRequest req = ResponseGenerator.gson().fromJson(request.getBody(), UpdateUserWordTagRequest.class); - - UserWord userWord = commandService.updateUserWordTag(userId, wordId, req.getBookmarked(), req.getFavorite(), req.getDifficulty()); - return ResponseGenerator.ok("Tag updated", userWord); - } - - private APIGatewayProxyResponseEvent updateWordStatus(APIGatewayProxyRequestEvent request, String userId) { - String wordId = request.getPathParameters().get("wordId"); - - Map body = ResponseGenerator.gson().fromJson(request.getBody(), - new TypeToken>(){}.getType()); - - String status = body != null ? body.get("status") : null; - if (status == null || status.isEmpty()) { - return ResponseGenerator.fail(CommonErrorCode.REQUIRED_FIELD_MISSING); - } - - try { - UserWord userWord = commandService.updateWordStatus(userId, wordId, status); - return ResponseGenerator.ok("Word status updated", userWord); - } catch (IllegalArgumentException e) { - return ResponseGenerator.fail(VocabularyErrorCode.INVALID_WORD_STATUS); - } - } - - private APIGatewayProxyResponseEvent getWrongAnswers(APIGatewayProxyRequestEvent request, String userId) { - Map queryParams = request.getQueryStringParameters(); - String cursor = queryParams != null ? queryParams.get("cursor") : null; - - int limit = parseIntParam(queryParams, "limit", 20, 1, 50); - int minCount = parseIntParam(queryParams, "minCount", 1, 1, 100); - - UserWordQueryService.UserWordsResult result = queryService.getWrongAnswers(userId, minCount, limit, cursor); - - Map response = new HashMap<>(); - response.put("wrongAnswers", result.userWords()); - response.put("nextCursor", result.nextCursor()); - response.put("hasMore", result.hasMore()); - - return ResponseGenerator.ok("Wrong answers retrieved", response); - } - - private int parseIntParam(Map params, String key, int defaultValue, int min, int max) { - if (params == null || params.get(key) == null) { - return defaultValue; - } - try { - int value = Integer.parseInt(params.get(key)); - return Math.max(min, Math.min(value, max)); - } catch (NumberFormatException e) { - return defaultValue; - } - } + + private static final Logger logger = LoggerFactory.getLogger(UserWordHandler.class); + + private final UserWordCommandService commandService; + private final UserWordQueryService queryService; + private final HandlerRouter router; + + public UserWordHandler() { + this.commandService = new UserWordCommandService(); + this.queryService = new UserWordQueryService(); + this.router = initRouter(); + } + + private HandlerRouter initRouter() { + return new HandlerRouter().addRoutes( + Route.getAuth("/wrong-answers", this::getWrongAnswers), + Route.getAuth("/user-words", this::getUserWords), + Route.getAuth("/user-words/{wordId}", this::getUserWord), + Route.putAuth("/user-words/{wordId}/tag", this::updateUserWordTag), + Route.putAuth("/user-words/{wordId}/status", this::updateWordStatus), + Route.putAuth("/user-words/{wordId}", this::updateUserWord) + ); + } + + @Override + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { + logger.info("Received request: {} {}", request.getHttpMethod(), request.getPath()); + return router.route(request); + } + + private APIGatewayProxyResponseEvent getUserWords(APIGatewayProxyRequestEvent request, String userId) { + Map queryParams = request.getQueryStringParameters(); + + String status = queryParams != null ? queryParams.get("status") : null; + String cursor = queryParams != null ? queryParams.get("cursor") : null; + String bookmarked = queryParams != null ? queryParams.get("bookmarked") : null; + String incorrectOnly = queryParams != null ? queryParams.get("incorrectOnly") : null; + + int limit = 20; + if (queryParams != null && queryParams.get("limit") != null) { + limit = Math.min(Integer.parseInt(queryParams.get("limit")), 50); + } + + UserWordQueryService.UserWordsResult result = queryService.getUserWords(userId, status, bookmarked, incorrectOnly, limit, cursor); + + Map response = new HashMap<>(); + response.put("userWords", result.userWords()); + response.put("nextCursor", result.nextCursor()); + response.put("hasMore", result.hasMore()); + + return ResponseGenerator.ok("User words retrieved", response); + } + + private APIGatewayProxyResponseEvent getUserWord(APIGatewayProxyRequestEvent request, String userId) { + String wordId = request.getPathParameters().get("wordId"); + + Optional optUserWord = queryService.getUserWord(userId, wordId); + if (optUserWord.isEmpty()) { + return ResponseGenerator.fail(VocabularyErrorCode.USER_WORD_NOT_FOUND); + } + + return ResponseGenerator.ok("UserWord retrieved", optUserWord.get()); + } + + private APIGatewayProxyResponseEvent updateUserWord(APIGatewayProxyRequestEvent request, String userId) { + String wordId = request.getPathParameters().get("wordId"); + UpdateUserWordRequest req = ResponseGenerator.gson().fromJson(request.getBody(), UpdateUserWordRequest.class); + + return BeanValidator.validateAndExecute(req, dto -> { + UserWord userWord = commandService.updateUserWord(userId, wordId, dto.getIsCorrect()); + return ResponseGenerator.ok("UserWord updated", userWord); + }); + } + + private APIGatewayProxyResponseEvent updateUserWordTag(APIGatewayProxyRequestEvent request, String userId) { + String wordId = request.getPathParameters().get("wordId"); + UpdateUserWordTagRequest req = ResponseGenerator.gson().fromJson(request.getBody(), UpdateUserWordTagRequest.class); + + UserWord userWord = commandService.updateUserWordTag(userId, wordId, req.getBookmarked(), req.getFavorite(), req.getDifficulty()); + return ResponseGenerator.ok("Tag updated", userWord); + } + + private APIGatewayProxyResponseEvent updateWordStatus(APIGatewayProxyRequestEvent request, String userId) { + String wordId = request.getPathParameters().get("wordId"); + + Map body = ResponseGenerator.gson().fromJson(request.getBody(), + new TypeToken>() { + }.getType()); + + String status = body != null ? body.get("status") : null; + if (status == null || status.isEmpty()) { + return ResponseGenerator.fail(CommonErrorCode.REQUIRED_FIELD_MISSING); + } + + try { + UserWord userWord = commandService.updateWordStatus(userId, wordId, status); + return ResponseGenerator.ok("Word status updated", userWord); + } catch (IllegalArgumentException e) { + return ResponseGenerator.fail(VocabularyErrorCode.INVALID_WORD_STATUS); + } + } + + private APIGatewayProxyResponseEvent getWrongAnswers(APIGatewayProxyRequestEvent request, String userId) { + Map queryParams = request.getQueryStringParameters(); + String cursor = queryParams != null ? queryParams.get("cursor") : null; + + int limit = parseIntParam(queryParams, "limit", 20, 1, 50); + int minCount = parseIntParam(queryParams, "minCount", 1, 1, 100); + + UserWordQueryService.UserWordsResult result = queryService.getWrongAnswers(userId, minCount, limit, cursor); + + Map response = new HashMap<>(); + response.put("wrongAnswers", result.userWords()); + response.put("nextCursor", result.nextCursor()); + response.put("hasMore", result.hasMore()); + + return ResponseGenerator.ok("Wrong answers retrieved", response); + } + + private int parseIntParam(Map params, String key, int defaultValue, int min, int max) { + if (params == null || params.get(key) == null) { + return defaultValue; + } + try { + int value = Integer.parseInt(params.get(key)); + return Math.max(min, Math.min(value, max)); + } catch (NumberFormatException e) { + return defaultValue; + } + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/VoiceHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/VoiceHandler.java index c66639ef..e5031139 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/VoiceHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/VoiceHandler.java @@ -5,13 +5,13 @@ import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; import com.mzc.secondproject.serverless.common.exception.CommonErrorCode; +import com.mzc.secondproject.serverless.common.service.PollyService; +import com.mzc.secondproject.serverless.common.util.ResponseGenerator; import com.mzc.secondproject.serverless.common.validation.BeanValidator; import com.mzc.secondproject.serverless.domain.vocabulary.dto.request.SynthesizeVoiceRequest; import com.mzc.secondproject.serverless.domain.vocabulary.exception.VocabularyErrorCode; -import com.mzc.secondproject.serverless.common.util.ResponseGenerator; import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; import com.mzc.secondproject.serverless.domain.vocabulary.repository.WordRepository; -import com.mzc.secondproject.serverless.common.service.PollyService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -20,115 +20,115 @@ import java.util.Optional; public class VoiceHandler implements RequestHandler { - - private static final Logger logger = LoggerFactory.getLogger(VoiceHandler.class); - private static final String BUCKET_NAME = System.getenv("VOCAB_BUCKET_NAME"); - - private final WordRepository wordRepository; - private final PollyService pollyService; - - public VoiceHandler() { - this.wordRepository = new WordRepository(); - this.pollyService = new PollyService(BUCKET_NAME, "vocab/voice/"); - } - - @Override - public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { - String httpMethod = request.getHttpMethod(); - String path = request.getPath(); - - logger.info("Received request: {} {}", httpMethod, path); - - try { - if ("POST".equals(httpMethod) && path.endsWith("/synthesize")) { - return synthesizeSpeech(request); - } - - return ResponseGenerator.fail(CommonErrorCode.RESOURCE_NOT_FOUND); - - } catch (Exception e) { - logger.error("Error handling request", e); - return ResponseGenerator.fail(CommonErrorCode.INTERNAL_SERVER_ERROR); - } - } - - private APIGatewayProxyResponseEvent synthesizeSpeech(APIGatewayProxyRequestEvent request) { - String body = request.getBody(); - SynthesizeVoiceRequest req = ResponseGenerator.gson().fromJson(body, SynthesizeVoiceRequest.class); - - return BeanValidator.validateAndExecute(req, dto -> { - String wordId = dto.getWordId(); - String voice = dto.getVoice() != null ? dto.getVoice() : "FEMALE"; - String type = dto.getType() != null ? dto.getType() : "WORD"; - - Optional optWord = wordRepository.findById(wordId); - if (optWord.isEmpty()) { - return ResponseGenerator.fail(VocabularyErrorCode.WORD_NOT_FOUND); - } - - Word word = optWord.get(); - boolean isMale = "MALE".equalsIgnoreCase(voice); - boolean isExample = "EXAMPLE".equalsIgnoreCase(type); - - if (isExample && (word.getExample() == null || word.getExample().isEmpty())) { - return ResponseGenerator.fail(VocabularyErrorCode.INVALID_WORD_DATA, "This word has no example sentence"); - } - - String textToSynthesize = isExample ? word.getExample() : word.getEnglish(); - String cachedKey = getCachedKey(word, isMale, isExample); - String audioUrl; - boolean cached = false; - - if (cachedKey != null && !cachedKey.isEmpty()) { - audioUrl = pollyService.getPresignedUrl(cachedKey); - cached = true; - logger.info("Cache hit from DB: wordId={}, voice={}, type={}", wordId, voice, type); - } else { - String s3KeySuffix = isExample ? "_example" : ""; - PollyService.VoiceSynthesisResult result = pollyService.synthesizeSpeech( - wordId + s3KeySuffix, textToSynthesize, voice); - - audioUrl = result.getAudioUrl(); - cached = result.isCached(); - - setCachedKey(word, isMale, isExample, result.getS3Key()); - wordRepository.save(word); - logger.info("Saved voice cache to DB: wordId={}, voice={}, type={}", wordId, voice, type); - } - - Map responseData = new HashMap<>(); - responseData.put("wordId", wordId); - responseData.put("text", textToSynthesize); - responseData.put("type", type); - responseData.put("voice", voice); - responseData.put("audioUrl", audioUrl); - responseData.put("cached", cached); - - return ResponseGenerator.ok("Speech synthesized", responseData); - }); - } - - private String getCachedKey(Word word, boolean isMale, boolean isExample) { - if (isExample) { - return isMale ? word.getMaleExampleVoiceKey() : word.getFemaleExampleVoiceKey(); - } else { - return isMale ? word.getMaleVoiceKey() : word.getFemaleVoiceKey(); - } - } - - private void setCachedKey(Word word, boolean isMale, boolean isExample, String s3Key) { - if (isExample) { - if (isMale) { - word.setMaleExampleVoiceKey(s3Key); - } else { - word.setFemaleExampleVoiceKey(s3Key); - } - } else { - if (isMale) { - word.setMaleVoiceKey(s3Key); - } else { - word.setFemaleVoiceKey(s3Key); - } - } - } + + private static final Logger logger = LoggerFactory.getLogger(VoiceHandler.class); + private static final String BUCKET_NAME = System.getenv("VOCAB_BUCKET_NAME"); + + private final WordRepository wordRepository; + private final PollyService pollyService; + + public VoiceHandler() { + this.wordRepository = new WordRepository(); + this.pollyService = new PollyService(BUCKET_NAME, "vocab/voice/"); + } + + @Override + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { + String httpMethod = request.getHttpMethod(); + String path = request.getPath(); + + logger.info("Received request: {} {}", httpMethod, path); + + try { + if ("POST".equals(httpMethod) && path.endsWith("/synthesize")) { + return synthesizeSpeech(request); + } + + return ResponseGenerator.fail(CommonErrorCode.RESOURCE_NOT_FOUND); + + } catch (Exception e) { + logger.error("Error handling request", e); + return ResponseGenerator.fail(CommonErrorCode.INTERNAL_SERVER_ERROR); + } + } + + private APIGatewayProxyResponseEvent synthesizeSpeech(APIGatewayProxyRequestEvent request) { + String body = request.getBody(); + SynthesizeVoiceRequest req = ResponseGenerator.gson().fromJson(body, SynthesizeVoiceRequest.class); + + return BeanValidator.validateAndExecute(req, dto -> { + String wordId = dto.getWordId(); + String voice = dto.getVoice() != null ? dto.getVoice() : "FEMALE"; + String type = dto.getType() != null ? dto.getType() : "WORD"; + + Optional optWord = wordRepository.findById(wordId); + if (optWord.isEmpty()) { + return ResponseGenerator.fail(VocabularyErrorCode.WORD_NOT_FOUND); + } + + Word word = optWord.get(); + boolean isMale = "MALE".equalsIgnoreCase(voice); + boolean isExample = "EXAMPLE".equalsIgnoreCase(type); + + if (isExample && (word.getExample() == null || word.getExample().isEmpty())) { + return ResponseGenerator.fail(VocabularyErrorCode.INVALID_WORD_DATA, "This word has no example sentence"); + } + + String textToSynthesize = isExample ? word.getExample() : word.getEnglish(); + String cachedKey = getCachedKey(word, isMale, isExample); + String audioUrl; + boolean cached = false; + + if (cachedKey != null && !cachedKey.isEmpty()) { + audioUrl = pollyService.getPresignedUrl(cachedKey); + cached = true; + logger.info("Cache hit from DB: wordId={}, voice={}, type={}", wordId, voice, type); + } else { + String s3KeySuffix = isExample ? "_example" : ""; + PollyService.VoiceSynthesisResult result = pollyService.synthesizeSpeech( + wordId + s3KeySuffix, textToSynthesize, voice); + + audioUrl = result.getAudioUrl(); + cached = result.isCached(); + + setCachedKey(word, isMale, isExample, result.getS3Key()); + wordRepository.save(word); + logger.info("Saved voice cache to DB: wordId={}, voice={}, type={}", wordId, voice, type); + } + + Map responseData = new HashMap<>(); + responseData.put("wordId", wordId); + responseData.put("text", textToSynthesize); + responseData.put("type", type); + responseData.put("voice", voice); + responseData.put("audioUrl", audioUrl); + responseData.put("cached", cached); + + return ResponseGenerator.ok("Speech synthesized", responseData); + }); + } + + private String getCachedKey(Word word, boolean isMale, boolean isExample) { + if (isExample) { + return isMale ? word.getMaleExampleVoiceKey() : word.getFemaleExampleVoiceKey(); + } else { + return isMale ? word.getMaleVoiceKey() : word.getFemaleVoiceKey(); + } + } + + private void setCachedKey(Word word, boolean isMale, boolean isExample, String s3Key) { + if (isExample) { + if (isMale) { + word.setMaleExampleVoiceKey(s3Key); + } else { + word.setFemaleExampleVoiceKey(s3Key); + } + } else { + if (isMale) { + word.setMaleVoiceKey(s3Key); + } else { + word.setFemaleVoiceKey(s3Key); + } + } + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/WordGroupHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/WordGroupHandler.java index 64c53af7..68206c51 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/WordGroupHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/WordGroupHandler.java @@ -21,142 +21,142 @@ import java.util.Map; public class WordGroupHandler implements RequestHandler { - - private static final Logger logger = LoggerFactory.getLogger(WordGroupHandler.class); - - private final WordGroupCommandService commandService; - private final WordGroupQueryService queryService; - private final HandlerRouter router; - - public WordGroupHandler() { - this.commandService = new WordGroupCommandService(); - this.queryService = new WordGroupQueryService(); - this.router = initRouter(); - } - - private HandlerRouter initRouter() { - return new HandlerRouter().addRoutes( - Route.postAuth("/groups", this::createGroup), - Route.getAuth("/groups", this::getGroups), - Route.getAuth("/groups/{groupId}", this::getGroupDetail), - Route.putAuth("/groups/{groupId}", this::updateGroup), - Route.deleteAuth("/groups/{groupId}", this::deleteGroup), - Route.postAuth("/groups/{groupId}/words/{wordId}", this::addWordToGroup), - Route.deleteAuth("/groups/{groupId}/words/{wordId}", this::removeWordFromGroup) - ); - } - - @Override - public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { - logger.info("Received request: {} {}", request.getHttpMethod(), request.getPath()); - return router.route(request); - } - - private APIGatewayProxyResponseEvent createGroup(APIGatewayProxyRequestEvent request, String userId) { - CreateWordGroupRequest req = ResponseGenerator.gson().fromJson(request.getBody(), CreateWordGroupRequest.class); - - return BeanValidator.validateAndExecute(req, dto -> { - WordGroup group = commandService.createGroup(userId, dto.getGroupName(), dto.getDescription()); - return ResponseGenerator.created("Group created", group); - }); - } - - private APIGatewayProxyResponseEvent getGroups(APIGatewayProxyRequestEvent request, String userId) { - Map queryParams = request.getQueryStringParameters(); - String cursor = queryParams != null ? queryParams.get("cursor") : null; - - int limit = parseIntParam(queryParams, "limit", 20, 1, 50); - - PaginatedResult result = queryService.getGroups(userId, limit, cursor); - - Map response = new HashMap<>(); - response.put("groups", result.items()); - response.put("nextCursor", result.nextCursor()); - response.put("hasMore", result.hasMore()); - - return ResponseGenerator.ok("Groups retrieved", response); - } - - private APIGatewayProxyResponseEvent getGroupDetail(APIGatewayProxyRequestEvent request, String userId) { - String groupId = request.getPathParameters().get("groupId"); - - var optDetail = queryService.getGroupDetail(userId, groupId); - if (optDetail.isEmpty()) { - return ResponseGenerator.fail(CommonErrorCode.RESOURCE_NOT_FOUND); - } - - var detail = optDetail.get(); - - Map response = new HashMap<>(); - response.put("groupId", detail.group().getGroupId()); - response.put("groupName", detail.group().getGroupName()); - response.put("description", detail.group().getDescription()); - response.put("wordCount", detail.group().getWordCount()); - response.put("words", detail.words()); - response.put("createdAt", detail.group().getCreatedAt()); - response.put("updatedAt", detail.group().getUpdatedAt()); - - return ResponseGenerator.ok("Group detail retrieved", response); - } - - private APIGatewayProxyResponseEvent updateGroup(APIGatewayProxyRequestEvent request, String userId) { - String groupId = request.getPathParameters().get("groupId"); - CreateWordGroupRequest req = ResponseGenerator.gson().fromJson(request.getBody(), CreateWordGroupRequest.class); - - try { - WordGroup group = commandService.updateGroup(userId, groupId, - req != null ? req.getGroupName() : null, - req != null ? req.getDescription() : null); - return ResponseGenerator.ok("Group updated", group); - } catch (IllegalArgumentException e) { - return ResponseGenerator.fail(CommonErrorCode.RESOURCE_NOT_FOUND); - } - } - - private APIGatewayProxyResponseEvent deleteGroup(APIGatewayProxyRequestEvent request, String userId) { - String groupId = request.getPathParameters().get("groupId"); - - try { - commandService.deleteGroup(userId, groupId); - return ResponseGenerator.ok("Group deleted", null); - } catch (IllegalArgumentException e) { - return ResponseGenerator.fail(CommonErrorCode.RESOURCE_NOT_FOUND); - } - } - - private APIGatewayProxyResponseEvent addWordToGroup(APIGatewayProxyRequestEvent request, String userId) { - String groupId = request.getPathParameters().get("groupId"); - String wordId = request.getPathParameters().get("wordId"); - - try { - WordGroup group = commandService.addWordToGroup(userId, groupId, wordId); - return ResponseGenerator.ok("Word added to group", group); - } catch (IllegalArgumentException e) { - return ResponseGenerator.fail(CommonErrorCode.RESOURCE_NOT_FOUND); - } - } - - private APIGatewayProxyResponseEvent removeWordFromGroup(APIGatewayProxyRequestEvent request, String userId) { - String groupId = request.getPathParameters().get("groupId"); - String wordId = request.getPathParameters().get("wordId"); - - try { - WordGroup group = commandService.removeWordFromGroup(userId, groupId, wordId); - return ResponseGenerator.ok("Word removed from group", group); - } catch (IllegalArgumentException e) { - return ResponseGenerator.fail(CommonErrorCode.RESOURCE_NOT_FOUND); - } - } - - private int parseIntParam(Map params, String key, int defaultValue, int min, int max) { - if (params == null || params.get(key) == null) { - return defaultValue; - } - try { - int value = Integer.parseInt(params.get(key)); - return Math.max(min, Math.min(value, max)); - } catch (NumberFormatException e) { - return defaultValue; - } - } + + private static final Logger logger = LoggerFactory.getLogger(WordGroupHandler.class); + + private final WordGroupCommandService commandService; + private final WordGroupQueryService queryService; + private final HandlerRouter router; + + public WordGroupHandler() { + this.commandService = new WordGroupCommandService(); + this.queryService = new WordGroupQueryService(); + this.router = initRouter(); + } + + private HandlerRouter initRouter() { + return new HandlerRouter().addRoutes( + Route.postAuth("/groups", this::createGroup), + Route.getAuth("/groups", this::getGroups), + Route.getAuth("/groups/{groupId}", this::getGroupDetail), + Route.putAuth("/groups/{groupId}", this::updateGroup), + Route.deleteAuth("/groups/{groupId}", this::deleteGroup), + Route.postAuth("/groups/{groupId}/words/{wordId}", this::addWordToGroup), + Route.deleteAuth("/groups/{groupId}/words/{wordId}", this::removeWordFromGroup) + ); + } + + @Override + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { + logger.info("Received request: {} {}", request.getHttpMethod(), request.getPath()); + return router.route(request); + } + + private APIGatewayProxyResponseEvent createGroup(APIGatewayProxyRequestEvent request, String userId) { + CreateWordGroupRequest req = ResponseGenerator.gson().fromJson(request.getBody(), CreateWordGroupRequest.class); + + return BeanValidator.validateAndExecute(req, dto -> { + WordGroup group = commandService.createGroup(userId, dto.getGroupName(), dto.getDescription()); + return ResponseGenerator.created("Group created", group); + }); + } + + private APIGatewayProxyResponseEvent getGroups(APIGatewayProxyRequestEvent request, String userId) { + Map queryParams = request.getQueryStringParameters(); + String cursor = queryParams != null ? queryParams.get("cursor") : null; + + int limit = parseIntParam(queryParams, "limit", 20, 1, 50); + + PaginatedResult result = queryService.getGroups(userId, limit, cursor); + + Map response = new HashMap<>(); + response.put("groups", result.items()); + response.put("nextCursor", result.nextCursor()); + response.put("hasMore", result.hasMore()); + + return ResponseGenerator.ok("Groups retrieved", response); + } + + private APIGatewayProxyResponseEvent getGroupDetail(APIGatewayProxyRequestEvent request, String userId) { + String groupId = request.getPathParameters().get("groupId"); + + var optDetail = queryService.getGroupDetail(userId, groupId); + if (optDetail.isEmpty()) { + return ResponseGenerator.fail(CommonErrorCode.RESOURCE_NOT_FOUND); + } + + var detail = optDetail.get(); + + Map response = new HashMap<>(); + response.put("groupId", detail.group().getGroupId()); + response.put("groupName", detail.group().getGroupName()); + response.put("description", detail.group().getDescription()); + response.put("wordCount", detail.group().getWordCount()); + response.put("words", detail.words()); + response.put("createdAt", detail.group().getCreatedAt()); + response.put("updatedAt", detail.group().getUpdatedAt()); + + return ResponseGenerator.ok("Group detail retrieved", response); + } + + private APIGatewayProxyResponseEvent updateGroup(APIGatewayProxyRequestEvent request, String userId) { + String groupId = request.getPathParameters().get("groupId"); + CreateWordGroupRequest req = ResponseGenerator.gson().fromJson(request.getBody(), CreateWordGroupRequest.class); + + try { + WordGroup group = commandService.updateGroup(userId, groupId, + req != null ? req.getGroupName() : null, + req != null ? req.getDescription() : null); + return ResponseGenerator.ok("Group updated", group); + } catch (IllegalArgumentException e) { + return ResponseGenerator.fail(CommonErrorCode.RESOURCE_NOT_FOUND); + } + } + + private APIGatewayProxyResponseEvent deleteGroup(APIGatewayProxyRequestEvent request, String userId) { + String groupId = request.getPathParameters().get("groupId"); + + try { + commandService.deleteGroup(userId, groupId); + return ResponseGenerator.ok("Group deleted", null); + } catch (IllegalArgumentException e) { + return ResponseGenerator.fail(CommonErrorCode.RESOURCE_NOT_FOUND); + } + } + + private APIGatewayProxyResponseEvent addWordToGroup(APIGatewayProxyRequestEvent request, String userId) { + String groupId = request.getPathParameters().get("groupId"); + String wordId = request.getPathParameters().get("wordId"); + + try { + WordGroup group = commandService.addWordToGroup(userId, groupId, wordId); + return ResponseGenerator.ok("Word added to group", group); + } catch (IllegalArgumentException e) { + return ResponseGenerator.fail(CommonErrorCode.RESOURCE_NOT_FOUND); + } + } + + private APIGatewayProxyResponseEvent removeWordFromGroup(APIGatewayProxyRequestEvent request, String userId) { + String groupId = request.getPathParameters().get("groupId"); + String wordId = request.getPathParameters().get("wordId"); + + try { + WordGroup group = commandService.removeWordFromGroup(userId, groupId, wordId); + return ResponseGenerator.ok("Word removed from group", group); + } catch (IllegalArgumentException e) { + return ResponseGenerator.fail(CommonErrorCode.RESOURCE_NOT_FOUND); + } + } + + private int parseIntParam(Map params, String key, int defaultValue, int min, int max) { + if (params == null || params.get(key) == null) { + return defaultValue; + } + try { + int value = Integer.parseInt(params.get(key)); + return Math.max(min, Math.min(value, max)); + } catch (NumberFormatException e) { + return defaultValue; + } + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/WordHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/WordHandler.java index 51130c52..8944e872 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/WordHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/WordHandler.java @@ -6,14 +6,14 @@ import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; import com.mzc.secondproject.serverless.common.dto.PaginatedResult; import com.mzc.secondproject.serverless.common.exception.CommonErrorCode; +import com.mzc.secondproject.serverless.common.router.HandlerRouter; +import com.mzc.secondproject.serverless.common.router.Route; +import com.mzc.secondproject.serverless.common.util.ResponseGenerator; import com.mzc.secondproject.serverless.common.validation.BeanValidator; import com.mzc.secondproject.serverless.domain.vocabulary.dto.request.BatchGetWordsRequest; import com.mzc.secondproject.serverless.domain.vocabulary.dto.request.CreateWordRequest; import com.mzc.secondproject.serverless.domain.vocabulary.dto.request.CreateWordsBatchRequest; import com.mzc.secondproject.serverless.domain.vocabulary.exception.VocabularyErrorCode; -import com.mzc.secondproject.serverless.common.router.HandlerRouter; -import com.mzc.secondproject.serverless.common.router.Route; -import com.mzc.secondproject.serverless.common.util.ResponseGenerator; import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; import com.mzc.secondproject.serverless.domain.vocabulary.service.WordCommandService; import com.mzc.secondproject.serverless.domain.vocabulary.service.WordQueryService; @@ -26,154 +26,154 @@ import java.util.Optional; public class WordHandler implements RequestHandler { - - private static final Logger logger = LoggerFactory.getLogger(WordHandler.class); - - private final WordCommandService commandService; - private final WordQueryService queryService; - private final HandlerRouter router; - - public WordHandler() { - this.commandService = new WordCommandService(); - this.queryService = new WordQueryService(); - this.router = initRouter(); - } - - private HandlerRouter initRouter() { - return new HandlerRouter().addRoutes( - Route.post("/words/batch/get", this::getWordsBatch), - Route.post("/words/batch", this::createWordsBatch), - Route.get("/words/search", this::searchWords).requireQueryParams("q"), - Route.post("/words", this::createWord), - Route.get("/words", this::getWords), - Route.get("/words/{wordId}", this::getWord), - Route.put("/words/{wordId}", this::updateWord), - Route.delete("/words/{wordId}", this::deleteWord) - ); - } - - @Override - public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { - logger.info("Received request: {} {}", request.getHttpMethod(), request.getPath()); - return router.route(request); - } - - private APIGatewayProxyResponseEvent createWord(APIGatewayProxyRequestEvent request) { - String body = request.getBody(); - CreateWordRequest req = ResponseGenerator.gson().fromJson(body, CreateWordRequest.class); - - return BeanValidator.validateAndExecute(req, dto -> { - String level = dto.getLevel() != null ? dto.getLevel() : "BEGINNER"; - String category = dto.getCategory() != null ? dto.getCategory() : "DAILY"; - - Word word = commandService.createWord(dto.getEnglish(), dto.getKorean(), dto.getExample(), level, category); - return ResponseGenerator.created("Word created", word); - }); - } - - private APIGatewayProxyResponseEvent getWords(APIGatewayProxyRequestEvent request) { - Map queryParams = request.getQueryStringParameters(); - - String level = queryParams != null ? queryParams.get("level") : null; - String category = queryParams != null ? queryParams.get("category") : null; - String cursor = queryParams != null ? queryParams.get("cursor") : null; - - int limit = 20; - if (queryParams != null && queryParams.get("limit") != null) { - limit = Math.min(Integer.parseInt(queryParams.get("limit")), 50); - } - - PaginatedResult wordPage = queryService.getWords(level, category, limit, cursor); - - Map result = new HashMap<>(); - result.put("words", wordPage.items()); - result.put("nextCursor", wordPage.nextCursor()); - result.put("hasMore", wordPage.hasMore()); - - return ResponseGenerator.ok("Words retrieved", result); - } - - private APIGatewayProxyResponseEvent getWord(APIGatewayProxyRequestEvent request) { - String wordId = request.getPathParameters().get("wordId"); - - Optional optWord = queryService.getWord(wordId); - if (optWord.isEmpty()) { - return ResponseGenerator.fail(VocabularyErrorCode.WORD_NOT_FOUND); - } - - return ResponseGenerator.ok("Word retrieved", optWord.get()); - } - - private APIGatewayProxyResponseEvent updateWord(APIGatewayProxyRequestEvent request) { - String wordId = request.getPathParameters().get("wordId"); - String body = request.getBody(); - Map requestBody = ResponseGenerator.gson().fromJson(body, Map.class); - - Word word = commandService.updateWord(wordId, requestBody); - return ResponseGenerator.ok("Word updated", word); - } - - private APIGatewayProxyResponseEvent deleteWord(APIGatewayProxyRequestEvent request) { - String wordId = request.getPathParameters().get("wordId"); - commandService.deleteWord(wordId); - return ResponseGenerator.ok("Word deleted", null); - } - - private APIGatewayProxyResponseEvent createWordsBatch(APIGatewayProxyRequestEvent request) { - String body = request.getBody(); - CreateWordsBatchRequest req = ResponseGenerator.gson().fromJson(body, CreateWordsBatchRequest.class); - - return BeanValidator.validateAndExecute(req, dto -> { - WordCommandService.BatchResult result = commandService.createWordsBatch(dto.getWords()); - - Map response = new HashMap<>(); - response.put("successCount", result.successCount()); - response.put("failCount", result.failCount()); - response.put("totalRequested", result.totalRequested()); - - return ResponseGenerator.created("Batch completed", response); - }); - } - - private APIGatewayProxyResponseEvent searchWords(APIGatewayProxyRequestEvent request) { - Map queryParams = request.getQueryStringParameters(); - - String query = queryParams.get("q"); - String cursor = queryParams.get("cursor"); - - int limit = 20; - if (queryParams.get("limit") != null) { - limit = Math.min(Integer.parseInt(queryParams.get("limit")), 50); - } - - PaginatedResult wordPage = queryService.searchWords(query, limit, cursor); - - Map result = new HashMap<>(); - result.put("words", wordPage.items()); - result.put("query", query); - result.put("nextCursor", wordPage.nextCursor()); - result.put("hasMore", wordPage.hasMore()); - - return ResponseGenerator.ok("Search completed", result); - } - - private APIGatewayProxyResponseEvent getWordsBatch(APIGatewayProxyRequestEvent request) { - String body = request.getBody(); - BatchGetWordsRequest req = ResponseGenerator.gson().fromJson(body, BatchGetWordsRequest.class); - - return BeanValidator.validateAndExecute(req, dto -> { - if (dto.getWordIds().size() > 100) { - return ResponseGenerator.fail(CommonErrorCode.VALUE_OUT_OF_RANGE, "Maximum 100 wordIds allowed per request"); - } - - List words = queryService.getWordsByIds(dto.getWordIds()); - - Map result = new HashMap<>(); - result.put("words", words); - result.put("requestedCount", dto.getWordIds().size()); - result.put("retrievedCount", words.size()); - - return ResponseGenerator.ok("Words retrieved", result); - }); - } + + private static final Logger logger = LoggerFactory.getLogger(WordHandler.class); + + private final WordCommandService commandService; + private final WordQueryService queryService; + private final HandlerRouter router; + + public WordHandler() { + this.commandService = new WordCommandService(); + this.queryService = new WordQueryService(); + this.router = initRouter(); + } + + private HandlerRouter initRouter() { + return new HandlerRouter().addRoutes( + Route.post("/words/batch/get", this::getWordsBatch), + Route.post("/words/batch", this::createWordsBatch), + Route.get("/words/search", this::searchWords).requireQueryParams("q"), + Route.post("/words", this::createWord), + Route.get("/words", this::getWords), + Route.get("/words/{wordId}", this::getWord), + Route.put("/words/{wordId}", this::updateWord), + Route.delete("/words/{wordId}", this::deleteWord) + ); + } + + @Override + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { + logger.info("Received request: {} {}", request.getHttpMethod(), request.getPath()); + return router.route(request); + } + + private APIGatewayProxyResponseEvent createWord(APIGatewayProxyRequestEvent request) { + String body = request.getBody(); + CreateWordRequest req = ResponseGenerator.gson().fromJson(body, CreateWordRequest.class); + + return BeanValidator.validateAndExecute(req, dto -> { + String level = dto.getLevel() != null ? dto.getLevel() : "BEGINNER"; + String category = dto.getCategory() != null ? dto.getCategory() : "DAILY"; + + Word word = commandService.createWord(dto.getEnglish(), dto.getKorean(), dto.getExample(), level, category); + return ResponseGenerator.created("Word created", word); + }); + } + + private APIGatewayProxyResponseEvent getWords(APIGatewayProxyRequestEvent request) { + Map queryParams = request.getQueryStringParameters(); + + String level = queryParams != null ? queryParams.get("level") : null; + String category = queryParams != null ? queryParams.get("category") : null; + String cursor = queryParams != null ? queryParams.get("cursor") : null; + + int limit = 20; + if (queryParams != null && queryParams.get("limit") != null) { + limit = Math.min(Integer.parseInt(queryParams.get("limit")), 50); + } + + PaginatedResult wordPage = queryService.getWords(level, category, limit, cursor); + + Map result = new HashMap<>(); + result.put("words", wordPage.items()); + result.put("nextCursor", wordPage.nextCursor()); + result.put("hasMore", wordPage.hasMore()); + + return ResponseGenerator.ok("Words retrieved", result); + } + + private APIGatewayProxyResponseEvent getWord(APIGatewayProxyRequestEvent request) { + String wordId = request.getPathParameters().get("wordId"); + + Optional optWord = queryService.getWord(wordId); + if (optWord.isEmpty()) { + return ResponseGenerator.fail(VocabularyErrorCode.WORD_NOT_FOUND); + } + + return ResponseGenerator.ok("Word retrieved", optWord.get()); + } + + private APIGatewayProxyResponseEvent updateWord(APIGatewayProxyRequestEvent request) { + String wordId = request.getPathParameters().get("wordId"); + String body = request.getBody(); + Map requestBody = ResponseGenerator.gson().fromJson(body, Map.class); + + Word word = commandService.updateWord(wordId, requestBody); + return ResponseGenerator.ok("Word updated", word); + } + + private APIGatewayProxyResponseEvent deleteWord(APIGatewayProxyRequestEvent request) { + String wordId = request.getPathParameters().get("wordId"); + commandService.deleteWord(wordId); + return ResponseGenerator.ok("Word deleted", null); + } + + private APIGatewayProxyResponseEvent createWordsBatch(APIGatewayProxyRequestEvent request) { + String body = request.getBody(); + CreateWordsBatchRequest req = ResponseGenerator.gson().fromJson(body, CreateWordsBatchRequest.class); + + return BeanValidator.validateAndExecute(req, dto -> { + WordCommandService.BatchResult result = commandService.createWordsBatch(dto.getWords()); + + Map response = new HashMap<>(); + response.put("successCount", result.successCount()); + response.put("failCount", result.failCount()); + response.put("totalRequested", result.totalRequested()); + + return ResponseGenerator.created("Batch completed", response); + }); + } + + private APIGatewayProxyResponseEvent searchWords(APIGatewayProxyRequestEvent request) { + Map queryParams = request.getQueryStringParameters(); + + String query = queryParams.get("q"); + String cursor = queryParams.get("cursor"); + + int limit = 20; + if (queryParams.get("limit") != null) { + limit = Math.min(Integer.parseInt(queryParams.get("limit")), 50); + } + + PaginatedResult wordPage = queryService.searchWords(query, limit, cursor); + + Map result = new HashMap<>(); + result.put("words", wordPage.items()); + result.put("query", query); + result.put("nextCursor", wordPage.nextCursor()); + result.put("hasMore", wordPage.hasMore()); + + return ResponseGenerator.ok("Search completed", result); + } + + private APIGatewayProxyResponseEvent getWordsBatch(APIGatewayProxyRequestEvent request) { + String body = request.getBody(); + BatchGetWordsRequest req = ResponseGenerator.gson().fromJson(body, BatchGetWordsRequest.class); + + return BeanValidator.validateAndExecute(req, dto -> { + if (dto.getWordIds().size() > 100) { + return ResponseGenerator.fail(CommonErrorCode.VALUE_OUT_OF_RANGE, "Maximum 100 wordIds allowed per request"); + } + + List words = queryService.getWordsByIds(dto.getWordIds()); + + Map result = new HashMap<>(); + result.put("words", words); + result.put("requestedCount", dto.getWordIds().size()); + result.put("retrievedCount", words.size()); + + return ResponseGenerator.ok("Words retrieved", result); + }); + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/model/DailyStudy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/model/DailyStudy.java index 8a7fc48d..a6f8388c 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/model/DailyStudy.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/model/DailyStudy.java @@ -4,12 +4,7 @@ import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbAttribute; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondaryPartitionKey; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondarySortKey; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSortKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.*; import java.util.List; @@ -25,50 +20,50 @@ @AllArgsConstructor @DynamoDbBean public class DailyStudy { - - private String pk; // DAILY#{userId} - private String sk; // DATE#{date} - private String gsi1pk; // DAILY#ALL - private String gsi1sk; // DATE#{date} - - private String userId; - private String date; // yyyy-MM-dd - - // 학습 단어 목록 (55개: 50개 신규 + 5개 복습) - private List newWordIds; // 신규 단어 ID 목록 (50개) - private List reviewWordIds; // 복습 단어 ID 목록 (5개) - private List learnedWordIds; // 학습 완료 단어 ID 목록 - - // 진행 상태 - private Integer totalWords; // 총 단어 수 (55) - private Integer learnedCount; // 학습 완료 수 - private Boolean isCompleted; // 일일 학습 완료 여부 - - private String createdAt; - private String updatedAt; - private Long ttl; - - @DynamoDbPartitionKey - @DynamoDbAttribute("PK") - public String getPk() { - return pk; - } - - @DynamoDbSortKey - @DynamoDbAttribute("SK") - public String getSk() { - return sk; - } - - @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") - @DynamoDbAttribute("GSI1PK") - public String getGsi1pk() { - return gsi1pk; - } - - @DynamoDbSecondarySortKey(indexNames = "GSI1") - @DynamoDbAttribute("GSI1SK") - public String getGsi1sk() { - return gsi1sk; - } + + private String pk; // DAILY#{userId} + private String sk; // DATE#{date} + private String gsi1pk; // DAILY#ALL + private String gsi1sk; // DATE#{date} + + private String userId; + private String date; // yyyy-MM-dd + + // 학습 단어 목록 (55개: 50개 신규 + 5개 복습) + private List newWordIds; // 신규 단어 ID 목록 (50개) + private List reviewWordIds; // 복습 단어 ID 목록 (5개) + private List learnedWordIds; // 학습 완료 단어 ID 목록 + + // 진행 상태 + private Integer totalWords; // 총 단어 수 (55) + private Integer learnedCount; // 학습 완료 수 + private Boolean isCompleted; // 일일 학습 완료 여부 + + private String createdAt; + private String updatedAt; + private Long ttl; + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { + return pk; + } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { + return sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1PK") + public String getGsi1pk() { + return gsi1pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1SK") + public String getGsi1sk() { + return gsi1sk; + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/model/TestResult.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/model/TestResult.java index e8bb593a..dbd4ad41 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/model/TestResult.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/model/TestResult.java @@ -4,12 +4,7 @@ import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbAttribute; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondaryPartitionKey; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondarySortKey; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSortKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.*; import java.util.List; @@ -25,53 +20,53 @@ @AllArgsConstructor @DynamoDbBean public class TestResult { - - private String pk; // TEST#{userId} - private String sk; // RESULT#{timestamp} - private String gsi1pk; // TEST#ALL - private String gsi1sk; // DATE#{date} - - private String testId; - private String userId; - private String testType; // DAILY, WEEKLY, CUSTOM - - // 시험 결과 - private Integer totalQuestions; - private Integer correctAnswers; - private Integer incorrectAnswers; - private Double successRate; // 성공률 (%) - - // 시험에 출제된 전체 단어 목록 - private List testedWordIds; - - // 오답 단어 목록 - private List incorrectWordIds; - - private String startedAt; - private String completedAt; - private Long ttl; - - @DynamoDbPartitionKey - @DynamoDbAttribute("PK") - public String getPk() { - return pk; - } - - @DynamoDbSortKey - @DynamoDbAttribute("SK") - public String getSk() { - return sk; - } - - @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") - @DynamoDbAttribute("GSI1PK") - public String getGsi1pk() { - return gsi1pk; - } - - @DynamoDbSecondarySortKey(indexNames = "GSI1") - @DynamoDbAttribute("GSI1SK") - public String getGsi1sk() { - return gsi1sk; - } + + private String pk; // TEST#{userId} + private String sk; // RESULT#{timestamp} + private String gsi1pk; // TEST#ALL + private String gsi1sk; // DATE#{date} + + private String testId; + private String userId; + private String testType; // DAILY, WEEKLY, CUSTOM + + // 시험 결과 + private Integer totalQuestions; + private Integer correctAnswers; + private Integer incorrectAnswers; + private Double successRate; // 성공률 (%) + + // 시험에 출제된 전체 단어 목록 + private List testedWordIds; + + // 오답 단어 목록 + private List incorrectWordIds; + + private String startedAt; + private String completedAt; + private Long ttl; + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { + return pk; + } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { + return sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1PK") + public String getGsi1pk() { + return gsi1pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1SK") + public String getGsi1sk() { + return gsi1sk; + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/model/UserWord.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/model/UserWord.java index 969315b9..d5b2d8d8 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/model/UserWord.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/model/UserWord.java @@ -4,12 +4,7 @@ import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbAttribute; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondaryPartitionKey; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondarySortKey; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSortKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.*; /** * 사용자별 단어 학습 상태 (Spaced Repetition) @@ -25,84 +20,84 @@ @AllArgsConstructor @DynamoDbBean public class UserWord { - - private String pk; // USER#{userId} - private String sk; // WORD#{wordId} - private String gsi1pk; // USER#{userId}#REVIEW - private String gsi1sk; // DATE#{nextReviewAt} - private String gsi2pk; // USER#{userId}#STATUS - private String gsi2sk; // STATUS#{status} - private String gsi3pk; // USER#{userId}#BOOKMARKED (Sparse: only when bookmarked) - private String gsi3sk; // WORD#{wordId} - - private String userId; - private String wordId; - private String status; // NEW, LEARNING, REVIEWING, MASTERED - - // Spaced Repetition 알고리즘 필드 - private Integer interval; // 복습 간격 (일) - private Double easeFactor; // 난이도 계수 (2.5 기본) - private Integer repetitions; // 연속 정답 횟수 - private String nextReviewAt; // 다음 복습 예정일 - private String lastReviewedAt; // 마지막 복습일 - - // 학습 통계 - private Integer correctCount; // 정답 횟수 - private Integer incorrectCount; // 오답 횟수 - private String createdAt; - private String updatedAt; - private Long ttl; - - // 사용자 태그 - private Boolean bookmarked; // 북마크 여부 - private Boolean favorite; // 즐겨찾기 여부 - private String difficulty; // 사용자 지정 난이도 (EASY, NORMAL, HARD) - - @DynamoDbPartitionKey - @DynamoDbAttribute("PK") - public String getPk() { - return pk; - } - - @DynamoDbSortKey - @DynamoDbAttribute("SK") - public String getSk() { - return sk; - } - - @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") - @DynamoDbAttribute("GSI1PK") - public String getGsi1pk() { - return gsi1pk; - } - - @DynamoDbSecondarySortKey(indexNames = "GSI1") - @DynamoDbAttribute("GSI1SK") - public String getGsi1sk() { - return gsi1sk; - } - - @DynamoDbSecondaryPartitionKey(indexNames = "GSI2") - @DynamoDbAttribute("GSI2PK") - public String getGsi2pk() { - return gsi2pk; - } - - @DynamoDbSecondarySortKey(indexNames = "GSI2") - @DynamoDbAttribute("GSI2SK") - public String getGsi2sk() { - return gsi2sk; - } - - @DynamoDbSecondaryPartitionKey(indexNames = "GSI3") - @DynamoDbAttribute("GSI3PK") - public String getGsi3pk() { - return gsi3pk; - } - - @DynamoDbSecondarySortKey(indexNames = "GSI3") - @DynamoDbAttribute("GSI3SK") - public String getGsi3sk() { - return gsi3sk; - } + + private String pk; // USER#{userId} + private String sk; // WORD#{wordId} + private String gsi1pk; // USER#{userId}#REVIEW + private String gsi1sk; // DATE#{nextReviewAt} + private String gsi2pk; // USER#{userId}#STATUS + private String gsi2sk; // STATUS#{status} + private String gsi3pk; // USER#{userId}#BOOKMARKED (Sparse: only when bookmarked) + private String gsi3sk; // WORD#{wordId} + + private String userId; + private String wordId; + private String status; // NEW, LEARNING, REVIEWING, MASTERED + + // Spaced Repetition 알고리즘 필드 + private Integer interval; // 복습 간격 (일) + private Double easeFactor; // 난이도 계수 (2.5 기본) + private Integer repetitions; // 연속 정답 횟수 + private String nextReviewAt; // 다음 복습 예정일 + private String lastReviewedAt; // 마지막 복습일 + + // 학습 통계 + private Integer correctCount; // 정답 횟수 + private Integer incorrectCount; // 오답 횟수 + private String createdAt; + private String updatedAt; + private Long ttl; + + // 사용자 태그 + private Boolean bookmarked; // 북마크 여부 + private Boolean favorite; // 즐겨찾기 여부 + private String difficulty; // 사용자 지정 난이도 (EASY, NORMAL, HARD) + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { + return pk; + } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { + return sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1PK") + public String getGsi1pk() { + return gsi1pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1SK") + public String getGsi1sk() { + return gsi1sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI2") + @DynamoDbAttribute("GSI2PK") + public String getGsi2pk() { + return gsi2pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI2") + @DynamoDbAttribute("GSI2SK") + public String getGsi2sk() { + return gsi2sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI3") + @DynamoDbAttribute("GSI3PK") + public String getGsi3pk() { + return gsi3pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI3") + @DynamoDbAttribute("GSI3SK") + public String getGsi3sk() { + return gsi3sk; + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/model/Word.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/model/Word.java index f217362d..ace8e4ec 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/model/Word.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/model/Word.java @@ -4,12 +4,7 @@ import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbAttribute; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondaryPartitionKey; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondarySortKey; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSortKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.*; /** * 단어 정보 모델 @@ -24,64 +19,64 @@ @AllArgsConstructor @DynamoDbBean public class Word { - - private String pk; // WORD#{wordId} - private String sk; // METADATA - private String gsi1pk; // LEVEL#{level} - private String gsi1sk; // WORD#{wordId} - private String gsi2pk; // CATEGORY#{category} - private String gsi2sk; // WORD#{wordId} - - private String wordId; - private String english; // 영어 단어 - private String korean; // 한국어 뜻 - private String example; // 예문 - private String level; // BEGINNER, INTERMEDIATE, ADVANCED - private String category; // DAILY, BUSINESS, ACADEMIC, etc. - private String createdAt; - private Long ttl; - - // 단어 음성 캐시용 S3 키 (vocab/voice/{wordId}_{voice}.mp3) - private String maleVoiceKey; - private String femaleVoiceKey; - - // 예문 음성 캐시용 S3 키 (vocab/voice/{wordId}_{voice}_example.mp3) - private String maleExampleVoiceKey; - private String femaleExampleVoiceKey; - - @DynamoDbPartitionKey - @DynamoDbAttribute("PK") - public String getPk() { - return pk; - } - - @DynamoDbSortKey - @DynamoDbAttribute("SK") - public String getSk() { - return sk; - } - - @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") - @DynamoDbAttribute("GSI1PK") - public String getGsi1pk() { - return gsi1pk; - } - - @DynamoDbSecondarySortKey(indexNames = "GSI1") - @DynamoDbAttribute("GSI1SK") - public String getGsi1sk() { - return gsi1sk; - } - - @DynamoDbSecondaryPartitionKey(indexNames = "GSI2") - @DynamoDbAttribute("GSI2PK") - public String getGsi2pk() { - return gsi2pk; - } - - @DynamoDbSecondarySortKey(indexNames = "GSI2") - @DynamoDbAttribute("GSI2SK") - public String getGsi2sk() { - return gsi2sk; - } + + private String pk; // WORD#{wordId} + private String sk; // METADATA + private String gsi1pk; // LEVEL#{level} + private String gsi1sk; // WORD#{wordId} + private String gsi2pk; // CATEGORY#{category} + private String gsi2sk; // WORD#{wordId} + + private String wordId; + private String english; // 영어 단어 + private String korean; // 한국어 뜻 + private String example; // 예문 + private String level; // BEGINNER, INTERMEDIATE, ADVANCED + private String category; // DAILY, BUSINESS, ACADEMIC, etc. + private String createdAt; + private Long ttl; + + // 단어 음성 캐시용 S3 키 (vocab/voice/{wordId}_{voice}.mp3) + private String maleVoiceKey; + private String femaleVoiceKey; + + // 예문 음성 캐시용 S3 키 (vocab/voice/{wordId}_{voice}_example.mp3) + private String maleExampleVoiceKey; + private String femaleExampleVoiceKey; + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { + return pk; + } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { + return sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1PK") + public String getGsi1pk() { + return gsi1pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1SK") + public String getGsi1sk() { + return gsi1sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI2") + @DynamoDbAttribute("GSI2PK") + public String getGsi2pk() { + return gsi2pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI2") + @DynamoDbAttribute("GSI2SK") + public String getGsi2sk() { + return gsi2sk; + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/model/WordGroup.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/model/WordGroup.java index c65f9091..72579653 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/model/WordGroup.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/model/WordGroup.java @@ -22,29 +22,29 @@ @AllArgsConstructor @DynamoDbBean public class WordGroup { - - private String pk; // USER#{userId}#GROUP - private String sk; // GROUP#{groupId} - - private String groupId; - private String userId; - private String groupName; // TOEIC, TOEFL, 내 단어장 등 - private String description; - private List wordIds; - private Integer wordCount; - - private String createdAt; - private String updatedAt; - - @DynamoDbPartitionKey - @DynamoDbAttribute("PK") - public String getPk() { - return pk; - } - - @DynamoDbSortKey - @DynamoDbAttribute("SK") - public String getSk() { - return sk; - } + + private String pk; // USER#{userId}#GROUP + private String sk; // GROUP#{groupId} + + private String groupId; + private String userId; + private String groupName; // TOEIC, TOEFL, 내 단어장 등 + private String description; + private List wordIds; + private Integer wordCount; + + private String createdAt; + private String updatedAt; + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { + return pk; + } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { + return sk; + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/DailyStudyRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/DailyStudyRepository.java index 91fc967c..e3601078 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/DailyStudyRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/DailyStudyRepository.java @@ -1,5 +1,8 @@ package com.mzc.secondproject.serverless.domain.vocabulary.repository; +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.common.util.CursorUtil; import com.mzc.secondproject.serverless.domain.vocabulary.model.DailyStudy; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -12,95 +15,91 @@ import software.amazon.awssdk.services.dynamodb.model.AttributeValue; import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest; -import com.mzc.secondproject.serverless.common.dto.PaginatedResult; -import com.mzc.secondproject.serverless.common.config.AwsClients; -import com.mzc.secondproject.serverless.common.util.CursorUtil; - import java.util.HashMap; import java.util.Map; import java.util.Optional; public class DailyStudyRepository { - - private static final Logger logger = LoggerFactory.getLogger(DailyStudyRepository.class); - private static final String TABLE_NAME = System.getenv("VOCAB_TABLE_NAME"); - - private final DynamoDbTable table; - - public DailyStudyRepository() { - this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(DailyStudy.class)); - } - - public DailyStudy save(DailyStudy dailyStudy) { - logger.info("Saving daily study: userId={}, date={}", dailyStudy.getUserId(), dailyStudy.getDate()); - table.putItem(dailyStudy); - return dailyStudy; - } - - public Optional findByUserIdAndDate(String userId, String date) { - Key key = Key.builder() - .partitionValue("DAILY#" + userId) - .sortValue("DATE#" + date) - .build(); - - DailyStudy dailyStudy = table.getItem(key); - return Optional.ofNullable(dailyStudy); - } - - /** - * 사용자의 일일 학습 기록 조회 - 최신순, 페이지네이션 - */ - public PaginatedResult findByUserIdWithPagination(String userId, int limit, String cursor) { - QueryConditional queryConditional = QueryConditional - .sortBeginsWith(Key.builder() - .partitionValue("DAILY#" + userId) - .sortValue("DATE#") - .build()); - - QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() - .queryConditional(queryConditional) - .scanIndexForward(false) // 최신순 - .limit(limit); - - if (cursor != null && !cursor.isEmpty()) { - Map exclusiveStartKey = CursorUtil.decode(cursor); - if (exclusiveStartKey != null) { - requestBuilder.exclusiveStartKey(exclusiveStartKey); - } - } - - Page page = table.query(requestBuilder.build()).iterator().next(); - String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); - - return new PaginatedResult<>(page.items(), nextCursor); - } - - /** - * 학습 완료 단어 추가 (UpdateExpression 사용 - N+1 방지) - * List 타입에 대해 list_append 사용 - */ - public void addLearnedWord(String userId, String date, String wordId) { - Map key = new HashMap<>(); - key.put("PK", AttributeValue.builder().s("DAILY#" + userId).build()); - key.put("SK", AttributeValue.builder().s("DATE#" + date).build()); - - Map expressionValues = new HashMap<>(); - expressionValues.put(":newWord", AttributeValue.builder().l( - AttributeValue.builder().s(wordId).build() - ).build()); - expressionValues.put(":emptyList", AttributeValue.builder().l(java.util.Collections.emptyList()).build()); - expressionValues.put(":one", AttributeValue.builder().n("1").build()); - expressionValues.put(":zero", AttributeValue.builder().n("0").build()); - - UpdateItemRequest updateRequest = UpdateItemRequest.builder() - .tableName(TABLE_NAME) - .key(key) - .updateExpression("SET learnedWordIds = list_append(if_not_exists(learnedWordIds, :emptyList), :newWord), " + - "learnedCount = if_not_exists(learnedCount, :zero) + :one") - .expressionAttributeValues(expressionValues) - .build(); - - AwsClients.dynamoDb().updateItem(updateRequest); - logger.info("Added learned word: userId={}, date={}, wordId={}", userId, date, wordId); - } + + private static final Logger logger = LoggerFactory.getLogger(DailyStudyRepository.class); + private static final String TABLE_NAME = System.getenv("VOCAB_TABLE_NAME"); + + private final DynamoDbTable table; + + public DailyStudyRepository() { + this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(DailyStudy.class)); + } + + public DailyStudy save(DailyStudy dailyStudy) { + logger.info("Saving daily study: userId={}, date={}", dailyStudy.getUserId(), dailyStudy.getDate()); + table.putItem(dailyStudy); + return dailyStudy; + } + + public Optional findByUserIdAndDate(String userId, String date) { + Key key = Key.builder() + .partitionValue("DAILY#" + userId) + .sortValue("DATE#" + date) + .build(); + + DailyStudy dailyStudy = table.getItem(key); + return Optional.ofNullable(dailyStudy); + } + + /** + * 사용자의 일일 학습 기록 조회 - 최신순, 페이지네이션 + */ + public PaginatedResult findByUserIdWithPagination(String userId, int limit, String cursor) { + QueryConditional queryConditional = QueryConditional + .sortBeginsWith(Key.builder() + .partitionValue("DAILY#" + userId) + .sortValue("DATE#") + .build()); + + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .scanIndexForward(false) // 최신순 + .limit(limit); + + if (cursor != null && !cursor.isEmpty()) { + Map exclusiveStartKey = CursorUtil.decode(cursor); + if (exclusiveStartKey != null) { + requestBuilder.exclusiveStartKey(exclusiveStartKey); + } + } + + Page page = table.query(requestBuilder.build()).iterator().next(); + String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); + + return new PaginatedResult<>(page.items(), nextCursor); + } + + /** + * 학습 완료 단어 추가 (UpdateExpression 사용 - N+1 방지) + * List 타입에 대해 list_append 사용 + */ + public void addLearnedWord(String userId, String date, String wordId) { + Map key = new HashMap<>(); + key.put("PK", AttributeValue.builder().s("DAILY#" + userId).build()); + key.put("SK", AttributeValue.builder().s("DATE#" + date).build()); + + Map expressionValues = new HashMap<>(); + expressionValues.put(":newWord", AttributeValue.builder().l( + AttributeValue.builder().s(wordId).build() + ).build()); + expressionValues.put(":emptyList", AttributeValue.builder().l(java.util.Collections.emptyList()).build()); + expressionValues.put(":one", AttributeValue.builder().n("1").build()); + expressionValues.put(":zero", AttributeValue.builder().n("0").build()); + + UpdateItemRequest updateRequest = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(key) + .updateExpression("SET learnedWordIds = list_append(if_not_exists(learnedWordIds, :emptyList), :newWord), " + + "learnedCount = if_not_exists(learnedCount, :zero) + :one") + .expressionAttributeValues(expressionValues) + .build(); + + AwsClients.dynamoDb().updateItem(updateRequest); + logger.info("Added learned word: userId={}, date={}, wordId={}", userId, date, wordId); + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/TestResultRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/TestResultRepository.java index e7cd6fd0..0eeba78e 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/TestResultRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/TestResultRepository.java @@ -1,103 +1,102 @@ package com.mzc.secondproject.serverless.domain.vocabulary.repository; +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.common.util.CursorUtil; import com.mzc.secondproject.serverless.domain.vocabulary.model.TestResult; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; +import software.amazon.awssdk.enhanced.dynamodb.Expression; import software.amazon.awssdk.enhanced.dynamodb.Key; import software.amazon.awssdk.enhanced.dynamodb.TableSchema; -import software.amazon.awssdk.enhanced.dynamodb.Expression; import software.amazon.awssdk.enhanced.dynamodb.model.Page; import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; import software.amazon.awssdk.services.dynamodb.model.AttributeValue; -import com.mzc.secondproject.serverless.common.dto.PaginatedResult; -import com.mzc.secondproject.serverless.common.config.AwsClients; -import com.mzc.secondproject.serverless.common.util.CursorUtil; - import java.util.Map; import java.util.Optional; public class TestResultRepository { - - private static final Logger logger = LoggerFactory.getLogger(TestResultRepository.class); - private static final String TABLE_NAME = System.getenv("VOCAB_TABLE_NAME"); - - private final DynamoDbTable table; - - public TestResultRepository() { - this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(TestResult.class)); - } - - public TestResult save(TestResult testResult) { - logger.info("Saving test result: userId={}, testId={}", testResult.getUserId(), testResult.getTestId()); - table.putItem(testResult); - return testResult; - } - - public Optional findByUserIdAndTimestamp(String userId, String timestamp) { - Key key = Key.builder() - .partitionValue("TEST#" + userId) - .sortValue("RESULT#" + timestamp) - .build(); - - TestResult testResult = table.getItem(key); - return Optional.ofNullable(testResult); - } - - public Optional findByUserIdAndTestId(String userId, String testId) { - QueryConditional queryConditional = QueryConditional - .sortBeginsWith(Key.builder() - .partitionValue("TEST#" + userId) - .sortValue("RESULT#") - .build()); - - Expression filterExpression = Expression.builder() - .expression("testId = :testId") - .putExpressionValue(":testId", AttributeValue.builder().s(testId).build()) - .build(); - - QueryEnhancedRequest request = QueryEnhancedRequest.builder() - .queryConditional(queryConditional) - .filterExpression(filterExpression) - .limit(1) - .build(); - - for (Page page : table.query(request)) { - if (!page.items().isEmpty()) { - return Optional.of(page.items().get(0)); - } - } - - return Optional.empty(); - } - - /** - * 사용자의 시험 결과 조회 - 최신순, 페이지네이션 - */ - public PaginatedResult findByUserIdWithPagination(String userId, int limit, String cursor) { - QueryConditional queryConditional = QueryConditional - .sortBeginsWith(Key.builder() - .partitionValue("TEST#" + userId) - .sortValue("RESULT#") - .build()); - - QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() - .queryConditional(queryConditional) - .scanIndexForward(false) // 최신순 - .limit(limit); - - if (cursor != null && !cursor.isEmpty()) { - Map exclusiveStartKey = CursorUtil.decode(cursor); - if (exclusiveStartKey != null) { - requestBuilder.exclusiveStartKey(exclusiveStartKey); - } - } - - Page page = table.query(requestBuilder.build()).iterator().next(); - String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); - - return new PaginatedResult<>(page.items(), nextCursor); - } + + private static final Logger logger = LoggerFactory.getLogger(TestResultRepository.class); + private static final String TABLE_NAME = System.getenv("VOCAB_TABLE_NAME"); + + private final DynamoDbTable table; + + public TestResultRepository() { + this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(TestResult.class)); + } + + public TestResult save(TestResult testResult) { + logger.info("Saving test result: userId={}, testId={}", testResult.getUserId(), testResult.getTestId()); + table.putItem(testResult); + return testResult; + } + + public Optional findByUserIdAndTimestamp(String userId, String timestamp) { + Key key = Key.builder() + .partitionValue("TEST#" + userId) + .sortValue("RESULT#" + timestamp) + .build(); + + TestResult testResult = table.getItem(key); + return Optional.ofNullable(testResult); + } + + public Optional findByUserIdAndTestId(String userId, String testId) { + QueryConditional queryConditional = QueryConditional + .sortBeginsWith(Key.builder() + .partitionValue("TEST#" + userId) + .sortValue("RESULT#") + .build()); + + Expression filterExpression = Expression.builder() + .expression("testId = :testId") + .putExpressionValue(":testId", AttributeValue.builder().s(testId).build()) + .build(); + + QueryEnhancedRequest request = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .filterExpression(filterExpression) + .limit(1) + .build(); + + for (Page page : table.query(request)) { + if (!page.items().isEmpty()) { + return Optional.of(page.items().get(0)); + } + } + + return Optional.empty(); + } + + /** + * 사용자의 시험 결과 조회 - 최신순, 페이지네이션 + */ + public PaginatedResult findByUserIdWithPagination(String userId, int limit, String cursor) { + QueryConditional queryConditional = QueryConditional + .sortBeginsWith(Key.builder() + .partitionValue("TEST#" + userId) + .sortValue("RESULT#") + .build()); + + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .scanIndexForward(false) // 최신순 + .limit(limit); + + if (cursor != null && !cursor.isEmpty()) { + Map exclusiveStartKey = CursorUtil.decode(cursor); + if (exclusiveStartKey != null) { + requestBuilder.exclusiveStartKey(exclusiveStartKey); + } + } + + Page page = table.query(requestBuilder.build()).iterator().next(); + String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); + + return new PaginatedResult<>(page.items(), nextCursor); + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/UserWordRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/UserWordRepository.java index d2daf48a..b221462f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/UserWordRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/UserWordRepository.java @@ -1,200 +1,195 @@ package com.mzc.secondproject.serverless.domain.vocabulary.repository; +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.common.util.CursorUtil; import com.mzc.secondproject.serverless.domain.vocabulary.model.UserWord; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import software.amazon.awssdk.enhanced.dynamodb.DynamoDbIndex; -import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; -import software.amazon.awssdk.enhanced.dynamodb.Key; -import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.*; import software.amazon.awssdk.enhanced.dynamodb.model.Page; import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; -import software.amazon.awssdk.enhanced.dynamodb.Expression; import software.amazon.awssdk.services.dynamodb.model.AttributeValue; -import com.mzc.secondproject.serverless.common.dto.PaginatedResult; -import com.mzc.secondproject.serverless.common.config.AwsClients; -import com.mzc.secondproject.serverless.common.util.CursorUtil; - import java.util.Map; import java.util.Optional; public class UserWordRepository { - - private static final Logger logger = LoggerFactory.getLogger(UserWordRepository.class); - private static final String TABLE_NAME = System.getenv("VOCAB_TABLE_NAME"); - - private final DynamoDbTable table; - - public UserWordRepository() { - this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(UserWord.class)); - } - - public UserWord save(UserWord userWord) { - logger.info("Saving user word: userId={}, wordId={}", userWord.getUserId(), userWord.getWordId()); - table.putItem(userWord); - return userWord; - } - - public Optional findByUserIdAndWordId(String userId, String wordId) { - Key key = Key.builder() - .partitionValue("USER#" + userId) - .sortValue("WORD#" + wordId) - .build(); - - UserWord userWord = table.getItem(key); - return Optional.ofNullable(userWord); - } - - /** - * 사용자의 모든 단어 학습 상태 조회 - 페이지네이션 - */ - public PaginatedResult findByUserIdWithPagination(String userId, int limit, String cursor) { - QueryConditional queryConditional = QueryConditional - .sortBeginsWith(Key.builder() - .partitionValue("USER#" + userId) - .sortValue("WORD#") - .build()); - - QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() - .queryConditional(queryConditional) - .limit(limit); - - if (cursor != null && !cursor.isEmpty()) { - Map exclusiveStartKey = CursorUtil.decode(cursor); - if (exclusiveStartKey != null) { - requestBuilder.exclusiveStartKey(exclusiveStartKey); - } - } - - Page page = table.query(requestBuilder.build()).iterator().next(); - String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); - - return new PaginatedResult<>(page.items(), nextCursor); - } - - /** - * 복습 예정 단어 조회 (오늘 이전 날짜) - 페이지네이션 - */ - public PaginatedResult findReviewDueWords(String userId, String todayDate, int limit, String cursor) { - QueryConditional queryConditional = QueryConditional - .sortLessThanOrEqualTo(Key.builder() - .partitionValue("USER#" + userId + "#REVIEW") - .sortValue("DATE#" + todayDate) - .build()); - - QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() - .queryConditional(queryConditional) - .limit(limit); - - if (cursor != null && !cursor.isEmpty()) { - Map exclusiveStartKey = CursorUtil.decode(cursor); - if (exclusiveStartKey != null) { - requestBuilder.exclusiveStartKey(exclusiveStartKey); - } - } - - DynamoDbIndex gsi1 = table.index("GSI1"); - Page page = gsi1.query(requestBuilder.build()).iterator().next(); - String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); - - return new PaginatedResult<>(page.items(), nextCursor); - } - - /** - * 북마크된 단어만 조회 - GSI3 Sparse Index 활용 - */ - public PaginatedResult findBookmarkedWords(String userId, int limit, String cursor) { - QueryConditional queryConditional = QueryConditional - .sortBeginsWith(Key.builder() - .partitionValue("USER#" + userId + "#BOOKMARKED") - .sortValue("WORD#") - .build()); - - QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() - .queryConditional(queryConditional) - .limit(limit); - - if (cursor != null && !cursor.isEmpty()) { - Map exclusiveStartKey = CursorUtil.decode(cursor); - if (exclusiveStartKey != null) { - requestBuilder.exclusiveStartKey(exclusiveStartKey); - } - } - - DynamoDbIndex gsi3 = table.index("GSI3"); - Page page = gsi3.query(requestBuilder.build()).iterator().next(); - String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); - - return new PaginatedResult<>(page.items(), nextCursor); - } - - /** - * 틀린 적 있는 단어만 조회 - FilterExpression 사용 (GSI 추가 없이 비용 최적화) - */ - public PaginatedResult findIncorrectWords(String userId, int limit, String cursor) { - return findIncorrectWords(userId, 1, limit, cursor); - } - - /** - * 최소 N회 이상 틀린 단어 조회 - */ - public PaginatedResult findIncorrectWords(String userId, int minCount, int limit, String cursor) { - QueryConditional queryConditional = QueryConditional - .sortBeginsWith(Key.builder() - .partitionValue("USER#" + userId) - .sortValue("WORD#") - .build()); - - Expression filterExpression = Expression.builder() - .expression("incorrectCount >= :minCount") - .putExpressionValue(":minCount", AttributeValue.builder().n(String.valueOf(minCount)).build()) - .build(); - - QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() - .queryConditional(queryConditional) - .filterExpression(filterExpression) - .limit(limit * 3); // FilterExpression 적용되므로 넉넉히 - - if (cursor != null && !cursor.isEmpty()) { - Map exclusiveStartKey = CursorUtil.decode(cursor); - if (exclusiveStartKey != null) { - requestBuilder.exclusiveStartKey(exclusiveStartKey); - } - } - - Page page = table.query(requestBuilder.build()).iterator().next(); - String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); - - return new PaginatedResult<>(page.items(), nextCursor); - } - - /** - * 상태별 단어 조회 - 페이지네이션 - */ - public PaginatedResult findByUserIdAndStatus(String userId, String status, int limit, String cursor) { - QueryConditional queryConditional = QueryConditional - .keyEqualTo(Key.builder() - .partitionValue("USER#" + userId + "#STATUS") - .sortValue("STATUS#" + status) - .build()); - - QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() - .queryConditional(queryConditional) - .limit(limit); - - if (cursor != null && !cursor.isEmpty()) { - Map exclusiveStartKey = CursorUtil.decode(cursor); - if (exclusiveStartKey != null) { - requestBuilder.exclusiveStartKey(exclusiveStartKey); - } - } - - DynamoDbIndex gsi2 = table.index("GSI2"); - Page page = gsi2.query(requestBuilder.build()).iterator().next(); - String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); - - return new PaginatedResult<>(page.items(), nextCursor); - } + + private static final Logger logger = LoggerFactory.getLogger(UserWordRepository.class); + private static final String TABLE_NAME = System.getenv("VOCAB_TABLE_NAME"); + + private final DynamoDbTable table; + + public UserWordRepository() { + this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(UserWord.class)); + } + + public UserWord save(UserWord userWord) { + logger.info("Saving user word: userId={}, wordId={}", userWord.getUserId(), userWord.getWordId()); + table.putItem(userWord); + return userWord; + } + + public Optional findByUserIdAndWordId(String userId, String wordId) { + Key key = Key.builder() + .partitionValue("USER#" + userId) + .sortValue("WORD#" + wordId) + .build(); + + UserWord userWord = table.getItem(key); + return Optional.ofNullable(userWord); + } + + /** + * 사용자의 모든 단어 학습 상태 조회 - 페이지네이션 + */ + public PaginatedResult findByUserIdWithPagination(String userId, int limit, String cursor) { + QueryConditional queryConditional = QueryConditional + .sortBeginsWith(Key.builder() + .partitionValue("USER#" + userId) + .sortValue("WORD#") + .build()); + + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .limit(limit); + + if (cursor != null && !cursor.isEmpty()) { + Map exclusiveStartKey = CursorUtil.decode(cursor); + if (exclusiveStartKey != null) { + requestBuilder.exclusiveStartKey(exclusiveStartKey); + } + } + + Page page = table.query(requestBuilder.build()).iterator().next(); + String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); + + return new PaginatedResult<>(page.items(), nextCursor); + } + + /** + * 복습 예정 단어 조회 (오늘 이전 날짜) - 페이지네이션 + */ + public PaginatedResult findReviewDueWords(String userId, String todayDate, int limit, String cursor) { + QueryConditional queryConditional = QueryConditional + .sortLessThanOrEqualTo(Key.builder() + .partitionValue("USER#" + userId + "#REVIEW") + .sortValue("DATE#" + todayDate) + .build()); + + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .limit(limit); + + if (cursor != null && !cursor.isEmpty()) { + Map exclusiveStartKey = CursorUtil.decode(cursor); + if (exclusiveStartKey != null) { + requestBuilder.exclusiveStartKey(exclusiveStartKey); + } + } + + DynamoDbIndex gsi1 = table.index("GSI1"); + Page page = gsi1.query(requestBuilder.build()).iterator().next(); + String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); + + return new PaginatedResult<>(page.items(), nextCursor); + } + + /** + * 북마크된 단어만 조회 - GSI3 Sparse Index 활용 + */ + public PaginatedResult findBookmarkedWords(String userId, int limit, String cursor) { + QueryConditional queryConditional = QueryConditional + .sortBeginsWith(Key.builder() + .partitionValue("USER#" + userId + "#BOOKMARKED") + .sortValue("WORD#") + .build()); + + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .limit(limit); + + if (cursor != null && !cursor.isEmpty()) { + Map exclusiveStartKey = CursorUtil.decode(cursor); + if (exclusiveStartKey != null) { + requestBuilder.exclusiveStartKey(exclusiveStartKey); + } + } + + DynamoDbIndex gsi3 = table.index("GSI3"); + Page page = gsi3.query(requestBuilder.build()).iterator().next(); + String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); + + return new PaginatedResult<>(page.items(), nextCursor); + } + + /** + * 틀린 적 있는 단어만 조회 - FilterExpression 사용 (GSI 추가 없이 비용 최적화) + */ + public PaginatedResult findIncorrectWords(String userId, int limit, String cursor) { + return findIncorrectWords(userId, 1, limit, cursor); + } + + /** + * 최소 N회 이상 틀린 단어 조회 + */ + public PaginatedResult findIncorrectWords(String userId, int minCount, int limit, String cursor) { + QueryConditional queryConditional = QueryConditional + .sortBeginsWith(Key.builder() + .partitionValue("USER#" + userId) + .sortValue("WORD#") + .build()); + + Expression filterExpression = Expression.builder() + .expression("incorrectCount >= :minCount") + .putExpressionValue(":minCount", AttributeValue.builder().n(String.valueOf(minCount)).build()) + .build(); + + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .filterExpression(filterExpression) + .limit(limit * 3); // FilterExpression 적용되므로 넉넉히 + + if (cursor != null && !cursor.isEmpty()) { + Map exclusiveStartKey = CursorUtil.decode(cursor); + if (exclusiveStartKey != null) { + requestBuilder.exclusiveStartKey(exclusiveStartKey); + } + } + + Page page = table.query(requestBuilder.build()).iterator().next(); + String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); + + return new PaginatedResult<>(page.items(), nextCursor); + } + + /** + * 상태별 단어 조회 - 페이지네이션 + */ + public PaginatedResult findByUserIdAndStatus(String userId, String status, int limit, String cursor) { + QueryConditional queryConditional = QueryConditional + .keyEqualTo(Key.builder() + .partitionValue("USER#" + userId + "#STATUS") + .sortValue("STATUS#" + status) + .build()); + + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .limit(limit); + + if (cursor != null && !cursor.isEmpty()) { + Map exclusiveStartKey = CursorUtil.decode(cursor); + if (exclusiveStartKey != null) { + requestBuilder.exclusiveStartKey(exclusiveStartKey); + } + } + + DynamoDbIndex gsi2 = table.index("GSI2"); + Page page = gsi2.query(requestBuilder.build()).iterator().next(); + String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); + + return new PaginatedResult<>(page.items(), nextCursor); + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordGroupRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordGroupRepository.java index 4c277647..92263ff2 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordGroupRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordGroupRepository.java @@ -18,63 +18,63 @@ import java.util.Optional; public class WordGroupRepository { - - private static final Logger logger = LoggerFactory.getLogger(WordGroupRepository.class); - private static final String TABLE_NAME = System.getenv("VOCAB_TABLE_NAME"); - - private final DynamoDbTable table; - - public WordGroupRepository() { - this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(WordGroup.class)); - } - - public WordGroup save(WordGroup wordGroup) { - logger.info("Saving word group: userId={}, groupId={}", wordGroup.getUserId(), wordGroup.getGroupId()); - table.putItem(wordGroup); - return wordGroup; - } - - public Optional findByUserIdAndGroupId(String userId, String groupId) { - Key key = Key.builder() - .partitionValue("USER#" + userId + "#GROUP") - .sortValue("GROUP#" + groupId) - .build(); - - WordGroup wordGroup = table.getItem(key); - return Optional.ofNullable(wordGroup); - } - - public void delete(String userId, String groupId) { - Key key = Key.builder() - .partitionValue("USER#" + userId + "#GROUP") - .sortValue("GROUP#" + groupId) - .build(); - - table.deleteItem(key); - logger.info("Deleted word group: userId={}, groupId={}", userId, groupId); - } - - public PaginatedResult findByUserId(String userId, int limit, String cursor) { - QueryConditional queryConditional = QueryConditional - .sortBeginsWith(Key.builder() - .partitionValue("USER#" + userId + "#GROUP") - .sortValue("GROUP#") - .build()); - - QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() - .queryConditional(queryConditional) - .limit(limit); - - if (cursor != null && !cursor.isEmpty()) { - Map exclusiveStartKey = CursorUtil.decode(cursor); - if (exclusiveStartKey != null) { - requestBuilder.exclusiveStartKey(exclusiveStartKey); - } - } - - Page page = table.query(requestBuilder.build()).iterator().next(); - String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); - - return new PaginatedResult<>(page.items(), nextCursor); - } + + private static final Logger logger = LoggerFactory.getLogger(WordGroupRepository.class); + private static final String TABLE_NAME = System.getenv("VOCAB_TABLE_NAME"); + + private final DynamoDbTable table; + + public WordGroupRepository() { + this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(WordGroup.class)); + } + + public WordGroup save(WordGroup wordGroup) { + logger.info("Saving word group: userId={}, groupId={}", wordGroup.getUserId(), wordGroup.getGroupId()); + table.putItem(wordGroup); + return wordGroup; + } + + public Optional findByUserIdAndGroupId(String userId, String groupId) { + Key key = Key.builder() + .partitionValue("USER#" + userId + "#GROUP") + .sortValue("GROUP#" + groupId) + .build(); + + WordGroup wordGroup = table.getItem(key); + return Optional.ofNullable(wordGroup); + } + + public void delete(String userId, String groupId) { + Key key = Key.builder() + .partitionValue("USER#" + userId + "#GROUP") + .sortValue("GROUP#" + groupId) + .build(); + + table.deleteItem(key); + logger.info("Deleted word group: userId={}, groupId={}", userId, groupId); + } + + public PaginatedResult findByUserId(String userId, int limit, String cursor) { + QueryConditional queryConditional = QueryConditional + .sortBeginsWith(Key.builder() + .partitionValue("USER#" + userId + "#GROUP") + .sortValue("GROUP#") + .build()); + + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .limit(limit); + + if (cursor != null && !cursor.isEmpty()) { + Map exclusiveStartKey = CursorUtil.decode(cursor); + if (exclusiveStartKey != null) { + requestBuilder.exclusiveStartKey(exclusiveStartKey); + } + } + + Page page = table.query(requestBuilder.build()).iterator().next(); + String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); + + return new PaginatedResult<>(page.items(), nextCursor); + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordRepository.java index 53037970..d6ba6491 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordRepository.java @@ -1,205 +1,194 @@ package com.mzc.secondproject.serverless.domain.vocabulary.repository; +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.common.util.CursorUtil; import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; -import software.amazon.awssdk.enhanced.dynamodb.DynamoDbIndex; -import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; -import software.amazon.awssdk.enhanced.dynamodb.Key; -import software.amazon.awssdk.enhanced.dynamodb.TableSchema; -import software.amazon.awssdk.enhanced.dynamodb.model.BatchGetResultPageIterable; -import software.amazon.awssdk.enhanced.dynamodb.model.Page; -import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; -import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; -import software.amazon.awssdk.enhanced.dynamodb.model.ReadBatch; -import software.amazon.awssdk.enhanced.dynamodb.model.ScanEnhancedRequest; -import software.amazon.awssdk.enhanced.dynamodb.Expression; +import software.amazon.awssdk.enhanced.dynamodb.*; +import software.amazon.awssdk.enhanced.dynamodb.model.*; import software.amazon.awssdk.services.dynamodb.model.AttributeValue; -import com.mzc.secondproject.serverless.common.dto.PaginatedResult; -import com.mzc.secondproject.serverless.common.config.AwsClients; -import com.mzc.secondproject.serverless.common.util.CursorUtil; - import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Optional; public class WordRepository { - - private static final Logger logger = LoggerFactory.getLogger(WordRepository.class); - private static final String TABLE_NAME = System.getenv("VOCAB_TABLE_NAME"); - - private final DynamoDbEnhancedClient enhancedClient; - private final DynamoDbTable table; - - public WordRepository() { - this.enhancedClient = AwsClients.dynamoDbEnhanced(); - this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(Word.class)); - } - - public Word save(Word word) { - logger.info("Saving word to DynamoDB: {}", word.getWordId()); - table.putItem(word); - return word; - } - - public Optional findById(String wordId) { - Key key = Key.builder() - .partitionValue("WORD#" + wordId) - .sortValue("METADATA") - .build(); - - Word word = table.getItem(key); - return Optional.ofNullable(word); - } - - /** - * 여러 단어를 한 번에 조회 (BatchGetItem) - N+1 문제 해결 - * DynamoDB BatchGetItem은 최대 100개까지 지원 - */ - public List findByIds(List wordIds) { - if (wordIds == null || wordIds.isEmpty()) { - return new ArrayList<>(); - } - - List results = new ArrayList<>(); - - // BatchGetItem은 최대 100개까지 지원하므로 분할 처리 - int batchSize = 100; - for (int i = 0; i < wordIds.size(); i += batchSize) { - List batch = wordIds.subList(i, Math.min(i + batchSize, wordIds.size())); - results.addAll(batchGetWords(batch)); - } - - return results; - } - - private List batchGetWords(List wordIds) { - ReadBatch.Builder readBatchBuilder = ReadBatch.builder(Word.class) - .mappedTableResource(table); - - for (String wordId : wordIds) { - Key key = Key.builder() - .partitionValue("WORD#" + wordId) - .sortValue("METADATA") - .build(); - readBatchBuilder.addGetItem(key); - } - - BatchGetResultPageIterable resultPages = enhancedClient.batchGetItem(r -> r.readBatches(readBatchBuilder.build())); - - List words = new ArrayList<>(); - resultPages.resultsForTable(table).forEach(words::add); - logger.info("BatchGetItem: requested={}, retrieved={}", wordIds.size(), words.size()); - - return words; - } - - public void delete(String wordId) { - Key key = Key.builder() - .partitionValue("WORD#" + wordId) - .sortValue("METADATA") - .build(); - - table.deleteItem(key); - logger.info("Deleted word: {}", wordId); - } - - /** - * 난이도별 단어 조회 - 페이지네이션 - */ - public PaginatedResult findByLevelWithPagination(String level, int limit, String cursor) { - QueryConditional queryConditional = QueryConditional - .keyEqualTo(Key.builder().partitionValue("LEVEL#" + level).build()); - - QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() - .queryConditional(queryConditional) - .limit(limit); - - if (cursor != null && !cursor.isEmpty()) { - Map exclusiveStartKey = CursorUtil.decode(cursor); - if (exclusiveStartKey != null) { - requestBuilder.exclusiveStartKey(exclusiveStartKey); - } - } - - DynamoDbIndex gsi1 = table.index("GSI1"); - Page page = gsi1.query(requestBuilder.build()).iterator().next(); - String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); - - return new PaginatedResult<>(page.items(), nextCursor); - } - - /** - * 카테고리별 단어 조회 - 페이지네이션 - */ - public PaginatedResult findByCategoryWithPagination(String category, int limit, String cursor) { - QueryConditional queryConditional = QueryConditional - .keyEqualTo(Key.builder().partitionValue("CATEGORY#" + category).build()); - - QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() - .queryConditional(queryConditional) - .limit(limit); - - if (cursor != null && !cursor.isEmpty()) { - Map exclusiveStartKey = CursorUtil.decode(cursor); - if (exclusiveStartKey != null) { - requestBuilder.exclusiveStartKey(exclusiveStartKey); - } - } - - DynamoDbIndex gsi2 = table.index("GSI2"); - Page page = gsi2.query(requestBuilder.build()).iterator().next(); - String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); - - return new PaginatedResult<>(page.items(), nextCursor); - } - - /** - * 키워드로 단어 검색 (영어/한국어 contains) - * 참고: Scan은 비용이 높으므로 데이터가 많아지면 OpenSearch 도입 권장 - */ - public PaginatedResult searchByKeyword(String keyword, int limit, String cursor) { - String lowerKeyword = keyword.toLowerCase(); - - // Filter: PK가 WORD#로 시작하고, english 또는 korean에 keyword 포함 - Expression filterExpression = Expression.builder() - .expression("begins_with(PK, :pk) AND (contains(#eng, :keyword) OR contains(korean, :keyword))") - .putExpressionName("#eng", "english") - .putExpressionValue(":pk", AttributeValue.builder().s("WORD#").build()) - .putExpressionValue(":keyword", AttributeValue.builder().s(lowerKeyword).build()) - .build(); - - ScanEnhancedRequest.Builder requestBuilder = ScanEnhancedRequest.builder() - .filterExpression(filterExpression) - .limit(limit * 3); // filter 적용되므로 넉넉히 - - if (cursor != null && !cursor.isEmpty()) { - Map exclusiveStartKey = CursorUtil.decode(cursor); - if (exclusiveStartKey != null) { - requestBuilder.exclusiveStartKey(exclusiveStartKey); - } - } - - List results = new ArrayList<>(); - Map lastKey = null; - - for (Page page : table.scan(requestBuilder.build())) { - for (Word word : page.items()) { - // 대소문자 무시 검색 - if (word.getEnglish().toLowerCase().contains(lowerKeyword) || - word.getKorean().contains(keyword)) { - results.add(word); - if (results.size() >= limit) break; - } - } - lastKey = page.lastEvaluatedKey(); - if (results.size() >= limit) break; - } - - String nextCursor = results.size() >= limit ? CursorUtil.encode(lastKey) : null; - return new PaginatedResult<>(results, nextCursor); - } + + private static final Logger logger = LoggerFactory.getLogger(WordRepository.class); + private static final String TABLE_NAME = System.getenv("VOCAB_TABLE_NAME"); + + private final DynamoDbEnhancedClient enhancedClient; + private final DynamoDbTable table; + + public WordRepository() { + this.enhancedClient = AwsClients.dynamoDbEnhanced(); + this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(Word.class)); + } + + public Word save(Word word) { + logger.info("Saving word to DynamoDB: {}", word.getWordId()); + table.putItem(word); + return word; + } + + public Optional findById(String wordId) { + Key key = Key.builder() + .partitionValue("WORD#" + wordId) + .sortValue("METADATA") + .build(); + + Word word = table.getItem(key); + return Optional.ofNullable(word); + } + + /** + * 여러 단어를 한 번에 조회 (BatchGetItem) - N+1 문제 해결 + * DynamoDB BatchGetItem은 최대 100개까지 지원 + */ + public List findByIds(List wordIds) { + if (wordIds == null || wordIds.isEmpty()) { + return new ArrayList<>(); + } + + List results = new ArrayList<>(); + + // BatchGetItem은 최대 100개까지 지원하므로 분할 처리 + int batchSize = 100; + for (int i = 0; i < wordIds.size(); i += batchSize) { + List batch = wordIds.subList(i, Math.min(i + batchSize, wordIds.size())); + results.addAll(batchGetWords(batch)); + } + + return results; + } + + private List batchGetWords(List wordIds) { + ReadBatch.Builder readBatchBuilder = ReadBatch.builder(Word.class) + .mappedTableResource(table); + + for (String wordId : wordIds) { + Key key = Key.builder() + .partitionValue("WORD#" + wordId) + .sortValue("METADATA") + .build(); + readBatchBuilder.addGetItem(key); + } + + BatchGetResultPageIterable resultPages = enhancedClient.batchGetItem(r -> r.readBatches(readBatchBuilder.build())); + + List words = new ArrayList<>(); + resultPages.resultsForTable(table).forEach(words::add); + logger.info("BatchGetItem: requested={}, retrieved={}", wordIds.size(), words.size()); + + return words; + } + + public void delete(String wordId) { + Key key = Key.builder() + .partitionValue("WORD#" + wordId) + .sortValue("METADATA") + .build(); + + table.deleteItem(key); + logger.info("Deleted word: {}", wordId); + } + + /** + * 난이도별 단어 조회 - 페이지네이션 + */ + public PaginatedResult findByLevelWithPagination(String level, int limit, String cursor) { + QueryConditional queryConditional = QueryConditional + .keyEqualTo(Key.builder().partitionValue("LEVEL#" + level).build()); + + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .limit(limit); + + if (cursor != null && !cursor.isEmpty()) { + Map exclusiveStartKey = CursorUtil.decode(cursor); + if (exclusiveStartKey != null) { + requestBuilder.exclusiveStartKey(exclusiveStartKey); + } + } + + DynamoDbIndex gsi1 = table.index("GSI1"); + Page page = gsi1.query(requestBuilder.build()).iterator().next(); + String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); + + return new PaginatedResult<>(page.items(), nextCursor); + } + + /** + * 카테고리별 단어 조회 - 페이지네이션 + */ + public PaginatedResult findByCategoryWithPagination(String category, int limit, String cursor) { + QueryConditional queryConditional = QueryConditional + .keyEqualTo(Key.builder().partitionValue("CATEGORY#" + category).build()); + + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .limit(limit); + + if (cursor != null && !cursor.isEmpty()) { + Map exclusiveStartKey = CursorUtil.decode(cursor); + if (exclusiveStartKey != null) { + requestBuilder.exclusiveStartKey(exclusiveStartKey); + } + } + + DynamoDbIndex gsi2 = table.index("GSI2"); + Page page = gsi2.query(requestBuilder.build()).iterator().next(); + String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); + + return new PaginatedResult<>(page.items(), nextCursor); + } + + /** + * 키워드로 단어 검색 (영어/한국어 contains) + * 참고: Scan은 비용이 높으므로 데이터가 많아지면 OpenSearch 도입 권장 + */ + public PaginatedResult searchByKeyword(String keyword, int limit, String cursor) { + String lowerKeyword = keyword.toLowerCase(); + + // Filter: PK가 WORD#로 시작하고, english 또는 korean에 keyword 포함 + Expression filterExpression = Expression.builder() + .expression("begins_with(PK, :pk) AND (contains(#eng, :keyword) OR contains(korean, :keyword))") + .putExpressionName("#eng", "english") + .putExpressionValue(":pk", AttributeValue.builder().s("WORD#").build()) + .putExpressionValue(":keyword", AttributeValue.builder().s(lowerKeyword).build()) + .build(); + + ScanEnhancedRequest.Builder requestBuilder = ScanEnhancedRequest.builder() + .filterExpression(filterExpression) + .limit(limit * 3); // filter 적용되므로 넉넉히 + + if (cursor != null && !cursor.isEmpty()) { + Map exclusiveStartKey = CursorUtil.decode(cursor); + if (exclusiveStartKey != null) { + requestBuilder.exclusiveStartKey(exclusiveStartKey); + } + } + + List results = new ArrayList<>(); + Map lastKey = null; + + for (Page page : table.scan(requestBuilder.build())) { + for (Word word : page.items()) { + // 대소문자 무시 검색 + if (word.getEnglish().toLowerCase().contains(lowerKeyword) || + word.getKorean().contains(keyword)) { + results.add(word); + if (results.size() >= limit) break; + } + } + lastKey = page.lastEvaluatedKey(); + if (results.size() >= limit) break; + } + + String nextCursor = results.size() >= limit ? CursorUtil.encode(lastKey) : null; + return new PaginatedResult<>(results, nextCursor); + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyCommandService.java index 4f25cfa8..5e0b1967 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyCommandService.java @@ -2,213 +2,207 @@ import com.mzc.secondproject.serverless.common.dto.PaginatedResult; import com.mzc.secondproject.serverless.common.enums.StudyLevel; +import com.mzc.secondproject.serverless.domain.badge.service.BadgeService; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; +import com.mzc.secondproject.serverless.domain.stats.repository.UserStatsRepository; import com.mzc.secondproject.serverless.domain.vocabulary.constants.VocabKey; +import com.mzc.secondproject.serverless.domain.vocabulary.exception.VocabularyException; import com.mzc.secondproject.serverless.domain.vocabulary.model.DailyStudy; import com.mzc.secondproject.serverless.domain.vocabulary.model.UserWord; import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; import com.mzc.secondproject.serverless.domain.vocabulary.repository.DailyStudyRepository; import com.mzc.secondproject.serverless.domain.vocabulary.repository.UserWordRepository; import com.mzc.secondproject.serverless.domain.vocabulary.repository.WordRepository; -import com.mzc.secondproject.serverless.domain.stats.model.UserStats; -import com.mzc.secondproject.serverless.domain.stats.repository.UserStatsRepository; -import com.mzc.secondproject.serverless.domain.badge.service.BadgeService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.mzc.secondproject.serverless.domain.vocabulary.exception.VocabularyException; - import java.time.Instant; import java.time.LocalDate; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.HashSet; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; +import java.util.*; import java.util.stream.Collectors; /** * DailyStudy 변경 전용 서비스 (CQRS Command) */ public class DailyStudyCommandService { - - private static final Logger logger = LoggerFactory.getLogger(DailyStudyCommandService.class); - - private static final int NEW_WORDS_COUNT = 50; - private static final int REVIEW_WORDS_COUNT = 5; - - private final DailyStudyRepository dailyStudyRepository; - private final UserWordRepository userWordRepository; - private final WordRepository wordRepository; - private final UserStatsRepository userStatsRepository; - private final BadgeService badgeService; - - public DailyStudyCommandService() { - this.dailyStudyRepository = new DailyStudyRepository(); - this.userWordRepository = new UserWordRepository(); - this.wordRepository = new WordRepository(); - this.userStatsRepository = new UserStatsRepository(); - this.badgeService = new BadgeService(); - } - - public DailyStudyResult getDailyWords(String userId, String level) { - String today = LocalDate.now().toString(); - - Optional optDailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today); - - DailyStudy dailyStudy; - if (optDailyStudy.isPresent()) { - dailyStudy = optDailyStudy.get(); - } else { - if (level == null || level.isEmpty()) { - throw VocabularyException.invalidStudyLevel("level is required (BEGINNER, INTERMEDIATE, ADVANCED)"); - } - if (!StudyLevel.isValid(level)) { - throw VocabularyException.invalidStudyLevel(level); - } - dailyStudy = createDailyStudy(userId, today, level); - } - - List newWords = getWordDetails(dailyStudy.getNewWordIds()); - List reviewWords = getWordDetails(dailyStudy.getReviewWordIds()); - Map progress = calculateProgress(dailyStudy); - - return new DailyStudyResult(dailyStudy, newWords, reviewWords, progress); - } - - public Map markWordLearned(String userId, String wordId) { - String today = LocalDate.now().toString(); - - Optional optDailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today); - if (optDailyStudy.isEmpty()) { - throw VocabularyException.dailyStudyNotFound(userId, today); - } - - DailyStudy dailyStudy = optDailyStudy.get(); - - // 이미 학습한 단어면 스킵 - if (dailyStudy.getLearnedWordIds() != null && dailyStudy.getLearnedWordIds().contains(wordId)) { - return calculateProgress(dailyStudy); - } - - // 새 단어인지 복습 단어인지 확인 - boolean isNewWord = dailyStudy.getNewWordIds() != null && dailyStudy.getNewWordIds().contains(wordId); - boolean isReviewWord = dailyStudy.getReviewWordIds() != null && dailyStudy.getReviewWordIds().contains(wordId); - - dailyStudyRepository.addLearnedWord(userId, today, wordId); - - // Write-through: 통계 즉시 업데이트 - if (isNewWord) { - userStatsRepository.incrementWordsLearned(userId, 1, 0); - } else if (isReviewWord) { - userStatsRepository.incrementWordsLearned(userId, 0, 1); - } - - // 단어 학습량 뱃지 체크 - checkWordsBadge(userId); - - DailyStudy updatedDailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today).orElse(dailyStudy); - - if (updatedDailyStudy.getLearnedCount() >= updatedDailyStudy.getTotalWords()) { - updatedDailyStudy.setIsCompleted(true); - dailyStudyRepository.save(updatedDailyStudy); - } - - logger.info("Marked word as learned: userId={}, wordId={}, isNew={}, isReview={}", - userId, wordId, isNewWord, isReviewWord); - return calculateProgress(updatedDailyStudy); - } - - private DailyStudy createDailyStudy(String userId, String date, String level) { - String now = Instant.now().toString(); - - PaginatedResult reviewPage = userWordRepository.findReviewDueWords(userId, date, REVIEW_WORDS_COUNT, null); - List reviewWordIds = reviewPage.items().stream() - .map(UserWord::getWordId) - .collect(Collectors.toList()); - - List newWordIds = getNewWordsForUser(userId, level, NEW_WORDS_COUNT); - - DailyStudy dailyStudy = DailyStudy.builder() - .pk(VocabKey.dailyPk(userId)) - .sk(VocabKey.dateSk(date)) - .gsi1pk(VocabKey.DAILY_ALL) - .gsi1sk(VocabKey.dateSk(date)) - .userId(userId) - .date(date) - .newWordIds(newWordIds) - .reviewWordIds(reviewWordIds) - .learnedWordIds(new ArrayList<>()) - .totalWords(newWordIds.size() + reviewWordIds.size()) - .learnedCount(0) - .isCompleted(false) - .createdAt(now) - .updatedAt(now) - .build(); - - dailyStudyRepository.save(dailyStudy); - logger.info("Created daily study for user: {}, date: {}", userId, date); - - return dailyStudy; - } - - private List getNewWordsForUser(String userId, String level, int count) { - PaginatedResult userWordPage = userWordRepository.findByUserIdWithPagination(userId, 1000, null); - Set learnedWordIds = userWordPage.items().stream() - .map(UserWord::getWordId) - .collect(Collectors.toSet()); - - Set newWordIds = new LinkedHashSet<>(); - String lastEvaluatedKey = null; - - do { - PaginatedResult wordPage = wordRepository.findByLevelWithPagination(level, count * 2, lastEvaluatedKey); - for (Word word : wordPage.items()) { - if (!learnedWordIds.contains(word.getWordId()) && !newWordIds.contains(word.getWordId())) { - newWordIds.add(word.getWordId()); - if (newWordIds.size() >= count) break; - } - } - lastEvaluatedKey = wordPage.nextCursor(); - } while (newWordIds.size() < count && lastEvaluatedKey != null); - - logger.info("Selected {} new words for user {} at level {}", newWordIds.size(), userId, level); - return new ArrayList<>(newWordIds); - } - - private List getWordDetails(List wordIds) { - if (wordIds == null || wordIds.isEmpty()) { - return new ArrayList<>(); - } - return wordRepository.findByIds(wordIds); - } - - private Map calculateProgress(DailyStudy dailyStudy) { - Map progress = new HashMap<>(); - int total = dailyStudy.getTotalWords(); - int learned = dailyStudy.getLearnedCount(); - - progress.put("total", total); - progress.put("learned", learned); - progress.put("remaining", total - learned); - progress.put("percentage", total > 0 ? (learned * 100.0 / total) : 0); - progress.put("isCompleted", dailyStudy.getIsCompleted()); - - return progress; - } - - private void checkWordsBadge(String userId) { - try { - Optional totalStats = userStatsRepository.findTotalStats(userId); - if (totalStats.isPresent()) { - badgeService.checkAndAwardBadges(userId, totalStats.get()); - } - } catch (Exception e) { - logger.warn("Failed to check badges for user: {}", userId, e); - } - } - - public record DailyStudyResult(DailyStudy dailyStudy, List newWords, List reviewWords, Map progress) {} + + private static final Logger logger = LoggerFactory.getLogger(DailyStudyCommandService.class); + + private static final int NEW_WORDS_COUNT = 50; + private static final int REVIEW_WORDS_COUNT = 5; + + private final DailyStudyRepository dailyStudyRepository; + private final UserWordRepository userWordRepository; + private final WordRepository wordRepository; + private final UserStatsRepository userStatsRepository; + private final BadgeService badgeService; + + public DailyStudyCommandService() { + this.dailyStudyRepository = new DailyStudyRepository(); + this.userWordRepository = new UserWordRepository(); + this.wordRepository = new WordRepository(); + this.userStatsRepository = new UserStatsRepository(); + this.badgeService = new BadgeService(); + } + + public DailyStudyResult getDailyWords(String userId, String level) { + String today = LocalDate.now().toString(); + + Optional optDailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today); + + DailyStudy dailyStudy; + if (optDailyStudy.isPresent()) { + dailyStudy = optDailyStudy.get(); + } else { + if (level == null || level.isEmpty()) { + throw VocabularyException.invalidStudyLevel("level is required (BEGINNER, INTERMEDIATE, ADVANCED)"); + } + if (!StudyLevel.isValid(level)) { + throw VocabularyException.invalidStudyLevel(level); + } + dailyStudy = createDailyStudy(userId, today, level); + } + + List newWords = getWordDetails(dailyStudy.getNewWordIds()); + List reviewWords = getWordDetails(dailyStudy.getReviewWordIds()); + Map progress = calculateProgress(dailyStudy); + + return new DailyStudyResult(dailyStudy, newWords, reviewWords, progress); + } + + public Map markWordLearned(String userId, String wordId) { + String today = LocalDate.now().toString(); + + Optional optDailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today); + if (optDailyStudy.isEmpty()) { + throw VocabularyException.dailyStudyNotFound(userId, today); + } + + DailyStudy dailyStudy = optDailyStudy.get(); + + // 이미 학습한 단어면 스킵 + if (dailyStudy.getLearnedWordIds() != null && dailyStudy.getLearnedWordIds().contains(wordId)) { + return calculateProgress(dailyStudy); + } + + // 새 단어인지 복습 단어인지 확인 + boolean isNewWord = dailyStudy.getNewWordIds() != null && dailyStudy.getNewWordIds().contains(wordId); + boolean isReviewWord = dailyStudy.getReviewWordIds() != null && dailyStudy.getReviewWordIds().contains(wordId); + + dailyStudyRepository.addLearnedWord(userId, today, wordId); + + // Write-through: 통계 즉시 업데이트 + if (isNewWord) { + userStatsRepository.incrementWordsLearned(userId, 1, 0); + } else if (isReviewWord) { + userStatsRepository.incrementWordsLearned(userId, 0, 1); + } + + // 단어 학습량 뱃지 체크 + checkWordsBadge(userId); + + DailyStudy updatedDailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today).orElse(dailyStudy); + + if (updatedDailyStudy.getLearnedCount() >= updatedDailyStudy.getTotalWords()) { + updatedDailyStudy.setIsCompleted(true); + dailyStudyRepository.save(updatedDailyStudy); + } + + logger.info("Marked word as learned: userId={}, wordId={}, isNew={}, isReview={}", + userId, wordId, isNewWord, isReviewWord); + return calculateProgress(updatedDailyStudy); + } + + private DailyStudy createDailyStudy(String userId, String date, String level) { + String now = Instant.now().toString(); + + PaginatedResult reviewPage = userWordRepository.findReviewDueWords(userId, date, REVIEW_WORDS_COUNT, null); + List reviewWordIds = reviewPage.items().stream() + .map(UserWord::getWordId) + .collect(Collectors.toList()); + + List newWordIds = getNewWordsForUser(userId, level, NEW_WORDS_COUNT); + + DailyStudy dailyStudy = DailyStudy.builder() + .pk(VocabKey.dailyPk(userId)) + .sk(VocabKey.dateSk(date)) + .gsi1pk(VocabKey.DAILY_ALL) + .gsi1sk(VocabKey.dateSk(date)) + .userId(userId) + .date(date) + .newWordIds(newWordIds) + .reviewWordIds(reviewWordIds) + .learnedWordIds(new ArrayList<>()) + .totalWords(newWordIds.size() + reviewWordIds.size()) + .learnedCount(0) + .isCompleted(false) + .createdAt(now) + .updatedAt(now) + .build(); + + dailyStudyRepository.save(dailyStudy); + logger.info("Created daily study for user: {}, date: {}", userId, date); + + return dailyStudy; + } + + private List getNewWordsForUser(String userId, String level, int count) { + PaginatedResult userWordPage = userWordRepository.findByUserIdWithPagination(userId, 1000, null); + Set learnedWordIds = userWordPage.items().stream() + .map(UserWord::getWordId) + .collect(Collectors.toSet()); + + Set newWordIds = new LinkedHashSet<>(); + String lastEvaluatedKey = null; + + do { + PaginatedResult wordPage = wordRepository.findByLevelWithPagination(level, count * 2, lastEvaluatedKey); + for (Word word : wordPage.items()) { + if (!learnedWordIds.contains(word.getWordId()) && !newWordIds.contains(word.getWordId())) { + newWordIds.add(word.getWordId()); + if (newWordIds.size() >= count) break; + } + } + lastEvaluatedKey = wordPage.nextCursor(); + } while (newWordIds.size() < count && lastEvaluatedKey != null); + + logger.info("Selected {} new words for user {} at level {}", newWordIds.size(), userId, level); + return new ArrayList<>(newWordIds); + } + + private List getWordDetails(List wordIds) { + if (wordIds == null || wordIds.isEmpty()) { + return new ArrayList<>(); + } + return wordRepository.findByIds(wordIds); + } + + private Map calculateProgress(DailyStudy dailyStudy) { + Map progress = new HashMap<>(); + int total = dailyStudy.getTotalWords(); + int learned = dailyStudy.getLearnedCount(); + + progress.put("total", total); + progress.put("learned", learned); + progress.put("remaining", total - learned); + progress.put("percentage", total > 0 ? (learned * 100.0 / total) : 0); + progress.put("isCompleted", dailyStudy.getIsCompleted()); + + return progress; + } + + private void checkWordsBadge(String userId) { + try { + Optional totalStats = userStatsRepository.findTotalStats(userId); + if (totalStats.isPresent()) { + badgeService.checkAndAwardBadges(userId, totalStats.get()); + } + } catch (Exception e) { + logger.warn("Failed to check badges for user: {}", userId, e); + } + } + + public record DailyStudyResult(DailyStudy dailyStudy, List newWords, List reviewWords, + Map progress) { + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyQueryService.java index fe3b33e6..67c3265d 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyQueryService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyQueryService.java @@ -8,54 +8,50 @@ import org.slf4j.LoggerFactory; import java.time.LocalDate; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; +import java.util.*; /** * DailyStudy 조회 전용 서비스 (CQRS Query) */ public class DailyStudyQueryService { - - private static final Logger logger = LoggerFactory.getLogger(DailyStudyQueryService.class); - - private final DailyStudyRepository dailyStudyRepository; - private final WordRepository wordRepository; - - public DailyStudyQueryService() { - this.dailyStudyRepository = new DailyStudyRepository(); - this.wordRepository = new WordRepository(); - } - - public Optional getDailyStudy(String userId, String date) { - return dailyStudyRepository.findByUserIdAndDate(userId, date); - } - - public Optional getTodayDailyStudy(String userId) { - String today = LocalDate.now().toString(); - return dailyStudyRepository.findByUserIdAndDate(userId, today); - } - - public List getWordDetails(List wordIds) { - if (wordIds == null || wordIds.isEmpty()) { - return new ArrayList<>(); - } - return wordRepository.findByIds(wordIds); - } - - public Map calculateProgress(DailyStudy dailyStudy) { - Map progress = new HashMap<>(); - int total = dailyStudy.getTotalWords(); - int learned = dailyStudy.getLearnedCount(); - - progress.put("total", total); - progress.put("learned", learned); - progress.put("remaining", total - learned); - progress.put("percentage", total > 0 ? (learned * 100.0 / total) : 0); - progress.put("isCompleted", dailyStudy.getIsCompleted()); - - return progress; - } + + private static final Logger logger = LoggerFactory.getLogger(DailyStudyQueryService.class); + + private final DailyStudyRepository dailyStudyRepository; + private final WordRepository wordRepository; + + public DailyStudyQueryService() { + this.dailyStudyRepository = new DailyStudyRepository(); + this.wordRepository = new WordRepository(); + } + + public Optional getDailyStudy(String userId, String date) { + return dailyStudyRepository.findByUserIdAndDate(userId, date); + } + + public Optional getTodayDailyStudy(String userId) { + String today = LocalDate.now().toString(); + return dailyStudyRepository.findByUserIdAndDate(userId, today); + } + + public List getWordDetails(List wordIds) { + if (wordIds == null || wordIds.isEmpty()) { + return new ArrayList<>(); + } + return wordRepository.findByIds(wordIds); + } + + public Map calculateProgress(DailyStudy dailyStudy) { + Map progress = new HashMap<>(); + int total = dailyStudy.getTotalWords(); + int learned = dailyStudy.getLearnedCount(); + + progress.put("total", total); + progress.put("learned", learned); + progress.put("remaining", total - learned); + progress.put("percentage", total > 0 ? (learned * 100.0 / total) : 0); + progress.put("isCompleted", dailyStudy.getIsCompleted()); + + return progress; + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyService.java index 6bc0d1c6..c715d14a 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyService.java @@ -12,159 +12,157 @@ import java.time.Instant; import java.time.LocalDate; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; +import java.util.*; import java.util.stream.Collectors; public class DailyStudyService { - - private static final Logger logger = LoggerFactory.getLogger(DailyStudyService.class); - - private static final int NEW_WORDS_COUNT = 50; - private static final int REVIEW_WORDS_COUNT = 5; - - private final DailyStudyRepository dailyStudyRepository; - private final UserWordRepository userWordRepository; - private final WordRepository wordRepository; - - public DailyStudyService() { - this.dailyStudyRepository = new DailyStudyRepository(); - this.userWordRepository = new UserWordRepository(); - this.wordRepository = new WordRepository(); - } - - public DailyStudyResult getDailyWords(String userId, String level) { - String today = LocalDate.now().toString(); - - Optional optDailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today); - - DailyStudy dailyStudy; - if (optDailyStudy.isPresent()) { - dailyStudy = optDailyStudy.get(); - } else { - if (level == null || level.isEmpty()) { - throw new IllegalArgumentException("level is required for first daily study (BEGINNER, INTERMEDIATE, ADVANCED)"); - } - if (!level.equals("BEGINNER") && !level.equals("INTERMEDIATE") && !level.equals("ADVANCED")) { - throw new IllegalArgumentException("Invalid level. Must be BEGINNER, INTERMEDIATE, or ADVANCED"); - } - dailyStudy = createDailyStudy(userId, today, level); - } - - List newWords = getWordDetails(dailyStudy.getNewWordIds()); - List reviewWords = getWordDetails(dailyStudy.getReviewWordIds()); - Map progress = calculateProgress(dailyStudy); - - return new DailyStudyResult(dailyStudy, newWords, reviewWords, progress); - } - - public Map markWordLearned(String userId, String wordId) { - String today = LocalDate.now().toString(); - - Optional optDailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today); - if (optDailyStudy.isEmpty()) { - throw new IllegalStateException("Daily study not found"); - } - - DailyStudy dailyStudy = optDailyStudy.get(); - - if (dailyStudy.getLearnedWordIds() != null && dailyStudy.getLearnedWordIds().contains(wordId)) { - return calculateProgress(dailyStudy); - } - - dailyStudyRepository.addLearnedWord(userId, today, wordId); - - DailyStudy updatedDailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today).orElse(dailyStudy); - - if (updatedDailyStudy.getLearnedCount() >= updatedDailyStudy.getTotalWords()) { - updatedDailyStudy.setIsCompleted(true); - dailyStudyRepository.save(updatedDailyStudy); - } - - logger.info("Marked word as learned: userId={}, wordId={}", userId, wordId); - return calculateProgress(updatedDailyStudy); - } - - private DailyStudy createDailyStudy(String userId, String date, String level) { - String now = Instant.now().toString(); - - PaginatedResult reviewPage = userWordRepository.findReviewDueWords(userId, date, REVIEW_WORDS_COUNT, null); - List reviewWordIds = reviewPage.items().stream() - .map(UserWord::getWordId) - .collect(Collectors.toList()); - - List newWordIds = getNewWordsForUser(userId, level, NEW_WORDS_COUNT); - - DailyStudy dailyStudy = DailyStudy.builder() - .pk("DAILY#" + userId) - .sk("DATE#" + date) - .gsi1pk("DAILY#ALL") - .gsi1sk("DATE#" + date) - .userId(userId) - .date(date) - .newWordIds(newWordIds) - .reviewWordIds(reviewWordIds) - .learnedWordIds(new ArrayList<>()) - .totalWords(newWordIds.size() + reviewWordIds.size()) - .learnedCount(0) - .isCompleted(false) - .createdAt(now) - .updatedAt(now) - .build(); - - dailyStudyRepository.save(dailyStudy); - logger.info("Created daily study for user: {}, date: {}", userId, date); - - return dailyStudy; - } - - private List getNewWordsForUser(String userId, String level, int count) { - PaginatedResult userWordPage = userWordRepository.findByUserIdWithPagination(userId, 1000, null); - List learnedWordIds = userWordPage.items().stream() - .map(UserWord::getWordId) - .collect(Collectors.toList()); - - List newWordIds = new ArrayList<>(); - String lastEvaluatedKey = null; - - do { - PaginatedResult wordPage = wordRepository.findByLevelWithPagination(level, count * 2, lastEvaluatedKey); - for (Word word : wordPage.items()) { - if (!learnedWordIds.contains(word.getWordId()) && !newWordIds.contains(word.getWordId())) { - newWordIds.add(word.getWordId()); - if (newWordIds.size() >= count) break; - } - } - lastEvaluatedKey = wordPage.nextCursor(); - } while (newWordIds.size() < count && lastEvaluatedKey != null); - - logger.info("Selected {} new words for user {} at level {}", newWordIds.size(), userId, level); - return newWordIds; - } - - private List getWordDetails(List wordIds) { - if (wordIds == null || wordIds.isEmpty()) { - return new ArrayList<>(); - } - return wordRepository.findByIds(wordIds); - } - - private Map calculateProgress(DailyStudy dailyStudy) { - Map progress = new HashMap<>(); - int total = dailyStudy.getTotalWords(); - int learned = dailyStudy.getLearnedCount(); - - progress.put("total", total); - progress.put("learned", learned); - progress.put("remaining", total - learned); - progress.put("percentage", total > 0 ? (learned * 100.0 / total) : 0); - progress.put("isCompleted", dailyStudy.getIsCompleted()); - - return progress; - } - - public record DailyStudyResult(DailyStudy dailyStudy, List newWords, List reviewWords, Map progress) {} + + private static final Logger logger = LoggerFactory.getLogger(DailyStudyService.class); + + private static final int NEW_WORDS_COUNT = 50; + private static final int REVIEW_WORDS_COUNT = 5; + + private final DailyStudyRepository dailyStudyRepository; + private final UserWordRepository userWordRepository; + private final WordRepository wordRepository; + + public DailyStudyService() { + this.dailyStudyRepository = new DailyStudyRepository(); + this.userWordRepository = new UserWordRepository(); + this.wordRepository = new WordRepository(); + } + + public DailyStudyResult getDailyWords(String userId, String level) { + String today = LocalDate.now().toString(); + + Optional optDailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today); + + DailyStudy dailyStudy; + if (optDailyStudy.isPresent()) { + dailyStudy = optDailyStudy.get(); + } else { + if (level == null || level.isEmpty()) { + throw new IllegalArgumentException("level is required for first daily study (BEGINNER, INTERMEDIATE, ADVANCED)"); + } + if (!level.equals("BEGINNER") && !level.equals("INTERMEDIATE") && !level.equals("ADVANCED")) { + throw new IllegalArgumentException("Invalid level. Must be BEGINNER, INTERMEDIATE, or ADVANCED"); + } + dailyStudy = createDailyStudy(userId, today, level); + } + + List newWords = getWordDetails(dailyStudy.getNewWordIds()); + List reviewWords = getWordDetails(dailyStudy.getReviewWordIds()); + Map progress = calculateProgress(dailyStudy); + + return new DailyStudyResult(dailyStudy, newWords, reviewWords, progress); + } + + public Map markWordLearned(String userId, String wordId) { + String today = LocalDate.now().toString(); + + Optional optDailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today); + if (optDailyStudy.isEmpty()) { + throw new IllegalStateException("Daily study not found"); + } + + DailyStudy dailyStudy = optDailyStudy.get(); + + if (dailyStudy.getLearnedWordIds() != null && dailyStudy.getLearnedWordIds().contains(wordId)) { + return calculateProgress(dailyStudy); + } + + dailyStudyRepository.addLearnedWord(userId, today, wordId); + + DailyStudy updatedDailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today).orElse(dailyStudy); + + if (updatedDailyStudy.getLearnedCount() >= updatedDailyStudy.getTotalWords()) { + updatedDailyStudy.setIsCompleted(true); + dailyStudyRepository.save(updatedDailyStudy); + } + + logger.info("Marked word as learned: userId={}, wordId={}", userId, wordId); + return calculateProgress(updatedDailyStudy); + } + + private DailyStudy createDailyStudy(String userId, String date, String level) { + String now = Instant.now().toString(); + + PaginatedResult reviewPage = userWordRepository.findReviewDueWords(userId, date, REVIEW_WORDS_COUNT, null); + List reviewWordIds = reviewPage.items().stream() + .map(UserWord::getWordId) + .collect(Collectors.toList()); + + List newWordIds = getNewWordsForUser(userId, level, NEW_WORDS_COUNT); + + DailyStudy dailyStudy = DailyStudy.builder() + .pk("DAILY#" + userId) + .sk("DATE#" + date) + .gsi1pk("DAILY#ALL") + .gsi1sk("DATE#" + date) + .userId(userId) + .date(date) + .newWordIds(newWordIds) + .reviewWordIds(reviewWordIds) + .learnedWordIds(new ArrayList<>()) + .totalWords(newWordIds.size() + reviewWordIds.size()) + .learnedCount(0) + .isCompleted(false) + .createdAt(now) + .updatedAt(now) + .build(); + + dailyStudyRepository.save(dailyStudy); + logger.info("Created daily study for user: {}, date: {}", userId, date); + + return dailyStudy; + } + + private List getNewWordsForUser(String userId, String level, int count) { + PaginatedResult userWordPage = userWordRepository.findByUserIdWithPagination(userId, 1000, null); + List learnedWordIds = userWordPage.items().stream() + .map(UserWord::getWordId) + .collect(Collectors.toList()); + + List newWordIds = new ArrayList<>(); + String lastEvaluatedKey = null; + + do { + PaginatedResult wordPage = wordRepository.findByLevelWithPagination(level, count * 2, lastEvaluatedKey); + for (Word word : wordPage.items()) { + if (!learnedWordIds.contains(word.getWordId()) && !newWordIds.contains(word.getWordId())) { + newWordIds.add(word.getWordId()); + if (newWordIds.size() >= count) break; + } + } + lastEvaluatedKey = wordPage.nextCursor(); + } while (newWordIds.size() < count && lastEvaluatedKey != null); + + logger.info("Selected {} new words for user {} at level {}", newWordIds.size(), userId, level); + return newWordIds; + } + + private List getWordDetails(List wordIds) { + if (wordIds == null || wordIds.isEmpty()) { + return new ArrayList<>(); + } + return wordRepository.findByIds(wordIds); + } + + private Map calculateProgress(DailyStudy dailyStudy) { + Map progress = new HashMap<>(); + int total = dailyStudy.getTotalWords(); + int learned = dailyStudy.getLearnedCount(); + + progress.put("total", total); + progress.put("learned", learned); + progress.put("remaining", total - learned); + progress.put("percentage", total > 0 ? (learned * 100.0 / total) : 0); + progress.put("isCompleted", dailyStudy.getIsCompleted()); + + return progress; + } + + public record DailyStudyResult(DailyStudy dailyStudy, List newWords, List reviewWords, + Map progress) { + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/StatisticsService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/StatisticsService.java index eebece22..f1d1e75c 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/StatisticsService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/StatisticsService.java @@ -12,122 +12,123 @@ import java.util.Optional; public class StatisticsService { - - private static final Logger logger = LoggerFactory.getLogger(StatisticsService.class); - - private final UserWordRepository userWordRepository; - - public StatisticsService() { - this.userWordRepository = new UserWordRepository(); - } - - /** - * 시험 결과를 처리하여 UserWord 통계를 업데이트 - * @param userId 사용자 ID - * @param results 시험 결과 목록 (wordId, isCorrect 포함) - * @return 업데이트된 단어 수 - */ - @SuppressWarnings("unchecked") - public int processTestResults(String userId, List> results) { - if (userId == null || results == null) { - throw new IllegalArgumentException("userId and results are required"); - } - - String now = Instant.now().toString(); - int updatedCount = 0; - - for (Map result : results) { - String wordId = (String) result.get("wordId"); - Boolean isCorrect = (Boolean) result.get("isCorrect"); - - if (wordId == null || isCorrect == null) { - continue; - } - - updateUserWordStatistics(userId, wordId, isCorrect, now); - updatedCount++; - } - - logger.info("Processed test result for user: {}, {} words updated", userId, updatedCount); - return updatedCount; - } - - /** - * 단일 단어의 학습 결과를 업데이트 - */ - public void updateUserWordStatistics(String userId, String wordId, boolean isCorrect, String now) { - Optional optUserWord = userWordRepository.findByUserIdAndWordId(userId, wordId); - UserWord userWord; - - if (optUserWord.isEmpty()) { - userWord = createNewUserWord(userId, wordId, now); - } else { - userWord = optUserWord.get(); - } - - applySpacedRepetition(userWord, isCorrect); - userWord.setUpdatedAt(now); - userWord.setLastReviewedAt(now); - - userWord.setGsi1sk("DATE#" + userWord.getNextReviewAt()); - userWord.setGsi2sk("STATUS#" + userWord.getStatus()); - - userWordRepository.save(userWord); - } - - private UserWord createNewUserWord(String userId, String wordId, String now) { - return UserWord.builder() - .pk("USER#" + userId) - .sk("WORD#" + wordId) - .gsi1pk("USER#" + userId + "#REVIEW") - .gsi2pk("USER#" + userId + "#STATUS") - .userId(userId) - .wordId(wordId) - .status("NEW") - .interval(1) - .easeFactor(2.5) - .repetitions(0) - .correctCount(0) - .incorrectCount(0) - .createdAt(now) - .build(); - } - - /** - * SM-2 Spaced Repetition 알고리즘 적용 - */ - private void applySpacedRepetition(UserWord userWord, boolean isCorrect) { - if (isCorrect) { - userWord.setCorrectCount(userWord.getCorrectCount() + 1); - userWord.setRepetitions(userWord.getRepetitions() + 1); - - if (userWord.getRepetitions() == 1) { - userWord.setInterval(1); - } else if (userWord.getRepetitions() == 2) { - userWord.setInterval(6); - } else { - int newInterval = (int) Math.round(userWord.getInterval() * userWord.getEaseFactor()); - userWord.setInterval(newInterval); - } - - if (userWord.getRepetitions() >= 5) { - userWord.setStatus("MASTERED"); - } else if (userWord.getRepetitions() >= 2) { - userWord.setStatus("REVIEWING"); - } else { - userWord.setStatus("LEARNING"); - } - } else { - userWord.setIncorrectCount(userWord.getIncorrectCount() + 1); - userWord.setRepetitions(0); - userWord.setInterval(1); - userWord.setStatus("LEARNING"); - - double newEaseFactor = userWord.getEaseFactor() - 0.2; - userWord.setEaseFactor(Math.max(1.3, newEaseFactor)); - } - - LocalDate nextReview = LocalDate.now().plusDays(userWord.getInterval()); - userWord.setNextReviewAt(nextReview.toString()); - } + + private static final Logger logger = LoggerFactory.getLogger(StatisticsService.class); + + private final UserWordRepository userWordRepository; + + public StatisticsService() { + this.userWordRepository = new UserWordRepository(); + } + + /** + * 시험 결과를 처리하여 UserWord 통계를 업데이트 + * + * @param userId 사용자 ID + * @param results 시험 결과 목록 (wordId, isCorrect 포함) + * @return 업데이트된 단어 수 + */ + @SuppressWarnings("unchecked") + public int processTestResults(String userId, List> results) { + if (userId == null || results == null) { + throw new IllegalArgumentException("userId and results are required"); + } + + String now = Instant.now().toString(); + int updatedCount = 0; + + for (Map result : results) { + String wordId = (String) result.get("wordId"); + Boolean isCorrect = (Boolean) result.get("isCorrect"); + + if (wordId == null || isCorrect == null) { + continue; + } + + updateUserWordStatistics(userId, wordId, isCorrect, now); + updatedCount++; + } + + logger.info("Processed test result for user: {}, {} words updated", userId, updatedCount); + return updatedCount; + } + + /** + * 단일 단어의 학습 결과를 업데이트 + */ + public void updateUserWordStatistics(String userId, String wordId, boolean isCorrect, String now) { + Optional optUserWord = userWordRepository.findByUserIdAndWordId(userId, wordId); + UserWord userWord; + + if (optUserWord.isEmpty()) { + userWord = createNewUserWord(userId, wordId, now); + } else { + userWord = optUserWord.get(); + } + + applySpacedRepetition(userWord, isCorrect); + userWord.setUpdatedAt(now); + userWord.setLastReviewedAt(now); + + userWord.setGsi1sk("DATE#" + userWord.getNextReviewAt()); + userWord.setGsi2sk("STATUS#" + userWord.getStatus()); + + userWordRepository.save(userWord); + } + + private UserWord createNewUserWord(String userId, String wordId, String now) { + return UserWord.builder() + .pk("USER#" + userId) + .sk("WORD#" + wordId) + .gsi1pk("USER#" + userId + "#REVIEW") + .gsi2pk("USER#" + userId + "#STATUS") + .userId(userId) + .wordId(wordId) + .status("NEW") + .interval(1) + .easeFactor(2.5) + .repetitions(0) + .correctCount(0) + .incorrectCount(0) + .createdAt(now) + .build(); + } + + /** + * SM-2 Spaced Repetition 알고리즘 적용 + */ + private void applySpacedRepetition(UserWord userWord, boolean isCorrect) { + if (isCorrect) { + userWord.setCorrectCount(userWord.getCorrectCount() + 1); + userWord.setRepetitions(userWord.getRepetitions() + 1); + + if (userWord.getRepetitions() == 1) { + userWord.setInterval(1); + } else if (userWord.getRepetitions() == 2) { + userWord.setInterval(6); + } else { + int newInterval = (int) Math.round(userWord.getInterval() * userWord.getEaseFactor()); + userWord.setInterval(newInterval); + } + + if (userWord.getRepetitions() >= 5) { + userWord.setStatus("MASTERED"); + } else if (userWord.getRepetitions() >= 2) { + userWord.setStatus("REVIEWING"); + } else { + userWord.setStatus("LEARNING"); + } + } else { + userWord.setIncorrectCount(userWord.getIncorrectCount() + 1); + userWord.setRepetitions(0); + userWord.setInterval(1); + userWord.setStatus("LEARNING"); + + double newEaseFactor = userWord.getEaseFactor() - 0.2; + userWord.setEaseFactor(Math.max(1.3, newEaseFactor)); + } + + LocalDate nextReview = LocalDate.now().plusDays(userWord.getInterval()); + userWord.setNextReviewAt(nextReview.toString()); + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/StatsService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/StatsService.java index cb64feb0..c3664156 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/StatsService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/StatsService.java @@ -11,227 +11,224 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.HashMap; -import java.util.List; -import java.util.Map; +import java.util.*; import java.util.stream.Collectors; public class StatsService { - - private static final Logger logger = LoggerFactory.getLogger(StatsService.class); - - private final UserWordRepository userWordRepository; - private final DailyStudyRepository dailyStudyRepository; - private final TestResultRepository testResultRepository; - private final WordRepository wordRepository; - - public StatsService() { - this.userWordRepository = new UserWordRepository(); - this.dailyStudyRepository = new DailyStudyRepository(); - this.testResultRepository = new TestResultRepository(); - this.wordRepository = new WordRepository(); - } - - public Map getOverallStats(String userId) { - Map wordStatusCounts = new HashMap<>(); - wordStatusCounts.put("NEW", 0); - wordStatusCounts.put("LEARNING", 0); - wordStatusCounts.put("REVIEWING", 0); - wordStatusCounts.put("MASTERED", 0); - - int totalCorrect = 0; - int totalIncorrect = 0; - - String cursor = null; - do { - PaginatedResult page = userWordRepository.findByUserIdWithPagination(userId, 100, cursor); - for (UserWord userWord : page.items()) { - String status = userWord.getStatus(); - wordStatusCounts.merge(status, 1, Integer::sum); - totalCorrect += userWord.getCorrectCount() != null ? userWord.getCorrectCount() : 0; - totalIncorrect += userWord.getIncorrectCount() != null ? userWord.getIncorrectCount() : 0; - } - cursor = page.nextCursor(); - } while (cursor != null); - - int totalWords = wordStatusCounts.values().stream().mapToInt(Integer::intValue).sum(); - - PaginatedResult testPage = testResultRepository.findByUserIdWithPagination(userId, 100, null); - List testResults = testPage.items(); - - double avgSuccessRate = testResults.stream() - .mapToDouble(TestResult::getSuccessRate) - .average() - .orElse(0.0); - - PaginatedResult dailyPage = dailyStudyRepository.findByUserIdWithPagination(userId, 365, null); - int studyDays = dailyPage.items().size(); - int completedDays = (int) dailyPage.items().stream() - .filter(d -> Boolean.TRUE.equals(d.getIsCompleted())) - .count(); - - Map stats = new HashMap<>(); - stats.put("totalWords", totalWords); - stats.put("wordStatusCounts", wordStatusCounts); - stats.put("totalCorrect", totalCorrect); - stats.put("totalIncorrect", totalIncorrect); - stats.put("accuracy", totalCorrect + totalIncorrect > 0 - ? (totalCorrect * 100.0 / (totalCorrect + totalIncorrect)) : 0); - stats.put("testCount", testResults.size()); - stats.put("avgSuccessRate", avgSuccessRate); - stats.put("studyDays", studyDays); - stats.put("completedDays", completedDays); - stats.put("completionRate", studyDays > 0 ? (completedDays * 100.0 / studyDays) : 0); - - return stats; - } - - public DailyStatsResult getDailyStats(String userId, int limit, String cursor) { - PaginatedResult dailyPage = dailyStudyRepository.findByUserIdWithPagination(userId, limit, cursor); - - List> dailyStats = dailyPage.items().stream() - .map(daily -> { - Map stat = new HashMap<>(); - stat.put("date", daily.getDate()); - stat.put("totalWords", daily.getTotalWords()); - stat.put("learnedCount", daily.getLearnedCount()); - stat.put("isCompleted", daily.getIsCompleted()); - stat.put("progress", daily.getTotalWords() > 0 - ? (daily.getLearnedCount() * 100.0 / daily.getTotalWords()) : 0); - return stat; - }) - .toList(); - - return new DailyStatsResult(dailyStats, dailyPage.nextCursor(), dailyPage.hasMore()); - } - - public Map getWeaknessAnalysis(String userId) { - List allUserWords = new ArrayList<>(); - String cursor = null; - do { - PaginatedResult page = userWordRepository.findByUserIdWithPagination(userId, 100, cursor); - allUserWords.addAll(page.items()); - cursor = page.nextCursor(); - } while (cursor != null); - - if (allUserWords.isEmpty()) { - Map emptyResult = new HashMap<>(); - emptyResult.put("weakestWords", List.of()); - emptyResult.put("categoryAnalysis", Map.of()); - emptyResult.put("levelAnalysis", Map.of()); - emptyResult.put("suggestions", List.of()); - return emptyResult; - } - - List> weakestWords = allUserWords.stream() - .filter(uw -> uw.getIncorrectCount() != null && uw.getIncorrectCount() > 0) - .sorted(Comparator.comparingInt(UserWord::getIncorrectCount).reversed()) - .limit(10) - .map(uw -> { - Map wordInfo = new HashMap<>(); - wordInfo.put("wordId", uw.getWordId()); - wordInfo.put("incorrectCount", uw.getIncorrectCount()); - wordInfo.put("correctCount", uw.getCorrectCount()); - wordInfo.put("status", uw.getStatus()); - - wordRepository.findById(uw.getWordId()).ifPresent(word -> { - wordInfo.put("english", word.getEnglish()); - wordInfo.put("korean", word.getKorean()); - wordInfo.put("level", word.getLevel()); - wordInfo.put("category", word.getCategory()); - }); - - int total = (uw.getCorrectCount() != null ? uw.getCorrectCount() : 0) + - (uw.getIncorrectCount() != null ? uw.getIncorrectCount() : 0); - wordInfo.put("accuracy", total > 0 ? - (uw.getCorrectCount() != null ? uw.getCorrectCount() * 100.0 / total : 0) : 0); - - return wordInfo; - }) - .collect(Collectors.toList()); - - Map> categoryAnalysis = new HashMap<>(); - Map> levelAnalysis = new HashMap<>(); - - for (UserWord uw : allUserWords) { - wordRepository.findById(uw.getWordId()).ifPresent(word -> { - String category = word.getCategory(); - String level = word.getLevel(); - - int correct = uw.getCorrectCount() != null ? uw.getCorrectCount() : 0; - int incorrect = uw.getIncorrectCount() != null ? uw.getIncorrectCount() : 0; - - categoryAnalysis.computeIfAbsent(category, k -> { - Map stats = new HashMap<>(); - stats.put("totalCorrect", 0); - stats.put("totalIncorrect", 0); - stats.put("wordCount", 0); - return stats; - }); - Map catStats = categoryAnalysis.get(category); - catStats.put("totalCorrect", (Integer) catStats.get("totalCorrect") + correct); - catStats.put("totalIncorrect", (Integer) catStats.get("totalIncorrect") + incorrect); - catStats.put("wordCount", (Integer) catStats.get("wordCount") + 1); - - levelAnalysis.computeIfAbsent(level, k -> { - Map stats = new HashMap<>(); - stats.put("totalCorrect", 0); - stats.put("totalIncorrect", 0); - stats.put("wordCount", 0); - return stats; - }); - Map lvlStats = levelAnalysis.get(level); - lvlStats.put("totalCorrect", (Integer) lvlStats.get("totalCorrect") + correct); - lvlStats.put("totalIncorrect", (Integer) lvlStats.get("totalIncorrect") + incorrect); - lvlStats.put("wordCount", (Integer) lvlStats.get("wordCount") + 1); - }); - } - - categoryAnalysis.values().forEach(stats -> { - int correct = (Integer) stats.get("totalCorrect"); - int incorrect = (Integer) stats.get("totalIncorrect"); - int total = correct + incorrect; - stats.put("accuracy", total > 0 ? (correct * 100.0 / total) : 0); - }); - - levelAnalysis.values().forEach(stats -> { - int correct = (Integer) stats.get("totalCorrect"); - int incorrect = (Integer) stats.get("totalIncorrect"); - int total = correct + incorrect; - stats.put("accuracy", total > 0 ? (correct * 100.0 / total) : 0); - }); - - List suggestions = new ArrayList<>(); - - categoryAnalysis.entrySet().stream() - .filter(e -> (Integer) e.getValue().get("wordCount") >= 3) - .min(Comparator.comparingDouble(e -> (Double) e.getValue().get("accuracy"))) - .ifPresent(e -> suggestions.add( - String.format("%s 카테고리의 정확도가 %.1f%%로 가장 낮습니다. 집중 학습을 권장합니다.", - e.getKey(), e.getValue().get("accuracy")))); - - levelAnalysis.entrySet().stream() - .filter(e -> (Integer) e.getValue().get("wordCount") >= 3) - .min(Comparator.comparingDouble(e -> (Double) e.getValue().get("accuracy"))) - .ifPresent(e -> suggestions.add( - String.format("%s 레벨의 정확도가 %.1f%%입니다. 이 레벨의 단어들을 더 복습해보세요.", - e.getKey(), e.getValue().get("accuracy")))); - - if (!weakestWords.isEmpty()) { - suggestions.add(String.format("자주 틀리는 단어 %d개가 있습니다. 북마크하여 집중 복습하세요.", - weakestWords.size())); - } - - Map result = new HashMap<>(); - result.put("weakestWords", weakestWords); - result.put("categoryAnalysis", categoryAnalysis); - result.put("levelAnalysis", levelAnalysis); - result.put("suggestions", suggestions); - - return result; - } - - public record DailyStatsResult(List> dailyStats, String nextCursor, boolean hasMore) {} + + private static final Logger logger = LoggerFactory.getLogger(StatsService.class); + + private final UserWordRepository userWordRepository; + private final DailyStudyRepository dailyStudyRepository; + private final TestResultRepository testResultRepository; + private final WordRepository wordRepository; + + public StatsService() { + this.userWordRepository = new UserWordRepository(); + this.dailyStudyRepository = new DailyStudyRepository(); + this.testResultRepository = new TestResultRepository(); + this.wordRepository = new WordRepository(); + } + + public Map getOverallStats(String userId) { + Map wordStatusCounts = new HashMap<>(); + wordStatusCounts.put("NEW", 0); + wordStatusCounts.put("LEARNING", 0); + wordStatusCounts.put("REVIEWING", 0); + wordStatusCounts.put("MASTERED", 0); + + int totalCorrect = 0; + int totalIncorrect = 0; + + String cursor = null; + do { + PaginatedResult page = userWordRepository.findByUserIdWithPagination(userId, 100, cursor); + for (UserWord userWord : page.items()) { + String status = userWord.getStatus(); + wordStatusCounts.merge(status, 1, Integer::sum); + totalCorrect += userWord.getCorrectCount() != null ? userWord.getCorrectCount() : 0; + totalIncorrect += userWord.getIncorrectCount() != null ? userWord.getIncorrectCount() : 0; + } + cursor = page.nextCursor(); + } while (cursor != null); + + int totalWords = wordStatusCounts.values().stream().mapToInt(Integer::intValue).sum(); + + PaginatedResult testPage = testResultRepository.findByUserIdWithPagination(userId, 100, null); + List testResults = testPage.items(); + + double avgSuccessRate = testResults.stream() + .mapToDouble(TestResult::getSuccessRate) + .average() + .orElse(0.0); + + PaginatedResult dailyPage = dailyStudyRepository.findByUserIdWithPagination(userId, 365, null); + int studyDays = dailyPage.items().size(); + int completedDays = (int) dailyPage.items().stream() + .filter(d -> Boolean.TRUE.equals(d.getIsCompleted())) + .count(); + + Map stats = new HashMap<>(); + stats.put("totalWords", totalWords); + stats.put("wordStatusCounts", wordStatusCounts); + stats.put("totalCorrect", totalCorrect); + stats.put("totalIncorrect", totalIncorrect); + stats.put("accuracy", totalCorrect + totalIncorrect > 0 + ? (totalCorrect * 100.0 / (totalCorrect + totalIncorrect)) : 0); + stats.put("testCount", testResults.size()); + stats.put("avgSuccessRate", avgSuccessRate); + stats.put("studyDays", studyDays); + stats.put("completedDays", completedDays); + stats.put("completionRate", studyDays > 0 ? (completedDays * 100.0 / studyDays) : 0); + + return stats; + } + + public DailyStatsResult getDailyStats(String userId, int limit, String cursor) { + PaginatedResult dailyPage = dailyStudyRepository.findByUserIdWithPagination(userId, limit, cursor); + + List> dailyStats = dailyPage.items().stream() + .map(daily -> { + Map stat = new HashMap<>(); + stat.put("date", daily.getDate()); + stat.put("totalWords", daily.getTotalWords()); + stat.put("learnedCount", daily.getLearnedCount()); + stat.put("isCompleted", daily.getIsCompleted()); + stat.put("progress", daily.getTotalWords() > 0 + ? (daily.getLearnedCount() * 100.0 / daily.getTotalWords()) : 0); + return stat; + }) + .toList(); + + return new DailyStatsResult(dailyStats, dailyPage.nextCursor(), dailyPage.hasMore()); + } + + public Map getWeaknessAnalysis(String userId) { + List allUserWords = new ArrayList<>(); + String cursor = null; + do { + PaginatedResult page = userWordRepository.findByUserIdWithPagination(userId, 100, cursor); + allUserWords.addAll(page.items()); + cursor = page.nextCursor(); + } while (cursor != null); + + if (allUserWords.isEmpty()) { + Map emptyResult = new HashMap<>(); + emptyResult.put("weakestWords", List.of()); + emptyResult.put("categoryAnalysis", Map.of()); + emptyResult.put("levelAnalysis", Map.of()); + emptyResult.put("suggestions", List.of()); + return emptyResult; + } + + List> weakestWords = allUserWords.stream() + .filter(uw -> uw.getIncorrectCount() != null && uw.getIncorrectCount() > 0) + .sorted(Comparator.comparingInt(UserWord::getIncorrectCount).reversed()) + .limit(10) + .map(uw -> { + Map wordInfo = new HashMap<>(); + wordInfo.put("wordId", uw.getWordId()); + wordInfo.put("incorrectCount", uw.getIncorrectCount()); + wordInfo.put("correctCount", uw.getCorrectCount()); + wordInfo.put("status", uw.getStatus()); + + wordRepository.findById(uw.getWordId()).ifPresent(word -> { + wordInfo.put("english", word.getEnglish()); + wordInfo.put("korean", word.getKorean()); + wordInfo.put("level", word.getLevel()); + wordInfo.put("category", word.getCategory()); + }); + + int total = (uw.getCorrectCount() != null ? uw.getCorrectCount() : 0) + + (uw.getIncorrectCount() != null ? uw.getIncorrectCount() : 0); + wordInfo.put("accuracy", total > 0 ? + (uw.getCorrectCount() != null ? uw.getCorrectCount() * 100.0 / total : 0) : 0); + + return wordInfo; + }) + .collect(Collectors.toList()); + + Map> categoryAnalysis = new HashMap<>(); + Map> levelAnalysis = new HashMap<>(); + + for (UserWord uw : allUserWords) { + wordRepository.findById(uw.getWordId()).ifPresent(word -> { + String category = word.getCategory(); + String level = word.getLevel(); + + int correct = uw.getCorrectCount() != null ? uw.getCorrectCount() : 0; + int incorrect = uw.getIncorrectCount() != null ? uw.getIncorrectCount() : 0; + + categoryAnalysis.computeIfAbsent(category, k -> { + Map stats = new HashMap<>(); + stats.put("totalCorrect", 0); + stats.put("totalIncorrect", 0); + stats.put("wordCount", 0); + return stats; + }); + Map catStats = categoryAnalysis.get(category); + catStats.put("totalCorrect", (Integer) catStats.get("totalCorrect") + correct); + catStats.put("totalIncorrect", (Integer) catStats.get("totalIncorrect") + incorrect); + catStats.put("wordCount", (Integer) catStats.get("wordCount") + 1); + + levelAnalysis.computeIfAbsent(level, k -> { + Map stats = new HashMap<>(); + stats.put("totalCorrect", 0); + stats.put("totalIncorrect", 0); + stats.put("wordCount", 0); + return stats; + }); + Map lvlStats = levelAnalysis.get(level); + lvlStats.put("totalCorrect", (Integer) lvlStats.get("totalCorrect") + correct); + lvlStats.put("totalIncorrect", (Integer) lvlStats.get("totalIncorrect") + incorrect); + lvlStats.put("wordCount", (Integer) lvlStats.get("wordCount") + 1); + }); + } + + categoryAnalysis.values().forEach(stats -> { + int correct = (Integer) stats.get("totalCorrect"); + int incorrect = (Integer) stats.get("totalIncorrect"); + int total = correct + incorrect; + stats.put("accuracy", total > 0 ? (correct * 100.0 / total) : 0); + }); + + levelAnalysis.values().forEach(stats -> { + int correct = (Integer) stats.get("totalCorrect"); + int incorrect = (Integer) stats.get("totalIncorrect"); + int total = correct + incorrect; + stats.put("accuracy", total > 0 ? (correct * 100.0 / total) : 0); + }); + + List suggestions = new ArrayList<>(); + + categoryAnalysis.entrySet().stream() + .filter(e -> (Integer) e.getValue().get("wordCount") >= 3) + .min(Comparator.comparingDouble(e -> (Double) e.getValue().get("accuracy"))) + .ifPresent(e -> suggestions.add( + String.format("%s 카테고리의 정확도가 %.1f%%로 가장 낮습니다. 집중 학습을 권장합니다.", + e.getKey(), e.getValue().get("accuracy")))); + + levelAnalysis.entrySet().stream() + .filter(e -> (Integer) e.getValue().get("wordCount") >= 3) + .min(Comparator.comparingDouble(e -> (Double) e.getValue().get("accuracy"))) + .ifPresent(e -> suggestions.add( + String.format("%s 레벨의 정확도가 %.1f%%입니다. 이 레벨의 단어들을 더 복습해보세요.", + e.getKey(), e.getValue().get("accuracy")))); + + if (!weakestWords.isEmpty()) { + suggestions.add(String.format("자주 틀리는 단어 %d개가 있습니다. 북마크하여 집중 복습하세요.", + weakestWords.size())); + } + + Map result = new HashMap<>(); + result.put("weakestWords", weakestWords); + result.put("categoryAnalysis", categoryAnalysis); + result.put("levelAnalysis", levelAnalysis); + result.put("suggestions", suggestions); + + return result; + } + + public record DailyStatsResult(List> dailyStats, String nextCursor, boolean hasMore) { + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java index 907a2d95..41ce80ee 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java @@ -1,10 +1,10 @@ package com.mzc.secondproject.serverless.domain.vocabulary.service; -import com.mzc.secondproject.serverless.common.dto.PaginatedResult; -import com.mzc.secondproject.serverless.domain.vocabulary.exception.VocabularyException; -import com.mzc.secondproject.serverless.domain.vocabulary.dto.request.SubmitTestRequest; import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.dto.PaginatedResult; import com.mzc.secondproject.serverless.common.util.ResponseGenerator; +import com.mzc.secondproject.serverless.domain.vocabulary.dto.request.SubmitTestRequest; +import com.mzc.secondproject.serverless.domain.vocabulary.exception.VocabularyException; import com.mzc.secondproject.serverless.domain.vocabulary.model.DailyStudy; import com.mzc.secondproject.serverless.domain.vocabulary.model.TestResult; import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; @@ -17,256 +17,249 @@ import java.time.Instant; import java.time.LocalDate; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Random; -import java.util.Set; -import java.util.UUID; +import java.util.*; import java.util.stream.Collectors; /** * Test 변경 전용 서비스 (CQRS Command) */ public class TestCommandService { - - private static final Logger logger = LoggerFactory.getLogger(TestCommandService.class); - private static final String TEST_RESULT_TOPIC_ARN = System.getenv("TEST_RESULT_TOPIC_ARN"); - - private final TestResultRepository testResultRepository; - private final DailyStudyRepository dailyStudyRepository; - private final WordRepository wordRepository; - private final UserWordCommandService userWordCommandService; - - public TestCommandService() { - this.testResultRepository = new TestResultRepository(); - this.dailyStudyRepository = new DailyStudyRepository(); - this.wordRepository = new WordRepository(); - this.userWordCommandService = new UserWordCommandService(); - } - - public StartTestResult startTest(String userId, String testType) { - String today = LocalDate.now().toString(); - - Optional optDailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today); - if (optDailyStudy.isEmpty()) { - throw VocabularyException.dailyStudyNotFound(userId, today); - } - - DailyStudy dailyStudy = optDailyStudy.get(); - List allWordIds = new ArrayList<>(); - if (dailyStudy.getNewWordIds() != null) allWordIds.addAll(dailyStudy.getNewWordIds()); - if (dailyStudy.getReviewWordIds() != null) allWordIds.addAll(dailyStudy.getReviewWordIds()); - - if (allWordIds.isEmpty()) { - throw VocabularyException.noWordsToTest(); - } - - List words = wordRepository.findByIds(allWordIds); - - Map> wordsByLevel = words.stream() - .collect(Collectors.groupingBy(Word::getLevel)); - - Map> distractorsByLevel = new HashMap<>(); - for (String level : wordsByLevel.keySet()) { - List distractors = getDistractorsForLevel(level, allWordIds); - distractorsByLevel.put(level, distractors); - } - - Random random = new Random(); - List> questions = new ArrayList<>(); - for (Word word : words) { - Map question = new HashMap<>(); - question.put("wordId", word.getWordId()); - question.put("english", word.getEnglish()); - question.put("example", word.getExample()); - - List options = generateOptions(word, wordsByLevel, distractorsByLevel, random); - question.put("options", options); - - questions.add(question); - } - - String testId = UUID.randomUUID().toString(); - String now = Instant.now().toString(); - - logger.info("Started test: userId={}, testId={}, questions={}", userId, testId, questions.size()); - - return new StartTestResult(testId, testType, questions, questions.size(), now); - } - - public SubmitTestResult submitTest(String userId, String testId, String testType, - List answers, String startedAt) { - String now = Instant.now().toString(); - String today = LocalDate.now().toString(); - - int correctCount = 0; - int incorrectCount = 0; - List incorrectWordIds = new ArrayList<>(); - List> results = new ArrayList<>(); - - List wordIds = answers.stream() - .map(SubmitTestRequest.TestAnswer::getWordId) - .collect(Collectors.toList()); - List words = wordRepository.findByIds(wordIds); - - Map wordMap = words.stream() - .collect(Collectors.toMap(Word::getWordId, w -> w)); - - for (SubmitTestRequest.TestAnswer answer : answers) { - String wordId = answer.getWordId(); - String userAnswer = answer.getAnswer(); - - Word word = wordMap.get(wordId); - if (word != null) { - // 빈 답변은 오답 처리 - boolean isCorrect = userAnswer != null - && !userAnswer.isBlank() - && word.getKorean().trim().equalsIgnoreCase(userAnswer.trim()); - - Map resultItem = new HashMap<>(); - resultItem.put("wordId", wordId); - resultItem.put("english", word.getEnglish()); - resultItem.put("correctAnswer", word.getKorean()); - resultItem.put("userAnswer", userAnswer != null ? userAnswer : ""); - resultItem.put("isCorrect", isCorrect); - results.add(resultItem); - - if (isCorrect) { - correctCount++; - } else { - incorrectCount++; - incorrectWordIds.add(wordId); - } - } - } - - int totalQuestions = answers.size(); - double successRate = totalQuestions > 0 ? (correctCount * 100.0 / totalQuestions) : 0; - - TestResult testResult = TestResult.builder() - .pk("TEST#" + userId) - .sk("RESULT#" + now) - .gsi1pk("TEST#ALL") - .gsi1sk("DATE#" + today) - .testId(testId) - .userId(userId) - .testType(testType) - .totalQuestions(totalQuestions) - .correctAnswers(correctCount) - .incorrectAnswers(incorrectCount) - .successRate(successRate) - .testedWordIds(wordIds) - .incorrectWordIds(incorrectWordIds) - .startedAt(startedAt) - .completedAt(now) - .build(); - - testResultRepository.save(testResult); - - // 오답 단어 자동 북마크 - bookmarkIncorrectWords(userId, incorrectWordIds); - - publishTestResultToSns(userId, results); - - logger.info("Test submitted: userId={}, testId={}, successRate={}%", userId, testId, successRate); - - return new SubmitTestResult(testId, testType, totalQuestions, correctCount, incorrectCount, successRate, results); - } - - private void bookmarkIncorrectWords(String userId, List incorrectWordIds) { - if (incorrectWordIds == null || incorrectWordIds.isEmpty()) { - return; - } - - int bookmarkedCount = 0; - for (String wordId : incorrectWordIds) { - try { - userWordCommandService.updateUserWordTag(userId, wordId, true, null, null); - bookmarkedCount++; - } catch (Exception e) { - logger.warn("Failed to bookmark word: userId={}, wordId={}", userId, wordId, e); - } - } - logger.info("Auto-bookmarked {} incorrect words for user: {}", bookmarkedCount, userId); - } - - private List getDistractorsForLevel(String level, List excludeWordIds) { - Set excludeSet = new HashSet<>(excludeWordIds); - PaginatedResult wordPage = wordRepository.findByLevelWithPagination(level, 50, null); - return wordPage.items().stream() - .filter(w -> !excludeSet.contains(w.getWordId())) - .map(Word::getKorean) - .collect(Collectors.toList()); - } - - private List generateOptions(Word correctWord, Map> wordsByLevel, - Map> distractorsByLevel, Random random) { - List options = new ArrayList<>(); - String correctAnswer = correctWord.getKorean(); - options.add(correctAnswer); - - String level = correctWord.getLevel(); - - List sameLevelOptions = wordsByLevel.getOrDefault(level, new ArrayList<>()).stream() - .filter(w -> !w.getWordId().equals(correctWord.getWordId())) - .map(Word::getKorean) - .collect(Collectors.toList()); - - List additionalDistractors = distractorsByLevel.getOrDefault(level, new ArrayList<>()); - - List allDistractors = new ArrayList<>(); - allDistractors.addAll(sameLevelOptions); - allDistractors.addAll(additionalDistractors); - - allDistractors = allDistractors.stream() - .filter(d -> !d.equals(correctAnswer)) - .distinct() - .collect(Collectors.toList()); - - Collections.shuffle(allDistractors, random); - int distractorCount = Math.min(3, allDistractors.size()); - for (int i = 0; i < distractorCount; i++) { - options.add(allDistractors.get(i)); - } - - Collections.shuffle(options, random); - return options; - } - - private void publishTestResultToSns(String userId, List> results) { - if (TEST_RESULT_TOPIC_ARN == null || TEST_RESULT_TOPIC_ARN.isEmpty()) { - logger.warn("TEST_RESULT_TOPIC_ARN is not configured, skipping SNS publish"); - return; - } - - try { - Map message = new HashMap<>(); - message.put("userId", userId); - message.put("results", results); - - String messageJson = ResponseGenerator.gson().toJson(message); - - PublishRequest publishRequest = PublishRequest.builder() - .topicArn(TEST_RESULT_TOPIC_ARN) - .message(messageJson) - .build(); - - AwsClients.sns().publish(publishRequest); - logger.info("Published test result to SNS for user: {}", userId); - } catch (Exception e) { - logger.error("Failed to publish test result to SNS for user: {}", userId, e); - } - } - - public record StartTestResult(String testId, String testType, List> questions, - int totalQuestions, String startedAt) {} - - public record SubmitTestResult(String testId, String testType, int totalQuestions, - int correctCount, int incorrectCount, double successRate, - List> results) {} + + private static final Logger logger = LoggerFactory.getLogger(TestCommandService.class); + private static final String TEST_RESULT_TOPIC_ARN = System.getenv("TEST_RESULT_TOPIC_ARN"); + + private final TestResultRepository testResultRepository; + private final DailyStudyRepository dailyStudyRepository; + private final WordRepository wordRepository; + private final UserWordCommandService userWordCommandService; + + public TestCommandService() { + this.testResultRepository = new TestResultRepository(); + this.dailyStudyRepository = new DailyStudyRepository(); + this.wordRepository = new WordRepository(); + this.userWordCommandService = new UserWordCommandService(); + } + + public StartTestResult startTest(String userId, String testType) { + String today = LocalDate.now().toString(); + + Optional optDailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today); + if (optDailyStudy.isEmpty()) { + throw VocabularyException.dailyStudyNotFound(userId, today); + } + + DailyStudy dailyStudy = optDailyStudy.get(); + List allWordIds = new ArrayList<>(); + if (dailyStudy.getNewWordIds() != null) allWordIds.addAll(dailyStudy.getNewWordIds()); + if (dailyStudy.getReviewWordIds() != null) allWordIds.addAll(dailyStudy.getReviewWordIds()); + + if (allWordIds.isEmpty()) { + throw VocabularyException.noWordsToTest(); + } + + List words = wordRepository.findByIds(allWordIds); + + Map> wordsByLevel = words.stream() + .collect(Collectors.groupingBy(Word::getLevel)); + + Map> distractorsByLevel = new HashMap<>(); + for (String level : wordsByLevel.keySet()) { + List distractors = getDistractorsForLevel(level, allWordIds); + distractorsByLevel.put(level, distractors); + } + + Random random = new Random(); + List> questions = new ArrayList<>(); + for (Word word : words) { + Map question = new HashMap<>(); + question.put("wordId", word.getWordId()); + question.put("english", word.getEnglish()); + question.put("example", word.getExample()); + + List options = generateOptions(word, wordsByLevel, distractorsByLevel, random); + question.put("options", options); + + questions.add(question); + } + + String testId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + + logger.info("Started test: userId={}, testId={}, questions={}", userId, testId, questions.size()); + + return new StartTestResult(testId, testType, questions, questions.size(), now); + } + + public SubmitTestResult submitTest(String userId, String testId, String testType, + List answers, String startedAt) { + String now = Instant.now().toString(); + String today = LocalDate.now().toString(); + + int correctCount = 0; + int incorrectCount = 0; + List incorrectWordIds = new ArrayList<>(); + List> results = new ArrayList<>(); + + List wordIds = answers.stream() + .map(SubmitTestRequest.TestAnswer::getWordId) + .collect(Collectors.toList()); + List words = wordRepository.findByIds(wordIds); + + Map wordMap = words.stream() + .collect(Collectors.toMap(Word::getWordId, w -> w)); + + for (SubmitTestRequest.TestAnswer answer : answers) { + String wordId = answer.getWordId(); + String userAnswer = answer.getAnswer(); + + Word word = wordMap.get(wordId); + if (word != null) { + // 빈 답변은 오답 처리 + boolean isCorrect = userAnswer != null + && !userAnswer.isBlank() + && word.getKorean().trim().equalsIgnoreCase(userAnswer.trim()); + + Map resultItem = new HashMap<>(); + resultItem.put("wordId", wordId); + resultItem.put("english", word.getEnglish()); + resultItem.put("correctAnswer", word.getKorean()); + resultItem.put("userAnswer", userAnswer != null ? userAnswer : ""); + resultItem.put("isCorrect", isCorrect); + results.add(resultItem); + + if (isCorrect) { + correctCount++; + } else { + incorrectCount++; + incorrectWordIds.add(wordId); + } + } + } + + int totalQuestions = answers.size(); + double successRate = totalQuestions > 0 ? (correctCount * 100.0 / totalQuestions) : 0; + + TestResult testResult = TestResult.builder() + .pk("TEST#" + userId) + .sk("RESULT#" + now) + .gsi1pk("TEST#ALL") + .gsi1sk("DATE#" + today) + .testId(testId) + .userId(userId) + .testType(testType) + .totalQuestions(totalQuestions) + .correctAnswers(correctCount) + .incorrectAnswers(incorrectCount) + .successRate(successRate) + .testedWordIds(wordIds) + .incorrectWordIds(incorrectWordIds) + .startedAt(startedAt) + .completedAt(now) + .build(); + + testResultRepository.save(testResult); + + // 오답 단어 자동 북마크 + bookmarkIncorrectWords(userId, incorrectWordIds); + + publishTestResultToSns(userId, results); + + logger.info("Test submitted: userId={}, testId={}, successRate={}%", userId, testId, successRate); + + return new SubmitTestResult(testId, testType, totalQuestions, correctCount, incorrectCount, successRate, results); + } + + private void bookmarkIncorrectWords(String userId, List incorrectWordIds) { + if (incorrectWordIds == null || incorrectWordIds.isEmpty()) { + return; + } + + int bookmarkedCount = 0; + for (String wordId : incorrectWordIds) { + try { + userWordCommandService.updateUserWordTag(userId, wordId, true, null, null); + bookmarkedCount++; + } catch (Exception e) { + logger.warn("Failed to bookmark word: userId={}, wordId={}", userId, wordId, e); + } + } + logger.info("Auto-bookmarked {} incorrect words for user: {}", bookmarkedCount, userId); + } + + private List getDistractorsForLevel(String level, List excludeWordIds) { + Set excludeSet = new HashSet<>(excludeWordIds); + PaginatedResult wordPage = wordRepository.findByLevelWithPagination(level, 50, null); + return wordPage.items().stream() + .filter(w -> !excludeSet.contains(w.getWordId())) + .map(Word::getKorean) + .collect(Collectors.toList()); + } + + private List generateOptions(Word correctWord, Map> wordsByLevel, + Map> distractorsByLevel, Random random) { + List options = new ArrayList<>(); + String correctAnswer = correctWord.getKorean(); + options.add(correctAnswer); + + String level = correctWord.getLevel(); + + List sameLevelOptions = wordsByLevel.getOrDefault(level, new ArrayList<>()).stream() + .filter(w -> !w.getWordId().equals(correctWord.getWordId())) + .map(Word::getKorean) + .collect(Collectors.toList()); + + List additionalDistractors = distractorsByLevel.getOrDefault(level, new ArrayList<>()); + + List allDistractors = new ArrayList<>(); + allDistractors.addAll(sameLevelOptions); + allDistractors.addAll(additionalDistractors); + + allDistractors = allDistractors.stream() + .filter(d -> !d.equals(correctAnswer)) + .distinct() + .collect(Collectors.toList()); + + Collections.shuffle(allDistractors, random); + int distractorCount = Math.min(3, allDistractors.size()); + for (int i = 0; i < distractorCount; i++) { + options.add(allDistractors.get(i)); + } + + Collections.shuffle(options, random); + return options; + } + + private void publishTestResultToSns(String userId, List> results) { + if (TEST_RESULT_TOPIC_ARN == null || TEST_RESULT_TOPIC_ARN.isEmpty()) { + logger.warn("TEST_RESULT_TOPIC_ARN is not configured, skipping SNS publish"); + return; + } + + try { + Map message = new HashMap<>(); + message.put("userId", userId); + message.put("results", results); + + String messageJson = ResponseGenerator.gson().toJson(message); + + PublishRequest publishRequest = PublishRequest.builder() + .topicArn(TEST_RESULT_TOPIC_ARN) + .message(messageJson) + .build(); + + AwsClients.sns().publish(publishRequest); + logger.info("Published test result to SNS for user: {}", userId); + } catch (Exception e) { + logger.error("Failed to publish test result to SNS for user: {}", userId, e); + } + } + + public record StartTestResult(String testId, String testType, List> questions, + int totalQuestions, String startedAt) { + } + + public record SubmitTestResult(String testId, String testType, int totalQuestions, + int correctCount, int incorrectCount, double successRate, + List> results) { + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestQueryService.java index fadd6a26..47dab64b 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestQueryService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestQueryService.java @@ -8,76 +8,74 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.util.ArrayList; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Optional; -import java.util.Set; +import java.util.*; /** * Test 조회 전용 서비스 (CQRS Query) */ public class TestQueryService { - - private static final Logger logger = LoggerFactory.getLogger(TestQueryService.class); - - private final TestResultRepository testResultRepository; - private final WordRepository wordRepository; - - public TestQueryService() { - this.testResultRepository = new TestResultRepository(); - this.wordRepository = new WordRepository(); - } - - public PaginatedResult getTestResults(String userId, int limit, String cursor) { - return testResultRepository.findByUserIdWithPagination(userId, limit, cursor); - } - - public Optional getTestResultDetail(String userId, String testId) { - Optional optResult = testResultRepository.findByUserIdAndTestId(userId, testId); - - if (optResult.isEmpty()) { - return Optional.empty(); - } - - TestResult testResult = optResult.get(); - List incorrectWords = List.of(); - - if (testResult.getIncorrectWordIds() != null && !testResult.getIncorrectWordIds().isEmpty()) { - incorrectWords = wordRepository.findByIds(testResult.getIncorrectWordIds()); - } - - return Optional.of(new TestResultDetail(testResult, incorrectWords)); - } - - /** - * 시험에 나온 단어 조회 - * 최근 테스트 결과들에서 출제된 단어들을 조회 - */ - public TestedWordsResult getTestedWords(String userId, int recentTests, int limit) { - PaginatedResult results = testResultRepository.findByUserIdWithPagination(userId, recentTests, null); - - Set testedWordIds = new LinkedHashSet<>(); - for (TestResult result : results.items()) { - if (result.getTestedWordIds() != null) { - testedWordIds.addAll(result.getTestedWordIds()); - } - } - - if (testedWordIds.isEmpty()) { - return new TestedWordsResult(new ArrayList<>(), testedWordIds.size()); - } - - List wordIdList = new ArrayList<>(testedWordIds); - if (wordIdList.size() > limit) { - wordIdList = wordIdList.subList(0, limit); - } - - List words = wordRepository.findByIds(wordIdList); - return new TestedWordsResult(words, testedWordIds.size()); - } - - public record TestResultDetail(TestResult testResult, List incorrectWords) {} - - public record TestedWordsResult(List words, int totalCount) {} + + private static final Logger logger = LoggerFactory.getLogger(TestQueryService.class); + + private final TestResultRepository testResultRepository; + private final WordRepository wordRepository; + + public TestQueryService() { + this.testResultRepository = new TestResultRepository(); + this.wordRepository = new WordRepository(); + } + + public PaginatedResult getTestResults(String userId, int limit, String cursor) { + return testResultRepository.findByUserIdWithPagination(userId, limit, cursor); + } + + public Optional getTestResultDetail(String userId, String testId) { + Optional optResult = testResultRepository.findByUserIdAndTestId(userId, testId); + + if (optResult.isEmpty()) { + return Optional.empty(); + } + + TestResult testResult = optResult.get(); + List incorrectWords = List.of(); + + if (testResult.getIncorrectWordIds() != null && !testResult.getIncorrectWordIds().isEmpty()) { + incorrectWords = wordRepository.findByIds(testResult.getIncorrectWordIds()); + } + + return Optional.of(new TestResultDetail(testResult, incorrectWords)); + } + + /** + * 시험에 나온 단어 조회 + * 최근 테스트 결과들에서 출제된 단어들을 조회 + */ + public TestedWordsResult getTestedWords(String userId, int recentTests, int limit) { + PaginatedResult results = testResultRepository.findByUserIdWithPagination(userId, recentTests, null); + + Set testedWordIds = new LinkedHashSet<>(); + for (TestResult result : results.items()) { + if (result.getTestedWordIds() != null) { + testedWordIds.addAll(result.getTestedWordIds()); + } + } + + if (testedWordIds.isEmpty()) { + return new TestedWordsResult(new ArrayList<>(), testedWordIds.size()); + } + + List wordIdList = new ArrayList<>(testedWordIds); + if (wordIdList.size() > limit) { + wordIdList = wordIdList.subList(0, limit); + } + + List words = wordRepository.findByIds(wordIdList); + return new TestedWordsResult(words, testedWordIds.size()); + } + + public record TestResultDetail(TestResult testResult, List incorrectWords) { + } + + public record TestedWordsResult(List words, int totalCount) { + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestService.java index 425c3d02..a8eb506c 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestService.java @@ -1,7 +1,7 @@ package com.mzc.secondproject.serverless.domain.vocabulary.service; -import com.mzc.secondproject.serverless.common.dto.PaginatedResult; import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.dto.PaginatedResult; import com.mzc.secondproject.serverless.common.util.ResponseGenerator; import com.mzc.secondproject.serverless.domain.vocabulary.model.DailyStudy; import com.mzc.secondproject.serverless.domain.vocabulary.model.TestResult; @@ -15,228 +15,223 @@ import java.time.Instant; import java.time.LocalDate; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Random; -import java.util.UUID; +import java.util.*; import java.util.stream.Collectors; public class TestService { - - private static final Logger logger = LoggerFactory.getLogger(TestService.class); - private static final String TEST_RESULT_TOPIC_ARN = System.getenv("TEST_RESULT_TOPIC_ARN"); - - private final TestResultRepository testResultRepository; - private final DailyStudyRepository dailyStudyRepository; - private final WordRepository wordRepository; - - public TestService() { - this.testResultRepository = new TestResultRepository(); - this.dailyStudyRepository = new DailyStudyRepository(); - this.wordRepository = new WordRepository(); - } - - public StartTestResult startTest(String userId, String testType) { - String today = LocalDate.now().toString(); - - Optional optDailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today); - if (optDailyStudy.isEmpty()) { - throw new IllegalStateException("No daily study found for today"); - } - - DailyStudy dailyStudy = optDailyStudy.get(); - List allWordIds = new ArrayList<>(); - if (dailyStudy.getNewWordIds() != null) allWordIds.addAll(dailyStudy.getNewWordIds()); - if (dailyStudy.getReviewWordIds() != null) allWordIds.addAll(dailyStudy.getReviewWordIds()); - - if (allWordIds.isEmpty()) { - throw new IllegalStateException("No words to test"); - } - - List words = wordRepository.findByIds(allWordIds); - - Map> wordsByLevel = words.stream() - .collect(Collectors.groupingBy(Word::getLevel)); - - Map> distractorsByLevel = new HashMap<>(); - for (String level : wordsByLevel.keySet()) { - List distractors = getDistractorsForLevel(level, allWordIds); - distractorsByLevel.put(level, distractors); - } - - Random random = new Random(); - List> questions = new ArrayList<>(); - for (Word word : words) { - Map question = new HashMap<>(); - question.put("wordId", word.getWordId()); - question.put("english", word.getEnglish()); - question.put("example", word.getExample()); - - List options = generateOptions(word, wordsByLevel, distractorsByLevel, random); - question.put("options", options); - - questions.add(question); - } - - String testId = UUID.randomUUID().toString(); - String now = Instant.now().toString(); - - logger.info("Started test: userId={}, testId={}, questions={}", userId, testId, questions.size()); - - return new StartTestResult(testId, testType, questions, questions.size(), now); - } - - public SubmitTestResult submitTest(String userId, String testId, String testType, - List> answers, String startedAt) { - String now = Instant.now().toString(); - String today = LocalDate.now().toString(); - - int correctCount = 0; - int incorrectCount = 0; - List incorrectWordIds = new ArrayList<>(); - List> results = new ArrayList<>(); - - List wordIds = answers.stream() - .map(a -> (String) a.get("wordId")) - .collect(Collectors.toList()); - List words = wordRepository.findByIds(wordIds); - - Map wordMap = words.stream() - .collect(Collectors.toMap(Word::getWordId, w -> w)); - - for (Map answer : answers) { - String wordId = (String) answer.get("wordId"); - String userAnswer = (String) answer.get("answer"); - - Word word = wordMap.get(wordId); - if (word != null) { - boolean isCorrect = word.getKorean().trim().equalsIgnoreCase(userAnswer.trim()); - - Map resultItem = new HashMap<>(); - resultItem.put("wordId", wordId); - resultItem.put("english", word.getEnglish()); - resultItem.put("correctAnswer", word.getKorean()); - resultItem.put("userAnswer", userAnswer); - resultItem.put("isCorrect", isCorrect); - results.add(resultItem); - - if (isCorrect) { - correctCount++; - } else { - incorrectCount++; - incorrectWordIds.add(wordId); - } - } - } - - int totalQuestions = answers.size(); - double successRate = totalQuestions > 0 ? (correctCount * 100.0 / totalQuestions) : 0; - - TestResult testResult = TestResult.builder() - .pk("TEST#" + userId) - .sk("RESULT#" + now) - .gsi1pk("TEST#ALL") - .gsi1sk("DATE#" + today) - .testId(testId) - .userId(userId) - .testType(testType) - .totalQuestions(totalQuestions) - .correctAnswers(correctCount) - .incorrectAnswers(incorrectCount) - .successRate(successRate) - .incorrectWordIds(incorrectWordIds) - .startedAt(startedAt) - .completedAt(now) - .build(); - - testResultRepository.save(testResult); - - publishTestResultToSns(userId, results); - - logger.info("Test submitted: userId={}, testId={}, successRate={}%", userId, testId, successRate); - - return new SubmitTestResult(testId, testType, totalQuestions, correctCount, incorrectCount, successRate, results); - } - - public PaginatedResult getTestResults(String userId, int limit, String cursor) { - return testResultRepository.findByUserIdWithPagination(userId, limit, cursor); - } - - private List getDistractorsForLevel(String level, List excludeWordIds) { - PaginatedResult wordPage = wordRepository.findByLevelWithPagination(level, 50, null); - return wordPage.items().stream() - .filter(w -> !excludeWordIds.contains(w.getWordId())) - .map(Word::getKorean) - .collect(Collectors.toList()); - } - - private List generateOptions(Word correctWord, Map> wordsByLevel, - Map> distractorsByLevel, Random random) { - List options = new ArrayList<>(); - String correctAnswer = correctWord.getKorean(); - options.add(correctAnswer); - - String level = correctWord.getLevel(); - - List sameLevelOptions = wordsByLevel.getOrDefault(level, new ArrayList<>()).stream() - .filter(w -> !w.getWordId().equals(correctWord.getWordId())) - .map(Word::getKorean) - .collect(Collectors.toList()); - - List additionalDistractors = distractorsByLevel.getOrDefault(level, new ArrayList<>()); - - List allDistractors = new ArrayList<>(); - allDistractors.addAll(sameLevelOptions); - allDistractors.addAll(additionalDistractors); - - allDistractors = allDistractors.stream() - .filter(d -> !d.equals(correctAnswer)) - .distinct() - .collect(Collectors.toList()); - - Collections.shuffle(allDistractors, random); - int distractorCount = Math.min(3, allDistractors.size()); - for (int i = 0; i < distractorCount; i++) { - options.add(allDistractors.get(i)); - } - - Collections.shuffle(options, random); - return options; - } - - private void publishTestResultToSns(String userId, List> results) { - if (TEST_RESULT_TOPIC_ARN == null || TEST_RESULT_TOPIC_ARN.isEmpty()) { - logger.warn("TEST_RESULT_TOPIC_ARN is not configured, skipping SNS publish"); - return; - } - - try { - Map message = new HashMap<>(); - message.put("userId", userId); - message.put("results", results); - - String messageJson = ResponseGenerator.gson().toJson(message); - - PublishRequest publishRequest = PublishRequest.builder() - .topicArn(TEST_RESULT_TOPIC_ARN) - .message(messageJson) - .build(); - - AwsClients.sns().publish(publishRequest); - logger.info("Published test result to SNS for user: {}", userId); - } catch (Exception e) { - logger.error("Failed to publish test result to SNS for user: {}", userId, e); - } - } - - public record StartTestResult(String testId, String testType, List> questions, - int totalQuestions, String startedAt) {} - - public record SubmitTestResult(String testId, String testType, int totalQuestions, - int correctCount, int incorrectCount, double successRate, - List> results) {} + + private static final Logger logger = LoggerFactory.getLogger(TestService.class); + private static final String TEST_RESULT_TOPIC_ARN = System.getenv("TEST_RESULT_TOPIC_ARN"); + + private final TestResultRepository testResultRepository; + private final DailyStudyRepository dailyStudyRepository; + private final WordRepository wordRepository; + + public TestService() { + this.testResultRepository = new TestResultRepository(); + this.dailyStudyRepository = new DailyStudyRepository(); + this.wordRepository = new WordRepository(); + } + + public StartTestResult startTest(String userId, String testType) { + String today = LocalDate.now().toString(); + + Optional optDailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today); + if (optDailyStudy.isEmpty()) { + throw new IllegalStateException("No daily study found for today"); + } + + DailyStudy dailyStudy = optDailyStudy.get(); + List allWordIds = new ArrayList<>(); + if (dailyStudy.getNewWordIds() != null) allWordIds.addAll(dailyStudy.getNewWordIds()); + if (dailyStudy.getReviewWordIds() != null) allWordIds.addAll(dailyStudy.getReviewWordIds()); + + if (allWordIds.isEmpty()) { + throw new IllegalStateException("No words to test"); + } + + List words = wordRepository.findByIds(allWordIds); + + Map> wordsByLevel = words.stream() + .collect(Collectors.groupingBy(Word::getLevel)); + + Map> distractorsByLevel = new HashMap<>(); + for (String level : wordsByLevel.keySet()) { + List distractors = getDistractorsForLevel(level, allWordIds); + distractorsByLevel.put(level, distractors); + } + + Random random = new Random(); + List> questions = new ArrayList<>(); + for (Word word : words) { + Map question = new HashMap<>(); + question.put("wordId", word.getWordId()); + question.put("english", word.getEnglish()); + question.put("example", word.getExample()); + + List options = generateOptions(word, wordsByLevel, distractorsByLevel, random); + question.put("options", options); + + questions.add(question); + } + + String testId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + + logger.info("Started test: userId={}, testId={}, questions={}", userId, testId, questions.size()); + + return new StartTestResult(testId, testType, questions, questions.size(), now); + } + + public SubmitTestResult submitTest(String userId, String testId, String testType, + List> answers, String startedAt) { + String now = Instant.now().toString(); + String today = LocalDate.now().toString(); + + int correctCount = 0; + int incorrectCount = 0; + List incorrectWordIds = new ArrayList<>(); + List> results = new ArrayList<>(); + + List wordIds = answers.stream() + .map(a -> (String) a.get("wordId")) + .collect(Collectors.toList()); + List words = wordRepository.findByIds(wordIds); + + Map wordMap = words.stream() + .collect(Collectors.toMap(Word::getWordId, w -> w)); + + for (Map answer : answers) { + String wordId = (String) answer.get("wordId"); + String userAnswer = (String) answer.get("answer"); + + Word word = wordMap.get(wordId); + if (word != null) { + boolean isCorrect = word.getKorean().trim().equalsIgnoreCase(userAnswer.trim()); + + Map resultItem = new HashMap<>(); + resultItem.put("wordId", wordId); + resultItem.put("english", word.getEnglish()); + resultItem.put("correctAnswer", word.getKorean()); + resultItem.put("userAnswer", userAnswer); + resultItem.put("isCorrect", isCorrect); + results.add(resultItem); + + if (isCorrect) { + correctCount++; + } else { + incorrectCount++; + incorrectWordIds.add(wordId); + } + } + } + + int totalQuestions = answers.size(); + double successRate = totalQuestions > 0 ? (correctCount * 100.0 / totalQuestions) : 0; + + TestResult testResult = TestResult.builder() + .pk("TEST#" + userId) + .sk("RESULT#" + now) + .gsi1pk("TEST#ALL") + .gsi1sk("DATE#" + today) + .testId(testId) + .userId(userId) + .testType(testType) + .totalQuestions(totalQuestions) + .correctAnswers(correctCount) + .incorrectAnswers(incorrectCount) + .successRate(successRate) + .incorrectWordIds(incorrectWordIds) + .startedAt(startedAt) + .completedAt(now) + .build(); + + testResultRepository.save(testResult); + + publishTestResultToSns(userId, results); + + logger.info("Test submitted: userId={}, testId={}, successRate={}%", userId, testId, successRate); + + return new SubmitTestResult(testId, testType, totalQuestions, correctCount, incorrectCount, successRate, results); + } + + public PaginatedResult getTestResults(String userId, int limit, String cursor) { + return testResultRepository.findByUserIdWithPagination(userId, limit, cursor); + } + + private List getDistractorsForLevel(String level, List excludeWordIds) { + PaginatedResult wordPage = wordRepository.findByLevelWithPagination(level, 50, null); + return wordPage.items().stream() + .filter(w -> !excludeWordIds.contains(w.getWordId())) + .map(Word::getKorean) + .collect(Collectors.toList()); + } + + private List generateOptions(Word correctWord, Map> wordsByLevel, + Map> distractorsByLevel, Random random) { + List options = new ArrayList<>(); + String correctAnswer = correctWord.getKorean(); + options.add(correctAnswer); + + String level = correctWord.getLevel(); + + List sameLevelOptions = wordsByLevel.getOrDefault(level, new ArrayList<>()).stream() + .filter(w -> !w.getWordId().equals(correctWord.getWordId())) + .map(Word::getKorean) + .collect(Collectors.toList()); + + List additionalDistractors = distractorsByLevel.getOrDefault(level, new ArrayList<>()); + + List allDistractors = new ArrayList<>(); + allDistractors.addAll(sameLevelOptions); + allDistractors.addAll(additionalDistractors); + + allDistractors = allDistractors.stream() + .filter(d -> !d.equals(correctAnswer)) + .distinct() + .collect(Collectors.toList()); + + Collections.shuffle(allDistractors, random); + int distractorCount = Math.min(3, allDistractors.size()); + for (int i = 0; i < distractorCount; i++) { + options.add(allDistractors.get(i)); + } + + Collections.shuffle(options, random); + return options; + } + + private void publishTestResultToSns(String userId, List> results) { + if (TEST_RESULT_TOPIC_ARN == null || TEST_RESULT_TOPIC_ARN.isEmpty()) { + logger.warn("TEST_RESULT_TOPIC_ARN is not configured, skipping SNS publish"); + return; + } + + try { + Map message = new HashMap<>(); + message.put("userId", userId); + message.put("results", results); + + String messageJson = ResponseGenerator.gson().toJson(message); + + PublishRequest publishRequest = PublishRequest.builder() + .topicArn(TEST_RESULT_TOPIC_ARN) + .message(messageJson) + .build(); + + AwsClients.sns().publish(publishRequest); + logger.info("Published test result to SNS for user: {}", userId); + } catch (Exception e) { + logger.error("Failed to publish test result to SNS for user: {}", userId, e); + } + } + + public record StartTestResult(String testId, String testType, List> questions, + int totalQuestions, String startedAt) { + } + + public record SubmitTestResult(String testId, String testType, int totalQuestions, + int correctCount, int incorrectCount, double successRate, + List> results) { + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordCommandService.java index b236a0e7..18d0c283 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordCommandService.java @@ -20,172 +20,172 @@ * UserWord 변경 전용 서비스 (CQRS Command) */ public class UserWordCommandService { - - private static final Logger logger = LoggerFactory.getLogger(UserWordCommandService.class); - - private final UserWordRepository userWordRepository; - - public UserWordCommandService() { - this.userWordRepository = new UserWordRepository(); - } - - public UserWord updateUserWord(String userId, String wordId, boolean isCorrect) { - Optional optUserWord = userWordRepository.findByUserIdAndWordId(userId, wordId); - UserWord userWord; - String now = Instant.now().toString(); - - if (optUserWord.isEmpty()) { - userWord = UserWord.builder() - .pk(VocabKey.userPk(userId)) - .sk(VocabKey.wordSk(wordId)) - .gsi1pk(VocabKey.userReviewPk(userId)) - .gsi2pk(VocabKey.userStatusPk(userId)) - .userId(userId) - .wordId(wordId) - .status(WordStatus.NEW.name()) - .interval(StudyConfig.INITIAL_INTERVAL_DAYS) - .easeFactor(StudyConfig.DEFAULT_EASE_FACTOR) - .repetitions(StudyConfig.INITIAL_REPETITIONS) - .correctCount(StudyConfig.INITIAL_CORRECT_COUNT) - .incorrectCount(StudyConfig.INITIAL_INCORRECT_COUNT) - .createdAt(now) - .build(); - } else { - userWord = optUserWord.get(); - } - - applySpacedRepetition(userWord, isCorrect); - userWord.setUpdatedAt(now); - userWord.setLastReviewedAt(now); - - userWord.setGsi1sk(VocabKey.dateSk(userWord.getNextReviewAt())); - userWord.setGsi2sk(VocabKey.statusSk(userWord.getStatus())); - - userWordRepository.save(userWord); - - logger.info("Updated user word: userId={}, wordId={}, isCorrect={}", userId, wordId, isCorrect); - return userWord; - } - - public UserWord updateUserWordTag(String userId, String wordId, Boolean bookmarked, - Boolean favorite, String difficulty) { - Optional optUserWord = userWordRepository.findByUserIdAndWordId(userId, wordId); - UserWord userWord; - String now = Instant.now().toString(); - - if (optUserWord.isEmpty()) { - userWord = UserWord.builder() - .pk(VocabKey.userPk(userId)) - .sk(VocabKey.wordSk(wordId)) - .gsi1pk(VocabKey.userReviewPk(userId)) - .gsi2pk(VocabKey.userStatusPk(userId)) - .gsi2sk(VocabKey.statusSk(WordStatus.NEW.name())) - .userId(userId) - .wordId(wordId) - .status(WordStatus.NEW.name()) - .interval(StudyConfig.INITIAL_INTERVAL_DAYS) - .easeFactor(StudyConfig.DEFAULT_EASE_FACTOR) - .repetitions(StudyConfig.INITIAL_REPETITIONS) - .correctCount(StudyConfig.INITIAL_CORRECT_COUNT) - .incorrectCount(StudyConfig.INITIAL_INCORRECT_COUNT) - .bookmarked(false) - .favorite(false) - .createdAt(now) - .build(); - } else { - userWord = optUserWord.get(); - } - - if (bookmarked != null) { - userWord.setBookmarked(bookmarked); - if (bookmarked) { - userWord.setGsi3pk(VocabKey.userBookmarkedPk(userId)); - userWord.setGsi3sk(VocabKey.wordSk(wordId)); - } else { - userWord.setGsi3pk(null); - userWord.setGsi3sk(null); - } - } - if (favorite != null) { - userWord.setFavorite(favorite); - } - if (difficulty != null) { - if (!Difficulty.isValid(difficulty)) { - throw new IllegalArgumentException("difficulty must be EASY, NORMAL, or HARD"); - } - userWord.setDifficulty(difficulty); - } - - userWord.setUpdatedAt(now); - userWordRepository.save(userWord); - - logger.info("Updated user word tag: userId={}, wordId={}", userId, wordId); - return userWord; - } - - /** - * 단어 상태 수동 변경 - */ - public UserWord updateWordStatus(String userId, String wordId, String newStatus) { - if (!WordStatus.isValid(newStatus)) { - throw new IllegalArgumentException("Invalid status: " + newStatus); - } - - Optional optUserWord = userWordRepository.findByUserIdAndWordId(userId, wordId); - UserWord userWord; - String now = Instant.now().toString(); - - if (optUserWord.isEmpty()) { - userWord = UserWord.builder() - .pk(VocabKey.userPk(userId)) - .sk(VocabKey.wordSk(wordId)) - .gsi1pk(VocabKey.userReviewPk(userId)) - .gsi2pk(VocabKey.userStatusPk(userId)) - .userId(userId) - .wordId(wordId) - .interval(StudyConfig.INITIAL_INTERVAL_DAYS) - .easeFactor(StudyConfig.DEFAULT_EASE_FACTOR) - .repetitions(StudyConfig.INITIAL_REPETITIONS) - .correctCount(StudyConfig.INITIAL_CORRECT_COUNT) - .incorrectCount(StudyConfig.INITIAL_INCORRECT_COUNT) - .createdAt(now) - .build(); - } else { - userWord = optUserWord.get(); - } - - userWord.setStatus(newStatus.toUpperCase()); - userWord.setGsi2sk(VocabKey.statusSk(newStatus.toUpperCase())); - userWord.setUpdatedAt(now); - - userWordRepository.save(userWord); - - logger.info("Updated word status: userId={}, wordId={}, status={}", userId, wordId, newStatus); - return userWord; - } - - private void applySpacedRepetition(UserWord userWord, boolean isCorrect) { - SpacedRepetitionContext context = new SpacedRepetitionContext( - userWord.getRepetitions(), - userWord.getInterval(), - userWord.getEaseFactor(), - userWord.getCorrectCount(), - userWord.getIncorrectCount() - ); - - WordState currentState = WordStateFactory.fromString(userWord.getStatus()); - WordState nextState = isCorrect - ? currentState.onCorrectAnswer(context) - : currentState.onWrongAnswer(context); - - userWord.setRepetitions(context.getRepetitions()); - userWord.setInterval(context.getInterval()); - userWord.setEaseFactor(context.getEaseFactor()); - userWord.setCorrectCount(context.getCorrectCount()); - userWord.setIncorrectCount(context.getIncorrectCount()); - userWord.setStatus(nextState.getStateName()); - - LocalDate nextReview = LocalDate.now().plusDays(context.getInterval()); - userWord.setNextReviewAt(nextReview.toString()); - } + + private static final Logger logger = LoggerFactory.getLogger(UserWordCommandService.class); + + private final UserWordRepository userWordRepository; + + public UserWordCommandService() { + this.userWordRepository = new UserWordRepository(); + } + + public UserWord updateUserWord(String userId, String wordId, boolean isCorrect) { + Optional optUserWord = userWordRepository.findByUserIdAndWordId(userId, wordId); + UserWord userWord; + String now = Instant.now().toString(); + + if (optUserWord.isEmpty()) { + userWord = UserWord.builder() + .pk(VocabKey.userPk(userId)) + .sk(VocabKey.wordSk(wordId)) + .gsi1pk(VocabKey.userReviewPk(userId)) + .gsi2pk(VocabKey.userStatusPk(userId)) + .userId(userId) + .wordId(wordId) + .status(WordStatus.NEW.name()) + .interval(StudyConfig.INITIAL_INTERVAL_DAYS) + .easeFactor(StudyConfig.DEFAULT_EASE_FACTOR) + .repetitions(StudyConfig.INITIAL_REPETITIONS) + .correctCount(StudyConfig.INITIAL_CORRECT_COUNT) + .incorrectCount(StudyConfig.INITIAL_INCORRECT_COUNT) + .createdAt(now) + .build(); + } else { + userWord = optUserWord.get(); + } + + applySpacedRepetition(userWord, isCorrect); + userWord.setUpdatedAt(now); + userWord.setLastReviewedAt(now); + + userWord.setGsi1sk(VocabKey.dateSk(userWord.getNextReviewAt())); + userWord.setGsi2sk(VocabKey.statusSk(userWord.getStatus())); + + userWordRepository.save(userWord); + + logger.info("Updated user word: userId={}, wordId={}, isCorrect={}", userId, wordId, isCorrect); + return userWord; + } + + public UserWord updateUserWordTag(String userId, String wordId, Boolean bookmarked, + Boolean favorite, String difficulty) { + Optional optUserWord = userWordRepository.findByUserIdAndWordId(userId, wordId); + UserWord userWord; + String now = Instant.now().toString(); + + if (optUserWord.isEmpty()) { + userWord = UserWord.builder() + .pk(VocabKey.userPk(userId)) + .sk(VocabKey.wordSk(wordId)) + .gsi1pk(VocabKey.userReviewPk(userId)) + .gsi2pk(VocabKey.userStatusPk(userId)) + .gsi2sk(VocabKey.statusSk(WordStatus.NEW.name())) + .userId(userId) + .wordId(wordId) + .status(WordStatus.NEW.name()) + .interval(StudyConfig.INITIAL_INTERVAL_DAYS) + .easeFactor(StudyConfig.DEFAULT_EASE_FACTOR) + .repetitions(StudyConfig.INITIAL_REPETITIONS) + .correctCount(StudyConfig.INITIAL_CORRECT_COUNT) + .incorrectCount(StudyConfig.INITIAL_INCORRECT_COUNT) + .bookmarked(false) + .favorite(false) + .createdAt(now) + .build(); + } else { + userWord = optUserWord.get(); + } + + if (bookmarked != null) { + userWord.setBookmarked(bookmarked); + if (bookmarked) { + userWord.setGsi3pk(VocabKey.userBookmarkedPk(userId)); + userWord.setGsi3sk(VocabKey.wordSk(wordId)); + } else { + userWord.setGsi3pk(null); + userWord.setGsi3sk(null); + } + } + if (favorite != null) { + userWord.setFavorite(favorite); + } + if (difficulty != null) { + if (!Difficulty.isValid(difficulty)) { + throw new IllegalArgumentException("difficulty must be EASY, NORMAL, or HARD"); + } + userWord.setDifficulty(difficulty); + } + + userWord.setUpdatedAt(now); + userWordRepository.save(userWord); + + logger.info("Updated user word tag: userId={}, wordId={}", userId, wordId); + return userWord; + } + + /** + * 단어 상태 수동 변경 + */ + public UserWord updateWordStatus(String userId, String wordId, String newStatus) { + if (!WordStatus.isValid(newStatus)) { + throw new IllegalArgumentException("Invalid status: " + newStatus); + } + + Optional optUserWord = userWordRepository.findByUserIdAndWordId(userId, wordId); + UserWord userWord; + String now = Instant.now().toString(); + + if (optUserWord.isEmpty()) { + userWord = UserWord.builder() + .pk(VocabKey.userPk(userId)) + .sk(VocabKey.wordSk(wordId)) + .gsi1pk(VocabKey.userReviewPk(userId)) + .gsi2pk(VocabKey.userStatusPk(userId)) + .userId(userId) + .wordId(wordId) + .interval(StudyConfig.INITIAL_INTERVAL_DAYS) + .easeFactor(StudyConfig.DEFAULT_EASE_FACTOR) + .repetitions(StudyConfig.INITIAL_REPETITIONS) + .correctCount(StudyConfig.INITIAL_CORRECT_COUNT) + .incorrectCount(StudyConfig.INITIAL_INCORRECT_COUNT) + .createdAt(now) + .build(); + } else { + userWord = optUserWord.get(); + } + + userWord.setStatus(newStatus.toUpperCase()); + userWord.setGsi2sk(VocabKey.statusSk(newStatus.toUpperCase())); + userWord.setUpdatedAt(now); + + userWordRepository.save(userWord); + + logger.info("Updated word status: userId={}, wordId={}, status={}", userId, wordId, newStatus); + return userWord; + } + + private void applySpacedRepetition(UserWord userWord, boolean isCorrect) { + SpacedRepetitionContext context = new SpacedRepetitionContext( + userWord.getRepetitions(), + userWord.getInterval(), + userWord.getEaseFactor(), + userWord.getCorrectCount(), + userWord.getIncorrectCount() + ); + + WordState currentState = WordStateFactory.fromString(userWord.getStatus()); + WordState nextState = isCorrect + ? currentState.onCorrectAnswer(context) + : currentState.onWrongAnswer(context); + + userWord.setRepetitions(context.getRepetitions()); + userWord.setInterval(context.getInterval()); + userWord.setEaseFactor(context.getEaseFactor()); + userWord.setCorrectCount(context.getCorrectCount()); + userWord.setIncorrectCount(context.getIncorrectCount()); + userWord.setStatus(nextState.getStateName()); + + LocalDate nextReview = LocalDate.now().plusDays(context.getInterval()); + userWord.setNextReviewAt(nextReview.toString()); + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordQueryService.java index 3fe3402a..1d5fe08c 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordQueryService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordQueryService.java @@ -8,119 +8,116 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; +import java.util.*; import java.util.stream.Collectors; /** * UserWord 조회 전용 서비스 (CQRS Query) */ public class UserWordQueryService { - - private static final Logger logger = LoggerFactory.getLogger(UserWordQueryService.class); - - private final UserWordRepository userWordRepository; - private final WordRepository wordRepository; - - public UserWordQueryService() { - this.userWordRepository = new UserWordRepository(); - this.wordRepository = new WordRepository(); - } - - public UserWordsResult getUserWords(String userId, String status, String bookmarked, - String incorrectOnly, int limit, String cursor) { - PaginatedResult userWordPage; - - if ("true".equalsIgnoreCase(bookmarked)) { - userWordPage = userWordRepository.findBookmarkedWords(userId, limit, cursor); - } else if ("true".equalsIgnoreCase(incorrectOnly)) { - userWordPage = userWordRepository.findIncorrectWords(userId, limit, cursor); - } else if (status != null && !status.isEmpty()) { - userWordPage = userWordRepository.findByUserIdAndStatus(userId, status, limit, cursor); - } else { - userWordPage = userWordRepository.findByUserIdWithPagination(userId, limit, cursor); - } - - List> enrichedUserWords = enrichWithWordInfo(userWordPage.items()); - - return new UserWordsResult(enrichedUserWords, userWordPage.nextCursor(), userWordPage.hasMore()); - } - - /** - * 오답 노트 조회 - 오답 횟수 기준 내림차순 정렬 - */ - public UserWordsResult getWrongAnswers(String userId, int minCount, int limit, String cursor) { - PaginatedResult userWordPage = userWordRepository.findIncorrectWords(userId, minCount, limit * 2, cursor); - - // 오답 횟수 기준 내림차순 정렬 - List sorted = userWordPage.items().stream() - .sorted((a, b) -> { - int countA = a.getIncorrectCount() != null ? a.getIncorrectCount() : 0; - int countB = b.getIncorrectCount() != null ? b.getIncorrectCount() : 0; - return Integer.compare(countB, countA); - }) - .limit(limit) - .collect(Collectors.toList()); - - List> enrichedUserWords = enrichWithWordInfo(sorted); - - return new UserWordsResult(enrichedUserWords, userWordPage.nextCursor(), userWordPage.hasMore()); - } - - public Optional getUserWord(String userId, String wordId) { - return userWordRepository.findByUserIdAndWordId(userId, wordId); - } - - private List> enrichWithWordInfo(List userWords) { - if (userWords == null || userWords.isEmpty()) { - return new ArrayList<>(); - } - - List wordIds = userWords.stream() - .map(UserWord::getWordId) - .collect(Collectors.toList()); - - List words = wordRepository.findByIds(wordIds); - - Map wordMap = words.stream() - .collect(Collectors.toMap(Word::getWordId, w -> w, (w1, w2) -> w1)); - - List> enrichedList = new ArrayList<>(); - for (UserWord userWord : userWords) { - Map enriched = new HashMap<>(); - - enriched.put("wordId", userWord.getWordId()); - enriched.put("userId", userWord.getUserId()); - enriched.put("status", userWord.getStatus()); - enriched.put("correctCount", userWord.getCorrectCount()); - enriched.put("incorrectCount", userWord.getIncorrectCount()); - enriched.put("bookmarked", userWord.getBookmarked()); - enriched.put("favorite", userWord.getFavorite()); - enriched.put("difficulty", userWord.getDifficulty()); - enriched.put("nextReviewAt", userWord.getNextReviewAt()); - enriched.put("lastReviewedAt", userWord.getLastReviewedAt()); - enriched.put("repetitions", userWord.getRepetitions()); - enriched.put("interval", userWord.getInterval()); - - Word word = wordMap.get(userWord.getWordId()); - if (word != null) { - enriched.put("english", word.getEnglish()); - enriched.put("korean", word.getKorean()); - enriched.put("level", word.getLevel()); - enriched.put("category", word.getCategory()); - enriched.put("example", word.getExample()); - enriched.put("maleVoiceKey", word.getMaleVoiceKey()); - enriched.put("femaleVoiceKey", word.getFemaleVoiceKey()); - } - - enrichedList.add(enriched); - } - - return enrichedList; - } - - public record UserWordsResult(List> userWords, String nextCursor, boolean hasMore) {} + + private static final Logger logger = LoggerFactory.getLogger(UserWordQueryService.class); + + private final UserWordRepository userWordRepository; + private final WordRepository wordRepository; + + public UserWordQueryService() { + this.userWordRepository = new UserWordRepository(); + this.wordRepository = new WordRepository(); + } + + public UserWordsResult getUserWords(String userId, String status, String bookmarked, + String incorrectOnly, int limit, String cursor) { + PaginatedResult userWordPage; + + if ("true".equalsIgnoreCase(bookmarked)) { + userWordPage = userWordRepository.findBookmarkedWords(userId, limit, cursor); + } else if ("true".equalsIgnoreCase(incorrectOnly)) { + userWordPage = userWordRepository.findIncorrectWords(userId, limit, cursor); + } else if (status != null && !status.isEmpty()) { + userWordPage = userWordRepository.findByUserIdAndStatus(userId, status, limit, cursor); + } else { + userWordPage = userWordRepository.findByUserIdWithPagination(userId, limit, cursor); + } + + List> enrichedUserWords = enrichWithWordInfo(userWordPage.items()); + + return new UserWordsResult(enrichedUserWords, userWordPage.nextCursor(), userWordPage.hasMore()); + } + + /** + * 오답 노트 조회 - 오답 횟수 기준 내림차순 정렬 + */ + public UserWordsResult getWrongAnswers(String userId, int minCount, int limit, String cursor) { + PaginatedResult userWordPage = userWordRepository.findIncorrectWords(userId, minCount, limit * 2, cursor); + + // 오답 횟수 기준 내림차순 정렬 + List sorted = userWordPage.items().stream() + .sorted((a, b) -> { + int countA = a.getIncorrectCount() != null ? a.getIncorrectCount() : 0; + int countB = b.getIncorrectCount() != null ? b.getIncorrectCount() : 0; + return Integer.compare(countB, countA); + }) + .limit(limit) + .collect(Collectors.toList()); + + List> enrichedUserWords = enrichWithWordInfo(sorted); + + return new UserWordsResult(enrichedUserWords, userWordPage.nextCursor(), userWordPage.hasMore()); + } + + public Optional getUserWord(String userId, String wordId) { + return userWordRepository.findByUserIdAndWordId(userId, wordId); + } + + private List> enrichWithWordInfo(List userWords) { + if (userWords == null || userWords.isEmpty()) { + return new ArrayList<>(); + } + + List wordIds = userWords.stream() + .map(UserWord::getWordId) + .collect(Collectors.toList()); + + List words = wordRepository.findByIds(wordIds); + + Map wordMap = words.stream() + .collect(Collectors.toMap(Word::getWordId, w -> w, (w1, w2) -> w1)); + + List> enrichedList = new ArrayList<>(); + for (UserWord userWord : userWords) { + Map enriched = new HashMap<>(); + + enriched.put("wordId", userWord.getWordId()); + enriched.put("userId", userWord.getUserId()); + enriched.put("status", userWord.getStatus()); + enriched.put("correctCount", userWord.getCorrectCount()); + enriched.put("incorrectCount", userWord.getIncorrectCount()); + enriched.put("bookmarked", userWord.getBookmarked()); + enriched.put("favorite", userWord.getFavorite()); + enriched.put("difficulty", userWord.getDifficulty()); + enriched.put("nextReviewAt", userWord.getNextReviewAt()); + enriched.put("lastReviewedAt", userWord.getLastReviewedAt()); + enriched.put("repetitions", userWord.getRepetitions()); + enriched.put("interval", userWord.getInterval()); + + Word word = wordMap.get(userWord.getWordId()); + if (word != null) { + enriched.put("english", word.getEnglish()); + enriched.put("korean", word.getKorean()); + enriched.put("level", word.getLevel()); + enriched.put("category", word.getCategory()); + enriched.put("example", word.getExample()); + enriched.put("maleVoiceKey", word.getMaleVoiceKey()); + enriched.put("femaleVoiceKey", word.getFemaleVoiceKey()); + } + + enrichedList.add(enriched); + } + + return enrichedList; + } + + public record UserWordsResult(List> userWords, String nextCursor, boolean hasMore) { + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordService.java index 5815f71f..c0bf7c3f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordService.java @@ -10,217 +10,214 @@ import java.time.Instant; import java.time.LocalDate; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; +import java.util.*; import java.util.stream.Collectors; public class UserWordService { - - private static final Logger logger = LoggerFactory.getLogger(UserWordService.class); - - private final UserWordRepository userWordRepository; - private final WordRepository wordRepository; - - public UserWordService() { - this.userWordRepository = new UserWordRepository(); - this.wordRepository = new WordRepository(); - } - - public UserWordsResult getUserWords(String userId, String status, String bookmarked, - String incorrectOnly, int limit, String cursor) { - PaginatedResult userWordPage; - - if ("true".equalsIgnoreCase(bookmarked)) { - userWordPage = userWordRepository.findBookmarkedWords(userId, limit, cursor); - } else if ("true".equalsIgnoreCase(incorrectOnly)) { - userWordPage = userWordRepository.findIncorrectWords(userId, limit, cursor); - } else if (status != null && !status.isEmpty()) { - userWordPage = userWordRepository.findByUserIdAndStatus(userId, status, limit, cursor); - } else { - userWordPage = userWordRepository.findByUserIdWithPagination(userId, limit, cursor); - } - - List> enrichedUserWords = enrichWithWordInfo(userWordPage.items()); - - return new UserWordsResult(enrichedUserWords, userWordPage.nextCursor(), userWordPage.hasMore()); - } - - public Optional getUserWord(String userId, String wordId) { - return userWordRepository.findByUserIdAndWordId(userId, wordId); - } - - public UserWord updateUserWord(String userId, String wordId, boolean isCorrect) { - Optional optUserWord = userWordRepository.findByUserIdAndWordId(userId, wordId); - UserWord userWord; - String now = Instant.now().toString(); - - if (optUserWord.isEmpty()) { - userWord = UserWord.builder() - .pk("USER#" + userId) - .sk("WORD#" + wordId) - .gsi1pk("USER#" + userId + "#REVIEW") - .gsi2pk("USER#" + userId + "#STATUS") - .userId(userId) - .wordId(wordId) - .status("NEW") - .interval(1) - .easeFactor(2.5) - .repetitions(0) - .correctCount(0) - .incorrectCount(0) - .createdAt(now) - .build(); - } else { - userWord = optUserWord.get(); - } - - applySpacedRepetition(userWord, isCorrect); - userWord.setUpdatedAt(now); - userWord.setLastReviewedAt(now); - - userWord.setGsi1sk("DATE#" + userWord.getNextReviewAt()); - userWord.setGsi2sk("STATUS#" + userWord.getStatus()); - - userWordRepository.save(userWord); - - logger.info("Updated user word: userId={}, wordId={}, isCorrect={}", userId, wordId, isCorrect); - return userWord; - } - - public UserWord updateUserWordTag(String userId, String wordId, Boolean bookmarked, - Boolean favorite, String difficulty) { - Optional optUserWord = userWordRepository.findByUserIdAndWordId(userId, wordId); - UserWord userWord; - String now = Instant.now().toString(); - - if (optUserWord.isEmpty()) { - userWord = UserWord.builder() - .pk("USER#" + userId) - .sk("WORD#" + wordId) - .gsi1pk("USER#" + userId + "#REVIEW") - .gsi2pk("USER#" + userId + "#STATUS") - .gsi2sk("STATUS#NEW") - .userId(userId) - .wordId(wordId) - .status("NEW") - .interval(1) - .easeFactor(2.5) - .repetitions(0) - .correctCount(0) - .incorrectCount(0) - .bookmarked(false) - .favorite(false) - .createdAt(now) - .build(); - } else { - userWord = optUserWord.get(); - } - - if (bookmarked != null) { - userWord.setBookmarked(bookmarked); - } - if (favorite != null) { - userWord.setFavorite(favorite); - } - if (difficulty != null) { - if (!difficulty.equals("EASY") && !difficulty.equals("NORMAL") && !difficulty.equals("HARD")) { - throw new IllegalArgumentException("difficulty must be EASY, NORMAL, or HARD"); - } - userWord.setDifficulty(difficulty); - } - - userWord.setUpdatedAt(now); - userWordRepository.save(userWord); - - logger.info("Updated user word tag: userId={}, wordId={}", userId, wordId); - return userWord; - } - - private void applySpacedRepetition(UserWord userWord, boolean isCorrect) { - if (isCorrect) { - userWord.setCorrectCount(userWord.getCorrectCount() + 1); - userWord.setRepetitions(userWord.getRepetitions() + 1); - - if (userWord.getRepetitions() == 1) { - userWord.setInterval(1); - } else if (userWord.getRepetitions() == 2) { - userWord.setInterval(6); - } else { - int newInterval = (int) Math.round(userWord.getInterval() * userWord.getEaseFactor()); - userWord.setInterval(newInterval); - } - - if (userWord.getRepetitions() >= 5) { - userWord.setStatus("MASTERED"); - } else if (userWord.getRepetitions() >= 2) { - userWord.setStatus("REVIEWING"); - } else { - userWord.setStatus("LEARNING"); - } - } else { - userWord.setIncorrectCount(userWord.getIncorrectCount() + 1); - userWord.setRepetitions(0); - userWord.setInterval(1); - userWord.setStatus("LEARNING"); - - double newEaseFactor = userWord.getEaseFactor() - 0.2; - userWord.setEaseFactor(Math.max(1.3, newEaseFactor)); - } - - LocalDate nextReview = LocalDate.now().plusDays(userWord.getInterval()); - userWord.setNextReviewAt(nextReview.toString()); - } - - private List> enrichWithWordInfo(List userWords) { - if (userWords == null || userWords.isEmpty()) { - return new ArrayList<>(); - } - - List wordIds = userWords.stream() - .map(UserWord::getWordId) - .collect(Collectors.toList()); - - List words = wordRepository.findByIds(wordIds); - - Map wordMap = words.stream() - .collect(Collectors.toMap(Word::getWordId, w -> w, (w1, w2) -> w1)); - - List> enrichedList = new ArrayList<>(); - for (UserWord userWord : userWords) { - Map enriched = new HashMap<>(); - - enriched.put("wordId", userWord.getWordId()); - enriched.put("userId", userWord.getUserId()); - enriched.put("status", userWord.getStatus()); - enriched.put("correctCount", userWord.getCorrectCount()); - enriched.put("incorrectCount", userWord.getIncorrectCount()); - enriched.put("bookmarked", userWord.getBookmarked()); - enriched.put("favorite", userWord.getFavorite()); - enriched.put("difficulty", userWord.getDifficulty()); - enriched.put("nextReviewAt", userWord.getNextReviewAt()); - enriched.put("lastReviewedAt", userWord.getLastReviewedAt()); - enriched.put("repetitions", userWord.getRepetitions()); - enriched.put("interval", userWord.getInterval()); - - Word word = wordMap.get(userWord.getWordId()); - if (word != null) { - enriched.put("english", word.getEnglish()); - enriched.put("korean", word.getKorean()); - enriched.put("level", word.getLevel()); - enriched.put("category", word.getCategory()); - enriched.put("example", word.getExample()); - enriched.put("maleVoiceKey", word.getMaleVoiceKey()); - enriched.put("femaleVoiceKey", word.getFemaleVoiceKey()); - } - - enrichedList.add(enriched); - } - - return enrichedList; - } - - public record UserWordsResult(List> userWords, String nextCursor, boolean hasMore) {} + + private static final Logger logger = LoggerFactory.getLogger(UserWordService.class); + + private final UserWordRepository userWordRepository; + private final WordRepository wordRepository; + + public UserWordService() { + this.userWordRepository = new UserWordRepository(); + this.wordRepository = new WordRepository(); + } + + public UserWordsResult getUserWords(String userId, String status, String bookmarked, + String incorrectOnly, int limit, String cursor) { + PaginatedResult userWordPage; + + if ("true".equalsIgnoreCase(bookmarked)) { + userWordPage = userWordRepository.findBookmarkedWords(userId, limit, cursor); + } else if ("true".equalsIgnoreCase(incorrectOnly)) { + userWordPage = userWordRepository.findIncorrectWords(userId, limit, cursor); + } else if (status != null && !status.isEmpty()) { + userWordPage = userWordRepository.findByUserIdAndStatus(userId, status, limit, cursor); + } else { + userWordPage = userWordRepository.findByUserIdWithPagination(userId, limit, cursor); + } + + List> enrichedUserWords = enrichWithWordInfo(userWordPage.items()); + + return new UserWordsResult(enrichedUserWords, userWordPage.nextCursor(), userWordPage.hasMore()); + } + + public Optional getUserWord(String userId, String wordId) { + return userWordRepository.findByUserIdAndWordId(userId, wordId); + } + + public UserWord updateUserWord(String userId, String wordId, boolean isCorrect) { + Optional optUserWord = userWordRepository.findByUserIdAndWordId(userId, wordId); + UserWord userWord; + String now = Instant.now().toString(); + + if (optUserWord.isEmpty()) { + userWord = UserWord.builder() + .pk("USER#" + userId) + .sk("WORD#" + wordId) + .gsi1pk("USER#" + userId + "#REVIEW") + .gsi2pk("USER#" + userId + "#STATUS") + .userId(userId) + .wordId(wordId) + .status("NEW") + .interval(1) + .easeFactor(2.5) + .repetitions(0) + .correctCount(0) + .incorrectCount(0) + .createdAt(now) + .build(); + } else { + userWord = optUserWord.get(); + } + + applySpacedRepetition(userWord, isCorrect); + userWord.setUpdatedAt(now); + userWord.setLastReviewedAt(now); + + userWord.setGsi1sk("DATE#" + userWord.getNextReviewAt()); + userWord.setGsi2sk("STATUS#" + userWord.getStatus()); + + userWordRepository.save(userWord); + + logger.info("Updated user word: userId={}, wordId={}, isCorrect={}", userId, wordId, isCorrect); + return userWord; + } + + public UserWord updateUserWordTag(String userId, String wordId, Boolean bookmarked, + Boolean favorite, String difficulty) { + Optional optUserWord = userWordRepository.findByUserIdAndWordId(userId, wordId); + UserWord userWord; + String now = Instant.now().toString(); + + if (optUserWord.isEmpty()) { + userWord = UserWord.builder() + .pk("USER#" + userId) + .sk("WORD#" + wordId) + .gsi1pk("USER#" + userId + "#REVIEW") + .gsi2pk("USER#" + userId + "#STATUS") + .gsi2sk("STATUS#NEW") + .userId(userId) + .wordId(wordId) + .status("NEW") + .interval(1) + .easeFactor(2.5) + .repetitions(0) + .correctCount(0) + .incorrectCount(0) + .bookmarked(false) + .favorite(false) + .createdAt(now) + .build(); + } else { + userWord = optUserWord.get(); + } + + if (bookmarked != null) { + userWord.setBookmarked(bookmarked); + } + if (favorite != null) { + userWord.setFavorite(favorite); + } + if (difficulty != null) { + if (!difficulty.equals("EASY") && !difficulty.equals("NORMAL") && !difficulty.equals("HARD")) { + throw new IllegalArgumentException("difficulty must be EASY, NORMAL, or HARD"); + } + userWord.setDifficulty(difficulty); + } + + userWord.setUpdatedAt(now); + userWordRepository.save(userWord); + + logger.info("Updated user word tag: userId={}, wordId={}", userId, wordId); + return userWord; + } + + private void applySpacedRepetition(UserWord userWord, boolean isCorrect) { + if (isCorrect) { + userWord.setCorrectCount(userWord.getCorrectCount() + 1); + userWord.setRepetitions(userWord.getRepetitions() + 1); + + if (userWord.getRepetitions() == 1) { + userWord.setInterval(1); + } else if (userWord.getRepetitions() == 2) { + userWord.setInterval(6); + } else { + int newInterval = (int) Math.round(userWord.getInterval() * userWord.getEaseFactor()); + userWord.setInterval(newInterval); + } + + if (userWord.getRepetitions() >= 5) { + userWord.setStatus("MASTERED"); + } else if (userWord.getRepetitions() >= 2) { + userWord.setStatus("REVIEWING"); + } else { + userWord.setStatus("LEARNING"); + } + } else { + userWord.setIncorrectCount(userWord.getIncorrectCount() + 1); + userWord.setRepetitions(0); + userWord.setInterval(1); + userWord.setStatus("LEARNING"); + + double newEaseFactor = userWord.getEaseFactor() - 0.2; + userWord.setEaseFactor(Math.max(1.3, newEaseFactor)); + } + + LocalDate nextReview = LocalDate.now().plusDays(userWord.getInterval()); + userWord.setNextReviewAt(nextReview.toString()); + } + + private List> enrichWithWordInfo(List userWords) { + if (userWords == null || userWords.isEmpty()) { + return new ArrayList<>(); + } + + List wordIds = userWords.stream() + .map(UserWord::getWordId) + .collect(Collectors.toList()); + + List words = wordRepository.findByIds(wordIds); + + Map wordMap = words.stream() + .collect(Collectors.toMap(Word::getWordId, w -> w, (w1, w2) -> w1)); + + List> enrichedList = new ArrayList<>(); + for (UserWord userWord : userWords) { + Map enriched = new HashMap<>(); + + enriched.put("wordId", userWord.getWordId()); + enriched.put("userId", userWord.getUserId()); + enriched.put("status", userWord.getStatus()); + enriched.put("correctCount", userWord.getCorrectCount()); + enriched.put("incorrectCount", userWord.getIncorrectCount()); + enriched.put("bookmarked", userWord.getBookmarked()); + enriched.put("favorite", userWord.getFavorite()); + enriched.put("difficulty", userWord.getDifficulty()); + enriched.put("nextReviewAt", userWord.getNextReviewAt()); + enriched.put("lastReviewedAt", userWord.getLastReviewedAt()); + enriched.put("repetitions", userWord.getRepetitions()); + enriched.put("interval", userWord.getInterval()); + + Word word = wordMap.get(userWord.getWordId()); + if (word != null) { + enriched.put("english", word.getEnglish()); + enriched.put("korean", word.getKorean()); + enriched.put("level", word.getLevel()); + enriched.put("category", word.getCategory()); + enriched.put("example", word.getExample()); + enriched.put("maleVoiceKey", word.getMaleVoiceKey()); + enriched.put("femaleVoiceKey", word.getFemaleVoiceKey()); + } + + enrichedList.add(enriched); + } + + return enrichedList; + } + + public record UserWordsResult(List> userWords, String nextCursor, boolean hasMore) { + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordCommandService.java index d9291e2e..5807055a 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordCommandService.java @@ -9,144 +9,141 @@ import org.slf4j.LoggerFactory; import java.time.Instant; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.UUID; +import java.util.*; /** * Word 변경 전용 서비스 (CQRS Command) */ public class WordCommandService { - - private static final Logger logger = LoggerFactory.getLogger(WordCommandService.class); - - private final WordRepository wordRepository; - - public WordCommandService() { - this.wordRepository = new WordRepository(); - } - - public Word createWord(String english, String korean, String example, String level, String category) { - String wordId = UUID.randomUUID().toString(); - String now = Instant.now().toString(); - - Word word = Word.builder() - .pk(VocabKey.wordPk(wordId)) - .sk(DynamoDbKey.METADATA) - .gsi1pk(VocabKey.levelPk(level)) - .gsi1sk(VocabKey.wordSk(wordId)) - .gsi2pk(VocabKey.categoryPk(category)) - .gsi2sk(VocabKey.wordSk(wordId)) - .wordId(wordId) - .english(english) - .korean(korean) - .example(example) - .level(level) - .category(category) - .createdAt(now) - .build(); - - wordRepository.save(word); - logger.info("Created word: {}", wordId); - - return word; - } - - public Word updateWord(String wordId, Map updates) { - Optional optWord = wordRepository.findById(wordId); - if (optWord.isEmpty()) { - throw new IllegalArgumentException("Word not found"); - } - - Word word = optWord.get(); - - if (updates.containsKey("english")) { - word.setEnglish((String) updates.get("english")); - } - if (updates.containsKey("korean")) { - word.setKorean((String) updates.get("korean")); - } - if (updates.containsKey("example")) { - word.setExample((String) updates.get("example")); - } - if (updates.containsKey("level")) { - String newLevel = (String) updates.get("level"); - word.setLevel(newLevel); - word.setGsi1pk(VocabKey.levelPk(newLevel)); - } - if (updates.containsKey("category")) { - String newCategory = (String) updates.get("category"); - word.setCategory(newCategory); - word.setGsi2pk(VocabKey.categoryPk(newCategory)); - } - - wordRepository.save(word); - logger.info("Updated word: {}", wordId); - - return word; - } - - public void deleteWord(String wordId) { - Optional optWord = wordRepository.findById(wordId); - if (optWord.isEmpty()) { - throw new IllegalArgumentException("Word not found"); - } - - wordRepository.delete(wordId); - logger.info("Deleted word: {}", wordId); - } - - public BatchResult createWordsBatch(List wordsList) { - String now = Instant.now().toString(); - List createdWords = new ArrayList<>(); - int successCount = 0; - int failCount = 0; - - for (CreateWordRequest wordData : wordsList) { - try { - String english = wordData.getEnglish(); - String korean = wordData.getKorean(); - String example = wordData.getExample(); - String level = wordData.getLevel() != null ? wordData.getLevel() : "BEGINNER"; - String category = wordData.getCategory() != null ? wordData.getCategory() : "DAILY"; - - if (english == null || korean == null) { - failCount++; - continue; - } - - String wordId = UUID.randomUUID().toString(); - - Word word = Word.builder() - .pk(VocabKey.wordPk(wordId)) - .sk(DynamoDbKey.METADATA) - .gsi1pk(VocabKey.levelPk(level)) - .gsi1sk(VocabKey.wordSk(wordId)) - .gsi2pk(VocabKey.categoryPk(category)) - .gsi2sk(VocabKey.wordSk(wordId)) - .wordId(wordId) - .english(english) - .korean(korean) - .example(example) - .level(level) - .category(category) - .createdAt(now) - .build(); - - wordRepository.save(word); - createdWords.add(word); - successCount++; - } catch (Exception e) { - logger.error("Failed to create word", e); - failCount++; - } - } - - logger.info("Batch created {} words, failed {}", successCount, failCount); - return new BatchResult(successCount, failCount, wordsList.size()); - } - - public record BatchResult(int successCount, int failCount, int totalRequested) {} + + private static final Logger logger = LoggerFactory.getLogger(WordCommandService.class); + + private final WordRepository wordRepository; + + public WordCommandService() { + this.wordRepository = new WordRepository(); + } + + public Word createWord(String english, String korean, String example, String level, String category) { + String wordId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + + Word word = Word.builder() + .pk(VocabKey.wordPk(wordId)) + .sk(DynamoDbKey.METADATA) + .gsi1pk(VocabKey.levelPk(level)) + .gsi1sk(VocabKey.wordSk(wordId)) + .gsi2pk(VocabKey.categoryPk(category)) + .gsi2sk(VocabKey.wordSk(wordId)) + .wordId(wordId) + .english(english) + .korean(korean) + .example(example) + .level(level) + .category(category) + .createdAt(now) + .build(); + + wordRepository.save(word); + logger.info("Created word: {}", wordId); + + return word; + } + + public Word updateWord(String wordId, Map updates) { + Optional optWord = wordRepository.findById(wordId); + if (optWord.isEmpty()) { + throw new IllegalArgumentException("Word not found"); + } + + Word word = optWord.get(); + + if (updates.containsKey("english")) { + word.setEnglish((String) updates.get("english")); + } + if (updates.containsKey("korean")) { + word.setKorean((String) updates.get("korean")); + } + if (updates.containsKey("example")) { + word.setExample((String) updates.get("example")); + } + if (updates.containsKey("level")) { + String newLevel = (String) updates.get("level"); + word.setLevel(newLevel); + word.setGsi1pk(VocabKey.levelPk(newLevel)); + } + if (updates.containsKey("category")) { + String newCategory = (String) updates.get("category"); + word.setCategory(newCategory); + word.setGsi2pk(VocabKey.categoryPk(newCategory)); + } + + wordRepository.save(word); + logger.info("Updated word: {}", wordId); + + return word; + } + + public void deleteWord(String wordId) { + Optional optWord = wordRepository.findById(wordId); + if (optWord.isEmpty()) { + throw new IllegalArgumentException("Word not found"); + } + + wordRepository.delete(wordId); + logger.info("Deleted word: {}", wordId); + } + + public BatchResult createWordsBatch(List wordsList) { + String now = Instant.now().toString(); + List createdWords = new ArrayList<>(); + int successCount = 0; + int failCount = 0; + + for (CreateWordRequest wordData : wordsList) { + try { + String english = wordData.getEnglish(); + String korean = wordData.getKorean(); + String example = wordData.getExample(); + String level = wordData.getLevel() != null ? wordData.getLevel() : "BEGINNER"; + String category = wordData.getCategory() != null ? wordData.getCategory() : "DAILY"; + + if (english == null || korean == null) { + failCount++; + continue; + } + + String wordId = UUID.randomUUID().toString(); + + Word word = Word.builder() + .pk(VocabKey.wordPk(wordId)) + .sk(DynamoDbKey.METADATA) + .gsi1pk(VocabKey.levelPk(level)) + .gsi1sk(VocabKey.wordSk(wordId)) + .gsi2pk(VocabKey.categoryPk(category)) + .gsi2sk(VocabKey.wordSk(wordId)) + .wordId(wordId) + .english(english) + .korean(korean) + .example(example) + .level(level) + .category(category) + .createdAt(now) + .build(); + + wordRepository.save(word); + createdWords.add(word); + successCount++; + } catch (Exception e) { + logger.error("Failed to create word", e); + failCount++; + } + } + + logger.info("Batch created {} words, failed {}", successCount, failCount); + return new BatchResult(successCount, failCount, wordsList.size()); + } + + public record BatchResult(int successCount, int failCount, int totalRequested) { + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordGroupCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordGroupCommandService.java index 6d6edb5a..018405b5 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordGroupCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordGroupCommandService.java @@ -16,109 +16,109 @@ * WordGroup 변경 전용 서비스 (CQRS Command) */ public class WordGroupCommandService { - - private static final Logger logger = LoggerFactory.getLogger(WordGroupCommandService.class); - - private final WordGroupRepository wordGroupRepository; - - public WordGroupCommandService() { - this.wordGroupRepository = new WordGroupRepository(); - } - - public WordGroup createGroup(String userId, String groupName, String description) { - String groupId = UUID.randomUUID().toString(); - String now = Instant.now().toString(); - - WordGroup wordGroup = WordGroup.builder() - .pk("USER#" + userId + "#GROUP") - .sk("GROUP#" + groupId) - .groupId(groupId) - .userId(userId) - .groupName(groupName) - .description(description) - .wordIds(new ArrayList<>()) - .wordCount(0) - .createdAt(now) - .updatedAt(now) - .build(); - - wordGroupRepository.save(wordGroup); - logger.info("Created word group: userId={}, groupId={}, name={}", userId, groupId, groupName); - - return wordGroup; - } - - public WordGroup updateGroup(String userId, String groupId, String groupName, String description) { - Optional optGroup = wordGroupRepository.findByUserIdAndGroupId(userId, groupId); - if (optGroup.isEmpty()) { - throw VocabularyException.groupNotFound(groupId); - } - - WordGroup group = optGroup.get(); - if (groupName != null) { - group.setGroupName(groupName); - } - if (description != null) { - group.setDescription(description); - } - group.setUpdatedAt(Instant.now().toString()); - - wordGroupRepository.save(group); - logger.info("Updated word group: userId={}, groupId={}", userId, groupId); - - return group; - } - - public void deleteGroup(String userId, String groupId) { - Optional optGroup = wordGroupRepository.findByUserIdAndGroupId(userId, groupId); - if (optGroup.isEmpty()) { - throw VocabularyException.groupNotFound(groupId); - } - - wordGroupRepository.delete(userId, groupId); - logger.info("Deleted word group: userId={}, groupId={}", userId, groupId); - } - - public WordGroup addWordToGroup(String userId, String groupId, String wordId) { - Optional optGroup = wordGroupRepository.findByUserIdAndGroupId(userId, groupId); - if (optGroup.isEmpty()) { - throw VocabularyException.groupNotFound(groupId); - } - - WordGroup group = optGroup.get(); - List wordIds = group.getWordIds(); - if (wordIds == null) { - wordIds = new ArrayList<>(); - } - - if (!wordIds.contains(wordId)) { - wordIds.add(wordId); - group.setWordIds(wordIds); - group.setWordCount(wordIds.size()); - group.setUpdatedAt(Instant.now().toString()); - wordGroupRepository.save(group); - logger.info("Added word to group: userId={}, groupId={}, wordId={}", userId, groupId, wordId); - } - - return group; - } - - public WordGroup removeWordFromGroup(String userId, String groupId, String wordId) { - Optional optGroup = wordGroupRepository.findByUserIdAndGroupId(userId, groupId); - if (optGroup.isEmpty()) { - throw VocabularyException.groupNotFound(groupId); - } - - WordGroup group = optGroup.get(); - List wordIds = group.getWordIds(); - if (wordIds != null && wordIds.remove(wordId)) { - group.setWordIds(wordIds); - group.setWordCount(wordIds.size()); - group.setUpdatedAt(Instant.now().toString()); - wordGroupRepository.save(group); - logger.info("Removed word from group: userId={}, groupId={}, wordId={}", userId, groupId, wordId); - } - - return group; - } + + private static final Logger logger = LoggerFactory.getLogger(WordGroupCommandService.class); + + private final WordGroupRepository wordGroupRepository; + + public WordGroupCommandService() { + this.wordGroupRepository = new WordGroupRepository(); + } + + public WordGroup createGroup(String userId, String groupName, String description) { + String groupId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + + WordGroup wordGroup = WordGroup.builder() + .pk("USER#" + userId + "#GROUP") + .sk("GROUP#" + groupId) + .groupId(groupId) + .userId(userId) + .groupName(groupName) + .description(description) + .wordIds(new ArrayList<>()) + .wordCount(0) + .createdAt(now) + .updatedAt(now) + .build(); + + wordGroupRepository.save(wordGroup); + logger.info("Created word group: userId={}, groupId={}, name={}", userId, groupId, groupName); + + return wordGroup; + } + + public WordGroup updateGroup(String userId, String groupId, String groupName, String description) { + Optional optGroup = wordGroupRepository.findByUserIdAndGroupId(userId, groupId); + if (optGroup.isEmpty()) { + throw VocabularyException.groupNotFound(groupId); + } + + WordGroup group = optGroup.get(); + if (groupName != null) { + group.setGroupName(groupName); + } + if (description != null) { + group.setDescription(description); + } + group.setUpdatedAt(Instant.now().toString()); + + wordGroupRepository.save(group); + logger.info("Updated word group: userId={}, groupId={}", userId, groupId); + + return group; + } + + public void deleteGroup(String userId, String groupId) { + Optional optGroup = wordGroupRepository.findByUserIdAndGroupId(userId, groupId); + if (optGroup.isEmpty()) { + throw VocabularyException.groupNotFound(groupId); + } + + wordGroupRepository.delete(userId, groupId); + logger.info("Deleted word group: userId={}, groupId={}", userId, groupId); + } + + public WordGroup addWordToGroup(String userId, String groupId, String wordId) { + Optional optGroup = wordGroupRepository.findByUserIdAndGroupId(userId, groupId); + if (optGroup.isEmpty()) { + throw VocabularyException.groupNotFound(groupId); + } + + WordGroup group = optGroup.get(); + List wordIds = group.getWordIds(); + if (wordIds == null) { + wordIds = new ArrayList<>(); + } + + if (!wordIds.contains(wordId)) { + wordIds.add(wordId); + group.setWordIds(wordIds); + group.setWordCount(wordIds.size()); + group.setUpdatedAt(Instant.now().toString()); + wordGroupRepository.save(group); + logger.info("Added word to group: userId={}, groupId={}, wordId={}", userId, groupId, wordId); + } + + return group; + } + + public WordGroup removeWordFromGroup(String userId, String groupId, String wordId) { + Optional optGroup = wordGroupRepository.findByUserIdAndGroupId(userId, groupId); + if (optGroup.isEmpty()) { + throw VocabularyException.groupNotFound(groupId); + } + + WordGroup group = optGroup.get(); + List wordIds = group.getWordIds(); + if (wordIds != null && wordIds.remove(wordId)) { + group.setWordIds(wordIds); + group.setWordCount(wordIds.size()); + group.setUpdatedAt(Instant.now().toString()); + wordGroupRepository.save(group); + logger.info("Removed word from group: userId={}, groupId={}, wordId={}", userId, groupId, wordId); + } + + return group; + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordGroupQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordGroupQueryService.java index 9cbf6080..0cc86c3c 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordGroupQueryService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordGroupQueryService.java @@ -15,40 +15,41 @@ * WordGroup 조회 전용 서비스 (CQRS Query) */ public class WordGroupQueryService { - - private static final Logger logger = LoggerFactory.getLogger(WordGroupQueryService.class); - - private final WordGroupRepository wordGroupRepository; - private final WordRepository wordRepository; - - public WordGroupQueryService() { - this.wordGroupRepository = new WordGroupRepository(); - this.wordRepository = new WordRepository(); - } - - public PaginatedResult getGroups(String userId, int limit, String cursor) { - return wordGroupRepository.findByUserId(userId, limit, cursor); - } - - public Optional getGroup(String userId, String groupId) { - return wordGroupRepository.findByUserIdAndGroupId(userId, groupId); - } - - public Optional getGroupDetail(String userId, String groupId) { - Optional optGroup = wordGroupRepository.findByUserIdAndGroupId(userId, groupId); - if (optGroup.isEmpty()) { - return Optional.empty(); - } - - WordGroup group = optGroup.get(); - List words = List.of(); - - if (group.getWordIds() != null && !group.getWordIds().isEmpty()) { - words = wordRepository.findByIds(group.getWordIds()); - } - - return Optional.of(new WordGroupDetail(group, words)); - } - - public record WordGroupDetail(WordGroup group, List words) {} + + private static final Logger logger = LoggerFactory.getLogger(WordGroupQueryService.class); + + private final WordGroupRepository wordGroupRepository; + private final WordRepository wordRepository; + + public WordGroupQueryService() { + this.wordGroupRepository = new WordGroupRepository(); + this.wordRepository = new WordRepository(); + } + + public PaginatedResult getGroups(String userId, int limit, String cursor) { + return wordGroupRepository.findByUserId(userId, limit, cursor); + } + + public Optional getGroup(String userId, String groupId) { + return wordGroupRepository.findByUserIdAndGroupId(userId, groupId); + } + + public Optional getGroupDetail(String userId, String groupId) { + Optional optGroup = wordGroupRepository.findByUserIdAndGroupId(userId, groupId); + if (optGroup.isEmpty()) { + return Optional.empty(); + } + + WordGroup group = optGroup.get(); + List words = List.of(); + + if (group.getWordIds() != null && !group.getWordIds().isEmpty()) { + words = wordRepository.findByIds(group.getWordIds()); + } + + return Optional.of(new WordGroupDetail(group, words)); + } + + public record WordGroupDetail(WordGroup group, List words) { + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordQueryService.java index c0f0fb4c..a77cac3c 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordQueryService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordQueryService.java @@ -13,33 +13,33 @@ * Word 조회 전용 서비스 (CQRS Query) */ public class WordQueryService { - - private static final Logger logger = LoggerFactory.getLogger(WordQueryService.class); - - private final WordRepository wordRepository; - - public WordQueryService() { - this.wordRepository = new WordRepository(); - } - - public Optional getWord(String wordId) { - return wordRepository.findById(wordId); - } - - public PaginatedResult getWords(String level, String category, int limit, String cursor) { - if (level != null && !level.isEmpty()) { - return wordRepository.findByLevelWithPagination(level, limit, cursor); - } else if (category != null && !category.isEmpty()) { - return wordRepository.findByCategoryWithPagination(category, limit, cursor); - } - return wordRepository.findByLevelWithPagination("BEGINNER", limit, cursor); - } - - public PaginatedResult searchWords(String query, int limit, String cursor) { - return wordRepository.searchByKeyword(query, limit, cursor); - } - - public List getWordsByIds(List wordIds) { - return wordRepository.findByIds(wordIds); - } + + private static final Logger logger = LoggerFactory.getLogger(WordQueryService.class); + + private final WordRepository wordRepository; + + public WordQueryService() { + this.wordRepository = new WordRepository(); + } + + public Optional getWord(String wordId) { + return wordRepository.findById(wordId); + } + + public PaginatedResult getWords(String level, String category, int limit, String cursor) { + if (level != null && !level.isEmpty()) { + return wordRepository.findByLevelWithPagination(level, limit, cursor); + } else if (category != null && !category.isEmpty()) { + return wordRepository.findByCategoryWithPagination(category, limit, cursor); + } + return wordRepository.findByLevelWithPagination("BEGINNER", limit, cursor); + } + + public PaginatedResult searchWords(String query, int limit, String cursor) { + return wordRepository.searchByKeyword(query, limit, cursor); + } + + public List getWordsByIds(List wordIds) { + return wordRepository.findByIds(wordIds); + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordService.java index 84f7473a..fec9e7a9 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordService.java @@ -7,158 +7,155 @@ import org.slf4j.LoggerFactory; import java.time.Instant; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.UUID; +import java.util.*; public class WordService { - - private static final Logger logger = LoggerFactory.getLogger(WordService.class); - - private final WordRepository wordRepository; - - public WordService() { - this.wordRepository = new WordRepository(); - } - - public Word createWord(String english, String korean, String example, String level, String category) { - String wordId = UUID.randomUUID().toString(); - String now = Instant.now().toString(); - - Word word = Word.builder() - .pk("WORD#" + wordId) - .sk("METADATA") - .gsi1pk("LEVEL#" + level) - .gsi1sk("WORD#" + wordId) - .gsi2pk("CATEGORY#" + category) - .gsi2sk("WORD#" + wordId) - .wordId(wordId) - .english(english) - .korean(korean) - .example(example) - .level(level) - .category(category) - .createdAt(now) - .build(); - - wordRepository.save(word); - logger.info("Created word: {}", wordId); - - return word; - } - - public Optional getWord(String wordId) { - return wordRepository.findById(wordId); - } - - public PaginatedResult getWords(String level, String category, int limit, String cursor) { - if (level != null && !level.isEmpty()) { - return wordRepository.findByLevelWithPagination(level, limit, cursor); - } else if (category != null && !category.isEmpty()) { - return wordRepository.findByCategoryWithPagination(category, limit, cursor); - } - return wordRepository.findByLevelWithPagination("BEGINNER", limit, cursor); - } - - public Word updateWord(String wordId, Map updates) { - Optional optWord = wordRepository.findById(wordId); - if (optWord.isEmpty()) { - throw new IllegalArgumentException("Word not found"); - } - - Word word = optWord.get(); - - if (updates.containsKey("english")) { - word.setEnglish((String) updates.get("english")); - } - if (updates.containsKey("korean")) { - word.setKorean((String) updates.get("korean")); - } - if (updates.containsKey("example")) { - word.setExample((String) updates.get("example")); - } - if (updates.containsKey("level")) { - String newLevel = (String) updates.get("level"); - word.setLevel(newLevel); - word.setGsi1pk("LEVEL#" + newLevel); - } - if (updates.containsKey("category")) { - String newCategory = (String) updates.get("category"); - word.setCategory(newCategory); - word.setGsi2pk("CATEGORY#" + newCategory); - } - - wordRepository.save(word); - logger.info("Updated word: {}", wordId); - - return word; - } - - public void deleteWord(String wordId) { - Optional optWord = wordRepository.findById(wordId); - if (optWord.isEmpty()) { - throw new IllegalArgumentException("Word not found"); - } - - wordRepository.delete(wordId); - logger.info("Deleted word: {}", wordId); - } - - public BatchResult createWordsBatch(List> wordsList) { - String now = Instant.now().toString(); - List createdWords = new ArrayList<>(); - int successCount = 0; - int failCount = 0; - - for (Map wordData : wordsList) { - try { - String english = (String) wordData.get("english"); - String korean = (String) wordData.get("korean"); - String example = (String) wordData.get("example"); - String level = (String) wordData.getOrDefault("level", "BEGINNER"); - String category = (String) wordData.getOrDefault("category", "DAILY"); - - if (english == null || korean == null) { - failCount++; - continue; - } - - String wordId = UUID.randomUUID().toString(); - - Word word = Word.builder() - .pk("WORD#" + wordId) - .sk("METADATA") - .gsi1pk("LEVEL#" + level) - .gsi1sk("WORD#" + wordId) - .gsi2pk("CATEGORY#" + category) - .gsi2sk("WORD#" + wordId) - .wordId(wordId) - .english(english) - .korean(korean) - .example(example) - .level(level) - .category(category) - .createdAt(now) - .build(); - - wordRepository.save(word); - createdWords.add(word); - successCount++; - } catch (Exception e) { - logger.error("Failed to create word", e); - failCount++; - } - } - - logger.info("Batch created {} words, failed {}", successCount, failCount); - return new BatchResult(successCount, failCount, wordsList.size()); - } - - public PaginatedResult searchWords(String query, int limit, String cursor) { - return wordRepository.searchByKeyword(query, limit, cursor); - } - - public record BatchResult(int successCount, int failCount, int totalRequested) {} + + private static final Logger logger = LoggerFactory.getLogger(WordService.class); + + private final WordRepository wordRepository; + + public WordService() { + this.wordRepository = new WordRepository(); + } + + public Word createWord(String english, String korean, String example, String level, String category) { + String wordId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + + Word word = Word.builder() + .pk("WORD#" + wordId) + .sk("METADATA") + .gsi1pk("LEVEL#" + level) + .gsi1sk("WORD#" + wordId) + .gsi2pk("CATEGORY#" + category) + .gsi2sk("WORD#" + wordId) + .wordId(wordId) + .english(english) + .korean(korean) + .example(example) + .level(level) + .category(category) + .createdAt(now) + .build(); + + wordRepository.save(word); + logger.info("Created word: {}", wordId); + + return word; + } + + public Optional getWord(String wordId) { + return wordRepository.findById(wordId); + } + + public PaginatedResult getWords(String level, String category, int limit, String cursor) { + if (level != null && !level.isEmpty()) { + return wordRepository.findByLevelWithPagination(level, limit, cursor); + } else if (category != null && !category.isEmpty()) { + return wordRepository.findByCategoryWithPagination(category, limit, cursor); + } + return wordRepository.findByLevelWithPagination("BEGINNER", limit, cursor); + } + + public Word updateWord(String wordId, Map updates) { + Optional optWord = wordRepository.findById(wordId); + if (optWord.isEmpty()) { + throw new IllegalArgumentException("Word not found"); + } + + Word word = optWord.get(); + + if (updates.containsKey("english")) { + word.setEnglish((String) updates.get("english")); + } + if (updates.containsKey("korean")) { + word.setKorean((String) updates.get("korean")); + } + if (updates.containsKey("example")) { + word.setExample((String) updates.get("example")); + } + if (updates.containsKey("level")) { + String newLevel = (String) updates.get("level"); + word.setLevel(newLevel); + word.setGsi1pk("LEVEL#" + newLevel); + } + if (updates.containsKey("category")) { + String newCategory = (String) updates.get("category"); + word.setCategory(newCategory); + word.setGsi2pk("CATEGORY#" + newCategory); + } + + wordRepository.save(word); + logger.info("Updated word: {}", wordId); + + return word; + } + + public void deleteWord(String wordId) { + Optional optWord = wordRepository.findById(wordId); + if (optWord.isEmpty()) { + throw new IllegalArgumentException("Word not found"); + } + + wordRepository.delete(wordId); + logger.info("Deleted word: {}", wordId); + } + + public BatchResult createWordsBatch(List> wordsList) { + String now = Instant.now().toString(); + List createdWords = new ArrayList<>(); + int successCount = 0; + int failCount = 0; + + for (Map wordData : wordsList) { + try { + String english = (String) wordData.get("english"); + String korean = (String) wordData.get("korean"); + String example = (String) wordData.get("example"); + String level = (String) wordData.getOrDefault("level", "BEGINNER"); + String category = (String) wordData.getOrDefault("category", "DAILY"); + + if (english == null || korean == null) { + failCount++; + continue; + } + + String wordId = UUID.randomUUID().toString(); + + Word word = Word.builder() + .pk("WORD#" + wordId) + .sk("METADATA") + .gsi1pk("LEVEL#" + level) + .gsi1sk("WORD#" + wordId) + .gsi2pk("CATEGORY#" + category) + .gsi2sk("WORD#" + wordId) + .wordId(wordId) + .english(english) + .korean(korean) + .example(example) + .level(level) + .category(category) + .createdAt(now) + .build(); + + wordRepository.save(word); + createdWords.add(word); + successCount++; + } catch (Exception e) { + logger.error("Failed to create word", e); + failCount++; + } + } + + logger.info("Batch created {} words, failed {}", successCount, failCount); + return new BatchResult(successCount, failCount, wordsList.size()); + } + + public PaginatedResult searchWords(String query, int limit, String cursor) { + return wordRepository.searchByKeyword(query, limit, cursor); + } + + public record BatchResult(int successCount, int failCount, int totalRequested) { + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/state/LearningState.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/state/LearningState.java index 23568c56..58de229c 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/state/LearningState.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/state/LearningState.java @@ -8,53 +8,54 @@ * repetitions >= 2 시 REVIEWING으로 전이. */ public class LearningState implements WordState { - - private static final LearningState INSTANCE = new LearningState(); - private static final int TRANSITION_TO_REVIEWING_THRESHOLD = 2; - private static final int SECOND_INTERVAL_DAYS = 6; - - private LearningState() {} - - public static LearningState getInstance() { - return INSTANCE; - } - - @Override - public WordState onCorrectAnswer(SpacedRepetitionContext context) { - context.incrementCorrectCount(); - context.incrementRepetitions(); - - int repetitions = context.getRepetitions(); - if (repetitions == 1) { - context.updateInterval(StudyConfig.INITIAL_INTERVAL_DAYS); - } else if (repetitions == 2) { - context.updateInterval(SECOND_INTERVAL_DAYS); - } else { - context.updateInterval(context.calculateNextInterval()); - } - - if (repetitions >= TRANSITION_TO_REVIEWING_THRESHOLD) { - return ReviewingState.getInstance(); - } - return this; - } - - @Override - public WordState onWrongAnswer(SpacedRepetitionContext context) { - context.incrementIncorrectCount(); - context.resetRepetitions(); - context.resetInterval(); - context.decreaseEaseFactor(); - return this; - } - - @Override - public int getIntervalDays(SpacedRepetitionContext context) { - return context.getInterval(); - } - - @Override - public String getStateName() { - return WordStatus.LEARNING.name(); - } + + private static final LearningState INSTANCE = new LearningState(); + private static final int TRANSITION_TO_REVIEWING_THRESHOLD = 2; + private static final int SECOND_INTERVAL_DAYS = 6; + + private LearningState() { + } + + public static LearningState getInstance() { + return INSTANCE; + } + + @Override + public WordState onCorrectAnswer(SpacedRepetitionContext context) { + context.incrementCorrectCount(); + context.incrementRepetitions(); + + int repetitions = context.getRepetitions(); + if (repetitions == 1) { + context.updateInterval(StudyConfig.INITIAL_INTERVAL_DAYS); + } else if (repetitions == 2) { + context.updateInterval(SECOND_INTERVAL_DAYS); + } else { + context.updateInterval(context.calculateNextInterval()); + } + + if (repetitions >= TRANSITION_TO_REVIEWING_THRESHOLD) { + return ReviewingState.getInstance(); + } + return this; + } + + @Override + public WordState onWrongAnswer(SpacedRepetitionContext context) { + context.incrementIncorrectCount(); + context.resetRepetitions(); + context.resetInterval(); + context.decreaseEaseFactor(); + return this; + } + + @Override + public int getIntervalDays(SpacedRepetitionContext context) { + return context.getInterval(); + } + + @Override + public String getStateName() { + return WordStatus.LEARNING.name(); + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/state/MasteredState.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/state/MasteredState.java index 080f8cb2..192de2c5 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/state/MasteredState.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/state/MasteredState.java @@ -1,6 +1,5 @@ package com.mzc.secondproject.serverless.domain.vocabulary.state; -import com.mzc.secondproject.serverless.common.config.StudyConfig; import com.mzc.secondproject.serverless.domain.vocabulary.enums.WordStatus; /** @@ -8,39 +7,40 @@ * 정답 시 상태 유지, 오답 시 REVIEWING으로 강등. */ public class MasteredState implements WordState { - - private static final MasteredState INSTANCE = new MasteredState(); - - private MasteredState() {} - - public static MasteredState getInstance() { - return INSTANCE; - } - - @Override - public WordState onCorrectAnswer(SpacedRepetitionContext context) { - context.incrementCorrectCount(); - context.incrementRepetitions(); - context.updateInterval(context.calculateNextInterval()); - return this; - } - - @Override - public WordState onWrongAnswer(SpacedRepetitionContext context) { - context.incrementIncorrectCount(); - context.resetRepetitions(); - context.resetInterval(); - context.decreaseEaseFactor(); - return ReviewingState.getInstance(); - } - - @Override - public int getIntervalDays(SpacedRepetitionContext context) { - return context.getInterval(); - } - - @Override - public String getStateName() { - return WordStatus.MASTERED.name(); - } + + private static final MasteredState INSTANCE = new MasteredState(); + + private MasteredState() { + } + + public static MasteredState getInstance() { + return INSTANCE; + } + + @Override + public WordState onCorrectAnswer(SpacedRepetitionContext context) { + context.incrementCorrectCount(); + context.incrementRepetitions(); + context.updateInterval(context.calculateNextInterval()); + return this; + } + + @Override + public WordState onWrongAnswer(SpacedRepetitionContext context) { + context.incrementIncorrectCount(); + context.resetRepetitions(); + context.resetInterval(); + context.decreaseEaseFactor(); + return ReviewingState.getInstance(); + } + + @Override + public int getIntervalDays(SpacedRepetitionContext context) { + return context.getInterval(); + } + + @Override + public String getStateName() { + return WordStatus.MASTERED.name(); + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/state/NewState.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/state/NewState.java index ea6df113..84c85fd0 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/state/NewState.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/state/NewState.java @@ -8,39 +8,40 @@ * 첫 정답 시 LEARNING으로 전이. */ public class NewState implements WordState { - - private static final NewState INSTANCE = new NewState(); - - private NewState() {} - - public static NewState getInstance() { - return INSTANCE; - } - - @Override - public WordState onCorrectAnswer(SpacedRepetitionContext context) { - context.incrementCorrectCount(); - context.incrementRepetitions(); - context.updateInterval(StudyConfig.INITIAL_INTERVAL_DAYS); - return LearningState.getInstance(); - } - - @Override - public WordState onWrongAnswer(SpacedRepetitionContext context) { - context.incrementIncorrectCount(); - context.resetRepetitions(); - context.resetInterval(); - context.decreaseEaseFactor(); - return LearningState.getInstance(); - } - - @Override - public int getIntervalDays(SpacedRepetitionContext context) { - return StudyConfig.INITIAL_INTERVAL_DAYS; - } - - @Override - public String getStateName() { - return WordStatus.NEW.name(); - } + + private static final NewState INSTANCE = new NewState(); + + private NewState() { + } + + public static NewState getInstance() { + return INSTANCE; + } + + @Override + public WordState onCorrectAnswer(SpacedRepetitionContext context) { + context.incrementCorrectCount(); + context.incrementRepetitions(); + context.updateInterval(StudyConfig.INITIAL_INTERVAL_DAYS); + return LearningState.getInstance(); + } + + @Override + public WordState onWrongAnswer(SpacedRepetitionContext context) { + context.incrementIncorrectCount(); + context.resetRepetitions(); + context.resetInterval(); + context.decreaseEaseFactor(); + return LearningState.getInstance(); + } + + @Override + public int getIntervalDays(SpacedRepetitionContext context) { + return StudyConfig.INITIAL_INTERVAL_DAYS; + } + + @Override + public String getStateName() { + return WordStatus.NEW.name(); + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/state/ReviewingState.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/state/ReviewingState.java index e87a8c56..67ef0605 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/state/ReviewingState.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/state/ReviewingState.java @@ -1,6 +1,5 @@ package com.mzc.secondproject.serverless.domain.vocabulary.state; -import com.mzc.secondproject.serverless.common.config.StudyConfig; import com.mzc.secondproject.serverless.domain.vocabulary.enums.WordStatus; /** @@ -8,44 +7,45 @@ * repetitions >= 5 시 MASTERED로 전이. */ public class ReviewingState implements WordState { - - private static final ReviewingState INSTANCE = new ReviewingState(); - private static final int TRANSITION_TO_MASTERED_THRESHOLD = 5; - - private ReviewingState() {} - - public static ReviewingState getInstance() { - return INSTANCE; - } - - @Override - public WordState onCorrectAnswer(SpacedRepetitionContext context) { - context.incrementCorrectCount(); - context.incrementRepetitions(); - context.updateInterval(context.calculateNextInterval()); - - if (context.getRepetitions() >= TRANSITION_TO_MASTERED_THRESHOLD) { - return MasteredState.getInstance(); - } - return this; - } - - @Override - public WordState onWrongAnswer(SpacedRepetitionContext context) { - context.incrementIncorrectCount(); - context.resetRepetitions(); - context.resetInterval(); - context.decreaseEaseFactor(); - return LearningState.getInstance(); - } - - @Override - public int getIntervalDays(SpacedRepetitionContext context) { - return context.getInterval(); - } - - @Override - public String getStateName() { - return WordStatus.REVIEWING.name(); - } + + private static final ReviewingState INSTANCE = new ReviewingState(); + private static final int TRANSITION_TO_MASTERED_THRESHOLD = 5; + + private ReviewingState() { + } + + public static ReviewingState getInstance() { + return INSTANCE; + } + + @Override + public WordState onCorrectAnswer(SpacedRepetitionContext context) { + context.incrementCorrectCount(); + context.incrementRepetitions(); + context.updateInterval(context.calculateNextInterval()); + + if (context.getRepetitions() >= TRANSITION_TO_MASTERED_THRESHOLD) { + return MasteredState.getInstance(); + } + return this; + } + + @Override + public WordState onWrongAnswer(SpacedRepetitionContext context) { + context.incrementIncorrectCount(); + context.resetRepetitions(); + context.resetInterval(); + context.decreaseEaseFactor(); + return LearningState.getInstance(); + } + + @Override + public int getIntervalDays(SpacedRepetitionContext context) { + return context.getInterval(); + } + + @Override + public String getStateName() { + return WordStatus.REVIEWING.name(); + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/state/SpacedRepetitionContext.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/state/SpacedRepetitionContext.java index 5565f19a..ae622e78 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/state/SpacedRepetitionContext.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/state/SpacedRepetitionContext.java @@ -7,81 +7,81 @@ * State 객체가 상태 전이 및 간격 계산 시 참조한다. */ public class SpacedRepetitionContext { - - private int repetitions; - private int interval; - private double easeFactor; - private int correctCount; - private int incorrectCount; - - public SpacedRepetitionContext() { - this.repetitions = StudyConfig.INITIAL_REPETITIONS; - this.interval = StudyConfig.INITIAL_INTERVAL_DAYS; - this.easeFactor = StudyConfig.DEFAULT_EASE_FACTOR; - this.correctCount = StudyConfig.INITIAL_CORRECT_COUNT; - this.incorrectCount = StudyConfig.INITIAL_INCORRECT_COUNT; - } - - public SpacedRepetitionContext(int repetitions, int interval, double easeFactor, - int correctCount, int incorrectCount) { - this.repetitions = repetitions; - this.interval = interval; - this.easeFactor = easeFactor; - this.correctCount = correctCount; - this.incorrectCount = incorrectCount; - } - - public void incrementRepetitions() { - this.repetitions++; - } - - public void resetRepetitions() { - this.repetitions = StudyConfig.INITIAL_REPETITIONS; - } - - public void incrementCorrectCount() { - this.correctCount++; - } - - public void incrementIncorrectCount() { - this.incorrectCount++; - } - - public void updateInterval(int newInterval) { - this.interval = newInterval; - } - - public void resetInterval() { - this.interval = StudyConfig.INITIAL_INTERVAL_DAYS; - } - - public void decreaseEaseFactor() { - double newEaseFactor = this.easeFactor - 0.2; - this.easeFactor = Math.max(StudyConfig.MIN_EASE_FACTOR, newEaseFactor); - } - - public int calculateNextInterval() { - return (int) Math.round(this.interval * this.easeFactor); - } - - // Getters - public int getRepetitions() { - return repetitions; - } - - public int getInterval() { - return interval; - } - - public double getEaseFactor() { - return easeFactor; - } - - public int getCorrectCount() { - return correctCount; - } - - public int getIncorrectCount() { - return incorrectCount; - } + + private int repetitions; + private int interval; + private double easeFactor; + private int correctCount; + private int incorrectCount; + + public SpacedRepetitionContext() { + this.repetitions = StudyConfig.INITIAL_REPETITIONS; + this.interval = StudyConfig.INITIAL_INTERVAL_DAYS; + this.easeFactor = StudyConfig.DEFAULT_EASE_FACTOR; + this.correctCount = StudyConfig.INITIAL_CORRECT_COUNT; + this.incorrectCount = StudyConfig.INITIAL_INCORRECT_COUNT; + } + + public SpacedRepetitionContext(int repetitions, int interval, double easeFactor, + int correctCount, int incorrectCount) { + this.repetitions = repetitions; + this.interval = interval; + this.easeFactor = easeFactor; + this.correctCount = correctCount; + this.incorrectCount = incorrectCount; + } + + public void incrementRepetitions() { + this.repetitions++; + } + + public void resetRepetitions() { + this.repetitions = StudyConfig.INITIAL_REPETITIONS; + } + + public void incrementCorrectCount() { + this.correctCount++; + } + + public void incrementIncorrectCount() { + this.incorrectCount++; + } + + public void updateInterval(int newInterval) { + this.interval = newInterval; + } + + public void resetInterval() { + this.interval = StudyConfig.INITIAL_INTERVAL_DAYS; + } + + public void decreaseEaseFactor() { + double newEaseFactor = this.easeFactor - 0.2; + this.easeFactor = Math.max(StudyConfig.MIN_EASE_FACTOR, newEaseFactor); + } + + public int calculateNextInterval() { + return (int) Math.round(this.interval * this.easeFactor); + } + + // Getters + public int getRepetitions() { + return repetitions; + } + + public int getInterval() { + return interval; + } + + public double getEaseFactor() { + return easeFactor; + } + + public int getCorrectCount() { + return correctCount; + } + + public int getIncorrectCount() { + return incorrectCount; + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/state/WordState.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/state/WordState.java index 7ddbc4ae..28ae87e5 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/state/WordState.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/state/WordState.java @@ -5,31 +5,35 @@ * 각 상태는 정답/오답에 따른 상태 전이와 복습 간격을 결정한다. */ public interface WordState { - - /** - * 정답 시 다음 상태로 전이 - * @param context Spaced Repetition 컨텍스트 - * @return 전이된 상태 - */ - WordState onCorrectAnswer(SpacedRepetitionContext context); - - /** - * 오답 시 다음 상태로 전이 - * @param context Spaced Repetition 컨텍스트 - * @return 전이된 상태 - */ - WordState onWrongAnswer(SpacedRepetitionContext context); - - /** - * 현재 상태의 복습 간격(일) 반환 - * @param context Spaced Repetition 컨텍스트 - * @return 복습 간격 (일 단위) - */ - int getIntervalDays(SpacedRepetitionContext context); - - /** - * 상태 이름 반환 (DynamoDB 저장용) - * @return 상태 이름 (NEW, LEARNING, REVIEWING, MASTERED) - */ - String getStateName(); + + /** + * 정답 시 다음 상태로 전이 + * + * @param context Spaced Repetition 컨텍스트 + * @return 전이된 상태 + */ + WordState onCorrectAnswer(SpacedRepetitionContext context); + + /** + * 오답 시 다음 상태로 전이 + * + * @param context Spaced Repetition 컨텍스트 + * @return 전이된 상태 + */ + WordState onWrongAnswer(SpacedRepetitionContext context); + + /** + * 현재 상태의 복습 간격(일) 반환 + * + * @param context Spaced Repetition 컨텍스트 + * @return 복습 간격 (일 단위) + */ + int getIntervalDays(SpacedRepetitionContext context); + + /** + * 상태 이름 반환 (DynamoDB 저장용) + * + * @return 상태 이름 (NEW, LEARNING, REVIEWING, MASTERED) + */ + String getStateName(); } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/state/WordStateFactory.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/state/WordStateFactory.java index 47170334..1293685f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/state/WordStateFactory.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/state/WordStateFactory.java @@ -7,42 +7,46 @@ * 상태 이름(String)으로부터 적절한 State 객체를 반환한다. */ public final class WordStateFactory { - - private WordStateFactory() {} - - /** - * 상태 이름으로부터 WordState 인스턴스 반환 - * @param stateName 상태 이름 (NEW, LEARNING, REVIEWING, MASTERED) - * @return 해당 상태의 WordState 인스턴스 - */ - public static WordState fromString(String stateName) { - if (stateName == null) { - return NewState.getInstance(); - } - - WordStatus status = WordStatus.fromStringOrDefault(stateName, WordStatus.NEW); - return fromStatus(status); - } - - /** - * WordStatus enum으로부터 WordState 인스턴스 반환 - * @param status WordStatus enum - * @return 해당 상태의 WordState 인스턴스 - */ - public static WordState fromStatus(WordStatus status) { - return switch (status) { - case NEW, UNKNOWN -> NewState.getInstance(); - case LEARNING -> LearningState.getInstance(); - case REVIEWING -> ReviewingState.getInstance(); - case MASTERED -> MasteredState.getInstance(); - }; - } - - /** - * 기본 상태(NEW) 반환 - * @return NewState 인스턴스 - */ - public static WordState getInitialState() { - return NewState.getInstance(); - } + + private WordStateFactory() { + } + + /** + * 상태 이름으로부터 WordState 인스턴스 반환 + * + * @param stateName 상태 이름 (NEW, LEARNING, REVIEWING, MASTERED) + * @return 해당 상태의 WordState 인스턴스 + */ + public static WordState fromString(String stateName) { + if (stateName == null) { + return NewState.getInstance(); + } + + WordStatus status = WordStatus.fromStringOrDefault(stateName, WordStatus.NEW); + return fromStatus(status); + } + + /** + * WordStatus enum으로부터 WordState 인스턴스 반환 + * + * @param status WordStatus enum + * @return 해당 상태의 WordState 인스턴스 + */ + public static WordState fromStatus(WordStatus status) { + return switch (status) { + case NEW, UNKNOWN -> NewState.getInstance(); + case LEARNING -> LearningState.getInstance(); + case REVIEWING -> ReviewingState.getInstance(); + case MASTERED -> MasteredState.getInstance(); + }; + } + + /** + * 기본 상태(NEW) 반환 + * + * @return NewState 인스턴스 + */ + public static WordState getInitialState() { + return NewState.getInstance(); + } } diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/SampleSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/SampleSpec.groovy index e79f76ba..c1d66c28 100644 --- a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/SampleSpec.groovy +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/SampleSpec.groovy @@ -1,7 +1,6 @@ package com.mzc.secondproject.serverless import spock.lang.Specification -import spock.lang.Subject /** * Spock 테스트 환경 설정 확인을 위한 샘플 테스트 diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/vocabulary/state/WordStateSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/vocabulary/state/WordStateSpec.groovy index 20dff2a4..0c416d9e 100644 --- a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/vocabulary/state/WordStateSpec.groovy +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/vocabulary/state/WordStateSpec.groovy @@ -43,7 +43,7 @@ class WordStateSpec extends Specification { then: "LEARNING 상태로 전이" nextState instanceof LearningState - and: "오답 카운트 증가, easeFactor 감소" + and : "오답 카운트 증가, easeFactor 감소" context.getIncorrectCount() == 1 context.getEaseFactor() < initialEaseFactor } @@ -170,15 +170,15 @@ class WordStateSpec extends Specification { state.class == expectedType where: - stateName | expectedType - "NEW" | NewState - "LEARNING" | LearningState - "REVIEWING" | ReviewingState - "MASTERED" | MasteredState - "new" | NewState - "learning" | LearningState - null | NewState - "INVALID" | NewState + stateName | expectedType + "NEW" | NewState + "LEARNING" | LearningState + "REVIEWING" | ReviewingState + "MASTERED" | MasteredState + "new" | NewState + "learning" | LearningState + null | NewState + "INVALID" | NewState } // ==================== Interval Calculation Tests ==================== diff --git a/docs/IMPROVEMENT-GUIDE.md b/docs/IMPROVEMENT-GUIDE.md index 3d6f08be..699294cf 100644 --- a/docs/IMPROVEMENT-GUIDE.md +++ b/docs/IMPROVEMENT-GUIDE.md @@ -131,12 +131,12 @@ if (!StudyLevel.isValid(level)) { ### 1.2 하드코딩된 값들 -| 위치 | 하드코딩 값 | 권장 | -|------|-----------|------| -| `UserWordService.java` | `"USER#"`, `"WORD#"`, `"DATE#"` | DynamoDbKeyPrefix 클래스 | -| `DailyStudyService.java` | `NEW_WORDS_COUNT = 50` | Config 클래스 | -| `ChatRoomHandler.java` | `"beginner"`, `6`, `false` | ChatRoomDefaults 클래스 | -| `UserWordRepository.java` | `limit * 3` | 상수로 추출 | +| 위치 | 하드코딩 값 | 권장 | +|---------------------------|---------------------------------|-----------------------| +| `UserWordService.java` | `"USER#"`, `"WORD#"`, `"DATE#"` | DynamoDbKeyPrefix 클래스 | +| `DailyStudyService.java` | `NEW_WORDS_COUNT = 50` | Config 클래스 | +| `ChatRoomHandler.java` | `"beginner"`, `6`, `false` | ChatRoomDefaults 클래스 | +| `UserWordRepository.java` | `limit * 3` | 상수로 추출 | **개선안**: 상수 클래스 생성 @@ -233,6 +233,7 @@ flowchart LR #### CQRS (Command Query Responsibility Segregation) **잘 적용됨**: + - `UserWordCommandService` / `UserWordQueryService` - `WordCommandService` / `WordQueryService` - `TestCommandService` / `TestQueryService` @@ -531,6 +532,7 @@ public class WordCache { ``` **예상 효과**: + - DynamoDB RCU 30-40% 감소 - 응답 시간 50-70% 단축 @@ -848,29 +850,29 @@ public UserWord updateUserWord(String userId, String wordId, boolean isCorrect) ### 5.1 높음 우선순위 (즉시 적용) -| 항목 | 영향도 | 예상 소요 | -|------|--------|----------| -| Enum 도입 (StudyLevel, Difficulty, WordStatus) | 높음 | 4시간 | -| N+1 쿼리 최적화 (BatchGetItem, HashSet) | 높음 | 2시간 | -| 커스텀 예외 클래스 | 중간 | 1시간 | +| 항목 | 영향도 | 예상 소요 | +|----------------------------------------------|-----|-------| +| Enum 도입 (StudyLevel, Difficulty, WordStatus) | 높음 | 4시간 | +| N+1 쿼리 최적화 (BatchGetItem, HashSet) | 높음 | 2시간 | +| 커스텀 예외 클래스 | 중간 | 1시간 | ### 5.2 중간 우선순위 (중기 개선) -| 항목 | 영향도 | 예상 소요 | -|------|--------|----------| -| Factory Pattern (UserWord, Word) | 중간 | 2시간 | -| Word 캐싱 (Guava LoadingCache) | 높음 | 3시간 | -| 메서드 추출 (StatsService, TestService) | 중간 | 3시간 | -| ServiceContainer Singleton | 중간 | 2시간 | +| 항목 | 영향도 | 예상 소요 | +|------------------------------------|-----|-------| +| Factory Pattern (UserWord, Word) | 중간 | 2시간 | +| Word 캐싱 (Guava LoadingCache) | 높음 | 3시간 | +| 메서드 추출 (StatsService, TestService) | 중간 | 3시간 | +| ServiceContainer Singleton | 중간 | 2시간 | ### 5.3 낮음 우선순위 (장기 개선) -| 항목 | 영향도 | 예상 소요 | -|------|--------|----------| -| State Pattern (WordLearningState) | 낮음 | 5시간 | -| Specification Pattern (쿼리 추상화) | 낮음 | 4시간 | -| 구조화 로깅 | 낮음 | 2시간 | -| RequestValidator 확대 적용 | 낮음 | 2시간 | +| 항목 | 영향도 | 예상 소요 | +|-----------------------------------|-----|-------| +| State Pattern (WordLearningState) | 낮음 | 5시간 | +| Specification Pattern (쿼리 추상화) | 낮음 | 4시간 | +| 구조화 로깅 | 낮음 | 2시간 | +| RequestValidator 확대 적용 | 낮음 | 2시간 | --- @@ -895,12 +897,12 @@ flowchart LR Before --> After ``` -| 지표 | 현재 | 개선 후 | 감소율 | -|------|------|--------|--------| -| DynamoDB RCU | 100 | 40-50 | 50-60% | -| 평균 응답 시간 | 200ms | 80-100ms | 50-60% | -| 콜드 스타트 시간 | 3s | 2-2.5s | 20-30% | -| 코드 라인 수 (중복) | 500+ | 200- | 60%+ | +| 지표 | 현재 | 개선 후 | 감소율 | +|--------------|-------|----------|--------| +| DynamoDB RCU | 100 | 40-50 | 50-60% | +| 평균 응답 시간 | 200ms | 80-100ms | 50-60% | +| 콜드 스타트 시간 | 3s | 2-2.5s | 20-30% | +| 코드 라인 수 (중복) | 500+ | 200- | 60%+ | --- diff --git a/docs/chatting/CHATTING-GUIDE.md b/docs/chatting/CHATTING-GUIDE.md index 467a1c6c..e37f8102 100644 --- a/docs/chatting/CHATTING-GUIDE.md +++ b/docs/chatting/CHATTING-GUIDE.md @@ -4,31 +4,32 @@ ### 1.1 목적 -Chatting Server는 영어 회화 학습 플랫폼의 실시간 채팅 기능을 담당하는 서버리스 마이크로서비스이다. 사용자들이 영어 난이도별 채팅방에 참여하여 실시간으로 대화하고, AI 응답 및 TTS 기능을 활용할 수 있다. +Chatting Server는 영어 회화 학습 플랫폼의 실시간 채팅 기능을 담당하는 서버리스 마이크로서비스이다. 사용자들이 영어 난이도별 채팅방에 참여하여 실시간으로 대화하고, AI 응답 및 TTS 기능을 활용할 수 +있다. ### 1.2 주요 기능 -| 기능 | 설명 | -|------|------| -| 채팅방 관리 | 생성, 조회, 입장, 퇴장, 삭제 | -| 실시간 메시징 | WebSocket 기반 양방향 통신 | -| 토큰 인증 | REST → WebSocket 전환 시 RoomToken 검증 | -| 난이도별 필터링 | BEGINNER, INTERMEDIATE, ADVANCED | -| AI 응답 | AWS Bedrock 기반 AI 메시지 생성 | -| TTS (음성 합성) | AWS Polly 기반 음성 변환 | -| 비밀방 | BCrypt 암호화 비밀번호 지원 | +| 기능 | 설명 | +|-------------|------------------------------------| +| 채팅방 관리 | 생성, 조회, 입장, 퇴장, 삭제 | +| 실시간 메시징 | WebSocket 기반 양방향 통신 | +| 토큰 인증 | REST → WebSocket 전환 시 RoomToken 검증 | +| 난이도별 필터링 | BEGINNER, INTERMEDIATE, ADVANCED | +| AI 응답 | AWS Bedrock 기반 AI 메시지 생성 | +| TTS (음성 합성) | AWS Polly 기반 음성 변환 | +| 비밀방 | BCrypt 암호화 비밀번호 지원 | ### 1.3 기술 스택 -| 구분 | 기술 | -|------|------| -| Platform | AWS Lambda (Serverless) | -| Language | Java 21 (Eclipse Temurin) | -| Database | AWS DynamoDB (Single Table Design) | -| Real-time | API Gateway WebSocket | -| AI | AWS Bedrock (Claude/Llama) | -| TTS | AWS Polly | -| Storage | AWS S3 (음성 캐시) | +| 구분 | 기술 | +|-----------|------------------------------------| +| Platform | AWS Lambda (Serverless) | +| Language | Java 21 (Eclipse Temurin) | +| Database | AWS DynamoDB (Single Table Design) | +| Real-time | API Gateway WebSocket | +| AI | AWS Bedrock (Claude/Llama) | +| TTS | AWS Polly | +| Storage | AWS S3 (음성 캐시) | --- @@ -353,71 +354,71 @@ erDiagram #### ChatRoom (채팅방) -| 필드 | 타입 | 필수 | 설명 | -|------|------|------|------| -| PK | String | Y | ROOM#{roomId} | -| SK | String | Y | METADATA | -| GSI1PK | String | Y | ROOMS (전체 조회용) | -| GSI1SK | String | Y | {level}#{createdAt} (정렬) | -| roomId | String | Y | UUID | -| name | String | Y | 채팅방 이름 | -| description | String | N | 설명 | -| level | String | Y | beginner, intermediate, advanced | -| currentMembers | Integer | Y | 현재 참여 인원 | -| maxMembers | Integer | Y | 최대 인원 (기본: 6) | -| isPrivate | Boolean | Y | 비밀방 여부 | -| password | String | N | BCrypt 해시 비밀번호 | -| createdBy | String | Y | 방장 userId | -| memberIds | List | Y | 참여자 userId 목록 | -| createdAt | String | Y | ISO 8601 형식 | -| lastMessageAt | String | Y | 마지막 메시지 시각 | +| 필드 | 타입 | 필수 | 설명 | +|----------------|---------|----|----------------------------------| +| PK | String | Y | ROOM#{roomId} | +| SK | String | Y | METADATA | +| GSI1PK | String | Y | ROOMS (전체 조회용) | +| GSI1SK | String | Y | {level}#{createdAt} (정렬) | +| roomId | String | Y | UUID | +| name | String | Y | 채팅방 이름 | +| description | String | N | 설명 | +| level | String | Y | beginner, intermediate, advanced | +| currentMembers | Integer | Y | 현재 참여 인원 | +| maxMembers | Integer | Y | 최대 인원 (기본: 6) | +| isPrivate | Boolean | Y | 비밀방 여부 | +| password | String | N | BCrypt 해시 비밀번호 | +| createdBy | String | Y | 방장 userId | +| memberIds | List | Y | 참여자 userId 목록 | +| createdAt | String | Y | ISO 8601 형식 | +| lastMessageAt | String | Y | 마지막 메시지 시각 | #### ChatMessage (채팅 메시지) -| 필드 | 타입 | 필수 | 설명 | -|------|------|------|------| -| PK | String | Y | ROOM#{roomId} | -| SK | String | Y | MSG#{timestamp}#{messageId} | -| GSI1PK | String | Y | USER#{userId} | -| GSI1SK | String | Y | MSG#{timestamp} | -| GSI2PK | String | Y | MSG#{messageId} | -| GSI2SK | String | Y | ROOM#{roomId} | -| messageId | String | Y | UUID | -| roomId | String | Y | 채팅방 ID | -| userId | String | Y | 발신자 ID | -| content | String | Y | 메시지 내용 | -| messageType | String | Y | TEXT, IMAGE, VOICE, AI_RESPONSE | -| maleVoiceKey | String | N | S3 음성 파일 키 (남성) | -| femaleVoiceKey | String | N | S3 음성 파일 키 (여성) | -| createdAt | String | Y | ISO 8601 형식 | +| 필드 | 타입 | 필수 | 설명 | +|----------------|--------|----|---------------------------------| +| PK | String | Y | ROOM#{roomId} | +| SK | String | Y | MSG#{timestamp}#{messageId} | +| GSI1PK | String | Y | USER#{userId} | +| GSI1SK | String | Y | MSG#{timestamp} | +| GSI2PK | String | Y | MSG#{messageId} | +| GSI2SK | String | Y | ROOM#{roomId} | +| messageId | String | Y | UUID | +| roomId | String | Y | 채팅방 ID | +| userId | String | Y | 발신자 ID | +| content | String | Y | 메시지 내용 | +| messageType | String | Y | TEXT, IMAGE, VOICE, AI_RESPONSE | +| maleVoiceKey | String | N | S3 음성 파일 키 (남성) | +| femaleVoiceKey | String | N | S3 음성 파일 키 (여성) | +| createdAt | String | Y | ISO 8601 형식 | #### Connection (WebSocket 연결) -| 필드 | 타입 | 필수 | 설명 | -|------|------|------|------| -| PK | String | Y | CONN#{connectionId} | -| SK | String | Y | METADATA | -| GSI1PK | String | Y | ROOM#{roomId} | -| GSI1SK | String | Y | CONN#{connectionId} | -| GSI2PK | String | Y | USER#{userId} | -| GSI2SK | String | Y | CONN#{connectionId} | -| connectionId | String | Y | API Gateway Connection ID | -| userId | String | Y | 사용자 ID | -| roomId | String | Y | 채팅방 ID | -| connectedAt | String | Y | 연결 시각 | -| ttl | Long | Y | DynamoDB TTL (10분 후 자동 삭제) | +| 필드 | 타입 | 필수 | 설명 | +|--------------|--------|----|----------------------------| +| PK | String | Y | CONN#{connectionId} | +| SK | String | Y | METADATA | +| GSI1PK | String | Y | ROOM#{roomId} | +| GSI1SK | String | Y | CONN#{connectionId} | +| GSI2PK | String | Y | USER#{userId} | +| GSI2SK | String | Y | CONN#{connectionId} | +| connectionId | String | Y | API Gateway Connection ID | +| userId | String | Y | 사용자 ID | +| roomId | String | Y | 채팅방 ID | +| connectedAt | String | Y | 연결 시각 | +| ttl | Long | Y | DynamoDB TTL (10분 후 자동 삭제) | #### RoomToken (입장 토큰) -| 필드 | 타입 | 필수 | 설명 | -|------|------|------|------| -| PK | String | Y | TOKEN#{token} | -| SK | String | Y | METADATA | -| token | String | Y | UUID 토큰 | -| roomId | String | Y | 채팅방 ID | -| userId | String | Y | 사용자 ID | -| createdAt | String | Y | 발급 시각 | -| ttl | Long | Y | DynamoDB TTL (5분 후 자동 삭제) | +| 필드 | 타입 | 필수 | 설명 | +|-----------|--------|----|---------------------------| +| PK | String | Y | TOKEN#{token} | +| SK | String | Y | METADATA | +| token | String | Y | UUID 토큰 | +| roomId | String | Y | 채팅방 ID | +| userId | String | Y | 사용자 ID | +| createdAt | String | Y | 발급 시각 | +| ttl | Long | Y | DynamoDB TTL (5분 후 자동 삭제) | ### 3.3 GSI (Global Secondary Index) 설계 @@ -489,13 +490,13 @@ flowchart LR **Query Parameters** -| 파라미터 | 타입 | 필수 | 설명 | -|---------|------|------|------| -| level | String | N | 난이도 필터 (beginner, intermediate, advanced) | -| userId | String | N | 사용자 ID (joined 필터 시 필수) | -| joined | String | N | "true"면 가입된 방만 조회 | -| cursor | String | N | 페이징 커서 | -| limit | Integer | N | 페이지 크기 (기본: 10, 최대: 20) | +| 파라미터 | 타입 | 필수 | 설명 | +|--------|---------|----|-------------------------------------------| +| level | String | N | 난이도 필터 (beginner, intermediate, advanced) | +| userId | String | N | 사용자 ID (joined 필터 시 필수) | +| joined | String | N | "true"면 가입된 방만 조회 | +| cursor | String | N | 페이징 커서 | +| limit | Integer | N | 페이지 크기 (기본: 10, 최대: 20) | **Response (200 OK)** @@ -623,9 +624,9 @@ flowchart LR **Query Parameter** -| 파라미터 | 타입 | 필수 | 설명 | -|---------|------|------|------| -| roomToken | String | Y | joinRoom에서 발급받은 토큰 | +| 파라미터 | 타입 | 필수 | 설명 | +|-----------|--------|----|--------------------| +| roomToken | String | Y | joinRoom에서 발급받은 토큰 | **연결 URL 예시** @@ -690,13 +691,13 @@ stateDiagram-v2 ### 5.3 접근 제어 -| 기능 | 조건 | -|------|------| -| 방 생성 | 모든 사용자 | -| 방 조회 | 모든 사용자 | -| 방 입장 | 비밀방인 경우 비밀번호 필요 | -| 방 퇴장 | 참여 멤버만 | -| 방 삭제 | 방장(createdBy)만 | +| 기능 | 조건 | +|--------------|------------------| +| 방 생성 | 모든 사용자 | +| 방 조회 | 모든 사용자 | +| 방 입장 | 비밀방인 경우 비밀번호 필요 | +| 방 퇴장 | 참여 멤버만 | +| 방 삭제 | 방장(createdBy)만 | | WebSocket 연결 | 유효한 roomToken 필요 | ### 5.4 비밀번호 처리 @@ -714,13 +715,13 @@ flowchart LR ### 5.5 제한 사항 -| 항목 | 제한 | -|------|------| -| 최대 참여 인원 | 기본 6명, 최대 설정 가능 | -| 방 목록 페이지 크기 | 최대 20 | -| RoomToken 유효 시간 | 5분 (300초) | -| Connection TTL | 10분 (600초) | -| 비밀번호 | BCrypt 해시 | +| 항목 | 제한 | +|-----------------|-----------------| +| 최대 참여 인원 | 기본 6명, 최대 설정 가능 | +| 방 목록 페이지 크기 | 최대 20 | +| RoomToken 유효 시간 | 5분 (300초) | +| Connection TTL | 10분 (600초) | +| 비밀번호 | BCrypt 해시 | --- @@ -728,14 +729,14 @@ flowchart LR ### 6.1 HTTP 에러 -| HTTP Code | 설명 | 예시 | -|-----------|------|------| -| 400 | 잘못된 요청 | 필수 파라미터 누락 | -| 401 | 인증 실패 | 유효하지 않은 토큰 | -| 403 | 권한 없음 | 비밀번호 불일치, 방장 아님 | -| 404 | 리소스 없음 | 존재하지 않는 방 | -| 409 | 충돌 | 정원 초과 | -| 500 | 서버 오류 | 내부 오류 | +| HTTP Code | 설명 | 예시 | +|-----------|--------|-----------------| +| 400 | 잘못된 요청 | 필수 파라미터 누락 | +| 401 | 인증 실패 | 유효하지 않은 토큰 | +| 403 | 권한 없음 | 비밀번호 불일치, 방장 아님 | +| 404 | 리소스 없음 | 존재하지 않는 방 | +| 409 | 충돌 | 정원 초과 | +| 500 | 서버 오류 | 내부 오류 | ### 6.2 에러 응답 형식 diff --git a/docs/user/USER-GUIDE.md b/docs/user/USER-GUIDE.md index feb4740f..9eaca193 100644 --- a/docs/user/USER-GUIDE.md +++ b/docs/user/USER-GUIDE.md @@ -4,28 +4,29 @@ ### 1.1 목적 -User Server는 영어 회화 학습 플랫폼의 사용자 인증 및 프로필 관리를 담당하는 서버리스 마이크로서비스이다. AWS Cognito를 활용하여 안전한 인증 체계를 제공하고, 사용자별 학습 데이터 및 개인 설정을 관리한다. +User Server는 영어 회화 학습 플랫폼의 사용자 인증 및 프로필 관리를 담당하는 서버리스 마이크로서비스이다. AWS Cognito를 활용하여 안전한 인증 체계를 제공하고, 사용자별 학습 데이터 및 개인 설정을 +관리한다. ### 1.2 주요 기능 -| 기능 | 설명 | -|------|------| -| 회원가입 | Cognito 기반 이메일 회원가입 | -| 이메일 인증 | Cognito 자동 인증 코드 발송 | -| 로그인 | JWT 토큰 발급 (IdToken, AccessToken, RefreshToken) | -| 프로필 조회 | 인증된 사용자 정보 조회 | +| 기능 | 설명 | +|--------|--------------------------------------------------| +| 회원가입 | Cognito 기반 이메일 회원가입 | +| 이메일 인증 | Cognito 자동 인증 코드 발송 | +| 로그인 | JWT 토큰 발급 (IdToken, AccessToken, RefreshToken) | +| 프로필 조회 | 인증된 사용자 정보 조회 | | 기본값 설정 | PreSignUp 트리거로 nickname, level, profileUrl 자동 설정 | ### 1.3 기술 스택 -| 구분 | 기술 | -|------|------| -| Platform | AWS Lambda (Serverless) | -| Language | Java 21 (Eclipse Temurin) | -| Authentication | AWS Cognito User Pool | -| Authorization | Cognito Built-in Authorizer | -| Database | AWS DynamoDB (Single Table Design) | -| Storage | AWS S3 (프로필 이미지) | +| 구분 | 기술 | +|----------------|------------------------------------| +| Platform | AWS Lambda (Serverless) | +| Language | Java 21 (Eclipse Temurin) | +| Authentication | AWS Cognito User Pool | +| Authorization | Cognito Built-in Authorizer | +| Database | AWS DynamoDB (Single Table Design) | +| Storage | AWS S3 (프로필 이미지) | --- @@ -178,15 +179,14 @@ sequenceDiagram ### 3.1 Cognito User Attributes -| Attribute | Type | Required | Mutable | 설명 | -|-----------|------|----------|---------|------| -| sub | Standard | Y | N | Cognito 고유 ID (UUID) | -| email | Standard | Y | N | 이메일 (로그인 ID) | -| email_verified | Standard | Y | N | 이메일 인증 여부 | -| nickname | Standard | N | Y | 닉네임 | -| custom:level | Custom | N | Y | 학습 난이도 (BEGINNER/INTERMEDIATE/ADVANCED) | -| custom:profileUrl | Custom | N | Y | 프로필 이미지 URL | - +| Attribute | Type | Required | Mutable | 설명 | +|-------------------|----------|----------|---------|-----------------------------------------| +| sub | Standard | Y | N | Cognito 고유 ID (UUID) | +| email | Standard | Y | N | 이메일 (로그인 ID) | +| email_verified | Standard | Y | N | 이메일 인증 여부 | +| nickname | Standard | N | Y | 닉네임 | +| custom:level | Custom | N | Y | 학습 난이도 (BEGINNER/INTERMEDIATE/ADVANCED) | +| custom:profileUrl | Custom | N | Y | 프로필 이미지 URL | ### 3.2 ERD (DynamoDB - 향후 확장용) @@ -213,14 +213,14 @@ erDiagram } ``` -| 필드 | 패턴 | 설명 | -|------|------|------| -| PK | USER#{cognitoSub} | 파티션 키 | -| SK | METADATA | 정렬 키 | -| GSI1PK | EMAIL#{email} | 이메일 조회용 | -| GSI1SK | USER#{cognitoSub} | - | -| GSI2PK | LEVEL#{level} | 레벨별 조회용 | -| GSI2SK | USER#{cognitoSub} | - | +| 필드 | 패턴 | 설명 | +|--------|-------------------|---------| +| PK | USER#{cognitoSub} | 파티션 키 | +| SK | METADATA | 정렬 키 | +| GSI1PK | EMAIL#{email} | 이메일 조회용 | +| GSI1SK | USER#{cognitoSub} | - | +| GSI2PK | LEVEL#{level} | 레벨별 조회용 | +| GSI2SK | USER#{cognitoSub} | - | ### 3.3 GSI (Global Secondary Index) 설계 @@ -325,15 +325,15 @@ aws cognito-idp initiate-auth \ --auth-parameters REFRESH_TOKEN={REFRESH_TOKEN} ``` -### 4.2 프로필 API +### 4.2 프로필 API #### GET /users/profile/me - 내 정보 조회 **Headers** -| Header | 값 | 필수 | -|--------|------|------| -| Authorization | Bearer {IdToken} | Y | +| Header | 값 | 필수 | +|---------------|------------------|----| +| Authorization | Bearer {IdToken} | Y | **Response (200 OK)** @@ -355,30 +355,28 @@ aws cognito-idp initiate-auth \ ### 5.1 회원가입 기본값 (PreSignUp Trigger) -| 항목 | 조건 | 기본값 | 예시 | -|------|------|--------|------| -| nickname | null 또는 빈 문자열 | UUID 6자 + "님" | "A7K2X9님" | -| custom:level | null 또는 빈 문자열 | BEGINNER | "BEGINNER" | -| custom:profileUrl | null 또는 빈 문자열 | S3 기본 이미지 | https://group2-englishstudy.s3.amazonaws.com/profile/default.png | +| 항목 | 조건 | 기본값 | 예시 | +|-------------------|---------------|---------------|------------------------------------------------------------------| +| nickname | null 또는 빈 문자열 | UUID 6자 + "님" | "A7K2X9님" | +| custom:level | null 또는 빈 문자열 | BEGINNER | "BEGINNER" | +| custom:profileUrl | null 또는 빈 문자열 | S3 기본 이미지 | https://group2-englishstudy.s3.amazonaws.com/profile/default.png | ### 5.2 비밀번호 정책 (Cognito) -| 항목 | 요구사항 | -|------|----------| -| 최소 길이 | 8자 | -| 소문자 | 1개 이상 | -| 숫자 | 1개 이상 | -| 특수문자 | 1개 이상 | - +| 항목 | 요구사항 | +|-------|-------| +| 최소 길이 | 8자 | +| 소문자 | 1개 이상 | +| 숫자 | 1개 이상 | +| 특수문자 | 1개 이상 | ### 5.3 토큰 유효 시간 -| 토큰 | 유효 시간 | 용도 | -|------|----------|------| -| IdToken | 1시간 (3600초) | API 인증, 사용자 정보 | -| AccessToken | 1시간 (3600초) | Cognito API 호출 | -| RefreshToken | 30일 | 토큰 갱신 | - +| 토큰 | 유효 시간 | 용도 | +|--------------|-------------|----------------| +| IdToken | 1시간 (3600초) | API 인증, 사용자 정보 | +| AccessToken | 1시간 (3600초) | Cognito API 호출 | +| RefreshToken | 30일 | 토큰 갱신 | --- @@ -386,24 +384,24 @@ aws cognito-idp initiate-auth \ ### 6.1 Cognito 에러 -| Error Code | HTTP | 설명 | 해결 방법 | -|------------|------|------|----------| -| UsernameExistsException | 400 | 이미 존재하는 이메일 | 다른 이메일 사용 | -| InvalidPasswordException | 400 | 비밀번호 정책 미충족 | 정책에 맞는 비밀번호 | -| CodeMismatchException | 400 | 인증 코드 불일치 | 올바른 코드 입력 | -| ExpiredCodeException | 400 | 인증 코드 만료 | 재발송 요청 | -| NotAuthorizedException | 401 | 비밀번호 틀림 | 올바른 비밀번호 | -| UserNotConfirmedException | 400 | 이메일 미인증 | 인증 완료 필요 | -| UserNotFoundException | 400 | 존재하지 않는 사용자 | 회원가입 필요 | +| Error Code | HTTP | 설명 | 해결 방법 | +|---------------------------|------|-------------|-------------| +| UsernameExistsException | 400 | 이미 존재하는 이메일 | 다른 이메일 사용 | +| InvalidPasswordException | 400 | 비밀번호 정책 미충족 | 정책에 맞는 비밀번호 | +| CodeMismatchException | 400 | 인증 코드 불일치 | 올바른 코드 입력 | +| ExpiredCodeException | 400 | 인증 코드 만료 | 재발송 요청 | +| NotAuthorizedException | 401 | 비밀번호 틀림 | 올바른 비밀번호 | +| UserNotConfirmedException | 400 | 이메일 미인증 | 인증 완료 필요 | +| UserNotFoundException | 400 | 존재하지 않는 사용자 | 회원가입 필요 | ### 6.2 API 에러 -| HTTP Code | Error Code | 메시지 | -|-----------|------------|--------| -| 401 | AUTH_001 | 인증이 필요합니다 | -| 401 | AUTH_003 | 유효하지 않은 토큰입니다 | -| 401 | AUTH_004 | 토큰이 만료되었습니다 | -| 500 | SYSTEM_001 | 내부 서버 오류가 발생했습니다 | +| HTTP Code | Error Code | 메시지 | +|-----------|------------|------------------| +| 401 | AUTH_001 | 인증이 필요합니다 | +| 401 | AUTH_003 | 유효하지 않은 토큰입니다 | +| 401 | AUTH_004 | 토큰이 만료되었습니다 | +| 500 | SYSTEM_001 | 내부 서버 오류가 발생했습니다 | ### 6.3 에러 응답 형식 @@ -528,7 +526,7 @@ domain/user/ ## 9. 구현 현황 -### Phase 1 - Cognito 인증 (완료) +### Phase 1 - Cognito 인증 (완료) - [x] Cognito User Pool 생성 - [x] Cognito User Pool Client 생성 @@ -537,33 +535,31 @@ domain/user/ - [x] 회원가입/이메일인증/로그인 테스트 - [x] UserHandler (claims 추출) -### Phase 2 - 프로필 관리 (예정) +### Phase 2 - 프로필 관리 (예정) - [ ] GET /users/profile/me - 내 프로필 상세 조회 - [ ] PUT /users/profile/me - 프로필 수정 (닉네임, 레벨) - [ ] POST /users/profile/me/image - 프로필 이미지 업로드 (S3) - [ ] DynamoDB에 추가 사용자 정보 저장 -### Phase 3 - 추가 기능 (예정) +### Phase 3 - 추가 기능 (예정) - [ ] 비밀번호 변경 -- [ ] 비밀번호 찾기 +- [ ] 비밀번호 찾기 - [ ] 회원 탈퇴 (delete-user) - [ ] 학습 통계 연동 (Vocabulary Domain) - [ ] 사용자 설정 (알림, 학습 목표, 일일 학습량) -### Phase 4 - 최적화 +### Phase 4 - 최적화 -- [ ] 소셜 로그인 (Kakao, Google, Apple) +- [ ] 소셜 로그인 (Kakao, Google, Apple) - [ ] SNS-SQS Fan-out 패턴 (이메일 발송 비동기 처리) - [ ] 이메일 타임아웃 방안 SQS 마련 - [ ] S3 이벤트 트리거 - 이미지 리사이징 패턴 - - --- **버전**: 1.0.0 **최종 업데이트**: 2026-01-11 **작성자**: hye-inA -**팀**: MZC 2nd Project Team \ No newline at end of file +**팀**: MZC 2nd Project Team diff --git a/docs/vocabulary/VOCABULARY-GUIDE.md b/docs/vocabulary/VOCABULARY-GUIDE.md index 61829dc5..7891a29c 100644 --- a/docs/vocabulary/VOCABULARY-GUIDE.md +++ b/docs/vocabulary/VOCABULARY-GUIDE.md @@ -4,30 +4,31 @@ ### 1.1 목적 -Vocabulary Server는 영어 단어 학습 플랫폼의 단어 관리 및 학습 기능을 담당하는 서버리스 마이크로서비스이다. Spaced Repetition 알고리즘을 활용한 효율적인 단어 암기, 시험 기능, 일일 학습 추적 등을 제공한다. +Vocabulary Server는 영어 단어 학습 플랫폼의 단어 관리 및 학습 기능을 담당하는 서버리스 마이크로서비스이다. Spaced Repetition 알고리즘을 활용한 효율적인 단어 암기, 시험 기능, 일일 +학습 추적 등을 제공한다. ### 1.2 주요 기능 -| 기능 | 설명 | -|------|------| -| 단어 관리 | CRUD, 배치 생성/조회, 검색 | +| 기능 | 설명 | +|--------|-----------------------------| +| 단어 관리 | CRUD, 배치 생성/조회, 검색 | | 사용자 학습 | Spaced Repetition 기반 복습 스케줄 | -| 시험 기능 | DAILY, WEEKLY, CUSTOM 테스트 | -| 일일 학습 | 학습 기록 및 진도 추적 | -| 통계 | 학습 통계 및 성취도 분석 | -| TTS | AWS Polly 기반 발음 듣기 | -| 단어 그룹 | 카테고리/레벨별 그룹화 | +| 시험 기능 | DAILY, WEEKLY, CUSTOM 테스트 | +| 일일 학습 | 학습 기록 및 진도 추적 | +| 통계 | 학습 통계 및 성취도 분석 | +| TTS | AWS Polly 기반 발음 듣기 | +| 단어 그룹 | 카테고리/레벨별 그룹화 | ### 1.3 기술 스택 -| 구분 | 기술 | -|------|------| -| Platform | AWS Lambda (Serverless) | -| Language | Java 21 (Eclipse Temurin) | -| Database | AWS DynamoDB (Single Table Design) | -| TTS | AWS Polly | -| Storage | AWS S3 (음성 캐시) | -| Algorithm | SM-2 기반 Spaced Repetition | +| 구분 | 기술 | +|-----------|------------------------------------| +| Platform | AWS Lambda (Serverless) | +| Language | Java 21 (Eclipse Temurin) | +| Database | AWS DynamoDB (Single Table Design) | +| TTS | AWS Polly | +| Storage | AWS S3 (음성 캐시) | +| Algorithm | SM-2 기반 Spaced Repetition | --- @@ -378,70 +379,70 @@ erDiagram #### Word (단어) -| 필드 | 타입 | 필수 | 설명 | -|------|------|------|------| -| PK | String | Y | WORD#{wordId} | -| SK | String | Y | METADATA | -| GSI1PK | String | Y | LEVEL#{level} | -| GSI1SK | String | Y | WORD#{wordId} | -| GSI2PK | String | Y | CATEGORY#{category} | -| GSI2SK | String | Y | WORD#{wordId} | -| wordId | String | Y | UUID | -| english | String | Y | 영어 단어 | -| korean | String | Y | 한국어 뜻 | -| example | String | N | 예문 | -| level | String | Y | BEGINNER, INTERMEDIATE, ADVANCED | -| category | String | Y | DAILY, BUSINESS, ACADEMIC | -| maleVoiceKey | String | N | S3 음성 파일 키 (남성) | -| femaleVoiceKey | String | N | S3 음성 파일 키 (여성) | -| maleExampleVoiceKey | String | N | S3 예문 음성 키 (남성) | -| femaleExampleVoiceKey | String | N | S3 예문 음성 키 (여성) | -| createdAt | String | Y | ISO 8601 형식 | +| 필드 | 타입 | 필수 | 설명 | +|-----------------------|--------|----|----------------------------------| +| PK | String | Y | WORD#{wordId} | +| SK | String | Y | METADATA | +| GSI1PK | String | Y | LEVEL#{level} | +| GSI1SK | String | Y | WORD#{wordId} | +| GSI2PK | String | Y | CATEGORY#{category} | +| GSI2SK | String | Y | WORD#{wordId} | +| wordId | String | Y | UUID | +| english | String | Y | 영어 단어 | +| korean | String | Y | 한국어 뜻 | +| example | String | N | 예문 | +| level | String | Y | BEGINNER, INTERMEDIATE, ADVANCED | +| category | String | Y | DAILY, BUSINESS, ACADEMIC | +| maleVoiceKey | String | N | S3 음성 파일 키 (남성) | +| femaleVoiceKey | String | N | S3 음성 파일 키 (여성) | +| maleExampleVoiceKey | String | N | S3 예문 음성 키 (남성) | +| femaleExampleVoiceKey | String | N | S3 예문 음성 키 (여성) | +| createdAt | String | Y | ISO 8601 형식 | #### UserWord (사용자 학습 상태) -| 필드 | 타입 | 필수 | 설명 | -|------|------|------|------| -| PK | String | Y | USER#{userId} | -| SK | String | Y | WORD#{wordId} | -| GSI1PK | String | Y | USER#{userId}#REVIEW | -| GSI1SK | String | Y | DATE#{nextReviewAt} | -| GSI2PK | String | Y | USER#{userId}#STATUS | -| GSI2SK | String | Y | STATUS#{status} | -| userId | String | Y | 사용자 ID | -| wordId | String | Y | 단어 ID | -| status | String | Y | NEW, LEARNING, REVIEWING, MASTERED | -| interval | Integer | Y | 복습 간격 (일) | -| easeFactor | Double | Y | 난이도 계수 (기본: 2.5) | -| repetitions | Integer | Y | 연속 정답 횟수 | -| nextReviewAt | String | N | 다음 복습 예정일 | -| lastReviewedAt | String | N | 마지막 복습일 | -| correctCount | Integer | Y | 총 정답 횟수 | -| incorrectCount | Integer | Y | 총 오답 횟수 | -| bookmarked | Boolean | N | 북마크 여부 | -| favorite | Boolean | N | 즐겨찾기 여부 | -| difficulty | String | N | EASY, NORMAL, HARD | -| createdAt | String | Y | 생성 시각 | -| updatedAt | String | Y | 수정 시각 | +| 필드 | 타입 | 필수 | 설명 | +|----------------|---------|----|------------------------------------| +| PK | String | Y | USER#{userId} | +| SK | String | Y | WORD#{wordId} | +| GSI1PK | String | Y | USER#{userId}#REVIEW | +| GSI1SK | String | Y | DATE#{nextReviewAt} | +| GSI2PK | String | Y | USER#{userId}#STATUS | +| GSI2SK | String | Y | STATUS#{status} | +| userId | String | Y | 사용자 ID | +| wordId | String | Y | 단어 ID | +| status | String | Y | NEW, LEARNING, REVIEWING, MASTERED | +| interval | Integer | Y | 복습 간격 (일) | +| easeFactor | Double | Y | 난이도 계수 (기본: 2.5) | +| repetitions | Integer | Y | 연속 정답 횟수 | +| nextReviewAt | String | N | 다음 복습 예정일 | +| lastReviewedAt | String | N | 마지막 복습일 | +| correctCount | Integer | Y | 총 정답 횟수 | +| incorrectCount | Integer | Y | 총 오답 횟수 | +| bookmarked | Boolean | N | 북마크 여부 | +| favorite | Boolean | N | 즐겨찾기 여부 | +| difficulty | String | N | EASY, NORMAL, HARD | +| createdAt | String | Y | 생성 시각 | +| updatedAt | String | Y | 수정 시각 | #### TestResult (시험 결과) -| 필드 | 타입 | 필수 | 설명 | -|------|------|------|------| -| PK | String | Y | TEST#{userId} | -| SK | String | Y | RESULT#{timestamp} | -| GSI1PK | String | Y | TEST#ALL | -| GSI1SK | String | Y | DATE#{date} | -| testId | String | Y | UUID | -| userId | String | Y | 사용자 ID | -| testType | String | Y | DAILY, WEEKLY, CUSTOM | -| totalQuestions | Integer | Y | 총 문제 수 | -| correctAnswers | Integer | Y | 정답 수 | -| incorrectAnswers | Integer | Y | 오답 수 | -| successRate | Double | Y | 성공률 (%) | -| incorrectWordIds | List | N | 오답 단어 ID 목록 | -| startedAt | String | Y | 시험 시작 시각 | -| completedAt | String | Y | 시험 완료 시각 | +| 필드 | 타입 | 필수 | 설명 | +|------------------|---------|----|-----------------------| +| PK | String | Y | TEST#{userId} | +| SK | String | Y | RESULT#{timestamp} | +| GSI1PK | String | Y | TEST#ALL | +| GSI1SK | String | Y | DATE#{date} | +| testId | String | Y | UUID | +| userId | String | Y | 사용자 ID | +| testType | String | Y | DAILY, WEEKLY, CUSTOM | +| totalQuestions | Integer | Y | 총 문제 수 | +| correctAnswers | Integer | Y | 정답 수 | +| incorrectAnswers | Integer | Y | 오답 수 | +| successRate | Double | Y | 성공률 (%) | +| incorrectWordIds | List | N | 오답 단어 ID 목록 | +| startedAt | String | Y | 시험 시작 시각 | +| completedAt | String | Y | 시험 완료 시각 | ### 3.3 Spaced Repetition 알고리즘 @@ -562,12 +563,12 @@ stateDiagram-v2 **Query Parameters** -| 파라미터 | 타입 | 필수 | 설명 | -|---------|------|------|------| -| level | String | N | 난이도 필터 | -| category | String | N | 카테고리 필터 | -| cursor | String | N | 페이징 커서 | -| limit | Integer | N | 페이지 크기 (기본: 20, 최대: 50) | +| 파라미터 | 타입 | 필수 | 설명 | +|----------|---------|----|-------------------------| +| level | String | N | 난이도 필터 | +| category | String | N | 카테고리 필터 | +| cursor | String | N | 페이징 커서 | +| limit | Integer | N | 페이지 크기 (기본: 20, 최대: 50) | **Response (200 OK)** @@ -597,11 +598,11 @@ stateDiagram-v2 **Query Parameters** -| 파라미터 | 타입 | 필수 | 설명 | -|---------|------|------|------| -| q | String | Y | 검색어 (영어/한국어) | -| cursor | String | N | 페이징 커서 | -| limit | Integer | N | 페이지 크기 (기본: 20) | +| 파라미터 | 타입 | 필수 | 설명 | +|--------|---------|----|-----------------| +| q | String | Y | 검색어 (영어/한국어) | +| cursor | String | N | 페이징 커서 | +| limit | Integer | N | 페이지 크기 (기본: 20) | **Response (200 OK)** @@ -760,12 +761,12 @@ stateDiagram-v2 **Query Parameters** -| 파라미터 | 타입 | 필수 | 설명 | -|---------|------|------|------| -| userId | String | Y | 사용자 ID | -| date | String | N | 조회 날짜 (기본: 오늘) | -| cursor | String | N | 페이징 커서 | -| limit | Integer | N | 페이지 크기 | +| 파라미터 | 타입 | 필수 | 설명 | +|--------|---------|----|----------------| +| userId | String | Y | 사용자 ID | +| date | String | N | 조회 날짜 (기본: 오늘) | +| cursor | String | N | 페이징 커서 | +| limit | Integer | N | 페이지 크기 | **Response (200 OK)** @@ -896,10 +897,10 @@ stateDiagram-v2 **Query Parameters** -| 파라미터 | 타입 | 필수 | 설명 | -|---------|------|------|------| -| userId | String | Y | 사용자 ID | -| period | String | N | WEEK, MONTH, ALL (기본: WEEK) | +| 파라미터 | 타입 | 필수 | 설명 | +|--------|--------|----|-----------------------------| +| userId | String | Y | 사용자 ID | +| period | String | N | WEEK, MONTH, ALL (기본: WEEK) | **Response (200 OK)** @@ -955,21 +956,21 @@ stateDiagram-v2 ### 5.1 Spaced Repetition 규칙 -| 조건 | interval 계산 | status 변경 | -|------|--------------|-------------| -| 첫 정답 (rep=1) | 1일 | LEARNING | -| 두번째 정답 (rep=2) | 6일 | REVIEWING | +| 조건 | interval 계산 | status 변경 | +|----------------|-----------------------|-----------| +| 첫 정답 (rep=1) | 1일 | LEARNING | +| 두번째 정답 (rep=2) | 6일 | REVIEWING | | 이후 정답 (rep>=3) | interval × easeFactor | REVIEWING | -| 5회 연속 정답 | 유지 | MASTERED | -| 오답 | 1일 (리셋) | LEARNING | +| 5회 연속 정답 | 유지 | MASTERED | +| 오답 | 1일 (리셋) | LEARNING | ### 5.2 easeFactor 규칙 -| 조건 | easeFactor 변경 | -|------|-----------------| -| 초기값 | 2.5 | +| 조건 | easeFactor 변경 | +|------|----------------------------| +| 초기값 | 2.5 | | 오답 시 | max(1.3, easeFactor - 0.2) | -| 정답 시 | 유지 | +| 정답 시 | 유지 | ### 5.3 난이도별 카테고리 @@ -997,12 +998,12 @@ flowchart LR ### 5.4 제한 사항 -| 항목 | 제한 | -|------|------| -| 단어 목록 페이지 크기 | 최대 50 | -| 배치 조회 ID | 최대 100개 | -| 시험 문제 수 | 최소 5, 최대 50 | -| 사용자 난이도 | EASY, NORMAL, HARD | +| 항목 | 제한 | +|--------------|--------------------| +| 단어 목록 페이지 크기 | 최대 50 | +| 배치 조회 ID | 최대 100개 | +| 시험 문제 수 | 최소 5, 최대 50 | +| 사용자 난이도 | EASY, NORMAL, HARD | --- @@ -1010,11 +1011,11 @@ flowchart LR ### 6.1 HTTP 에러 -| HTTP Code | 설명 | 예시 | -|-----------|------|------| -| 400 | 잘못된 요청 | 필수 파라미터 누락, 잘못된 difficulty 값 | -| 404 | 리소스 없음 | 존재하지 않는 단어 | -| 500 | 서버 오류 | 내부 오류 | +| HTTP Code | 설명 | 예시 | +|-----------|--------|------------------------------| +| 400 | 잘못된 요청 | 필수 파라미터 누락, 잘못된 difficulty 값 | +| 404 | 리소스 없음 | 존재하지 않는 단어 | +| 500 | 서버 오류 | 내부 오류 | ### 6.2 에러 응답 형식 diff --git a/vocabulary/seed-data/words.json b/vocabulary/seed-data/words.json index fe07ba2a..a098367f 100644 --- a/vocabulary/seed-data/words.json +++ b/vocabulary/seed-data/words.json @@ -1,73 +1,487 @@ { "words": [ - {"english": "apple", "korean": "사과", "example": "I eat an apple every day.", "level": "BEGINNER", "category": "DAILY"}, - {"english": "book", "korean": "책", "example": "She reads a book before bed.", "level": "BEGINNER", "category": "DAILY"}, - {"english": "cat", "korean": "고양이", "example": "The cat is sleeping on the sofa.", "level": "BEGINNER", "category": "DAILY"}, - {"english": "dog", "korean": "개", "example": "My dog loves to play fetch.", "level": "BEGINNER", "category": "DAILY"}, - {"english": "eat", "korean": "먹다", "example": "We eat dinner at 7 PM.", "level": "BEGINNER", "category": "DAILY"}, - {"english": "family", "korean": "가족", "example": "My family lives in Seoul.", "level": "BEGINNER", "category": "DAILY"}, - {"english": "good", "korean": "좋은", "example": "This is a good idea.", "level": "BEGINNER", "category": "DAILY"}, - {"english": "happy", "korean": "행복한", "example": "She looks very happy today.", "level": "BEGINNER", "category": "DAILY"}, - {"english": "house", "korean": "집", "example": "They bought a new house.", "level": "BEGINNER", "category": "DAILY"}, - {"english": "important", "korean": "중요한", "example": "This meeting is very important.", "level": "BEGINNER", "category": "DAILY"}, - {"english": "job", "korean": "직업", "example": "He got a new job last month.", "level": "BEGINNER", "category": "DAILY"}, - {"english": "kind", "korean": "친절한", "example": "She is always kind to everyone.", "level": "BEGINNER", "category": "DAILY"}, - {"english": "learn", "korean": "배우다", "example": "I want to learn English.", "level": "BEGINNER", "category": "DAILY"}, - {"english": "money", "korean": "돈", "example": "He saved a lot of money.", "level": "BEGINNER", "category": "DAILY"}, - {"english": "name", "korean": "이름", "example": "What is your name?", "level": "BEGINNER", "category": "DAILY"}, - {"english": "open", "korean": "열다", "example": "Please open the window.", "level": "BEGINNER", "category": "DAILY"}, - {"english": "people", "korean": "사람들", "example": "Many people came to the party.", "level": "BEGINNER", "category": "DAILY"}, - {"english": "question", "korean": "질문", "example": "Do you have any questions?", "level": "BEGINNER", "category": "DAILY"}, - {"english": "run", "korean": "달리다", "example": "He runs every morning.", "level": "BEGINNER", "category": "DAILY"}, - {"english": "school", "korean": "학교", "example": "The school starts at 9 AM.", "level": "BEGINNER", "category": "DAILY"}, - {"english": "time", "korean": "시간", "example": "What time is it now?", "level": "BEGINNER", "category": "DAILY"}, - {"english": "understand", "korean": "이해하다", "example": "I understand your concern.", "level": "BEGINNER", "category": "DAILY"}, - {"english": "very", "korean": "매우", "example": "This coffee is very hot.", "level": "BEGINNER", "category": "DAILY"}, - {"english": "water", "korean": "물", "example": "Please give me some water.", "level": "BEGINNER", "category": "DAILY"}, - {"english": "year", "korean": "년", "example": "I lived here for three years.", "level": "BEGINNER", "category": "DAILY"}, - {"english": "achieve", "korean": "달성하다", "example": "She achieved her goal.", "level": "INTERMEDIATE", "category": "BUSINESS"}, - {"english": "benefit", "korean": "이익, 혜택", "example": "What are the benefits of this plan?", "level": "INTERMEDIATE", "category": "BUSINESS"}, - {"english": "challenge", "korean": "도전", "example": "This project is a big challenge.", "level": "INTERMEDIATE", "category": "BUSINESS"}, - {"english": "decision", "korean": "결정", "example": "We need to make a decision today.", "level": "INTERMEDIATE", "category": "BUSINESS"}, - {"english": "efficient", "korean": "효율적인", "example": "This method is more efficient.", "level": "INTERMEDIATE", "category": "BUSINESS"}, - {"english": "flexible", "korean": "유연한", "example": "We need a flexible schedule.", "level": "INTERMEDIATE", "category": "BUSINESS"}, - {"english": "growth", "korean": "성장", "example": "The company showed rapid growth.", "level": "INTERMEDIATE", "category": "BUSINESS"}, - {"english": "implement", "korean": "실행하다", "example": "We will implement the new policy.", "level": "INTERMEDIATE", "category": "BUSINESS"}, - {"english": "invest", "korean": "투자하다", "example": "They decided to invest in technology.", "level": "INTERMEDIATE", "category": "BUSINESS"}, - {"english": "leadership", "korean": "리더십", "example": "Good leadership is essential.", "level": "INTERMEDIATE", "category": "BUSINESS"}, - {"english": "maintain", "korean": "유지하다", "example": "We need to maintain quality.", "level": "INTERMEDIATE", "category": "BUSINESS"}, - {"english": "negotiate", "korean": "협상하다", "example": "Let's negotiate the terms.", "level": "INTERMEDIATE", "category": "BUSINESS"}, - {"english": "opportunity", "korean": "기회", "example": "This is a great opportunity.", "level": "INTERMEDIATE", "category": "BUSINESS"}, - {"english": "performance", "korean": "성과", "example": "His performance was excellent.", "level": "INTERMEDIATE", "category": "BUSINESS"}, - {"english": "quality", "korean": "품질", "example": "Quality is our top priority.", "level": "INTERMEDIATE", "category": "BUSINESS"}, - {"english": "revenue", "korean": "수익", "example": "Revenue increased by 20%.", "level": "INTERMEDIATE", "category": "BUSINESS"}, - {"english": "strategy", "korean": "전략", "example": "We need a new marketing strategy.", "level": "INTERMEDIATE", "category": "BUSINESS"}, - {"english": "target", "korean": "목표", "example": "We reached our sales target.", "level": "INTERMEDIATE", "category": "BUSINESS"}, - {"english": "utilize", "korean": "활용하다", "example": "We should utilize all resources.", "level": "INTERMEDIATE", "category": "BUSINESS"}, - {"english": "valuable", "korean": "가치 있는", "example": "Your feedback is valuable.", "level": "INTERMEDIATE", "category": "BUSINESS"}, - {"english": "workflow", "korean": "워크플로우", "example": "Improve your workflow efficiency.", "level": "INTERMEDIATE", "category": "BUSINESS"}, - {"english": "yield", "korean": "산출하다", "example": "This investment will yield profit.", "level": "INTERMEDIATE", "category": "BUSINESS"}, - {"english": "zone", "korean": "구역", "example": "This is a no-parking zone.", "level": "INTERMEDIATE", "category": "BUSINESS"}, - {"english": "adjacent", "korean": "인접한", "example": "The two buildings are adjacent.", "level": "INTERMEDIATE", "category": "BUSINESS"}, - {"english": "abstract", "korean": "추상적인", "example": "The concept is too abstract.", "level": "ADVANCED", "category": "ACADEMIC"}, - {"english": "comprehensive", "korean": "포괄적인", "example": "We need a comprehensive analysis.", "level": "ADVANCED", "category": "ACADEMIC"}, - {"english": "contemporary", "korean": "현대의", "example": "Contemporary art is fascinating.", "level": "ADVANCED", "category": "ACADEMIC"}, - {"english": "differentiate", "korean": "구별하다", "example": "Can you differentiate between them?", "level": "ADVANCED", "category": "ACADEMIC"}, - {"english": "empirical", "korean": "경험적인", "example": "We need empirical evidence.", "level": "ADVANCED", "category": "ACADEMIC"}, - {"english": "fundamental", "korean": "근본적인", "example": "This is a fundamental problem.", "level": "ADVANCED", "category": "ACADEMIC"}, - {"english": "hypothesis", "korean": "가설", "example": "The hypothesis was proven correct.", "level": "ADVANCED", "category": "ACADEMIC"}, - {"english": "indigenous", "korean": "토착의", "example": "Indigenous plants are protected.", "level": "ADVANCED", "category": "ACADEMIC"}, - {"english": "jurisdiction", "korean": "관할권", "example": "This is outside our jurisdiction.", "level": "ADVANCED", "category": "ACADEMIC"}, - {"english": "methodology", "korean": "방법론", "example": "What methodology did you use?", "level": "ADVANCED", "category": "ACADEMIC"}, - {"english": "phenomenon", "korean": "현상", "example": "This is a natural phenomenon.", "level": "ADVANCED", "category": "ACADEMIC"}, - {"english": "paradigm", "korean": "패러다임", "example": "We need a paradigm shift.", "level": "ADVANCED", "category": "ACADEMIC"}, - {"english": "qualitative", "korean": "질적인", "example": "This is a qualitative study.", "level": "ADVANCED", "category": "ACADEMIC"}, - {"english": "quantitative", "korean": "양적인", "example": "We need quantitative data.", "level": "ADVANCED", "category": "ACADEMIC"}, - {"english": "synthesis", "korean": "합성, 종합", "example": "This requires a synthesis of ideas.", "level": "ADVANCED", "category": "ACADEMIC"}, - {"english": "theoretical", "korean": "이론적인", "example": "This is a theoretical framework.", "level": "ADVANCED", "category": "ACADEMIC"}, - {"english": "unprecedented", "korean": "전례 없는", "example": "This is an unprecedented situation.", "level": "ADVANCED", "category": "ACADEMIC"}, - {"english": "validity", "korean": "타당성", "example": "We must check the validity.", "level": "ADVANCED", "category": "ACADEMIC"}, - {"english": "variable", "korean": "변수", "example": "Control all variables carefully.", "level": "ADVANCED", "category": "ACADEMIC"}, - {"english": "ambiguous", "korean": "애매한", "example": "The statement is ambiguous.", "level": "ADVANCED", "category": "ACADEMIC"} + { + "english": "apple", + "korean": "사과", + "example": "I eat an apple every day.", + "level": "BEGINNER", + "category": "DAILY" + }, + { + "english": "book", + "korean": "책", + "example": "She reads a book before bed.", + "level": "BEGINNER", + "category": "DAILY" + }, + { + "english": "cat", + "korean": "고양이", + "example": "The cat is sleeping on the sofa.", + "level": "BEGINNER", + "category": "DAILY" + }, + { + "english": "dog", + "korean": "개", + "example": "My dog loves to play fetch.", + "level": "BEGINNER", + "category": "DAILY" + }, + { + "english": "eat", + "korean": "먹다", + "example": "We eat dinner at 7 PM.", + "level": "BEGINNER", + "category": "DAILY" + }, + { + "english": "family", + "korean": "가족", + "example": "My family lives in Seoul.", + "level": "BEGINNER", + "category": "DAILY" + }, + { + "english": "good", + "korean": "좋은", + "example": "This is a good idea.", + "level": "BEGINNER", + "category": "DAILY" + }, + { + "english": "happy", + "korean": "행복한", + "example": "She looks very happy today.", + "level": "BEGINNER", + "category": "DAILY" + }, + { + "english": "house", + "korean": "집", + "example": "They bought a new house.", + "level": "BEGINNER", + "category": "DAILY" + }, + { + "english": "important", + "korean": "중요한", + "example": "This meeting is very important.", + "level": "BEGINNER", + "category": "DAILY" + }, + { + "english": "job", + "korean": "직업", + "example": "He got a new job last month.", + "level": "BEGINNER", + "category": "DAILY" + }, + { + "english": "kind", + "korean": "친절한", + "example": "She is always kind to everyone.", + "level": "BEGINNER", + "category": "DAILY" + }, + { + "english": "learn", + "korean": "배우다", + "example": "I want to learn English.", + "level": "BEGINNER", + "category": "DAILY" + }, + { + "english": "money", + "korean": "돈", + "example": "He saved a lot of money.", + "level": "BEGINNER", + "category": "DAILY" + }, + { + "english": "name", + "korean": "이름", + "example": "What is your name?", + "level": "BEGINNER", + "category": "DAILY" + }, + { + "english": "open", + "korean": "열다", + "example": "Please open the window.", + "level": "BEGINNER", + "category": "DAILY" + }, + { + "english": "people", + "korean": "사람들", + "example": "Many people came to the party.", + "level": "BEGINNER", + "category": "DAILY" + }, + { + "english": "question", + "korean": "질문", + "example": "Do you have any questions?", + "level": "BEGINNER", + "category": "DAILY" + }, + { + "english": "run", + "korean": "달리다", + "example": "He runs every morning.", + "level": "BEGINNER", + "category": "DAILY" + }, + { + "english": "school", + "korean": "학교", + "example": "The school starts at 9 AM.", + "level": "BEGINNER", + "category": "DAILY" + }, + { + "english": "time", + "korean": "시간", + "example": "What time is it now?", + "level": "BEGINNER", + "category": "DAILY" + }, + { + "english": "understand", + "korean": "이해하다", + "example": "I understand your concern.", + "level": "BEGINNER", + "category": "DAILY" + }, + { + "english": "very", + "korean": "매우", + "example": "This coffee is very hot.", + "level": "BEGINNER", + "category": "DAILY" + }, + { + "english": "water", + "korean": "물", + "example": "Please give me some water.", + "level": "BEGINNER", + "category": "DAILY" + }, + { + "english": "year", + "korean": "년", + "example": "I lived here for three years.", + "level": "BEGINNER", + "category": "DAILY" + }, + { + "english": "achieve", + "korean": "달성하다", + "example": "She achieved her goal.", + "level": "INTERMEDIATE", + "category": "BUSINESS" + }, + { + "english": "benefit", + "korean": "이익, 혜택", + "example": "What are the benefits of this plan?", + "level": "INTERMEDIATE", + "category": "BUSINESS" + }, + { + "english": "challenge", + "korean": "도전", + "example": "This project is a big challenge.", + "level": "INTERMEDIATE", + "category": "BUSINESS" + }, + { + "english": "decision", + "korean": "결정", + "example": "We need to make a decision today.", + "level": "INTERMEDIATE", + "category": "BUSINESS" + }, + { + "english": "efficient", + "korean": "효율적인", + "example": "This method is more efficient.", + "level": "INTERMEDIATE", + "category": "BUSINESS" + }, + { + "english": "flexible", + "korean": "유연한", + "example": "We need a flexible schedule.", + "level": "INTERMEDIATE", + "category": "BUSINESS" + }, + { + "english": "growth", + "korean": "성장", + "example": "The company showed rapid growth.", + "level": "INTERMEDIATE", + "category": "BUSINESS" + }, + { + "english": "implement", + "korean": "실행하다", + "example": "We will implement the new policy.", + "level": "INTERMEDIATE", + "category": "BUSINESS" + }, + { + "english": "invest", + "korean": "투자하다", + "example": "They decided to invest in technology.", + "level": "INTERMEDIATE", + "category": "BUSINESS" + }, + { + "english": "leadership", + "korean": "리더십", + "example": "Good leadership is essential.", + "level": "INTERMEDIATE", + "category": "BUSINESS" + }, + { + "english": "maintain", + "korean": "유지하다", + "example": "We need to maintain quality.", + "level": "INTERMEDIATE", + "category": "BUSINESS" + }, + { + "english": "negotiate", + "korean": "협상하다", + "example": "Let's negotiate the terms.", + "level": "INTERMEDIATE", + "category": "BUSINESS" + }, + { + "english": "opportunity", + "korean": "기회", + "example": "This is a great opportunity.", + "level": "INTERMEDIATE", + "category": "BUSINESS" + }, + { + "english": "performance", + "korean": "성과", + "example": "His performance was excellent.", + "level": "INTERMEDIATE", + "category": "BUSINESS" + }, + { + "english": "quality", + "korean": "품질", + "example": "Quality is our top priority.", + "level": "INTERMEDIATE", + "category": "BUSINESS" + }, + { + "english": "revenue", + "korean": "수익", + "example": "Revenue increased by 20%.", + "level": "INTERMEDIATE", + "category": "BUSINESS" + }, + { + "english": "strategy", + "korean": "전략", + "example": "We need a new marketing strategy.", + "level": "INTERMEDIATE", + "category": "BUSINESS" + }, + { + "english": "target", + "korean": "목표", + "example": "We reached our sales target.", + "level": "INTERMEDIATE", + "category": "BUSINESS" + }, + { + "english": "utilize", + "korean": "활용하다", + "example": "We should utilize all resources.", + "level": "INTERMEDIATE", + "category": "BUSINESS" + }, + { + "english": "valuable", + "korean": "가치 있는", + "example": "Your feedback is valuable.", + "level": "INTERMEDIATE", + "category": "BUSINESS" + }, + { + "english": "workflow", + "korean": "워크플로우", + "example": "Improve your workflow efficiency.", + "level": "INTERMEDIATE", + "category": "BUSINESS" + }, + { + "english": "yield", + "korean": "산출하다", + "example": "This investment will yield profit.", + "level": "INTERMEDIATE", + "category": "BUSINESS" + }, + { + "english": "zone", + "korean": "구역", + "example": "This is a no-parking zone.", + "level": "INTERMEDIATE", + "category": "BUSINESS" + }, + { + "english": "adjacent", + "korean": "인접한", + "example": "The two buildings are adjacent.", + "level": "INTERMEDIATE", + "category": "BUSINESS" + }, + { + "english": "abstract", + "korean": "추상적인", + "example": "The concept is too abstract.", + "level": "ADVANCED", + "category": "ACADEMIC" + }, + { + "english": "comprehensive", + "korean": "포괄적인", + "example": "We need a comprehensive analysis.", + "level": "ADVANCED", + "category": "ACADEMIC" + }, + { + "english": "contemporary", + "korean": "현대의", + "example": "Contemporary art is fascinating.", + "level": "ADVANCED", + "category": "ACADEMIC" + }, + { + "english": "differentiate", + "korean": "구별하다", + "example": "Can you differentiate between them?", + "level": "ADVANCED", + "category": "ACADEMIC" + }, + { + "english": "empirical", + "korean": "경험적인", + "example": "We need empirical evidence.", + "level": "ADVANCED", + "category": "ACADEMIC" + }, + { + "english": "fundamental", + "korean": "근본적인", + "example": "This is a fundamental problem.", + "level": "ADVANCED", + "category": "ACADEMIC" + }, + { + "english": "hypothesis", + "korean": "가설", + "example": "The hypothesis was proven correct.", + "level": "ADVANCED", + "category": "ACADEMIC" + }, + { + "english": "indigenous", + "korean": "토착의", + "example": "Indigenous plants are protected.", + "level": "ADVANCED", + "category": "ACADEMIC" + }, + { + "english": "jurisdiction", + "korean": "관할권", + "example": "This is outside our jurisdiction.", + "level": "ADVANCED", + "category": "ACADEMIC" + }, + { + "english": "methodology", + "korean": "방법론", + "example": "What methodology did you use?", + "level": "ADVANCED", + "category": "ACADEMIC" + }, + { + "english": "phenomenon", + "korean": "현상", + "example": "This is a natural phenomenon.", + "level": "ADVANCED", + "category": "ACADEMIC" + }, + { + "english": "paradigm", + "korean": "패러다임", + "example": "We need a paradigm shift.", + "level": "ADVANCED", + "category": "ACADEMIC" + }, + { + "english": "qualitative", + "korean": "질적인", + "example": "This is a qualitative study.", + "level": "ADVANCED", + "category": "ACADEMIC" + }, + { + "english": "quantitative", + "korean": "양적인", + "example": "We need quantitative data.", + "level": "ADVANCED", + "category": "ACADEMIC" + }, + { + "english": "synthesis", + "korean": "합성, 종합", + "example": "This requires a synthesis of ideas.", + "level": "ADVANCED", + "category": "ACADEMIC" + }, + { + "english": "theoretical", + "korean": "이론적인", + "example": "This is a theoretical framework.", + "level": "ADVANCED", + "category": "ACADEMIC" + }, + { + "english": "unprecedented", + "korean": "전례 없는", + "example": "This is an unprecedented situation.", + "level": "ADVANCED", + "category": "ACADEMIC" + }, + { + "english": "validity", + "korean": "타당성", + "example": "We must check the validity.", + "level": "ADVANCED", + "category": "ACADEMIC" + }, + { + "english": "variable", + "korean": "변수", + "example": "Control all variables carefully.", + "level": "ADVANCED", + "category": "ACADEMIC" + }, + { + "english": "ambiguous", + "korean": "애매한", + "example": "The statement is ambiguous.", + "level": "ADVANCED", + "category": "ACADEMIC" + } ] } From 742f6779b112a896fe8b749b6eed01c43b29683f Mon Sep 17 00:00:00 2001 From: hyein Heo <128613248+hye-inA@users.noreply.github.com> Date: Tue, 13 Jan 2026 13:59:27 +0900 Subject: [PATCH 137/528] =?UTF-8?q?chore=20:=20GitHub=20Issue,=20PR=20?= =?UTF-8?q?=E2=86=92=20Jira=20=EB=8F=99=EA=B8=B0=ED=99=94=20workflow=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=9E=91=EC=84=B1=20(#218)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore : GitHub Issue → Jira 동기화 workflow 파일 작성 * chore : GitHub PR → Jira 동기화 workflow 파일 작성 --- .github/workflows/github-jira-issue-sync.yml | 4 +- .github/workflows/github-jira-pr-sync.yml | 151 +++++++++++++++++++ 2 files changed, 153 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/github-jira-pr-sync.yml diff --git a/.github/workflows/github-jira-issue-sync.yml b/.github/workflows/github-jira-issue-sync.yml index e095e8ee..b3fdec0f 100644 --- a/.github/workflows/github-jira-issue-sync.yml +++ b/.github/workflows/github-jira-issue-sync.yml @@ -3,9 +3,9 @@ name: Issue-Jira Sync on: issues: - types: [ opened, closed, reopened ] + types: [opened, closed, reopened] issue_comment: - types: [ created ] + types: [created] env: JIRA_PROJECT_KEY: MESP diff --git a/.github/workflows/github-jira-pr-sync.yml b/.github/workflows/github-jira-pr-sync.yml new file mode 100644 index 00000000..2cfce003 --- /dev/null +++ b/.github/workflows/github-jira-pr-sync.yml @@ -0,0 +1,151 @@ +# GitHub PR → Jira 동기화 +name: Github-Jira PR Sync + +on: + pull_request: + types: [opened, closed, reopened] + +env: + JIRA_PROJECT_KEY: MESP + +jobs: + # GitHub PR 생성 → Jira 티켓 생성 + create-jira-from-pr: + runs-on: ubuntu-latest + if: github.event.action == 'opened' + + steps: + - name: Determine Issue Type from PR Title + id: issue-type + run: | + TITLE='${{ github.event.pull_request.title }}' + + # PR 제목 기반 Jira 타입 매핑 + if echo "$TITLE" | grep -qiE "^\[EPIC\]"; then + echo "type=Epic" >> $GITHUB_OUTPUT + elif echo "$TITLE" | grep -qiE "^\[STORY\]"; then + echo "type=Story" >> $GITHUB_OUTPUT + elif echo "$TITLE" | grep -qiE "^(fix:|hotfix:)"; then + echo "type=Bug" >> $GITHUB_OUTPUT + else + # feat:, improve:, refactor:, release:, [TASK] 등 → Task + echo "type=Task" >> $GITHUB_OUTPUT + fi + + - name: Create Jira Issue + id: create-jira + run: | + RESPONSE=$(curl -s -X POST \ + -H "Authorization: Basic $(echo -n '${{ secrets.JIRA_USER_EMAIL }}:${{ secrets.JIRA_API_TOKEN }}' | base64)" \ + -H "Content-Type: application/json" \ + "${{ secrets.JIRA_BASE_URL }}/rest/api/3/issue" \ + -d '{ + "fields": { + "project": {"key": "${{ env.JIRA_PROJECT_KEY }}"}, + "summary": "[PR-#${{ github.event.pull_request.number }}] ${{ github.event.pull_request.title }}", + "description": { + "type": "doc", + "version": 1, + "content": [ + {"type": "paragraph", "content": [{"type": "text", "text": "GitHub PR: ${{ github.event.pull_request.html_url }}"}]}, + {"type": "paragraph", "content": [{"type": "text", "text": "Author: ${{ github.event.pull_request.user.login }}"}]}, + {"type": "paragraph", "content": [{"type": "text", "text": "Branch: ${{ github.head_ref }} → ${{ github.base_ref }}"}]} + ] + }, + "issuetype": {"name": "${{ steps.issue-type.outputs.type }}"} + } + }') + + JIRA_KEY=$(echo "$RESPONSE" | jq -r '.key // empty') + echo "jira_key=$JIRA_KEY" >> $GITHUB_OUTPUT + + - name: Update PR with Jira Link + if: steps.create-jira.outputs.jira_key != '' + uses: actions/github-script@v7 + with: + script: | + const jiraKey = '${{ steps.create-jira.outputs.jira_key }}'; + const jiraUrl = '${{ secrets.JIRA_BASE_URL }}/browse/' + jiraKey; + const currentBody = context.payload.pull_request.body || ''; + const newBody = `\n---\n**Jira:** [${jiraKey}](${jiraUrl})\n---\n` + currentBody; + + await github.rest.pulls.update({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.pull_request.number, + body: newBody + }); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + body: `Jira 티켓 생성: [${jiraKey}](${jiraUrl})` + }); + + # PR 머지 → Jira 티켓 Done + close-jira-on-pr-merge: + runs-on: ubuntu-latest + if: github.event.action == 'closed' && github.event.pull_request.merged == true + + steps: + - name: Extract Jira Key and Transition to Done + run: | + BODY='${{ github.event.pull_request.body }}' + JIRA_KEY=$(echo "$BODY" | grep -oE '${{ env.JIRA_PROJECT_KEY }}-[0-9]+' | head -1) + + [ -z "$JIRA_KEY" ] && exit 0 + + TRANSITIONS=$(curl -s -X GET \ + -H "Authorization: Basic $(echo -n '${{ secrets.JIRA_USER_EMAIL }}:${{ secrets.JIRA_API_TOKEN }}' | base64)" \ + "${{ secrets.JIRA_BASE_URL }}/rest/api/3/issue/${JIRA_KEY}/transitions") + + DONE_ID=$(echo "$TRANSITIONS" | jq -r '.transitions[] | select(.name | test("Done|완료|Closed|종료"; "i")) | .id' | head -1) + + [ -z "$DONE_ID" ] && exit 0 + + curl -s -X POST \ + -H "Authorization: Basic $(echo -n '${{ secrets.JIRA_USER_EMAIL }}:${{ secrets.JIRA_API_TOKEN }}' | base64)" \ + -H "Content-Type: application/json" \ + "${{ secrets.JIRA_BASE_URL }}/rest/api/3/issue/${JIRA_KEY}/transitions" \ + -d "{\"transition\": {\"id\": \"$DONE_ID\"}}" + + # Add merge comment to Jira + curl -s -X POST \ + -H "Authorization: Basic $(echo -n '${{ secrets.JIRA_USER_EMAIL }}:${{ secrets.JIRA_API_TOKEN }}' | base64)" \ + -H "Content-Type: application/json" \ + "${{ secrets.JIRA_BASE_URL }}/rest/api/3/issue/${JIRA_KEY}/comment" \ + -d '{ + "body": { + "type": "doc", + "version": 1, + "content": [{"type": "paragraph", "content": [{"type": "text", "text": "PR #${{ github.event.pull_request.number }} merged to ${{ github.base_ref }}"}]}] + } + }' + + # PR 재오픈 → Jira 상태 복구 + reopen-jira-on-pr-reopen: + runs-on: ubuntu-latest + if: github.event.action == 'reopened' + + steps: + - name: Extract Jira Key and Transition to In Progress + run: | + BODY='${{ github.event.pull_request.body }}' + JIRA_KEY=$(echo "$BODY" | grep -oE '${{ env.JIRA_PROJECT_KEY }}-[0-9]+' | head -1) + + [ -z "$JIRA_KEY" ] && exit 0 + + TRANSITIONS=$(curl -s -X GET \ + -H "Authorization: Basic $(echo -n '${{ secrets.JIRA_USER_EMAIL }}:${{ secrets.JIRA_API_TOKEN }}' | base64)" \ + "${{ secrets.JIRA_BASE_URL }}/rest/api/3/issue/${JIRA_KEY}/transitions") + + PROGRESS_ID=$(echo "$TRANSITIONS" | jq -r '.transitions[] | select(.name | test("In Progress|진행|Open|To Do"; "i")) | .id' | head -1) + + [ -z "$PROGRESS_ID" ] && exit 0 + + curl -s -X POST \ + -H "Authorization: Basic $(echo -n '${{ secrets.JIRA_USER_EMAIL }}:${{ secrets.JIRA_API_TOKEN }}' | base64)" \ + -H "Content-Type: application/json" \ + "${{ secrets.JIRA_BASE_URL }}/rest/api/3/issue/${JIRA_KEY}/transitions" \ + -d "{\"transition\": {\"id\": \"$PROGRESS_ID\"}}" \ No newline at end of file From ceb94b95dc61d0b60b5f7acf18776edfe841899c Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 13 Jan 2026 14:04:00 +0900 Subject: [PATCH 138/528] chore: Adjust formatting and remove redundant tags across multiple files - Updated malformed Javadoc tags in `HandlerRouter` and `BeanValidator` for better readability. - Standardized spacing in GitHub Action workflows (`github-jira-pr-sync.yml`, `github-jira-issue-sync.yml`). - Minor style tweaks and empty line additions in `StudyConfig`, `WebSocketConfig`, and `StatsKey` for consistency. --- .github/workflows/github-jira-issue-sync.yml | 4 ++-- .github/workflows/github-jira-pr-sync.yml | 4 ++-- .../serverless/common/config/StudyConfig.java | 1 + .../common/config/WebSocketConfig.java | 1 + .../serverless/common/router/HandlerRouter.java | 8 ++++---- .../common/validation/BeanValidator.java | 16 ++++++++-------- .../domain/stats/constants/StatsKey.java | 1 + 7 files changed, 19 insertions(+), 16 deletions(-) diff --git a/.github/workflows/github-jira-issue-sync.yml b/.github/workflows/github-jira-issue-sync.yml index b3fdec0f..e095e8ee 100644 --- a/.github/workflows/github-jira-issue-sync.yml +++ b/.github/workflows/github-jira-issue-sync.yml @@ -3,9 +3,9 @@ name: Issue-Jira Sync on: issues: - types: [opened, closed, reopened] + types: [ opened, closed, reopened ] issue_comment: - types: [created] + types: [ created ] env: JIRA_PROJECT_KEY: MESP diff --git a/.github/workflows/github-jira-pr-sync.yml b/.github/workflows/github-jira-pr-sync.yml index 2cfce003..c1a7278a 100644 --- a/.github/workflows/github-jira-pr-sync.yml +++ b/.github/workflows/github-jira-pr-sync.yml @@ -3,7 +3,7 @@ name: Github-Jira PR Sync on: pull_request: - types: [opened, closed, reopened] + types: [ opened, closed, reopened ] env: JIRA_PROJECT_KEY: MESP @@ -148,4 +148,4 @@ jobs: -H "Authorization: Basic $(echo -n '${{ secrets.JIRA_USER_EMAIL }}:${{ secrets.JIRA_API_TOKEN }}' | base64)" \ -H "Content-Type: application/json" \ "${{ secrets.JIRA_BASE_URL }}/rest/api/3/issue/${JIRA_KEY}/transitions" \ - -d "{\"transition\": {\"id\": \"$PROGRESS_ID\"}}" \ No newline at end of file + -d "{\"transition\": {\"id\": \"$PROGRESS_ID\"}}" diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/StudyConfig.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/StudyConfig.java index 43a769b9..747e346b 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/StudyConfig.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/StudyConfig.java @@ -20,6 +20,7 @@ public final class StudyConfig { // 정답/오답 카운트 초기값 public static final int INITIAL_CORRECT_COUNT = 0; public static final int INITIAL_INCORRECT_COUNT = 0; + private StudyConfig() { } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/WebSocketConfig.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/WebSocketConfig.java index 2fdcb9e6..1ceb6331 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/WebSocketConfig.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/WebSocketConfig.java @@ -14,6 +14,7 @@ public final class WebSocketConfig { // 캐시된 값 (Cold Start 최적화) private static final long CONNECTION_TTL_SECONDS = parseConnectionTtl(); private static final String WEBSOCKET_ENDPOINT = System.getenv(ENV_WEBSOCKET_ENDPOINT); + private WebSocketConfig() { // 인스턴스화 방지 } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/HandlerRouter.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/HandlerRouter.java index e7cc2482..92d6dc1c 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/HandlerRouter.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/HandlerRouter.java @@ -22,12 +22,12 @@ * 선언적 라우팅 + 자동 Path/Query 파라미터 검증 제공 *

* 사용 예시: - *

+ * 

* new HandlerRouter().addRoutes( - * Route.get("/rooms/{roomId}", this::getRoom), // roomId 자동 검증 - * Route.delete("/rooms/{roomId}", this::deleteRoom).requireQueryParams("userId") // roomId + userId 검증 + * Route.get("/rooms/{roomId}", this::getRoom), // roomId 자동 검증 + * Route.delete("/rooms/{roomId}", this::deleteRoom).requireQueryParams("userId") // roomId + userId 검증 * ); - *

+ * */ public class HandlerRouter { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/validation/BeanValidator.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/validation/BeanValidator.java index cc0893bb..27c204d4 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/validation/BeanValidator.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/validation/BeanValidator.java @@ -19,16 +19,16 @@ * DTO에 선언된 @NotNull, @NotEmpty 등의 어노테이션을 검증합니다. *

* 사용 예시: - *

+ * 

* CreateRoomRequest req = ResponseGenerator.gson().fromJson(body, CreateRoomRequest.class); - * + *

* return BeanValidator.validate(req) - * .map(error -> ResponseGenerator.fail(CommonErrorCode.REQUIRED_FIELD_MISSING, error)) - * .orElseGet(() -> { - * // 비즈니스 로직 - * return ResponseGenerator.ok("Success", result); - * }); - *

+ * .map(error -> ResponseGenerator.fail(CommonErrorCode.REQUIRED_FIELD_MISSING, error)) + * .orElseGet(() -> { + * // 비즈니스 로직 + * return ResponseGenerator.ok("Success", result); + * }); + * */ public final class BeanValidator { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/constants/StatsKey.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/constants/StatsKey.java index b426a8da..94c4a4e1 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/constants/StatsKey.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/constants/StatsKey.java @@ -14,6 +14,7 @@ public final class StatsKey { public static final String STATS_WEEKLY = "WEEKLY#"; public static final String STATS_MONTHLY = "MONTHLY#"; public static final String STATS_TOTAL = "TOTAL"; + private StatsKey() { } From 1156571acccf782be24f8913b5694556b4de8525 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 13 Jan 2026 14:30:53 +0900 Subject: [PATCH 139/528] =?UTF-8?q?feat(chatting):=20CommandService=20?= =?UTF-8?q?=EA=B8=B0=EB=B3=B8=20=EA=B5=AC=EC=A1=B0=20=EB=B0=8F=20=EC=8A=AC?= =?UTF-8?q?=EB=9E=98=EC=8B=9C=20=EB=AA=85=EB=A0=B9=EC=96=B4=20=EC=8B=9C?= =?UTF-8?q?=EC=8A=A4=ED=85=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CommandService 클래스 생성 및 명령어 파싱 로직 구현 - CommandResult DTO 생성 (record 타입) - MessageType에 게임 관련 타입 추가 (GAME_START, ROUND_START 등) - ChatRoom 모델에 게임 관련 필드 추가 refs #228 --- .../chatting/dto/response/CommandResult.java | 26 ++ .../domain/chatting/enums/MessageType.java | 14 +- .../domain/chatting/model/ChatRoom.java | 16 ++ .../chatting/service/CommandService.java | 235 ++++++++++++++++++ 4 files changed, 290 insertions(+), 1 deletion(-) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/CommandResult.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/CommandService.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/CommandResult.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/CommandResult.java new file mode 100644 index 00000000..6082e3c4 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/CommandResult.java @@ -0,0 +1,26 @@ +package com.mzc.secondproject.serverless.domain.chatting.dto.response; + +import com.mzc.secondproject.serverless.domain.chatting.enums.MessageType; + +/** + * 슬래시 명령어 처리 결과 + */ +public record CommandResult( + MessageType messageType, + String message, + boolean success, + Object data +) { + + public static CommandResult success(MessageType messageType, String message) { + return new CommandResult(messageType, message, true, null); + } + + public static CommandResult success(MessageType messageType, String message, Object data) { + return new CommandResult(messageType, message, true, data); + } + + public static CommandResult error(String message) { + return new CommandResult(MessageType.SYSTEM_COMMAND, message, false, null); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/MessageType.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/MessageType.java index 688425c1..5736db6d 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/MessageType.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/MessageType.java @@ -6,7 +6,19 @@ public enum MessageType { TEXT("text", "텍스트"), IMAGE("image", "이미지"), VOICE("voice", "음성"), - AI_RESPONSE("ai_response", "AI 응답"); + AI_RESPONSE("ai_response", "AI 응답"), + + // 게임 관련 메시지 타입 + GAME_START("game_start", "게임 시작"), + GAME_END("game_end", "게임 종료"), + ROUND_START("round_start", "라운드 시작"), + ROUND_END("round_end", "라운드 종료"), + DRAWING("drawing", "그림 데이터"), + DRAWING_CLEAR("drawing_clear", "그림 초기화"), + CORRECT_ANSWER("correct_answer", "정답"), + SCORE_UPDATE("score_update", "점수 업데이트"), + SYSTEM_COMMAND("system_command", "시스템 명령"), + HINT("hint", "힌트"); private final String code; private final String displayName; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/ChatRoom.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/ChatRoom.java index f90530c1..1de3bd4c 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/ChatRoom.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/ChatRoom.java @@ -7,6 +7,7 @@ import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.*; import java.util.List; +import java.util.Map; @Data @Builder @@ -33,6 +34,21 @@ public class ChatRoom { private String lastMessageAt; private List memberIds; // 참여 멤버 목록 private Long ttl; + + // 게임 관련 필드 + private String gameStatus; // NONE, WAITING, PLAYING, ROUND_END, FINISHED + private String gameStartedBy; // 게임 시작한 사용자 ID + private Integer currentRound; // 현재 라운드 (1부터 시작) + private Integer totalRounds; // 총 라운드 수 + private String currentDrawerId; // 현재 출제자 userId + private String currentWordId; // 현재 제시어 wordId + private String currentWord; // 현재 제시어 (korean) + private Long roundStartTime; // 라운드 시작 시간 (Unix timestamp) + private Integer roundTimeLimit; // 라운드 제한 시간 (초) + private List drawerOrder; // 출제 순서 (userId 목록) + private Map scores; // 사용자별 점수 + private Boolean hintUsed; // 현재 라운드 힌트 사용 여부 + private List correctGuessers; // 현재 라운드 정답자 목록 @DynamoDbPartitionKey @DynamoDbAttribute("PK") diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/CommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/CommandService.java new file mode 100644 index 00000000..08133c0d --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/CommandService.java @@ -0,0 +1,235 @@ +package com.mzc.secondproject.serverless.domain.chatting.service; + +import com.mzc.secondproject.serverless.domain.chatting.dto.response.CommandResult; +import com.mzc.secondproject.serverless.domain.chatting.enums.MessageType; +import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; +import com.mzc.secondproject.serverless.domain.chatting.model.Connection; +import com.mzc.secondproject.serverless.domain.chatting.repository.ChatRoomRepository; +import com.mzc.secondproject.serverless.domain.chatting.repository.ConnectionRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * 슬래시 명령어 처리 서비스 + */ +public class CommandService { + + private static final Logger logger = LoggerFactory.getLogger(CommandService.class); + + private final ConnectionRepository connectionRepository; + private final ChatRoomRepository chatRoomRepository; + + public CommandService() { + this.connectionRepository = new ConnectionRepository(); + this.chatRoomRepository = new ChatRoomRepository(); + } + + /** + * 명령어 처리 + * @param content 메시지 내용 + * @param roomId 채팅방 ID + * @param userId 사용자 ID + * @return 명령어 처리 결과 (명령어가 아닌 경우 Optional.empty()) + */ + public Optional processCommand(String content, String roomId, String userId) { + if (content == null || !content.startsWith("/")) { + return Optional.empty(); + } + + String[] parts = content.trim().split("\\s+", 2); + String command = parts[0].toLowerCase(); + + logger.info("Processing command: {} from user: {} in room: {}", command, userId, roomId); + + return switch (command) { + case "/member", "/members" -> Optional.of(handleMemberCommand(roomId)); + case "/start" -> Optional.of(handleStartCommand(roomId, userId)); + case "/stop" -> Optional.of(handleStopCommand(roomId, userId)); + case "/score" -> Optional.of(handleScoreCommand(roomId)); + case "/skip" -> Optional.of(handleSkipCommand(roomId, userId)); + case "/hint" -> Optional.of(handleHintCommand(roomId, userId)); + case "/help" -> Optional.of(handleHelpCommand()); + default -> Optional.empty(); + }; + } + + /** + * /member - 현재 접속자 목록 조회 + */ + private CommandResult handleMemberCommand(String roomId) { + List connections = connectionRepository.findByRoomId(roomId); + + if (connections.isEmpty()) { + return CommandResult.success(MessageType.SYSTEM_COMMAND, "현재 접속자가 없습니다."); + } + + String memberList = connections.stream() + .map(Connection::getUserId) + .collect(Collectors.joining(", ")); + + String message = String.format("현재 접속자 (%d명): %s", connections.size(), memberList); + return CommandResult.success(MessageType.SYSTEM_COMMAND, message, connections.size()); + } + + /** + * /start - 게임 시작 + */ + private CommandResult handleStartCommand(String roomId, String userId) { + List connections = connectionRepository.findByRoomId(roomId); + + if (connections.size() < 2) { + return CommandResult.error("최소 2명 이상 접속해야 게임을 시작할 수 있습니다. (현재: " + connections.size() + "명)"); + } + + Optional optRoom = chatRoomRepository.findById(roomId); + if (optRoom.isEmpty()) { + return CommandResult.error("채팅방을 찾을 수 없습니다."); + } + + ChatRoom room = optRoom.get(); + + // 이미 게임 중인지 확인 + if (room.getGameStatus() != null && !"NONE".equals(room.getGameStatus()) && !"FINISHED".equals(room.getGameStatus())) { + return CommandResult.error("이미 게임이 진행 중입니다."); + } + + // TODO: GameService.startGame() 호출 (Story #223에서 구현) + return CommandResult.success(MessageType.GAME_START, "게임이 곧 시작됩니다! 준비하세요."); + } + + /** + * /stop - 게임 중단 + */ + private CommandResult handleStopCommand(String roomId, String userId) { + Optional optRoom = chatRoomRepository.findById(roomId); + if (optRoom.isEmpty()) { + return CommandResult.error("채팅방을 찾을 수 없습니다."); + } + + ChatRoom room = optRoom.get(); + + // 게임 진행 중인지 확인 + if (room.getGameStatus() == null || "NONE".equals(room.getGameStatus()) || "FINISHED".equals(room.getGameStatus())) { + return CommandResult.error("진행 중인 게임이 없습니다."); + } + + // 권한 확인: 게임 시작한 사람 또는 방장 + boolean isOwner = userId.equals(room.getCreatedBy()); + boolean isGameStarter = userId.equals(room.getGameStartedBy()); + + if (!isOwner && !isGameStarter) { + return CommandResult.error("게임을 중단할 권한이 없습니다. (방장 또는 게임 시작자만 가능)"); + } + + // TODO: GameService.stopGame() 호출 (Story #223에서 구현) + return CommandResult.success(MessageType.GAME_END, "게임이 중단되었습니다."); + } + + /** + * /score - 현재 점수 조회 + */ + private CommandResult handleScoreCommand(String roomId) { + Optional optRoom = chatRoomRepository.findById(roomId); + if (optRoom.isEmpty()) { + return CommandResult.error("채팅방을 찾을 수 없습니다."); + } + + ChatRoom room = optRoom.get(); + + if (room.getGameStatus() == null || "NONE".equals(room.getGameStatus())) { + return CommandResult.error("진행 중인 게임이 없습니다."); + } + + // TODO: 점수 포맷팅 (Story #225에서 구현) + if (room.getScores() == null || room.getScores().isEmpty()) { + return CommandResult.success(MessageType.SCORE_UPDATE, "아직 점수가 없습니다."); + } + + StringBuilder sb = new StringBuilder("📊 현재 점수:\n"); + room.getScores().entrySet().stream() + .sorted((a, b) -> b.getValue().compareTo(a.getValue())) + .forEach(entry -> sb.append(String.format(" %s: %d점\n", entry.getKey(), entry.getValue()))); + + return CommandResult.success(MessageType.SCORE_UPDATE, sb.toString(), room.getScores()); + } + + /** + * /skip - 라운드 스킵 (출제자만) + */ + private CommandResult handleSkipCommand(String roomId, String userId) { + Optional optRoom = chatRoomRepository.findById(roomId); + if (optRoom.isEmpty()) { + return CommandResult.error("채팅방을 찾을 수 없습니다."); + } + + ChatRoom room = optRoom.get(); + + if (!"PLAYING".equals(room.getGameStatus())) { + return CommandResult.error("게임이 진행 중이 아닙니다."); + } + + if (!userId.equals(room.getCurrentDrawerId())) { + return CommandResult.error("출제자만 라운드를 스킵할 수 있습니다."); + } + + // TODO: GameService.skipRound() 호출 (Story #223에서 구현) + return CommandResult.success(MessageType.ROUND_END, "라운드가 스킵되었습니다. 정답: " + room.getCurrentWord()); + } + + /** + * /hint - 힌트 제공 (출제자만) + */ + private CommandResult handleHintCommand(String roomId, String userId) { + Optional optRoom = chatRoomRepository.findById(roomId); + if (optRoom.isEmpty()) { + return CommandResult.error("채팅방을 찾을 수 없습니다."); + } + + ChatRoom room = optRoom.get(); + + if (!"PLAYING".equals(room.getGameStatus())) { + return CommandResult.error("게임이 진행 중이 아닙니다."); + } + + if (!userId.equals(room.getCurrentDrawerId())) { + return CommandResult.error("출제자만 힌트를 제공할 수 있습니다."); + } + + // 힌트 사용 여부 체크 + if (Boolean.TRUE.equals(room.getHintUsed())) { + return CommandResult.error("이번 라운드에서 이미 힌트를 사용했습니다."); + } + + String currentWord = room.getCurrentWord(); + if (currentWord == null || currentWord.isEmpty()) { + return CommandResult.error("제시어가 설정되지 않았습니다."); + } + + // 첫 글자 힌트 + String hint = currentWord.charAt(0) + "○".repeat(currentWord.length() - 1); + + // TODO: hintUsed 플래그 업데이트 (Story #223에서 구현) + return CommandResult.success(MessageType.HINT, "💡 힌트: " + hint); + } + + /** + * /help - 도움말 + */ + private CommandResult handleHelpCommand() { + String helpMessage = """ + 📖 사용 가능한 명령어: + /member - 현재 접속자 목록 + /start - 게임 시작 (2명 이상) + /stop - 게임 중단 + /score - 현재 점수 보기 + /skip - 라운드 스킵 (출제자) + /hint - 힌트 보기 (출제자) + /help - 도움말 + """; + return CommandResult.success(MessageType.SYSTEM_COMMAND, helpMessage); + } +} From d71512e5fe618eab1e3a68fa96fb29e540b3e02d Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 13 Jan 2026 14:33:08 +0900 Subject: [PATCH 140/528] =?UTF-8?q?feat(chatting):=20WebSocketMessageHandl?= =?UTF-8?q?er=EC=97=90=20=EC=8A=AC=EB=9E=98=EC=8B=9C=20=EB=AA=85=EB=A0=B9?= =?UTF-8?q?=EC=96=B4=20=EC=B2=98=EB=A6=AC=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CommandService를 WebSocketMessageHandler에 주입 - 메시지가 /로 시작하면 명령어로 처리 - 명령어 결과를 SYSTEM 메시지로 브로드캐스트 refs #232 --- .../websocket/WebSocketMessageHandler.java | 56 +++++++++++++++++-- 1 file changed, 52 insertions(+), 4 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java index 3a134caa..dc22d0d8 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java @@ -5,11 +5,13 @@ import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.mzc.secondproject.serverless.common.util.WebSocketBroadcaster; +import com.mzc.secondproject.serverless.domain.chatting.dto.response.CommandResult; import com.mzc.secondproject.serverless.domain.chatting.model.ChatMessage; import com.mzc.secondproject.serverless.domain.chatting.model.Connection; import com.mzc.secondproject.serverless.domain.chatting.repository.ChatRoomRepository; import com.mzc.secondproject.serverless.domain.chatting.repository.ConnectionRepository; import com.mzc.secondproject.serverless.domain.chatting.service.ChatMessageService; +import com.mzc.secondproject.serverless.domain.chatting.service.CommandService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -32,12 +34,14 @@ public class WebSocketMessageHandler implements RequestHandler handleRequest(Map event, Context cont if (payload.roomId == null || payload.userId == null || payload.content == null) { return createResponse(400, "roomId, userId, and content are required"); } - + + // 슬래시 명령어 처리 + var commandResult = commandService.processCommand(payload.content, payload.roomId, payload.userId); + if (commandResult.isPresent()) { + return handleCommandResult(commandResult.get(), payload.roomId, payload.userId); + } + String messageType = payload.messageType != null ? payload.messageType : "TEXT"; String messageId = UUID.randomUUID().toString(); String now = Instant.now().toString(); - + ChatMessage message = ChatMessage.builder() .pk("ROOM#" + payload.roomId) .sk("MSG#" + now + "#" + messageId) @@ -113,7 +123,45 @@ private Map createResponse(int statusCode, String body) { response.put("body", body); return response; } - + + /** + * 명령어 처리 결과를 브로드캐스트 + */ + private Map handleCommandResult(CommandResult result, String roomId, String userId) { + String messageId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + + // 시스템 메시지 생성 + ChatMessage systemMessage = ChatMessage.builder() + .pk("ROOM#" + roomId) + .sk("MSG#" + now + "#" + messageId) + .gsi1pk("SYSTEM") + .gsi1sk("MSG#" + now) + .gsi2pk("MSG#" + messageId) + .gsi2sk("ROOM#" + roomId) + .messageId(messageId) + .roomId(roomId) + .userId("SYSTEM") + .content(result.message()) + .messageType(result.messageType().getCode()) + .createdAt(now) + .build(); + + // 명령어 결과는 저장하지 않고 브로드캐스트만 수행 + List connections = connectionRepository.findByRoomId(roomId); + String broadcastPayload = gson.toJson(systemMessage); + List failedConnections = broadcaster.broadcast(connections, broadcastPayload); + + // 실패한 연결 정리 + for (String failedConnectionId : failedConnections) { + connectionRepository.delete(failedConnectionId); + logger.info("Deleted stale connection: {}", failedConnectionId); + } + + logger.info("Command result broadcasted: type={}, roomId={}", result.messageType(), roomId); + return createResponse(200, "Command executed"); + } + /** * 메시지 페이로드 DTO */ From 8e85f8fd68e6735f39f27b2ef117b98b9ffc981c Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 13 Jan 2026 14:41:33 +0900 Subject: [PATCH 141/528] =?UTF-8?q?feat(chatting):=20=EA=B2=8C=EC=9E=84=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EA=B4=80=EB=A6=AC=20=EB=B0=8F=20=EB=9D=BC?= =?UTF-8?q?=EC=9A=B4=EB=93=9C=20=EC=A7=84=ED=96=89=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GameStatus enum 생성 (NONE, WAITING, PLAYING, ROUND_END, FINISHED) - GameRound 모델 및 GameRoundRepository 생성 - GameService 구현: - startGame(): 게임 시작 (출제 순서 생성, 단어 추출) - stopGame(): 게임 강제 종료 - checkAnswer(): 정답 체크 및 점수 계산 - skipRound(): 라운드 스킵 - provideHint(): 힌트 제공 - endRound(): 라운드 종료 및 다음 라운드 진행 - CommandService에 GameService 연동 refs #234, #235, #236 --- .../domain/chatting/enums/GameStatus.java | 49 ++ .../domain/chatting/model/GameRound.java | 69 +++ .../repository/GameRoundRepository.java | 79 +++ .../chatting/service/CommandService.java | 101 +--- .../domain/chatting/service/GameService.java | 485 ++++++++++++++++++ 5 files changed, 699 insertions(+), 84 deletions(-) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/GameStatus.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameRound.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/GameRoundRepository.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/GameStatus.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/GameStatus.java new file mode 100644 index 00000000..d5534add --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/GameStatus.java @@ -0,0 +1,49 @@ +package com.mzc.secondproject.serverless.domain.chatting.enums; + +import java.util.Arrays; + +public enum GameStatus { + NONE("none", "게임 없음"), + WAITING("waiting", "게임 대기 중"), + PLAYING("playing", "게임 진행 중"), + ROUND_END("round_end", "라운드 종료"), + FINISHED("finished", "게임 종료"); + + private final String code; + private final String displayName; + + GameStatus(String code, String displayName) { + this.code = code; + this.displayName = displayName; + } + + public String getCode() { + return code; + } + + public String getDisplayName() { + return displayName; + } + + public static boolean isValid(String value) { + if (value == null) return false; + return Arrays.stream(values()) + .anyMatch(status -> status.name().equalsIgnoreCase(value) || status.code.equalsIgnoreCase(value)); + } + + public static GameStatus fromString(String value) { + if (value == null) return NONE; + return Arrays.stream(values()) + .filter(status -> status.name().equalsIgnoreCase(value) || status.code.equalsIgnoreCase(value)) + .findFirst() + .orElse(NONE); + } + + public boolean isGameActive() { + return this == PLAYING || this == ROUND_END; + } + + public boolean canStartGame() { + return this == NONE || this == FINISHED; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameRound.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameRound.java new file mode 100644 index 00000000..38dcdd8b --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameRound.java @@ -0,0 +1,69 @@ +package com.mzc.secondproject.serverless.domain.chatting.model; + +import lombok.*; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.*; + +import java.util.List; +import java.util.Map; + +/** + * 캐치마인드 게임 라운드 기록 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DynamoDbBean +public class GameRound { + + private String pk; // ROOM#{roomId}#GAME + private String sk; // ROUND#{roundNumber} + + private String roomId; + private Integer roundNumber; + private String drawerId; // 출제자 userId + private String wordId; // 제시어 wordId + private String word; // 제시어 (korean) + private String wordEnglish; // 제시어 (english) + + private List correctGuessers; // 정답 맞춘 순서 + private Map guessTimes; // userId -> 정답까지 걸린 시간(ms) + private Map roundScores; // userId -> 이 라운드 획득 점수 + + private Long startTime; // 라운드 시작 시간 (Unix timestamp ms) + private Long endTime; // 라운드 종료 시간 + private String endReason; // TIME_UP, ALL_CORRECT, SKIP + + private Boolean hintUsed; // 힌트 사용 여부 + private String createdAt; + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { + return pk; + } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { + return sk; + } + + /** + * 라운드가 진행 중인지 확인 + */ + @DynamoDbIgnore + public boolean isActive() { + return endTime == null; + } + + /** + * 경과 시간 계산 (ms) + */ + @DynamoDbIgnore + public long getElapsedTime() { + if (startTime == null) return 0; + long end = endTime != null ? endTime : System.currentTimeMillis(); + return end - startTime; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/GameRoundRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/GameRoundRepository.java new file mode 100644 index 00000000..60f0bacf --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/GameRoundRepository.java @@ -0,0 +1,79 @@ +package com.mzc.secondproject.serverless.domain.chatting.repository; + +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.domain.chatting.model.GameRound; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; +import software.amazon.awssdk.enhanced.dynamodb.Key; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * 게임 라운드 저장소 + */ +public class GameRoundRepository { + + private static final Logger logger = LoggerFactory.getLogger(GameRoundRepository.class); + private static final String TABLE_NAME = System.getenv("CHAT_TABLE_NAME"); + + private final DynamoDbTable table; + + public GameRoundRepository() { + this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(GameRound.class)); + } + + public GameRound save(GameRound gameRound) { + logger.info("Saving game round: roomId={}, round={}", gameRound.getRoomId(), gameRound.getRoundNumber()); + table.putItem(gameRound); + return gameRound; + } + + public Optional findByRoomIdAndRound(String roomId, Integer roundNumber) { + Key key = Key.builder() + .partitionValue("ROOM#" + roomId + "#GAME") + .sortValue("ROUND#" + roundNumber) + .build(); + + GameRound round = table.getItem(key); + return Optional.ofNullable(round); + } + + /** + * 특정 게임의 모든 라운드 조회 + */ + public List findByRoomId(String roomId) { + QueryConditional queryConditional = QueryConditional + .keyEqualTo(Key.builder() + .partitionValue("ROOM#" + roomId + "#GAME") + .build()); + + QueryEnhancedRequest request = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .build(); + + return table.query(request).stream() + .flatMap(page -> page.items().stream()) + .collect(Collectors.toList()); + } + + /** + * 특정 게임의 모든 라운드 삭제 + */ + public void deleteByRoomId(String roomId) { + List rounds = findByRoomId(roomId); + for (GameRound round : rounds) { + Key key = Key.builder() + .partitionValue(round.getPk()) + .sortValue(round.getSk()) + .build(); + table.deleteItem(key); + } + logger.info("Deleted {} rounds for roomId={}", rounds.size(), roomId); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/CommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/CommandService.java index 08133c0d..bd209f37 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/CommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/CommandService.java @@ -22,10 +22,12 @@ public class CommandService { private final ConnectionRepository connectionRepository; private final ChatRoomRepository chatRoomRepository; + private final GameService gameService; public CommandService() { this.connectionRepository = new ConnectionRepository(); this.chatRoomRepository = new ChatRoomRepository(); + this.gameService = new GameService(); } /** @@ -79,54 +81,30 @@ private CommandResult handleMemberCommand(String roomId) { * /start - 게임 시작 */ private CommandResult handleStartCommand(String roomId, String userId) { - List connections = connectionRepository.findByRoomId(roomId); - - if (connections.size() < 2) { - return CommandResult.error("최소 2명 이상 접속해야 게임을 시작할 수 있습니다. (현재: " + connections.size() + "명)"); - } + GameService.GameStartResult result = gameService.startGame(roomId, userId); - Optional optRoom = chatRoomRepository.findById(roomId); - if (optRoom.isEmpty()) { - return CommandResult.error("채팅방을 찾을 수 없습니다."); + if (!result.success()) { + return CommandResult.error(result.error()); } - ChatRoom room = optRoom.get(); + String message = String.format(""" + 🎮 게임 시작! + 총 %d 라운드 - // 이미 게임 중인지 확인 - if (room.getGameStatus() != null && !"NONE".equals(room.getGameStatus()) && !"FINISHED".equals(room.getGameStatus())) { - return CommandResult.error("이미 게임이 진행 중입니다."); - } + 라운드 1 시작! + 출제자: %s + """, + result.room().getTotalRounds(), + result.room().getCurrentDrawerId()); - // TODO: GameService.startGame() 호출 (Story #223에서 구현) - return CommandResult.success(MessageType.GAME_START, "게임이 곧 시작됩니다! 준비하세요."); + return CommandResult.success(MessageType.GAME_START, message, result); } /** * /stop - 게임 중단 */ private CommandResult handleStopCommand(String roomId, String userId) { - Optional optRoom = chatRoomRepository.findById(roomId); - if (optRoom.isEmpty()) { - return CommandResult.error("채팅방을 찾을 수 없습니다."); - } - - ChatRoom room = optRoom.get(); - - // 게임 진행 중인지 확인 - if (room.getGameStatus() == null || "NONE".equals(room.getGameStatus()) || "FINISHED".equals(room.getGameStatus())) { - return CommandResult.error("진행 중인 게임이 없습니다."); - } - - // 권한 확인: 게임 시작한 사람 또는 방장 - boolean isOwner = userId.equals(room.getCreatedBy()); - boolean isGameStarter = userId.equals(room.getGameStartedBy()); - - if (!isOwner && !isGameStarter) { - return CommandResult.error("게임을 중단할 권한이 없습니다. (방장 또는 게임 시작자만 가능)"); - } - - // TODO: GameService.stopGame() 호출 (Story #223에서 구현) - return CommandResult.success(MessageType.GAME_END, "게임이 중단되었습니다."); + return gameService.stopGame(roomId, userId); } /** @@ -161,59 +139,14 @@ private CommandResult handleScoreCommand(String roomId) { * /skip - 라운드 스킵 (출제자만) */ private CommandResult handleSkipCommand(String roomId, String userId) { - Optional optRoom = chatRoomRepository.findById(roomId); - if (optRoom.isEmpty()) { - return CommandResult.error("채팅방을 찾을 수 없습니다."); - } - - ChatRoom room = optRoom.get(); - - if (!"PLAYING".equals(room.getGameStatus())) { - return CommandResult.error("게임이 진행 중이 아닙니다."); - } - - if (!userId.equals(room.getCurrentDrawerId())) { - return CommandResult.error("출제자만 라운드를 스킵할 수 있습니다."); - } - - // TODO: GameService.skipRound() 호출 (Story #223에서 구현) - return CommandResult.success(MessageType.ROUND_END, "라운드가 스킵되었습니다. 정답: " + room.getCurrentWord()); + return gameService.skipRound(roomId, userId); } /** * /hint - 힌트 제공 (출제자만) */ private CommandResult handleHintCommand(String roomId, String userId) { - Optional optRoom = chatRoomRepository.findById(roomId); - if (optRoom.isEmpty()) { - return CommandResult.error("채팅방을 찾을 수 없습니다."); - } - - ChatRoom room = optRoom.get(); - - if (!"PLAYING".equals(room.getGameStatus())) { - return CommandResult.error("게임이 진행 중이 아닙니다."); - } - - if (!userId.equals(room.getCurrentDrawerId())) { - return CommandResult.error("출제자만 힌트를 제공할 수 있습니다."); - } - - // 힌트 사용 여부 체크 - if (Boolean.TRUE.equals(room.getHintUsed())) { - return CommandResult.error("이번 라운드에서 이미 힌트를 사용했습니다."); - } - - String currentWord = room.getCurrentWord(); - if (currentWord == null || currentWord.isEmpty()) { - return CommandResult.error("제시어가 설정되지 않았습니다."); - } - - // 첫 글자 힌트 - String hint = currentWord.charAt(0) + "○".repeat(currentWord.length() - 1); - - // TODO: hintUsed 플래그 업데이트 (Story #223에서 구현) - return CommandResult.success(MessageType.HINT, "💡 힌트: " + hint); + return gameService.provideHint(roomId, userId); } /** diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java new file mode 100644 index 00000000..ccf66977 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java @@ -0,0 +1,485 @@ +package com.mzc.secondproject.serverless.domain.chatting.service; + +import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.domain.chatting.dto.response.CommandResult; +import com.mzc.secondproject.serverless.domain.chatting.enums.GameStatus; +import com.mzc.secondproject.serverless.domain.chatting.enums.MessageType; +import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; +import com.mzc.secondproject.serverless.domain.chatting.model.Connection; +import com.mzc.secondproject.serverless.domain.chatting.model.GameRound; +import com.mzc.secondproject.serverless.domain.chatting.repository.ChatRoomRepository; +import com.mzc.secondproject.serverless.domain.chatting.repository.ConnectionRepository; +import com.mzc.secondproject.serverless.domain.chatting.repository.GameRoundRepository; +import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; +import com.mzc.secondproject.serverless.domain.vocabulary.repository.WordRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.util.*; +import java.util.stream.Collectors; + +/** + * 캐치마인드 게임 로직 서비스 + */ +public class GameService { + + private static final Logger logger = LoggerFactory.getLogger(GameService.class); + private static final int DEFAULT_TOTAL_ROUNDS = 5; + private static final int DEFAULT_ROUND_TIME_LIMIT = 60; // 초 + + private final ChatRoomRepository chatRoomRepository; + private final ConnectionRepository connectionRepository; + private final GameRoundRepository gameRoundRepository; + private final WordRepository wordRepository; + + public GameService() { + this.chatRoomRepository = new ChatRoomRepository(); + this.connectionRepository = new ConnectionRepository(); + this.gameRoundRepository = new GameRoundRepository(); + this.wordRepository = new WordRepository(); + } + + /** + * 게임 시작 + */ + public GameStartResult startGame(String roomId, String userId) { + ChatRoom room = chatRoomRepository.findById(roomId) + .orElseThrow(() -> new IllegalArgumentException("채팅방을 찾을 수 없습니다.")); + + // 이미 게임 중인지 확인 + GameStatus currentStatus = GameStatus.fromString(room.getGameStatus()); + if (!currentStatus.canStartGame()) { + return GameStartResult.error("이미 게임이 진행 중입니다."); + } + + // 접속자 확인 + List connections = connectionRepository.findByRoomId(roomId); + if (connections.size() < 2) { + return GameStartResult.error("최소 2명 이상 접속해야 게임을 시작할 수 있습니다."); + } + + // 출제 순서 생성 (랜덤 셔플) + List drawerOrder = connections.stream() + .map(Connection::getUserId) + .collect(Collectors.toList()); + Collections.shuffle(drawerOrder); + + // 제시어 추출 (난이도별) + String level = room.getLevel() != null ? room.getLevel() : "beginner"; + List words = getRandomWords(level, DEFAULT_TOTAL_ROUNDS); + + if (words.size() < DEFAULT_TOTAL_ROUNDS) { + return GameStartResult.error("단어가 부족합니다. 관리자에게 문의하세요."); + } + + // 게임 상태 업데이트 + room.setGameStatus(GameStatus.PLAYING.name()); + room.setGameStartedBy(userId); + room.setCurrentRound(1); + room.setTotalRounds(DEFAULT_TOTAL_ROUNDS); + room.setDrawerOrder(drawerOrder); + room.setScores(new HashMap<>()); + room.setRoundTimeLimit(DEFAULT_ROUND_TIME_LIMIT); + + // 첫 라운드 설정 + String firstDrawer = drawerOrder.get(0); + Word firstWord = words.get(0); + room.setCurrentDrawerId(firstDrawer); + room.setCurrentWordId(firstWord.getWordId()); + room.setCurrentWord(firstWord.getKorean()); + room.setRoundStartTime(System.currentTimeMillis()); + room.setHintUsed(false); + room.setCorrectGuessers(new ArrayList<>()); + + chatRoomRepository.save(room); + + // 첫 라운드 기록 생성 + GameRound firstRound = GameRound.builder() + .pk("ROOM#" + roomId + "#GAME") + .sk("ROUND#1") + .roomId(roomId) + .roundNumber(1) + .drawerId(firstDrawer) + .wordId(firstWord.getWordId()) + .word(firstWord.getKorean()) + .wordEnglish(firstWord.getEnglish()) + .startTime(System.currentTimeMillis()) + .hintUsed(false) + .correctGuessers(new ArrayList<>()) + .guessTimes(new HashMap<>()) + .roundScores(new HashMap<>()) + .createdAt(Instant.now().toString()) + .build(); + + gameRoundRepository.save(firstRound); + + logger.info("Game started: roomId={}, starter={}, rounds={}", roomId, userId, DEFAULT_TOTAL_ROUNDS); + + return GameStartResult.success(room, firstWord, drawerOrder); + } + + /** + * 게임 종료 + */ + public CommandResult stopGame(String roomId, String userId) { + ChatRoom room = chatRoomRepository.findById(roomId) + .orElseThrow(() -> new IllegalArgumentException("채팅방을 찾을 수 없습니다.")); + + GameStatus currentStatus = GameStatus.fromString(room.getGameStatus()); + if (!currentStatus.isGameActive()) { + return CommandResult.error("진행 중인 게임이 없습니다."); + } + + // 권한 확인 + boolean isOwner = userId.equals(room.getCreatedBy()); + boolean isGameStarter = userId.equals(room.getGameStartedBy()); + + if (!isOwner && !isGameStarter) { + return CommandResult.error("게임을 중단할 권한이 없습니다."); + } + + // 게임 종료 처리 + return finishGame(room, "STOPPED"); + } + + /** + * 정답 체크 + */ + public AnswerCheckResult checkAnswer(String roomId, String userId, String answer) { + ChatRoom room = chatRoomRepository.findById(roomId) + .orElseThrow(() -> new IllegalArgumentException("채팅방을 찾을 수 없습니다.")); + + // 게임 진행 중인지 확인 + if (!GameStatus.PLAYING.name().equals(room.getGameStatus())) { + return AnswerCheckResult.gameNotPlaying(); + } + + // 출제자는 정답 체크 제외 + if (userId.equals(room.getCurrentDrawerId())) { + return AnswerCheckResult.drawerCannotGuess(); + } + + // 이미 맞춘 사람인지 확인 + if (room.getCorrectGuessers() != null && room.getCorrectGuessers().contains(userId)) { + return AnswerCheckResult.alreadyGuessedCorrect(); + } + + // 정답 체크 + String currentWord = room.getCurrentWord(); + if (!isCorrectAnswer(answer, currentWord)) { + return AnswerCheckResult.wrongAnswer(); + } + + // 정답 처리 + long elapsedTime = System.currentTimeMillis() - room.getRoundStartTime(); + int score = calculateScore(room, elapsedTime, userId); + + // 정답자 목록에 추가 + if (room.getCorrectGuessers() == null) { + room.setCorrectGuessers(new ArrayList<>()); + } + room.getCorrectGuessers().add(userId); + + // 점수 업데이트 + if (room.getScores() == null) { + room.setScores(new HashMap<>()); + } + room.getScores().merge(userId, score, Integer::sum); + + // 출제자 점수도 추가 + room.getScores().merge(room.getCurrentDrawerId(), 5, Integer::sum); + + chatRoomRepository.save(room); + + // 라운드 기록 업데이트 + updateRoundRecord(roomId, room.getCurrentRound(), userId, elapsedTime, score); + + // 전원 정답 체크 + List connections = connectionRepository.findByRoomId(roomId); + int nonDrawerCount = (int) connections.stream() + .filter(c -> !c.getUserId().equals(room.getCurrentDrawerId())) + .count(); + + boolean allCorrect = room.getCorrectGuessers().size() >= nonDrawerCount; + + logger.info("Answer correct: roomId={}, userId={}, score={}, allCorrect={}", + roomId, userId, score, allCorrect); + + return AnswerCheckResult.correctAnswer(score, elapsedTime, allCorrect, room.getScores()); + } + + /** + * 라운드 스킵 + */ + public CommandResult skipRound(String roomId, String userId) { + ChatRoom room = chatRoomRepository.findById(roomId) + .orElseThrow(() -> new IllegalArgumentException("채팅방을 찾을 수 없습니다.")); + + if (!GameStatus.PLAYING.name().equals(room.getGameStatus())) { + return CommandResult.error("게임이 진행 중이 아닙니다."); + } + + if (!userId.equals(room.getCurrentDrawerId())) { + return CommandResult.error("출제자만 라운드를 스킵할 수 있습니다."); + } + + return endRound(room, "SKIP"); + } + + /** + * 힌트 제공 + */ + public CommandResult provideHint(String roomId, String userId) { + ChatRoom room = chatRoomRepository.findById(roomId) + .orElseThrow(() -> new IllegalArgumentException("채팅방을 찾을 수 없습니다.")); + + if (!GameStatus.PLAYING.name().equals(room.getGameStatus())) { + return CommandResult.error("게임이 진행 중이 아닙니다."); + } + + if (!userId.equals(room.getCurrentDrawerId())) { + return CommandResult.error("출제자만 힌트를 제공할 수 있습니다."); + } + + if (Boolean.TRUE.equals(room.getHintUsed())) { + return CommandResult.error("이번 라운드에서 이미 힌트를 사용했습니다."); + } + + String currentWord = room.getCurrentWord(); + String hint = currentWord.charAt(0) + "○".repeat(currentWord.length() - 1); + + room.setHintUsed(true); + chatRoomRepository.save(room); + + // 라운드 기록 업데이트 + gameRoundRepository.findByRoomIdAndRound(roomId, room.getCurrentRound()) + .ifPresent(round -> { + round.setHintUsed(true); + gameRoundRepository.save(round); + }); + + return CommandResult.success(MessageType.HINT, "💡 힌트: " + hint); + } + + /** + * 라운드 종료 처리 + */ + public CommandResult endRound(ChatRoom room, String reason) { + String roomId = room.getRoomId(); + Integer currentRound = room.getCurrentRound(); + String answer = room.getCurrentWord(); + + // 라운드 기록 종료 + gameRoundRepository.findByRoomIdAndRound(roomId, currentRound) + .ifPresent(round -> { + round.setEndTime(System.currentTimeMillis()); + round.setEndReason(reason); + gameRoundRepository.save(round); + }); + + // 다음 라운드로 진행 + if (currentRound >= room.getTotalRounds()) { + return finishGame(room, "COMPLETED"); + } + + // 다음 라운드 준비 + int nextRound = currentRound + 1; + int drawerIndex = (nextRound - 1) % room.getDrawerOrder().size(); + String nextDrawer = room.getDrawerOrder().get(drawerIndex); + + // 다음 단어 추출 + String level = room.getLevel() != null ? room.getLevel() : "beginner"; + List words = getRandomWords(level, 1); + if (words.isEmpty()) { + return finishGame(room, "NO_WORDS"); + } + Word nextWord = words.get(0); + + // 상태 업데이트 + room.setCurrentRound(nextRound); + room.setCurrentDrawerId(nextDrawer); + room.setCurrentWordId(nextWord.getWordId()); + room.setCurrentWord(nextWord.getKorean()); + room.setRoundStartTime(System.currentTimeMillis()); + room.setHintUsed(false); + room.setCorrectGuessers(new ArrayList<>()); + + chatRoomRepository.save(room); + + // 다음 라운드 기록 생성 + GameRound nextRoundRecord = GameRound.builder() + .pk("ROOM#" + roomId + "#GAME") + .sk("ROUND#" + nextRound) + .roomId(roomId) + .roundNumber(nextRound) + .drawerId(nextDrawer) + .wordId(nextWord.getWordId()) + .word(nextWord.getKorean()) + .wordEnglish(nextWord.getEnglish()) + .startTime(System.currentTimeMillis()) + .hintUsed(false) + .correctGuessers(new ArrayList<>()) + .guessTimes(new HashMap<>()) + .roundScores(new HashMap<>()) + .createdAt(Instant.now().toString()) + .build(); + + gameRoundRepository.save(nextRoundRecord); + + String message = String.format("라운드 %d 종료! 정답: %s\n\n라운드 %d 시작! 출제자: %s", + currentRound, answer, nextRound, nextDrawer); + + logger.info("Round ended: roomId={}, round={}, reason={}", roomId, currentRound, reason); + + return CommandResult.success(MessageType.ROUND_END, message, + Map.of("answer", answer, "nextRound", nextRound, "nextDrawer", nextDrawer, "nextWord", nextWord)); + } + + /** + * 게임 완전 종료 + */ + private CommandResult finishGame(ChatRoom room, String reason) { + room.setGameStatus(GameStatus.FINISHED.name()); + chatRoomRepository.save(room); + + // 최종 점수 정렬 + StringBuilder sb = new StringBuilder("🎮 게임 종료!\n\n📊 최종 순위:\n"); + if (room.getScores() != null && !room.getScores().isEmpty()) { + List> sorted = room.getScores().entrySet().stream() + .sorted((a, b) -> b.getValue().compareTo(a.getValue())) + .toList(); + + int rank = 1; + for (Map.Entry entry : sorted) { + String medal = switch (rank) { + case 1 -> "🥇"; + case 2 -> "🥈"; + case 3 -> "🥉"; + default -> rank + "위"; + }; + sb.append(String.format(" %s %s: %d점\n", medal, entry.getKey(), entry.getValue())); + rank++; + } + } else { + sb.append(" 점수 없음"); + } + + logger.info("Game finished: roomId={}, reason={}", room.getRoomId(), reason); + + return CommandResult.success(MessageType.GAME_END, sb.toString(), room.getScores()); + } + + /** + * 랜덤 단어 추출 + */ + private List getRandomWords(String level, int count) { + PaginatedResult result = wordRepository.findByLevelWithPagination(level, 50, null); + List words = new ArrayList<>(result.items()); + Collections.shuffle(words); + return words.stream().limit(count).collect(Collectors.toList()); + } + + /** + * 정답 체크 로직 + */ + private boolean isCorrectAnswer(String input, String answer) { + if (input == null || answer == null) return false; + + String normalizedInput = input.trim().toLowerCase().replace(" ", ""); + String normalizedAnswer = answer.trim().toLowerCase().replace(" ", ""); + + return normalizedInput.equals(normalizedAnswer); + } + + /** + * 점수 계산 + */ + private int calculateScore(ChatRoom room, long elapsedTimeMs, String userId) { + int baseScore = 10; + + // 시간 보너스 (빨리 맞출수록 높은 점수) + int elapsedSeconds = (int) (elapsedTimeMs / 1000); + int timeLimit = room.getRoundTimeLimit() != null ? room.getRoundTimeLimit() : DEFAULT_ROUND_TIME_LIMIT; + int timeBonus = Math.max(0, (timeLimit - elapsedSeconds) / 2); + + // 연속 정답 보너스 (추후 구현) + int streakBonus = 0; + + return baseScore + timeBonus + streakBonus; + } + + /** + * 라운드 기록 업데이트 + */ + private void updateRoundRecord(String roomId, Integer roundNumber, String userId, long elapsedTime, int score) { + gameRoundRepository.findByRoomIdAndRound(roomId, roundNumber) + .ifPresent(round -> { + if (round.getCorrectGuessers() == null) { + round.setCorrectGuessers(new ArrayList<>()); + } + round.getCorrectGuessers().add(userId); + + if (round.getGuessTimes() == null) { + round.setGuessTimes(new HashMap<>()); + } + round.getGuessTimes().put(userId, elapsedTime); + + if (round.getRoundScores() == null) { + round.setRoundScores(new HashMap<>()); + } + round.getRoundScores().put(userId, score); + + gameRoundRepository.save(round); + }); + } + + // ========== Result DTOs ========== + + public record GameStartResult( + boolean success, + String error, + ChatRoom room, + Word firstWord, + List drawerOrder + ) { + public static GameStartResult success(ChatRoom room, Word word, List order) { + return new GameStartResult(true, null, room, word, order); + } + + public static GameStartResult error(String message) { + return new GameStartResult(false, message, null, null, null); + } + } + + public record AnswerCheckResult( + boolean correct, + boolean drawer, + boolean alreadyGuessed, + boolean gameNotActive, + boolean allCorrect, + int score, + long elapsedTime, + Map scores + ) { + public static AnswerCheckResult correctAnswer(int score, long elapsed, boolean allCorrect, Map scores) { + return new AnswerCheckResult(true, false, false, false, allCorrect, score, elapsed, scores); + } + + public static AnswerCheckResult wrongAnswer() { + return new AnswerCheckResult(false, false, false, false, false, 0, 0, null); + } + + public static AnswerCheckResult drawerCannotGuess() { + return new AnswerCheckResult(false, true, false, false, false, 0, 0, null); + } + + public static AnswerCheckResult alreadyGuessedCorrect() { + return new AnswerCheckResult(false, false, true, false, false, 0, 0, null); + } + + public static AnswerCheckResult gameNotPlaying() { + return new AnswerCheckResult(false, false, false, true, false, 0, 0, null); + } + } +} From b2a4cf5822f8ddd4e455eef7888663ac0ba8ec69 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 13 Jan 2026 14:45:32 +0900 Subject: [PATCH 142/528] =?UTF-8?q?feat:=20DRAWING=20=EB=A9=94=EC=8B=9C?= =?UTF-8?q?=EC=A7=80=20=ED=83=80=EC=9E=85=20=EC=B2=98=EB=A6=AC=20=EB=B0=8F?= =?UTF-8?q?=20=EB=B8=8C=EB=A1=9C=EB=93=9C=EC=BA=90=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WebSocketMessageHandler에 메시지 타입별 처리 로직 추가 - DRAWING/DRAWING_CLEAR 메시지는 저장 없이 실시간 브로드캐스트 - 그림 데이터는 본인 제외 다른 접속자에게만 전송 - 게임 중 TEXT 메시지 정답 체크 로직 추가 - 정답 시 CORRECT_ANSWER 메시지 브로드캐스트 - 전원 정답 시 자동 라운드 종료 처리 --- .../websocket/WebSocketMessageHandler.java | 213 ++++++++++++++---- 1 file changed, 166 insertions(+), 47 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java index dc22d0d8..a2b057a2 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java @@ -10,8 +10,10 @@ import com.mzc.secondproject.serverless.domain.chatting.model.Connection; import com.mzc.secondproject.serverless.domain.chatting.repository.ChatRoomRepository; import com.mzc.secondproject.serverless.domain.chatting.repository.ConnectionRepository; +import com.mzc.secondproject.serverless.domain.chatting.enums.MessageType; import com.mzc.secondproject.serverless.domain.chatting.service.ChatMessageService; import com.mzc.secondproject.serverless.domain.chatting.service.CommandService; +import com.mzc.secondproject.serverless.domain.chatting.service.GameService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -35,6 +37,7 @@ public class WebSocketMessageHandler implements RequestHandler handleRequest(Map event, Context context) { logger.info("WebSocket message event: {}", event); - + try { String connectionId = extractConnectionId(event); String body = (String) event.get("body"); - + if (body == null || body.isEmpty()) { return createResponse(400, "Message body is required"); } - + MessagePayload payload = gson.fromJson(body, MessagePayload.class); - - if (payload.roomId == null || payload.userId == null || payload.content == null) { - return createResponse(400, "roomId, userId, and content are required"); - } - // 슬래시 명령어 처리 - var commandResult = commandService.processCommand(payload.content, payload.roomId, payload.userId); - if (commandResult.isPresent()) { - return handleCommandResult(commandResult.get(), payload.roomId, payload.userId); + if (payload.roomId == null || payload.userId == null) { + return createResponse(400, "roomId and userId are required"); } String messageType = payload.messageType != null ? payload.messageType : "TEXT"; - String messageId = UUID.randomUUID().toString(); - String now = Instant.now().toString(); - - ChatMessage message = ChatMessage.builder() - .pk("ROOM#" + payload.roomId) - .sk("MSG#" + now + "#" + messageId) - .gsi1pk("USER#" + payload.userId) - .gsi1sk("MSG#" + now) - .gsi2pk("MSG#" + messageId) - .gsi2sk("ROOM#" + payload.roomId) - .messageId(messageId) - .roomId(payload.roomId) - .userId(payload.userId) - .content(payload.content) - .messageType(messageType) - .createdAt(now) - .build(); - - ChatMessage savedMessage = chatMessageService.saveMessage(message); - chatRoomRepository.updateLastMessageAt(payload.roomId, now); - - logger.info("Message saved: messageId={}, roomId={}", messageId, payload.roomId); - - // 브로드캐스트 - List connections = connectionRepository.findByRoomId(payload.roomId); - String broadcastPayload = gson.toJson(savedMessage); - List failedConnections = broadcaster.broadcast(connections, broadcastPayload); - - // 실패한 연결 정리 - for (String failedConnectionId : failedConnections) { - connectionRepository.delete(failedConnectionId); - logger.info("Deleted stale connection: {}", failedConnectionId); - } - - return createResponse(200, "Message sent"); - + + // 메시지 타입별 처리 + return switch (messageType.toUpperCase()) { + case "DRAWING", "DRAWING_CLEAR" -> handleDrawingMessage(connectionId, payload, messageType); + default -> handleRegularMessage(connectionId, payload, messageType); + }; + } catch (Exception e) { logger.error("Error handling message: {}", e.getMessage(), e); return createResponse(500, "Internal server error"); } } + + /** + * 그림 데이터 처리 (DRAWING, DRAWING_CLEAR) + * - 저장하지 않음 (실시간 전송만) + * - 출제자만 그릴 수 있음 + * - 본인 제외 브로드캐스트 + */ + private Map handleDrawingMessage(String connectionId, MessagePayload payload, String messageType) { + logger.info("Drawing message: type={}, roomId={}, userId={}", messageType, payload.roomId, payload.userId); + + // 그림 데이터 메시지 생성 (저장 안 함) + Map drawingMessage = new HashMap<>(); + drawingMessage.put("messageType", messageType); + drawingMessage.put("roomId", payload.roomId); + drawingMessage.put("userId", payload.userId); + drawingMessage.put("content", payload.content); + drawingMessage.put("createdAt", Instant.now().toString()); + + // 본인 제외 브로드캐스트 + List connections = connectionRepository.findByRoomId(payload.roomId); + List otherConnections = connections.stream() + .filter(c -> !c.getConnectionId().equals(connectionId)) + .toList(); + + String broadcastPayload = gson.toJson(drawingMessage); + List failedConnections = broadcaster.broadcast(otherConnections, broadcastPayload); + + // 실패한 연결 정리 + for (String failedConnectionId : failedConnections) { + connectionRepository.delete(failedConnectionId); + logger.info("Deleted stale connection: {}", failedConnectionId); + } + + logger.info("Drawing broadcasted to {} connections (excluding sender)", otherConnections.size()); + return createResponse(200, "Drawing sent"); + } + + /** + * 일반 메시지 처리 (TEXT 등) + */ + private Map handleRegularMessage(String connectionId, MessagePayload payload, String messageType) { + if (payload.content == null) { + return createResponse(400, "content is required for text messages"); + } + + // 슬래시 명령어 처리 + var commandResult = commandService.processCommand(payload.content, payload.roomId, payload.userId); + if (commandResult.isPresent()) { + return handleCommandResult(commandResult.get(), payload.roomId, payload.userId); + } + + // 게임 중 정답 체크 + var answerResult = gameService.checkAnswer(payload.roomId, payload.userId, payload.content); + if (answerResult.correct()) { + return handleCorrectAnswer(payload, answerResult); + } + + // 일반 메시지 저장 및 브로드캐스트 + String messageId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + + ChatMessage message = ChatMessage.builder() + .pk("ROOM#" + payload.roomId) + .sk("MSG#" + now + "#" + messageId) + .gsi1pk("USER#" + payload.userId) + .gsi1sk("MSG#" + now) + .gsi2pk("MSG#" + messageId) + .gsi2sk("ROOM#" + payload.roomId) + .messageId(messageId) + .roomId(payload.roomId) + .userId(payload.userId) + .content(payload.content) + .messageType(messageType) + .createdAt(now) + .build(); + + ChatMessage savedMessage = chatMessageService.saveMessage(message); + chatRoomRepository.updateLastMessageAt(payload.roomId, now); + + logger.info("Message saved: messageId={}, roomId={}", messageId, payload.roomId); + + // 브로드캐스트 + List connections = connectionRepository.findByRoomId(payload.roomId); + String broadcastPayload = gson.toJson(savedMessage); + List failedConnections = broadcaster.broadcast(connections, broadcastPayload); + + // 실패한 연결 정리 + for (String failedConnectionId : failedConnections) { + connectionRepository.delete(failedConnectionId); + logger.info("Deleted stale connection: {}", failedConnectionId); + } + + return createResponse(200, "Message sent"); + } + + /** + * 정답 처리 + */ + private Map handleCorrectAnswer(MessagePayload payload, GameService.AnswerCheckResult result) { + String messageId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + + // 정답 알림 메시지 생성 + String message = String.format("🎉 %s님이 정답을 맞췄습니다! (+%d점)", payload.userId, result.score()); + + ChatMessage correctMessage = ChatMessage.builder() + .pk("ROOM#" + payload.roomId) + .sk("MSG#" + now + "#" + messageId) + .gsi1pk("SYSTEM") + .gsi1sk("MSG#" + now) + .gsi2pk("MSG#" + messageId) + .gsi2sk("ROOM#" + payload.roomId) + .messageId(messageId) + .roomId(payload.roomId) + .userId("SYSTEM") + .content(message) + .messageType(MessageType.CORRECT_ANSWER.getCode()) + .createdAt(now) + .build(); + + // 브로드캐스트 + List connections = connectionRepository.findByRoomId(payload.roomId); + String broadcastPayload = gson.toJson(correctMessage); + List failedConnections = broadcaster.broadcast(connections, broadcastPayload); + + // 실패한 연결 정리 + for (String failedConnectionId : failedConnections) { + connectionRepository.delete(failedConnectionId); + logger.info("Deleted stale connection: {}", failedConnectionId); + } + + logger.info("Correct answer: roomId={}, userId={}, score={}", payload.roomId, payload.userId, result.score()); + + // 전원 정답 시 라운드 종료 처리 + if (result.allCorrect()) { + handleAllCorrect(payload.roomId); + } + + return createResponse(200, "Correct answer"); + } + + /** + * 전원 정답 시 라운드 종료 + */ + private void handleAllCorrect(String roomId) { + chatRoomRepository.findById(roomId).ifPresent(room -> { + CommandResult endResult = gameService.endRound(room, "ALL_CORRECT"); + handleCommandResult(endResult, roomId, "SYSTEM"); + }); + } @SuppressWarnings("unchecked") private String extractConnectionId(Map event) { From c3a4f903fab8891aca910397e616f011990cebf8 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 13 Jan 2026 14:51:07 +0900 Subject: [PATCH 143/528] =?UTF-8?q?feat:=20=EC=97=B0=EC=86=8D=20=EC=A0=95?= =?UTF-8?q?=EB=8B=B5=20=EB=B3=B4=EB=84=88=EC=8A=A4=20=EB=B0=8F=20SCORE=5FU?= =?UTF-8?q?PDATE=20=EB=B8=8C=EB=A1=9C=EB=93=9C=EC=BA=90=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChatRoom에 streaks 필드 추가 (사용자별 연속 정답 수) - 점수 계산 공식 구현: 기본(10) + 시간보너스 + 연속보너스 - 시간 보너스: (제한시간 - 경과시간) * 0.5 - 연속 정답 보너스: 연속정답수 * 2 - 정답 시 SCORE_UPDATE 메시지 브로드캐스트 - 라운드 종료 시 정답 못 맞춘 사용자 연속 정답 초기화 --- .../websocket/WebSocketMessageHandler.java | 74 +++++++++++++++---- .../domain/chatting/model/ChatRoom.java | 1 + .../domain/chatting/service/GameService.java | 52 +++++++++++-- 3 files changed, 108 insertions(+), 19 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java index a2b057a2..2e0cd234 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java @@ -178,10 +178,31 @@ private Map handleRegularMessage(String connectionId, MessagePay * 정답 처리 */ private Map handleCorrectAnswer(MessagePayload payload, GameService.AnswerCheckResult result) { + List connections = connectionRepository.findByRoomId(payload.roomId); + + // 1. 정답 알림 메시지 브로드캐스트 + broadcastCorrectAnswerMessage(payload, result, connections); + + // 2. 점수 업데이트 메시지 브로드캐스트 + broadcastScoreUpdate(payload.roomId, result.scores(), connections); + + logger.info("Correct answer: roomId={}, userId={}, score={}", payload.roomId, payload.userId, result.score()); + + // 전원 정답 시 라운드 종료 처리 + if (result.allCorrect()) { + handleAllCorrect(payload.roomId); + } + + return createResponse(200, "Correct answer"); + } + + /** + * 정답 알림 메시지 브로드캐스트 + */ + private void broadcastCorrectAnswerMessage(MessagePayload payload, GameService.AnswerCheckResult result, List connections) { String messageId = UUID.randomUUID().toString(); String now = Instant.now().toString(); - // 정답 알림 메시지 생성 String message = String.format("🎉 %s님이 정답을 맞췄습니다! (+%d점)", payload.userId, result.score()); ChatMessage correctMessage = ChatMessage.builder() @@ -199,25 +220,52 @@ private Map handleCorrectAnswer(MessagePayload payload, GameServ .createdAt(now) .build(); - // 브로드캐스트 - List connections = connectionRepository.findByRoomId(payload.roomId); String broadcastPayload = gson.toJson(correctMessage); List failedConnections = broadcaster.broadcast(connections, broadcastPayload); + cleanupFailedConnections(failedConnections); + } - // 실패한 연결 정리 - for (String failedConnectionId : failedConnections) { - connectionRepository.delete(failedConnectionId); - logger.info("Deleted stale connection: {}", failedConnectionId); + /** + * 점수 업데이트 메시지 브로드캐스트 + */ + private void broadcastScoreUpdate(String roomId, Map scores, List connections) { + if (scores == null || scores.isEmpty()) { + return; } - logger.info("Correct answer: roomId={}, userId={}, score={}", payload.roomId, payload.userId, result.score()); + String messageId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); - // 전원 정답 시 라운드 종료 처리 - if (result.allCorrect()) { - handleAllCorrect(payload.roomId); - } + // 점수 현황 문자열 생성 + StringBuilder sb = new StringBuilder("📊 현재 점수:\n"); + scores.entrySet().stream() + .sorted((a, b) -> b.getValue().compareTo(a.getValue())) + .forEach(entry -> sb.append(String.format(" %s: %d점\n", entry.getKey(), entry.getValue()))); + + Map scoreUpdateMessage = new HashMap<>(); + scoreUpdateMessage.put("messageId", messageId); + scoreUpdateMessage.put("roomId", roomId); + scoreUpdateMessage.put("userId", "SYSTEM"); + scoreUpdateMessage.put("content", sb.toString()); + scoreUpdateMessage.put("messageType", MessageType.SCORE_UPDATE.getCode()); + scoreUpdateMessage.put("createdAt", now); + scoreUpdateMessage.put("scores", scores); + + String broadcastPayload = gson.toJson(scoreUpdateMessage); + List failedConnections = broadcaster.broadcast(connections, broadcastPayload); + cleanupFailedConnections(failedConnections); - return createResponse(200, "Correct answer"); + logger.info("Score update broadcasted: roomId={}", roomId); + } + + /** + * 실패한 연결 정리 + */ + private void cleanupFailedConnections(List failedConnections) { + for (String failedConnectionId : failedConnections) { + connectionRepository.delete(failedConnectionId); + logger.info("Deleted stale connection: {}", failedConnectionId); + } } /** diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/ChatRoom.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/ChatRoom.java index 1de3bd4c..253e496b 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/ChatRoom.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/ChatRoom.java @@ -47,6 +47,7 @@ public class ChatRoom { private Integer roundTimeLimit; // 라운드 제한 시간 (초) private List drawerOrder; // 출제 순서 (userId 목록) private Map scores; // 사용자별 점수 + private Map streaks; // 사용자별 연속 정답 수 private Boolean hintUsed; // 현재 라운드 힌트 사용 여부 private List correctGuessers; // 현재 라운드 정답자 목록 diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java index ccf66977..c2c20ac0 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java @@ -80,6 +80,7 @@ public GameStartResult startGame(String roomId, String userId) { room.setTotalRounds(DEFAULT_TOTAL_ROUNDS); room.setDrawerOrder(drawerOrder); room.setScores(new HashMap<>()); + room.setStreaks(new HashMap<>()); room.setRoundTimeLimit(DEFAULT_ROUND_TIME_LIMIT); // 첫 라운드 설정 @@ -173,7 +174,15 @@ public AnswerCheckResult checkAnswer(String roomId, String userId, String answer // 정답 처리 long elapsedTime = System.currentTimeMillis() - room.getRoundStartTime(); - int score = calculateScore(room, elapsedTime, userId); + + // 연속 정답 업데이트 (점수 계산 전에) + if (room.getStreaks() == null) { + room.setStreaks(new HashMap<>()); + } + int currentStreak = room.getStreaks().getOrDefault(userId, 0) + 1; + room.getStreaks().put(userId, currentStreak); + + int score = calculateScore(room, elapsedTime, userId, currentStreak); // 정답자 목록에 추가 if (room.getCorrectGuessers() == null) { @@ -270,6 +279,9 @@ public CommandResult endRound(ChatRoom room, String reason) { Integer currentRound = room.getCurrentRound(); String answer = room.getCurrentWord(); + // 정답 못 맞춘 사용자 연속 정답 초기화 + resetStreaksForNonGuessers(room); + // 라운드 기록 종료 gameRoundRepository.findByRoomIdAndRound(roomId, currentRound) .ifPresent(round -> { @@ -394,17 +406,25 @@ private boolean isCorrectAnswer(String input, String answer) { /** * 점수 계산 + * @param room 채팅방 + * @param elapsedTimeMs 경과 시간 (밀리초) + * @param userId 사용자 ID + * @param streak 연속 정답 수 + * @return 계산된 점수 */ - private int calculateScore(ChatRoom room, long elapsedTimeMs, String userId) { + private int calculateScore(ChatRoom room, long elapsedTimeMs, String userId, int streak) { int baseScore = 10; - // 시간 보너스 (빨리 맞출수록 높은 점수) + // 시간 보너스 (빨리 맞출수록 높은 점수): (제한시간 - 경과시간) * 0.5 int elapsedSeconds = (int) (elapsedTimeMs / 1000); int timeLimit = room.getRoundTimeLimit() != null ? room.getRoundTimeLimit() : DEFAULT_ROUND_TIME_LIMIT; - int timeBonus = Math.max(0, (timeLimit - elapsedSeconds) / 2); + int timeBonus = Math.max(0, (int) ((timeLimit - elapsedSeconds) * 0.5)); - // 연속 정답 보너스 (추후 구현) - int streakBonus = 0; + // 연속 정답 보너스: 연속정답수 * 2 + int streakBonus = streak * 2; + + logger.info("Score calculation: base={}, timeBonus={}, streakBonus={}, total={}", + baseScore, timeBonus, streakBonus, baseScore + timeBonus + streakBonus); return baseScore + timeBonus + streakBonus; } @@ -434,6 +454,26 @@ private void updateRoundRecord(String roomId, Integer roundNumber, String userId }); } + /** + * 정답 못 맞춘 사용자 연속 정답 초기화 + */ + private void resetStreaksForNonGuessers(ChatRoom room) { + if (room.getStreaks() == null || room.getStreaks().isEmpty()) { + return; + } + + List correctGuessers = room.getCorrectGuessers() != null + ? room.getCorrectGuessers() + : List.of(); + + // 정답 못 맞춘 사용자의 연속 정답 초기화 + room.getStreaks().keySet().stream() + .filter(userId -> !correctGuessers.contains(userId)) + .forEach(userId -> room.getStreaks().put(userId, 0)); + + logger.info("Reset streaks for non-guessers: correctGuessers={}", correctGuessers); + } + // ========== Result DTOs ========== public record GameStartResult( From 03a8569c9af2fb8402d3d57587ce51998c248cd8 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 13 Jan 2026 14:56:43 +0900 Subject: [PATCH 144/528] =?UTF-8?q?feat:=20=EA=B2=8C=EC=9E=84=20REST=20API?= =?UTF-8?q?=20=EB=B0=8F=20Lambda=20=ED=95=A8=EC=88=98=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GameHandler 신규 생성 (HandlerRouter 패턴) - POST /chat/rooms/{roomId}/game/start - 게임 시작 API - POST /chat/rooms/{roomId}/game/stop - 게임 중단 API - GET /chat/rooms/{roomId}/game/status - 게임 상태 조회 API - GET /chat/rooms/{roomId}/game/scores - 점수 조회 API - GameStatusResponse, ScoreboardResponse DTO 추가 - ChattingErrorCode에 게임 관련 에러 코드 추가 - template.yaml에 GameFunction Lambda 구성 추가 --- .../dto/response/GameStatusResponse.java | 37 ++++ .../dto/response/ScoreboardResponse.java | 51 +++++ .../chatting/exception/ChattingErrorCode.java | 7 + .../domain/chatting/handler/GameHandler.java | 194 ++++++++++++++++++ ServerlessFunction/template.yaml | 51 +++++ 5 files changed, 340 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/GameStatusResponse.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/ScoreboardResponse.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameHandler.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/GameStatusResponse.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/GameStatusResponse.java new file mode 100644 index 00000000..5676a93a --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/GameStatusResponse.java @@ -0,0 +1,37 @@ +package com.mzc.secondproject.serverless.domain.chatting.dto.response; + +import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; + +import java.util.List; +import java.util.Map; + +/** + * 게임 상태 응답 DTO + */ +public record GameStatusResponse( + String gameStatus, + Integer currentRound, + Integer totalRounds, + String currentDrawerId, + Long roundStartTime, + Integer roundTimeLimit, + List drawerOrder, + Map scores, + Boolean hintUsed, + List correctGuessers +) { + public static GameStatusResponse from(ChatRoom room, List drawerOrder) { + return new GameStatusResponse( + room.getGameStatus(), + room.getCurrentRound(), + room.getTotalRounds(), + room.getCurrentDrawerId(), + room.getRoundStartTime(), + room.getRoundTimeLimit(), + drawerOrder != null ? drawerOrder : room.getDrawerOrder(), + room.getScores(), + room.getHintUsed(), + room.getCorrectGuessers() + ); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/ScoreboardResponse.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/ScoreboardResponse.java new file mode 100644 index 00000000..bb4e81fb --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/ScoreboardResponse.java @@ -0,0 +1,51 @@ +package com.mzc.secondproject.serverless.domain.chatting.dto.response; + +import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; + +import java.util.Comparator; +import java.util.List; +import java.util.Map; + +/** + * 점수판 응답 DTO + */ +public record ScoreboardResponse( + Map scores, + List ranking, + String gameStatus, + Integer currentRound, + Integer totalRounds +) { + public record RankEntry( + int rank, + String userId, + int score + ) {} + + public static ScoreboardResponse from(ChatRoom room) { + Map scores = room.getScores(); + List ranking = buildRanking(scores); + + return new ScoreboardResponse( + scores, + ranking, + room.getGameStatus(), + room.getCurrentRound(), + room.getTotalRounds() + ); + } + + private static List buildRanking(Map scores) { + if (scores == null || scores.isEmpty()) { + return List.of(); + } + + List> sorted = scores.entrySet().stream() + .sorted(Map.Entry.comparingByValue().reversed()) + .toList(); + + return java.util.stream.IntStream.range(0, sorted.size()) + .mapToObj(i -> new RankEntry(i + 1, sorted.get(i).getKey(), sorted.get(i).getValue())) + .toList(); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCode.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCode.java index dc9785fc..5831547d 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCode.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCode.java @@ -33,6 +33,13 @@ public enum ChattingErrorCode implements DomainErrorCode { // 연결 관련 에러 CONNECTION_FAILED("CONN_001", "연결에 실패했습니다", 500), CONNECTION_TIMEOUT("CONN_002", "연결 시간이 초과되었습니다", 408), + + // 게임 관련 에러 + GAME_START_FAILED("GAME_001", "게임 시작에 실패했습니다", 400), + GAME_STOP_FAILED("GAME_002", "게임 중단에 실패했습니다", 400), + GAME_NOT_IN_PROGRESS("GAME_003", "진행 중인 게임이 없습니다", 400), + GAME_ALREADY_IN_PROGRESS("GAME_004", "이미 게임이 진행 중입니다", 409), + NOT_GAME_STARTER("GAME_005", "게임 시작자만 중단할 수 있습니다", 403), ; private static final String DOMAIN = "CHATTING"; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameHandler.java new file mode 100644 index 00000000..5e506327 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameHandler.java @@ -0,0 +1,194 @@ +package com.mzc.secondproject.serverless.domain.chatting.handler; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import com.mzc.secondproject.serverless.common.router.HandlerRouter; +import com.mzc.secondproject.serverless.common.router.Route; +import com.mzc.secondproject.serverless.common.util.ResponseGenerator; +import com.mzc.secondproject.serverless.common.util.WebSocketBroadcaster; +import com.mzc.secondproject.serverless.domain.chatting.dto.response.CommandResult; +import com.mzc.secondproject.serverless.domain.chatting.dto.response.GameStatusResponse; +import com.mzc.secondproject.serverless.domain.chatting.dto.response.ScoreboardResponse; +import com.mzc.secondproject.serverless.domain.chatting.enums.GameStatus; +import com.mzc.secondproject.serverless.domain.chatting.enums.MessageType; +import com.mzc.secondproject.serverless.domain.chatting.exception.ChattingErrorCode; +import com.mzc.secondproject.serverless.domain.chatting.model.ChatMessage; +import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; +import com.mzc.secondproject.serverless.domain.chatting.model.Connection; +import com.mzc.secondproject.serverless.domain.chatting.repository.ChatRoomRepository; +import com.mzc.secondproject.serverless.domain.chatting.repository.ConnectionRepository; +import com.mzc.secondproject.serverless.domain.chatting.service.GameService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.util.*; + +/** + * 게임 REST API 핸들러 + */ +public class GameHandler implements RequestHandler { + + private static final Logger logger = LoggerFactory.getLogger(GameHandler.class); + + private final GameService gameService; + private final ChatRoomRepository chatRoomRepository; + private final ConnectionRepository connectionRepository; + private final WebSocketBroadcaster broadcaster; + private final HandlerRouter router; + + public GameHandler() { + this.gameService = new GameService(); + this.chatRoomRepository = new ChatRoomRepository(); + this.connectionRepository = new ConnectionRepository(); + this.broadcaster = new WebSocketBroadcaster(); + this.router = initRouter(); + } + + private HandlerRouter initRouter() { + return new HandlerRouter().addRoutes( + Route.postAuth("/rooms/{roomId}/game/start", this::startGame), + Route.postAuth("/rooms/{roomId}/game/stop", this::stopGame), + Route.getAuth("/rooms/{roomId}/game/status", this::getGameStatus), + Route.getAuth("/rooms/{roomId}/game/scores", this::getScores) + ); + } + + @Override + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { + logger.info("Received request: {} {}", request.getHttpMethod(), request.getPath()); + return router.route(request); + } + + /** + * POST /rooms/{roomId}/game/start - 게임 시작 + */ + private APIGatewayProxyResponseEvent startGame(APIGatewayProxyRequestEvent request, String userId) { + String roomId = request.getPathParameters().get("roomId"); + + GameService.GameStartResult result = gameService.startGame(roomId, userId); + + if (!result.success()) { + return ResponseGenerator.fail(ChattingErrorCode.GAME_START_FAILED, result.error()); + } + + // WebSocket으로 게임 시작 알림 브로드캐스트 + broadcastGameStart(roomId, result); + + GameStatusResponse response = GameStatusResponse.from(result.room(), result.drawerOrder()); + return ResponseGenerator.ok("Game started", response); + } + + /** + * POST /rooms/{roomId}/game/stop - 게임 중단 + */ + private APIGatewayProxyResponseEvent stopGame(APIGatewayProxyRequestEvent request, String userId) { + String roomId = request.getPathParameters().get("roomId"); + + CommandResult result = gameService.stopGame(roomId, userId); + + if (!result.success()) { + return ResponseGenerator.fail(ChattingErrorCode.GAME_STOP_FAILED, result.message()); + } + + // WebSocket으로 게임 종료 알림 브로드캐스트 + broadcastSystemMessage(roomId, result.message(), MessageType.GAME_END); + + return ResponseGenerator.ok("Game stopped", Map.of("message", result.message())); + } + + /** + * GET /rooms/{roomId}/game/status - 게임 상태 조회 + */ + private APIGatewayProxyResponseEvent getGameStatus(APIGatewayProxyRequestEvent request, String userId) { + String roomId = request.getPathParameters().get("roomId"); + + Optional optRoom = chatRoomRepository.findById(roomId); + if (optRoom.isEmpty()) { + return ResponseGenerator.fail(ChattingErrorCode.ROOM_NOT_FOUND); + } + + ChatRoom room = optRoom.get(); + GameStatusResponse response = GameStatusResponse.from(room, room.getDrawerOrder()); + + return ResponseGenerator.ok("Game status retrieved", response); + } + + /** + * GET /rooms/{roomId}/game/scores - 점수 조회 + */ + private APIGatewayProxyResponseEvent getScores(APIGatewayProxyRequestEvent request, String userId) { + String roomId = request.getPathParameters().get("roomId"); + + Optional optRoom = chatRoomRepository.findById(roomId); + if (optRoom.isEmpty()) { + return ResponseGenerator.fail(ChattingErrorCode.ROOM_NOT_FOUND); + } + + ChatRoom room = optRoom.get(); + ScoreboardResponse response = ScoreboardResponse.from(room); + + return ResponseGenerator.ok("Scores retrieved", response); + } + + /** + * 게임 시작 브로드캐스트 + */ + private void broadcastGameStart(String roomId, GameService.GameStartResult result) { + String messageId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + + String message = String.format(""" + 🎮 게임 시작! + 총 %d 라운드 + + 라운드 1 시작! + 출제자: %s + """, + result.room().getTotalRounds(), + result.room().getCurrentDrawerId()); + + Map gameStartMessage = new HashMap<>(); + gameStartMessage.put("messageId", messageId); + gameStartMessage.put("roomId", roomId); + gameStartMessage.put("userId", "SYSTEM"); + gameStartMessage.put("content", message); + gameStartMessage.put("messageType", MessageType.GAME_START.getCode()); + gameStartMessage.put("createdAt", now); + gameStartMessage.put("gameStatus", result.room().getGameStatus()); + gameStartMessage.put("currentRound", result.room().getCurrentRound()); + gameStartMessage.put("totalRounds", result.room().getTotalRounds()); + gameStartMessage.put("currentDrawerId", result.room().getCurrentDrawerId()); + gameStartMessage.put("drawerOrder", result.drawerOrder()); + + List connections = connectionRepository.findByRoomId(roomId); + String broadcastPayload = ResponseGenerator.gson().toJson(gameStartMessage); + broadcaster.broadcast(connections, broadcastPayload); + + logger.info("Game start broadcasted: roomId={}", roomId); + } + + /** + * 시스템 메시지 브로드캐스트 + */ + private void broadcastSystemMessage(String roomId, String message, MessageType messageType) { + String messageId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + + Map systemMessage = new HashMap<>(); + systemMessage.put("messageId", messageId); + systemMessage.put("roomId", roomId); + systemMessage.put("userId", "SYSTEM"); + systemMessage.put("content", message); + systemMessage.put("messageType", messageType.getCode()); + systemMessage.put("createdAt", now); + + List connections = connectionRepository.findByRoomId(roomId); + String broadcastPayload = ResponseGenerator.gson().toJson(systemMessage); + broadcaster.broadcast(connections, broadcastPayload); + + logger.info("System message broadcasted: roomId={}, type={}", roomId, messageType); + } +} diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 8c154722..88569334 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -330,6 +330,57 @@ Resources: Auth: Authorizer: CognitoAuthorizer + GameFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: group2-englishstudy-game-handler + CodeUri: . + Handler: com.mzc.secondproject.serverless.domain.chatting.handler.GameHandler::handleRequest + Description: Handle catch-mind game operations + SnapStart: + ApplyOn: PublishedVersions + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref ChatTable + - Statement: + - Effect: Allow + Action: + - execute-api:ManageConnections + Resource: !Sub "arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${ChatWebSocketApi}/*" + Events: + StartGame: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /chat/rooms/{roomId}/game/start + Method: POST + Auth: + Authorizer: CognitoAuthorizer + StopGame: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /chat/rooms/{roomId}/game/stop + Method: POST + Auth: + Authorizer: CognitoAuthorizer + GetGameStatus: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /chat/rooms/{roomId}/game/status + Method: GET + Auth: + Authorizer: CognitoAuthorizer + GetScores: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /chat/rooms/{roomId}/game/scores + Method: GET + Auth: + Authorizer: CognitoAuthorizer + ChatMessageFunction: Type: AWS::Serverless::Function Properties: From 2da7d7e3c1e9ae6ecbf5529bff60d88d8a902fb0 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 13 Jan 2026 15:05:37 +0900 Subject: [PATCH 145/528] =?UTF-8?q?feat:=20=EA=B2=8C=EC=9E=84=20=ED=86=B5?= =?UTF-8?q?=EA=B3=84=20=EB=B0=8F=20=EB=B1=83=EC=A7=80=20=EC=8B=9C=EC=8A=A4?= =?UTF-8?q?=ED=85=9C=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UserStats에 게임 통계 필드 추가 (gamesPlayed, gamesWon, correctGuesses, totalGameScore, quickGuesses, perfectDraws) - BadgeType에 게임 관련 뱃지 4종 추가 (GAME_FIRST_PLAY, GAME_10_WINS, QUICK_GUESSER, PERFECT_DRAWER) - BadgeService에 게임 뱃지 조건 체크 로직 추가 - UserStatsRepository에 incrementGameStats 메서드 추가 (Atomic Counter 패턴) - GameStatsService 신규 생성 (게임 종료 시 통계 업데이트) - GameService에서 finishGame 시 통계 업데이트 호출 --- .../domain/badge/enums/BadgeType.java | 6 + .../domain/badge/service/BadgeService.java | 16 +- .../domain/chatting/service/GameService.java | 10 ++ .../chatting/service/GameStatsService.java | 147 ++++++++++++++++++ .../domain/stats/model/UserStats.java | 10 +- .../stats/repository/UserStatsRepository.java | 45 ++++++ 6 files changed, 231 insertions(+), 3 deletions(-) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameStatsService.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/enums/BadgeType.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/enums/BadgeType.java index 6515a8ef..ca56e6b1 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/enums/BadgeType.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/enums/BadgeType.java @@ -24,6 +24,12 @@ public enum BadgeType { // 정확도 ACCURACY_90("정확도 달인", "전체 정확도 90%를 달성했습니다", "accuracy_90.png", "ACCURACY", 90), + // 게임 관련 + GAME_FIRST_PLAY("첫 게임", "첫 게임에 참여했습니다", "game_first.png", "GAMES_PLAYED", 1), + GAME_10_WINS("게임 10승", "게임에서 10번 1등을 했습니다", "game_10_wins.png", "GAMES_WON", 10), + QUICK_GUESSER("번개 정답", "5초 내에 정답을 맞췄습니다", "quick_guesser.png", "QUICK_GUESSES", 1), + PERFECT_DRAWER("완벽한 출제자", "출제 시 전원이 정답을 맞췄습니다", "perfect_drawer.png", "PERFECT_DRAWS", 1), + // 특별 MASTER("학습 마스터", "모든 업적을 달성했습니다", "master.png", "ALL_BADGES", 1); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/service/BadgeService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/service/BadgeService.java index 1140b80d..ac1c979b 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/service/BadgeService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/service/BadgeService.java @@ -131,7 +131,7 @@ private UserBadge createBadge(String userId, BadgeType type, String now) { private boolean checkBadgeCondition(BadgeType type, UserStats stats) { if (stats == null) return false; - + return switch (type.getCategory()) { case "FIRST_STUDY" -> stats.getTestsCompleted() != null && stats.getTestsCompleted() >= 1; case "STREAK" -> stats.getCurrentStreak() != null && stats.getCurrentStreak() >= type.getThreshold(); @@ -148,6 +148,14 @@ private boolean checkBadgeCondition(BadgeType type, UserStats stats) { double accuracy = (stats.getCorrectAnswers() * 100.0) / stats.getQuestionsAnswered(); yield accuracy >= type.getThreshold(); } + case "GAMES_PLAYED" -> + stats.getGamesPlayed() != null && stats.getGamesPlayed() >= type.getThreshold(); + case "GAMES_WON" -> + stats.getGamesWon() != null && stats.getGamesWon() >= type.getThreshold(); + case "QUICK_GUESSES" -> + stats.getQuickGuesses() != null && stats.getQuickGuesses() >= type.getThreshold(); + case "PERFECT_DRAWS" -> + stats.getPerfectDraws() != null && stats.getPerfectDraws() >= type.getThreshold(); case "ALL_BADGES" -> false; // 별도 로직 필요 default -> false; }; @@ -155,7 +163,7 @@ private boolean checkBadgeCondition(BadgeType type, UserStats stats) { private int calculateProgress(BadgeType type, UserStats stats) { if (stats == null) return 0; - + return switch (type.getCategory()) { case "FIRST_STUDY" -> stats.getTestsCompleted() != null && stats.getTestsCompleted() >= 1 ? 1 : 0; case "STREAK" -> stats.getCurrentStreak() != null ? stats.getCurrentStreak() : 0; @@ -166,6 +174,10 @@ private int calculateProgress(BadgeType type, UserStats stats) { if (stats.getQuestionsAnswered() == null || stats.getQuestionsAnswered() == 0) yield 0; yield (int) ((stats.getCorrectAnswers() * 100.0) / stats.getQuestionsAnswered()); } + case "GAMES_PLAYED" -> stats.getGamesPlayed() != null ? stats.getGamesPlayed() : 0; + case "GAMES_WON" -> stats.getGamesWon() != null ? stats.getGamesWon() : 0; + case "QUICK_GUESSES" -> stats.getQuickGuesses() != null ? stats.getQuickGuesses() : 0; + case "PERFECT_DRAWS" -> stats.getPerfectDraws() != null ? stats.getPerfectDraws() : 0; default -> 0; }; } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java index c2c20ac0..34effc04 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java @@ -32,12 +32,14 @@ public class GameService { private final ConnectionRepository connectionRepository; private final GameRoundRepository gameRoundRepository; private final WordRepository wordRepository; + private final GameStatsService gameStatsService; public GameService() { this.chatRoomRepository = new ChatRoomRepository(); this.connectionRepository = new ConnectionRepository(); this.gameRoundRepository = new GameRoundRepository(); this.wordRepository = new WordRepository(); + this.gameStatsService = new GameStatsService(); } /** @@ -355,6 +357,14 @@ private CommandResult finishGame(ChatRoom room, String reason) { room.setGameStatus(GameStatus.FINISHED.name()); chatRoomRepository.save(room); + // 게임 통계 업데이트 및 뱃지 체크 + try { + var newBadges = gameStatsService.updateGameStats(room); + logger.info("Game stats updated: roomId={}, newBadges={}", room.getRoomId(), newBadges.size()); + } catch (Exception e) { + logger.error("Failed to update game stats: roomId={}, error={}", room.getRoomId(), e.getMessage()); + } + // 최종 점수 정렬 StringBuilder sb = new StringBuilder("🎮 게임 종료!\n\n📊 최종 순위:\n"); if (room.getScores() != null && !room.getScores().isEmpty()) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameStatsService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameStatsService.java new file mode 100644 index 00000000..5167ca4d --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameStatsService.java @@ -0,0 +1,147 @@ +package com.mzc.secondproject.serverless.domain.chatting.service; + +import com.mzc.secondproject.serverless.domain.badge.model.UserBadge; +import com.mzc.secondproject.serverless.domain.badge.service.BadgeService; +import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; +import com.mzc.secondproject.serverless.domain.chatting.model.GameRound; +import com.mzc.secondproject.serverless.domain.chatting.repository.GameRoundRepository; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; +import com.mzc.secondproject.serverless.domain.stats.repository.UserStatsRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.util.*; + +/** + * 게임 통계 및 뱃지 연동 서비스 + */ +public class GameStatsService { + + private static final Logger logger = LoggerFactory.getLogger(GameStatsService.class); + private static final long QUICK_GUESS_THRESHOLD_MS = 5000; // 5초 + + private final UserStatsRepository userStatsRepository; + private final GameRoundRepository gameRoundRepository; + private final BadgeService badgeService; + + public GameStatsService() { + this.userStatsRepository = new UserStatsRepository(); + this.gameRoundRepository = new GameRoundRepository(); + this.badgeService = new BadgeService(); + } + + /** + * 게임 종료 시 모든 참가자 통계 업데이트 + */ + public Map> updateGameStats(ChatRoom room) { + Map> newBadges = new HashMap<>(); + String roomId = room.getRoomId(); + + // 모든 라운드 조회 + List rounds = gameRoundRepository.findByRoomId(roomId); + + // 참가자별 통계 수집 + Map scores = room.getScores() != null ? room.getScores() : Map.of(); + Set participants = new HashSet<>(scores.keySet()); + if (room.getDrawerOrder() != null) { + participants.addAll(room.getDrawerOrder()); + } + + // 1등 찾기 + String winner = findWinner(scores); + + // 각 참가자 통계 업데이트 + for (String userId : participants) { + List badges = updateUserGameStats(userId, scores.getOrDefault(userId, 0), + userId.equals(winner), rounds); + if (!badges.isEmpty()) { + newBadges.put(userId, badges); + } + } + + logger.info("Game stats updated: roomId={}, participants={}, winner={}", + roomId, participants.size(), winner); + + return newBadges; + } + + /** + * 개별 사용자 게임 통계 업데이트 + */ + private List updateUserGameStats(String userId, int score, boolean isWinner, + List rounds) { + // 라운드별 통계 수집 + int correctGuesses = 0; + int quickGuesses = 0; + int perfectDraws = 0; + + for (GameRound round : rounds) { + // 정답 횟수 + if (round.getCorrectGuessers() != null && round.getCorrectGuessers().contains(userId)) { + correctGuesses++; + + // 빠른 정답 체크 (5초 이내) + if (round.getGuessTimes() != null) { + Long guessTime = round.getGuessTimes().get(userId); + if (guessTime != null && guessTime <= QUICK_GUESS_THRESHOLD_MS) { + quickGuesses++; + } + } + } + + // 완벽한 출제자 체크 + if (userId.equals(round.getDrawerId())) { + // 출제자일 때 전원 정답인지 확인 + if (round.getEndReason() != null && "ALL_CORRECT".equals(round.getEndReason())) { + perfectDraws++; + } + } + } + + // Atomic 업데이트 + userStatsRepository.incrementGameStats( + userId, + 1, // gamesPlayed + isWinner ? 1 : 0, // gamesWon + correctGuesses, + score, + quickGuesses, + perfectDraws + ); + + // 뱃지 체크를 위해 업데이트된 통계 조회 + UserStats stats = userStatsRepository.findTotalStats(userId).orElse(null); + List newBadges = badgeService.checkAndAwardBadges(userId, stats); + + logger.info("User game stats updated: userId={}, correctGuesses={}, newBadges={}", + userId, correctGuesses, newBadges.size()); + + return newBadges; + } + + /** + * 정답 시 즉시 통계 업데이트 (빠른 정답 뱃지용) + */ + public List updateOnCorrectAnswer(String userId, long elapsedTimeMs) { + if (elapsedTimeMs > QUICK_GUESS_THRESHOLD_MS) { + return List.of(); + } + + // 빠른 정답만 업데이트 + userStatsRepository.incrementGameStats(userId, 0, 0, 0, 0, 1, 0); + + UserStats stats = userStatsRepository.findTotalStats(userId).orElse(null); + return badgeService.checkAndAwardBadges(userId, stats); + } + + private String findWinner(Map scores) { + if (scores == null || scores.isEmpty()) { + return null; + } + return scores.entrySet().stream() + .max(Map.Entry.comparingByValue()) + .map(Map.Entry::getKey) + .orElse(null); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/model/UserStats.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/model/UserStats.java index 21a1487a..c51f7df5 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/model/UserStats.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/model/UserStats.java @@ -48,7 +48,15 @@ public class UserStats { private Integer currentStreak; // 현재 연속 학습일 private Integer longestStreak; // 최장 연속 학습일 private String lastStudyDate; // 마지막 학습일 - + + // 게임 통계 + private Integer gamesPlayed; // 참여한 게임 수 + private Integer gamesWon; // 1등 횟수 + private Integer correctGuesses; // 정답 맞춘 횟수 + private Integer totalGameScore; // 누적 게임 점수 + private Integer quickGuesses; // 5초 내 정답 횟수 + private Integer perfectDraws; // 전원 정답 유도 횟수 + // 메타데이터 private String createdAt; private String updatedAt; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java index 3ef8fa79..cea1d7db 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java @@ -253,6 +253,51 @@ public void updateStreak(String userId, int currentStreak, int longestStreak, St logger.info("Updated streak: userId={}, current={}, longest={}", userId, currentStreak, longestStreak); } + /** + * 게임 통계 Atomic 업데이트 + */ + public void incrementGameStats(String userId, int gamesPlayed, int gamesWon, + int correctGuesses, int totalScore, int quickGuesses, int perfectDraws) { + String pk = StatsKey.userStatsPk(userId); + String sk = StatsKey.statsTotalSk(); + String now = Instant.now().toString(); + + Map key = new HashMap<>(); + key.put("PK", AttributeValue.builder().s(pk).build()); + key.put("SK", AttributeValue.builder().s(sk).build()); + + Map values = new HashMap<>(); + values.put(":gamesPlayed", AttributeValue.builder().n(String.valueOf(gamesPlayed)).build()); + values.put(":gamesWon", AttributeValue.builder().n(String.valueOf(gamesWon)).build()); + values.put(":correctGuesses", AttributeValue.builder().n(String.valueOf(correctGuesses)).build()); + values.put(":totalScore", AttributeValue.builder().n(String.valueOf(totalScore)).build()); + values.put(":quickGuesses", AttributeValue.builder().n(String.valueOf(quickGuesses)).build()); + values.put(":perfectDraws", AttributeValue.builder().n(String.valueOf(perfectDraws)).build()); + values.put(":zero", AttributeValue.builder().n("0").build()); + values.put(":now", AttributeValue.builder().s(now).build()); + + String updateExpression = "SET " + + "gamesPlayed = if_not_exists(gamesPlayed, :zero) + :gamesPlayed, " + + "gamesWon = if_not_exists(gamesWon, :zero) + :gamesWon, " + + "correctGuesses = if_not_exists(correctGuesses, :zero) + :correctGuesses, " + + "totalGameScore = if_not_exists(totalGameScore, :zero) + :totalScore, " + + "quickGuesses = if_not_exists(quickGuesses, :zero) + :quickGuesses, " + + "perfectDraws = if_not_exists(perfectDraws, :zero) + :perfectDraws, " + + "updatedAt = :now, " + + "createdAt = if_not_exists(createdAt, :now)"; + + UpdateItemRequest request = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(key) + .updateExpression(updateExpression) + .expressionAttributeValues(values) + .build(); + + AwsClients.dynamoDb().updateItem(request); + logger.info("Incremented game stats: userId={}, gamesPlayed={}, gamesWon={}, correctGuesses={}", + userId, gamesPlayed, gamesWon, correctGuesses); + } + /** * 현재 연도-주차 반환 (예: 2026-W02) */ From e2d0d1b3773f3851a3650614061e02a8526547cc Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 13 Jan 2026 15:12:19 +0900 Subject: [PATCH 146/528] =?UTF-8?q?refactor:=20JoinRoomResponse=EB=A5=BC?= =?UTF-8?q?=20Record=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Lombok 클래스 → Java 21 Record 변환 - 팩토리 메서드 of() 유지 - ChatRoomHandler, ChatRoomCommandService 접근자 수정 --- .../dto/response/JoinRoomResponse.java | 20 ++++++++----------- .../chatting/handler/ChatRoomHandler.java | 2 +- .../service/ChatRoomCommandService.java | 6 +----- 3 files changed, 10 insertions(+), 18 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/JoinRoomResponse.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/JoinRoomResponse.java index 4ab83f79..3ccd3215 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/JoinRoomResponse.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/JoinRoomResponse.java @@ -1,21 +1,17 @@ package com.mzc.secondproject.serverless.domain.chatting.dto.response; import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; /** * 채팅방 입장 응답 * 방 정보와 WebSocket 연결용 토큰 포함 */ -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class JoinRoomResponse { - private ChatRoom room; - private String roomToken; - private Long tokenExpiresAt; +public record JoinRoomResponse( + ChatRoom room, + String roomToken, + Long tokenExpiresAt +) { + public static JoinRoomResponse of(ChatRoom room, String roomToken, Long tokenExpiresAt) { + return new JoinRoomResponse(room, roomToken, tokenExpiresAt); + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java index 2ea12fd4..0edcf0a9 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java @@ -120,7 +120,7 @@ private APIGatewayProxyResponseEvent joinRoom(APIGatewayProxyRequestEvent reques String password = req != null ? req.getPassword() : null; JoinRoomResponse response = commandService.joinRoom(roomId, userId, password); - response.getRoom().setPassword(null); + response.room().setPassword(null); return ResponseGenerator.ok("Joined room", response); } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomCommandService.java index 6c167ebc..b278c8b9 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomCommandService.java @@ -94,11 +94,7 @@ public JoinRoomResponse joinRoom(String roomId, String userId, String password) // 토큰 발급 RoomToken token = roomTokenService.generateToken(roomId, userId); - return JoinRoomResponse.builder() - .room(room) - .roomToken(token.getToken()) - .tokenExpiresAt(token.getTtl()) - .build(); + return JoinRoomResponse.of(room, token.getToken(), token.getTtl()); } public LeaveResult leaveRoom(String roomId, String userId) { From 9343b0116e9db31de15bf50b529b6e2a350e6fcb Mon Sep 17 00:00:00 2001 From: hyein Heo <128613248+hye-inA@users.noreply.github.com> Date: Tue, 13 Jan 2026 15:20:43 +0900 Subject: [PATCH 147/528] =?UTF-8?q?hotfix=20:=20Jira=20API=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=EA=B0=92=20=EC=B6=9C=EB=A0=A5=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/github-jira-issue-sync.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/github-jira-issue-sync.yml b/.github/workflows/github-jira-issue-sync.yml index e095e8ee..493b85ad 100644 --- a/.github/workflows/github-jira-issue-sync.yml +++ b/.github/workflows/github-jira-issue-sync.yml @@ -35,6 +35,7 @@ jobs: - name: Create Jira Issue id: create-jira run: | + set +e RESPONSE=$(curl -s -X POST \ -H "Authorization: Basic $(echo -n '${{ secrets.JIRA_USER_EMAIL }}:${{ secrets.JIRA_API_TOKEN }}' | base64)" \ -H "Content-Type: application/json" \ @@ -54,6 +55,8 @@ jobs: "issuetype": {"name": "${{ steps.issue-type.outputs.type }}"} } }') + + echo "API 응답값: $RESPONSE" JIRA_KEY=$(echo "$RESPONSE" | jq -r '.key // empty') echo "jira_key=$JIRA_KEY" >> $GITHUB_OUTPUT From 45319301ec365629195ce66fbf3389a6e806cfee Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 13 Jan 2026 15:21:15 +0900 Subject: [PATCH 148/528] =?UTF-8?q?refactor:=20DynamoDB=20=EC=A0=91?= =?UTF-8?q?=EA=B7=BC=20=ED=8C=A8=ED=84=B4=20=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GameRoundRepository: BatchWriteItem으로 삭제 작업 최적화 - 개별 DeleteItem → BatchWriteItem (최대 25개씩) - N번의 API 호출 → ceil(N/25)번으로 감소 - GameRound 모델: TTL 필드 추가 (7일 후 자동 만료) - 게임 종료 후 불필요한 데이터 자동 정리 - DynamoDB TTL 기능 활용으로 스토리지 비용 절감 - 기존 최적화 패턴 검증 완료: - Atomic Counter 패턴 (UserStatsRepository) - BatchGetItem 패턴 (WordRepository) - GSI 활용 패턴 (Query vs Scan) --- .../domain/chatting/model/GameRound.java | 1 + .../repository/GameRoundRepository.java | 30 ++++++++++++++++--- .../domain/chatting/service/GameService.java | 8 +++-- 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameRound.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameRound.java index 38dcdd8b..ff3eddad 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameRound.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameRound.java @@ -36,6 +36,7 @@ public class GameRound { private Boolean hintUsed; // 힌트 사용 여부 private String createdAt; + private Long ttl; // 자동 만료 (7일 후) @DynamoDbPartitionKey @DynamoDbAttribute("PK") diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/GameRoundRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/GameRoundRepository.java index 60f0bacf..db5f57d7 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/GameRoundRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/GameRoundRepository.java @@ -4,11 +4,13 @@ import com.mzc.secondproject.serverless.domain.chatting.model.GameRound; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; import software.amazon.awssdk.enhanced.dynamodb.Key; import software.amazon.awssdk.enhanced.dynamodb.TableSchema; import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; +import software.amazon.awssdk.enhanced.dynamodb.model.WriteBatch; import java.util.List; import java.util.Optional; @@ -21,11 +23,14 @@ public class GameRoundRepository { private static final Logger logger = LoggerFactory.getLogger(GameRoundRepository.class); private static final String TABLE_NAME = System.getenv("CHAT_TABLE_NAME"); + private static final int BATCH_SIZE = 25; // DynamoDB BatchWriteItem 최대 25개 + private final DynamoDbEnhancedClient enhancedClient; private final DynamoDbTable table; public GameRoundRepository() { - this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(GameRound.class)); + this.enhancedClient = AwsClients.dynamoDbEnhanced(); + this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(GameRound.class)); } public GameRound save(GameRound gameRound) { @@ -63,17 +68,34 @@ public List findByRoomId(String roomId) { } /** - * 특정 게임의 모든 라운드 삭제 + * 특정 게임의 모든 라운드 삭제 (BatchWriteItem 사용) */ public void deleteByRoomId(String roomId) { List rounds = findByRoomId(roomId); + if (rounds.isEmpty()) { + return; + } + + // BatchWriteItem은 최대 25개까지 지원하므로 분할 처리 + for (int i = 0; i < rounds.size(); i += BATCH_SIZE) { + List batch = rounds.subList(i, Math.min(i + BATCH_SIZE, rounds.size())); + batchDeleteRounds(batch); + } + logger.info("Deleted {} rounds for roomId={}", rounds.size(), roomId); + } + + private void batchDeleteRounds(List rounds) { + WriteBatch.Builder writeBatchBuilder = WriteBatch.builder(GameRound.class) + .mappedTableResource(table); + for (GameRound round : rounds) { Key key = Key.builder() .partitionValue(round.getPk()) .sortValue(round.getSk()) .build(); - table.deleteItem(key); + writeBatchBuilder.addDeleteItem(key); } - logger.info("Deleted {} rounds for roomId={}", rounds.size(), roomId); + + enhancedClient.batchWriteItem(r -> r.writeBatches(writeBatchBuilder.build())); } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java index 34effc04..6022bf67 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java @@ -97,7 +97,8 @@ public GameStartResult startGame(String roomId, String userId) { chatRoomRepository.save(room); - // 첫 라운드 기록 생성 + // 첫 라운드 기록 생성 (7일 후 자동 삭제) + long ttlSeconds = Instant.now().plusSeconds(7 * 24 * 60 * 60).getEpochSecond(); GameRound firstRound = GameRound.builder() .pk("ROOM#" + roomId + "#GAME") .sk("ROUND#1") @@ -113,6 +114,7 @@ public GameStartResult startGame(String roomId, String userId) { .guessTimes(new HashMap<>()) .roundScores(new HashMap<>()) .createdAt(Instant.now().toString()) + .ttl(ttlSeconds) .build(); gameRoundRepository.save(firstRound); @@ -321,7 +323,8 @@ public CommandResult endRound(ChatRoom room, String reason) { chatRoomRepository.save(room); - // 다음 라운드 기록 생성 + // 다음 라운드 기록 생성 (7일 후 자동 삭제) + long nextTtlSeconds = Instant.now().plusSeconds(7 * 24 * 60 * 60).getEpochSecond(); GameRound nextRoundRecord = GameRound.builder() .pk("ROOM#" + roomId + "#GAME") .sk("ROUND#" + nextRound) @@ -337,6 +340,7 @@ public CommandResult endRound(ChatRoom room, String reason) { .guessTimes(new HashMap<>()) .roundScores(new HashMap<>()) .createdAt(Instant.now().toString()) + .ttl(nextTtlSeconds) .build(); gameRoundRepository.save(nextRoundRecord); From 4de03f90c2bd7e283e8296f39dd5e675afc91865 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 13 Jan 2026 15:24:53 +0900 Subject: [PATCH 149/528] =?UTF-8?q?refactor:=20=EC=84=9C=EB=B9=84=EC=8A=A4?= =?UTF-8?q?=20=EB=A0=88=EC=9D=B4=EC=96=B4=20=EB=94=94=EC=9E=90=EC=9D=B8=20?= =?UTF-8?q?=ED=8C=A8=ED=84=B4=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Factory 패턴 적용: - WordFactory 생성: Word 엔티티 생성 로직 중앙 집중화 - GSI 키 설정, 기본값 처리 등 일관성 유지 의존성 주입 개선: - WordService: 생성자 주입 추가 (테스트 용이성) - GameService: 생성자 주입 추가 (5개 의존성) - 기본 생성자는 Lambda 환경용으로 유지 코드 간소화: - WordService: 중복된 Word 생성 로직 제거 - createWord, createWordsBatch, updateWord에 Factory 적용 --- .../domain/chatting/service/GameService.java | 23 +++- .../vocabulary/factory/WordFactory.java | 73 ++++++++++++ .../vocabulary/service/WordService.java | 112 ++++++------------ 3 files changed, 127 insertions(+), 81 deletions(-) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/factory/WordFactory.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java index 6022bf67..7f5f54f2 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java @@ -34,12 +34,25 @@ public class GameService { private final WordRepository wordRepository; private final GameStatsService gameStatsService; + /** + * 기본 생성자 (Lambda에서 사용) + */ public GameService() { - this.chatRoomRepository = new ChatRoomRepository(); - this.connectionRepository = new ConnectionRepository(); - this.gameRoundRepository = new GameRoundRepository(); - this.wordRepository = new WordRepository(); - this.gameStatsService = new GameStatsService(); + this(new ChatRoomRepository(), new ConnectionRepository(), + new GameRoundRepository(), new WordRepository(), new GameStatsService()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public GameService(ChatRoomRepository chatRoomRepository, ConnectionRepository connectionRepository, + GameRoundRepository gameRoundRepository, WordRepository wordRepository, + GameStatsService gameStatsService) { + this.chatRoomRepository = chatRoomRepository; + this.connectionRepository = connectionRepository; + this.gameRoundRepository = gameRoundRepository; + this.wordRepository = wordRepository; + this.gameStatsService = gameStatsService; } /** diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/factory/WordFactory.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/factory/WordFactory.java new file mode 100644 index 00000000..e70806ad --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/factory/WordFactory.java @@ -0,0 +1,73 @@ +package com.mzc.secondproject.serverless.domain.vocabulary.factory; + +import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; + +import java.time.Instant; +import java.util.UUID; + +/** + * Word 엔티티 생성 Factory + * 객체 생성 로직을 중앙 집중화하여 일관성 유지 + */ +public class WordFactory { + + private static final String DEFAULT_LEVEL = "BEGINNER"; + private static final String DEFAULT_CATEGORY = "DAILY"; + + /** + * 새 Word 엔티티 생성 + */ + public Word create(String english, String korean, String example, String level, String category) { + String wordId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + String resolvedLevel = level != null ? level : DEFAULT_LEVEL; + String resolvedCategory = category != null ? category : DEFAULT_CATEGORY; + + return Word.builder() + .pk("WORD#" + wordId) + .sk("METADATA") + .gsi1pk("LEVEL#" + resolvedLevel) + .gsi1sk("WORD#" + wordId) + .gsi2pk("CATEGORY#" + resolvedCategory) + .gsi2sk("WORD#" + wordId) + .wordId(wordId) + .english(english) + .korean(korean) + .example(example) + .level(resolvedLevel) + .category(resolvedCategory) + .createdAt(now) + .build(); + } + + /** + * 기본값으로 Word 생성 + */ + public Word create(String english, String korean, String example) { + return create(english, korean, example, DEFAULT_LEVEL, DEFAULT_CATEGORY); + } + + /** + * Word 엔티티 업데이트 (GSI 키 자동 갱신) + */ + public Word updateFields(Word word, String english, String korean, String example, String level, String category) { + if (english != null) { + word.setEnglish(english); + } + if (korean != null) { + word.setKorean(korean); + } + if (example != null) { + word.setExample(example); + } + if (level != null) { + word.setLevel(level); + word.setGsi1pk("LEVEL#" + level); + } + if (category != null) { + word.setCategory(category); + word.setGsi2pk("CATEGORY#" + category); + } + return word; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordService.java index fec9e7a9..a2105e9f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordService.java @@ -1,47 +1,40 @@ package com.mzc.secondproject.serverless.domain.vocabulary.service; import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.domain.vocabulary.factory.WordFactory; import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; import com.mzc.secondproject.serverless.domain.vocabulary.repository.WordRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.time.Instant; import java.util.*; public class WordService { - + private static final Logger logger = LoggerFactory.getLogger(WordService.class); - + private final WordRepository wordRepository; - + private final WordFactory wordFactory; + + /** + * 기본 생성자 (Lambda에서 사용) + */ public WordService() { - this.wordRepository = new WordRepository(); + this(new WordRepository(), new WordFactory()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public WordService(WordRepository wordRepository, WordFactory wordFactory) { + this.wordRepository = wordRepository; + this.wordFactory = wordFactory; } public Word createWord(String english, String korean, String example, String level, String category) { - String wordId = UUID.randomUUID().toString(); - String now = Instant.now().toString(); - - Word word = Word.builder() - .pk("WORD#" + wordId) - .sk("METADATA") - .gsi1pk("LEVEL#" + level) - .gsi1sk("WORD#" + wordId) - .gsi2pk("CATEGORY#" + category) - .gsi2sk("WORD#" + wordId) - .wordId(wordId) - .english(english) - .korean(korean) - .example(example) - .level(level) - .category(category) - .createdAt(now) - .build(); - + Word word = wordFactory.create(english, korean, example, level, category); wordRepository.save(word); - logger.info("Created word: {}", wordId); - + logger.info("Created word: {}", word.getWordId()); return word; } @@ -63,32 +56,19 @@ public Word updateWord(String wordId, Map updates) { if (optWord.isEmpty()) { throw new IllegalArgumentException("Word not found"); } - + Word word = optWord.get(); - - if (updates.containsKey("english")) { - word.setEnglish((String) updates.get("english")); - } - if (updates.containsKey("korean")) { - word.setKorean((String) updates.get("korean")); - } - if (updates.containsKey("example")) { - word.setExample((String) updates.get("example")); - } - if (updates.containsKey("level")) { - String newLevel = (String) updates.get("level"); - word.setLevel(newLevel); - word.setGsi1pk("LEVEL#" + newLevel); - } - if (updates.containsKey("category")) { - String newCategory = (String) updates.get("category"); - word.setCategory(newCategory); - word.setGsi2pk("CATEGORY#" + newCategory); - } - + wordFactory.updateFields( + word, + (String) updates.get("english"), + (String) updates.get("korean"), + (String) updates.get("example"), + (String) updates.get("level"), + (String) updates.get("category") + ); + wordRepository.save(word); logger.info("Updated word: {}", wordId); - return word; } @@ -103,51 +83,31 @@ public void deleteWord(String wordId) { } public BatchResult createWordsBatch(List> wordsList) { - String now = Instant.now().toString(); - List createdWords = new ArrayList<>(); int successCount = 0; int failCount = 0; - + for (Map wordData : wordsList) { try { String english = (String) wordData.get("english"); String korean = (String) wordData.get("korean"); String example = (String) wordData.get("example"); - String level = (String) wordData.getOrDefault("level", "BEGINNER"); - String category = (String) wordData.getOrDefault("category", "DAILY"); - + String level = (String) wordData.get("level"); + String category = (String) wordData.get("category"); + if (english == null || korean == null) { failCount++; continue; } - - String wordId = UUID.randomUUID().toString(); - - Word word = Word.builder() - .pk("WORD#" + wordId) - .sk("METADATA") - .gsi1pk("LEVEL#" + level) - .gsi1sk("WORD#" + wordId) - .gsi2pk("CATEGORY#" + category) - .gsi2sk("WORD#" + wordId) - .wordId(wordId) - .english(english) - .korean(korean) - .example(example) - .level(level) - .category(category) - .createdAt(now) - .build(); - + + Word word = wordFactory.create(english, korean, example, level, category); wordRepository.save(word); - createdWords.add(word); successCount++; } catch (Exception e) { logger.error("Failed to create word", e); failCount++; } } - + logger.info("Batch created {} words, failed {}", successCount, failCount); return new BatchResult(successCount, failCount, wordsList.size()); } From 766e5877ee555388f97834ad0b5c9d8d3617f27f Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 13 Jan 2026 15:30:27 +0900 Subject: [PATCH 150/528] =?UTF-8?q?refactor:=20=EA=B3=B5=ED=86=B5=20?= =?UTF-8?q?=EC=9C=A0=ED=8B=B8=EB=A6=AC=ED=8B=B0=20=EB=B0=8F=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=EC=BD=94=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 중복 유틸리티 통합: - DynamoDbKey에 userPk() 메서드 추가 (공통화) - VocabKey, ChatKey에서 중복된 userPk() 제거 - UserWordCommandService가 DynamoDbKey.userPk() 사용하도록 수정 상수 적용: - WordFactory에서 VocabKey, DynamoDbKey 상수 사용 - 하드코딩된 "WORD#", "LEVEL#" 등 제거 - 키 생성 로직 일관성 확보 --- .../common/constants/DynamoDbKey.java | 16 ++++++++++++++-- .../domain/chatting/constants/ChatKey.java | 11 ++++------- .../domain/vocabulary/constants/VocabKey.java | 7 ++----- .../domain/vocabulary/factory/WordFactory.java | 18 ++++++++++-------- .../service/UserWordCommandService.java | 7 ++++--- 5 files changed, 34 insertions(+), 25 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/constants/DynamoDbKey.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/constants/DynamoDbKey.java index c512210b..132b0781 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/constants/DynamoDbKey.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/constants/DynamoDbKey.java @@ -1,7 +1,11 @@ package com.mzc.secondproject.serverless.common.constants; +/** + * DynamoDB 공통 키 상수 및 빌더 + * 모든 도메인에서 공통으로 사용되는 키 패턴 정의 + */ public final class DynamoDbKey { - + // Partition/Sort Key Attributes public static final String PK = "PK"; public static final String SK = "SK"; @@ -20,7 +24,15 @@ public final class DynamoDbKey { public static final String METADATA = "METADATA"; // 공용 Entity Prefix public static final String USER = "USER#"; - + private DynamoDbKey() { } + + /** + * 사용자 PK 생성 (공통) + * 여러 도메인에서 동일한 패턴으로 사용 + */ + public static String userPk(String userId) { + return USER + userId; + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/constants/ChatKey.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/constants/ChatKey.java index 33a25a66..7c7ba7b7 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/constants/ChatKey.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/constants/ChatKey.java @@ -15,19 +15,16 @@ public final class ChatKey { private ChatKey() { } - // Key Builders + // Key Builders (userPk는 DynamoDbKey.userPk() 사용) + public static String roomPk(String roomId) { return ROOM + roomId; } - + public static String messageSk(String messageId) { return MESSAGE + messageId; } - - public static String userPk(String userId) { - return DynamoDbKey.USER + userId; - } - + public static String connectionPk(String connectionId) { return CONNECTION + connectionId; } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/constants/VocabKey.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/constants/VocabKey.java index 9c994acc..968809fc 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/constants/VocabKey.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/constants/VocabKey.java @@ -23,11 +23,8 @@ public final class VocabKey { private VocabKey() { } - // Key Builders - public static String userPk(String userId) { - return DynamoDbKey.USER + userId; - } - + // Key Builders (userPk는 DynamoDbKey.userPk() 사용) + public static String wordPk(String wordId) { return WORD + wordId; } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/factory/WordFactory.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/factory/WordFactory.java index e70806ad..870d3963 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/factory/WordFactory.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/factory/WordFactory.java @@ -1,5 +1,7 @@ package com.mzc.secondproject.serverless.domain.vocabulary.factory; +import com.mzc.secondproject.serverless.common.constants.DynamoDbKey; +import com.mzc.secondproject.serverless.domain.vocabulary.constants.VocabKey; import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; import java.time.Instant; @@ -24,12 +26,12 @@ public Word create(String english, String korean, String example, String level, String resolvedCategory = category != null ? category : DEFAULT_CATEGORY; return Word.builder() - .pk("WORD#" + wordId) - .sk("METADATA") - .gsi1pk("LEVEL#" + resolvedLevel) - .gsi1sk("WORD#" + wordId) - .gsi2pk("CATEGORY#" + resolvedCategory) - .gsi2sk("WORD#" + wordId) + .pk(VocabKey.wordPk(wordId)) + .sk(DynamoDbKey.METADATA) + .gsi1pk(VocabKey.levelPk(resolvedLevel)) + .gsi1sk(VocabKey.wordSk(wordId)) + .gsi2pk(VocabKey.categoryPk(resolvedCategory)) + .gsi2sk(VocabKey.wordSk(wordId)) .wordId(wordId) .english(english) .korean(korean) @@ -62,11 +64,11 @@ public Word updateFields(Word word, String english, String korean, String exampl } if (level != null) { word.setLevel(level); - word.setGsi1pk("LEVEL#" + level); + word.setGsi1pk(VocabKey.levelPk(level)); } if (category != null) { word.setCategory(category); - word.setGsi2pk("CATEGORY#" + category); + word.setGsi2pk(VocabKey.categoryPk(category)); } return word; } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordCommandService.java index 18d0c283..d356c125 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordCommandService.java @@ -1,6 +1,7 @@ package com.mzc.secondproject.serverless.domain.vocabulary.service; import com.mzc.secondproject.serverless.common.config.StudyConfig; +import com.mzc.secondproject.serverless.common.constants.DynamoDbKey; import com.mzc.secondproject.serverless.common.enums.Difficulty; import com.mzc.secondproject.serverless.domain.vocabulary.constants.VocabKey; import com.mzc.secondproject.serverless.domain.vocabulary.enums.WordStatus; @@ -36,7 +37,7 @@ public UserWord updateUserWord(String userId, String wordId, boolean isCorrect) if (optUserWord.isEmpty()) { userWord = UserWord.builder() - .pk(VocabKey.userPk(userId)) + .pk(DynamoDbKey.userPk(userId)) .sk(VocabKey.wordSk(wordId)) .gsi1pk(VocabKey.userReviewPk(userId)) .gsi2pk(VocabKey.userStatusPk(userId)) @@ -75,7 +76,7 @@ public UserWord updateUserWordTag(String userId, String wordId, Boolean bookmark if (optUserWord.isEmpty()) { userWord = UserWord.builder() - .pk(VocabKey.userPk(userId)) + .pk(DynamoDbKey.userPk(userId)) .sk(VocabKey.wordSk(wordId)) .gsi1pk(VocabKey.userReviewPk(userId)) .gsi2pk(VocabKey.userStatusPk(userId)) @@ -137,7 +138,7 @@ public UserWord updateWordStatus(String userId, String wordId, String newStatus) if (optUserWord.isEmpty()) { userWord = UserWord.builder() - .pk(VocabKey.userPk(userId)) + .pk(DynamoDbKey.userPk(userId)) .sk(VocabKey.wordSk(wordId)) .gsi1pk(VocabKey.userReviewPk(userId)) .gsi2pk(VocabKey.userStatusPk(userId)) From 1387c7a3e19d3314d54dcb582bf4e96199b393f7 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 13 Jan 2026 15:48:08 +0900 Subject: [PATCH 151/528] =?UTF-8?q?fix:=20GameHandler=20=ED=99=98=EA=B2=BD?= =?UTF-8?q?=EB=B3=80=EC=88=98=20=EB=B0=8F=20WebSocket=20API=20=EC=B0=B8?= =?UTF-8?q?=EC=A1=B0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GameFunction에 WEBSOCKET_ENDPOINT 환경변수 추가 - ChatWebSocketApi를 WebSocketApi로 수정 (오타 수정) --- ServerlessFunction/template.yaml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 88569334..c94eda7b 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -339,6 +339,9 @@ Resources: Description: Handle catch-mind game operations SnapStart: ApplyOn: PublishedVersions + Environment: + Variables: + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -346,7 +349,7 @@ Resources: - Effect: Allow Action: - execute-api:ManageConnections - Resource: !Sub "arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${ChatWebSocketApi}/*" + Resource: !Sub "arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${WebSocketApi}/*" Events: StartGame: Type: Api From d7a064d5d00a689c7f3e11b431778336e6bcb263 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 13 Jan 2026 16:21:58 +0900 Subject: [PATCH 152/528] =?UTF-8?q?feat:=20Grammar=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EA=B8=B0=EB=B0=98=20=EA=B5=AC=EC=A1=B0=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GrammarLevel enum 생성 (BEGINNER, INTERMEDIATE, ADVANCED) - GrammarErrorType enum 생성 (VERB_TENSE, ARTICLE 등 12개 타입) - GrammarErrorCode enum 생성 (문법 체크, Bedrock API, 세션 관련 에러) - GrammarException class 생성 (정적 팩토리 메서드 패턴) refs #267 --- .../grammar/enums/GrammarErrorType.java | 32 +++++++++ .../domain/grammar/enums/GrammarLevel.java | 54 +++++++++++++++ .../grammar/exception/GrammarErrorCode.java | 54 +++++++++++++++ .../grammar/exception/GrammarException.java | 68 +++++++++++++++++++ 4 files changed, 208 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/enums/GrammarErrorType.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/enums/GrammarLevel.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/exception/GrammarErrorCode.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/exception/GrammarException.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/enums/GrammarErrorType.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/enums/GrammarErrorType.java new file mode 100644 index 00000000..84a210fa --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/enums/GrammarErrorType.java @@ -0,0 +1,32 @@ +package com.mzc.secondproject.serverless.domain.grammar.enums; + +public enum GrammarErrorType { + VERB_TENSE("동사 시제", "Verb Tense"), + SUBJECT_VERB_AGREEMENT("주어-동사 일치", "Subject-Verb Agreement"), + ARTICLE("관사", "Article"), + PREPOSITION("전치사", "Preposition"), + WORD_ORDER("어순", "Word Order"), + PLURAL_SINGULAR("단/복수", "Plural/Singular"), + PRONOUN("대명사", "Pronoun"), + SPELLING("철자", "Spelling"), + PUNCTUATION("구두점", "Punctuation"), + WORD_CHOICE("어휘 선택", "Word Choice"), + SENTENCE_STRUCTURE("문장 구조", "Sentence Structure"), + OTHER("기타", "Other"); + + private final String koreanName; + private final String englishName; + + GrammarErrorType(String koreanName, String englishName) { + this.koreanName = koreanName; + this.englishName = englishName; + } + + public String getKoreanName() { + return koreanName; + } + + public String getEnglishName() { + return englishName; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/enums/GrammarLevel.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/enums/GrammarLevel.java new file mode 100644 index 00000000..ac13d931 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/enums/GrammarLevel.java @@ -0,0 +1,54 @@ +package com.mzc.secondproject.serverless.domain.grammar.enums; + +import java.util.Arrays; + +public enum GrammarLevel { + BEGINNER("beginner", "초급", "한국어 번역과 쉬운 설명 포함"), + INTERMEDIATE("intermediate", "중급", "영어 위주 설명"), + ADVANCED("advanced", "고급", "상세한 문법 규칙 설명"); + + private final String code; + private final String displayName; + private final String description; + + GrammarLevel(String code, String displayName, String description) { + this.code = code; + this.displayName = displayName; + this.description = description; + } + + public static boolean isValid(String value) { + if (value == null) return false; + return Arrays.stream(values()) + .anyMatch(level -> level.name().equalsIgnoreCase(value) || level.code.equalsIgnoreCase(value)); + } + + public static GrammarLevel fromString(String value) { + if (value == null) { + throw new IllegalArgumentException("GrammarLevel value cannot be null"); + } + return Arrays.stream(values()) + .filter(level -> level.name().equalsIgnoreCase(value) || level.code.equalsIgnoreCase(value)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Unknown GrammarLevel: " + value)); + } + + public static GrammarLevel fromStringOrDefault(String value, GrammarLevel defaultValue) { + if (value == null || !isValid(value)) { + return defaultValue; + } + return fromString(value); + } + + public String getCode() { + return code; + } + + public String getDisplayName() { + return displayName; + } + + public String getDescription() { + return description; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/exception/GrammarErrorCode.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/exception/GrammarErrorCode.java new file mode 100644 index 00000000..9fa326e4 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/exception/GrammarErrorCode.java @@ -0,0 +1,54 @@ +package com.mzc.secondproject.serverless.domain.grammar.exception; + +import com.mzc.secondproject.serverless.common.exception.DomainErrorCode; + +public enum GrammarErrorCode implements DomainErrorCode { + + // 문법 체크 관련 에러 + INVALID_SENTENCE("GRAMMAR_001", "유효하지 않은 문장입니다", 400), + GRAMMAR_CHECK_FAILED("GRAMMAR_002", "문법 체크에 실패했습니다", 500), + + // 레벨 관련 에러 + INVALID_LEVEL("GRAMMAR_003", "유효하지 않은 레벨입니다", 400), + + // Bedrock API 관련 에러 + BEDROCK_API_ERROR("GRAMMAR_004", "AI 서비스 호출에 실패했습니다", 502), + BEDROCK_RESPONSE_PARSE_ERROR("GRAMMAR_005", "AI 응답 파싱에 실패했습니다", 500), + + // 세션 관련 에러 + SESSION_NOT_FOUND("GRAMMAR_006", "세션을 찾을 수 없습니다", 404), + SESSION_EXPIRED("GRAMMAR_007", "세션이 만료되었습니다", 410), + ; + + private static final String DOMAIN = "GRAMMAR"; + + private final String code; + private final String message; + private final int statusCode; + + GrammarErrorCode(String code, String message, int statusCode) { + this.code = code; + this.message = message; + this.statusCode = statusCode; + } + + @Override + public String getDomain() { + return DOMAIN; + } + + @Override + public String getCode() { + return code; + } + + @Override + public String getMessage() { + return message; + } + + @Override + public int getStatusCode() { + return statusCode; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/exception/GrammarException.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/exception/GrammarException.java new file mode 100644 index 00000000..762e215c --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/exception/GrammarException.java @@ -0,0 +1,68 @@ +package com.mzc.secondproject.serverless.domain.grammar.exception; + +import com.mzc.secondproject.serverless.common.exception.ServerlessException; + +public class GrammarException extends ServerlessException { + + private GrammarException(GrammarErrorCode errorCode) { + super(errorCode); + } + + private GrammarException(GrammarErrorCode errorCode, String message) { + super(errorCode, message); + } + + private GrammarException(GrammarErrorCode errorCode, Throwable cause) { + super(errorCode, cause); + } + + // === 문법 체크 관련 팩토리 메서드 === + + public static GrammarException invalidSentence(String sentence) { + return (GrammarException) new GrammarException(GrammarErrorCode.INVALID_SENTENCE, + "유효하지 않은 문장입니다. 문장을 확인해주세요.") + .addDetail("sentence", sentence); + } + + public static GrammarException grammarCheckFailed(String reason) { + return (GrammarException) new GrammarException(GrammarErrorCode.GRAMMAR_CHECK_FAILED, + String.format("문법 체크에 실패했습니다: %s", reason)) + .addDetail("reason", reason); + } + + // === 레벨 관련 팩토리 메서드 === + + public static GrammarException invalidLevel(String level) { + return (GrammarException) new GrammarException(GrammarErrorCode.INVALID_LEVEL, + String.format("유효하지 않은 레벨입니다: '%s'. BEGINNER, INTERMEDIATE, ADVANCED 중 하나여야 합니다.", level)) + .addDetail("invalidValue", level) + .addDetail("allowedValues", "BEGINNER, INTERMEDIATE, ADVANCED"); + } + + // === Bedrock API 관련 팩토리 메서드 === + + public static GrammarException bedrockApiError(Throwable cause) { + return (GrammarException) new GrammarException(GrammarErrorCode.BEDROCK_API_ERROR, cause) + .addDetail("errorType", cause.getClass().getSimpleName()); + } + + public static GrammarException bedrockResponseParseError(String response) { + return (GrammarException) new GrammarException(GrammarErrorCode.BEDROCK_RESPONSE_PARSE_ERROR, + "AI 응답을 파싱하는데 실패했습니다.") + .addDetail("rawResponse", response); + } + + // === 세션 관련 팩토리 메서드 === + + public static GrammarException sessionNotFound(String sessionId) { + return (GrammarException) new GrammarException(GrammarErrorCode.SESSION_NOT_FOUND, + String.format("세션을 찾을 수 없습니다 (sessionId: %s)", sessionId)) + .addDetail("sessionId", sessionId); + } + + public static GrammarException sessionExpired(String sessionId) { + return (GrammarException) new GrammarException(GrammarErrorCode.SESSION_EXPIRED, + String.format("세션이 만료되었습니다 (sessionId: %s)", sessionId)) + .addDetail("sessionId", sessionId); + } +} From af4c2b282b88050af5d5dacbf9ef85c322da4fca Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 13 Jan 2026 16:22:57 +0900 Subject: [PATCH 153/528] =?UTF-8?q?feat:=20=EB=AC=B8=EB=B2=95=20=EC=B2=B4?= =?UTF-8?q?=ED=81=AC=20Request/Response=20DTO=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GrammarCheckRequest DTO (sentence, level) - GrammarError DTO (type, original, corrected, explanation, indices) - GrammarCheckResponse DTO (correctedSentence, score, errors, feedback) refs #268 --- .../dto/request/GrammarCheckRequest.java | 14 ++++++++++++++ .../dto/response/GrammarCheckResponse.java | 17 +++++++++++++++++ .../grammar/dto/response/GrammarError.java | 17 +++++++++++++++++ 3 files changed, 48 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/dto/request/GrammarCheckRequest.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/dto/response/GrammarCheckResponse.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/dto/response/GrammarError.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/dto/request/GrammarCheckRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/dto/request/GrammarCheckRequest.java new file mode 100644 index 00000000..1a4c42f4 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/dto/request/GrammarCheckRequest.java @@ -0,0 +1,14 @@ +package com.mzc.secondproject.serverless.domain.grammar.dto.request; + +import lombok.*; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class GrammarCheckRequest { + private String sentence; + + @Builder.Default + private String level = "BEGINNER"; +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/dto/response/GrammarCheckResponse.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/dto/response/GrammarCheckResponse.java new file mode 100644 index 00000000..aa4dc611 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/dto/response/GrammarCheckResponse.java @@ -0,0 +1,17 @@ +package com.mzc.secondproject.serverless.domain.grammar.dto.response; + +import lombok.*; +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class GrammarCheckResponse { + private String originalSentence; + private String correctedSentence; + private Integer score; + private List errors; + private String feedback; + private Boolean isCorrect; +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/dto/response/GrammarError.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/dto/response/GrammarError.java new file mode 100644 index 00000000..dfc19f92 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/dto/response/GrammarError.java @@ -0,0 +1,17 @@ +package com.mzc.secondproject.serverless.domain.grammar.dto.response; + +import com.mzc.secondproject.serverless.domain.grammar.enums.GrammarErrorType; +import lombok.*; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class GrammarError { + private GrammarErrorType type; + private String original; + private String corrected; + private String explanation; + private Integer startIndex; + private Integer endIndex; +} From 2735dd671c4bd8837f992f4adb62ba8d3198b736 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 13 Jan 2026 16:24:30 +0900 Subject: [PATCH 154/528] =?UTF-8?q?feat:=20BedrockGrammarCheckFactory=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GrammarCheckFactory 인터페이스 정의 - BedrockGrammarCheckFactory 구현 (Claude 3 Haiku 사용) - 레벨별 시스템 프롬프트 분기 (BEGINNER: 한국어 설명 포함) - JSON 형식 응답 파싱 로직 구현 - GrammarException 연동 refs #269 --- .../factory/BedrockGrammarCheckFactory.java | 207 ++++++++++++++++++ .../grammar/factory/GrammarCheckFactory.java | 8 + 2 files changed, 215 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/factory/BedrockGrammarCheckFactory.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/factory/GrammarCheckFactory.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/factory/BedrockGrammarCheckFactory.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/factory/BedrockGrammarCheckFactory.java new file mode 100644 index 00000000..ed99accb --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/factory/BedrockGrammarCheckFactory.java @@ -0,0 +1,207 @@ +package com.mzc.secondproject.serverless.domain.grammar.factory; + +import com.google.gson.*; +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.domain.grammar.dto.response.GrammarCheckResponse; +import com.mzc.secondproject.serverless.domain.grammar.dto.response.GrammarError; +import com.mzc.secondproject.serverless.domain.grammar.enums.GrammarErrorType; +import com.mzc.secondproject.serverless.domain.grammar.enums.GrammarLevel; +import com.mzc.secondproject.serverless.domain.grammar.exception.GrammarException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.core.SdkBytes; +import software.amazon.awssdk.services.bedrockruntime.model.InvokeModelRequest; +import software.amazon.awssdk.services.bedrockruntime.model.InvokeModelResponse; + +import java.util.ArrayList; +import java.util.List; + +public class BedrockGrammarCheckFactory implements GrammarCheckFactory { + + private static final Logger logger = LoggerFactory.getLogger(BedrockGrammarCheckFactory.class); + private static final Gson gson = new Gson(); + + private static final String MODEL_ID = "anthropic.claude-3-haiku-20240307-v1:0"; + private static final int MAX_TOKENS = 2048; + + @Override + public GrammarCheckResponse checkGrammar(String sentence, GrammarLevel level) { + logger.info("Checking grammar: level={}, sentence={}", level.name(), sentence); + + long startTime = System.currentTimeMillis(); + + try { + String systemPrompt = buildSystemPrompt(level); + String userPrompt = buildUserPrompt(sentence); + + JsonObject requestBody = buildRequestBody(userPrompt, systemPrompt); + + InvokeModelRequest request = InvokeModelRequest.builder() + .modelId(MODEL_ID) + .contentType("application/json") + .accept("application/json") + .body(SdkBytes.fromUtf8String(gson.toJson(requestBody))) + .build(); + + InvokeModelResponse response = AwsClients.bedrock().invokeModel(request); + + String responseBody = response.body().asUtf8String(); + JsonObject jsonResponse = gson.fromJson(responseBody, JsonObject.class); + + String content = jsonResponse.getAsJsonArray("content") + .get(0).getAsJsonObject() + .get("text").getAsString(); + + long processingTime = System.currentTimeMillis() - startTime; + logger.info("Grammar check completed in {}ms", processingTime); + + return parseGrammarResponse(sentence, content); + + } catch (GrammarException e) { + throw e; + } catch (Exception e) { + logger.error("Error checking grammar", e); + throw GrammarException.bedrockApiError(e); + } + } + + private String buildSystemPrompt(GrammarLevel level) { + String basePrompt = """ + You are an expert English grammar checker. Analyze the given sentence and provide feedback. + + You MUST respond in the following JSON format only, with no additional text: + { + "correctedSentence": "the corrected sentence", + "score": 85, + "isCorrect": false, + "errors": [ + { + "type": "VERB_TENSE", + "original": "goed", + "corrected": "went", + "explanation": "explanation here", + "startIndex": 2, + "endIndex": 6 + } + ], + "feedback": "overall feedback message" + } + + Error types: VERB_TENSE, SUBJECT_VERB_AGREEMENT, ARTICLE, PREPOSITION, WORD_ORDER, PLURAL_SINGULAR, PRONOUN, SPELLING, PUNCTUATION, WORD_CHOICE, SENTENCE_STRUCTURE, OTHER + + Score should be 0-100 based on grammar correctness. + If the sentence is correct, set isCorrect to true, errors to empty array, and score to 100. + """; + + return switch (level) { + case BEGINNER -> basePrompt + """ + + Additional instructions for BEGINNER level: + - Provide explanations in simple English + - Include Korean translations for key grammar concepts in the explanation + - Be encouraging in feedback + """; + case INTERMEDIATE -> basePrompt + """ + + Additional instructions for INTERMEDIATE level: + - Provide clear explanations in English + - Focus on common grammar patterns + """; + case ADVANCED -> basePrompt + """ + + Additional instructions for ADVANCED level: + - Provide detailed grammar explanations + - Include nuanced usage notes + - Mention style improvements if applicable + """; + }; + } + + private String buildUserPrompt(String sentence) { + return String.format("Please check the grammar of this sentence: \"%s\"", sentence); + } + + private JsonObject buildRequestBody(String userPrompt, String systemPrompt) { + JsonObject requestBody = new JsonObject(); + requestBody.addProperty("anthropic_version", "bedrock-2023-05-31"); + requestBody.addProperty("max_tokens", MAX_TOKENS); + requestBody.addProperty("system", systemPrompt); + + JsonArray messages = new JsonArray(); + JsonObject userMessage = new JsonObject(); + userMessage.addProperty("role", "user"); + userMessage.addProperty("content", userPrompt); + messages.add(userMessage); + + requestBody.add("messages", messages); + + return requestBody; + } + + private GrammarCheckResponse parseGrammarResponse(String originalSentence, String aiResponse) { + try { + String jsonContent = extractJson(aiResponse); + JsonObject json = gson.fromJson(jsonContent, JsonObject.class); + + String correctedSentence = json.get("correctedSentence").getAsString(); + int score = json.get("score").getAsInt(); + boolean isCorrect = json.get("isCorrect").getAsBoolean(); + String feedback = json.get("feedback").getAsString(); + + List errors = new ArrayList<>(); + JsonArray errorsArray = json.getAsJsonArray("errors"); + if (errorsArray != null) { + for (JsonElement element : errorsArray) { + JsonObject errorObj = element.getAsJsonObject(); + GrammarError error = GrammarError.builder() + .type(parseErrorType(errorObj.get("type").getAsString())) + .original(errorObj.get("original").getAsString()) + .corrected(errorObj.get("corrected").getAsString()) + .explanation(errorObj.get("explanation").getAsString()) + .startIndex(getIntOrNull(errorObj, "startIndex")) + .endIndex(getIntOrNull(errorObj, "endIndex")) + .build(); + errors.add(error); + } + } + + return GrammarCheckResponse.builder() + .originalSentence(originalSentence) + .correctedSentence(correctedSentence) + .score(score) + .errors(errors) + .feedback(feedback) + .isCorrect(isCorrect) + .build(); + + } catch (Exception e) { + logger.error("Failed to parse AI response: {}", aiResponse, e); + throw GrammarException.bedrockResponseParseError(aiResponse); + } + } + + private String extractJson(String response) { + int start = response.indexOf('{'); + int end = response.lastIndexOf('}'); + if (start != -1 && end != -1 && end > start) { + return response.substring(start, end + 1); + } + return response; + } + + private GrammarErrorType parseErrorType(String type) { + try { + return GrammarErrorType.valueOf(type); + } catch (IllegalArgumentException e) { + return GrammarErrorType.OTHER; + } + } + + private Integer getIntOrNull(JsonObject obj, String key) { + JsonElement element = obj.get(key); + if (element == null || element.isJsonNull()) { + return null; + } + return element.getAsInt(); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/factory/GrammarCheckFactory.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/factory/GrammarCheckFactory.java new file mode 100644 index 00000000..cb252236 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/factory/GrammarCheckFactory.java @@ -0,0 +1,8 @@ +package com.mzc.secondproject.serverless.domain.grammar.factory; + +import com.mzc.secondproject.serverless.domain.grammar.dto.response.GrammarCheckResponse; +import com.mzc.secondproject.serverless.domain.grammar.enums.GrammarLevel; + +public interface GrammarCheckFactory { + GrammarCheckResponse checkGrammar(String sentence, GrammarLevel level); +} From dfbea8c69ca6d9b6242250a1ebef4df36456650d Mon Sep 17 00:00:00 2001 From: hyein Heo <128613248+hye-inA@users.noreply.github.com> Date: Tue, 13 Jan 2026 16:24:34 +0900 Subject: [PATCH 155/528] =?UTF-8?q?refactor=20:=20JSON=20payload=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#278)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/github-jira-issue-sync.yml | 50 +++++++++++++------- 1 file changed, 34 insertions(+), 16 deletions(-) diff --git a/.github/workflows/github-jira-issue-sync.yml b/.github/workflows/github-jira-issue-sync.yml index 493b85ad..70bb9627 100644 --- a/.github/workflows/github-jira-issue-sync.yml +++ b/.github/workflows/github-jira-issue-sync.yml @@ -34,31 +34,49 @@ jobs: - name: Create Jira Issue id: create-jira + env: + ISSUE_TITLE: ${{ github.event.issue.title }} + ISSUE_URL: ${{ github.event.issue.html_url }} + ISSUE_AUTHOR: ${{ github.event.issue.user.login }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + ISSUE_TYPE: ${{ steps.issue-type.outputs.type }} + JIRA_AUTH: ${{ secrets.JIRA_USER_EMAIL }}:${{ secrets.JIRA_API_TOKEN }} run: | - set +e - RESPONSE=$(curl -s -X POST \ - -H "Authorization: Basic $(echo -n '${{ secrets.JIRA_USER_EMAIL }}:${{ secrets.JIRA_API_TOKEN }}' | base64)" \ - -H "Content-Type: application/json" \ - "${{ secrets.JIRA_BASE_URL }}/rest/api/3/issue" \ - -d '{ - "fields": { - "project": {"key": "${{ env.JIRA_PROJECT_KEY }}"}, - "summary": "[GH-#${{ github.event.issue.number }}] ${{ github.event.issue.title }}", - "description": { - "type": "doc", - "version": 1, - "content": [ - {"type": "paragraph", "content": [{"type": "text", "text": "GitHub Issue: ${{ github.event.issue.html_url }}"}]}, - {"type": "paragraph", "content": [{"type": "text", "text": "Author: ${{ github.event.issue.user.login }}"}]} + PAYLOAD=$(jq -n \ + --arg project "$JIRA_PROJECT_KEY" \ + --arg summary "[GH-#$ISSUE_NUMBER] $ISSUE_TITLE" \ + --arg issue_url "GitHub Issue: $ISSUE_URL" \ + --arg author "Author: $ISSUE_AUTHOR" \ + --arg type "$ISSUE_TYPE" \ + '{ + fields: { + project: {key: $project}, + summary: $summary, + description: { + type: "doc", + version: 1, + content: [ + {type: "paragraph", content: [{type: "text", text: $issue_url}]}, + {type: "paragraph", content: [{type: "text", text: $author}]} ] }, - "issuetype": {"name": "${{ steps.issue-type.outputs.type }}"} + issuetype: {name: $type} } }') + + RESPONSE=$(curl -s -X POST \ + -H "Authorization: Basic $(echo -n "$JIRA_AUTH" | base64)" \ + -H "Content-Type: application/json" \ + "${{ secrets.JIRA_BASE_URL }}/rest/api/3/issue" \ + -d "$PAYLOAD") echo "API 응답값: $RESPONSE" JIRA_KEY=$(echo "$RESPONSE" | jq -r '.key // empty') + if [ -z "$JIRA_KEY" ]; then + echo "Jira 티켓 생성 실패" + exit 1 + fi echo "jira_key=$JIRA_KEY" >> $GITHUB_OUTPUT - name: Update GitHub Issue with Jira Link From 12eca7de2664457a98525c0360e12df9b1ff1264 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 13 Jan 2026 16:27:27 +0900 Subject: [PATCH 156/528] =?UTF-8?q?feat:=20GrammarCheckService=20=EB=B0=8F?= =?UTF-8?q?=20Handler=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GrammarCheckService 생성 (요청 검증, 레벨 파싱) - GrammarHandler 생성 (POST /grammar/check 라우트) - template.yaml에 GrammarFunction Lambda 추가 - Timeout 60초, MemorySize 1024MB - Bedrock InvokeModel 권한 부여 refs #270 --- .../grammar/handler/GrammarHandler.java | 61 +++++++++++++++++++ .../grammar/service/GrammarCheckService.java | 53 ++++++++++++++++ ServerlessFunction/template.yaml | 33 ++++++++++ 3 files changed, 147 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/GrammarHandler.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/service/GrammarCheckService.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/GrammarHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/GrammarHandler.java new file mode 100644 index 00000000..c35d485e --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/GrammarHandler.java @@ -0,0 +1,61 @@ +package com.mzc.secondproject.serverless.domain.grammar.handler; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import com.mzc.secondproject.serverless.common.router.HandlerRouter; +import com.mzc.secondproject.serverless.common.router.Route; +import com.mzc.secondproject.serverless.common.util.ResponseGenerator; +import com.mzc.secondproject.serverless.common.validation.BeanValidator; +import com.mzc.secondproject.serverless.domain.grammar.dto.request.GrammarCheckRequest; +import com.mzc.secondproject.serverless.domain.grammar.dto.response.GrammarCheckResponse; +import com.mzc.secondproject.serverless.domain.grammar.service.GrammarCheckService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.HashMap; +import java.util.Map; + +public class GrammarHandler implements RequestHandler { + + private static final Logger logger = LoggerFactory.getLogger(GrammarHandler.class); + + private final GrammarCheckService grammarCheckService; + private final HandlerRouter router; + + public GrammarHandler() { + this.grammarCheckService = new GrammarCheckService(); + this.router = initRouter(); + } + + private HandlerRouter initRouter() { + return new HandlerRouter().addRoutes( + Route.postAuth("/grammar/check", this::checkGrammar) + ); + } + + @Override + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { + logger.info("Received request: {} {}", request.getHttpMethod(), request.getPath()); + return router.route(request); + } + + private APIGatewayProxyResponseEvent checkGrammar(APIGatewayProxyRequestEvent request, String userId) { + GrammarCheckRequest req = ResponseGenerator.gson().fromJson(request.getBody(), GrammarCheckRequest.class); + + return BeanValidator.validateAndExecute(req, dto -> { + GrammarCheckResponse result = grammarCheckService.checkGrammar(dto); + + Map response = new HashMap<>(); + response.put("originalSentence", result.getOriginalSentence()); + response.put("correctedSentence", result.getCorrectedSentence()); + response.put("score", result.getScore()); + response.put("isCorrect", result.getIsCorrect()); + response.put("errors", result.getErrors()); + response.put("feedback", result.getFeedback()); + + return ResponseGenerator.ok("Grammar checked successfully", response); + }); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/service/GrammarCheckService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/service/GrammarCheckService.java new file mode 100644 index 00000000..5ab9939a --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/service/GrammarCheckService.java @@ -0,0 +1,53 @@ +package com.mzc.secondproject.serverless.domain.grammar.service; + +import com.mzc.secondproject.serverless.domain.grammar.dto.request.GrammarCheckRequest; +import com.mzc.secondproject.serverless.domain.grammar.dto.response.GrammarCheckResponse; +import com.mzc.secondproject.serverless.domain.grammar.enums.GrammarLevel; +import com.mzc.secondproject.serverless.domain.grammar.exception.GrammarException; +import com.mzc.secondproject.serverless.domain.grammar.factory.BedrockGrammarCheckFactory; +import com.mzc.secondproject.serverless.domain.grammar.factory.GrammarCheckFactory; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class GrammarCheckService { + + private static final Logger logger = LoggerFactory.getLogger(GrammarCheckService.class); + + private final GrammarCheckFactory grammarCheckFactory; + + public GrammarCheckService() { + this.grammarCheckFactory = new BedrockGrammarCheckFactory(); + } + + public GrammarCheckService(GrammarCheckFactory grammarCheckFactory) { + this.grammarCheckFactory = grammarCheckFactory; + } + + public GrammarCheckResponse checkGrammar(GrammarCheckRequest request) { + logger.info("Grammar check requested: sentence={}", request.getSentence()); + + validateRequest(request); + + GrammarLevel level = parseLevel(request.getLevel()); + + return grammarCheckFactory.checkGrammar(request.getSentence(), level); + } + + private void validateRequest(GrammarCheckRequest request) { + if (request.getSentence() == null || request.getSentence().trim().isEmpty()) { + throw GrammarException.invalidSentence(request.getSentence()); + } + } + + private GrammarLevel parseLevel(String levelStr) { + if (levelStr == null || levelStr.isEmpty()) { + return GrammarLevel.BEGINNER; + } + + if (!GrammarLevel.isValid(levelStr)) { + throw GrammarException.invalidLevel(levelStr); + } + + return GrammarLevel.fromString(levelStr); + } +} diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index c94eda7b..4dd6e258 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -982,6 +982,39 @@ Resources: Auth: Authorizer: CognitoAuthorizer + ############################################# + # Grammar Lambda Functions + ############################################# + + GrammarFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: group2-englishstudy-grammar-handler + CodeUri: . + Handler: com.mzc.secondproject.serverless.domain.grammar.handler.GrammarHandler::handleRequest + Description: Handle grammar check using Bedrock AI + Timeout: 60 + MemorySize: 1024 + SnapStart: + ApplyOn: PublishedVersions + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref ChatTable + - Statement: + - Effect: Allow + Action: + - bedrock:InvokeModel + Resource: "*" + Events: + GrammarCheck: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /grammar/check + Method: POST + Auth: + Authorizer: CognitoAuthorizer + # EventBridge Scheduler - 매일 자정 단어 학습 통계 집계 ScheduledStatsFunction: Type: AWS::Serverless::Function From 4dee705660c62835f19d404e7966f0c30d413cc4 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 13 Jan 2026 16:28:32 +0900 Subject: [PATCH 157/528] =?UTF-8?q?feat:=20=EB=8C=80=ED=99=94=20=EC=97=B0?= =?UTF-8?q?=EC=8A=B5=20DTO=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ConversationRequest DTO (sessionId, message, level) - ConversationResponse DTO (grammarCheck, aiResponse, conversationTip) refs #271 --- .../grammar/dto/request/ConversationRequest.java | 15 +++++++++++++++ .../dto/response/ConversationResponse.java | 14 ++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/dto/request/ConversationRequest.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/dto/response/ConversationResponse.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/dto/request/ConversationRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/dto/request/ConversationRequest.java new file mode 100644 index 00000000..6eb6d6fb --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/dto/request/ConversationRequest.java @@ -0,0 +1,15 @@ +package com.mzc.secondproject.serverless.domain.grammar.dto.request; + +import lombok.*; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ConversationRequest { + private String sessionId; + private String message; + + @Builder.Default + private String level = "BEGINNER"; +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/dto/response/ConversationResponse.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/dto/response/ConversationResponse.java new file mode 100644 index 00000000..e099ce89 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/dto/response/ConversationResponse.java @@ -0,0 +1,14 @@ +package com.mzc.secondproject.serverless.domain.grammar.dto.response; + +import lombok.*; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ConversationResponse { + private String sessionId; + private GrammarCheckResponse grammarCheck; + private String aiResponse; + private String conversationTip; +} From ce81b4fe6d661b261e7d3d798dc3361a24b44ad2 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 13 Jan 2026 16:29:54 +0900 Subject: [PATCH 158/528] =?UTF-8?q?feat:=20GrammarConversationService=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BedrockGrammarCheckFactory에 대화 생성 메서드 추가 - generateConversation(): 문법 체크 + AI 대화 응답 + 학습 팁 - 레벨별 대화 프롬프트 분기 - GrammarConversationService 생성 - 세션 기반 대화 히스토리 관리 (메모리) - 대화 컨텍스트 유지 refs #272 --- .../factory/BedrockGrammarCheckFactory.java | 168 ++++++++++++++++++ .../service/GrammarConversationService.java | 92 ++++++++++ 2 files changed, 260 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/service/GrammarConversationService.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/factory/BedrockGrammarCheckFactory.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/factory/BedrockGrammarCheckFactory.java index ed99accb..b33a15fc 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/factory/BedrockGrammarCheckFactory.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/factory/BedrockGrammarCheckFactory.java @@ -2,6 +2,7 @@ import com.google.gson.*; import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.domain.grammar.dto.response.ConversationResponse; import com.mzc.secondproject.serverless.domain.grammar.dto.response.GrammarCheckResponse; import com.mzc.secondproject.serverless.domain.grammar.dto.response.GrammarError; import com.mzc.secondproject.serverless.domain.grammar.enums.GrammarErrorType; @@ -204,4 +205,171 @@ private Integer getIntOrNull(JsonObject obj, String key) { } return element.getAsInt(); } + + public ConversationResponse generateConversation(String sessionId, String message, GrammarLevel level, String conversationHistory) { + logger.info("Generating conversation: sessionId={}, level={}", sessionId, level.name()); + + long startTime = System.currentTimeMillis(); + + try { + String systemPrompt = buildConversationSystemPrompt(level); + String userPrompt = buildConversationUserPrompt(message, conversationHistory); + + JsonObject requestBody = buildRequestBody(userPrompt, systemPrompt); + + InvokeModelRequest request = InvokeModelRequest.builder() + .modelId(MODEL_ID) + .contentType("application/json") + .accept("application/json") + .body(SdkBytes.fromUtf8String(gson.toJson(requestBody))) + .build(); + + InvokeModelResponse response = AwsClients.bedrock().invokeModel(request); + + String responseBody = response.body().asUtf8String(); + JsonObject jsonResponse = gson.fromJson(responseBody, JsonObject.class); + + String content = jsonResponse.getAsJsonArray("content") + .get(0).getAsJsonObject() + .get("text").getAsString(); + + long processingTime = System.currentTimeMillis() - startTime; + logger.info("Conversation generated in {}ms", processingTime); + + return parseConversationResponse(sessionId, message, content); + + } catch (GrammarException e) { + throw e; + } catch (Exception e) { + logger.error("Error generating conversation", e); + throw GrammarException.bedrockApiError(e); + } + } + + private String buildConversationSystemPrompt(GrammarLevel level) { + String basePrompt = """ + You are a friendly English conversation partner who also helps with grammar. + When the user sends a message: + 1. First, check their grammar and provide corrections if needed + 2. Then respond naturally to continue the conversation + 3. Provide a helpful learning tip + + You MUST respond in the following JSON format only, with no additional text: + { + "grammarCheck": { + "correctedSentence": "the corrected sentence", + "score": 85, + "isCorrect": false, + "errors": [ + { + "type": "VERB_TENSE", + "original": "goed", + "corrected": "went", + "explanation": "explanation here" + } + ], + "feedback": "brief grammar feedback" + }, + "aiResponse": "Your natural conversational response here", + "conversationTip": "A helpful tip for the learner" + } + + Error types: VERB_TENSE, SUBJECT_VERB_AGREEMENT, ARTICLE, PREPOSITION, WORD_ORDER, PLURAL_SINGULAR, PRONOUN, SPELLING, PUNCTUATION, WORD_CHOICE, SENTENCE_STRUCTURE, OTHER + """; + + return switch (level) { + case BEGINNER -> basePrompt + """ + + For BEGINNER level: + - Use simple vocabulary in your response + - Keep sentences short + - Include Korean translations for difficult words in parentheses + - Be very encouraging + - Provide basic grammar tips + """; + case INTERMEDIATE -> basePrompt + """ + + For INTERMEDIATE level: + - Use natural everyday English + - Introduce new vocabulary naturally + - Provide practical grammar tips + """; + case ADVANCED -> basePrompt + """ + + For ADVANCED level: + - Use sophisticated vocabulary and idioms + - Challenge the learner + - Provide advanced grammar and style tips + """; + }; + } + + private String buildConversationUserPrompt(String message, String conversationHistory) { + StringBuilder prompt = new StringBuilder(); + + if (conversationHistory != null && !conversationHistory.isEmpty()) { + prompt.append("Previous conversation:\n"); + prompt.append(conversationHistory); + prompt.append("\n\n"); + } + + prompt.append("User's message: \"").append(message).append("\""); + + return prompt.toString(); + } + + private ConversationResponse parseConversationResponse(String sessionId, String originalMessage, String aiResponse) { + try { + String jsonContent = extractJson(aiResponse); + JsonObject json = gson.fromJson(jsonContent, JsonObject.class); + + JsonObject grammarCheckObj = json.getAsJsonObject("grammarCheck"); + GrammarCheckResponse grammarCheck = parseGrammarCheckFromJson(originalMessage, grammarCheckObj); + + String conversationResponse = json.get("aiResponse").getAsString(); + String tip = json.get("conversationTip").getAsString(); + + return ConversationResponse.builder() + .sessionId(sessionId) + .grammarCheck(grammarCheck) + .aiResponse(conversationResponse) + .conversationTip(tip) + .build(); + + } catch (Exception e) { + logger.error("Failed to parse conversation response: {}", aiResponse, e); + throw GrammarException.bedrockResponseParseError(aiResponse); + } + } + + private GrammarCheckResponse parseGrammarCheckFromJson(String originalSentence, JsonObject json) { + String correctedSentence = json.get("correctedSentence").getAsString(); + int score = json.get("score").getAsInt(); + boolean isCorrect = json.get("isCorrect").getAsBoolean(); + String feedback = json.get("feedback").getAsString(); + + List errors = new ArrayList<>(); + JsonArray errorsArray = json.getAsJsonArray("errors"); + if (errorsArray != null) { + for (JsonElement element : errorsArray) { + JsonObject errorObj = element.getAsJsonObject(); + GrammarError error = GrammarError.builder() + .type(parseErrorType(errorObj.get("type").getAsString())) + .original(errorObj.get("original").getAsString()) + .corrected(errorObj.get("corrected").getAsString()) + .explanation(errorObj.get("explanation").getAsString()) + .build(); + errors.add(error); + } + } + + return GrammarCheckResponse.builder() + .originalSentence(originalSentence) + .correctedSentence(correctedSentence) + .score(score) + .errors(errors) + .feedback(feedback) + .isCorrect(isCorrect) + .build(); + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/service/GrammarConversationService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/service/GrammarConversationService.java new file mode 100644 index 00000000..48ed34bb --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/service/GrammarConversationService.java @@ -0,0 +1,92 @@ +package com.mzc.secondproject.serverless.domain.grammar.service; + +import com.mzc.secondproject.serverless.domain.grammar.dto.request.ConversationRequest; +import com.mzc.secondproject.serverless.domain.grammar.dto.response.ConversationResponse; +import com.mzc.secondproject.serverless.domain.grammar.enums.GrammarLevel; +import com.mzc.secondproject.serverless.domain.grammar.exception.GrammarException; +import com.mzc.secondproject.serverless.domain.grammar.factory.BedrockGrammarCheckFactory; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +public class GrammarConversationService { + + private static final Logger logger = LoggerFactory.getLogger(GrammarConversationService.class); + + private final BedrockGrammarCheckFactory grammarFactory; + private final Map conversationHistories; + + public GrammarConversationService() { + this.grammarFactory = new BedrockGrammarCheckFactory(); + this.conversationHistories = new ConcurrentHashMap<>(); + } + + public ConversationResponse chat(ConversationRequest request) { + logger.info("Conversation chat requested: sessionId={}", request.getSessionId()); + + validateRequest(request); + + String sessionId = getOrCreateSessionId(request.getSessionId()); + GrammarLevel level = parseLevel(request.getLevel()); + + String conversationHistory = conversationHistories.getOrDefault(sessionId, new StringBuilder()).toString(); + + ConversationResponse response = grammarFactory.generateConversation( + sessionId, + request.getMessage(), + level, + conversationHistory + ); + + updateConversationHistory(sessionId, request.getMessage(), response.getAiResponse()); + + return response; + } + + private void validateRequest(ConversationRequest request) { + if (request.getMessage() == null || request.getMessage().trim().isEmpty()) { + throw GrammarException.invalidSentence(request.getMessage()); + } + } + + private String getOrCreateSessionId(String sessionId) { + if (sessionId == null || sessionId.trim().isEmpty()) { + return UUID.randomUUID().toString(); + } + return sessionId; + } + + private GrammarLevel parseLevel(String levelStr) { + if (levelStr == null || levelStr.isEmpty()) { + return GrammarLevel.BEGINNER; + } + + if (!GrammarLevel.isValid(levelStr)) { + throw GrammarException.invalidLevel(levelStr); + } + + return GrammarLevel.fromString(levelStr); + } + + private void updateConversationHistory(String sessionId, String userMessage, String aiResponse) { + StringBuilder history = conversationHistories.computeIfAbsent(sessionId, k -> new StringBuilder()); + + history.append("User: ").append(userMessage).append("\n"); + history.append("Assistant: ").append(aiResponse).append("\n\n"); + + if (history.length() > 10000) { + int cutIndex = history.indexOf("\n\n", history.length() - 8000); + if (cutIndex > 0) { + history.delete(0, cutIndex + 2); + } + } + } + + public void clearSession(String sessionId) { + conversationHistories.remove(sessionId); + logger.info("Session cleared: sessionId={}", sessionId); + } +} From da4c5b3f993070074e61b8ce3bc79de494f088f9 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 13 Jan 2026 16:31:36 +0900 Subject: [PATCH 159/528] =?UTF-8?q?feat:=20GrammarHandler=EC=97=90=20?= =?UTF-8?q?=EB=8C=80=ED=99=94=20=EB=9D=BC=EC=9A=B0=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /grammar/conversation 라우트 추가 - GrammarConversationService 연동 - template.yaml에 GrammarConversation 이벤트 추가 refs #273 --- .../grammar/handler/GrammarHandler.java | 24 ++++++++++++++++++- ServerlessFunction/template.yaml | 8 +++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/GrammarHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/GrammarHandler.java index c35d485e..3d1e2631 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/GrammarHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/GrammarHandler.java @@ -8,9 +8,12 @@ import com.mzc.secondproject.serverless.common.router.Route; import com.mzc.secondproject.serverless.common.util.ResponseGenerator; import com.mzc.secondproject.serverless.common.validation.BeanValidator; +import com.mzc.secondproject.serverless.domain.grammar.dto.request.ConversationRequest; import com.mzc.secondproject.serverless.domain.grammar.dto.request.GrammarCheckRequest; +import com.mzc.secondproject.serverless.domain.grammar.dto.response.ConversationResponse; import com.mzc.secondproject.serverless.domain.grammar.dto.response.GrammarCheckResponse; import com.mzc.secondproject.serverless.domain.grammar.service.GrammarCheckService; +import com.mzc.secondproject.serverless.domain.grammar.service.GrammarConversationService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -22,16 +25,19 @@ public class GrammarHandler implements RequestHandler { + ConversationResponse result = conversationService.chat(dto); + + Map response = new HashMap<>(); + response.put("sessionId", result.getSessionId()); + response.put("grammarCheck", result.getGrammarCheck()); + response.put("aiResponse", result.getAiResponse()); + response.put("conversationTip", result.getConversationTip()); + + return ResponseGenerator.ok("Conversation generated successfully", response); + }); + } } diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 4dd6e258..f6dd46b1 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -1014,6 +1014,14 @@ Resources: Method: POST Auth: Authorizer: CognitoAuthorizer + GrammarConversation: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /grammar/conversation + Method: POST + Auth: + Authorizer: CognitoAuthorizer # EventBridge Scheduler - 매일 자정 단어 학습 통계 집계 ScheduledStatsFunction: From 24d40e20e9aed885e1baad6ab247b05f55382859 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 13 Jan 2026 16:33:44 +0900 Subject: [PATCH 160/528] =?UTF-8?q?feat:=20GrammarSession/GrammarMessage?= =?UTF-8?q?=20DynamoDB=20=EB=AA=A8=EB=8D=B8=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GrammarKey 상수 클래스 생성 (PK/SK 생성 헬퍼) - GrammarSession 모델 생성 - PK: GSESSION#{userId}, SK: SESSION#{sessionId} - GSI1: 전체 세션 목록 조회 (업데이트 시간순) - GrammarMessage 모델 생성 - PK: GSESSION#{userId}, SK: MSG#{timestamp}#{messageId} - GSI1: 세션별 메시지 조회 refs #274 --- .../domain/grammar/constants/GrammarKey.java | 36 +++++++++++++ .../domain/grammar/model/GrammarMessage.java | 52 +++++++++++++++++++ .../domain/grammar/model/GrammarSession.java | 51 ++++++++++++++++++ 3 files changed, 139 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/constants/GrammarKey.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/model/GrammarMessage.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/model/GrammarSession.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/constants/GrammarKey.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/constants/GrammarKey.java new file mode 100644 index 00000000..69678677 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/constants/GrammarKey.java @@ -0,0 +1,36 @@ +package com.mzc.secondproject.serverless.domain.grammar.constants; + +public final class GrammarKey { + + private GrammarKey() {} + + public static final String SESSION_PREFIX = "GSESSION#"; + public static final String SESSION_SK_PREFIX = "SESSION#"; + public static final String MSG_PREFIX = "MSG#"; + public static final String ALL_SESSIONS = "GSESSION#ALL"; + public static final String UPDATED_PREFIX = "UPDATED#"; + + public static String sessionPk(String userId) { + return SESSION_PREFIX + userId; + } + + public static String sessionSk(String sessionId) { + return SESSION_SK_PREFIX + sessionId; + } + + public static String messageSk(String timestamp, String messageId) { + return MSG_PREFIX + timestamp + "#" + messageId; + } + + public static String messageGsi1Pk(String sessionId) { + return SESSION_PREFIX + sessionId; + } + + public static String messageGsi1Sk(String timestamp) { + return MSG_PREFIX + timestamp; + } + + public static String updatedSk(String timestamp) { + return UPDATED_PREFIX + timestamp; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/model/GrammarMessage.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/model/GrammarMessage.java new file mode 100644 index 00000000..a0dfee2d --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/model/GrammarMessage.java @@ -0,0 +1,52 @@ +package com.mzc.secondproject.serverless.domain.grammar.model; + +import lombok.*; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.*; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DynamoDbBean +public class GrammarMessage { + + private String pk; // GSESSION#{userId} + private String sk; // MSG#{timestamp}#{messageId} + private String gsi1pk; // GSESSION#{sessionId} + private String gsi1sk; // MSG#{timestamp} + + private String messageId; + private String sessionId; + private String userId; + private String role; // USER, ASSISTANT + private String content; + private String correctedContent; + private String errorsJson; + private Integer grammarScore; + private String createdAt; + private Long ttl; + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { + return pk; + } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { + return sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1PK") + public String getGsi1pk() { + return gsi1pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1SK") + public String getGsi1sk() { + return gsi1sk; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/model/GrammarSession.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/model/GrammarSession.java new file mode 100644 index 00000000..1dd8ba5c --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/model/GrammarSession.java @@ -0,0 +1,51 @@ +package com.mzc.secondproject.serverless.domain.grammar.model; + +import lombok.*; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.*; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DynamoDbBean +public class GrammarSession { + + private String pk; // GSESSION#{userId} + private String sk; // SESSION#{sessionId} + private String gsi1pk; // GSESSION#ALL + private String gsi1sk; // UPDATED#{timestamp} + + private String sessionId; + private String userId; + private String level; + private String topic; + private Integer messageCount; + private String lastMessage; + private String createdAt; + private String updatedAt; + private Long ttl; + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { + return pk; + } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { + return sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1PK") + public String getGsi1pk() { + return gsi1pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1SK") + public String getGsi1sk() { + return gsi1sk; + } +} From ad64af957b280383ff5d783dec62dc780840c092 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 13 Jan 2026 16:34:40 +0900 Subject: [PATCH 161/528] =?UTF-8?q?feat:=20GrammarSessionRepository=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 세션 CRUD (save, findById, findByUserId, delete) - 메시지 저장 및 조회 (페이지네이션 지원) - ChatTable 재사용 (GSESSION# 프리픽스) - GSI1 인덱스 활용 쿼리 refs #275 --- .../repository/GrammarSessionRepository.java | 141 ++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/repository/GrammarSessionRepository.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/repository/GrammarSessionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/repository/GrammarSessionRepository.java new file mode 100644 index 00000000..52f6b65c --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/repository/GrammarSessionRepository.java @@ -0,0 +1,141 @@ +package com.mzc.secondproject.serverless.domain.grammar.repository; + +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.common.util.CursorUtil; +import com.mzc.secondproject.serverless.domain.grammar.constants.GrammarKey; +import com.mzc.secondproject.serverless.domain.grammar.model.GrammarMessage; +import com.mzc.secondproject.serverless.domain.grammar.model.GrammarSession; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; +import software.amazon.awssdk.enhanced.dynamodb.Key; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.model.Page; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +public class GrammarSessionRepository { + + private static final Logger logger = LoggerFactory.getLogger(GrammarSessionRepository.class); + private static final String TABLE_NAME = System.getenv("CHAT_TABLE_NAME"); + + private final DynamoDbTable sessionTable; + private final DynamoDbTable messageTable; + + public GrammarSessionRepository() { + this.sessionTable = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(GrammarSession.class)); + this.messageTable = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(GrammarMessage.class)); + } + + // ============ Session CRUD ============ + + public GrammarSession saveSession(GrammarSession session) { + logger.info("Saving session: sessionId={}", session.getSessionId()); + sessionTable.putItem(session); + return session; + } + + public Optional findSessionById(String userId, String sessionId) { + Key key = Key.builder() + .partitionValue(GrammarKey.sessionPk(userId)) + .sortValue(GrammarKey.sessionSk(sessionId)) + .build(); + return Optional.ofNullable(sessionTable.getItem(key)); + } + + public PaginatedResult findSessionsByUserId(String userId, int limit, String cursor) { + QueryConditional queryConditional = QueryConditional + .sortBeginsWith(Key.builder() + .partitionValue(GrammarKey.sessionPk(userId)) + .sortValue(GrammarKey.SESSION_SK_PREFIX) + .build()); + + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .scanIndexForward(false) + .limit(limit); + + if (cursor != null && !cursor.isEmpty()) { + Map exclusiveStartKey = CursorUtil.decode(cursor); + if (exclusiveStartKey != null) { + requestBuilder.exclusiveStartKey(exclusiveStartKey); + } + } + + Page page = sessionTable.query(requestBuilder.build()).iterator().next(); + String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); + + return new PaginatedResult<>(page.items(), nextCursor); + } + + public void deleteSession(String userId, String sessionId) { + Key key = Key.builder() + .partitionValue(GrammarKey.sessionPk(userId)) + .sortValue(GrammarKey.sessionSk(sessionId)) + .build(); + sessionTable.deleteItem(key); + logger.info("Session deleted: sessionId={}", sessionId); + } + + // ============ Message CRUD ============ + + public GrammarMessage saveMessage(GrammarMessage message) { + logger.info("Saving message: messageId={}", message.getMessageId()); + messageTable.putItem(message); + return message; + } + + public PaginatedResult findMessagesBySessionId(String sessionId, int limit, String cursor) { + QueryConditional queryConditional = QueryConditional + .sortBeginsWith(Key.builder() + .partitionValue(GrammarKey.messageGsi1Pk(sessionId)) + .sortValue(GrammarKey.MSG_PREFIX) + .build()); + + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .scanIndexForward(false) + .limit(limit); + + if (cursor != null && !cursor.isEmpty()) { + Map exclusiveStartKey = CursorUtil.decode(cursor); + if (exclusiveStartKey != null) { + requestBuilder.exclusiveStartKey(exclusiveStartKey); + } + } + + Page page = messageTable.index("GSI1") + .query(requestBuilder.build()) + .iterator() + .next(); + + String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); + return new PaginatedResult<>(page.items(), nextCursor); + } + + public List findRecentMessagesBySessionId(String sessionId, int limit) { + QueryConditional queryConditional = QueryConditional + .sortBeginsWith(Key.builder() + .partitionValue(GrammarKey.messageGsi1Pk(sessionId)) + .sortValue(GrammarKey.MSG_PREFIX) + .build()); + + QueryEnhancedRequest request = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .scanIndexForward(false) + .limit(limit) + .build(); + + return messageTable.index("GSI1") + .query(request) + .iterator() + .next() + .items(); + } +} From b04d5b3df52d8fe18084d8105a928d1843793238 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 13 Jan 2026 16:36:28 +0900 Subject: [PATCH 162/528] =?UTF-8?q?feat:=20=EC=84=B8=EC=85=98=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20API=20(CRUD)=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GrammarSessionQueryService 생성 (세션 목록, 상세, 삭제) - GrammarHandler에 세션 관리 라우트 추가 - GET /grammar/sessions: 세션 목록 조회 - GET /grammar/sessions/{sessionId}: 세션 상세 조회 - DELETE /grammar/sessions/{sessionId}: 세션 삭제 - template.yaml에 세션 관리 API 이벤트 추가 refs #276 --- .../grammar/handler/GrammarHandler.java | 54 ++++++++++++++++++- .../service/GrammarSessionQueryService.java | 53 ++++++++++++++++++ ServerlessFunction/template.yaml | 24 +++++++++ 3 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/service/GrammarSessionQueryService.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/GrammarHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/GrammarHandler.java index 3d1e2631..527d81be 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/GrammarHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/GrammarHandler.java @@ -12,8 +12,10 @@ import com.mzc.secondproject.serverless.domain.grammar.dto.request.GrammarCheckRequest; import com.mzc.secondproject.serverless.domain.grammar.dto.response.ConversationResponse; import com.mzc.secondproject.serverless.domain.grammar.dto.response.GrammarCheckResponse; +import com.mzc.secondproject.serverless.domain.grammar.model.GrammarSession; import com.mzc.secondproject.serverless.domain.grammar.service.GrammarCheckService; import com.mzc.secondproject.serverless.domain.grammar.service.GrammarConversationService; +import com.mzc.secondproject.serverless.domain.grammar.service.GrammarSessionQueryService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -26,18 +28,23 @@ public class GrammarHandler implements RequestHandler queryParams = request.getQueryStringParameters(); + String cursor = queryParams != null ? queryParams.get("cursor") : null; + + int limit = 10; + if (queryParams != null && queryParams.get("limit") != null) { + limit = Math.min(Integer.parseInt(queryParams.get("limit")), 50); + } + + var result = sessionQueryService.getSessions(userId, limit, cursor); + + Map response = new HashMap<>(); + response.put("sessions", result.items()); + response.put("nextCursor", result.nextCursor()); + response.put("hasMore", result.hasMore()); + + return ResponseGenerator.ok("Sessions retrieved successfully", response); + } + + private APIGatewayProxyResponseEvent getSessionDetail(APIGatewayProxyRequestEvent request, String userId) { + String sessionId = request.getPathParameters().get("sessionId"); + + Map queryParams = request.getQueryStringParameters(); + int messageLimit = 50; + if (queryParams != null && queryParams.get("messageLimit") != null) { + messageLimit = Math.min(Integer.parseInt(queryParams.get("messageLimit")), 100); + } + + var detail = sessionQueryService.getSessionDetail(userId, sessionId, messageLimit); + + Map response = new HashMap<>(); + response.put("session", detail.session()); + response.put("messages", detail.messages()); + + return ResponseGenerator.ok("Session detail retrieved successfully", response); + } + + private APIGatewayProxyResponseEvent deleteSession(APIGatewayProxyRequestEvent request, String userId) { + String sessionId = request.getPathParameters().get("sessionId"); + + sessionQueryService.deleteSession(userId, sessionId); + + return ResponseGenerator.ok("Session deleted successfully", null); + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/service/GrammarSessionQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/service/GrammarSessionQueryService.java new file mode 100644 index 00000000..ee0f0319 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/service/GrammarSessionQueryService.java @@ -0,0 +1,53 @@ +package com.mzc.secondproject.serverless.domain.grammar.service; + +import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.domain.grammar.exception.GrammarException; +import com.mzc.secondproject.serverless.domain.grammar.model.GrammarMessage; +import com.mzc.secondproject.serverless.domain.grammar.model.GrammarSession; +import com.mzc.secondproject.serverless.domain.grammar.repository.GrammarSessionRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; + +public class GrammarSessionQueryService { + + private static final Logger logger = LoggerFactory.getLogger(GrammarSessionQueryService.class); + + private final GrammarSessionRepository repository; + + public GrammarSessionQueryService() { + this.repository = new GrammarSessionRepository(); + } + + public GrammarSessionQueryService(GrammarSessionRepository repository) { + this.repository = repository; + } + + public PaginatedResult getSessions(String userId, int limit, String cursor) { + logger.info("Getting sessions for user: userId={}", userId); + return repository.findSessionsByUserId(userId, limit, cursor); + } + + public SessionDetail getSessionDetail(String userId, String sessionId, int messageLimit) { + logger.info("Getting session detail: sessionId={}", sessionId); + + GrammarSession session = repository.findSessionById(userId, sessionId) + .orElseThrow(() -> GrammarException.sessionNotFound(sessionId)); + + List messages = repository.findRecentMessagesBySessionId(sessionId, messageLimit); + + return new SessionDetail(session, messages); + } + + public void deleteSession(String userId, String sessionId) { + logger.info("Deleting session: sessionId={}", sessionId); + + repository.findSessionById(userId, sessionId) + .orElseThrow(() -> GrammarException.sessionNotFound(sessionId)); + + repository.deleteSession(userId, sessionId); + } + + public record SessionDetail(GrammarSession session, List messages) {} +} diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index f6dd46b1..867d193d 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -1022,6 +1022,30 @@ Resources: Method: POST Auth: Authorizer: CognitoAuthorizer + GetSessions: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /grammar/sessions + Method: GET + Auth: + Authorizer: CognitoAuthorizer + GetSessionDetail: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /grammar/sessions/{sessionId} + Method: GET + Auth: + Authorizer: CognitoAuthorizer + DeleteSession: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /grammar/sessions/{sessionId} + Method: DELETE + Auth: + Authorizer: CognitoAuthorizer # EventBridge Scheduler - 매일 자정 단어 학습 통계 집계 ScheduledStatsFunction: From e049154a139bcc3c8c7695661186e089e8a9e235 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 13 Jan 2026 16:38:35 +0900 Subject: [PATCH 163/528] =?UTF-8?q?refactor:=20=EA=B8=B0=EC=A1=B4=20?= =?UTF-8?q?=EC=B1=84=ED=8C=85=20=EB=8F=84=EB=A9=94=EC=9D=B8=20Bedrock=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChatAIHandler 제거 (Grammar 도메인으로 이전) - BedrockService 제거 - AiChatResponseFactory, ChatResponseFactory, MockChatResponseFactory, ChatResponse 제거 - template.yaml에서 ChatAIFunction 제거 AI 채팅 기능은 Grammar 도메인의 /grammar/conversation API로 대체됨 refs #277 --- .../factory/AiChatResponseFactory.java | 122 ------------------ .../domain/chatting/factory/ChatResponse.java | 18 --- .../chatting/factory/ChatResponseFactory.java | 31 ----- .../factory/MockChatResponseFactory.java | 45 ------- .../chatting/handler/ChatAIHandler.java | 68 ---------- .../chatting/service/BedrockService.java | 63 --------- ServerlessFunction/template.yaml | 30 ----- 7 files changed, 377 deletions(-) delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/factory/AiChatResponseFactory.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/factory/ChatResponse.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/factory/ChatResponseFactory.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/factory/MockChatResponseFactory.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatAIHandler.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/BedrockService.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/factory/AiChatResponseFactory.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/factory/AiChatResponseFactory.java deleted file mode 100644 index 261842d1..00000000 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/factory/AiChatResponseFactory.java +++ /dev/null @@ -1,122 +0,0 @@ -package com.mzc.secondproject.serverless.domain.chatting.factory; - -import com.google.gson.Gson; -import com.google.gson.JsonArray; -import com.google.gson.JsonObject; -import com.mzc.secondproject.serverless.common.config.AwsClients; -import com.mzc.secondproject.serverless.domain.chatting.enums.ChatLevel; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import software.amazon.awssdk.core.SdkBytes; -import software.amazon.awssdk.services.bedrockruntime.model.InvokeModelRequest; -import software.amazon.awssdk.services.bedrockruntime.model.InvokeModelResponse; - -/** - * AWS Bedrock 기반 AI 채팅 응답 Factory. - * ChatLevel에 따라 프롬프트와 응답 스타일을 조정한다. - */ -public class AiChatResponseFactory implements ChatResponseFactory { - - private static final Logger logger = LoggerFactory.getLogger(AiChatResponseFactory.class); - private static final Gson gson = new Gson(); - - private static final String MODEL_ID = "anthropic.claude-3-sonnet-20240229-v1:0"; - private static final int MAX_TOKENS = 1024; - - @Override - public ChatResponse create(String userMessage, ChatLevel level, String conversationHistory) { - logger.info("Generating AI response: level={}", level.name()); - - long startTime = System.currentTimeMillis(); - - try { - String systemPrompt = buildSystemPrompt(level); - String fullPrompt = buildFullPrompt(userMessage, conversationHistory, systemPrompt); - - JsonObject requestBody = buildRequestBody(fullPrompt, systemPrompt); - - InvokeModelRequest request = InvokeModelRequest.builder() - .modelId(MODEL_ID) - .contentType("application/json") - .accept("application/json") - .body(SdkBytes.fromUtf8String(gson.toJson(requestBody))) - .build(); - - InvokeModelResponse response = AwsClients.bedrock().invokeModel(request); - - String responseBody = response.body().asUtf8String(); - JsonObject jsonResponse = gson.fromJson(responseBody, JsonObject.class); - - String content = jsonResponse.getAsJsonArray("content") - .get(0).getAsJsonObject() - .get("text").getAsString(); - - long processingTime = System.currentTimeMillis() - startTime; - logger.info("AI response generated in {}ms", processingTime); - - return ChatResponse.of(content, MODEL_ID, processingTime); - - } catch (Exception e) { - logger.error("Error generating AI response", e); - throw new RuntimeException("Failed to generate AI response", e); - } - } - - private String buildSystemPrompt(ChatLevel level) { - return switch (level) { - case BEGINNER -> """ - You are a friendly English tutor for beginners. - - Use simple vocabulary and short sentences - - Explain grammar points when needed - - Provide Korean translations for difficult words - - Be encouraging and patient - - Speak slowly and clearly - """; - case INTERMEDIATE -> """ - You are an English conversation partner for intermediate learners. - - Use natural, everyday English - - Introduce new vocabulary with context clues - - Gently correct mistakes - - Encourage more complex sentence structures - """; - case ADVANCED -> """ - You are an advanced English conversation partner. - - Use sophisticated vocabulary and idioms - - Discuss complex topics naturally - - Challenge the learner with nuanced expressions - - Provide minimal corrections, focus on fluency - """; - }; - } - - private String buildFullPrompt(String userMessage, String conversationHistory, String systemPrompt) { - StringBuilder prompt = new StringBuilder(); - - if (conversationHistory != null && !conversationHistory.isEmpty()) { - prompt.append("Previous conversation:\n"); - prompt.append(conversationHistory); - prompt.append("\n\n"); - } - - prompt.append("User: ").append(userMessage); - - return prompt.toString(); - } - - private JsonObject buildRequestBody(String userPrompt, String systemPrompt) { - JsonObject requestBody = new JsonObject(); - requestBody.addProperty("anthropic_version", "bedrock-2023-05-31"); - requestBody.addProperty("max_tokens", MAX_TOKENS); - requestBody.addProperty("system", systemPrompt); - - JsonArray messages = new JsonArray(); - JsonObject userMessage = new JsonObject(); - userMessage.addProperty("role", "user"); - userMessage.addProperty("content", userPrompt); - messages.add(userMessage); - - requestBody.add("messages", messages); - - return requestBody; - } -} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/factory/ChatResponse.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/factory/ChatResponse.java deleted file mode 100644 index 6231fe20..00000000 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/factory/ChatResponse.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.mzc.secondproject.serverless.domain.chatting.factory; - -/** - * AI 채팅 응답 Value Object - */ -public record ChatResponse( - String content, - String modelId, - long processingTimeMs -) { - public static ChatResponse of(String content, String modelId, long processingTimeMs) { - return new ChatResponse(content, modelId, processingTimeMs); - } - - public static ChatResponse of(String content) { - return new ChatResponse(content, "unknown", 0); - } -} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/factory/ChatResponseFactory.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/factory/ChatResponseFactory.java deleted file mode 100644 index d3e09f47..00000000 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/factory/ChatResponseFactory.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.mzc.secondproject.serverless.domain.chatting.factory; - -import com.mzc.secondproject.serverless.domain.chatting.enums.ChatLevel; - -/** - * AI 채팅 응답 생성 Factory 인터페이스. - * 다양한 AI 백엔드(Bedrock, OpenAI 등)를 추상화한다. - */ -public interface ChatResponseFactory { - - /** - * AI 응답 생성 - * - * @param userMessage 사용자 메시지 - * @param level 채팅 난이도 레벨 - * @param conversationHistory 이전 대화 내역 (nullable) - * @return AI 응답 - */ - ChatResponse create(String userMessage, ChatLevel level, String conversationHistory); - - /** - * AI 응답 생성 (대화 내역 없이) - * - * @param userMessage 사용자 메시지 - * @param level 채팅 난이도 레벨 - * @return AI 응답 - */ - default ChatResponse create(String userMessage, ChatLevel level) { - return create(userMessage, level, null); - } -} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/factory/MockChatResponseFactory.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/factory/MockChatResponseFactory.java deleted file mode 100644 index 093d501e..00000000 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/factory/MockChatResponseFactory.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.mzc.secondproject.serverless.domain.chatting.factory; - -import com.mzc.secondproject.serverless.domain.chatting.enums.ChatLevel; - -/** - * 테스트용 Mock AI 채팅 응답 Factory. - * 외부 API 호출 없이 고정된 응답을 반환한다. - */ -public class MockChatResponseFactory implements ChatResponseFactory { - - private static final String MOCK_MODEL_ID = "mock-model-v1"; - - @Override - public ChatResponse create(String userMessage, ChatLevel level, String conversationHistory) { - String response = generateMockResponse(userMessage, level); - return ChatResponse.of(response, MOCK_MODEL_ID, 10); - } - - private String generateMockResponse(String userMessage, ChatLevel level) { - return switch (level) { - case BEGINNER -> String.format( - "Hello! That's a great question. '%s' - Let me explain simply. " + - "(안녕하세요! 좋은 질문이에요. 쉽게 설명해 드릴게요.)", - truncate(userMessage, 50) - ); - case INTERMEDIATE -> String.format( - "Good point! Regarding '%s', I think we can explore this topic further. " + - "What do you think about it?", - truncate(userMessage, 50) - ); - case ADVANCED -> String.format( - "That's an insightful observation about '%s'. " + - "Let me offer a nuanced perspective on this matter.", - truncate(userMessage, 50) - ); - }; - } - - private String truncate(String text, int maxLength) { - if (text == null) { - return ""; - } - return text.length() > maxLength ? text.substring(0, maxLength) + "..." : text; - } -} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatAIHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatAIHandler.java deleted file mode 100644 index 5073aae2..00000000 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatAIHandler.java +++ /dev/null @@ -1,68 +0,0 @@ -package com.mzc.secondproject.serverless.domain.chatting.handler; - -import com.amazonaws.services.lambda.runtime.Context; -import com.amazonaws.services.lambda.runtime.RequestHandler; -import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; -import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; -import com.google.gson.Gson; -import com.mzc.secondproject.serverless.common.exception.CommonErrorCode; -import com.mzc.secondproject.serverless.common.util.ResponseGenerator; -import com.mzc.secondproject.serverless.domain.chatting.enums.ChatLevel; -import com.mzc.secondproject.serverless.domain.chatting.factory.AiChatResponseFactory; -import com.mzc.secondproject.serverless.domain.chatting.factory.ChatResponse; -import com.mzc.secondproject.serverless.domain.chatting.factory.ChatResponseFactory; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.Map; - -public class ChatAIHandler implements RequestHandler { - - private static final Logger logger = LoggerFactory.getLogger(ChatAIHandler.class); - private static final Gson gson = new Gson(); - - private final ChatResponseFactory chatResponseFactory; - - public ChatAIHandler() { - this.chatResponseFactory = new AiChatResponseFactory(); - } - - public ChatAIHandler(ChatResponseFactory chatResponseFactory) { - this.chatResponseFactory = chatResponseFactory; - } - - @Override - public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { - logger.info("Received AI generation request"); - - try { - if (!"POST".equals(request.getHttpMethod())) { - return ResponseGenerator.fail(CommonErrorCode.METHOD_NOT_ALLOWED); - } - - String body = request.getBody(); - ChatRequest chatRequest = gson.fromJson(body, ChatRequest.class); - - String userMessage = chatRequest.message != null ? chatRequest.message : "Hello"; - ChatLevel level = ChatLevel.fromStringOrDefault(chatRequest.level, ChatLevel.BEGINNER); - - ChatResponse aiResponse = chatResponseFactory.create(userMessage, level, chatRequest.conversationHistory); - - return ResponseGenerator.ok("AI response generated", Map.of( - "response", aiResponse.content(), - "modelId", aiResponse.modelId(), - "processingTimeMs", aiResponse.processingTimeMs() - )); - - } catch (Exception e) { - logger.error("Error generating AI response", e); - return ResponseGenerator.fail(CommonErrorCode.INTERNAL_SERVER_ERROR); - } - } - - private static class ChatRequest { - String message; - String level; - String conversationHistory; - } -} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/BedrockService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/BedrockService.java deleted file mode 100644 index d4aa5b3b..00000000 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/BedrockService.java +++ /dev/null @@ -1,63 +0,0 @@ -package com.mzc.secondproject.serverless.domain.chatting.service; - -import com.google.gson.Gson; -import com.google.gson.JsonArray; -import com.google.gson.JsonObject; -import com.mzc.secondproject.serverless.common.config.AwsClients; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import software.amazon.awssdk.core.SdkBytes; -import software.amazon.awssdk.services.bedrockruntime.model.InvokeModelRequest; -import software.amazon.awssdk.services.bedrockruntime.model.InvokeModelResponse; - -public class BedrockService { - - private static final Logger logger = LoggerFactory.getLogger(BedrockService.class); - private static final Gson gson = new Gson(); - - // Claude 3 Sonnet 모델 ID - private static final String MODEL_ID = "anthropic.claude-3-sonnet-20240229-v1:0"; - - public BedrockService() { - } - - public String generateResponse(String prompt) { - logger.info("Generating AI response for prompt"); - - try { - // Claude 3 Messages API 형식 - JsonObject requestBody = new JsonObject(); - requestBody.addProperty("anthropic_version", "bedrock-2023-05-31"); - requestBody.addProperty("max_tokens", 1024); - - JsonArray messages = new JsonArray(); - JsonObject userMessage = new JsonObject(); - userMessage.addProperty("role", "user"); - userMessage.addProperty("content", prompt); - messages.add(userMessage); - - requestBody.add("messages", messages); - - InvokeModelRequest request = InvokeModelRequest.builder() - .modelId(MODEL_ID) - .contentType("application/json") - .accept("application/json") - .body(SdkBytes.fromUtf8String(gson.toJson(requestBody))) - .build(); - - InvokeModelResponse response = AwsClients.bedrock().invokeModel(request); - - String responseBody = response.body().asUtf8String(); - JsonObject jsonResponse = gson.fromJson(responseBody, JsonObject.class); - - // Claude 3 응답에서 텍스트 추출 - return jsonResponse.getAsJsonArray("content") - .get(0).getAsJsonObject() - .get("text").getAsString(); - - } catch (Exception e) { - logger.error("Error calling Bedrock", e); - throw new RuntimeException("Failed to generate AI response", e); - } - } -} diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 867d193d..cb0fe300 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -436,36 +436,6 @@ Resources: Auth: Authorizer: CognitoAuthorizer - ChatAIFunction: - Type: AWS::Serverless::Function - Properties: - FunctionName: group2-englishstudy-chat-ai-handler - CodeUri: . - Handler: com.mzc.secondproject.serverless.domain.chatting.handler.ChatAIHandler::handleRequest - Description: Generate AI responses using Bedrock - Timeout: 60 - MemorySize: 1024 - SnapStart: - ApplyOn: PublishedVersions - Policies: - - DynamoDBCrudPolicy: - TableName: !Ref ChatTable - - Statement: - - Effect: Allow - Action: - - bedrock:InvokeModel - - bedrock:InvokeModelWithResponseStream - Resource: "*" - Events: - GenerateAI: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /chat/ai/generate - Method: POST - Auth: - Authorizer: CognitoAuthorizer - ChatVoiceFunction: Type: AWS::Serverless::Function Properties: From 79b6d0f2275e9610aaa3591b944cc87f7ce70358 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 13 Jan 2026 16:40:07 +0900 Subject: [PATCH 164/528] =?UTF-8?q?test:=20=EC=82=AD=EC=A0=9C=EB=90=9C=20C?= =?UTF-8?q?hatResponseFactory=20=EA=B4=80=EB=A0=A8=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=ED=8C=8C=EC=9D=BC=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../factory/ChatResponseFactorySpec.groovy | 116 ------------------ 1 file changed, 116 deletions(-) delete mode 100644 ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/factory/ChatResponseFactorySpec.groovy diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/factory/ChatResponseFactorySpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/factory/ChatResponseFactorySpec.groovy deleted file mode 100644 index 0ed1d90f..00000000 --- a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/factory/ChatResponseFactorySpec.groovy +++ /dev/null @@ -1,116 +0,0 @@ -package com.mzc.secondproject.serverless.domain.chatting.factory - -import com.mzc.secondproject.serverless.domain.chatting.enums.ChatLevel -import spock.lang.Specification -import spock.lang.Subject -import spock.lang.Unroll - -class ChatResponseFactorySpec extends Specification { - - // ==================== MockChatResponseFactory Tests ==================== - - @Subject - MockChatResponseFactory mockFactory = new MockChatResponseFactory() - - def "MockChatResponseFactory: ChatResponse 객체 반환"() { - given: "Mock Factory" - def userMessage = "Hello, how are you?" - - when: "응답 생성" - def response = mockFactory.create(userMessage, ChatLevel.BEGINNER) - - then: "ChatResponse 객체 반환" - response != null - response.content() != null - response.modelId() == "mock-model-v1" - response.processingTimeMs() >= 0 - } - - @Unroll - def "MockChatResponseFactory: #level 레벨에 맞는 응답 생성"() { - given: "Mock Factory" - def userMessage = "Test message" - - when: "응답 생성" - def response = mockFactory.create(userMessage, level) - - then: "레벨에 맞는 응답" - response.content().contains(expectedKeyword) - - where: - level | expectedKeyword - ChatLevel.BEGINNER | "안녕하세요" - ChatLevel.INTERMEDIATE | "What do you think" - ChatLevel.ADVANCED | "nuanced perspective" - } - - def "MockChatResponseFactory: 대화 내역 포함 가능"() { - given: "Mock Factory와 대화 내역" - def userMessage = "Continue our discussion" - def history = "User: Hello\nAI: Hi there!" - - when: "대화 내역 포함하여 응답 생성" - def response = mockFactory.create(userMessage, ChatLevel.INTERMEDIATE, history) - - then: "정상 응답" - response != null - response.content() != null - } - - def "MockChatResponseFactory: null 메시지 처리"() { - given: "Mock Factory" - - when: "null 메시지로 응답 생성" - def response = mockFactory.create(null, ChatLevel.BEGINNER) - - then: "예외 없이 처리" - response != null - response.content() != null - } - - def "MockChatResponseFactory: 긴 메시지 truncate"() { - given: "Mock Factory와 긴 메시지" - def longMessage = "A" * 100 - - when: "응답 생성" - def response = mockFactory.create(longMessage, ChatLevel.BEGINNER) - - then: "메시지가 truncate되어 응답에 포함" - response.content().contains("...") - } - - // ==================== ChatResponse Tests ==================== - - def "ChatResponse: of 팩토리 메서드"() { - when: "ChatResponse 생성" - def response = ChatResponse.of("Hello", "model-v1", 100) - - then: "필드 값 확인" - response.content() == "Hello" - response.modelId() == "model-v1" - response.processingTimeMs() == 100 - } - - def "ChatResponse: 간단한 of 메서드"() { - when: "content만으로 생성" - def response = ChatResponse.of("Hello") - - then: "기본값 적용" - response.content() == "Hello" - response.modelId() == "unknown" - response.processingTimeMs() == 0 - } - - // ==================== ChatResponseFactory 인터페이스 테스트 ==================== - - def "ChatResponseFactory: default 메서드 동작"() { - given: "Mock Factory" - def userMessage = "Test" - - when: "대화 내역 없이 호출" - def response = mockFactory.create(userMessage, ChatLevel.BEGINNER) - - then: "정상 동작" - response != null - } -} From 49e03dbcd94e4113bed8bf9614e54860fcbe475a Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 13 Jan 2026 16:53:17 +0900 Subject: [PATCH 165/528] =?UTF-8?q?docs:=20Grammar=20API=20=EB=AA=85?= =?UTF-8?q?=EC=84=B8=EC=84=9C=20=EC=B6=94=EA=B0=80=20(=ED=94=84=EB=A1=A0?= =?UTF-8?q?=ED=8A=B8=EC=97=94=EB=93=9C=EC=9A=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/grammar-api-specification.md | 401 ++++++++++++++++++++++++++++++ 1 file changed, 401 insertions(+) create mode 100644 docs/grammar-api-specification.md diff --git a/docs/grammar-api-specification.md b/docs/grammar-api-specification.md new file mode 100644 index 00000000..dab8d63e --- /dev/null +++ b/docs/grammar-api-specification.md @@ -0,0 +1,401 @@ +# Grammar API 명세서 + +## 서비스 개요 + +영어 문법 체크 및 AI 대화 연습 서비스입니다. + +### 주요 기능 +| 기능 | 설명 | +|------|------| +| **문법 체크** | 사용자가 입력한 영어 문장의 문법 오류를 분석하고 교정 | +| **AI 대화 연습** | AI와 1:1 영어 대화 연습 (문법 체크 + 대화 응답 + 학습 팁) | +| **세션 관리** | 대화 세션 목록 조회, 상세 조회, 삭제 | + +### 레벨 시스템 +| 레벨 | 설명 | +|------|------| +| `BEGINNER` | 초급 - 한국어 번역 포함, 쉬운 설명 | +| `INTERMEDIATE` | 중급 - 영어 위주 설명 | +| `ADVANCED` | 고급 - 상세한 문법 규칙 설명 | + +--- + +## Base URL + +``` +https://gc8l9ijhzc.execute-api.ap-northeast-2.amazonaws.com/dev +``` + +## 인증 + +모든 API는 **Cognito 인증**이 필요합니다. + +``` +Authorization: Bearer {ID_TOKEN} +``` + +--- + +## API 목록 + +| Method | Endpoint | 설명 | +|--------|----------|------| +| POST | `/grammar/check` | 문법 체크 | +| POST | `/grammar/conversation` | AI 대화 연습 | +| GET | `/grammar/sessions` | 세션 목록 조회 | +| GET | `/grammar/sessions/{sessionId}` | 세션 상세 조회 | +| DELETE | `/grammar/sessions/{sessionId}` | 세션 삭제 | + +--- + +## 1. 문법 체크 API + +영어 문장의 문법 오류를 분석하고 교정합니다. + +### Request + +``` +POST /grammar/check +Content-Type: application/json +Authorization: Bearer {TOKEN} +``` + +**Body:** +```json +{ + "sentence": "I goed to school yesterday.", + "level": "BEGINNER" +} +``` + +| 필드 | 타입 | 필수 | 설명 | +|------|------|------|------| +| sentence | string | ✅ | 검사할 영어 문장 | +| level | string | ❌ | 레벨 (BEGINNER/INTERMEDIATE/ADVANCED), 기본값: BEGINNER | + +### Response (성공) + +```json +{ + "isSuccess": true, + "message": "Grammar checked successfully", + "data": { + "originalSentence": "I goed to school yesterday.", + "correctedSentence": "I went to school yesterday.", + "score": 80, + "isCorrect": false, + "errors": [ + { + "type": "VERB_TENSE", + "original": "goed", + "corrected": "went", + "explanation": "The verb 'go' in the past tense should be 'went'. In Korean, this would be '갔어'.", + "startIndex": 2, + "endIndex": 6 + } + ], + "feedback": "Good try! The past tense of 'go' is 'went'. Keep practicing!" + } +} +``` + +| 필드 | 타입 | 설명 | +|------|------|------| +| originalSentence | string | 원본 문장 | +| correctedSentence | string | 교정된 문장 | +| score | number | 문법 점수 (0-100) | +| isCorrect | boolean | 문법 오류 없음 여부 | +| errors | array | 오류 목록 | +| feedback | string | 전체 피드백 메시지 | + +### Error Types (오류 타입) + +| 타입 | 한국어 | 설명 | +|------|--------|------| +| VERB_TENSE | 동사 시제 | 시제 오류 | +| SUBJECT_VERB_AGREEMENT | 주어-동사 일치 | 주어와 동사 수 일치 오류 | +| ARTICLE | 관사 | a/an/the 오류 | +| PREPOSITION | 전치사 | 전치사 오류 | +| WORD_ORDER | 어순 | 단어 순서 오류 | +| PLURAL_SINGULAR | 단/복수 | 단수/복수 오류 | +| PRONOUN | 대명사 | 대명사 오류 | +| SPELLING | 철자 | 철자 오류 | +| PUNCTUATION | 구두점 | 구두점 오류 | +| WORD_CHOICE | 어휘 선택 | 단어 선택 오류 | +| SENTENCE_STRUCTURE | 문장 구조 | 문장 구조 오류 | +| OTHER | 기타 | 기타 오류 | + +--- + +## 2. AI 대화 연습 API + +AI와 대화하면서 영어를 연습합니다. 사용자의 메시지에 대해 문법 체크 + AI 응답 + 학습 팁을 제공합니다. + +### Request + +``` +POST /grammar/conversation +Content-Type: application/json +Authorization: Bearer {TOKEN} +``` + +**Body:** +```json +{ + "sessionId": "550e8400-e29b-41d4-a716-446655440000", + "message": "I wants to learn English. Can you help me?", + "level": "BEGINNER" +} +``` + +| 필드 | 타입 | 필수 | 설명 | +|------|------|------|------| +| sessionId | string | ❌ | 세션 ID (없으면 새 세션 생성) | +| message | string | ✅ | 사용자 메시지 | +| level | string | ❌ | 레벨, 기본값: BEGINNER | + +### Response (성공) + +```json +{ + "isSuccess": true, + "message": "Conversation generated successfully", + "data": { + "sessionId": "550e8400-e29b-41d4-a716-446655440000", + "grammarCheck": { + "originalSentence": "I wants to learn English. Can you help me?", + "correctedSentence": "I want to learn English. Can you help me?", + "score": 90, + "isCorrect": false, + "errors": [ + { + "type": "SUBJECT_VERB_AGREEMENT", + "original": "wants", + "corrected": "want", + "explanation": "With 'I', use 'want' not 'wants'. 'Wants' is for he/she/it." + } + ], + "feedback": "Small mistake with subject-verb agreement. Keep going!" + }, + "aiResponse": "Of course! I'd be happy to help you learn English. What would you like to practice today? We can talk about any topic you're interested in.", + "conversationTip": "Try to use simple sentences first. For example: 'I like music.' or 'I want to travel.'" + } +} +``` + +| 필드 | 타입 | 설명 | +|------|------|------| +| sessionId | string | 세션 ID (다음 요청에 포함하면 대화 이어가기) | +| grammarCheck | object | 문법 체크 결과 (위 문법 체크 API 응답과 동일) | +| aiResponse | string | AI의 대화 응답 | +| conversationTip | string | 학습 팁 | + +### 대화 이어가기 + +세션 ID를 포함하면 이전 대화 컨텍스트를 유지하며 대화를 이어갑니다. + +```json +{ + "sessionId": "550e8400-e29b-41d4-a716-446655440000", + "message": "I like to watch movies.", + "level": "BEGINNER" +} +``` + +--- + +## 3. 세션 목록 조회 API + +사용자의 대화 세션 목록을 조회합니다. + +### Request + +``` +GET /grammar/sessions?limit=10&cursor={cursor} +Authorization: Bearer {TOKEN} +``` + +| 파라미터 | 타입 | 필수 | 설명 | +|----------|------|------|------| +| limit | number | ❌ | 조회 개수 (기본: 10, 최대: 50) | +| cursor | string | ❌ | 페이지네이션 커서 | + +### Response (성공) + +```json +{ + "isSuccess": true, + "message": "Sessions retrieved successfully", + "data": { + "sessions": [ + { + "sessionId": "550e8400-e29b-41d4-a716-446655440000", + "level": "BEGINNER", + "topic": null, + "messageCount": 5, + "lastMessage": "I like to watch movies.", + "createdAt": "2026-01-13T10:30:00Z", + "updatedAt": "2026-01-13T11:00:00Z" + } + ], + "nextCursor": "eyJQSyI6IkdTRVNT...", + "hasMore": true + } +} +``` + +--- + +## 4. 세션 상세 조회 API + +특정 세션의 상세 정보와 대화 기록을 조회합니다. + +### Request + +``` +GET /grammar/sessions/{sessionId}?messageLimit=50 +Authorization: Bearer {TOKEN} +``` + +| 파라미터 | 타입 | 필수 | 설명 | +|----------|------|------|------| +| sessionId | path | ✅ | 세션 ID | +| messageLimit | query | ❌ | 메시지 조회 개수 (기본: 50, 최대: 100) | + +### Response (성공) + +```json +{ + "isSuccess": true, + "message": "Session detail retrieved successfully", + "data": { + "session": { + "sessionId": "550e8400-e29b-41d4-a716-446655440000", + "level": "BEGINNER", + "messageCount": 5, + "createdAt": "2026-01-13T10:30:00Z", + "updatedAt": "2026-01-13T11:00:00Z" + }, + "messages": [ + { + "messageId": "msg-001", + "role": "USER", + "content": "I wants to learn English.", + "correctedContent": "I want to learn English.", + "grammarScore": 90, + "createdAt": "2026-01-13T10:30:00Z" + }, + { + "messageId": "msg-002", + "role": "ASSISTANT", + "content": "Of course! I'd be happy to help you learn English.", + "createdAt": "2026-01-13T10:30:05Z" + } + ] + } +} +``` + +--- + +## 5. 세션 삭제 API + +특정 세션을 삭제합니다. + +### Request + +``` +DELETE /grammar/sessions/{sessionId} +Authorization: Bearer {TOKEN} +``` + +### Response (성공) + +```json +{ + "isSuccess": true, + "message": "Session deleted successfully", + "data": null +} +``` + +--- + +## 에러 응답 + +### 공통 에러 형식 + +```json +{ + "code": "GRAMMAR.GRAMMAR_001", + "message": "유효하지 않은 문장입니다", + "status": 400, + "details": { + "sentence": "" + } +} +``` + +### 에러 코드 목록 + +| 코드 | 메시지 | HTTP Status | +|------|--------|-------------| +| GRAMMAR_001 | 유효하지 않은 문장입니다 | 400 | +| GRAMMAR_002 | 문법 체크에 실패했습니다 | 500 | +| GRAMMAR_003 | 유효하지 않은 레벨입니다 | 400 | +| GRAMMAR_004 | AI 서비스 호출에 실패했습니다 | 502 | +| GRAMMAR_005 | AI 응답 파싱에 실패했습니다 | 500 | +| GRAMMAR_006 | 세션을 찾을 수 없습니다 | 404 | +| GRAMMAR_007 | 세션이 만료되었습니다 | 410 | + +--- + +## 사용 시나리오 + +### 시나리오 1: 문법 체크만 사용 + +``` +1. POST /grammar/check - 문장 검사 +2. 결과 표시 (오류, 교정, 피드백) +``` + +### 시나리오 2: AI 대화 연습 + +``` +1. POST /grammar/conversation (sessionId 없이) - 새 대화 시작 +2. 응답에서 sessionId 저장 +3. POST /grammar/conversation (sessionId 포함) - 대화 이어가기 +4. 반복... +``` + +### 시나리오 3: 대화 기록 관리 + +``` +1. GET /grammar/sessions - 세션 목록 조회 +2. GET /grammar/sessions/{id} - 특정 세션 대화 기록 조회 +3. DELETE /grammar/sessions/{id} - 세션 삭제 +``` + +--- + +## UI 구현 참고사항 + +### 문법 체크 결과 표시 +- `errors` 배열의 `startIndex`, `endIndex`를 사용하여 원문에서 오류 부분 하이라이트 +- `score`로 점수 표시 (프로그레스 바 등) +- `isCorrect`가 true면 "Perfect!" 메시지 표시 + +### 대화 UI +- 채팅 형식 UI 권장 +- 사용자 메시지 위에 문법 체크 결과 표시 (말풍선 위 작은 배지 등) +- AI 응답 아래에 `conversationTip` 표시 + +### 레벨 선택 +- 처음 사용자는 BEGINNER로 시작 +- 설정에서 레벨 변경 가능하게 구현 + +--- + +## 연락처 + +백엔드 관련 문의: [담당자 연락처] From f5dc419748814f2b9111307c069e3c1eefde6580 Mon Sep 17 00:00:00 2001 From: hyein Heo <128613248+hye-inA@users.noreply.github.com> Date: Tue, 13 Jan 2026 17:06:27 +0900 Subject: [PATCH 166/528] =?UTF-8?q?refactor=20:=20=EA=B8=B0=EC=A1=B4=20iss?= =?UTF-8?q?ue,=20PR=20template=20=EA=B8=B0=EB=B0=98=20jira=20=EB=A7=A4?= =?UTF-8?q?=ED=95=91=20workflow=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20(#280)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor : JSON payload 추가 * refactor : 기존 issue, PR template 기반 jira 매핑 workflow로 변경 --- .github/workflows/github-jira-issue-sync.yml | 220 ++++++------------- .github/workflows/github-jira-pr-sync.yml | 157 ++++--------- 2 files changed, 103 insertions(+), 274 deletions(-) diff --git a/.github/workflows/github-jira-issue-sync.yml b/.github/workflows/github-jira-issue-sync.yml index 70bb9627..12f51471 100644 --- a/.github/workflows/github-jira-issue-sync.yml +++ b/.github/workflows/github-jira-issue-sync.yml @@ -3,184 +3,90 @@ name: Issue-Jira Sync on: issues: - types: [ opened, closed, reopened ] - issue_comment: - types: [ created ] - -env: - JIRA_PROJECT_KEY: MESP + types: [opened] jobs: - # GitHub Issue 생성 → Jira 티켓 생성 - create-jira-from-issue: + sync-issue-to-jira: runs-on: ubuntu-latest - if: github.event_name == 'issues' && github.event.action == 'opened' steps: - - name: Determine Issue Type from Title + - name: Checkout + uses: actions/checkout@v4 + + - name: Login to Jira + uses: atlassian/gajira-login@v3 + env: + JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }} + JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} + JIRA_USER_EMAIL: ${{ secrets.JIRA_USER_EMAIL }} + + - name: Determine Issue Type id: issue-type run: | TITLE='${{ github.event.issue.title }}' - # Issue 제목 기반 Jira 타입 매핑 - # [EPIC], [STORY], [TASK] 3가지 if echo "$TITLE" | grep -qiE "^\[EPIC\]"; then echo "type=Epic" >> $GITHUB_OUTPUT + echo "template=epic.yml" >> $GITHUB_OUTPUT elif echo "$TITLE" | grep -qiE "^\[STORY\]"; then echo "type=Story" >> $GITHUB_OUTPUT + echo "template=story.yml" >> $GITHUB_OUTPUT + elif echo "$TITLE" | grep -qiE "^\[TASK\]"; then + echo "type=Task" >> $GITHUB_OUTPUT + echo "template=task.yml" >> $GITHUB_OUTPUT + elif echo "$TITLE" | grep -qiE "^\[SPIKE\]"; then + echo "type=Task" >> $GITHUB_OUTPUT + echo "template=spike.yml" >> $GITHUB_OUTPUT + elif echo "$TITLE" | grep -qiE "^\[CR\]"; then + echo "type=Task" >> $GITHUB_OUTPUT + echo "template=change_request.yml" >> $GITHUB_OUTPUT else echo "type=Task" >> $GITHUB_OUTPUT + echo "template=task.yml" >> $GITHUB_OUTPUT fi - - name: Create Jira Issue - id: create-jira - env: - ISSUE_TITLE: ${{ github.event.issue.title }} - ISSUE_URL: ${{ github.event.issue.html_url }} - ISSUE_AUTHOR: ${{ github.event.issue.user.login }} - ISSUE_NUMBER: ${{ github.event.issue.number }} - ISSUE_TYPE: ${{ steps.issue-type.outputs.type }} - JIRA_AUTH: ${{ secrets.JIRA_USER_EMAIL }}:${{ secrets.JIRA_API_TOKEN }} - run: | - PAYLOAD=$(jq -n \ - --arg project "$JIRA_PROJECT_KEY" \ - --arg summary "[GH-#$ISSUE_NUMBER] $ISSUE_TITLE" \ - --arg issue_url "GitHub Issue: $ISSUE_URL" \ - --arg author "Author: $ISSUE_AUTHOR" \ - --arg type "$ISSUE_TYPE" \ - '{ - fields: { - project: {key: $project}, - summary: $summary, - description: { - type: "doc", - version: 1, - content: [ - {type: "paragraph", content: [{type: "text", text: $issue_url}]}, - {type: "paragraph", content: [{type: "text", text: $author}]} - ] - }, - issuetype: {name: $type} - } - }') - - RESPONSE=$(curl -s -X POST \ - -H "Authorization: Basic $(echo -n "$JIRA_AUTH" | base64)" \ - -H "Content-Type: application/json" \ - "${{ secrets.JIRA_BASE_URL }}/rest/api/3/issue" \ - -d "$PAYLOAD") - - echo "API 응답값: $RESPONSE" - - JIRA_KEY=$(echo "$RESPONSE" | jq -r '.key // empty') - if [ -z "$JIRA_KEY" ]; then - echo "Jira 티켓 생성 실패" - exit 1 - fi - echo "jira_key=$JIRA_KEY" >> $GITHUB_OUTPUT + - name: Parse Issue + uses: stefanbuck/github-issue-parser@v3 + id: issue-parser + with: + template-path: .github/ISSUE_TEMPLATE/${{ steps.issue-type.outputs.template }} - - name: Update GitHub Issue with Jira Link - if: steps.create-jira.outputs.jira_key != '' - uses: actions/github-script@v7 + - name: Convert to Jira Syntax + uses: peter-evans/jira2md@v1 + id: md2jira with: - script: | - const jiraKey = '${{ steps.create-jira.outputs.jira_key }}'; - const jiraUrl = '${{ secrets.JIRA_BASE_URL }}/browse/' + jiraKey; - const currentBody = context.payload.issue.body || ''; - const newBody = `\n---\n**Jira:** [${jiraKey}](${jiraUrl})\n---\n` + currentBody; + input-text: | + h3. GitHub Issue + ${{ github.event.issue.html_url }} - await github.rest.issues.update({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body: newBody - }); + h3. Author + ${{ github.event.issue.user.login }} - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body: `Jira 티켓 생성: [${jiraKey}](${jiraUrl})` - }); - - # GitHub Issue 닫힘 → Jira 티켓 Done - close-jira-on-issue-close: - runs-on: ubuntu-latest - if: github.event_name == 'issues' && github.event.action == 'closed' - - steps: - - name: Extract Jira Key and Transition to Done - run: | - BODY='${{ github.event.issue.body }}' - JIRA_KEY=$(echo "$BODY" | grep -oE '${{ env.JIRA_PROJECT_KEY }}-[0-9]+' | head -1) - - [ -z "$JIRA_KEY" ] && exit 0 - - TRANSITIONS=$(curl -s -X GET \ - -H "Authorization: Basic $(echo -n '${{ secrets.JIRA_USER_EMAIL }}:${{ secrets.JIRA_API_TOKEN }}' | base64)" \ - "${{ secrets.JIRA_BASE_URL }}/rest/api/3/issue/${JIRA_KEY}/transitions") - - DONE_ID=$(echo "$TRANSITIONS" | jq -r '.transitions[] | select(.name | test("Done|완료|Closed|종료"; "i")) | .id' | head -1) - - [ -z "$DONE_ID" ] && exit 0 - - curl -s -X POST \ - -H "Authorization: Basic $(echo -n '${{ secrets.JIRA_USER_EMAIL }}:${{ secrets.JIRA_API_TOKEN }}' | base64)" \ - -H "Content-Type: application/json" \ - "${{ secrets.JIRA_BASE_URL }}/rest/api/3/issue/${JIRA_KEY}/transitions" \ - -d "{\"transition\": {\"id\": \"$DONE_ID\"}}" + ---- + ${{ github.event.issue.body }} + mode: md2jira - # GitHub Issue 재오픈 → Jira 상태 복구 - reopen-jira-on-issue-reopen: - runs-on: ubuntu-latest - if: github.event_name == 'issues' && github.event.action == 'reopened' - - steps: - - name: Extract Jira Key and Transition to In Progress - run: | - BODY='${{ github.event.issue.body }}' - JIRA_KEY=$(echo "$BODY" | grep -oE '${{ env.JIRA_PROJECT_KEY }}-[0-9]+' | head -1) - - [ -z "$JIRA_KEY" ] && exit 0 - - TRANSITIONS=$(curl -s -X GET \ - -H "Authorization: Basic $(echo -n '${{ secrets.JIRA_USER_EMAIL }}:${{ secrets.JIRA_API_TOKEN }}' | base64)" \ - "${{ secrets.JIRA_BASE_URL }}/rest/api/3/issue/${JIRA_KEY}/transitions") - - PROGRESS_ID=$(echo "$TRANSITIONS" | jq -r '.transitions[] | select(.name | test("In Progress|진행|Open|To Do"; "i")) | .id' | head -1) - - [ -z "$PROGRESS_ID" ] && exit 0 - - curl -s -X POST \ - -H "Authorization: Basic $(echo -n '${{ secrets.JIRA_USER_EMAIL }}:${{ secrets.JIRA_API_TOKEN }}' | base64)" \ - -H "Content-Type: application/json" \ - "${{ secrets.JIRA_BASE_URL }}/rest/api/3/issue/${JIRA_KEY}/transitions" \ - -d "{\"transition\": {\"id\": \"$PROGRESS_ID\"}}" + - name: Create Jira Issue + id: create-jira + uses: atlassian/gajira-create@v3 + with: + project: MESP + issuetype: ${{ steps.issue-type.outputs.type }} + summary: '${{ github.event.issue.title }}' + description: '${{ steps.md2jira.outputs.output-text }}' - # GitHub 코멘트 → Jira 코멘트 동기화 - sync-comment-to-jira: - runs-on: ubuntu-latest - if: github.event_name == 'issue_comment' && github.event.action == 'created' + - name: Update Issue Title + uses: actions-cool/issues-helper@v3 + with: + actions: 'update-issue' + token: ${{ secrets.GITHUB_TOKEN }} + title: '[${{ steps.create-jira.outputs.issue }}] ${{ github.event.issue.title }}' - steps: - - name: Sync Comment to Jira - run: | - BODY='${{ github.event.issue.body }}' - JIRA_KEY=$(echo "$BODY" | grep -oE '${{ env.JIRA_PROJECT_KEY }}-[0-9]+' | head -1) - - [ -z "$JIRA_KEY" ] && exit 0 - - COMMENT_AUTHOR="${{ github.event.comment.user.login }}" - COMMENT_BODY=$(echo '${{ github.event.comment.body }}' | head -c 500 | sed 's/"/\\"/g' | tr '\n' ' ') - - curl -s -X POST \ - -H "Authorization: Basic $(echo -n '${{ secrets.JIRA_USER_EMAIL }}:${{ secrets.JIRA_API_TOKEN }}' | base64)" \ - -H "Content-Type: application/json" \ - "${{ secrets.JIRA_BASE_URL }}/rest/api/3/issue/${JIRA_KEY}/comment" \ - -d "{ - \"body\": { - \"type\": \"doc\", - \"version\": 1, - \"content\": [{\"type\": \"paragraph\", \"content\": [{\"type\": \"text\", \"text\": \"@${COMMENT_AUTHOR}: ${COMMENT_BODY}\"}]}] - } - }" + - name: Add Jira Link Comment + uses: actions-cool/issues-helper@v3 + with: + actions: 'create-comment' + token: ${{ secrets.GITHUB_TOKEN }} + issue-number: ${{ github.event.issue.number }} + body: | + Jira: [${{ steps.create-jira.outputs.issue }}](${{ secrets.JIRA_BASE_URL }}/browse/${{ steps.create-jira.outputs.issue }}) \ No newline at end of file diff --git a/.github/workflows/github-jira-pr-sync.yml b/.github/workflows/github-jira-pr-sync.yml index c1a7278a..3b5a60bc 100644 --- a/.github/workflows/github-jira-pr-sync.yml +++ b/.github/workflows/github-jira-pr-sync.yml @@ -1,151 +1,74 @@ # GitHub PR → Jira 동기화 -name: Github-Jira PR Sync +name: PR-Jira Sync on: pull_request: - types: [ opened, closed, reopened ] - -env: - JIRA_PROJECT_KEY: MESP + types: [opened] jobs: - # GitHub PR 생성 → Jira 티켓 생성 - create-jira-from-pr: + sync-pr-to-jira: runs-on: ubuntu-latest - if: github.event.action == 'opened' steps: - - name: Determine Issue Type from PR Title + - name: Login to Jira + uses: atlassian/gajira-login@v3 + env: + JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }} + JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} + JIRA_USER_EMAIL: ${{ secrets.JIRA_USER_EMAIL }} + + - name: Determine Issue Type id: issue-type run: | TITLE='${{ github.event.pull_request.title }}' - # PR 제목 기반 Jira 타입 매핑 if echo "$TITLE" | grep -qiE "^\[EPIC\]"; then echo "type=Epic" >> $GITHUB_OUTPUT elif echo "$TITLE" | grep -qiE "^\[STORY\]"; then echo "type=Story" >> $GITHUB_OUTPUT - elif echo "$TITLE" | grep -qiE "^(fix:|hotfix:)"; then + elif echo "$TITLE" | grep -qiE "^fix:|^hotfix:"; then echo "type=Bug" >> $GITHUB_OUTPUT else - # feat:, improve:, refactor:, release:, [TASK] 등 → Task echo "type=Task" >> $GITHUB_OUTPUT fi + - name: Convert to Jira Syntax + uses: peter-evans/jira2md@v1 + id: md2jira + with: + input-text: | + h3. GitHub PR + ${{ github.event.pull_request.html_url }} + + h3. Author + ${{ github.event.pull_request.user.login }} + + h3. Branch + ${{ github.head_ref }} -> ${{ github.base_ref }} + + ---- + ${{ github.event.pull_request.body }} + mode: md2jira + - name: Create Jira Issue id: create-jira - run: | - RESPONSE=$(curl -s -X POST \ - -H "Authorization: Basic $(echo -n '${{ secrets.JIRA_USER_EMAIL }}:${{ secrets.JIRA_API_TOKEN }}' | base64)" \ - -H "Content-Type: application/json" \ - "${{ secrets.JIRA_BASE_URL }}/rest/api/3/issue" \ - -d '{ - "fields": { - "project": {"key": "${{ env.JIRA_PROJECT_KEY }}"}, - "summary": "[PR-#${{ github.event.pull_request.number }}] ${{ github.event.pull_request.title }}", - "description": { - "type": "doc", - "version": 1, - "content": [ - {"type": "paragraph", "content": [{"type": "text", "text": "GitHub PR: ${{ github.event.pull_request.html_url }}"}]}, - {"type": "paragraph", "content": [{"type": "text", "text": "Author: ${{ github.event.pull_request.user.login }}"}]}, - {"type": "paragraph", "content": [{"type": "text", "text": "Branch: ${{ github.head_ref }} → ${{ github.base_ref }}"}]} - ] - }, - "issuetype": {"name": "${{ steps.issue-type.outputs.type }}"} - } - }') - - JIRA_KEY=$(echo "$RESPONSE" | jq -r '.key // empty') - echo "jira_key=$JIRA_KEY" >> $GITHUB_OUTPUT + uses: atlassian/gajira-create@v3 + with: + project: MESP + issuetype: ${{ steps.issue-type.outputs.type }} + summary: '[PR-#${{ github.event.pull_request.number }}] ${{ github.event.pull_request.title }}' + description: '${{ steps.md2jira.outputs.output-text }}' - - name: Update PR with Jira Link - if: steps.create-jira.outputs.jira_key != '' + - name: Add Jira Link Comment uses: actions/github-script@v7 with: script: | - const jiraKey = '${{ steps.create-jira.outputs.jira_key }}'; + const jiraKey = '${{ steps.create-jira.outputs.issue }}'; const jiraUrl = '${{ secrets.JIRA_BASE_URL }}/browse/' + jiraKey; - const currentBody = context.payload.pull_request.body || ''; - const newBody = `\n---\n**Jira:** [${jiraKey}](${jiraUrl})\n---\n` + currentBody; - - await github.rest.pulls.update({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: context.payload.pull_request.number, - body: newBody - }); await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.payload.pull_request.number, - body: `Jira 티켓 생성: [${jiraKey}](${jiraUrl})` - }); - - # PR 머지 → Jira 티켓 Done - close-jira-on-pr-merge: - runs-on: ubuntu-latest - if: github.event.action == 'closed' && github.event.pull_request.merged == true - - steps: - - name: Extract Jira Key and Transition to Done - run: | - BODY='${{ github.event.pull_request.body }}' - JIRA_KEY=$(echo "$BODY" | grep -oE '${{ env.JIRA_PROJECT_KEY }}-[0-9]+' | head -1) - - [ -z "$JIRA_KEY" ] && exit 0 - - TRANSITIONS=$(curl -s -X GET \ - -H "Authorization: Basic $(echo -n '${{ secrets.JIRA_USER_EMAIL }}:${{ secrets.JIRA_API_TOKEN }}' | base64)" \ - "${{ secrets.JIRA_BASE_URL }}/rest/api/3/issue/${JIRA_KEY}/transitions") - - DONE_ID=$(echo "$TRANSITIONS" | jq -r '.transitions[] | select(.name | test("Done|완료|Closed|종료"; "i")) | .id' | head -1) - - [ -z "$DONE_ID" ] && exit 0 - - curl -s -X POST \ - -H "Authorization: Basic $(echo -n '${{ secrets.JIRA_USER_EMAIL }}:${{ secrets.JIRA_API_TOKEN }}' | base64)" \ - -H "Content-Type: application/json" \ - "${{ secrets.JIRA_BASE_URL }}/rest/api/3/issue/${JIRA_KEY}/transitions" \ - -d "{\"transition\": {\"id\": \"$DONE_ID\"}}" - - # Add merge comment to Jira - curl -s -X POST \ - -H "Authorization: Basic $(echo -n '${{ secrets.JIRA_USER_EMAIL }}:${{ secrets.JIRA_API_TOKEN }}' | base64)" \ - -H "Content-Type: application/json" \ - "${{ secrets.JIRA_BASE_URL }}/rest/api/3/issue/${JIRA_KEY}/comment" \ - -d '{ - "body": { - "type": "doc", - "version": 1, - "content": [{"type": "paragraph", "content": [{"type": "text", "text": "PR #${{ github.event.pull_request.number }} merged to ${{ github.base_ref }}"}]}] - } - }' - - # PR 재오픈 → Jira 상태 복구 - reopen-jira-on-pr-reopen: - runs-on: ubuntu-latest - if: github.event.action == 'reopened' - - steps: - - name: Extract Jira Key and Transition to In Progress - run: | - BODY='${{ github.event.pull_request.body }}' - JIRA_KEY=$(echo "$BODY" | grep -oE '${{ env.JIRA_PROJECT_KEY }}-[0-9]+' | head -1) - - [ -z "$JIRA_KEY" ] && exit 0 - - TRANSITIONS=$(curl -s -X GET \ - -H "Authorization: Basic $(echo -n '${{ secrets.JIRA_USER_EMAIL }}:${{ secrets.JIRA_API_TOKEN }}' | base64)" \ - "${{ secrets.JIRA_BASE_URL }}/rest/api/3/issue/${JIRA_KEY}/transitions") - - PROGRESS_ID=$(echo "$TRANSITIONS" | jq -r '.transitions[] | select(.name | test("In Progress|진행|Open|To Do"; "i")) | .id' | head -1) - - [ -z "$PROGRESS_ID" ] && exit 0 - - curl -s -X POST \ - -H "Authorization: Basic $(echo -n '${{ secrets.JIRA_USER_EMAIL }}:${{ secrets.JIRA_API_TOKEN }}' | base64)" \ - -H "Content-Type: application/json" \ - "${{ secrets.JIRA_BASE_URL }}/rest/api/3/issue/${JIRA_KEY}/transitions" \ - -d "{\"transition\": {\"id\": \"$PROGRESS_ID\"}}" + body: `Jira: [${jiraKey}](${jiraUrl})` + }); \ No newline at end of file From c18242d1c2c959c2574f88e29acdd476a4ed539e Mon Sep 17 00:00:00 2001 From: hyein Heo <128613248+hye-inA@users.noreply.github.com> Date: Tue, 13 Jan 2026 17:18:38 +0900 Subject: [PATCH 167/528] =?UTF-8?q?fix=20:=20jira=20issue=20=EB=8F=99?= =?UTF-8?q?=EA=B8=B0=ED=99=94=20=EC=97=90=EB=9F=AC=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?(#282)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor : JSON payload 추가 * refactor : 기존 issue, PR template 기반 jira 매핑 workflow로 변경 * fix : GITHUB_TOKEN 권한 추가 --- .github/workflows/github-jira-issue-sync.yml | 4 ++++ .github/workflows/github-jira-pr-sync.yml | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/.github/workflows/github-jira-issue-sync.yml b/.github/workflows/github-jira-issue-sync.yml index 12f51471..1238f1f9 100644 --- a/.github/workflows/github-jira-issue-sync.yml +++ b/.github/workflows/github-jira-issue-sync.yml @@ -5,6 +5,10 @@ on: issues: types: [opened] +permissions: + issues: write + contents: read + jobs: sync-issue-to-jira: runs-on: ubuntu-latest diff --git a/.github/workflows/github-jira-pr-sync.yml b/.github/workflows/github-jira-pr-sync.yml index 3b5a60bc..9a608fb1 100644 --- a/.github/workflows/github-jira-pr-sync.yml +++ b/.github/workflows/github-jira-pr-sync.yml @@ -5,6 +5,10 @@ on: pull_request: types: [opened] +permissions: + issues: write + contents: read + jobs: sync-pr-to-jira: runs-on: ubuntu-latest From 4bc43b02dc67f1bff389b48040fa760f60bdbde8 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 13 Jan 2026 17:54:54 +0900 Subject: [PATCH 168/528] =?UTF-8?q?fix:=20Grammar=20API=20Bedrock=20?= =?UTF-8?q?=EA=B6=8C=ED=95=9C=20=EB=B0=8F=20=EC=84=B8=EC=85=98=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EC=88=98=EC=A0=95=20(#284)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - template.yaml에 AWS Marketplace 권한 추가 - aws-marketplace:ViewSubscriptions - aws-marketplace:Subscribe - GrammarConversationService DynamoDB 저장 로직 구현 - 인메모리 → DynamoDB 영구 저장 - 세션 및 메시지 저장 - ConversationRequest에 userId 필드 추가 - GrammarHandler에서 JWT userId 전달 - CHATTING-GUIDE.md에 캐치마인드 API 문서 추가 Closes #284 --- .../dto/request/ConversationRequest.java | 1 + .../grammar/handler/GrammarHandler.java | 1 + .../service/GrammarConversationService.java | 189 ++++++++++++++--- ServerlessFunction/template.yaml | 6 + docs/chatting/CHATTING-GUIDE.md | 197 +++++++++++++++++- 5 files changed, 364 insertions(+), 30 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/dto/request/ConversationRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/dto/request/ConversationRequest.java index 6eb6d6fb..7d335fa4 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/dto/request/ConversationRequest.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/dto/request/ConversationRequest.java @@ -9,6 +9,7 @@ public class ConversationRequest { private String sessionId; private String message; + private String userId; // Handler에서 설정 @Builder.Default private String level = "BEGINNER"; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/GrammarHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/GrammarHandler.java index 527d81be..e6467272 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/GrammarHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/GrammarHandler.java @@ -74,6 +74,7 @@ private APIGatewayProxyResponseEvent checkGrammar(APIGatewayProxyRequestEvent re private APIGatewayProxyResponseEvent conversation(APIGatewayProxyRequestEvent request, String userId) { ConversationRequest req = ResponseGenerator.gson().fromJson(request.getBody(), ConversationRequest.class); + req.setUserId(userId); // JWT에서 추출한 userId 설정 return BeanValidator.validateAndExecute(req, dto -> { ConversationResponse result = conversationService.chat(dto); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/service/GrammarConversationService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/service/GrammarConversationService.java index 48ed34bb..7bc6ef04 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/service/GrammarConversationService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/service/GrammarConversationService.java @@ -1,47 +1,76 @@ package com.mzc.secondproject.serverless.domain.grammar.service; +import com.google.gson.Gson; +import com.mzc.secondproject.serverless.domain.grammar.constants.GrammarKey; import com.mzc.secondproject.serverless.domain.grammar.dto.request.ConversationRequest; import com.mzc.secondproject.serverless.domain.grammar.dto.response.ConversationResponse; +import com.mzc.secondproject.serverless.domain.grammar.dto.response.GrammarCheckResponse; import com.mzc.secondproject.serverless.domain.grammar.enums.GrammarLevel; import com.mzc.secondproject.serverless.domain.grammar.exception.GrammarException; import com.mzc.secondproject.serverless.domain.grammar.factory.BedrockGrammarCheckFactory; +import com.mzc.secondproject.serverless.domain.grammar.model.GrammarMessage; +import com.mzc.secondproject.serverless.domain.grammar.model.GrammarSession; +import com.mzc.secondproject.serverless.domain.grammar.repository.GrammarSessionRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.util.Map; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; import java.util.UUID; -import java.util.concurrent.ConcurrentHashMap; public class GrammarConversationService { private static final Logger logger = LoggerFactory.getLogger(GrammarConversationService.class); + private static final int SESSION_TTL_DAYS = 30; + private static final int MAX_HISTORY_MESSAGES = 10; private final BedrockGrammarCheckFactory grammarFactory; - private final Map conversationHistories; + private final GrammarSessionRepository repository; + private final Gson gson; public GrammarConversationService() { this.grammarFactory = new BedrockGrammarCheckFactory(); - this.conversationHistories = new ConcurrentHashMap<>(); + this.repository = new GrammarSessionRepository(); + this.gson = new Gson(); + } + + public GrammarConversationService(BedrockGrammarCheckFactory grammarFactory, GrammarSessionRepository repository) { + this.grammarFactory = grammarFactory; + this.repository = repository; + this.gson = new Gson(); } public ConversationResponse chat(ConversationRequest request) { - logger.info("Conversation chat requested: sessionId={}", request.getSessionId()); + logger.info("Conversation chat requested: sessionId={}, userId={}", request.getSessionId(), request.getUserId()); validateRequest(request); - String sessionId = getOrCreateSessionId(request.getSessionId()); + String userId = request.getUserId(); GrammarLevel level = parseLevel(request.getLevel()); - String conversationHistory = conversationHistories.getOrDefault(sessionId, new StringBuilder()).toString(); + // 세션 가져오기 또는 새로 생성 + GrammarSession session = getOrCreateSession(request.getSessionId(), userId, level); + // 이전 대화 히스토리 조회 + String conversationHistory = buildConversationHistory(session.getSessionId()); + + // AI 응답 생성 ConversationResponse response = grammarFactory.generateConversation( - sessionId, + session.getSessionId(), request.getMessage(), level, conversationHistory ); - updateConversationHistory(sessionId, request.getMessage(), response.getAiResponse()); + // 사용자 메시지 저장 + saveUserMessage(session, request.getMessage(), response.getGrammarCheck()); + + // AI 응답 메시지 저장 + saveAssistantMessage(session, response.getAiResponse()); + + // 세션 업데이트 + updateSession(session, request.getMessage()); return response; } @@ -50,13 +79,129 @@ private void validateRequest(ConversationRequest request) { if (request.getMessage() == null || request.getMessage().trim().isEmpty()) { throw GrammarException.invalidSentence(request.getMessage()); } + if (request.getUserId() == null || request.getUserId().trim().isEmpty()) { + throw new IllegalArgumentException("userId is required"); + } } - private String getOrCreateSessionId(String sessionId) { - if (sessionId == null || sessionId.trim().isEmpty()) { - return UUID.randomUUID().toString(); + private GrammarSession getOrCreateSession(String sessionId, String userId, GrammarLevel level) { + if (sessionId != null && !sessionId.trim().isEmpty()) { + return repository.findSessionById(userId, sessionId) + .orElseGet(() -> createNewSession(sessionId, userId, level)); + } + return createNewSession(UUID.randomUUID().toString(), userId, level); + } + + private GrammarSession createNewSession(String sessionId, String userId, GrammarLevel level) { + String now = Instant.now().toString(); + long ttl = Instant.now().plus(SESSION_TTL_DAYS, ChronoUnit.DAYS).getEpochSecond(); + + GrammarSession session = GrammarSession.builder() + .pk(GrammarKey.sessionPk(userId)) + .sk(GrammarKey.sessionSk(sessionId)) + .gsi1pk(GrammarKey.ALL_SESSIONS) + .gsi1sk(GrammarKey.updatedSk(now)) + .sessionId(sessionId) + .userId(userId) + .level(level.name()) + .topic("Conversation Practice") + .messageCount(0) + .createdAt(now) + .updatedAt(now) + .ttl(ttl) + .build(); + + repository.saveSession(session); + logger.info("New session created: sessionId={}", sessionId); + return session; + } + + private String buildConversationHistory(String sessionId) { + try { + List messages = repository.findRecentMessagesBySessionId(sessionId, MAX_HISTORY_MESSAGES); + + if (messages.isEmpty()) { + return ""; + } + + StringBuilder history = new StringBuilder(); + // 역순으로 정렬 (오래된 것 먼저) + for (int i = messages.size() - 1; i >= 0; i--) { + GrammarMessage msg = messages.get(i); + if ("USER".equals(msg.getRole())) { + history.append("User: ").append(msg.getContent()).append("\n"); + } else { + history.append("Assistant: ").append(msg.getContent()).append("\n"); + } + } + return history.toString(); + } catch (Exception e) { + logger.warn("Failed to build conversation history: {}", e.getMessage()); + return ""; } - return sessionId; + } + + private void saveUserMessage(GrammarSession session, String content, GrammarCheckResponse grammarCheck) { + String now = Instant.now().toString(); + String messageId = UUID.randomUUID().toString(); + long ttl = Instant.now().plus(SESSION_TTL_DAYS, ChronoUnit.DAYS).getEpochSecond(); + + GrammarMessage message = GrammarMessage.builder() + .pk(GrammarKey.sessionPk(session.getUserId())) + .sk(GrammarKey.messageSk(now, messageId)) + .gsi1pk(GrammarKey.messageGsi1Pk(session.getSessionId())) + .gsi1sk(GrammarKey.messageGsi1Sk(now)) + .messageId(messageId) + .sessionId(session.getSessionId()) + .userId(session.getUserId()) + .role("USER") + .content(content) + .correctedContent(grammarCheck != null ? grammarCheck.getCorrectedSentence() : null) + .errorsJson(grammarCheck != null ? gson.toJson(grammarCheck.getErrors()) : null) + .grammarScore(grammarCheck != null ? grammarCheck.getScore() : null) + .createdAt(now) + .ttl(ttl) + .build(); + + repository.saveMessage(message); + } + + private void saveAssistantMessage(GrammarSession session, String content) { + String now = Instant.now().toString(); + String messageId = UUID.randomUUID().toString(); + long ttl = Instant.now().plus(SESSION_TTL_DAYS, ChronoUnit.DAYS).getEpochSecond(); + + GrammarMessage message = GrammarMessage.builder() + .pk(GrammarKey.sessionPk(session.getUserId())) + .sk(GrammarKey.messageSk(now, messageId)) + .gsi1pk(GrammarKey.messageGsi1Pk(session.getSessionId())) + .gsi1sk(GrammarKey.messageGsi1Sk(now)) + .messageId(messageId) + .sessionId(session.getSessionId()) + .userId(session.getUserId()) + .role("ASSISTANT") + .content(content) + .createdAt(now) + .ttl(ttl) + .build(); + + repository.saveMessage(message); + } + + private void updateSession(GrammarSession session, String lastMessage) { + String now = Instant.now().toString(); + session.setGsi1sk(GrammarKey.updatedSk(now)); + session.setMessageCount(session.getMessageCount() + 2); // user + assistant + session.setLastMessage(truncateMessage(lastMessage, 100)); + session.setUpdatedAt(now); + + repository.saveSession(session); + } + + private String truncateMessage(String message, int maxLength) { + if (message == null) return null; + if (message.length() <= maxLength) return message; + return message.substring(0, maxLength - 3) + "..."; } private GrammarLevel parseLevel(String levelStr) { @@ -71,22 +216,8 @@ private GrammarLevel parseLevel(String levelStr) { return GrammarLevel.fromString(levelStr); } - private void updateConversationHistory(String sessionId, String userMessage, String aiResponse) { - StringBuilder history = conversationHistories.computeIfAbsent(sessionId, k -> new StringBuilder()); - - history.append("User: ").append(userMessage).append("\n"); - history.append("Assistant: ").append(aiResponse).append("\n\n"); - - if (history.length() > 10000) { - int cutIndex = history.indexOf("\n\n", history.length() - 8000); - if (cutIndex > 0) { - history.delete(0, cutIndex + 2); - } - } - } - - public void clearSession(String sessionId) { - conversationHistories.remove(sessionId); + public void clearSession(String userId, String sessionId) { + repository.deleteSession(userId, sessionId); logger.info("Session cleared: sessionId={}", sessionId); } } diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index cb0fe300..b1b6d2b4 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -975,6 +975,12 @@ Resources: Action: - bedrock:InvokeModel Resource: "*" + - Statement: + - Effect: Allow + Action: + - aws-marketplace:ViewSubscriptions + - aws-marketplace:Subscribe + Resource: "*" Events: GrammarCheck: Type: Api diff --git a/docs/chatting/CHATTING-GUIDE.md b/docs/chatting/CHATTING-GUIDE.md index e37f8102..2dc59e80 100644 --- a/docs/chatting/CHATTING-GUIDE.md +++ b/docs/chatting/CHATTING-GUIDE.md @@ -18,6 +18,7 @@ Chatting Server는 영어 회화 학습 플랫폼의 실시간 채팅 기능을 | AI 응답 | AWS Bedrock 기반 AI 메시지 생성 | | TTS (음성 합성) | AWS Polly 기반 음성 변환 | | 비밀방 | BCrypt 암호화 비밀번호 지원 | +| 캐치마인드 게임 | 실시간 그림 맞추기 게임 | ### 1.3 기술 스택 @@ -618,7 +619,201 @@ flowchart LR } ``` -### 4.7 WebSocket 엔드포인트 +--- + +## 캐치마인드 게임 API + +### 4.7 게임 시작 + +#### POST /chat/rooms/{roomId}/game/start + +게임을 시작합니다. 방장만 게임을 시작할 수 있습니다. + +**Request Header** +- `Authorization`: Bearer {token} + +**Response (200 OK)** + +```json +{ + "success": true, + "message": "Game started successfully", + "data": { + "roomId": "550e8400-e29b-41d4-a716-446655440000", + "gameId": "game-uuid-here", + "status": "IN_PROGRESS", + "currentWord": "apple", + "drawerId": "user123", + "startedAt": "2026-01-09T10:00:00Z" + } +} +``` + +### 4.8 게임 중지 + +#### POST /chat/rooms/{roomId}/game/stop + +진행 중인 게임을 중지합니다. 방장만 중지할 수 있습니다. + +**Request Header** +- `Authorization`: Bearer {token} + +**Response (200 OK)** + +```json +{ + "success": true, + "message": "Game stopped successfully", + "data": { + "roomId": "550e8400-e29b-41d4-a716-446655440000", + "gameId": "game-uuid-here", + "status": "STOPPED", + "finalScores": { + "user123": 30, + "user456": 20, + "user789": 10 + } + } +} +``` + +### 4.9 게임 상태 조회 + +#### GET /chat/rooms/{roomId}/game/status + +현재 게임 상태를 조회합니다. + +**Request Header** +- `Authorization`: Bearer {token} + +**Response (200 OK)** + +```json +{ + "success": true, + "message": "Game status retrieved", + "data": { + "roomId": "550e8400-e29b-41d4-a716-446655440000", + "gameId": "game-uuid-here", + "status": "IN_PROGRESS", + "currentRound": 3, + "totalRounds": 5, + "drawerId": "user123", + "timeRemaining": 45, + "scores": { + "user123": 30, + "user456": 20, + "user789": 10 + } + } +} +``` + +**게임 상태 (status)** +- `WAITING`: 게임 대기 중 +- `IN_PROGRESS`: 게임 진행 중 +- `ROUND_END`: 라운드 종료 +- `GAME_END`: 게임 종료 +- `STOPPED`: 강제 중지 + +### 4.10 점수 조회 + +#### GET /chat/rooms/{roomId}/game/scores + +현재 게임의 점수를 조회합니다. + +**Request Header** +- `Authorization`: Bearer {token} + +**Response (200 OK)** + +```json +{ + "success": true, + "message": "Scores retrieved", + "data": { + "roomId": "550e8400-e29b-41d4-a716-446655440000", + "gameId": "game-uuid-here", + "scores": [ + { + "userId": "user123", + "nickname": "Player1", + "score": 30, + "rank": 1 + }, + { + "userId": "user456", + "nickname": "Player2", + "score": 20, + "rank": 2 + }, + { + "userId": "user789", + "nickname": "Player3", + "score": 10, + "rank": 3 + } + ] + } +} +``` + +### 캐치마인드 게임 규칙 + +| 항목 | 설명 | +|-----|-----| +| 최소 인원 | 2명 | +| 라운드당 시간 | 60초 | +| 정답 점수 | 10점 (맞춘 사람) | +| 출제자 점수 | 5점 (누군가 맞추면) | +| 단어 카테고리 | 동물, 음식, 사물, 직업 등 | + +### WebSocket 게임 이벤트 + +게임 진행 중 WebSocket을 통해 실시간 이벤트가 전송됩니다. + +**게임 시작 이벤트** +```json +{ + "type": "GAME_START", + "gameId": "game-uuid", + "drawerId": "user123", + "round": 1 +} +``` + +**정답 이벤트** +```json +{ + "type": "CORRECT_ANSWER", + "userId": "user456", + "word": "apple", + "score": 10 +} +``` + +**라운드 종료 이벤트** +```json +{ + "type": "ROUND_END", + "round": 1, + "answer": "apple", + "scores": {...} +} +``` + +**게임 종료 이벤트** +```json +{ + "type": "GAME_END", + "winner": "user123", + "finalScores": {...} +} +``` + +--- + +### 4.11 WebSocket 엔드포인트 #### $connect From b12e21be6f83ada4a43bfb5a9eb6f82ee8a15495 Mon Sep 17 00:00:00 2001 From: hyein Heo <128613248+hye-inA@users.noreply.github.com> Date: Tue, 13 Jan 2026 19:07:44 +0900 Subject: [PATCH 169/528] =?UTF-8?q?refactor=20:=20github=20Jira=20PR=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99=20=EC=9E=90=EB=8F=99=ED=99=94=20workflow=20?= =?UTF-8?q?=EB=A6=AC=ED=8E=99=ED=86=A0=EB=A7=81=20(#286)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/github-jira-issue-sync.yml | 201 ++++++------------- .github/workflows/github-jira-pr-sync.yml | 164 +++++---------- 2 files changed, 113 insertions(+), 252 deletions(-) diff --git a/.github/workflows/github-jira-issue-sync.yml b/.github/workflows/github-jira-issue-sync.yml index e095e8ee..1238f1f9 100644 --- a/.github/workflows/github-jira-issue-sync.yml +++ b/.github/workflows/github-jira-issue-sync.yml @@ -3,163 +3,94 @@ name: Issue-Jira Sync on: issues: - types: [ opened, closed, reopened ] - issue_comment: - types: [ created ] + types: [opened] -env: - JIRA_PROJECT_KEY: MESP +permissions: + issues: write + contents: read jobs: - # GitHub Issue 생성 → Jira 티켓 생성 - create-jira-from-issue: + sync-issue-to-jira: runs-on: ubuntu-latest - if: github.event_name == 'issues' && github.event.action == 'opened' steps: - - name: Determine Issue Type from Title + - name: Checkout + uses: actions/checkout@v4 + + - name: Login to Jira + uses: atlassian/gajira-login@v3 + env: + JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }} + JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} + JIRA_USER_EMAIL: ${{ secrets.JIRA_USER_EMAIL }} + + - name: Determine Issue Type id: issue-type run: | TITLE='${{ github.event.issue.title }}' - # Issue 제목 기반 Jira 타입 매핑 - # [EPIC], [STORY], [TASK] 3가지 if echo "$TITLE" | grep -qiE "^\[EPIC\]"; then echo "type=Epic" >> $GITHUB_OUTPUT + echo "template=epic.yml" >> $GITHUB_OUTPUT elif echo "$TITLE" | grep -qiE "^\[STORY\]"; then echo "type=Story" >> $GITHUB_OUTPUT + echo "template=story.yml" >> $GITHUB_OUTPUT + elif echo "$TITLE" | grep -qiE "^\[TASK\]"; then + echo "type=Task" >> $GITHUB_OUTPUT + echo "template=task.yml" >> $GITHUB_OUTPUT + elif echo "$TITLE" | grep -qiE "^\[SPIKE\]"; then + echo "type=Task" >> $GITHUB_OUTPUT + echo "template=spike.yml" >> $GITHUB_OUTPUT + elif echo "$TITLE" | grep -qiE "^\[CR\]"; then + echo "type=Task" >> $GITHUB_OUTPUT + echo "template=change_request.yml" >> $GITHUB_OUTPUT else echo "type=Task" >> $GITHUB_OUTPUT + echo "template=task.yml" >> $GITHUB_OUTPUT fi - - name: Create Jira Issue - id: create-jira - run: | - RESPONSE=$(curl -s -X POST \ - -H "Authorization: Basic $(echo -n '${{ secrets.JIRA_USER_EMAIL }}:${{ secrets.JIRA_API_TOKEN }}' | base64)" \ - -H "Content-Type: application/json" \ - "${{ secrets.JIRA_BASE_URL }}/rest/api/3/issue" \ - -d '{ - "fields": { - "project": {"key": "${{ env.JIRA_PROJECT_KEY }}"}, - "summary": "[GH-#${{ github.event.issue.number }}] ${{ github.event.issue.title }}", - "description": { - "type": "doc", - "version": 1, - "content": [ - {"type": "paragraph", "content": [{"type": "text", "text": "GitHub Issue: ${{ github.event.issue.html_url }}"}]}, - {"type": "paragraph", "content": [{"type": "text", "text": "Author: ${{ github.event.issue.user.login }}"}]} - ] - }, - "issuetype": {"name": "${{ steps.issue-type.outputs.type }}"} - } - }') - - JIRA_KEY=$(echo "$RESPONSE" | jq -r '.key // empty') - echo "jira_key=$JIRA_KEY" >> $GITHUB_OUTPUT + - name: Parse Issue + uses: stefanbuck/github-issue-parser@v3 + id: issue-parser + with: + template-path: .github/ISSUE_TEMPLATE/${{ steps.issue-type.outputs.template }} - - name: Update GitHub Issue with Jira Link - if: steps.create-jira.outputs.jira_key != '' - uses: actions/github-script@v7 + - name: Convert to Jira Syntax + uses: peter-evans/jira2md@v1 + id: md2jira with: - script: | - const jiraKey = '${{ steps.create-jira.outputs.jira_key }}'; - const jiraUrl = '${{ secrets.JIRA_BASE_URL }}/browse/' + jiraKey; - const currentBody = context.payload.issue.body || ''; - const newBody = `\n---\n**Jira:** [${jiraKey}](${jiraUrl})\n---\n` + currentBody; + input-text: | + h3. GitHub Issue + ${{ github.event.issue.html_url }} - await github.rest.issues.update({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body: newBody - }); + h3. Author + ${{ github.event.issue.user.login }} - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body: `Jira 티켓 생성: [${jiraKey}](${jiraUrl})` - }); - - # GitHub Issue 닫힘 → Jira 티켓 Done - close-jira-on-issue-close: - runs-on: ubuntu-latest - if: github.event_name == 'issues' && github.event.action == 'closed' - - steps: - - name: Extract Jira Key and Transition to Done - run: | - BODY='${{ github.event.issue.body }}' - JIRA_KEY=$(echo "$BODY" | grep -oE '${{ env.JIRA_PROJECT_KEY }}-[0-9]+' | head -1) - - [ -z "$JIRA_KEY" ] && exit 0 - - TRANSITIONS=$(curl -s -X GET \ - -H "Authorization: Basic $(echo -n '${{ secrets.JIRA_USER_EMAIL }}:${{ secrets.JIRA_API_TOKEN }}' | base64)" \ - "${{ secrets.JIRA_BASE_URL }}/rest/api/3/issue/${JIRA_KEY}/transitions") - - DONE_ID=$(echo "$TRANSITIONS" | jq -r '.transitions[] | select(.name | test("Done|완료|Closed|종료"; "i")) | .id' | head -1) - - [ -z "$DONE_ID" ] && exit 0 - - curl -s -X POST \ - -H "Authorization: Basic $(echo -n '${{ secrets.JIRA_USER_EMAIL }}:${{ secrets.JIRA_API_TOKEN }}' | base64)" \ - -H "Content-Type: application/json" \ - "${{ secrets.JIRA_BASE_URL }}/rest/api/3/issue/${JIRA_KEY}/transitions" \ - -d "{\"transition\": {\"id\": \"$DONE_ID\"}}" + ---- + ${{ github.event.issue.body }} + mode: md2jira - # GitHub Issue 재오픈 → Jira 상태 복구 - reopen-jira-on-issue-reopen: - runs-on: ubuntu-latest - if: github.event_name == 'issues' && github.event.action == 'reopened' - - steps: - - name: Extract Jira Key and Transition to In Progress - run: | - BODY='${{ github.event.issue.body }}' - JIRA_KEY=$(echo "$BODY" | grep -oE '${{ env.JIRA_PROJECT_KEY }}-[0-9]+' | head -1) - - [ -z "$JIRA_KEY" ] && exit 0 - - TRANSITIONS=$(curl -s -X GET \ - -H "Authorization: Basic $(echo -n '${{ secrets.JIRA_USER_EMAIL }}:${{ secrets.JIRA_API_TOKEN }}' | base64)" \ - "${{ secrets.JIRA_BASE_URL }}/rest/api/3/issue/${JIRA_KEY}/transitions") - - PROGRESS_ID=$(echo "$TRANSITIONS" | jq -r '.transitions[] | select(.name | test("In Progress|진행|Open|To Do"; "i")) | .id' | head -1) - - [ -z "$PROGRESS_ID" ] && exit 0 - - curl -s -X POST \ - -H "Authorization: Basic $(echo -n '${{ secrets.JIRA_USER_EMAIL }}:${{ secrets.JIRA_API_TOKEN }}' | base64)" \ - -H "Content-Type: application/json" \ - "${{ secrets.JIRA_BASE_URL }}/rest/api/3/issue/${JIRA_KEY}/transitions" \ - -d "{\"transition\": {\"id\": \"$PROGRESS_ID\"}}" + - name: Create Jira Issue + id: create-jira + uses: atlassian/gajira-create@v3 + with: + project: MESP + issuetype: ${{ steps.issue-type.outputs.type }} + summary: '${{ github.event.issue.title }}' + description: '${{ steps.md2jira.outputs.output-text }}' - # GitHub 코멘트 → Jira 코멘트 동기화 - sync-comment-to-jira: - runs-on: ubuntu-latest - if: github.event_name == 'issue_comment' && github.event.action == 'created' + - name: Update Issue Title + uses: actions-cool/issues-helper@v3 + with: + actions: 'update-issue' + token: ${{ secrets.GITHUB_TOKEN }} + title: '[${{ steps.create-jira.outputs.issue }}] ${{ github.event.issue.title }}' - steps: - - name: Sync Comment to Jira - run: | - BODY='${{ github.event.issue.body }}' - JIRA_KEY=$(echo "$BODY" | grep -oE '${{ env.JIRA_PROJECT_KEY }}-[0-9]+' | head -1) - - [ -z "$JIRA_KEY" ] && exit 0 - - COMMENT_AUTHOR="${{ github.event.comment.user.login }}" - COMMENT_BODY=$(echo '${{ github.event.comment.body }}' | head -c 500 | sed 's/"/\\"/g' | tr '\n' ' ') - - curl -s -X POST \ - -H "Authorization: Basic $(echo -n '${{ secrets.JIRA_USER_EMAIL }}:${{ secrets.JIRA_API_TOKEN }}' | base64)" \ - -H "Content-Type: application/json" \ - "${{ secrets.JIRA_BASE_URL }}/rest/api/3/issue/${JIRA_KEY}/comment" \ - -d "{ - \"body\": { - \"type\": \"doc\", - \"version\": 1, - \"content\": [{\"type\": \"paragraph\", \"content\": [{\"type\": \"text\", \"text\": \"@${COMMENT_AUTHOR}: ${COMMENT_BODY}\"}]}] - } - }" + - name: Add Jira Link Comment + uses: actions-cool/issues-helper@v3 + with: + actions: 'create-comment' + token: ${{ secrets.GITHUB_TOKEN }} + issue-number: ${{ github.event.issue.number }} + body: | + Jira: [${{ steps.create-jira.outputs.issue }}](${{ secrets.JIRA_BASE_URL }}/browse/${{ steps.create-jira.outputs.issue }}) \ No newline at end of file diff --git a/.github/workflows/github-jira-pr-sync.yml b/.github/workflows/github-jira-pr-sync.yml index c1a7278a..1e9596d8 100644 --- a/.github/workflows/github-jira-pr-sync.yml +++ b/.github/workflows/github-jira-pr-sync.yml @@ -1,151 +1,81 @@ # GitHub PR → Jira 동기화 -name: Github-Jira PR Sync +name: PR-Jira Sync on: - pull_request: - types: [ opened, closed, reopened ] + pull_request_target: + types: [opened] + branches: + - develop + - main -env: - JIRA_PROJECT_KEY: MESP +permissions: + issues: write + contents: read jobs: - # GitHub PR 생성 → Jira 티켓 생성 - create-jira-from-pr: + sync-pr-to-jira: runs-on: ubuntu-latest - if: github.event.action == 'opened' steps: - - name: Determine Issue Type from PR Title + - name: Login to Jira + uses: atlassian/gajira-login@v3 + env: + JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }} + JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} + JIRA_USER_EMAIL: ${{ secrets.JIRA_USER_EMAIL }} + + - name: Determine Issue Type id: issue-type run: | TITLE='${{ github.event.pull_request.title }}' - # PR 제목 기반 Jira 타입 매핑 if echo "$TITLE" | grep -qiE "^\[EPIC\]"; then echo "type=Epic" >> $GITHUB_OUTPUT elif echo "$TITLE" | grep -qiE "^\[STORY\]"; then echo "type=Story" >> $GITHUB_OUTPUT - elif echo "$TITLE" | grep -qiE "^(fix:|hotfix:)"; then + elif echo "$TITLE" | grep -qiE "^fix:|^hotfix:"; then echo "type=Bug" >> $GITHUB_OUTPUT else - # feat:, improve:, refactor:, release:, [TASK] 등 → Task echo "type=Task" >> $GITHUB_OUTPUT fi + - name: Convert to Jira Syntax + uses: peter-evans/jira2md@v1 + id: md2jira + with: + input-text: | + h3. GitHub PR + ${{ github.event.pull_request.html_url }} + + h3. Author + ${{ github.event.pull_request.user.login }} + + h3. Branch + ${{ github.head_ref }} -> ${{ github.base_ref }} + + ---- + ${{ github.event.pull_request.body }} + mode: md2jira + - name: Create Jira Issue id: create-jira - run: | - RESPONSE=$(curl -s -X POST \ - -H "Authorization: Basic $(echo -n '${{ secrets.JIRA_USER_EMAIL }}:${{ secrets.JIRA_API_TOKEN }}' | base64)" \ - -H "Content-Type: application/json" \ - "${{ secrets.JIRA_BASE_URL }}/rest/api/3/issue" \ - -d '{ - "fields": { - "project": {"key": "${{ env.JIRA_PROJECT_KEY }}"}, - "summary": "[PR-#${{ github.event.pull_request.number }}] ${{ github.event.pull_request.title }}", - "description": { - "type": "doc", - "version": 1, - "content": [ - {"type": "paragraph", "content": [{"type": "text", "text": "GitHub PR: ${{ github.event.pull_request.html_url }}"}]}, - {"type": "paragraph", "content": [{"type": "text", "text": "Author: ${{ github.event.pull_request.user.login }}"}]}, - {"type": "paragraph", "content": [{"type": "text", "text": "Branch: ${{ github.head_ref }} → ${{ github.base_ref }}"}]} - ] - }, - "issuetype": {"name": "${{ steps.issue-type.outputs.type }}"} - } - }') - - JIRA_KEY=$(echo "$RESPONSE" | jq -r '.key // empty') - echo "jira_key=$JIRA_KEY" >> $GITHUB_OUTPUT + uses: atlassian/gajira-create@v3 + with: + project: MESP + issuetype: ${{ steps.issue-type.outputs.type }} + summary: '[PR-#${{ github.event.pull_request.number }}] ${{ github.event.pull_request.title }}' + description: '${{ steps.md2jira.outputs.output-text }}' - - name: Update PR with Jira Link - if: steps.create-jira.outputs.jira_key != '' + - name: Add Jira Link Comment uses: actions/github-script@v7 with: script: | - const jiraKey = '${{ steps.create-jira.outputs.jira_key }}'; + const jiraKey = '${{ steps.create-jira.outputs.issue }}'; const jiraUrl = '${{ secrets.JIRA_BASE_URL }}/browse/' + jiraKey; - const currentBody = context.payload.pull_request.body || ''; - const newBody = `\n---\n**Jira:** [${jiraKey}](${jiraUrl})\n---\n` + currentBody; - - await github.rest.pulls.update({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: context.payload.pull_request.number, - body: newBody - }); await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.payload.pull_request.number, - body: `Jira 티켓 생성: [${jiraKey}](${jiraUrl})` - }); - - # PR 머지 → Jira 티켓 Done - close-jira-on-pr-merge: - runs-on: ubuntu-latest - if: github.event.action == 'closed' && github.event.pull_request.merged == true - - steps: - - name: Extract Jira Key and Transition to Done - run: | - BODY='${{ github.event.pull_request.body }}' - JIRA_KEY=$(echo "$BODY" | grep -oE '${{ env.JIRA_PROJECT_KEY }}-[0-9]+' | head -1) - - [ -z "$JIRA_KEY" ] && exit 0 - - TRANSITIONS=$(curl -s -X GET \ - -H "Authorization: Basic $(echo -n '${{ secrets.JIRA_USER_EMAIL }}:${{ secrets.JIRA_API_TOKEN }}' | base64)" \ - "${{ secrets.JIRA_BASE_URL }}/rest/api/3/issue/${JIRA_KEY}/transitions") - - DONE_ID=$(echo "$TRANSITIONS" | jq -r '.transitions[] | select(.name | test("Done|완료|Closed|종료"; "i")) | .id' | head -1) - - [ -z "$DONE_ID" ] && exit 0 - - curl -s -X POST \ - -H "Authorization: Basic $(echo -n '${{ secrets.JIRA_USER_EMAIL }}:${{ secrets.JIRA_API_TOKEN }}' | base64)" \ - -H "Content-Type: application/json" \ - "${{ secrets.JIRA_BASE_URL }}/rest/api/3/issue/${JIRA_KEY}/transitions" \ - -d "{\"transition\": {\"id\": \"$DONE_ID\"}}" - - # Add merge comment to Jira - curl -s -X POST \ - -H "Authorization: Basic $(echo -n '${{ secrets.JIRA_USER_EMAIL }}:${{ secrets.JIRA_API_TOKEN }}' | base64)" \ - -H "Content-Type: application/json" \ - "${{ secrets.JIRA_BASE_URL }}/rest/api/3/issue/${JIRA_KEY}/comment" \ - -d '{ - "body": { - "type": "doc", - "version": 1, - "content": [{"type": "paragraph", "content": [{"type": "text", "text": "PR #${{ github.event.pull_request.number }} merged to ${{ github.base_ref }}"}]}] - } - }' - - # PR 재오픈 → Jira 상태 복구 - reopen-jira-on-pr-reopen: - runs-on: ubuntu-latest - if: github.event.action == 'reopened' - - steps: - - name: Extract Jira Key and Transition to In Progress - run: | - BODY='${{ github.event.pull_request.body }}' - JIRA_KEY=$(echo "$BODY" | grep -oE '${{ env.JIRA_PROJECT_KEY }}-[0-9]+' | head -1) - - [ -z "$JIRA_KEY" ] && exit 0 - - TRANSITIONS=$(curl -s -X GET \ - -H "Authorization: Basic $(echo -n '${{ secrets.JIRA_USER_EMAIL }}:${{ secrets.JIRA_API_TOKEN }}' | base64)" \ - "${{ secrets.JIRA_BASE_URL }}/rest/api/3/issue/${JIRA_KEY}/transitions") - - PROGRESS_ID=$(echo "$TRANSITIONS" | jq -r '.transitions[] | select(.name | test("In Progress|진행|Open|To Do"; "i")) | .id' | head -1) - - [ -z "$PROGRESS_ID" ] && exit 0 - - curl -s -X POST \ - -H "Authorization: Basic $(echo -n '${{ secrets.JIRA_USER_EMAIL }}:${{ secrets.JIRA_API_TOKEN }}' | base64)" \ - -H "Content-Type: application/json" \ - "${{ secrets.JIRA_BASE_URL }}/rest/api/3/issue/${JIRA_KEY}/transitions" \ - -d "{\"transition\": {\"id\": \"$PROGRESS_ID\"}}" + body: `Jira: [${jiraKey}](${jiraUrl})` + }); \ No newline at end of file From 08b2f72a66814daf32deb69e9f335cf61099915f Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Wed, 14 Jan 2026 09:47:55 +0900 Subject: [PATCH 170/528] =?UTF-8?q?fix:=20Grammar=20Conversation=20API=20p?= =?UTF-8?q?osition=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=88=84=EB=9D=BD=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20(#287)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Conversation 시스템 프롬프트에 startIndex, endIndex 필드 추가 - parseGrammarCheckFromJson()에서 position 필드 파싱 추가 - 프론트엔드 개발 가이드 인터페이스 및 응답 예시 수정 --- .../factory/BedrockGrammarCheckFactory.java | 6 +- docs/FRONTEND-DEVELOPMENT-GUIDE.md | 3904 +++++++++++++++++ docs/IMPROVEMENT-GUIDE.md | 911 ---- docs/ReadMe.md | 0 docs/chatting/CHATTING-GUIDE.md | 1140 ----- docs/grammar-api-specification.md | 401 -- docs/user/USER-GUIDE.md | 565 --- docs/vocabulary/VOCABULARY-GUIDE.md | 1248 ------ 8 files changed, 3909 insertions(+), 4266 deletions(-) create mode 100644 docs/FRONTEND-DEVELOPMENT-GUIDE.md delete mode 100644 docs/IMPROVEMENT-GUIDE.md delete mode 100644 docs/ReadMe.md delete mode 100644 docs/chatting/CHATTING-GUIDE.md delete mode 100644 docs/grammar-api-specification.md delete mode 100644 docs/user/USER-GUIDE.md delete mode 100644 docs/vocabulary/VOCABULARY-GUIDE.md diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/factory/BedrockGrammarCheckFactory.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/factory/BedrockGrammarCheckFactory.java index b33a15fc..dead7ad3 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/factory/BedrockGrammarCheckFactory.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/factory/BedrockGrammarCheckFactory.java @@ -265,7 +265,9 @@ private String buildConversationSystemPrompt(GrammarLevel level) { "type": "VERB_TENSE", "original": "goed", "corrected": "went", - "explanation": "explanation here" + "explanation": "explanation here", + "startIndex": 2, + "endIndex": 6 } ], "feedback": "brief grammar feedback" @@ -358,6 +360,8 @@ private GrammarCheckResponse parseGrammarCheckFromJson(String originalSentence, .original(errorObj.get("original").getAsString()) .corrected(errorObj.get("corrected").getAsString()) .explanation(errorObj.get("explanation").getAsString()) + .startIndex(getIntOrNull(errorObj, "startIndex")) + .endIndex(getIntOrNull(errorObj, "endIndex")) .build(); errors.add(error); } diff --git a/docs/FRONTEND-DEVELOPMENT-GUIDE.md b/docs/FRONTEND-DEVELOPMENT-GUIDE.md new file mode 100644 index 00000000..297fd387 --- /dev/null +++ b/docs/FRONTEND-DEVELOPMENT-GUIDE.md @@ -0,0 +1,3904 @@ +# 프론트엔드 개발 가이드 + +영어 학습 플랫폼 백엔드 API 통합을 위한 프론트엔드 개발 가이드입니다. + +**목차** +1. [개요](#개요) +2. [인증](#인증) +3. [API 공통 규칙](#api-공통-규칙) +4. [TypeScript 인터페이스](#typescript-인터페이스) +5. [API 엔드포인트](#api-엔드포인트) +6. [에러 코드 및 처리](#에러-코드-및-처리) +7. [WebSocket 연동 가이드](#websocket-연동-가이드) + +--- + +## 개요 + +### API 기본 정보 + +영어 학습 플랫폼은 AWS Serverless 아키텍처를 기반으로 한 백엔드 API를 제공합니다. + +**주요 기능:** +- 단어 학습 및 관리 (Spaced Repetition 알고리즘) +- 단어 테스트 및 통계 +- 실시간 채팅 및 게임 +- AI 기반 문법 검사 및 대화 +- 뱃지 시스템 + +**API Base URL:** +``` +Production: https://{api-id}.execute-api.{region}.amazonaws.com/prod +Development: https://{api-id}.execute-api.{region}.amazonaws.com/dev +``` + +### 기술 스택 + +- **Backend**: Java 21, Spring Cloud Functions (AWS Lambda) +- **Database**: DynamoDB +- **Authentication**: AWS Cognito (JWT) +- **Real-time**: API Gateway WebSocket +- **AI**: AWS Bedrock (Claude) +- **Voice**: AWS Polly + +--- + +## 인증 + +### Cognito JWT 인증 흐름 + +#### 1. 회원가입 및 로그인 + +AWS Cognito User Pool을 사용합니다. + +```typescript +// 예제: Cognito 초기화 (AWS Amplify 또는 aws-amplify) +import { Auth } from 'aws-amplify'; + +// Amplify 설정 +Auth.configure({ + region: 'ap-northeast-2', + userPoolId: '{USER_POOL_ID}', + userPoolWebClientId: '{CLIENT_ID}' +}); + +// 회원가입 +async function signUp(email: string, password: string, name: string) { + try { + const response = await Auth.signUp({ + username: email, + password: password, + attributes: { + email: email, + name: name + } + }); + console.log('회원가입 성공:', response); + } catch (error) { + console.error('회원가입 실패:', error); + } +} + +// 로그인 +async function signIn(email: string, password: string) { + try { + const user = await Auth.signIn(email, password); + console.log('로그인 성공:', user); + return user; + } catch (error) { + console.error('로그인 실패:', error); + } +} +``` + +#### 2. 토큰 획득 및 관리 + +로그인 후 JWT 토큰을 획득하여 API 요청에 사용합니다. + +```typescript +// JWT 토큰 획득 +async function getAccessToken(): Promise { + try { + const session = await Auth.currentSession(); + const accessToken = session.getAccessToken().getJwtToken(); + return accessToken; + } catch (error) { + console.error('토큰 획득 실패:', error); + throw error; + } +} + +// 토큰 갱신 +async function refreshToken() { + try { + const session = await Auth.currentSession(); + // 토큰이 만료되었을 경우 자동으로 갱신됨 + const idToken = session.getIdToken().getJwtToken(); + return idToken; + } catch (error) { + console.error('토큰 갱신 실패:', error); + } +} +``` + +#### 3. API 요청에 토큰 포함 + +모든 인증이 필요한 API 요청에는 Authorization 헤더에 JWT 토큰을 포함합니다. + +```typescript +// Fetch API를 사용한 인증 요청 +async function authenticatedFetch( + url: string, + options: RequestInit = {} +): Promise { + const token = await getAccessToken(); + + return fetch(url, { + ...options, + headers: { + ...options.headers, + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); +} + +// 사용 예제 +async function getUserWords() { + try { + const response = await authenticatedFetch( + 'https://api-id.execute-api.region.amazonaws.com/dev/vocab/user-words' + ); + const data = await response.json(); + console.log('사용자 단어:', data); + } catch (error) { + console.error('요청 실패:', error); + } +} +``` + +#### 4. Axios를 사용한 예제 + +```typescript +import axios from 'axios'; + +// Axios 인스턴스 생성 +const apiClient = axios.create({ + baseURL: 'https://api-id.execute-api.region.amazonaws.com/dev' +}); + +// 인터셉터로 토큰 자동 추가 +apiClient.interceptors.request.use( + async (config) => { + const token = await getAccessToken(); + config.headers.Authorization = `Bearer ${token}`; + return config; + }, + (error) => { + return Promise.reject(error); + } +); + +// 응답 인터셉터: 토큰 만료 시 갱신 +apiClient.interceptors.response.use( + (response) => response, + async (error) => { + if (error.response?.status === 401) { + try { + await refreshToken(); + // 원래 요청 재시도 + return apiClient(error.config); + } catch (refreshError) { + // 로그인 페이지로 리다이렉트 + window.location.href = '/login'; + return Promise.reject(refreshError); + } + } + return Promise.reject(error); + } +); + +// 사용 예제 +async function fetchUserWords() { + try { + const response = await apiClient.get('/vocab/user-words'); + console.log('사용자 단어:', response.data); + } catch (error) { + console.error('요청 실패:', error); + } +} +``` + +--- + +## API 공통 규칙 + +### 표준 응답 형식 + +모든 API 응답은 다음과 같은 표준 형식을 따릅니다. + +```typescript +interface ApiResponse { + isSuccess: boolean; // 성공 여부 + message: string | null; // 응답 메시지 (선택사항) + data: T | null; // 응답 데이터 + error: string | null; // 에러 메시지 (실패 시) +} +``` + +**성공 응답 예제:** + +```json +{ + "isSuccess": true, + "message": "단어 조회 성공", + "data": { + "wordId": "word_001", + "english": "hello", + "korean": "안녕하세요", + "level": "BEGINNER" + }, + "error": null +} +``` + +**실패 응답 예제:** + +```json +{ + "isSuccess": false, + "message": null, + "data": null, + "error": "필수 필드가 누락되었습니다" +} +``` + +### 페이지네이션 + +목록 조회 API는 커서 기반의 페이지네이션을 지원합니다. + +```typescript +interface PaginatedResponse { + items: T[]; + nextCursor?: string; + hasMore: boolean; +} +``` + +**페이지네이션 요청 예제:** + +```typescript +// 첫 번째 페이지 +const firstPage = await apiClient.get('/vocab/words', { + params: { + limit: 20, + level: 'BEGINNER', + category: 'DAILY' + } +}); + +// 다음 페이지 (cursor 사용) +if (firstPage.data.data.hasMore) { + const nextPage = await apiClient.get('/vocab/words', { + params: { + limit: 20, + cursor: firstPage.data.data.nextCursor, + level: 'BEGINNER', + category: 'DAILY' + } + }); +} +``` + +### 공통 쿼리 파라미터 + +| 파라미터 | 타입 | 필수 | 설명 | +|---------|------|------|------| +| limit | number | N | 반환할 아이템 개수 (기본값: 20, 최대: 100) | +| cursor | string | N | 커서 기반 페이지네이션 커서 | +| sortBy | string | N | 정렬 기준 (기본값: 'createdAt') | +| sortOrder | string | N | 정렬 순서 ('ASC' 또는 'DESC', 기본값: 'DESC') | + +--- + +## TypeScript 인터페이스 + +### 공통 인터페이스 + +```typescript +// API 응답 래퍼 +interface ApiResponse { + isSuccess: boolean; + message: string | null; + data: T | null; + error: string | null; +} + +// 페이지네이션 응답 +interface PaginatedResponse { + items: T[]; + nextCursor?: string; + hasMore: boolean; +} + +// 사용자 정보 +interface User { + userId: string; + email: string; + name: string; + level: StudyLevel; + createdAt: string; + updatedAt: string; +} +``` + +### 단어 관련 인터페이스 + +```typescript +// 단어 레벨 enum +type WordLevel = 'BEGINNER' | 'INTERMEDIATE' | 'ADVANCED'; + +// 단어 카테고리 enum +type WordCategory = 'DAILY' | 'IDIOM' | 'PHRASAL_VERB' | 'BUSINESS' | 'ACADEMIC'; + +// 단어 상태 enum +type WordStatus = 'NEW' | 'LEARNING' | 'REVIEWING' | 'MASTERED'; + +// 사용자 지정 난이도 enum +type Difficulty = 'EASY' | 'NORMAL' | 'HARD'; + +// 단어 정보 +interface Word { + wordId: string; + english: string; + korean: string; + example?: string; + level: WordLevel; + category: WordCategory; + createdAt: string; +} + +// 단어 생성 요청 +interface CreateWordRequest { + english: string; + korean: string; + example?: string; + level?: WordLevel; + category?: WordCategory; +} + +// 단어 수정 요청 +interface UpdateWordRequest { + english?: string; + korean?: string; + example?: string; + level?: WordLevel; + category?: WordCategory; +} + +// 단어 일괄 생성 요청 +interface CreateWordsBatchRequest { + words: CreateWordRequest[]; +} + +// 단어 일괄 조회 요청 +interface BatchGetWordsRequest { + wordIds: string[]; +} + +// 사용자 단어 (학습 상태 포함) +interface UserWord { + wordId: string; + userId: string; + status: WordStatus; + interval: number; // 복습 간격 (일) + easeFactor: number; // 난이도 계수 + repetitions: number; // 연속 정답 횟수 + nextReviewAt: string; // 다음 복습 예정일 + lastReviewedAt?: string; // 마지막 복습일 + correctCount: number; // 정답 횟수 + incorrectCount: number; // 오답 횟수 + bookmarked: boolean; // 북마크 여부 + favorite: boolean; // 즐겨찾기 여부 + difficulty?: Difficulty; // 사용자 지정 난이도 + createdAt: string; + updatedAt: string; +} + +// 사용자 단어 업데이트 요청 +interface UpdateUserWordRequest { + isCorrect: boolean; // 정답 여부 +} + +// 사용자 단어 태그 업데이트 요청 +interface UpdateUserWordTagRequest { + bookmarked?: boolean; + favorite?: boolean; + difficulty?: Difficulty; +} + +// 사용자 단어 상태 업데이트 요청 +interface UpdateUserWordStatusRequest { + status: WordStatus; +} + +// 단어 그룹 +interface WordGroup { + groupId: string; + userId: string; + name: string; + description?: string; + wordIds: string[]; + createdAt: string; + updatedAt: string; +} + +// 단어 그룹 생성 요청 +interface CreateWordGroupRequest { + name: string; + description?: string; + wordIds?: string[]; +} + +// 단어 그룹 수정 요청 +interface UpdateWordGroupRequest { + name?: string; + description?: string; +} +``` + +### 테스트 관련 인터페이스 + +```typescript +// 테스트 타입 enum +type TestType = 'DAILY' | 'CUSTOM' | 'QUICK'; + +// 테스트 시작 요청 +interface StartTestRequest { + testType?: TestType; +} + +// 테스트 응답 +interface StartTestResponse { + testId: string; + words: Word[]; + startedAt: string; +} + +// 테스트 답안 +interface TestAnswer { + wordId: string; + answer?: string; // 빈 값 허용 (오답 처리) +} + +// 테스트 제출 요청 +interface SubmitTestRequest { + testId: string; + testType?: TestType; + answers: TestAnswer[]; + startedAt: string; +} + +// 테스트 결과 +interface TestResult { + testId: string; + userId: string; + testType: TestType; + totalQuestions: number; + correctAnswers: number; + correctRate: number; // 0 ~ 100 + spentTime: number; // 밀리초 + completedAt: string; +} + +// 테스트된 단어 +interface TestedWord { + wordId: string; + english: string; + korean: string; + isCorrect: boolean; + userAnswer?: string; +} + +// 통계 +interface Statistics { + totalWords: number; + masteredCount: number; + learningCount: number; + reviewingCount: number; + newCount: number; + correctRate: number; + totalTestsTaken: number; + averageSpentTime: number; +} + +// 일별 통계 +interface DailyStatistics { + date: string; + wordsLearned: number; + testsTaken: number; + correctRate: number; + spentTime: number; +} + +// 취약점 분석 +interface WeaknessAnalysis { + wordId: string; + english: string; + korean: string; + incorrectCount: number; + level: WordLevel; +} +``` + +### 채팅 관련 인터페이스 + +```typescript +// 채팅 레벨 enum +type ChatLevel = 'beginner' | 'intermediate' | 'advanced'; + +// 메시지 타입 enum +type MessageType = 'TEXT' | 'IMAGE' | 'VOICE' | 'GAME_MOVE'; + +// 채팅 방 +interface ChatRoom { + roomId: string; + name: string; + description?: string; + level: ChatLevel; + maxMembers: number; + currentMembers: number; + isPrivate: boolean; + createdBy: string; + createdAt: string; +} + +// 채팅 방 생성 요청 +interface CreateRoomRequest { + name: string; + description?: string; + level?: ChatLevel; + maxMembers?: number; + isPrivate?: boolean; + password?: string; +} + +// 방 참여 요청 +interface JoinRoomRequest { + roomId: string; + password?: string; +} + +// 방 참여 응답 +interface JoinRoomResponse { + roomId: string; + roomToken: string; + name: string; + level: ChatLevel; + members: { + userId: string; + name: string; + }[]; +} + +// 방 나가기 요청 +interface LeaveRoomRequest { + roomId: string; +} + +// 채팅 메시지 +interface ChatMessage { + messageId: string; + roomId: string; + userId: string; + userName: string; + content: string; + messageType: MessageType; + createdAt: string; +} + +// 메시지 전송 요청 +interface SendMessageRequest { + roomId: string; + content: string; + messageType?: MessageType; +} + +// 음성 합성 요청 (채팅) +interface VoiceSynthesisRequest { + text: string; + voiceId?: string; +} + +// 게임 상태 +interface GameStatusResponse { + roomId: string; + isActive: boolean; + currentPlayer?: string; + gameData?: { + wordId: string; + hint?: string; + }; +} + +// 점수판 +interface ScoreboardResponse { + roomId: string; + scores: { + userId: string; + userName: string; + score: number; + }[]; +} +``` + +### 문법 관련 인터페이스 + +```typescript +// 문법 검사 요청 +interface GrammarCheckRequest { + sentence: string; + level?: WordLevel; +} + +// 문법 검사 응답 +interface GrammarCheckResponse { + originalSentence: string; + correctedSentence: string; + score: number; // 0-100 점수 + isCorrect: boolean; + errors: GrammarError[]; + feedback: string; // 전체 피드백 메시지 +} + +// 문법 오류 +interface GrammarError { + type: GrammarErrorType; // 오류 타입 + original: string; // 원본 텍스트 + corrected: string; // 수정된 텍스트 + explanation: string; // 오류 설명 (레벨별 언어/상세도 다름) + startIndex?: number; // 오류 시작 위치 (optional) + endIndex?: number; // 오류 끝 위치 (optional) +} + +// 문법 오류 타입 +type GrammarErrorType = + | 'VERB_TENSE' + | 'SUBJECT_VERB_AGREEMENT' + | 'ARTICLE' + | 'PREPOSITION' + | 'WORD_ORDER' + | 'PLURAL_SINGULAR' + | 'PRONOUN' + | 'SPELLING' + | 'PUNCTUATION' + | 'WORD_CHOICE' + | 'SENTENCE_STRUCTURE' + | 'OTHER'; + +// AI 대화 요청 +interface ConversationRequest { + message: string; + sessionId?: string; + level?: GrammarLevel; // BEGINNER | INTERMEDIATE | ADVANCED +} + +// AI 대화 응답 +interface ConversationResponse { + sessionId: string; + grammarCheck: GrammarCheckResponse; // 문법 검사 결과 + aiResponse: string; // AI 대화 응답 + conversationTip: string; // 학습 팁 (선택적으로 표시) +} + +// 문법 레벨 +type GrammarLevel = 'BEGINNER' | 'INTERMEDIATE' | 'ADVANCED'; + +// 세션 +interface GrammarSession { + sessionId: string; + userId: string; + startedAt: string; + updatedAt: string; + messageCount: number; +} +``` + +### 뱃지 관련 인터페이스 + +```typescript +// 뱃지 +interface Badge { + badgeId: string; + name: string; + description: string; + imageUrl: string; + condition: string; + isEarned: boolean; + earnedAt?: string; +} + +// 사용자 뱃지 +interface UserBadge { + badgeId: string; + userId: string; + earnedAt: string; +} +``` + +### 통계 관련 인터페이스 + +```typescript +// 일간 통계 +interface DailyStats { + date: string; + wordsLearned: number; + testsTaken: number; + correctRate: number; + spentTime: number; +} + +// 주간 통계 +interface WeeklyStats { + startDate: string; + endDate: string; + totalWordsLearned: number; + totalTestsTaken: number; + averageCorrectRate: number; + totalSpentTime: number; + dailyStats: DailyStats[]; +} + +// 월간 통계 +interface MonthlyStats { + month: string; + totalWordsLearned: number; + totalTestsTaken: number; + averageCorrectRate: number; + totalSpentTime: number; +} + +// 전체 통계 +interface TotalStats { + totalWordsLearned: number; + masteredCount: number; + learningCount: number; + totalTestsTaken: number; + averageCorrectRate: number; + totalSpentTime: number; + streak: number; +} +``` + +--- + +## API 엔드포인트 + +### 1. 단어 관리 (Vocabulary) + +#### 1.1 단어 CRUD + +##### POST /vocab/words - 단어 추가 + +단일 단어를 추가합니다. 공개 API입니다. + +**인증:** 불필요 + +**요청:** + +```typescript +const createWordRequest: CreateWordRequest = { + english: "hello", + korean: "안녕하세요", + example: "Hello, my name is John.", + level: "BEGINNER", + category: "DAILY" +}; + +const response = await fetch( + 'https://api-id.execute-api.region.amazonaws.com/dev/vocab/words', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(createWordRequest) + } +); + +const result: ApiResponse = await response.json(); +``` + +**응답:** + +```json +{ + "isSuccess": true, + "message": "단어가 등록되었습니다", + "data": { + "wordId": "word_001", + "english": "hello", + "korean": "안녕하세요", + "example": "Hello, my name is John.", + "level": "BEGINNER", + "category": "DAILY", + "createdAt": "2024-01-14T10:00:00Z" + }, + "error": null +} +``` + +**에러 케이스:** + +```json +{ + "isSuccess": false, + "message": null, + "data": null, + "error": "필수 필드가 누락되었습니다" +} +``` + +--- + +##### POST /vocab/words/batch - 단어 일괄 추가 + +최대 100개의 단어를 한번에 추가합니다. 공개 API입니다. + +**인증:** 불필요 + +**요청:** + +```typescript +const batchRequest: CreateWordsBatchRequest = { + words: [ + { + english: "hello", + korean: "안녕하세요", + level: "BEGINNER", + category: "DAILY" + }, + { + english: "goodbye", + korean: "안녕히 가세요", + level: "BEGINNER", + category: "DAILY" + } + ] +}; + +const response = await fetch( + 'https://api-id.execute-api.region.amazonaws.com/dev/vocab/words/batch', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(batchRequest) + } +); + +const result: ApiResponse = await response.json(); +``` + +**응답:** + +```json +{ + "isSuccess": true, + "message": "2개의 단어가 등록되었습니다", + "data": [ + { + "wordId": "word_001", + "english": "hello", + "korean": "안녕하세요", + "level": "BEGINNER", + "category": "DAILY", + "createdAt": "2024-01-14T10:00:00Z" + }, + { + "wordId": "word_002", + "english": "goodbye", + "korean": "안녕히 가세요", + "level": "BEGINNER", + "category": "DAILY", + "createdAt": "2024-01-14T10:00:01Z" + } + ], + "error": null +} +``` + +--- + +##### POST /vocab/words/batch/get - 단어 일괄 조회 + +최대 100개의 단어를 일괄 조회합니다. 공개 API입니다. + +**인증:** 불필요 + +**요청:** + +```typescript +const batchGetRequest: BatchGetWordsRequest = { + wordIds: ["word_001", "word_002", "word_003"] +}; + +const response = await fetch( + 'https://api-id.execute-api.region.amazonaws.com/dev/vocab/words/batch/get', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(batchGetRequest) + } +); + +const result: ApiResponse = await response.json(); +``` + +**응답:** + +```json +{ + "isSuccess": true, + "message": "3개의 단어를 조회했습니다", + "data": [ + { + "wordId": "word_001", + "english": "hello", + "korean": "안녕하세요", + "level": "BEGINNER", + "category": "DAILY", + "createdAt": "2024-01-14T10:00:00Z" + } + ], + "error": null +} +``` + +--- + +##### GET /vocab/words - 단어 목록 조회 + +페이지네이션과 필터를 지원합니다. 공개 API입니다. + +**인증:** 불필요 + +**쿼리 파라미터:** + +| 파라미터 | 타입 | 필수 | 설명 | +|---------|------|------|------| +| limit | number | N | 반환할 아이템 개수 (기본값: 20) | +| cursor | string | N | 페이지네이션 커서 | +| level | string | N | 단어 레벨 (BEGINNER, INTERMEDIATE, ADVANCED) | +| category | string | N | 단어 카테고리 (DAILY, IDIOM, etc.) | +| sortBy | string | N | 정렬 기준 (createdAt, english) | +| sortOrder | string | N | 정렬 순서 (ASC, DESC) | + +**요청:** + +```typescript +// Fetch API +const response = await fetch( + 'https://api-id.execute-api.region.amazonaws.com/dev/vocab/words?limit=20&level=BEGINNER&category=DAILY' +); + +const result: ApiResponse> = await response.json(); + +// Axios +const result = await apiClient.get('/vocab/words', { + params: { + limit: 20, + level: 'BEGINNER', + category: 'DAILY' + } +}); +``` + +**응답:** + +```json +{ + "isSuccess": true, + "message": "20개의 단어를 조회했습니다", + "data": { + "items": [ + { + "wordId": "word_001", + "english": "hello", + "korean": "안녕하세요", + "example": "Hello, my name is John.", + "level": "BEGINNER", + "category": "DAILY", + "createdAt": "2024-01-14T10:00:00Z" + } + ], + "nextCursor": "cursor_abc123", + "hasMore": true + }, + "error": null +} +``` + +--- + +##### GET /vocab/words/search - 단어 검색 + +단어를 검색합니다. 공개 API입니다. + +**인증:** 불필요 + +**쿼리 파라미터:** + +| 파라미터 | 타입 | 필수 | 설명 | +|---------|------|------|------| +| q | string | Y | 검색어 | +| limit | number | N | 반환할 아이템 개수 (기본값: 20) | + +**요청:** + +```typescript +const response = await fetch( + 'https://api-id.execute-api.region.amazonaws.com/dev/vocab/words/search?q=hello&limit=10' +); + +const result: ApiResponse = await response.json(); +``` + +**응답:** + +```json +{ + "isSuccess": true, + "message": "5개의 단어를 검색했습니다", + "data": [ + { + "wordId": "word_001", + "english": "hello", + "korean": "안녕하세요", + "level": "BEGINNER", + "category": "DAILY", + "createdAt": "2024-01-14T10:00:00Z" + } + ], + "error": null +} +``` + +--- + +##### GET /vocab/words/{wordId} - 단어 상세 조회 + +특정 단어의 상세 정보를 조회합니다. 공개 API입니다. + +**인증:** 불필요 + +**요청:** + +```typescript +const wordId = "word_001"; +const response = await authenticatedFetch( + `https://api-id.execute-api.region.amazonaws.com/dev/vocab/words/${wordId}` +); + +const result: ApiResponse = await response.json(); +``` + +**응답:** + +```json +{ + "isSuccess": true, + "message": "단어 조회 성공", + "data": { + "wordId": "word_001", + "english": "hello", + "korean": "안녕하세요", + "example": "Hello, my name is John.", + "level": "BEGINNER", + "category": "DAILY", + "createdAt": "2024-01-14T10:00:00Z" + }, + "error": null +} +``` + +--- + +##### PUT /vocab/words/{wordId} - 단어 수정 + +단어 정보를 수정합니다. 공개 API입니다. + +**인증:** 불필요 + +**요청:** + +```typescript +const wordId = "word_001"; +const updateRequest: UpdateWordRequest = { + korean: "안녕하세요 (인사)", + example: "Updated example sentence." +}; + +const response = await fetch( + `https://api-id.execute-api.region.amazonaws.com/dev/vocab/words/${wordId}`, + { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updateRequest) + } +); + +const result: ApiResponse = await response.json(); +``` + +**응답:** + +```json +{ + "isSuccess": true, + "message": "단어가 수정되었습니다", + "data": { + "wordId": "word_001", + "english": "hello", + "korean": "안녕하세요 (인사)", + "example": "Updated example sentence.", + "level": "BEGINNER", + "category": "DAILY", + "createdAt": "2024-01-14T10:00:00Z" + }, + "error": null +} +``` + +--- + +##### DELETE /vocab/words/{wordId} - 단어 삭제 + +단어를 삭제합니다. 공개 API입니다. + +**인증:** 불필요 + +**요청:** + +```typescript +const wordId = "word_001"; +const response = await fetch( + `https://api-id.execute-api.region.amazonaws.com/dev/vocab/words/${wordId}`, + { method: 'DELETE' } +); + +const result: ApiResponse = await response.json(); +``` + +**응답:** + +```json +{ + "isSuccess": true, + "message": "단어가 삭제되었습니다", + "data": null, + "error": null +} +``` + +--- + +#### 1.2 사용자 단어 관리 (인증 필요) + +##### GET /vocab/user-words - 사용자 학습 진행도 + +사용자의 단어 학습 진행도를 조회합니다. + +**인증:** 필수 (JWT) + +**쿼리 파라미터:** + +| 파라미터 | 타입 | 필수 | 설명 | +|---------|------|------|------| +| status | string | N | 상태 필터 (NEW, LEARNING, REVIEWING, MASTERED) | +| limit | number | N | 반환할 아이템 개수 (기본값: 20) | +| cursor | string | N | 페이지네이션 커서 | + +**요청:** + +```typescript +const response = await authenticatedFetch( + 'https://api-id.execute-api.region.amazonaws.com/dev/vocab/user-words?limit=20&status=LEARNING' +); + +const result: ApiResponse> = await response.json(); +``` + +**응답:** + +```json +{ + "isSuccess": true, + "message": "사용자 단어 조회 성공", + "data": { + "items": [ + { + "wordId": "word_001", + "userId": "user_123", + "status": "LEARNING", + "interval": 3, + "easeFactor": 2.5, + "repetitions": 2, + "nextReviewAt": "2024-01-17T10:00:00Z", + "correctCount": 5, + "incorrectCount": 2, + "bookmarked": false, + "favorite": true, + "createdAt": "2024-01-10T10:00:00Z", + "updatedAt": "2024-01-14T10:00:00Z" + } + ], + "nextCursor": "cursor_xyz789", + "hasMore": true + }, + "error": null +} +``` + +--- + +##### GET /vocab/user-words/{wordId} - 특정 단어 학습 상태 + +특정 단어의 사용자 학습 상태를 조회합니다. + +**인증:** 필수 (JWT) + +**요청:** + +```typescript +const wordId = "word_001"; +const response = await authenticatedFetch( + `https://api-id.execute-api.region.amazonaws.com/dev/vocab/user-words/${wordId}` +); + +const result: ApiResponse = await response.json(); +``` + +**응답:** + +```json +{ + "isSuccess": true, + "message": "단어 학습 상태 조회 성공", + "data": { + "wordId": "word_001", + "userId": "user_123", + "status": "LEARNING", + "interval": 3, + "easeFactor": 2.5, + "repetitions": 2, + "nextReviewAt": "2024-01-17T10:00:00Z", + "lastReviewedAt": "2024-01-14T10:00:00Z", + "correctCount": 5, + "incorrectCount": 2, + "bookmarked": false, + "favorite": true, + "difficulty": "NORMAL", + "createdAt": "2024-01-10T10:00:00Z", + "updatedAt": "2024-01-14T10:00:00Z" + }, + "error": null +} +``` + +--- + +##### PUT /vocab/user-words/{wordId} - 정오답 업데이트 + +단어 학습 진행도를 업데이트합니다. 정답/오답을 기록합니다. + +**인증:** 필수 (JWT) + +**요청:** + +```typescript +const wordId = "word_001"; +const updateRequest: UpdateUserWordRequest = { + isCorrect: true +}; + +const response = await authenticatedFetch( + `https://api-id.execute-api.region.amazonaws.com/dev/vocab/user-words/${wordId}`, + { + method: 'PUT', + body: JSON.stringify(updateRequest) + } +); + +const result: ApiResponse = await response.json(); +``` + +**응답:** + +```json +{ + "isSuccess": true, + "message": "단어 학습 상태가 업데이트되었습니다", + "data": { + "wordId": "word_001", + "userId": "user_123", + "status": "LEARNING", + "interval": 6, + "easeFactor": 2.6, + "repetitions": 3, + "nextReviewAt": "2024-01-20T10:00:00Z", + "correctCount": 6, + "incorrectCount": 2, + "bookmarked": false, + "favorite": true, + "createdAt": "2024-01-10T10:00:00Z", + "updatedAt": "2024-01-14T10:30:00Z" + }, + "error": null +} +``` + +--- + +##### PUT /vocab/user-words/{wordId}/tag - 태그 업데이트 + +북마크, 즐겨찾기, 난이도 등의 태그를 업데이트합니다. + +**인증:** 필수 (JWT) + +**요청:** + +```typescript +const wordId = "word_001"; +const updateTagRequest: UpdateUserWordTagRequest = { + bookmarked: true, + favorite: true, + difficulty: "HARD" +}; + +const response = await authenticatedFetch( + `https://api-id.execute-api.region.amazonaws.com/dev/vocab/user-words/${wordId}/tag`, + { + method: 'PUT', + body: JSON.stringify(updateTagRequest) + } +); + +const result: ApiResponse = await response.json(); +``` + +**응답:** + +```json +{ + "isSuccess": true, + "message": "태그가 업데이트되었습니다", + "data": { + "wordId": "word_001", + "userId": "user_123", + "status": "LEARNING", + "interval": 6, + "easeFactor": 2.6, + "repetitions": 3, + "nextReviewAt": "2024-01-20T10:00:00Z", + "correctCount": 6, + "incorrectCount": 2, + "bookmarked": true, + "favorite": true, + "difficulty": "HARD", + "createdAt": "2024-01-10T10:00:00Z", + "updatedAt": "2024-01-14T10:30:00Z" + }, + "error": null +} +``` + +--- + +##### PUT /vocab/user-words/{wordId}/status - 상태 변경 + +단어 학습 상태를 변경합니다 (NEW, LEARNING, REVIEWING, MASTERED). + +**인증:** 필수 (JWT) + +**요청:** + +```typescript +const wordId = "word_001"; +const statusRequest: UpdateUserWordStatusRequest = { + status: "MASTERED" +}; + +const response = await authenticatedFetch( + `https://api-id.execute-api.region.amazonaws.com/dev/vocab/user-words/${wordId}/status`, + { + method: 'PUT', + body: JSON.stringify(statusRequest) + } +); + +const result: ApiResponse = await response.json(); +``` + +**응답:** + +```json +{ + "isSuccess": true, + "message": "단어 상태가 변경되었습니다", + "data": { + "wordId": "word_001", + "userId": "user_123", + "status": "MASTERED", + "interval": 30, + "easeFactor": 2.6, + "repetitions": 10, + "nextReviewAt": "2024-02-13T10:00:00Z", + "correctCount": 15, + "incorrectCount": 2, + "bookmarked": true, + "favorite": true, + "createdAt": "2024-01-10T10:00:00Z", + "updatedAt": "2024-01-14T10:30:00Z" + }, + "error": null +} +``` + +--- + +##### GET /vocab/wrong-answers - 오답 목록 + +사용자의 오답 목록을 조회합니다. + +**인증:** 필수 (JWT) + +**쿼리 파라미터:** + +| 파라미터 | 타입 | 필수 | 설명 | +|---------|------|------|------| +| limit | number | N | 반환할 아이템 개수 (기본값: 20) | +| cursor | string | N | 페이지네이션 커서 | + +**요청:** + +```typescript +const response = await authenticatedFetch( + 'https://api-id.execute-api.region.amazonaws.com/dev/vocab/wrong-answers?limit=20' +); + +const result: ApiResponse> = await response.json(); +``` + +**응답:** + +```json +{ + "isSuccess": true, + "message": "오답 목록 조회 성공", + "data": { + "items": [ + { + "wordId": "word_005", + "userId": "user_123", + "status": "LEARNING", + "correctCount": 2, + "incorrectCount": 8, + "interval": 1, + "easeFactor": 1.3, + "repetitions": 0, + "nextReviewAt": "2024-01-15T10:00:00Z", + "createdAt": "2024-01-08T10:00:00Z", + "updatedAt": "2024-01-14T15:00:00Z" + } + ], + "nextCursor": "cursor_wrong123", + "hasMore": false + }, + "error": null +} +``` + +--- + +#### 1.3 단어 그룹 관리 (인증 필요) + +##### POST /vocab/groups - 그룹 생성 + +새로운 단어 그룹을 생성합니다. + +**인증:** 필수 (JWT) + +**요청:** + +```typescript +const createGroupRequest: CreateWordGroupRequest = { + name: "일상 회화", + description: "일상에서 자주 사용하는 표현들", + wordIds: ["word_001", "word_002", "word_003"] +}; + +const response = await authenticatedFetch( + 'https://api-id.execute-api.region.amazonaws.com/dev/vocab/groups', + { + method: 'POST', + body: JSON.stringify(createGroupRequest) + } +); + +const result: ApiResponse = await response.json(); +``` + +**응답:** + +```json +{ + "isSuccess": true, + "message": "단어 그룹이 생성되었습니다", + "data": { + "groupId": "group_001", + "userId": "user_123", + "name": "일상 회화", + "description": "일상에서 자주 사용하는 표현들", + "wordIds": ["word_001", "word_002", "word_003"], + "createdAt": "2024-01-14T10:00:00Z", + "updatedAt": "2024-01-14T10:00:00Z" + }, + "error": null +} +``` + +--- + +##### GET /vocab/groups - 그룹 목록 + +사용자의 단어 그룹 목록을 조회합니다. + +**인증:** 필수 (JWT) + +**쿼리 파라미터:** + +| 파라미터 | 타입 | 필수 | 설명 | +|---------|------|------|------| +| limit | number | N | 반환할 아이템 개수 (기본값: 20) | +| cursor | string | N | 페이지네이션 커서 | + +**요청:** + +```typescript +const response = await authenticatedFetch( + 'https://api-id.execute-api.region.amazonaws.com/dev/vocab/groups?limit=10' +); + +const result: ApiResponse> = await response.json(); +``` + +**응답:** + +```json +{ + "isSuccess": true, + "message": "그룹 목록 조회 성공", + "data": { + "items": [ + { + "groupId": "group_001", + "userId": "user_123", + "name": "일상 회화", + "description": "일상에서 자주 사용하는 표현들", + "wordIds": ["word_001", "word_002", "word_003"], + "createdAt": "2024-01-14T10:00:00Z", + "updatedAt": "2024-01-14T10:00:00Z" + } + ], + "nextCursor": "cursor_group123", + "hasMore": false + }, + "error": null +} +``` + +--- + +##### GET /vocab/groups/{groupId} - 그룹 상세 조회 + +그룹과 포함된 모든 단어를 조회합니다. + +**인증:** 필수 (JWT) + +**요청:** + +```typescript +const groupId = "group_001"; +const response = await authenticatedFetch( + `https://api-id.execute-api.region.amazonaws.com/dev/vocab/groups/${groupId}` +); + +const result: ApiResponse = await response.json(); +``` + +**응답:** + +```json +{ + "isSuccess": true, + "message": "그룹 상세 조회 성공", + "data": { + "groupId": "group_001", + "userId": "user_123", + "name": "일상 회화", + "description": "일상에서 자주 사용하는 표현들", + "wordIds": ["word_001", "word_002", "word_003"], + "words": [ + { + "wordId": "word_001", + "english": "hello", + "korean": "안녕하세요", + "level": "BEGINNER", + "category": "DAILY", + "createdAt": "2024-01-14T10:00:00Z" + } + ], + "createdAt": "2024-01-14T10:00:00Z", + "updatedAt": "2024-01-14T10:00:00Z" + }, + "error": null +} +``` + +--- + +##### PUT /vocab/groups/{groupId} - 그룹 수정 + +단어 그룹의 이름과 설명을 수정합니다. + +**인증:** 필수 (JWT) + +**요청:** + +```typescript +const groupId = "group_001"; +const updateRequest: UpdateWordGroupRequest = { + name: "일상 회화 (수정됨)", + description: "일상에서 자주 사용하는 인사 표현들" +}; + +const response = await authenticatedFetch( + `https://api-id.execute-api.region.amazonaws.com/dev/vocab/groups/${groupId}`, + { + method: 'PUT', + body: JSON.stringify(updateRequest) + } +); + +const result: ApiResponse = await response.json(); +``` + +**응답:** + +```json +{ + "isSuccess": true, + "message": "그룹이 수정되었습니다", + "data": { + "groupId": "group_001", + "userId": "user_123", + "name": "일상 회화 (수정됨)", + "description": "일상에서 자주 사용하는 인사 표현들", + "wordIds": ["word_001", "word_002", "word_003"], + "createdAt": "2024-01-14T10:00:00Z", + "updatedAt": "2024-01-14T10:30:00Z" + }, + "error": null +} +``` + +--- + +##### DELETE /vocab/groups/{groupId} - 그룹 삭제 + +단어 그룹을 삭제합니다. 포함된 단어는 삭제되지 않습니다. + +**인증:** 필수 (JWT) + +**요청:** + +```typescript +const groupId = "group_001"; +const response = await authenticatedFetch( + `https://api-id.execute-api.region.amazonaws.com/dev/vocab/groups/${groupId}`, + { method: 'DELETE' } +); + +const result: ApiResponse = await response.json(); +``` + +**응답:** + +```json +{ + "isSuccess": true, + "message": "그룹이 삭제되었습니다", + "data": null, + "error": null +} +``` + +--- + +##### POST /vocab/groups/{groupId}/words/{wordId} - 그룹에 단어 추가 + +그룹에 단어를 추가합니다. + +**인증:** 필수 (JWT) + +**요청:** + +```typescript +const groupId = "group_001"; +const wordId = "word_004"; + +const response = await authenticatedFetch( + `https://api-id.execute-api.region.amazonaws.com/dev/vocab/groups/${groupId}/words/${wordId}`, + { method: 'POST' } +); + +const result: ApiResponse = await response.json(); +``` + +**응답:** + +```json +{ + "isSuccess": true, + "message": "단어가 그룹에 추가되었습니다", + "data": { + "groupId": "group_001", + "userId": "user_123", + "name": "일상 회화", + "wordIds": ["word_001", "word_002", "word_003", "word_004"], + "createdAt": "2024-01-14T10:00:00Z", + "updatedAt": "2024-01-14T10:30:00Z" + }, + "error": null +} +``` + +--- + +##### DELETE /vocab/groups/{groupId}/words/{wordId} - 그룹에서 단어 제거 + +그룹에서 단어를 제거합니다. + +**인증:** 필수 (JWT) + +**요청:** + +```typescript +const groupId = "group_001"; +const wordId = "word_004"; + +const response = await authenticatedFetch( + `https://api-id.execute-api.region.amazonaws.com/dev/vocab/groups/${groupId}/words/${wordId}`, + { method: 'DELETE' } +); + +const result: ApiResponse = await response.json(); +``` + +**응답:** + +```json +{ + "isSuccess": true, + "message": "단어가 그룹에서 제거되었습니다", + "data": { + "groupId": "group_001", + "userId": "user_123", + "name": "일상 회화", + "wordIds": ["word_001", "word_002", "word_003"], + "createdAt": "2024-01-14T10:00:00Z", + "updatedAt": "2024-01-14T10:35:00Z" + }, + "error": null +} +``` + +--- + +#### 1.4 일일 학습 (인증 필요) + +##### GET /vocab/daily - 오늘의 학습 단어 + +오늘의 학습 단어 10개를 자동으로 선정하여 반환합니다. + +**인증:** 필수 (JWT) + +**요청:** + +```typescript +const response = await authenticatedFetch( + 'https://api-id.execute-api.region.amazonaws.com/dev/vocab/daily' +); + +const result: ApiResponse = await response.json(); +``` + +**응답:** + +```json +{ + "isSuccess": true, + "message": "오늘의 학습 단어를 조회했습니다", + "data": { + "userId": "user_123", + "date": "2024-01-14", + "words": [ + { + "wordId": "word_001", + "english": "hello", + "korean": "안녕하세요", + "level": "BEGINNER", + "category": "DAILY", + "createdAt": "2024-01-14T10:00:00Z" + } + ], + "createdAt": "2024-01-14T00:00:00Z" + }, + "error": null +} +``` + +--- + +##### POST /vocab/daily/words/{wordId}/learned - 학습 완료 표시 + +오늘의 학습 단어 중 하나를 학습 완료로 표시합니다. + +**인증:** 필수 (JWT) + +**요청:** + +```typescript +const wordId = "word_001"; + +const response = await authenticatedFetch( + `https://api-id.execute-api.region.amazonaws.com/dev/vocab/daily/words/${wordId}/learned`, + { method: 'POST' } +); + +const result: ApiResponse = await response.json(); +``` + +**응답:** + +```json +{ + "isSuccess": true, + "message": "학습이 완료되었습니다", + "data": { + "wordId": "word_001", + "userId": "user_123", + "status": "LEARNING", + "interval": 1, + "easeFactor": 2.5, + "repetitions": 0, + "nextReviewAt": "2024-01-15T10:00:00Z", + "correctCount": 1, + "incorrectCount": 0, + "createdAt": "2024-01-14T10:00:00Z", + "updatedAt": "2024-01-14T10:30:00Z" + }, + "error": null +} +``` + +--- + +#### 1.5 음성 합성 + +##### POST /vocab/voice/synthesize - TTS (텍스트-음성 변환) + +텍스트를 음성으로 변환합니다. 공개 API입니다. + +**인증:** 불필요 + +**요청:** + +```typescript +interface SynthesizeVoiceRequest { + text: string; + voiceId?: string; // Polly voice ID (Joanna, Kendra, etc.) + outputFormat?: string; // mp3, ogg_vorbis, pcm +} + +const synthesizeRequest = { + text: "Hello, my name is John.", + voiceId: "Joanna", + outputFormat: "mp3" +}; + +const response = await fetch( + 'https://api-id.execute-api.region.amazonaws.com/dev/vocab/voice/synthesize', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(synthesizeRequest) + } +); + +const result = await response.blob(); +// 결과는 음성 파일 (blob) +``` + +**응답:** + +음성 파일 (audio/mpeg, audio/ogg, audio/pcm) + +--- + +### 2. 테스트 (Vocabulary Tests) + +#### 2.1 테스트 관리 + +##### POST /vocab/test/start - 테스트 시작 + +새로운 테스트를 시작합니다. + +**인증:** 필수 (JWT) + +**요청:** + +```typescript +const startTestRequest: StartTestRequest = { + testType: "DAILY" // DAILY, CUSTOM, QUICK +}; + +const response = await authenticatedFetch( + 'https://api-id.execute-api.region.amazonaws.com/dev/vocab/test/start', + { + method: 'POST', + body: JSON.stringify(startTestRequest) + } +); + +const result: ApiResponse = await response.json(); +``` + +**응답:** + +```json +{ + "isSuccess": true, + "message": "테스트가 시작되었습니다", + "data": { + "testId": "test_abc123", + "words": [ + { + "wordId": "word_001", + "english": "hello", + "korean": "안녕하세요", + "level": "BEGINNER", + "category": "DAILY", + "createdAt": "2024-01-14T10:00:00Z" + } + ], + "startedAt": "2024-01-14T10:00:00Z" + }, + "error": null +} +``` + +--- + +##### POST /vocab/test/submit - 답안 제출 + +테스트 답안을 제출합니다. + +**인증:** 필수 (JWT) + +**요청:** + +```typescript +const submitRequest: SubmitTestRequest = { + testId: "test_abc123", + testType: "DAILY", + answers: [ + { wordId: "word_001", answer: "hello" }, + { wordId: "word_002", answer: "goodbye" }, + { wordId: "word_003", answer: "" } // 빈 답변 (오답) + ], + startedAt: "2024-01-14T10:00:00Z" +}; + +const response = await authenticatedFetch( + 'https://api-id.execute-api.region.amazonaws.com/dev/vocab/test/submit', + { + method: 'POST', + body: JSON.stringify(submitRequest) + } +); + +const result: ApiResponse = await response.json(); +``` + +**응답:** + +```json +{ + "isSuccess": true, + "message": "테스트 결과가 저장되었습니다", + "data": { + "testId": "test_abc123", + "userId": "user_123", + "testType": "DAILY", + "totalQuestions": 10, + "correctAnswers": 7, + "correctRate": 70.0, + "spentTime": 180000, + "completedAt": "2024-01-14T10:03:00Z" + }, + "error": null +} +``` + +--- + +##### GET /vocab/test/results - 테스트 결과 목록 + +과거 테스트 결과를 조회합니다. + +**인증:** 필수 (JWT) + +**쿼리 파라미터:** + +| 파라미터 | 타입 | 필수 | 설명 | +|---------|------|------|------| +| testType | string | N | 테스트 타입 필터 (DAILY, CUSTOM, QUICK) | +| limit | number | N | 반환할 아이템 개수 (기본값: 20) | +| cursor | string | N | 페이지네이션 커서 | + +**요청:** + +```typescript +const response = await authenticatedFetch( + 'https://api-id.execute-api.region.amazonaws.com/dev/vocab/test/results?limit=10' +); + +const result: ApiResponse> = await response.json(); +``` + +**응답:** + +```json +{ + "isSuccess": true, + "message": "테스트 결과 목록 조회 성공", + "data": { + "items": [ + { + "testId": "test_abc123", + "userId": "user_123", + "testType": "DAILY", + "totalQuestions": 10, + "correctAnswers": 7, + "correctRate": 70.0, + "spentTime": 180000, + "completedAt": "2024-01-14T10:03:00Z" + } + ], + "nextCursor": "cursor_test123", + "hasMore": false + }, + "error": null +} +``` + +--- + +##### GET /vocab/test/results/{testId} - 테스트 결과 상세 + +특정 테스트의 상세 결과를 조회합니다. + +**인증:** 필수 (JWT) + +**요청:** + +```typescript +const testId = "test_abc123"; + +const response = await authenticatedFetch( + `https://api-id.execute-api.region.amazonaws.com/dev/vocab/test/results/${testId}` +); + +const result: ApiResponse = + await response.json(); +``` + +**응답:** + +```json +{ + "isSuccess": true, + "message": "테스트 상세 조회 성공", + "data": { + "testId": "test_abc123", + "userId": "user_123", + "testType": "DAILY", + "totalQuestions": 10, + "correctAnswers": 7, + "correctRate": 70.0, + "spentTime": 180000, + "completedAt": "2024-01-14T10:03:00Z", + "testedWords": [ + { + "wordId": "word_001", + "english": "hello", + "korean": "안녕하세요", + "isCorrect": true, + "userAnswer": "hello" + }, + { + "wordId": "word_003", + "english": "goodbye", + "korean": "안녕히 가세요", + "isCorrect": false, + "userAnswer": "" + } + ] + }, + "error": null +} +``` + +--- + +##### GET /vocab/test/tested-words - 최근 테스트 단어 + +최근에 테스트한 단어들을 조회합니다. + +**인증:** 필수 (JWT) + +**쿼리 파라미터:** + +| 파라미터 | 타입 | 필수 | 설명 | +|---------|------|------|------| +| limit | number | N | 반환할 아이템 개수 (기본값: 20) | + +**요청:** + +```typescript +const response = await authenticatedFetch( + 'https://api-id.execute-api.region.amazonaws.com/dev/vocab/test/tested-words?limit=20' +); + +const result: ApiResponse = await response.json(); +``` + +**응답:** + +```json +{ + "isSuccess": true, + "message": "최근 테스트 단어 조회 성공", + "data": [ + { + "wordId": "word_001", + "english": "hello", + "korean": "안녕하세요", + "isCorrect": true, + "userAnswer": "hello" + }, + { + "wordId": "word_002", + "english": "goodbye", + "korean": "안녕히 가세요", + "isCorrect": false, + "userAnswer": "" + } + ], + "error": null +} +``` + +--- + +#### 2.2 통계 + +##### GET /vocab/stats - 전체 통계 + +사용자의 전체 학습 통계를 조회합니다. + +**인증:** 필수 (JWT) + +**요청:** + +```typescript +const response = await authenticatedFetch( + 'https://api-id.execute-api.region.amazonaws.com/dev/vocab/stats' +); + +const result: ApiResponse = await response.json(); +``` + +**응답:** + +```json +{ + "isSuccess": true, + "message": "통계 조회 성공", + "data": { + "totalWords": 150, + "masteredCount": 50, + "learningCount": 60, + "reviewingCount": 30, + "newCount": 10, + "correctRate": 82.5, + "totalTestsTaken": 25, + "averageSpentTime": 240000 + }, + "error": null +} +``` + +--- + +##### GET /vocab/stats/daily - 일별 통계 + +일별 학습 통계를 조회합니다. + +**인증:** 필수 (JWT) + +**쿼리 파라미터:** + +| 파라미터 | 타입 | 필수 | 설명 | +|---------|------|------|------| +| days | number | N | 조회할 일수 (기본값: 30) | + +**요청:** + +```typescript +const response = await authenticatedFetch( + 'https://api-id.execute-api.region.amazonaws.com/dev/vocab/stats/daily?days=30' +); + +const result: ApiResponse = await response.json(); +``` + +**응답:** + +```json +{ + "isSuccess": true, + "message": "일별 통계 조회 성공", + "data": [ + { + "date": "2024-01-14", + "wordsLearned": 5, + "testsTaken": 2, + "correctRate": 85.0, + "spentTime": 300000 + }, + { + "date": "2024-01-13", + "wordsLearned": 3, + "testsTaken": 1, + "correctRate": 80.0, + "spentTime": 180000 + } + ], + "error": null +} +``` + +--- + +##### GET /vocab/stats/weakness - 취약점 분석 + +오답이 많은 단어들을 조회합니다. + +**인証:** 필수 (JWT) + +**쿼리 파라미터:** + +| 파라미터 | 타입 | 필수 | 설명 | +|---------|------|------|------| +| limit | number | N | 반환할 아이템 개수 (기본값: 20) | + +**요청:** + +```typescript +const response = await authenticatedFetch( + 'https://api-id.execute-api.region.amazonaws.com/dev/vocab/stats/weakness?limit=20' +); + +const result: ApiResponse = await response.json(); +``` + +**응답:** + +```json +{ + "isSuccess": true, + "message": "취약점 분석 조회 성공", + "data": [ + { + "wordId": "word_005", + "english": "perspective", + "korean": "관점", + "incorrectCount": 8, + "level": "ADVANCED" + }, + { + "wordId": "word_012", + "english": "aggregate", + "korean": "집계하다", + "incorrectCount": 6, + "level": "INTERMEDIATE" + } + ], + "error": null +} +``` + +--- + +### 3. 채팅 (Chatting) + +#### 3.1 채팅 방 관리 + +##### POST /chat/rooms - 방 생성 + +새로운 채팅 방을 생성합니다. + +**인증:** 필수 (JWT) + +**요청:** + +```typescript +const createRoomRequest: CreateRoomRequest = { + name: "초급 회화 토론", + description: "초급 레벨 학습자들을 위한 회화 연습방", + level: "beginner", + maxMembers: 6, + isPrivate: false +}; + +const response = await authenticatedFetch( + 'https://api-id.execute-api.region.amazonaws.com/dev/chat/rooms', + { + method: 'POST', + body: JSON.stringify(createRoomRequest) + } +); + +const result: ApiResponse = await response.json(); +``` + +**응답:** + +```json +{ + "isSuccess": true, + "message": "채팅 방이 생성되었습니다", + "data": { + "roomId": "room_001", + "name": "초급 회화 토론", + "description": "초급 레벨 학습자들을 위한 회화 연습방", + "level": "beginner", + "maxMembers": 6, + "currentMembers": 1, + "isPrivate": false, + "createdBy": "user_123", + "createdAt": "2024-01-14T10:00:00Z" + }, + "error": null +} +``` + +--- + +##### GET /chat/rooms - 방 목록 + +채팅 방 목록을 조회합니다. + +**인증:** 불필요 (공개 목록), 인증 필수 (참여 여부 필터) + +**쿼리 파라미터:** + +| 파라미터 | 타입 | 필수 | 설명 | +|---------|------|------|------| +| level | string | N | 레벨 필터 (beginner, intermediate, advanced) | +| joined | boolean | N | 참여한 방만 (true) | +| limit | number | N | 반환할 아이템 개수 (기본값: 20) | +| cursor | string | N | 페이지네이션 커서 | + +**요청:** + +```typescript +const response = await authenticatedFetch( + 'https://api-id.execute-api.region.amazonaws.com/dev/chat/rooms?level=beginner&limit=10' +); + +const result: ApiResponse> = await response.json(); +``` + +**응답:** + +```json +{ + "isSuccess": true, + "message": "방 목록 조회 성공", + "data": { + "items": [ + { + "roomId": "room_001", + "name": "초급 회화 토론", + "description": "초급 레벨 학습자들을 위한 회화 연습방", + "level": "beginner", + "maxMembers": 6, + "currentMembers": 3, + "isPrivate": false, + "createdBy": "user_123", + "createdAt": "2024-01-14T10:00:00Z" + } + ], + "nextCursor": "cursor_room123", + "hasMore": false + }, + "error": null +} +``` + +--- + +##### GET /chat/rooms/{roomId} - 방 상세 조회 + +특정 채팅 방의 상세 정보를 조회합니다. + +**인증:** 불필요 + +**요청:** + +```typescript +const roomId = "room_001"; + +const response = await fetch( + `https://api-id.execute-api.region.amazonaws.com/dev/chat/rooms/${roomId}` +); + +const result: ApiResponse = await response.json(); +``` + +**응답:** + +```json +{ + "isSuccess": true, + "message": "방 상세 조회 성공", + "data": { + "roomId": "room_001", + "name": "초급 회화 토론", + "description": "초급 레벨 학습자들을 위한 회화 연습방", + "level": "beginner", + "maxMembers": 6, + "currentMembers": 3, + "isPrivate": false, + "createdBy": "user_123", + "createdAt": "2024-01-14T10:00:00Z" + }, + "error": null +} +``` + +--- + +##### POST /chat/rooms/{roomId}/join - 방 참여 + +채팅 방에 참여합니다. WebSocket 연결에 필요한 roomToken을 받습니다. + +**인증:** 필수 (JWT) + +**요청:** + +```typescript +const roomId = "room_001"; + +const joinRequest: JoinRoomRequest = { + roomId: roomId +}; + +const response = await authenticatedFetch( + `https://api-id.execute-api.region.amazonaws.com/dev/chat/rooms/${roomId}/join`, + { + method: 'POST', + body: JSON.stringify(joinRequest) + } +); + +const result: ApiResponse = await response.json(); +``` + +**응답:** + +```json +{ + "isSuccess": true, + "message": "방에 입장했습니다", + "data": { + "roomId": "room_001", + "roomToken": "token_xyz789", + "name": "초급 회화 토론", + "level": "beginner", + "members": [ + { + "userId": "user_123", + "name": "John" + }, + { + "userId": "user_456", + "name": "Jane" + } + ] + }, + "error": null +} +``` + +--- + +##### POST /chat/rooms/{roomId}/leave - 방 나가기 + +채팅 방을 나갑니다. + +**인증:** 필수 (JWT) + +**요청:** + +```typescript +const roomId = "room_001"; + +const leaveRequest: LeaveRoomRequest = { + roomId: roomId +}; + +const response = await authenticatedFetch( + `https://api-id.execute-api.region.amazonaws.com/dev/chat/rooms/${roomId}/leave`, + { + method: 'POST', + body: JSON.stringify(leaveRequest) + } +); + +const result: ApiResponse = await response.json(); +``` + +**응답:** + +```json +{ + "isSuccess": true, + "message": "방을 나갔습니다", + "data": null, + "error": null +} +``` + +--- + +##### DELETE /chat/rooms/{roomId} - 방 삭제 + +채팅 방을 삭제합니다. 방장만 가능합니다. + +**인증:** 필수 (JWT) + +**요청:** + +```typescript +const roomId = "room_001"; + +const response = await authenticatedFetch( + `https://api-id.execute-api.region.amazonaws.com/dev/chat/rooms/${roomId}`, + { method: 'DELETE' } +); + +const result: ApiResponse = await response.json(); +``` + +**응답:** + +```json +{ + "isSuccess": true, + "message": "방이 삭제되었습니다", + "data": null, + "error": null +} +``` + +--- + +#### 3.2 메시지 관리 + +##### POST /chat/rooms/{roomId}/messages - 메시지 전송 + +채팅 방에 메시지를 전송합니다. REST API로도 전송 가능하지만, 실시간 채팅은 WebSocket 사용을 권장합니다. + +**인증:** 필수 (JWT) + +**요청:** + +```typescript +const roomId = "room_001"; + +const sendMessageRequest: SendMessageRequest = { + roomId: roomId, + content: "Hello everyone!", + messageType: "TEXT" +}; + +const response = await authenticatedFetch( + `https://api-id.execute-api.region.amazonaws.com/dev/chat/rooms/${roomId}/messages`, + { + method: 'POST', + body: JSON.stringify(sendMessageRequest) + } +); + +const result: ApiResponse = await response.json(); +``` + +**응답:** + +```json +{ + "isSuccess": true, + "message": "메시지가 전송되었습니다", + "data": { + "messageId": "msg_001", + "roomId": "room_001", + "userId": "user_123", + "userName": "John", + "content": "Hello everyone!", + "messageType": "TEXT", + "createdAt": "2024-01-14T10:00:00Z" + }, + "error": null +} +``` + +--- + +##### GET /chat/rooms/{roomId}/messages - 메시지 목록 + +채팅 방의 메시지 목록을 조회합니다. + +**인증:** 필수 (JWT) + +**쿼리 파라미터:** + +| 파라미터 | 타입 | 필수 | 설명 | +|---------|------|------|------| +| limit | number | N | 반환할 아이템 개수 (기본값: 50) | +| cursor | string | N | 페이지네이션 커서 | + +**요청:** + +```typescript +const roomId = "room_001"; + +const response = await authenticatedFetch( + `https://api-id.execute-api.region.amazonaws.com/dev/chat/rooms/${roomId}/messages?limit=50` +); + +const result: ApiResponse> = await response.json(); +``` + +**응답:** + +```json +{ + "isSuccess": true, + "message": "메시지 목록 조회 성공", + "data": { + "items": [ + { + "messageId": "msg_001", + "roomId": "room_001", + "userId": "user_123", + "userName": "John", + "content": "Hello everyone!", + "messageType": "TEXT", + "createdAt": "2024-01-14T10:00:00Z" + } + ], + "nextCursor": "cursor_msg123", + "hasMore": false + }, + "error": null +} +``` + +--- + +##### GET /chat/rooms/{roomId}/messages/{messageId} - 메시지 상세 + +특정 메시지의 상세 정보를 조회합니다. + +**인증:** 필수 (JWT) + +**요청:** + +```typescript +const roomId = "room_001"; +const messageId = "msg_001"; + +const response = await authenticatedFetch( + `https://api-id.execute-api.region.amazonaws.com/dev/chat/rooms/${roomId}/messages/${messageId}` +); + +const result: ApiResponse = await response.json(); +``` + +**응답:** + +```json +{ + "isSuccess": true, + "message": "메시지 상세 조회 성공", + "data": { + "messageId": "msg_001", + "roomId": "room_001", + "userId": "user_123", + "userName": "John", + "content": "Hello everyone!", + "messageType": "TEXT", + "createdAt": "2024-01-14T10:00:00Z" + }, + "error": null +} +``` + +--- + +#### 3.3 음성 합성 (채팅) + +##### POST /chat/voice/synthesize - 음성 합성 + +텍스트를 음성으로 변환합니다. + +**인증:** 필수 (JWT) + +**요청:** + +```typescript +const synthesizeRequest: VoiceSynthesisRequest = { + text: "Hello everyone! How are you today?", + voiceId: "Joanna" +}; + +const response = await authenticatedFetch( + 'https://api-id.execute-api.region.amazonaws.com/dev/chat/voice/synthesize', + { + method: 'POST', + body: JSON.stringify(synthesizeRequest) + } +); + +const result = await response.blob(); +// 결과는 음성 파일 (blob) +``` + +**응답:** + +음성 파일 (audio/mpeg) + +--- + +#### 3.4 게임 (Catch-Mind) + +##### POST /chat/rooms/{roomId}/game/start - 게임 시작 + +채팅 방에서 Catch-Mind 게임을 시작합니다. + +**인증:** 필수 (JWT) + +**요청:** + +```typescript +const roomId = "room_001"; + +const response = await authenticatedFetch( + `https://api-id.execute-api.region.amazonaws.com/dev/chat/rooms/${roomId}/game/start`, + { method: 'POST' } +); + +const result: ApiResponse<{ gameId: string }> = await response.json(); +``` + +**응답:** + +```json +{ + "isSuccess": true, + "message": "게임이 시작되었습니다", + "data": { + "gameId": "game_001" + }, + "error": null +} +``` + +--- + +##### POST /chat/rooms/{roomId}/game/stop - 게임 종료 + +게임을 종료합니다. + +**인증:** 필수 (JWT) + +**요청:** + +```typescript +const roomId = "room_001"; + +const response = await authenticatedFetch( + `https://api-id.execute-api.region.amazonaws.com/dev/chat/rooms/${roomId}/game/stop`, + { method: 'POST' } +); + +const result: ApiResponse = await response.json(); +``` + +**응답:** + +```json +{ + "isSuccess": true, + "message": "게임이 종료되었습니다", + "data": null, + "error": null +} +``` + +--- + +##### GET /chat/rooms/{roomId}/game/status - 게임 상태 + +게임의 현재 상태를 조회합니다. + +**인증:** 필수 (JWT) + +**요청:** + +```typescript +const roomId = "room_001"; + +const response = await authenticatedFetch( + `https://api-id.execute-api.region.amazonaws.com/dev/chat/rooms/${roomId}/game/status` +); + +const result: ApiResponse = await response.json(); +``` + +**응답:** + +```json +{ + "isSuccess": true, + "message": "게임 상태 조회 성공", + "data": { + "roomId": "room_001", + "isActive": true, + "currentPlayer": "user_456", + "gameData": { + "wordId": "word_042", + "hint": "A greeting" + } + }, + "error": null +} +``` + +--- + +##### GET /chat/rooms/{roomId}/game/scores - 점수판 + +게임 점수판을 조회합니다. + +**인증:** 필수 (JWT) + +**요청:** + +```typescript +const roomId = "room_001"; + +const response = await authenticatedFetch( + `https://api-id.execute-api.region.amazonaws.com/dev/chat/rooms/${roomId}/game/scores` +); + +const result: ApiResponse = await response.json(); +``` + +**응답:** + +```json +{ + "isSuccess": true, + "message": "점수판 조회 성공", + "data": { + "roomId": "room_001", + "scores": [ + { + "userId": "user_123", + "userName": "John", + "score": 250 + }, + { + "userId": "user_456", + "userName": "Jane", + "score": 180 + } + ] + }, + "error": null +} +``` + +--- + +### 4. 문법 검사 및 AI 대화 (Grammar) + +#### 4.1 문법 검사 + +##### POST /grammar/check - 문법 검사 + +문장의 문법을 검사합니다. + +**인증:** 필수 (JWT) + +**요청:** + +```typescript +const grammarCheckRequest: GrammarCheckRequest = { + sentence: "She go to the school.", + level: "BEGINNER" +}; + +const response = await authenticatedFetch( + 'https://api-id.execute-api.region.amazonaws.com/dev/grammar/check', + { + method: 'POST', + body: JSON.stringify(grammarCheckRequest) + } +); + +const result: ApiResponse = await response.json(); +``` + +**응답:** + +```json +{ + "isSuccess": true, + "message": "문법 검사 완료", + "data": { + "originalSentence": "She go to the school.", + "correctedSentence": "She goes to school.", + "score": 70, + "isCorrect": false, + "errors": [ + { + "type": "SUBJECT_VERB_AGREEMENT", + "original": "go", + "corrected": "goes", + "explanation": "'She'는 3인칭 단수이므로 동사 'go'는 'goes'로 변경해야 합니다. (She goes, He goes)", + "startIndex": 4, + "endIndex": 6 + }, + { + "type": "ARTICLE", + "original": "the school", + "corrected": "school", + "explanation": "일반적인 장소(학교, 교회 등)를 나타낼 때는 관사 'the'를 생략합니다. (go to school, go to church)", + "startIndex": 10, + "endIndex": 20 + } + ], + "feedback": "주어-동사 일치와 관사 사용에 주의하세요. 3인칭 단수 주어에는 동사에 -s/-es를 붙여야 합니다." + }, + "error": null +} +``` + +**레벨별 explanation 예시:** + +| 레벨 | explanation 스타일 | +|------|-------------------| +| BEGINNER | `"'go'의 과거형은 'went'입니다. (go → went)"` (한국어 포함) | +| INTERMEDIATE | `"Use 'went' for past tense of 'go'. Irregular verbs don't follow the -ed rule."` | +| ADVANCED | `"The verb 'go' is irregular. Past simple: went, Past participle: gone."` | + +--- + +#### 4.2 AI 대화 + +##### POST /grammar/conversation - AI 대화 + +AI와의 회화형 학습을 진행합니다. + +**인증:** 필수 (JWT) + +**요청:** + +```typescript +const conversationRequest: ConversationRequest = { + message: "Hi, how are you today?", + level: "BEGINNER" +}; + +const response = await authenticatedFetch( + 'https://api-id.execute-api.region.amazonaws.com/dev/grammar/conversation', + { + method: 'POST', + body: JSON.stringify(conversationRequest) + } +); + +const result: ApiResponse = await response.json(); +``` + +**응답:** + +```json +{ + "isSuccess": true, + "message": "대화 완료", + "data": { + "sessionId": "550e8400-e29b-41d4-a716-446655440000", + "grammarCheck": { + "originalSentence": "I go to school yesterday", + "correctedSentence": "I went to school yesterday", + "score": 75, + "isCorrect": false, + "errors": [ + { + "type": "VERB_TENSE", + "original": "go", + "corrected": "went", + "explanation": "과거 시제에서는 'go'가 'went'로 변합니다. (go → went)", + "startIndex": 2, + "endIndex": 4 + } + ], + "feedback": "동사 시제에 주의하세요!" + }, + "aiResponse": "That sounds like a busy day! (바쁜 하루였겠네요!) What did you do at school?", + "conversationTip": "Try using past tense when talking about yesterday." + }, + "error": null +} +``` + +**레벨별 aiResponse 톤:** + +| 레벨 | 스타일 | +|------|--------| +| BEGINNER | 짧은 문장, 한국어 번역 포함. `"That sounds fun! (재미있었겠네요!)"` | +| INTERMEDIATE | 자연스러운 일상 영어. `"That sounds lovely! What did you do there?"` | +| ADVANCED | 고급 어휘, 관용어 사용. `"How delightful! What activities did you engage in?"` | + +--- + +##### GET /grammar/sessions - 세션 목록 + +과거 대화 세션을 조회합니다. + +**인증:** 필수 (JWT) + +**요청:** + +```typescript +const response = await authenticatedFetch( + 'https://api-id.execute-api.region.amazonaws.com/dev/grammar/sessions' +); + +const result: ApiResponse> = await response.json(); +``` + +**응답:** + +```json +{ + "isSuccess": true, + "message": "세션 목록 조회 성공", + "data": { + "items": [ + { + "sessionId": "session_001", + "userId": "user_123", + "startedAt": "2024-01-14T10:00:00Z", + "updatedAt": "2024-01-14T10:15:00Z", + "messageCount": 5 + } + ], + "nextCursor": "cursor_session123", + "hasMore": false + }, + "error": null +} +``` + +--- + +##### GET /grammar/sessions/{sessionId} - 세션 상세 + +특정 세션의 상세 정보를 조회합니다. + +**인증:** 필수 (JWT) + +**요청:** + +```typescript +const sessionId = "session_001"; + +const response = await authenticatedFetch( + `https://api-id.execute-api.region.amazonaws.com/dev/grammar/sessions/${sessionId}` +); + +const result: ApiResponse = await response.json(); +``` + +**응답:** + +```json +{ + "isSuccess": true, + "message": "세션 상세 조회 성공", + "data": { + "sessionId": "session_001", + "userId": "user_123", + "startedAt": "2024-01-14T10:00:00Z", + "updatedAt": "2024-01-14T10:15:00Z", + "messageCount": 5 + }, + "error": null +} +``` + +--- + +##### DELETE /grammar/sessions/{sessionId} - 세션 삭제 + +세션을 삭제합니다. + +**인증:** 필수 (JWT) + +**요청:** + +```typescript +const sessionId = "session_001"; + +const response = await authenticatedFetch( + `https://api-id.execute-api.region.amazonaws.com/dev/grammar/sessions/${sessionId}`, + { method: 'DELETE' } +); + +const result: ApiResponse = await response.json(); +``` + +**응답:** + +```json +{ + "isSuccess": true, + "message": "세션이 삭제되었습니다", + "data": null, + "error": null +} +``` + +--- + +### 5. 통계 (Statistics) + +#### 5.1 일/주/월간 통계 + +##### GET /stats/daily - 일간 통계 + +일간 학습 통계를 조회합니다. + +**인증:** 필수 (JWT) + +**요청:** + +```typescript +const response = await authenticatedFetch( + 'https://api-id.execute-api.region.amazonaws.com/dev/stats/daily' +); + +const result: ApiResponse = await response.json(); +``` + +**응답:** + +```json +{ + "isSuccess": true, + "message": "일간 통계 조회 성공", + "data": { + "date": "2024-01-14", + "wordsLearned": 5, + "testsTaken": 2, + "correctRate": 85.0, + "spentTime": 300000 + }, + "error": null +} +``` + +--- + +##### GET /stats/weekly - 주간 통계 + +주간 학습 통계를 조회합니다. + +**인증:** 필수 (JWT) + +**요청:** + +```typescript +const response = await authenticatedFetch( + 'https://api-id.execute-api.region.amazonaws.com/dev/stats/weekly' +); + +const result: ApiResponse = await response.json(); +``` + +**응답:** + +```json +{ + "isSuccess": true, + "message": "주간 통계 조회 성공", + "data": { + "startDate": "2024-01-08", + "endDate": "2024-01-14", + "totalWordsLearned": 35, + "totalTestsTaken": 12, + "averageCorrectRate": 82.5, + "totalSpentTime": 2100000, + "dailyStats": [ + { + "date": "2024-01-14", + "wordsLearned": 5, + "testsTaken": 2, + "correctRate": 85.0, + "spentTime": 300000 + } + ] + }, + "error": null +} +``` + +--- + +##### GET /stats/monthly - 월간 통계 + +월간 학습 통계를 조회합니다. + +**인증:** 필수 (JWT) + +**요청:** + +```typescript +const response = await authenticatedFetch( + 'https://api-id.execute-api.region.amazonaws.com/dev/stats/monthly' +); + +const result: ApiResponse = await response.json(); +``` + +**응답:** + +```json +{ + "isSuccess": true, + "message": "월간 통계 조회 성공", + "data": { + "month": "2024-01", + "totalWordsLearned": 150, + "totalTestsTaken": 50, + "averageCorrectRate": 81.5, + "totalSpentTime": 8700000 + }, + "error": null +} +``` + +--- + +##### GET /stats/total - 전체 통계 + +누적 학습 통계를 조회합니다. + +**인증:** 필수 (JWT) + +**요청:** + +```typescript +const response = await authenticatedFetch( + 'https://api-id.execute-api.region.amazonaws.com/dev/stats/total' +); + +const result: ApiResponse = await response.json(); +``` + +**응답:** + +```json +{ + "isSuccess": true, + "message": "전체 통계 조회 성공", + "data": { + "totalWordsLearned": 450, + "masteredCount": 150, + "learningCount": 200, + "totalTestsTaken": 120, + "averageCorrectRate": 80.5, + "totalSpentTime": 32400000, + "streak": 15 + }, + "error": null +} +``` + +--- + +##### GET /stats/history - 통계 히스토리 + +통계 변화 히스토리를 조회합니다. + +**인증:** 필수 (JWT) + +**쿼리 파라미터:** + +| 파라미터 | 타입 | 필수 | 설명 | +|---------|------|------|------| +| days | number | N | 조회할 일수 (기본값: 30) | + +**요청:** + +```typescript +const response = await authenticatedFetch( + 'https://api-id.execute-api.region.amazonaws.com/dev/stats/history?days=30' +); + +const result: ApiResponse = await response.json(); +``` + +**응답:** + +```json +{ + "isSuccess": true, + "message": "통계 히스토리 조회 성공", + "data": [ + { + "date": "2024-01-14", + "wordsLearned": 5, + "testsTaken": 2, + "correctRate": 85.0, + "spentTime": 300000 + }, + { + "date": "2024-01-13", + "wordsLearned": 3, + "testsTaken": 1, + "correctRate": 80.0, + "spentTime": 180000 + } + ], + "error": null +} +``` + +--- + +### 6. 뱃지 (Badges) + +#### 6.1 뱃지 조회 + +##### GET /badges - 전체 뱃지 + +모든 뱃지를 조회합니다 (획득 여부 포함). + +**인증:** 필수 (JWT) + +**요청:** + +```typescript +const response = await authenticatedFetch( + 'https://api-id.execute-api.region.amazonaws.com/dev/badges' +); + +const result: ApiResponse = await response.json(); +``` + +**응답:** + +```json +{ + "isSuccess": true, + "message": "뱃지 목록 조회 성공", + "data": [ + { + "badgeId": "badge_001", + "name": "첫 단어", + "description": "첫 번째 단어를 학습했습니다", + "imageUrl": "https://s3.amazonaws.com/badges/first_word.png", + "condition": "1개 단어 학습", + "isEarned": true, + "earnedAt": "2024-01-10T10:00:00Z" + }, + { + "badgeId": "badge_002", + "name": "100단어 마스터", + "description": "100개 단어를 마스터했습니다", + "imageUrl": "https://s3.amazonaws.com/badges/100_words.png", + "condition": "100개 단어 MASTERED 상태", + "isEarned": false + } + ], + "error": null +} +``` + +--- + +##### GET /badges/earned - 획득한 뱃지 + +사용자가 획득한 뱃지만 조회합니다. + +**인증:** 필수 (JWT) + +**요청:** + +```typescript +const response = await authenticatedFetch( + 'https://api-id.execute-api.region.amazonaws.com/dev/badges/earned' +); + +const result: ApiResponse = await response.json(); +``` + +**응답:** + +```json +{ + "isSuccess": true, + "message": "획득한 뱃지 조회 성공", + "data": [ + { + "badgeId": "badge_001", + "name": "첫 단어", + "description": "첫 번째 단어를 학습했습니다", + "imageUrl": "https://s3.amazonaws.com/badges/first_word.png", + "condition": "1개 단어 학습", + "isEarned": true, + "earnedAt": "2024-01-10T10:00:00Z" + } + ], + "error": null +} +``` + +--- + +## 에러 코드 및 처리 + +### 표준 에러 코드 + +| 코드 | HTTP 상태 | 메시지 | 설명 | +|------|---------|--------|------| +| AUTH_001 | 401 | 인증이 필요합니다 | JWT 토큰이 없거나 요청에 Authorization 헤더가 없음 | +| AUTH_002 | 403 | 접근 권한이 없습니다 | 사용자가 리소스에 접근할 권한이 없음 | +| AUTH_003 | 401 | 유효하지 않은 토큰입니다 | JWT 토큰이 유효하지 않음 (서명 오류 등) | +| AUTH_004 | 401 | 토큰이 만료되었습니다 | JWT 토큰의 유효 기간이 만료됨 | +| VALIDATION_001 | 400 | 잘못된 입력입니다 | 요청 데이터의 형식이 올바르지 않음 | +| VALIDATION_002 | 400 | 필수 필드가 누락되었습니다 | 필수 필드가 요청에 포함되지 않음 | +| VALIDATION_003 | 400 | 형식이 올바르지 않습니다 | 데이터 형식이 예상된 형식과 다름 | +| VALIDATION_004 | 400 | 값이 허용 범위를 벗어났습니다 | 값이 최소/최대 범위를 벗어남 | +| RESOURCE_001 | 404 | 리소스를 찾을 수 없습니다 | 요청한 리소스가 존재하지 않음 | +| RESOURCE_002 | 409 | 이미 존재하는 리소스입니다 | 중복 생성 시도 (예: 같은 이메일로 가입) | +| RESOURCE_003 | 405 | 허용되지 않는 메서드입니다 | HTTP 메서드가 지원되지 않음 | +| SYSTEM_001 | 500 | 내부 서버 오류가 발생했습니다 | 서버 내부 오류 | +| SYSTEM_002 | 500 | 데이터베이스 오류가 발생했습니다 | DynamoDB 쿼리 오류 | +| SYSTEM_003 | 502 | 외부 API 호출 오류가 발생했습니다 | AWS Bedrock 또는 Polly 호출 실패 | +| SYSTEM_004 | 503 | 서비스를 일시적으로 사용할 수 없습니다 | 서비스 일시적 불가능 (다시 시도하세요) | + +### 에러 처리 예제 + +```typescript +// 일반적인 에러 처리 +interface ErrorResponse { + isSuccess: false; + data: null; + message: null; + error: string; +} + +async function handleApiError(response: Response) { + const errorData: ErrorResponse = await response.json(); + + if (response.status === 401) { + // 인증 에러 - 로그인 페이지로 이동 + if (errorData.error.includes('토큰')) { + window.location.href = '/login'; + } + } else if (response.status === 403) { + // 권한 에러 - 사용자 알림 + alert('접근 권한이 없습니다'); + } else if (response.status === 404) { + // 리소스 없음 + console.error('리소스를 찾을 수 없습니다:', errorData.error); + } else if (response.status === 400) { + // 검증 에러 + console.error('입력 오류:', errorData.error); + } else if (response.status >= 500) { + // 서버 에러 + console.error('서버 에러:', errorData.error); + } +} + +// Axios 인터셉터로 통합 에러 처리 +apiClient.interceptors.response.use( + (response) => response, + (error) => { + if (error.response) { + const { status, data } = error.response; + + if (status === 401) { + // 재로그인 처리 + Auth.signOut(); + window.location.href = '/login'; + } else if (status === 403) { + // 권한 없음 처리 + throw new Error('접근 권한이 없습니다'); + } else if (status === 404) { + // 리소스 없음 처리 + throw new Error('리소스를 찾을 수 없습니다'); + } else if (status >= 500) { + // 서버 에러 처리 + throw new Error('서버 오류가 발생했습니다. 잠시 후 다시 시도하세요'); + } + + throw new Error(data.error || '요청 실패'); + } + + throw error; + } +); +``` + +--- + +## WebSocket 연동 가이드 + +### WebSocket 엔드포인트 + +``` +wss://{api-id}.execute-api.{region}.amazonaws.com/dev +``` + +### 연결 순서 + +1. REST API로 채팅 방에 참여 (`/chat/rooms/{roomId}/join`) +2. `roomToken` 획득 +3. WebSocket에 연결하고 `roomToken` 전송 +4. 메시지 수신/전송 + +### WebSocket 연동 예제 + +```typescript +import { Auth } from 'aws-amplify'; + +class ChatWebSocketManager { + private ws: WebSocket | null = null; + private roomId: string; + private roomToken: string; + private userId: string; + private reconnectAttempts = 0; + private maxReconnectAttempts = 5; + private reconnectDelay = 3000; + + constructor( + roomId: string, + roomToken: string, + userId: string + ) { + this.roomId = roomId; + this.roomToken = roomToken; + this.userId = userId; + } + + // WebSocket 연결 + connect(onMessageReceived: (message: ChatMessage) => void): Promise { + return new Promise((resolve, reject) => { + try { + const wsUrl = `wss://{api-id}.execute-api.{region}.amazonaws.com/dev`; + + this.ws = new WebSocket(wsUrl); + + this.ws.onopen = () => { + console.log('WebSocket 연결됨'); + this.reconnectAttempts = 0; + + // 인증 메시지 전송 + this.sendAuthMessage(); + resolve(); + }; + + this.ws.onmessage = (event) => { + try { + const message = JSON.parse(event.data); + console.log('메시지 수신:', message); + + if (message.action === 'sendMessage') { + onMessageReceived(message.data); + } + } catch (error) { + console.error('메시지 파싱 오류:', error); + } + }; + + this.ws.onerror = (error) => { + console.error('WebSocket 에러:', error); + reject(error); + }; + + this.ws.onclose = () => { + console.log('WebSocket 연결 종료'); + this.attemptReconnect(onMessageReceived); + }; + } catch (error) { + reject(error); + } + }); + } + + // 인증 메시지 전송 + private sendAuthMessage() { + const authMessage = { + action: 'connect', + roomId: this.roomId, + roomToken: this.roomToken, + userId: this.userId + }; + + this.send(authMessage); + } + + // 메시지 전송 + sendMessage(content: string, messageType: MessageType = 'TEXT') { + const message = { + action: 'sendMessage', + roomId: this.roomId, + userId: this.userId, + content: content, + messageType: messageType, + timestamp: new Date().toISOString() + }; + + this.send(message); + } + + // 내부 메서드: 메시지 전송 + private send(message: any) { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify(message)); + } else { + console.error('WebSocket이 연결되지 않았습니다'); + } + } + + // 재연결 시도 + private attemptReconnect(onMessageReceived: (message: ChatMessage) => void) { + if (this.reconnectAttempts < this.maxReconnectAttempts) { + this.reconnectAttempts++; + console.log( + `재연결 시도 ${this.reconnectAttempts}/${this.maxReconnectAttempts}` + ); + + setTimeout(() => { + this.connect(onMessageReceived).catch((error) => { + console.error('재연결 실패:', error); + }); + }, this.reconnectDelay); + } else { + console.error('최대 재연결 횟수 초과'); + } + } + + // 연결 종료 + disconnect() { + if (this.ws) { + this.ws.close(); + this.ws = null; + } + } + + // 연결 상태 확인 + isConnected(): boolean { + return this.ws !== null && this.ws.readyState === WebSocket.OPEN; + } +} + +// 사용 예제 +async function startChatting() { + try { + // 1. 방에 참여하여 roomToken 획득 + const joinResponse = await authenticatedFetch( + 'https://api-id.execute-api.region.amazonaws.com/dev/chat/rooms/room_001/join', + { method: 'POST', body: JSON.stringify({ roomId: 'room_001' }) } + ); + + const joinData: ApiResponse = await joinResponse.json(); + + if (!joinData.isSuccess) { + throw new Error(joinData.error); + } + + const { roomToken } = joinData.data; + + // 2. 사용자 정보 획득 + const user = await Auth.currentAuthenticatedUser(); + + // 3. WebSocket 매니저 생성 및 연결 + const chatManager = new ChatWebSocketManager( + 'room_001', + roomToken, + user.username + ); + + await chatManager.connect((message: ChatMessage) => { + console.log('새 메시지:', message); + // UI에 메시지 표시 + displayMessage(message); + }); + + // 4. 메시지 전송 + chatManager.sendMessage('Hello everyone!', 'TEXT'); + + // 5. 연결 종료 + // chatManager.disconnect(); + + } catch (error) { + console.error('채팅 시작 오류:', error); + } +} + +function displayMessage(message: ChatMessage) { + const messageElement = document.createElement('div'); + messageElement.className = 'message'; + messageElement.innerHTML = ` +
+ ${message.userName} + ${new Date(message.createdAt).toLocaleTimeString()} +
+
${escapeHtml(message.content)}
+ `; + + document.getElementById('messages-container')?.appendChild(messageElement); +} + +function escapeHtml(text: string): string { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} +``` + +### WebSocket 이벤트 + +#### $connect + +클라이언트가 WebSocket에 처음 연결할 때 발생합니다. + +```json +{ + "action": "connect", + "roomId": "room_001", + "roomToken": "token_xyz789", + "userId": "user_123" +} +``` + +#### $disconnect + +클라이언트가 WebSocket 연결을 종료할 때 발생합니다. + +```json +{ + "action": "disconnect", + "roomId": "room_001", + "userId": "user_123" +} +``` + +#### sendMessage + +메시지를 전송할 때 발생합니다. + +```json +{ + "action": "sendMessage", + "roomId": "room_001", + "userId": "user_123", + "content": "Hello everyone!", + "messageType": "TEXT", + "timestamp": "2024-01-14T10:00:00Z" +} +``` + +서버로부터의 응답: + +```json +{ + "action": "sendMessage", + "data": { + "messageId": "msg_001", + "roomId": "room_001", + "userId": "user_123", + "userName": "John", + "content": "Hello everyone!", + "messageType": "TEXT", + "createdAt": "2024-01-14T10:00:00Z" + } +} +``` + +--- + +## 추가 리소스 + +### 관련 문서 + +- AWS Cognito 인증: https://docs.aws.amazon.com/cognito/ +- AWS API Gateway: https://docs.aws.amazon.com/apigateway/ +- AWS Lambda: https://docs.aws.amazon.com/lambda/ +- AWS Amplify: https://docs.amplify.aws/ + +### 유용한 라이브러리 + +- **HTTP 클라이언트**: axios, fetch API +- **인증**: AWS Amplify, AWS SDK +- **실시간**: WebSocket API +- **상태 관리**: Redux, Zustand, Jotai +- **UI**: React, Vue, Angular + +### 베스트 프랙티스 + +1. **토큰 관리** + - 토큰 만료 시 자동 갱신 구현 + - 로컬 스토리지 대신 httpOnly 쿠키 사용 권장 + - 토큰 검증은 항상 백엔드에서 수행 + +2. **에러 처리** + - 모든 API 호출에 try-catch 또는 .catch() 구현 + - 사용자 친화적인 에러 메시지 표시 + - 에러 로깅 시스템 구축 + +3. **성능** + - API 응답 캐싱 (적절한 TTL 설정) + - 페이지네이션으로 대량 데이터 처리 + - 이미지 최적화 및 CDN 사용 + +4. **보안** + - HTTPS만 사용 + - CORS 설정 확인 + - 민감한 데이터는 암호화 + - 주기적인 보안 업데이트 + +--- + +**최종 수정일:** 2024년 1월 14일 +**버전:** 1.0.0 \ No newline at end of file diff --git a/docs/IMPROVEMENT-GUIDE.md b/docs/IMPROVEMENT-GUIDE.md deleted file mode 100644 index 699294cf..00000000 --- a/docs/IMPROVEMENT-GUIDE.md +++ /dev/null @@ -1,911 +0,0 @@ -# Code Improvement Guide - -## Overview - -프로젝트 코드 분석을 통해 도출된 리팩토링, 디자인 패턴, 성능 최적화 방안입니다. - -```mermaid -flowchart TB - subgraph Priority["개선 우선순위"] - HIGH[높음
즉시 적용] - MED[중간
중기 개선] - LOW[낮음
장기 개선] - end - - subgraph High_Items["높음 우선순위"] - H1[Enum 도입] - H2[N+1 쿼리 최적화] - H3[커스텀 예외 클래스] - end - - subgraph Med_Items["중간 우선순위"] - M1[Factory Pattern] - M2[Word 캐싱] - M3[메서드 추출] - end - - subgraph Low_Items["낮음 우선순위"] - L1[State Pattern] - L2[Specification Pattern] - L3[구조화 로깅] - end - - HIGH --> High_Items - MED --> Med_Items - LOW --> Low_Items -``` - ---- - -## 1. 리팩토링 필요 영역 - -### 1.1 중복 코드 패턴 - -#### UserWord 생성 로직 중복 - -**위치**: `UserWordService.java`, `UserWordCommandService.java` - -```java -// 동일한 코드가 두 곳에서 반복 -userWord = UserWord.builder() - .pk("USER#" + userId) - .sk("WORD#" + wordId) - .gsi1pk("USER#" + userId + "#REVIEW") - .gsi2pk("USER#" + userId + "#STATUS") - .userId(userId) - .wordId(wordId) - .status("NEW") - .interval(1) - .easeFactor(2.5) - .repetitions(0) - .correctCount(0) - .incorrectCount(0) - .createdAt(now) - .build(); -``` - -**개선안**: Factory 메서드로 추출 - -```java -public class UserWordFactory { - public static UserWord createNew(String userId, String wordId) { - String now = Instant.now().toString(); - return UserWord.builder() - .pk("USER#" + userId) - .sk("WORD#" + wordId) - .gsi1pk("USER#" + userId + "#REVIEW") - .gsi2pk("USER#" + userId + "#STATUS") - .userId(userId) - .wordId(wordId) - .status(WordStatus.NEW.name()) - .interval(1) - .easeFactor(2.5) - .repetitions(0) - .correctCount(0) - .incorrectCount(0) - .createdAt(now) - .build(); - } -} -``` - ---- - -#### 검증 로직 중복 - -**위치**: `DailyStudyService.java`, `DailyStudyCommandService.java` - -```java -// 하드코딩된 검증 -if (!level.equals("BEGINNER") && !level.equals("INTERMEDIATE") && !level.equals("ADVANCED")) { - throw new IllegalArgumentException("Invalid level"); -} -``` - -**개선안**: Enum 도입 - -```java -public enum StudyLevel { - BEGINNER, INTERMEDIATE, ADVANCED; - - public static boolean isValid(String value) { - return Arrays.stream(values()) - .anyMatch(l -> l.name().equals(value)); - } - - public static StudyLevel fromString(String value) { - return Arrays.stream(values()) - .filter(l -> l.name().equals(value)) - .findFirst() - .orElseThrow(() -> new IllegalArgumentException("Invalid level: " + value)); - } -} - -// 사용 -if (!StudyLevel.isValid(level)) { - throw new ValidationException("Invalid level"); -} -``` - ---- - -### 1.2 하드코딩된 값들 - -| 위치 | 하드코딩 값 | 권장 | -|---------------------------|---------------------------------|-----------------------| -| `UserWordService.java` | `"USER#"`, `"WORD#"`, `"DATE#"` | DynamoDbKeyPrefix 클래스 | -| `DailyStudyService.java` | `NEW_WORDS_COUNT = 50` | Config 클래스 | -| `ChatRoomHandler.java` | `"beginner"`, `6`, `false` | ChatRoomDefaults 클래스 | -| `UserWordRepository.java` | `limit * 3` | 상수로 추출 | - -**개선안**: 상수 클래스 생성 - -```java -public final class DynamoDbKeyPrefix { - public static final String USER = "USER#"; - public static final String WORD = "WORD#"; - public static final String ROOM = "ROOM#"; - public static final String TOKEN = "TOKEN#"; - public static final String CONN = "CONN#"; - public static final String MSG = "MSG#"; - public static final String DATE = "DATE#"; - public static final String STATUS = "STATUS#"; - - private DynamoDbKeyPrefix() {} -} - -public final class StudyConfig { - public static final int NEW_WORDS_COUNT = 50; - public static final int REVIEW_WORDS_COUNT = 5; - public static final double DEFAULT_EASE_FACTOR = 2.5; - public static final int INITIAL_INTERVAL = 1; - - private StudyConfig() {} -} -``` - ---- - -### 1.3 긴 메서드 분할 - -#### StatsService.getWeaknessAnalysis() - 125줄 - -```mermaid -flowchart TB - A[getWeaknessAnalysis] --> B[calculateCategoryAnalysis] - A --> C[calculateLevelAnalysis] - A --> D[generateSuggestions] - A --> E[buildResponse] - - B --> B1[collectCategoryStats] - B --> B2[calculateAccuracy] - - C --> C1[collectLevelStats] - C --> C2[calculateAccuracy] -``` - -**개선안**: 메서드 추출 - -```java -public Map getWeaknessAnalysis(String userId) { - List allUserWords = fetchAllUserWords(userId); - Map wordMap = fetchWordMap(allUserWords); - - Map> categoryAnalysis = calculateCategoryAnalysis(allUserWords, wordMap); - Map> levelAnalysis = calculateLevelAnalysis(allUserWords, wordMap); - List suggestions = generateSuggestions(categoryAnalysis, levelAnalysis); - - return buildWeaknessResponse(categoryAnalysis, levelAnalysis, suggestions); -} - -private double calculateAccuracy(int correct, int incorrect) { - int total = correct + incorrect; - return total > 0 ? (correct * 100.0 / total) : 0; -} -``` - ---- - -## 2. 디자인 패턴 - -### 2.1 현재 적용된 패턴 - -```mermaid -flowchart LR - subgraph Applied["적용됨"] - CQRS[CQRS Pattern] - BUILDER[Builder Pattern] - ROUTER[Router Pattern] - end - - subgraph Partial["부분 적용"] - FACTORY[Factory Pattern] - VALID[Validation Pattern] - end - - subgraph Needed["추가 필요"] - STATE[State Pattern] - SPEC[Specification Pattern] - STRATEGY[Strategy Pattern] - end -``` - -#### CQRS (Command Query Responsibility Segregation) - -**잘 적용됨**: - -- `UserWordCommandService` / `UserWordQueryService` -- `WordCommandService` / `WordQueryService` -- `TestCommandService` / `TestQueryService` - -**문제점**: 중복 로직이 있는 통합 Service 클래스 존재 - ---- - -### 2.2 적용 가능한 패턴 - -#### State Pattern - 학습 상태 관리 - -**현재 문제**: - -```java -// 상태 전환 로직이 서비스에 혼재 -if (userWord.getRepetitions() >= 5) { - userWord.setStatus("MASTERED"); -} else if (userWord.getRepetitions() >= 2) { - userWord.setStatus("REVIEWING"); -} else { - userWord.setStatus("LEARNING"); -} -``` - -**개선안**: - -```mermaid -stateDiagram-v2 - [*] --> NewState: 생성 - NewState --> LearningState: 학습 시작 - LearningState --> LearningState: 오답 - LearningState --> ReviewingState: 2회 정답 - ReviewingState --> LearningState: 오답 - ReviewingState --> MasteredState: 5회 정답 - MasteredState --> LearningState: 오답 -``` - -```java -public interface WordLearningState { - WordLearningState processAnswer(boolean isCorrect, UserWord userWord); - String getStatusName(); -} - -public class LearningState implements WordLearningState { - @Override - public WordLearningState processAnswer(boolean isCorrect, UserWord userWord) { - if (isCorrect) { - userWord.setRepetitions(userWord.getRepetitions() + 1); - if (userWord.getRepetitions() >= 2) { - return new ReviewingState(); - } - } else { - userWord.setRepetitions(0); - userWord.setEaseFactor(Math.max(1.3, userWord.getEaseFactor() - 0.2)); - } - return this; - } - - @Override - public String getStatusName() { - return "LEARNING"; - } -} -``` - ---- - -#### Strategy Pattern - 검증 전략 - -```java -public interface ValidationStrategy { - boolean validate(String value); - String getErrorMessage(); -} - -public class LevelValidationStrategy implements ValidationStrategy { - private static final Set VALID_LEVELS = - Set.of("BEGINNER", "INTERMEDIATE", "ADVANCED"); - - @Override - public boolean validate(String value) { - return VALID_LEVELS.contains(value); - } - - @Override - public String getErrorMessage() { - return "Level must be one of: " + String.join(", ", VALID_LEVELS); - } -} -``` - ---- - -#### Specification Pattern - 복잡한 쿼리 - -```java -public interface UserWordSpecification { - QueryConditional toQueryConditional(String userId); -} - -public class BookmarkedSpecification implements UserWordSpecification { - @Override - public QueryConditional toQueryConditional(String userId) { - return QueryConditional.keyEqualTo( - Key.builder().partitionValue("USER#" + userId + "#BOOKMARK").build()); - } -} - -public class ReviewDueSpecification implements UserWordSpecification { - private final String date; - - @Override - public QueryConditional toQueryConditional(String userId) { - return QueryConditional.sortLessThanOrEqualTo( - Key.builder() - .partitionValue("USER#" + userId + "#REVIEW") - .sortValue("DATE#" + date) - .build()); - } -} - -// 사용 -public PaginatedResult findBySpec(UserWordSpecification spec, String userId, int limit) { - QueryConditional conditional = spec.toQueryConditional(userId); - // ... -} -``` - ---- - -## 3. 성능 최적화 - -### 3.1 DynamoDB 쿼리 최적화 - -#### N+1 쿼리 문제 - -```mermaid -flowchart LR - subgraph Current["현재 (비효율)"] - A1[100개 UserWord 조회] --> B1[Word 1 조회] - A1 --> B2[Word 2 조회] - A1 --> B3[...] - A1 --> B100[Word 100 조회] - end - - subgraph Improved["개선 (효율)"] - A2[100개 UserWord 조회] --> C[BatchGetItem
100개 Word 한번에] - end - - Current -->|100 RCU| DB1[(DynamoDB)] - Improved -->|5-10 RCU| DB2[(DynamoDB)] -``` - -**문제 코드** (`StatsService.java`): - -```java -// N+1 문제: 각 UserWord마다 Word 개별 조회 -allUserWords.stream().map(uw -> { - wordRepository.findById(uw.getWordId()).ifPresent(word -> { - // ... - }); -}) -``` - -**개선안**: - -```java -// BatchGetItem 사용 -List wordIds = allUserWords.stream() - .map(UserWord::getWordId) - .collect(Collectors.toList()); - -Map wordMap = wordRepository.findByIds(wordIds).stream() - .collect(Collectors.toMap(Word::getWordId, w -> w)); - -// O(1) 조회 -allUserWords.stream().forEach(uw -> { - Word word = wordMap.get(uw.getWordId()); - if (word != null) { - // ... - } -}); -``` - -**Repository 메서드 추가**: - -```java -public List findByIds(List wordIds) { - if (wordIds == null || wordIds.isEmpty()) { - return Collections.emptyList(); - } - - List keys = wordIds.stream() - .map(id -> Key.builder() - .partitionValue("WORD#" + id) - .sortValue("METADATA") - .build()) - .collect(Collectors.toList()); - - ReadBatch readBatch = ReadBatch.builder(Word.class) - .mappedTableResource(table) - .addGetItem(keys.toArray(new Key[0])) - .build(); - - BatchGetResultPageIterable result = enhancedClient.batchGetItem(r -> r.readBatches(readBatch)); - - return result.resultsForTable(table).stream().collect(Collectors.toList()); -} -``` - ---- - -#### HashSet 활용 - -**문제 코드** (`DailyStudyService.java`): - -```java -// O(n) 검색 -if (!learnedWordIds.contains(word.getWordId())) { - newWordIds.add(word.getWordId()); -} -``` - -**개선안**: - -```java -// O(1) 검색 -Set learnedWordIdSet = new HashSet<>(learnedWordIds); -Set newWordIdSet = new HashSet<>(); - -for (Word word : wordPage.getItems()) { - if (!learnedWordIdSet.contains(word.getWordId()) - && !newWordIdSet.contains(word.getWordId())) { - newWordIdSet.add(word.getWordId()); - } -} -``` - ---- - -### 3.2 캐싱 전략 - -```mermaid -flowchart TB - subgraph Cache_Layer["캐시 레이어"] - WORD_CACHE[Word Cache
TTL: 1시간] - STATS_CACHE[Stats Cache
TTL: 5분] - ROOM_CACHE[Room Cache
TTL: 2분] - end - - subgraph Lambda["Lambda"] - SVC[Services] - end - - subgraph DynamoDB - DB[(DynamoDB)] - end - - SVC -->|Cache Miss| DB - SVC -->|Cache Hit| WORD_CACHE - SVC -->|Cache Hit| STATS_CACHE - SVC -->|Cache Hit| ROOM_CACHE - DB -->|Store| Cache_Layer -``` - -#### Word 데이터 캐싱 - -```java -public class WordCache { - private static final LoadingCache> CACHE = - CacheBuilder.newBuilder() - .expireAfterWrite(1, TimeUnit.HOURS) - .maximumSize(10000) - .build(new CacheLoader>() { - @Override - public Optional load(String wordId) { - return wordRepository.findById(wordId); - } - }); - - private final WordRepository wordRepository; - - public Optional get(String wordId) { - try { - return CACHE.get(wordId); - } catch (ExecutionException e) { - return wordRepository.findById(wordId); - } - } - - public void invalidate(String wordId) { - CACHE.invalidate(wordId); - } -} -``` - -**예상 효과**: - -- DynamoDB RCU 30-40% 감소 -- 응답 시간 50-70% 단축 - ---- - -#### 통계 캐싱 - -```java -public class StatsCache { - private static final Map CACHE = new ConcurrentHashMap<>(); - private static final long TTL_MS = 5 * 60 * 1000; // 5분 - - public Map getOrCompute(String userId, Supplier> compute) { - CachedStats cached = CACHE.get(userId); - - if (cached != null && !cached.isExpired()) { - return cached.getStats(); - } - - Map stats = compute.get(); - CACHE.put(userId, new CachedStats(stats)); - return stats; - } - - private static class CachedStats { - private final Map stats; - private final long timestamp; - - boolean isExpired() { - return System.currentTimeMillis() - timestamp > TTL_MS; - } - } -} -``` - ---- - -### 3.3 콜드 스타트 최적화 - -```mermaid -flowchart TB - subgraph Current["현재"] - H1[Handler 생성] --> S1[Service 생성] - S1 --> R1[Repository 생성] - R1 --> C1[DynamoDB Client 생성] - end - - subgraph Improved["개선"] - CONTAINER[ServiceContainer
Singleton] - H2[Handler] --> CONTAINER - CONTAINER --> S2[Services] - S2 --> R2[Repositories] - R2 --> C2[Shared Client] - end -``` - -**개선안**: ServiceContainer Singleton - -```java -public class ServiceContainer { - private static final ServiceContainer INSTANCE = new ServiceContainer(); - - private final UserWordCommandService userWordCommandService; - private final UserWordQueryService userWordQueryService; - private final WordQueryService wordQueryService; - private final TestCommandService testCommandService; - // ... - - private ServiceContainer() { - this.userWordCommandService = new UserWordCommandService(); - this.userWordQueryService = new UserWordQueryService(); - this.wordQueryService = new WordQueryService(); - this.testCommandService = new TestCommandService(); - } - - public static ServiceContainer getInstance() { - return INSTANCE; - } - - public UserWordCommandService getUserWordCommandService() { - return userWordCommandService; - } - // ... getters -} - -// Handler에서 사용 -public class UserWordHandler { - private final UserWordCommandService commandService; - private final UserWordQueryService queryService; - - public UserWordHandler() { - ServiceContainer container = ServiceContainer.getInstance(); - this.commandService = container.getUserWordCommandService(); - this.queryService = container.getUserWordQueryService(); - } -} -``` - ---- - -### 3.4 배치 처리 개선 - -#### Word 배치 저장 - -```java -public void saveBatch(List words) { - if (words == null || words.isEmpty()) { - return; - } - - // DynamoDB BatchWriteItem 제한: 25개 - List> batches = Lists.partition(words, 25); - - for (List batch : batches) { - WriteBatch.Builder writeBatchBuilder = WriteBatch.builder(Word.class) - .mappedTableResource(table); - - for (Word word : batch) { - writeBatchBuilder.addPutItem(word); - } - - enhancedClient.batchWriteItem(r -> r.writeBatches(writeBatchBuilder.build())); - } -} -``` - ---- - -## 4. 코드 품질 - -### 4.1 예외 처리 표준화 - -```mermaid -flowchart TB - subgraph Exceptions["예외 계층"] - BASE[BaseException] - BASE --> ENTITY[EntityNotFoundException] - BASE --> VALID[ValidationException] - BASE --> AUTH[AuthorizationException] - BASE --> DATA[DataAccessException] - end - - subgraph Handler["HandlerRouter"] - CATCH[예외 처리] - CATCH -->|EntityNotFoundException| R404[404 Not Found] - CATCH -->|ValidationException| R400[400 Bad Request] - CATCH -->|AuthorizationException| R403[403 Forbidden] - CATCH -->|DataAccessException| R500[500 Internal Error] - end -``` - -**예외 클래스 정의**: - -```java -public abstract class BaseException extends RuntimeException { - private final String errorCode; - - protected BaseException(String errorCode, String message) { - super(message); - this.errorCode = errorCode; - } - - public String getErrorCode() { - return errorCode; - } -} - -public class EntityNotFoundException extends BaseException { - public EntityNotFoundException(String entity, String id) { - super("NOT_FOUND", String.format("%s not found: %s", entity, id)); - } -} - -public class ValidationException extends BaseException { - public ValidationException(String message) { - super("VALIDATION_ERROR", message); - } -} -``` - -**HandlerRouter에서 처리**: - -```java -private APIGatewayProxyResponseEvent handleException(Exception e) { - if (e instanceof EntityNotFoundException) { - return createResponse(404, ApiResponse.error(e.getMessage())); - } - if (e instanceof ValidationException) { - return createResponse(400, ApiResponse.error(e.getMessage())); - } - if (e instanceof AuthorizationException) { - return createResponse(403, ApiResponse.error(e.getMessage())); - } - - logger.error("Unexpected error", e); - return createResponse(500, ApiResponse.error("Internal server error")); -} -``` - ---- - -### 4.2 RequestValidator 활용 확대 - -```java -public class RequestValidator { - private final List errors = new ArrayList<>(); - - public static RequestValidator create() { - return new RequestValidator(); - } - - public RequestValidator requireNotEmpty(String value, String fieldName) { - if (value == null || value.trim().isEmpty()) { - errors.add(fieldName + " is required"); - } - return this; - } - - public RequestValidator requireInRange(Integer value, int min, int max, String fieldName) { - if (value != null && (value < min || value > max)) { - errors.add(fieldName + " must be between " + min + " and " + max); - } - return this; - } - - public RequestValidator requireValidEnum(String value, Class> enumClass, String fieldName) { - if (value != null) { - boolean valid = Arrays.stream(enumClass.getEnumConstants()) - .anyMatch(e -> e.name().equals(value)); - if (!valid) { - errors.add(fieldName + " must be one of: " + - Arrays.toString(enumClass.getEnumConstants())); - } - } - return this; - } - - public ValidationResult build() { - return new ValidationResult(errors); - } -} - -// 사용 예시 -private APIGatewayProxyResponseEvent updateUserWord(APIGatewayProxyRequestEvent request) { - String userId = getQueryParam(request, "userId"); - String wordId = getPathParam(request, "wordId"); - String difficulty = getQueryParam(request, "difficulty"); - - ValidationResult validation = RequestValidator.create() - .requireNotEmpty(userId, "userId") - .requireNotEmpty(wordId, "wordId") - .requireValidEnum(difficulty, Difficulty.class, "difficulty") - .build(); - - if (validation.isInvalid()) { - return createResponse(400, ApiResponse.error(validation.getFirstError())); - } - - // 비즈니스 로직 -} -``` - ---- - -### 4.3 로깅 개선 - -#### 구조화된 로깅 - -```java -public class StructuredLogger { - private static final Gson GSON = new Gson(); - private final Logger logger; - - public StructuredLogger(Class clazz) { - this.logger = LoggerFactory.getLogger(clazz); - } - - public void info(String event, Map data) { - if (logger.isInfoEnabled()) { - Map log = new HashMap<>(data); - log.put("event", event); - log.put("timestamp", Instant.now().toString()); - logger.info("{}", GSON.toJson(log)); - } - } - - public void error(String event, Map data, Throwable t) { - Map log = new HashMap<>(data); - log.put("event", event); - log.put("timestamp", Instant.now().toString()); - log.put("errorType", t.getClass().getSimpleName()); - log.put("errorMessage", t.getMessage()); - logger.error("{}", GSON.toJson(log), t); - } -} - -// 사용 -private static final StructuredLogger slog = new StructuredLogger(UserWordService.class); - -public UserWord updateUserWord(String userId, String wordId, boolean isCorrect) { - // ... - slog.info("user_word_updated", Map.of( - "userId", userId, - "wordId", wordId, - "isCorrect", isCorrect, - "newStatus", userWord.getStatus() - )); - return userWord; -} -``` - ---- - -## 5. 개선 우선순위 및 일정 - -### 5.1 높음 우선순위 (즉시 적용) - -| 항목 | 영향도 | 예상 소요 | -|----------------------------------------------|-----|-------| -| Enum 도입 (StudyLevel, Difficulty, WordStatus) | 높음 | 4시간 | -| N+1 쿼리 최적화 (BatchGetItem, HashSet) | 높음 | 2시간 | -| 커스텀 예외 클래스 | 중간 | 1시간 | - -### 5.2 중간 우선순위 (중기 개선) - -| 항목 | 영향도 | 예상 소요 | -|------------------------------------|-----|-------| -| Factory Pattern (UserWord, Word) | 중간 | 2시간 | -| Word 캐싱 (Guava LoadingCache) | 높음 | 3시간 | -| 메서드 추출 (StatsService, TestService) | 중간 | 3시간 | -| ServiceContainer Singleton | 중간 | 2시간 | - -### 5.3 낮음 우선순위 (장기 개선) - -| 항목 | 영향도 | 예상 소요 | -|-----------------------------------|-----|-------| -| State Pattern (WordLearningState) | 낮음 | 5시간 | -| Specification Pattern (쿼리 추상화) | 낮음 | 4시간 | -| 구조화 로깅 | 낮음 | 2시간 | -| RequestValidator 확대 적용 | 낮음 | 2시간 | - ---- - -## 6. 예상 효과 - -```mermaid -flowchart LR - subgraph Before["개선 전"] - B1[DynamoDB RCU: 100%] - B2[응답 시간: 100%] - B3[콜드 스타트: 100%] - B4[코드 중복: 높음] - end - - subgraph After["개선 후"] - A1[DynamoDB RCU: 40-50%] - A2[응답 시간: 30-50%] - A3[콜드 스타트: 70-80%] - A4[코드 중복: 낮음] - end - - Before --> After -``` - -| 지표 | 현재 | 개선 후 | 감소율 | -|--------------|-------|----------|--------| -| DynamoDB RCU | 100 | 40-50 | 50-60% | -| 평균 응답 시간 | 200ms | 80-100ms | 50-60% | -| 콜드 스타트 시간 | 3s | 2-2.5s | 20-30% | -| 코드 라인 수 (중복) | 500+ | 200- | 60%+ | - ---- - -**버전**: 1.0.0 -**최종 업데이트**: 2026-01-09 -**팀**: MZC 2nd Project Team diff --git a/docs/ReadMe.md b/docs/ReadMe.md deleted file mode 100644 index e69de29b..00000000 diff --git a/docs/chatting/CHATTING-GUIDE.md b/docs/chatting/CHATTING-GUIDE.md deleted file mode 100644 index 2dc59e80..00000000 --- a/docs/chatting/CHATTING-GUIDE.md +++ /dev/null @@ -1,1140 +0,0 @@ -# Chatting Server 가이드 문서 - -## 1. 개요 - -### 1.1 목적 - -Chatting Server는 영어 회화 학습 플랫폼의 실시간 채팅 기능을 담당하는 서버리스 마이크로서비스이다. 사용자들이 영어 난이도별 채팅방에 참여하여 실시간으로 대화하고, AI 응답 및 TTS 기능을 활용할 수 -있다. - -### 1.2 주요 기능 - -| 기능 | 설명 | -|-------------|------------------------------------| -| 채팅방 관리 | 생성, 조회, 입장, 퇴장, 삭제 | -| 실시간 메시징 | WebSocket 기반 양방향 통신 | -| 토큰 인증 | REST → WebSocket 전환 시 RoomToken 검증 | -| 난이도별 필터링 | BEGINNER, INTERMEDIATE, ADVANCED | -| AI 응답 | AWS Bedrock 기반 AI 메시지 생성 | -| TTS (음성 합성) | AWS Polly 기반 음성 변환 | -| 비밀방 | BCrypt 암호화 비밀번호 지원 | -| 캐치마인드 게임 | 실시간 그림 맞추기 게임 | - -### 1.3 기술 스택 - -| 구분 | 기술 | -|-----------|------------------------------------| -| Platform | AWS Lambda (Serverless) | -| Language | Java 21 (Eclipse Temurin) | -| Database | AWS DynamoDB (Single Table Design) | -| Real-time | API Gateway WebSocket | -| AI | AWS Bedrock (Claude/Llama) | -| TTS | AWS Polly | -| Storage | AWS S3 (음성 캐시) | - ---- - -## 2. 시스템 아키텍처 - -### 2.1 전체 구조 - -```mermaid -flowchart TB - subgraph Client - APP[Mobile App] - WEB[Web Client] - end - - subgraph AWS_Gateway["API Gateway"] - REST[HTTP API] - WS[WebSocket API] - end - - subgraph Lambda["AWS Lambda"] - ROOM_H[ChatRoomHandler] - MSG_H[ChatMessageHandler] - AI_H[ChatAIHandler] - VOICE_H[ChatVoiceHandler] - WS_CONN[WebSocketConnectHandler] - WS_MSG[WebSocketMessageHandler] - WS_DISC[WebSocketDisconnectHandler] - end - - subgraph Data_Layer["Data Layer"] - DYNAMO[(DynamoDB)] - S3[(S3 Bucket)] - end - - subgraph AWS_AI["AI Services"] - BEDROCK[AWS Bedrock] - POLLY[AWS Polly] - end - - APP --> REST - WEB --> REST - APP --> WS - WEB --> WS - - REST --> ROOM_H - REST --> MSG_H - REST --> AI_H - REST --> VOICE_H - - WS -->|$connect| WS_CONN - WS -->|sendMessage| WS_MSG - WS -->|$disconnect| WS_DISC - - ROOM_H --> DYNAMO - MSG_H --> DYNAMO - WS_CONN --> DYNAMO - WS_MSG --> DYNAMO - WS_DISC --> DYNAMO - - AI_H --> BEDROCK - VOICE_H --> POLLY - VOICE_H --> S3 -``` - -### 2.2 레이어 아키텍처 - -```mermaid -flowchart TB - subgraph Presentation["Presentation Layer"] - HANDLER[Lambda Handlers] - ROUTER[HandlerRouter] - DTO[Request/Response DTOs] - end - - subgraph Application["Application Layer (CQRS)"] - CMD[Command Services] - QRY[Query Services] - end - - subgraph Domain["Domain Layer"] - MODEL[Models] - REPO[Repositories] - end - - subgraph Infrastructure["Infrastructure Layer"] - DYNAMO_CLIENT[DynamoDB Enhanced Client] - S3_CLIENT[S3 Client] - BROADCASTER[WebSocket Broadcaster] - end - - HANDLER --> ROUTER - ROUTER --> CMD - ROUTER --> QRY - CMD --> MODEL - QRY --> MODEL - MODEL --> REPO - REPO --> DYNAMO_CLIENT - HANDLER --> BROADCASTER - BROADCASTER --> WS_API[API Gateway Management API] -``` - -### 2.3 채팅방 생성 흐름 - -```mermaid -sequenceDiagram - participant C as Client - participant GW as API Gateway - participant H as ChatRoomHandler - participant CMD as ChatRoomCommandService - participant REPO as ChatRoomRepository - participant DB as DynamoDB - - C->>GW: POST /rooms - Note over C,GW: {name, level, maxMembers, isPrivate, password} - GW->>H: Lambda Invoke - H->>H: Request Validation - H->>CMD: createRoom(...) - CMD->>CMD: Generate UUID - CMD->>CMD: BCrypt Hash Password (if private) - CMD->>CMD: Build ChatRoom Entity - CMD->>REPO: save(room) - REPO->>DB: PutItem - Note over REPO,DB: PK=ROOM#{roomId}
GSI1PK=ROOMS - DB-->>REPO: Success - REPO-->>CMD: ChatRoom - CMD-->>H: ChatRoom - H-->>GW: 201 Created - GW-->>C: {success: true, data: room} -``` - -### 2.4 채팅방 입장 및 WebSocket 연결 흐름 - -```mermaid -sequenceDiagram - participant C as Client - participant REST as REST API - participant WS as WebSocket API - participant JOIN_H as ChatRoomHandler - participant CONN_H as WebSocketConnectHandler - participant TOKEN_S as RoomTokenService - participant CMD as ChatRoomCommandService - participant CONN_REPO as ConnectionRepository - participant DB as DynamoDB - - Note over C,DB: Phase 1: REST API로 입장 및 토큰 발급 - C->>REST: POST /rooms/{roomId}/join - Note over C,REST: {userId, password?} - REST->>JOIN_H: Lambda Invoke - JOIN_H->>CMD: joinRoom(roomId, userId, password) - CMD->>CMD: Validate Password (BCrypt) - CMD->>CMD: Check Room Capacity - CMD->>CMD: Add User to memberIds - CMD->>TOKEN_S: generateToken(roomId, userId) - TOKEN_S->>DB: Save RoomToken (TTL: 5분) - Note over TOKEN_S,DB: PK=TOKEN#{token} - TOKEN_S-->>CMD: RoomToken - CMD-->>JOIN_H: JoinRoomResponse - JOIN_H-->>C: {room, roomToken, tokenExpiresAt} - - Note over C,DB: Phase 2: WebSocket 연결 (토큰 검증) - C->>WS: $connect?roomToken={token} - WS->>CONN_H: Lambda Invoke - CONN_H->>TOKEN_S: validateToken(token) - TOKEN_S->>DB: GetItem TOKEN#{token} - DB-->>TOKEN_S: RoomToken (or empty) - alt Token Valid - TOKEN_S-->>CONN_H: RoomToken - CONN_H->>CONN_H: Build Connection Entity - CONN_H->>CONN_REPO: save(connection) - CONN_REPO->>DB: PutItem - Note over CONN_REPO,DB: PK=CONN#{connId}
GSI1PK=ROOM#{roomId}
GSI2PK=USER#{userId} - CONN_H-->>C: 200 Connected - else Token Invalid/Expired - TOKEN_S-->>CONN_H: Empty - CONN_H-->>C: 401 Unauthorized - end -``` - -### 2.5 메시지 전송 및 브로드캐스트 흐름 - -```mermaid -sequenceDiagram - participant C as Client - participant WS as WebSocket API - participant MSG_H as WebSocketMessageHandler - participant MSG_S as ChatMessageService - participant ROOM_REPO as ChatRoomRepository - participant CONN_REPO as ConnectionRepository - participant BC as WebSocketBroadcaster - participant DB as DynamoDB - participant OTHERS as Other Clients - - C->>WS: sendMessage - Note over C,WS: {roomId, userId, content, messageType} - WS->>MSG_H: Lambda Invoke - MSG_H->>MSG_H: Parse Payload - MSG_H->>MSG_H: Build ChatMessage Entity - MSG_H->>MSG_S: saveMessage(message) - MSG_S->>DB: PutItem - Note over MSG_S,DB: PK=ROOM#{roomId}
SK=MSG#{timestamp}#{msgId} - MSG_H->>ROOM_REPO: updateLastMessageAt(roomId) - ROOM_REPO->>DB: UpdateItem - - MSG_H->>CONN_REPO: findByRoomId(roomId) - CONN_REPO->>DB: Query GSI1 (ROOM#{roomId}) - DB-->>CONN_REPO: List - CONN_REPO-->>MSG_H: Connections - - MSG_H->>BC: broadcast(connections, payload) - loop Each Connection - BC->>WS: PostToConnection - WS->>OTHERS: Push Message - alt Connection Failed - BC->>BC: Add to failedList - end - end - BC-->>MSG_H: failedConnections - - loop Each Failed Connection - MSG_H->>CONN_REPO: delete(connectionId) - Note over MSG_H,CONN_REPO: Cleanup stale connections - end - - MSG_H-->>C: 200 Message Sent -``` - -### 2.6 WebSocket 연결 해제 흐름 - -```mermaid -sequenceDiagram - participant C as Client - participant WS as WebSocket API - participant DISC_H as WebSocketDisconnectHandler - participant CONN_REPO as ConnectionRepository - participant DB as DynamoDB - - C->>WS: $disconnect - Note over C,WS: Connection closed - WS->>DISC_H: Lambda Invoke - DISC_H->>DISC_H: Extract connectionId - DISC_H->>CONN_REPO: delete(connectionId) - CONN_REPO->>DB: DeleteItem - Note over CONN_REPO,DB: PK=CONN#{connectionId} - DISC_H-->>WS: 200 OK -``` - ---- - -## 3. 데이터 모델 - -### 3.1 ERD (DynamoDB Single Table Design) - -```mermaid -erDiagram - ChatTable ||--o{ ChatRoom : contains - ChatTable ||--o{ ChatMessage : contains - ChatTable ||--o{ Connection : contains - ChatTable ||--o{ RoomToken : contains - - ChatRoom { - string partitionKey "ROOM#{roomId}" - string sortKey "METADATA" - string gsi1PartitionKey "ROOMS" - string gsi1SortKey "level#createdAt" - string roomId "UUID" - string name "방 이름" - string description "설명" - string level "BEGINNER/INTERMEDIATE/ADVANCED" - int currentMembers "현재 인원" - int maxMembers "최대 인원 (기본 6)" - boolean isPrivate "비밀방 여부" - string password "BCrypt 해시" - string createdBy "방장 userId" - string memberIds "참여자 목록" - string createdAt "생성 시각" - string lastMessageAt "마지막 메시지 시각" - } - - ChatMessage { - string partitionKey "ROOM#{roomId}" - string sortKey "MSG#{timestamp}#{messageId}" - string gsi1PartitionKey "USER#{userId}" - string gsi1SortKey "MSG#{timestamp}" - string gsi2PartitionKey "MSG#{messageId}" - string gsi2SortKey "ROOM#{roomId}" - string messageId "UUID" - string roomId "방 ID" - string userId "발신자 ID" - string content "메시지 내용" - string messageType "TEXT/IMAGE/VOICE/AI_RESPONSE" - string maleVoiceKey "S3 음성 키 (남성)" - string femaleVoiceKey "S3 음성 키 (여성)" - string createdAt "전송 시각" - } - - Connection { - string partitionKey "CONN#{connectionId}" - string sortKey "METADATA" - string gsi1PartitionKey "ROOM#{roomId}" - string gsi1SortKey "CONN#{connectionId}" - string gsi2PartitionKey "USER#{userId}" - string gsi2SortKey "CONN#{connectionId}" - string connectionId "WebSocket Connection ID" - string userId "사용자 ID" - string roomId "방 ID" - string connectedAt "연결 시각" - long ttl "자동 만료 (10분)" - } - - RoomToken { - string partitionKey "TOKEN#{token}" - string sortKey "METADATA" - string token "UUID 토큰" - string roomId "방 ID" - string userId "사용자 ID" - string createdAt "발급 시각" - long ttl "자동 만료 (5분)" - } -``` - -### 3.2 테이블 상세 - -#### ChatRoom (채팅방) - -| 필드 | 타입 | 필수 | 설명 | -|----------------|---------|----|----------------------------------| -| PK | String | Y | ROOM#{roomId} | -| SK | String | Y | METADATA | -| GSI1PK | String | Y | ROOMS (전체 조회용) | -| GSI1SK | String | Y | {level}#{createdAt} (정렬) | -| roomId | String | Y | UUID | -| name | String | Y | 채팅방 이름 | -| description | String | N | 설명 | -| level | String | Y | beginner, intermediate, advanced | -| currentMembers | Integer | Y | 현재 참여 인원 | -| maxMembers | Integer | Y | 최대 인원 (기본: 6) | -| isPrivate | Boolean | Y | 비밀방 여부 | -| password | String | N | BCrypt 해시 비밀번호 | -| createdBy | String | Y | 방장 userId | -| memberIds | List | Y | 참여자 userId 목록 | -| createdAt | String | Y | ISO 8601 형식 | -| lastMessageAt | String | Y | 마지막 메시지 시각 | - -#### ChatMessage (채팅 메시지) - -| 필드 | 타입 | 필수 | 설명 | -|----------------|--------|----|---------------------------------| -| PK | String | Y | ROOM#{roomId} | -| SK | String | Y | MSG#{timestamp}#{messageId} | -| GSI1PK | String | Y | USER#{userId} | -| GSI1SK | String | Y | MSG#{timestamp} | -| GSI2PK | String | Y | MSG#{messageId} | -| GSI2SK | String | Y | ROOM#{roomId} | -| messageId | String | Y | UUID | -| roomId | String | Y | 채팅방 ID | -| userId | String | Y | 발신자 ID | -| content | String | Y | 메시지 내용 | -| messageType | String | Y | TEXT, IMAGE, VOICE, AI_RESPONSE | -| maleVoiceKey | String | N | S3 음성 파일 키 (남성) | -| femaleVoiceKey | String | N | S3 음성 파일 키 (여성) | -| createdAt | String | Y | ISO 8601 형식 | - -#### Connection (WebSocket 연결) - -| 필드 | 타입 | 필수 | 설명 | -|--------------|--------|----|----------------------------| -| PK | String | Y | CONN#{connectionId} | -| SK | String | Y | METADATA | -| GSI1PK | String | Y | ROOM#{roomId} | -| GSI1SK | String | Y | CONN#{connectionId} | -| GSI2PK | String | Y | USER#{userId} | -| GSI2SK | String | Y | CONN#{connectionId} | -| connectionId | String | Y | API Gateway Connection ID | -| userId | String | Y | 사용자 ID | -| roomId | String | Y | 채팅방 ID | -| connectedAt | String | Y | 연결 시각 | -| ttl | Long | Y | DynamoDB TTL (10분 후 자동 삭제) | - -#### RoomToken (입장 토큰) - -| 필드 | 타입 | 필수 | 설명 | -|-----------|--------|----|---------------------------| -| PK | String | Y | TOKEN#{token} | -| SK | String | Y | METADATA | -| token | String | Y | UUID 토큰 | -| roomId | String | Y | 채팅방 ID | -| userId | String | Y | 사용자 ID | -| createdAt | String | Y | 발급 시각 | -| ttl | Long | Y | DynamoDB TTL (5분 후 자동 삭제) | - -### 3.3 GSI (Global Secondary Index) 설계 - -```mermaid -flowchart LR - subgraph GSI1["GSI1: 범용 조회"] - direction TB - G1_ROOMS["ROOMS → 전체 방 조회"] - G1_USER["USER#{userId} → 사용자 메시지"] - G1_ROOM_CONN["ROOM#{roomId} → 방별 연결"] - G1_USER_REVIEW["USER#{userId}#REVIEW → 복습 예정"] - end - - subgraph GSI2["GSI2: 보조 조회"] - direction TB - G2_MSG["MSG#{messageId} → 메시지 직접 조회"] - G2_USER_CONN["USER#{userId} → 사용자 연결"] - G2_USER_STATUS["USER#{userId}#STATUS → 상태별"] - end -``` - ---- - -## 4. API 명세 - -### 4.1 채팅방 생성 - -#### POST /rooms - -**Request** - -```json -{ - "name": "English Beginners", - "description": "영어 초보자를 위한 채팅방", - "level": "beginner", - "maxMembers": 6, - "isPrivate": false, - "password": null, - "createdBy": "user123" -} -``` - -**Response (201 Created)** - -```json -{ - "success": true, - "message": "Room created", - "data": { - "roomId": "550e8400-e29b-41d4-a716-446655440000", - "name": "English Beginners", - "description": "영어 초보자를 위한 채팅방", - "level": "beginner", - "currentMembers": 1, - "maxMembers": 6, - "isPrivate": false, - "createdBy": "user123", - "memberIds": ["user123"], - "createdAt": "2026-01-09T10:00:00Z", - "lastMessageAt": "2026-01-09T10:00:00Z" - } -} -``` - -### 4.2 채팅방 목록 조회 - -#### GET /rooms - -**Query Parameters** - -| 파라미터 | 타입 | 필수 | 설명 | -|--------|---------|----|-------------------------------------------| -| level | String | N | 난이도 필터 (beginner, intermediate, advanced) | -| userId | String | N | 사용자 ID (joined 필터 시 필수) | -| joined | String | N | "true"면 가입된 방만 조회 | -| cursor | String | N | 페이징 커서 | -| limit | Integer | N | 페이지 크기 (기본: 10, 최대: 20) | - -**Response (200 OK)** - -```json -{ - "success": true, - "message": "Rooms retrieved", - "data": { - "rooms": [ - { - "roomId": "...", - "name": "English Beginners", - "level": "beginner", - "currentMembers": 3, - "maxMembers": 6, - "isPrivate": false, - "lastMessageAt": "2026-01-09T10:30:00Z" - } - ], - "nextCursor": "eyJQSyI6IlJPT00jLi4uIiwiU0siOiJNRVRBREFUQSJ9", - "hasMore": true - } -} -``` - -### 4.3 채팅방 상세 조회 - -#### GET /rooms/{roomId} - -**Response (200 OK)** - -```json -{ - "success": true, - "message": "Room retrieved", - "data": { - "roomId": "550e8400-e29b-41d4-a716-446655440000", - "name": "English Beginners", - "description": "영어 초보자를 위한 채팅방", - "level": "beginner", - "currentMembers": 3, - "maxMembers": 6, - "isPrivate": false, - "createdBy": "user123", - "memberIds": ["user123", "user456", "user789"], - "createdAt": "2026-01-09T10:00:00Z", - "lastMessageAt": "2026-01-09T10:30:00Z" - } -} -``` - -### 4.4 채팅방 입장 - -#### POST /rooms/{roomId}/join - -**Request** - -```json -{ - "userId": "user456", - "password": "secret123" -} -``` - -**Response (200 OK)** - -```json -{ - "success": true, - "message": "Joined room", - "data": { - "room": { - "roomId": "...", - "name": "English Beginners", - "currentMembers": 4 - }, - "roomToken": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - "tokenExpiresAt": 1704793800 - } -} -``` - -### 4.5 채팅방 퇴장 - -#### POST /rooms/{roomId}/leave - -**Request** - -```json -{ - "userId": "user456" -} -``` - -**Response (200 OK)** - -```json -{ - "success": true, - "message": "Left room", - "data": { - "roomId": "...", - "currentMembers": 3 - } -} -``` - -### 4.6 채팅방 삭제 - -#### DELETE /rooms/{roomId}?userId={userId} - -**Response (200 OK)** - -```json -{ - "success": true, - "message": "Room deleted", - "data": null -} -``` - ---- - -## 캐치마인드 게임 API - -### 4.7 게임 시작 - -#### POST /chat/rooms/{roomId}/game/start - -게임을 시작합니다. 방장만 게임을 시작할 수 있습니다. - -**Request Header** -- `Authorization`: Bearer {token} - -**Response (200 OK)** - -```json -{ - "success": true, - "message": "Game started successfully", - "data": { - "roomId": "550e8400-e29b-41d4-a716-446655440000", - "gameId": "game-uuid-here", - "status": "IN_PROGRESS", - "currentWord": "apple", - "drawerId": "user123", - "startedAt": "2026-01-09T10:00:00Z" - } -} -``` - -### 4.8 게임 중지 - -#### POST /chat/rooms/{roomId}/game/stop - -진행 중인 게임을 중지합니다. 방장만 중지할 수 있습니다. - -**Request Header** -- `Authorization`: Bearer {token} - -**Response (200 OK)** - -```json -{ - "success": true, - "message": "Game stopped successfully", - "data": { - "roomId": "550e8400-e29b-41d4-a716-446655440000", - "gameId": "game-uuid-here", - "status": "STOPPED", - "finalScores": { - "user123": 30, - "user456": 20, - "user789": 10 - } - } -} -``` - -### 4.9 게임 상태 조회 - -#### GET /chat/rooms/{roomId}/game/status - -현재 게임 상태를 조회합니다. - -**Request Header** -- `Authorization`: Bearer {token} - -**Response (200 OK)** - -```json -{ - "success": true, - "message": "Game status retrieved", - "data": { - "roomId": "550e8400-e29b-41d4-a716-446655440000", - "gameId": "game-uuid-here", - "status": "IN_PROGRESS", - "currentRound": 3, - "totalRounds": 5, - "drawerId": "user123", - "timeRemaining": 45, - "scores": { - "user123": 30, - "user456": 20, - "user789": 10 - } - } -} -``` - -**게임 상태 (status)** -- `WAITING`: 게임 대기 중 -- `IN_PROGRESS`: 게임 진행 중 -- `ROUND_END`: 라운드 종료 -- `GAME_END`: 게임 종료 -- `STOPPED`: 강제 중지 - -### 4.10 점수 조회 - -#### GET /chat/rooms/{roomId}/game/scores - -현재 게임의 점수를 조회합니다. - -**Request Header** -- `Authorization`: Bearer {token} - -**Response (200 OK)** - -```json -{ - "success": true, - "message": "Scores retrieved", - "data": { - "roomId": "550e8400-e29b-41d4-a716-446655440000", - "gameId": "game-uuid-here", - "scores": [ - { - "userId": "user123", - "nickname": "Player1", - "score": 30, - "rank": 1 - }, - { - "userId": "user456", - "nickname": "Player2", - "score": 20, - "rank": 2 - }, - { - "userId": "user789", - "nickname": "Player3", - "score": 10, - "rank": 3 - } - ] - } -} -``` - -### 캐치마인드 게임 규칙 - -| 항목 | 설명 | -|-----|-----| -| 최소 인원 | 2명 | -| 라운드당 시간 | 60초 | -| 정답 점수 | 10점 (맞춘 사람) | -| 출제자 점수 | 5점 (누군가 맞추면) | -| 단어 카테고리 | 동물, 음식, 사물, 직업 등 | - -### WebSocket 게임 이벤트 - -게임 진행 중 WebSocket을 통해 실시간 이벤트가 전송됩니다. - -**게임 시작 이벤트** -```json -{ - "type": "GAME_START", - "gameId": "game-uuid", - "drawerId": "user123", - "round": 1 -} -``` - -**정답 이벤트** -```json -{ - "type": "CORRECT_ANSWER", - "userId": "user456", - "word": "apple", - "score": 10 -} -``` - -**라운드 종료 이벤트** -```json -{ - "type": "ROUND_END", - "round": 1, - "answer": "apple", - "scores": {...} -} -``` - -**게임 종료 이벤트** -```json -{ - "type": "GAME_END", - "winner": "user123", - "finalScores": {...} -} -``` - ---- - -### 4.11 WebSocket 엔드포인트 - -#### $connect - -**Query Parameter** - -| 파라미터 | 타입 | 필수 | 설명 | -|-----------|--------|----|--------------------| -| roomToken | String | Y | joinRoom에서 발급받은 토큰 | - -**연결 URL 예시** - -``` -wss://api.example.com/ws?roomToken=a1b2c3d4-e5f6-7890-abcd-ef1234567890 -``` - -#### sendMessage (Action) - -**Payload** - -```json -{ - "action": "sendMessage", - "roomId": "550e8400-e29b-41d4-a716-446655440000", - "userId": "user456", - "content": "Hello everyone!", - "messageType": "TEXT" -} -``` - -**Broadcast Payload (수신)** - -```json -{ - "messageId": "msg-uuid-here", - "roomId": "550e8400-e29b-41d4-a716-446655440000", - "userId": "user456", - "content": "Hello everyone!", - "messageType": "TEXT", - "createdAt": "2026-01-09T10:35:00Z" -} -``` - ---- - -## 5. 비즈니스 규칙 - -### 5.1 채팅방 상태 전이 - -```mermaid -stateDiagram-v2 - [*] --> CREATED: 방 생성 - CREATED --> ACTIVE: 첫 메시지 - ACTIVE --> ACTIVE: 메시지 송수신 - ACTIVE --> EMPTY: 모든 멤버 퇴장 - ACTIVE --> DELETED: 방장 삭제 - EMPTY --> DELETED: 자동 삭제 - DELETED --> [*] -``` - -### 5.2 토큰 상태 전이 - -```mermaid -stateDiagram-v2 - [*] --> ISSUED: joinRoom 호출 - ISSUED --> VALIDATED: WebSocket 연결 성공 - ISSUED --> EXPIRED: TTL 만료 (5분) - VALIDATED --> [*]: 토큰 사용 완료 - EXPIRED --> [*]: 자동 삭제 -``` - -### 5.3 접근 제어 - -| 기능 | 조건 | -|--------------|------------------| -| 방 생성 | 모든 사용자 | -| 방 조회 | 모든 사용자 | -| 방 입장 | 비밀방인 경우 비밀번호 필요 | -| 방 퇴장 | 참여 멤버만 | -| 방 삭제 | 방장(createdBy)만 | -| WebSocket 연결 | 유효한 roomToken 필요 | - -### 5.4 비밀번호 처리 - -```mermaid -flowchart LR - A[Plain Password] -->|BCrypt.hashpw| B[Hashed Password] - B -->|저장| DB[(DynamoDB)] - - C[입력 Password] -->|BCrypt.checkpw| D{일치?} - DB -->|조회| D - D -->|Yes| E[입장 허용] - D -->|No| F[403 Forbidden] -``` - -### 5.5 제한 사항 - -| 항목 | 제한 | -|-----------------|-----------------| -| 최대 참여 인원 | 기본 6명, 최대 설정 가능 | -| 방 목록 페이지 크기 | 최대 20 | -| RoomToken 유효 시간 | 5분 (300초) | -| Connection TTL | 10분 (600초) | -| 비밀번호 | BCrypt 해시 | - ---- - -## 6. 에러 코드 - -### 6.1 HTTP 에러 - -| HTTP Code | 설명 | 예시 | -|-----------|--------|-----------------| -| 400 | 잘못된 요청 | 필수 파라미터 누락 | -| 401 | 인증 실패 | 유효하지 않은 토큰 | -| 403 | 권한 없음 | 비밀번호 불일치, 방장 아님 | -| 404 | 리소스 없음 | 존재하지 않는 방 | -| 409 | 충돌 | 정원 초과 | -| 500 | 서버 오류 | 내부 오류 | - -### 6.2 에러 응답 형식 - -```json -{ - "success": false, - "error": "Room not found" -} -``` - ---- - -## 7. 환경 설정 - -### 7.1 환경 변수 (template.yaml) - -```yaml -Environment: - Variables: - CHAT_TABLE_NAME: ChatTable - CHAT_BUCKET_NAME: group2-englishstudy - ROOM_TOKEN_TTL_SECONDS: "300" - AWS_REGION_NAME: ap-northeast-2 -``` - -### 7.2 DynamoDB 테이블 설정 - -```yaml -ChatTable: - Type: AWS::DynamoDB::Table - Properties: - TableName: ChatTable - BillingMode: PAY_PER_REQUEST - AttributeDefinitions: - - AttributeName: PK - AttributeType: S - - AttributeName: SK - AttributeType: S - - AttributeName: GSI1PK - AttributeType: S - - AttributeName: GSI1SK - AttributeType: S - - AttributeName: GSI2PK - AttributeType: S - - AttributeName: GSI2SK - AttributeType: S - KeySchema: - - AttributeName: PK - KeyType: HASH - - AttributeName: SK - KeyType: RANGE - GlobalSecondaryIndexes: - - IndexName: GSI1 - KeySchema: - - AttributeName: GSI1PK - KeyType: HASH - - AttributeName: GSI1SK - KeyType: RANGE - Projection: - ProjectionType: ALL - - IndexName: GSI2 - KeySchema: - - AttributeName: GSI2PK - KeyType: HASH - - AttributeName: GSI2SK - KeyType: RANGE - Projection: - ProjectionType: ALL - TimeToLiveSpecification: - AttributeName: ttl - Enabled: true -``` - -### 7.3 API Gateway WebSocket 설정 - -```yaml -WebSocketApi: - Type: AWS::ApiGatewayV2::Api - Properties: - Name: ChatWebSocketApi - ProtocolType: WEBSOCKET - RouteSelectionExpression: "$request.body.action" - -Routes: - - $connect → WebSocketConnectHandler - - $disconnect → WebSocketDisconnectHandler - - sendMessage → WebSocketMessageHandler -``` - ---- - -## 8. 프로젝트 구조 - -``` -domain/chatting/ -├── handler/ -│ ├── ChatRoomHandler.java # REST API - 채팅방 CRUD -│ ├── ChatMessageHandler.java # REST API - 메시지 조회 -│ ├── ChatAIHandler.java # REST API - AI 응답 -│ ├── ChatVoiceHandler.java # REST API - TTS -│ └── websocket/ -│ ├── WebSocketConnectHandler.java # $connect -│ ├── WebSocketMessageHandler.java # sendMessage -│ └── WebSocketDisconnectHandler.java # $disconnect -│ -├── service/ -│ ├── ChatRoomCommandService.java # 방 변경 (CQRS Command) -│ ├── ChatRoomQueryService.java # 방 조회 (CQRS Query) -│ ├── ChatMessageService.java # 메시지 저장/조회 -│ ├── RoomTokenService.java # 토큰 발급/검증 -│ └── BedrockService.java # AI 응답 생성 -│ -├── repository/ -│ ├── ChatRoomRepository.java # 채팅방 데이터 접근 -│ ├── ChatMessageRepository.java # 메시지 데이터 접근 -│ ├── ConnectionRepository.java # WebSocket 연결 데이터 접근 -│ └── RoomTokenRepository.java # 토큰 데이터 접근 -│ -├── model/ -│ ├── ChatRoom.java # 채팅방 엔티티 -│ ├── ChatMessage.java # 메시지 엔티티 -│ ├── Connection.java # 연결 엔티티 -│ └── RoomToken.java # 토큰 엔티티 -│ -└── dto/ - ├── request/ - │ ├── CreateRoomRequest.java - │ ├── JoinRoomRequest.java - │ ├── LeaveRoomRequest.java - │ └── SendMessageRequest.java - └── response/ - └── JoinRoomResponse.java -``` - ---- - -## 9. 테스트 - -### 9.1 테스트 시나리오 - -```mermaid -flowchart TB - subgraph Unit["단위 테스트"] - U1[Service Layer] - U2[Repository Layer] - U3[Model Layer] - end - - subgraph Integration["통합 테스트"] - I1[Handler + Service] - I2[WebSocket Flow] - I3[DynamoDB Integration] - end - - subgraph E2E["E2E 테스트"] - E1[방 생성 → 입장 → 메시지 → 퇴장] - E2[WebSocket 연결 → 브로드캐스트] - end -``` - -### 9.2 로컬 테스트 - -```bash -# SAM Local 실행 -sam local start-api - -# WebSocket 테스트 (wscat) -wscat -c "wss://localhost:3001?roomToken=test-token" -``` - ---- - -## 10. 구현 현황 - -### Phase 1 - 핵심 기능 (완료) - -- [x] 채팅방 CRUD -- [x] REST API Handler -- [x] CQRS 패턴 적용 -- [x] 커서 기반 페이징 - -### Phase 2 - 실시간 통신 (완료) - -- [x] WebSocket 연결/해제 -- [x] 메시지 브로드캐스트 -- [x] RoomToken 인증 -- [x] Connection 관리 - -### Phase 3 - 고급 기능 (완료) - -- [x] 비밀방 (BCrypt) -- [x] 난이도별 필터링 -- [x] AI 응답 (Bedrock) -- [x] TTS (Polly) - -### Phase 4 - 최적화 (진행 중) - -- [ ] 연결 상태 모니터링 -- [ ] 메시지 캐싱 -- [ ] 알림 기능 (SNS) - ---- - -**버전**: 1.0.0 -**최종 업데이트**: 2026-01-09 -**팀**: MZC 2nd Project Team diff --git a/docs/grammar-api-specification.md b/docs/grammar-api-specification.md deleted file mode 100644 index dab8d63e..00000000 --- a/docs/grammar-api-specification.md +++ /dev/null @@ -1,401 +0,0 @@ -# Grammar API 명세서 - -## 서비스 개요 - -영어 문법 체크 및 AI 대화 연습 서비스입니다. - -### 주요 기능 -| 기능 | 설명 | -|------|------| -| **문법 체크** | 사용자가 입력한 영어 문장의 문법 오류를 분석하고 교정 | -| **AI 대화 연습** | AI와 1:1 영어 대화 연습 (문법 체크 + 대화 응답 + 학습 팁) | -| **세션 관리** | 대화 세션 목록 조회, 상세 조회, 삭제 | - -### 레벨 시스템 -| 레벨 | 설명 | -|------|------| -| `BEGINNER` | 초급 - 한국어 번역 포함, 쉬운 설명 | -| `INTERMEDIATE` | 중급 - 영어 위주 설명 | -| `ADVANCED` | 고급 - 상세한 문법 규칙 설명 | - ---- - -## Base URL - -``` -https://gc8l9ijhzc.execute-api.ap-northeast-2.amazonaws.com/dev -``` - -## 인증 - -모든 API는 **Cognito 인증**이 필요합니다. - -``` -Authorization: Bearer {ID_TOKEN} -``` - ---- - -## API 목록 - -| Method | Endpoint | 설명 | -|--------|----------|------| -| POST | `/grammar/check` | 문법 체크 | -| POST | `/grammar/conversation` | AI 대화 연습 | -| GET | `/grammar/sessions` | 세션 목록 조회 | -| GET | `/grammar/sessions/{sessionId}` | 세션 상세 조회 | -| DELETE | `/grammar/sessions/{sessionId}` | 세션 삭제 | - ---- - -## 1. 문법 체크 API - -영어 문장의 문법 오류를 분석하고 교정합니다. - -### Request - -``` -POST /grammar/check -Content-Type: application/json -Authorization: Bearer {TOKEN} -``` - -**Body:** -```json -{ - "sentence": "I goed to school yesterday.", - "level": "BEGINNER" -} -``` - -| 필드 | 타입 | 필수 | 설명 | -|------|------|------|------| -| sentence | string | ✅ | 검사할 영어 문장 | -| level | string | ❌ | 레벨 (BEGINNER/INTERMEDIATE/ADVANCED), 기본값: BEGINNER | - -### Response (성공) - -```json -{ - "isSuccess": true, - "message": "Grammar checked successfully", - "data": { - "originalSentence": "I goed to school yesterday.", - "correctedSentence": "I went to school yesterday.", - "score": 80, - "isCorrect": false, - "errors": [ - { - "type": "VERB_TENSE", - "original": "goed", - "corrected": "went", - "explanation": "The verb 'go' in the past tense should be 'went'. In Korean, this would be '갔어'.", - "startIndex": 2, - "endIndex": 6 - } - ], - "feedback": "Good try! The past tense of 'go' is 'went'. Keep practicing!" - } -} -``` - -| 필드 | 타입 | 설명 | -|------|------|------| -| originalSentence | string | 원본 문장 | -| correctedSentence | string | 교정된 문장 | -| score | number | 문법 점수 (0-100) | -| isCorrect | boolean | 문법 오류 없음 여부 | -| errors | array | 오류 목록 | -| feedback | string | 전체 피드백 메시지 | - -### Error Types (오류 타입) - -| 타입 | 한국어 | 설명 | -|------|--------|------| -| VERB_TENSE | 동사 시제 | 시제 오류 | -| SUBJECT_VERB_AGREEMENT | 주어-동사 일치 | 주어와 동사 수 일치 오류 | -| ARTICLE | 관사 | a/an/the 오류 | -| PREPOSITION | 전치사 | 전치사 오류 | -| WORD_ORDER | 어순 | 단어 순서 오류 | -| PLURAL_SINGULAR | 단/복수 | 단수/복수 오류 | -| PRONOUN | 대명사 | 대명사 오류 | -| SPELLING | 철자 | 철자 오류 | -| PUNCTUATION | 구두점 | 구두점 오류 | -| WORD_CHOICE | 어휘 선택 | 단어 선택 오류 | -| SENTENCE_STRUCTURE | 문장 구조 | 문장 구조 오류 | -| OTHER | 기타 | 기타 오류 | - ---- - -## 2. AI 대화 연습 API - -AI와 대화하면서 영어를 연습합니다. 사용자의 메시지에 대해 문법 체크 + AI 응답 + 학습 팁을 제공합니다. - -### Request - -``` -POST /grammar/conversation -Content-Type: application/json -Authorization: Bearer {TOKEN} -``` - -**Body:** -```json -{ - "sessionId": "550e8400-e29b-41d4-a716-446655440000", - "message": "I wants to learn English. Can you help me?", - "level": "BEGINNER" -} -``` - -| 필드 | 타입 | 필수 | 설명 | -|------|------|------|------| -| sessionId | string | ❌ | 세션 ID (없으면 새 세션 생성) | -| message | string | ✅ | 사용자 메시지 | -| level | string | ❌ | 레벨, 기본값: BEGINNER | - -### Response (성공) - -```json -{ - "isSuccess": true, - "message": "Conversation generated successfully", - "data": { - "sessionId": "550e8400-e29b-41d4-a716-446655440000", - "grammarCheck": { - "originalSentence": "I wants to learn English. Can you help me?", - "correctedSentence": "I want to learn English. Can you help me?", - "score": 90, - "isCorrect": false, - "errors": [ - { - "type": "SUBJECT_VERB_AGREEMENT", - "original": "wants", - "corrected": "want", - "explanation": "With 'I', use 'want' not 'wants'. 'Wants' is for he/she/it." - } - ], - "feedback": "Small mistake with subject-verb agreement. Keep going!" - }, - "aiResponse": "Of course! I'd be happy to help you learn English. What would you like to practice today? We can talk about any topic you're interested in.", - "conversationTip": "Try to use simple sentences first. For example: 'I like music.' or 'I want to travel.'" - } -} -``` - -| 필드 | 타입 | 설명 | -|------|------|------| -| sessionId | string | 세션 ID (다음 요청에 포함하면 대화 이어가기) | -| grammarCheck | object | 문법 체크 결과 (위 문법 체크 API 응답과 동일) | -| aiResponse | string | AI의 대화 응답 | -| conversationTip | string | 학습 팁 | - -### 대화 이어가기 - -세션 ID를 포함하면 이전 대화 컨텍스트를 유지하며 대화를 이어갑니다. - -```json -{ - "sessionId": "550e8400-e29b-41d4-a716-446655440000", - "message": "I like to watch movies.", - "level": "BEGINNER" -} -``` - ---- - -## 3. 세션 목록 조회 API - -사용자의 대화 세션 목록을 조회합니다. - -### Request - -``` -GET /grammar/sessions?limit=10&cursor={cursor} -Authorization: Bearer {TOKEN} -``` - -| 파라미터 | 타입 | 필수 | 설명 | -|----------|------|------|------| -| limit | number | ❌ | 조회 개수 (기본: 10, 최대: 50) | -| cursor | string | ❌ | 페이지네이션 커서 | - -### Response (성공) - -```json -{ - "isSuccess": true, - "message": "Sessions retrieved successfully", - "data": { - "sessions": [ - { - "sessionId": "550e8400-e29b-41d4-a716-446655440000", - "level": "BEGINNER", - "topic": null, - "messageCount": 5, - "lastMessage": "I like to watch movies.", - "createdAt": "2026-01-13T10:30:00Z", - "updatedAt": "2026-01-13T11:00:00Z" - } - ], - "nextCursor": "eyJQSyI6IkdTRVNT...", - "hasMore": true - } -} -``` - ---- - -## 4. 세션 상세 조회 API - -특정 세션의 상세 정보와 대화 기록을 조회합니다. - -### Request - -``` -GET /grammar/sessions/{sessionId}?messageLimit=50 -Authorization: Bearer {TOKEN} -``` - -| 파라미터 | 타입 | 필수 | 설명 | -|----------|------|------|------| -| sessionId | path | ✅ | 세션 ID | -| messageLimit | query | ❌ | 메시지 조회 개수 (기본: 50, 최대: 100) | - -### Response (성공) - -```json -{ - "isSuccess": true, - "message": "Session detail retrieved successfully", - "data": { - "session": { - "sessionId": "550e8400-e29b-41d4-a716-446655440000", - "level": "BEGINNER", - "messageCount": 5, - "createdAt": "2026-01-13T10:30:00Z", - "updatedAt": "2026-01-13T11:00:00Z" - }, - "messages": [ - { - "messageId": "msg-001", - "role": "USER", - "content": "I wants to learn English.", - "correctedContent": "I want to learn English.", - "grammarScore": 90, - "createdAt": "2026-01-13T10:30:00Z" - }, - { - "messageId": "msg-002", - "role": "ASSISTANT", - "content": "Of course! I'd be happy to help you learn English.", - "createdAt": "2026-01-13T10:30:05Z" - } - ] - } -} -``` - ---- - -## 5. 세션 삭제 API - -특정 세션을 삭제합니다. - -### Request - -``` -DELETE /grammar/sessions/{sessionId} -Authorization: Bearer {TOKEN} -``` - -### Response (성공) - -```json -{ - "isSuccess": true, - "message": "Session deleted successfully", - "data": null -} -``` - ---- - -## 에러 응답 - -### 공통 에러 형식 - -```json -{ - "code": "GRAMMAR.GRAMMAR_001", - "message": "유효하지 않은 문장입니다", - "status": 400, - "details": { - "sentence": "" - } -} -``` - -### 에러 코드 목록 - -| 코드 | 메시지 | HTTP Status | -|------|--------|-------------| -| GRAMMAR_001 | 유효하지 않은 문장입니다 | 400 | -| GRAMMAR_002 | 문법 체크에 실패했습니다 | 500 | -| GRAMMAR_003 | 유효하지 않은 레벨입니다 | 400 | -| GRAMMAR_004 | AI 서비스 호출에 실패했습니다 | 502 | -| GRAMMAR_005 | AI 응답 파싱에 실패했습니다 | 500 | -| GRAMMAR_006 | 세션을 찾을 수 없습니다 | 404 | -| GRAMMAR_007 | 세션이 만료되었습니다 | 410 | - ---- - -## 사용 시나리오 - -### 시나리오 1: 문법 체크만 사용 - -``` -1. POST /grammar/check - 문장 검사 -2. 결과 표시 (오류, 교정, 피드백) -``` - -### 시나리오 2: AI 대화 연습 - -``` -1. POST /grammar/conversation (sessionId 없이) - 새 대화 시작 -2. 응답에서 sessionId 저장 -3. POST /grammar/conversation (sessionId 포함) - 대화 이어가기 -4. 반복... -``` - -### 시나리오 3: 대화 기록 관리 - -``` -1. GET /grammar/sessions - 세션 목록 조회 -2. GET /grammar/sessions/{id} - 특정 세션 대화 기록 조회 -3. DELETE /grammar/sessions/{id} - 세션 삭제 -``` - ---- - -## UI 구현 참고사항 - -### 문법 체크 결과 표시 -- `errors` 배열의 `startIndex`, `endIndex`를 사용하여 원문에서 오류 부분 하이라이트 -- `score`로 점수 표시 (프로그레스 바 등) -- `isCorrect`가 true면 "Perfect!" 메시지 표시 - -### 대화 UI -- 채팅 형식 UI 권장 -- 사용자 메시지 위에 문법 체크 결과 표시 (말풍선 위 작은 배지 등) -- AI 응답 아래에 `conversationTip` 표시 - -### 레벨 선택 -- 처음 사용자는 BEGINNER로 시작 -- 설정에서 레벨 변경 가능하게 구현 - ---- - -## 연락처 - -백엔드 관련 문의: [담당자 연락처] diff --git a/docs/user/USER-GUIDE.md b/docs/user/USER-GUIDE.md deleted file mode 100644 index 9eaca193..00000000 --- a/docs/user/USER-GUIDE.md +++ /dev/null @@ -1,565 +0,0 @@ -# User Domain 가이드 문서 - -## 1. 개요 - -### 1.1 목적 - -User Server는 영어 회화 학습 플랫폼의 사용자 인증 및 프로필 관리를 담당하는 서버리스 마이크로서비스이다. AWS Cognito를 활용하여 안전한 인증 체계를 제공하고, 사용자별 학습 데이터 및 개인 설정을 -관리한다. - -### 1.2 주요 기능 - -| 기능 | 설명 | -|--------|--------------------------------------------------| -| 회원가입 | Cognito 기반 이메일 회원가입 | -| 이메일 인증 | Cognito 자동 인증 코드 발송 | -| 로그인 | JWT 토큰 발급 (IdToken, AccessToken, RefreshToken) | -| 프로필 조회 | 인증된 사용자 정보 조회 | -| 기본값 설정 | PreSignUp 트리거로 nickname, level, profileUrl 자동 설정 | - -### 1.3 기술 스택 - -| 구분 | 기술 | -|----------------|------------------------------------| -| Platform | AWS Lambda (Serverless) | -| Language | Java 21 (Eclipse Temurin) | -| Authentication | AWS Cognito User Pool | -| Authorization | Cognito Built-in Authorizer | -| Database | AWS DynamoDB (Single Table Design) | -| Storage | AWS S3 (프로필 이미지) | - ---- - -## 2. 시스템 아키텍처 - -### 2.1 전체 구조 - -```mermaid -flowchart TB - subgraph Client - WEB[Web Client] - end - - subgraph AWS_Cognito["AWS Cognito"] - UP[User Pool] - UPC[User Pool Client] - TRIGGER[PreSignUp Trigger] - end - - subgraph AWS_Gateway["API Gateway"] - REST[REST API] - AUTH[Cognito Authorizer] - end - - subgraph Lambda["AWS Lambda"] - PRESIGNUP[PreSignUpHandler] - USER_H[UserHandler] - end - - subgraph Data_Layer["Data Layer"] - DYNAMO[(DynamoDB UserTable)] - S3[(S3 Profile Images)] - end - - WEB --> UP - UP --> TRIGGER - TRIGGER --> PRESIGNUP - UP --> UPC - - WEB --> REST - REST --> AUTH - AUTH -->|Token Validation| UP - AUTH -->|Claims| USER_H - - USER_H --> DYNAMO - USER_H --> S3 -``` - -### 2.2 레이어 아키텍처 - -```mermaid -flowchart TB - subgraph Presentation["Presentation Layer"] - HANDLER[Lambda Handlers] - DTO[Request/Response DTOs] - end - - subgraph Application["Application Layer"] - SERVICE[UserService] - end - - subgraph Domain["Domain Layer"] - MODEL[User Model] - REPO[UserRepository] - end - - subgraph Infrastructure["Infrastructure Layer"] - DYNAMO_CLIENT[DynamoDB Enhanced Client] - S3_CLIENT[S3 Client] - COGNITO[Cognito User Pool] - end - - HANDLER --> SERVICE - SERVICE --> MODEL - MODEL --> REPO - REPO --> DYNAMO_CLIENT - SERVICE --> S3_CLIENT -``` - -### 2.3 회원가입 흐름 - -```mermaid -sequenceDiagram - participant C as Client - participant COG as Cognito - participant TRIGGER as PreSignUpHandler - participant SES as AWS SES - - C->>COG: sign-up (email, password) - COG->>TRIGGER: PreSignUp Event - Note over TRIGGER: userAttributes 추출 - TRIGGER->>TRIGGER: 기본값 설정 - Note over TRIGGER: nickname: UUID 6자 + "님"
custom:level: BEGINNER
custom:profileUrl: 기본 이미지 - TRIGGER-->>COG: Modified Attributes - COG->>COG: Create User (UNCONFIRMED) - COG->>SES: 인증 코드 이메일 발송 - SES-->>C: 6자리 인증 코드 - C->>COG: confirm-sign-up (code) - COG->>COG: User Status → CONFIRMED - COG-->>C: 가입 완료 -``` - -### 2.4 로그인 및 토큰 발급 흐름 - -```mermaid -sequenceDiagram - participant C as Client - participant COG as Cognito - participant GW as API Gateway - participant AUTH as Cognito Authorizer - participant H as UserHandler - - C->>COG: initiate-auth (email, password) - COG->>COG: Validate Credentials - COG-->>C: AuthenticationResult - Note over C,COG: IdToken (1시간)
AccessToken (1시간)
RefreshToken (30일) - - C->>GW: GET /users/me - Note over C,GW: Authorization: Bearer {IdToken} - GW->>AUTH: Token Validation - AUTH->>COG: Verify JWT Signature - COG-->>AUTH: Valid - AUTH->>AUTH: Extract Claims - Note over AUTH: sub, email, nickname,
custom:level, custom:profileUrl - AUTH-->>GW: Claims 전달 - GW->>H: Request + Claims - H->>H: claims.get("sub"), claims.get("email")... - H-->>GW: User Info Response - GW-->>C: 200 OK -``` - -### 2.5 토큰 갱신 흐름 - -```mermaid -sequenceDiagram - participant C as Client - participant COG as Cognito - - Note over C: IdToken 만료 (1시간 후) - C->>COG: initiate-auth (REFRESH_TOKEN_AUTH) - Note over C,COG: RefreshToken 전달 - COG->>COG: Validate RefreshToken - COG-->>C: New AuthenticationResult - Note over C,COG: 새로운 IdToken
새로운 AccessToken
(RefreshToken은 동일) -``` - ---- - -## 3. 데이터 모델 - -### 3.1 Cognito User Attributes - -| Attribute | Type | Required | Mutable | 설명 | -|-------------------|----------|----------|---------|-----------------------------------------| -| sub | Standard | Y | N | Cognito 고유 ID (UUID) | -| email | Standard | Y | N | 이메일 (로그인 ID) | -| email_verified | Standard | Y | N | 이메일 인증 여부 | -| nickname | Standard | N | Y | 닉네임 | -| custom:level | Custom | N | Y | 학습 난이도 (BEGINNER/INTERMEDIATE/ADVANCED) | -| custom:profileUrl | Custom | N | Y | 프로필 이미지 URL | - -### 3.2 ERD (DynamoDB - 향후 확장용) - -```mermaid -erDiagram - UserTable ||--o{ User : contains - - User { - string partitionKey "USER#{cognitoSub}" - string sortKey "METADATA" - string gsi1PartitionKey "EMAIL#{email}" - string gsi1SortKey "USER#{cognitoSub}" - string gsi2PartitionKey "LEVEL#{level}" - string gsi2SortKey "USER#{cognitoSub}" - string cognitoSub "Cognito UUID" - string email "이메일" - string nickname "닉네임" - string level "BEGINNER/INTERMEDIATE/ADVANCED" - string profileUrl "프로필 이미지 URL" - string createdAt "생성 시각" - string updatedAt "수정 시각" - string lastLoginAt "마지막 로그인" - long ttl "자동 만료" - } -``` - -| 필드 | 패턴 | 설명 | -|--------|-------------------|---------| -| PK | USER#{cognitoSub} | 파티션 키 | -| SK | METADATA | 정렬 키 | -| GSI1PK | EMAIL#{email} | 이메일 조회용 | -| GSI1SK | USER#{cognitoSub} | - | -| GSI2PK | LEVEL#{level} | 레벨별 조회용 | -| GSI2SK | USER#{cognitoSub} | - | - -### 3.3 GSI (Global Secondary Index) 설계 - -```mermaid -flowchart LR - subgraph GSI1["GSI1: 이메일 조회"] - G1_EMAIL["EMAIL#{email} → 사용자 조회"] - end - - subgraph GSI2["GSI2: 레벨별 조회"] - G2_LEVEL["LEVEL#{level} → 레벨별 사용자"] - end -``` - ---- - -## 4. API 명세 - -### 4.1 인증 (Cognito SDK 직접 호출) - -#### 회원가입 (sign-up) - -```bash -aws cognito-idp sign-up \ - --client-id {CognitoClientId} \ - --username {EMAIL} \ - --password {PASSWORD} \ - --user-attributes Name=email,Value={EMAIL} -``` - -**Response (Success)** - -```json -{ - "UserConfirmed": false, - "CodeDeliveryDetails": { - "Destination": "h***@g***.com", - "DeliveryMedium": "EMAIL", - "AttributeName": "email" - }, - "UserSub": "d4088d7c-e0f1-70bd-3b7a-eb8b812e3ae4" -} -``` - -#### 이메일 인증 (confirm-sign-up) - -```bash -aws cognito-idp confirm-sign-up \ - --client-id {CognitoClientId} \ - --username {EMAIL} \ - --confirmation-code {6자리 코드} -``` - -#### 로그인 (initiate-auth) - -```bash -aws cognito-idp initiate-auth \ - --client-id {CognitoClientId} \ - --auth-flow USER_PASSWORD_AUTH \ - --auth-parameters USERNAME={EMAIL},PASSWORD={PASSWORD} -``` - -**Response (Success)** - -```json -{ - "ChallengeParameters": {}, - "AuthenticationResult": { - "AccessToken": "eyJraWQiOiJ1Y2F1aEZCT0o5djV0c29q...", - "ExpiresIn": 3600, - "TokenType": "Bearer", - "RefreshToken": "eyJjdHkiOiJKV1QiLCJlbmMiOiJBMjU2R0NNIi...", - "IdToken": "eyJraWQiOiJPbFAzMHFxZUxpK1VGMSs4SElVVnBN..." - } -} -``` - -**IdToken Payload (Decoded)** - -```json -{ - "sub": "d4088d7c-e0f1-70bd-3b7a-eb8b812e3ae4", - "email_verified": true, - "iss": "https://cognito-idp.ap-northeast-2.amazonaws.com/ap-northeast-2_ezDwzFCzR", - "cognito:username": "d4088d7c-e0f1-70bd-3b7a-eb8b812e3ae4", - "aud": "4ns077jcr1pkue2vvisr6qdpu5", - "event_id": "1a9b6343-fd03-4de3-b1c1-db52131442d4", - "token_use": "id", - "auth_time": 1768103576, - "exp": 1768107176, - "iat": 1768103576, - "email": "hye.ina0130@gmail.com" -} -``` - -#### 토큰 갱신 (refresh-token) - -```bash -aws cognito-idp initiate-auth \ - --client-id 4ns077jcr1pkue2vvisr6qdpu5 \ - --auth-flow REFRESH_TOKEN_AUTH \ - --auth-parameters REFRESH_TOKEN={REFRESH_TOKEN} -``` - -### 4.2 프로필 API - -#### GET /users/profile/me - 내 정보 조회 - -**Headers** - -| Header | 값 | 필수 | -|---------------|------------------|----| -| Authorization | Bearer {IdToken} | Y | - -**Response (200 OK)** - -```json -{ - "success": true, - "message": "A7K2X9님 환영합니다!", - "data": { - "userId": "d4088d7c-e0f1-70bd-3b7a-eb8b812e3ae4", - "email": "hye.ina0130@gmail.com", - "nickname": "A7K2X9님" - } -} -``` - ---- - -## 5. 비즈니스 규칙 - -### 5.1 회원가입 기본값 (PreSignUp Trigger) - -| 항목 | 조건 | 기본값 | 예시 | -|-------------------|---------------|---------------|------------------------------------------------------------------| -| nickname | null 또는 빈 문자열 | UUID 6자 + "님" | "A7K2X9님" | -| custom:level | null 또는 빈 문자열 | BEGINNER | "BEGINNER" | -| custom:profileUrl | null 또는 빈 문자열 | S3 기본 이미지 | https://group2-englishstudy.s3.amazonaws.com/profile/default.png | - -### 5.2 비밀번호 정책 (Cognito) - -| 항목 | 요구사항 | -|-------|-------| -| 최소 길이 | 8자 | -| 소문자 | 1개 이상 | -| 숫자 | 1개 이상 | -| 특수문자 | 1개 이상 | - -### 5.3 토큰 유효 시간 - -| 토큰 | 유효 시간 | 용도 | -|--------------|-------------|----------------| -| IdToken | 1시간 (3600초) | API 인증, 사용자 정보 | -| AccessToken | 1시간 (3600초) | Cognito API 호출 | -| RefreshToken | 30일 | 토큰 갱신 | - ---- - -## 6. 에러 코드 - -### 6.1 Cognito 에러 - -| Error Code | HTTP | 설명 | 해결 방법 | -|---------------------------|------|-------------|-------------| -| UsernameExistsException | 400 | 이미 존재하는 이메일 | 다른 이메일 사용 | -| InvalidPasswordException | 400 | 비밀번호 정책 미충족 | 정책에 맞는 비밀번호 | -| CodeMismatchException | 400 | 인증 코드 불일치 | 올바른 코드 입력 | -| ExpiredCodeException | 400 | 인증 코드 만료 | 재발송 요청 | -| NotAuthorizedException | 401 | 비밀번호 틀림 | 올바른 비밀번호 | -| UserNotConfirmedException | 400 | 이메일 미인증 | 인증 완료 필요 | -| UserNotFoundException | 400 | 존재하지 않는 사용자 | 회원가입 필요 | - -### 6.2 API 에러 - -| HTTP Code | Error Code | 메시지 | -|-----------|------------|------------------| -| 401 | AUTH_001 | 인증이 필요합니다 | -| 401 | AUTH_003 | 유효하지 않은 토큰입니다 | -| 401 | AUTH_004 | 토큰이 만료되었습니다 | -| 500 | SYSTEM_001 | 내부 서버 오류가 발생했습니다 | - -### 6.3 에러 응답 형식 - -```json -{ - "success": false, - "error": { - "code": "AUTH_003", - "message": "유효하지 않은 토큰입니다" - } -} -``` - ---- - -## 7. 환경 설정 - -### 7.1 Cognito User Pool (template.yaml) - -```yaml -CognitoUserPool: - Type: AWS::Cognito::UserPool - Properties: - UserPoolName: group2-englishstudy-userpool - AutoVerifiedAttributes: - - email - UsernameAttributes: - - email - Policies: - PasswordPolicy: - MinimumLength: 8 - RequireUppercase: false - RequireLowercase: true - RequireNumbers: true - RequireSymbols: true - Schema: - - Name: email - Required: true - Mutable: false - - Name: nickname - Mutable: true - - Name: level - AttributeDataType: String - Mutable: true - - Name: profileUrl - AttributeDataType: String - Mutable: true - LambdaConfig: - PreSignUp: !GetAtt PreSignUpFunction.Arn -``` - -### 7.2 Cognito User Pool Client - -```yaml -CognitoUserPoolClient: - Type: AWS::Cognito::UserPoolClient - Properties: - ClientName: group2-englishstudy-client - UserPoolId: !Ref CognitoUserPool - GenerateSecret: false - ExplicitAuthFlows: - - ALLOW_USER_PASSWORD_AUTH - - ALLOW_REFRESH_TOKEN_AUTH - ReadAttributes: - - email - - nickname - - custom:level - - custom:profileUrl - WriteAttributes: - - email - - nickname - - custom:level - - custom:profileUrl -``` - -### 7.3 API Gateway Cognito Authorizer - -```yaml -MainApi: - Type: AWS::Serverless::Api - Properties: - Auth: - DefaultAuthorizer: CognitoAuthorizer - Authorizers: - CognitoAuthorizer: - UserPoolArn: !GetAtt CognitoUserPool.Arn -``` - -### 7.4 환경 변수 - -```yaml -Environment: - Variables: - USER_TABLE_NAME: !Ref UserTable - DEFAULT_PROFILE_URL: https://group2-englishstudy.s3.amazonaws.com/profile/default.png -``` - ---- - -## 8. 프로젝트 구조 - -``` -domain/user/ -├── handler/ -│ ├── PreSignUpHandler.java # Cognito PreSignUp 트리거 -│ └── UserHandler.java # REST API - 프로필 조회/수정 -│ -├── service/ -│ └── UserService.java # 비즈니스 로직 -│ -├── repository/ -│ └── UserRepository.java # DynamoDB 데이터 접근 -│ -├── model/ -│ └── User.java # 사용자 엔티티 -│ -└── dto/ - -``` - ---- - -## 9. 구현 현황 - -### Phase 1 - Cognito 인증 (완료) - -- [x] Cognito User Pool 생성 -- [x] Cognito User Pool Client 생성 -- [x] PreSignUp Lambda 트리거 (기본값 설정) -- [x] Cognito Built-in Authorizer 연결 -- [x] 회원가입/이메일인증/로그인 테스트 -- [x] UserHandler (claims 추출) - -### Phase 2 - 프로필 관리 (예정) - -- [ ] GET /users/profile/me - 내 프로필 상세 조회 -- [ ] PUT /users/profile/me - 프로필 수정 (닉네임, 레벨) -- [ ] POST /users/profile/me/image - 프로필 이미지 업로드 (S3) -- [ ] DynamoDB에 추가 사용자 정보 저장 - -### Phase 3 - 추가 기능 (예정) - -- [ ] 비밀번호 변경 -- [ ] 비밀번호 찾기 -- [ ] 회원 탈퇴 (delete-user) -- [ ] 학습 통계 연동 (Vocabulary Domain) -- [ ] 사용자 설정 (알림, 학습 목표, 일일 학습량) - -### Phase 4 - 최적화 - -- [ ] 소셜 로그인 (Kakao, Google, Apple) -- [ ] SNS-SQS Fan-out 패턴 (이메일 발송 비동기 처리) -- [ ] 이메일 타임아웃 방안 SQS 마련 -- [ ] S3 이벤트 트리거 - 이미지 리사이징 패턴 - ---- - -**버전**: 1.0.0 -**최종 업데이트**: 2026-01-11 -**작성자**: hye-inA -**팀**: MZC 2nd Project Team diff --git a/docs/vocabulary/VOCABULARY-GUIDE.md b/docs/vocabulary/VOCABULARY-GUIDE.md deleted file mode 100644 index 7891a29c..00000000 --- a/docs/vocabulary/VOCABULARY-GUIDE.md +++ /dev/null @@ -1,1248 +0,0 @@ -# Vocabulary Server 가이드 문서 - -## 1. 개요 - -### 1.1 목적 - -Vocabulary Server는 영어 단어 학습 플랫폼의 단어 관리 및 학습 기능을 담당하는 서버리스 마이크로서비스이다. Spaced Repetition 알고리즘을 활용한 효율적인 단어 암기, 시험 기능, 일일 -학습 추적 등을 제공한다. - -### 1.2 주요 기능 - -| 기능 | 설명 | -|--------|-----------------------------| -| 단어 관리 | CRUD, 배치 생성/조회, 검색 | -| 사용자 학습 | Spaced Repetition 기반 복습 스케줄 | -| 시험 기능 | DAILY, WEEKLY, CUSTOM 테스트 | -| 일일 학습 | 학습 기록 및 진도 추적 | -| 통계 | 학습 통계 및 성취도 분석 | -| TTS | AWS Polly 기반 발음 듣기 | -| 단어 그룹 | 카테고리/레벨별 그룹화 | - -### 1.3 기술 스택 - -| 구분 | 기술 | -|-----------|------------------------------------| -| Platform | AWS Lambda (Serverless) | -| Language | Java 21 (Eclipse Temurin) | -| Database | AWS DynamoDB (Single Table Design) | -| TTS | AWS Polly | -| Storage | AWS S3 (음성 캐시) | -| Algorithm | SM-2 기반 Spaced Repetition | - ---- - -## 2. 시스템 아키텍처 - -### 2.1 전체 구조 - -```mermaid -flowchart TB - subgraph Client - APP[Mobile App] - WEB[Web Client] - end - - subgraph AWS_Gateway["API Gateway"] - REST[HTTP API] - end - - subgraph Lambda["AWS Lambda"] - WORD_H[WordHandler] - UWORD_H[UserWordHandler] - TEST_H[TestHandler] - DAILY_H[DailyStudyHandler] - STATS_H[StatisticsHandler] - VOICE_H[VoiceHandler] - GROUP_H[WordGroupHandler] - end - - subgraph Data_Layer["Data Layer"] - DYNAMO[(DynamoDB)] - S3[(S3 Bucket)] - end - - subgraph AWS_AI["AI Services"] - POLLY[AWS Polly] - end - - APP --> REST - WEB --> REST - - REST --> WORD_H - REST --> UWORD_H - REST --> TEST_H - REST --> DAILY_H - REST --> STATS_H - REST --> VOICE_H - REST --> GROUP_H - - WORD_H --> DYNAMO - UWORD_H --> DYNAMO - TEST_H --> DYNAMO - DAILY_H --> DYNAMO - STATS_H --> DYNAMO - GROUP_H --> DYNAMO - - VOICE_H --> POLLY - VOICE_H --> S3 -``` - -### 2.2 레이어 아키텍처 - -```mermaid -flowchart TB - subgraph Presentation["Presentation Layer"] - HANDLER[Lambda Handlers] - ROUTER[HandlerRouter] - DTO[Request/Response DTOs] - end - - subgraph Application["Application Layer (CQRS)"] - CMD[Command Services] - QRY[Query Services] - ALGO[Spaced Repetition Algorithm] - end - - subgraph Domain["Domain Layer"] - MODEL[Models] - REPO[Repositories] - end - - subgraph Infrastructure["Infrastructure Layer"] - DYNAMO_CLIENT[DynamoDB Enhanced Client] - S3_CLIENT[S3 Client] - POLLY_CLIENT[Polly Client] - end - - HANDLER --> ROUTER - ROUTER --> CMD - ROUTER --> QRY - CMD --> ALGO - CMD --> MODEL - QRY --> MODEL - MODEL --> REPO - REPO --> DYNAMO_CLIENT -``` - -### 2.3 단어 학습 흐름 (Spaced Repetition) - -```mermaid -sequenceDiagram - participant C as Client - participant H as UserWordHandler - participant CMD as UserWordCommandService - participant REPO as UserWordRepository - participant DB as DynamoDB - - C->>H: POST /user-words/{wordId}/review - Note over C,H: {userId, isCorrect: true/false} - H->>CMD: updateUserWord(userId, wordId, isCorrect) - - alt UserWord 없음 - CMD->>CMD: Create new UserWord - Note over CMD: status=NEW, interval=1, easeFactor=2.5 - end - - CMD->>CMD: applySpacedRepetition(userWord, isCorrect) - - alt isCorrect = true - CMD->>CMD: repetitions++ - CMD->>CMD: Calculate new interval - Note over CMD: interval = interval * easeFactor - CMD->>CMD: Update status - Note over CMD: LEARNING → REVIEWING → MASTERED - else isCorrect = false - CMD->>CMD: Reset repetitions to 0 - CMD->>CMD: interval = 1 - CMD->>CMD: Decrease easeFactor - Note over CMD: easeFactor = max(1.3, easeFactor - 0.2) - CMD->>CMD: status = LEARNING - end - - CMD->>CMD: Calculate nextReviewAt - Note over CMD: today + interval days - CMD->>CMD: Update GSI keys - CMD->>REPO: save(userWord) - REPO->>DB: PutItem - CMD-->>H: UserWord - H-->>C: 200 OK -``` - -### 2.4 시험 흐름 - -```mermaid -sequenceDiagram - participant C as Client - participant H as TestHandler - participant CMD as TestCommandService - participant QRY as WordQueryService - participant REPO as TestResultRepository - participant DB as DynamoDB - - Note over C,DB: Phase 1: 시험 시작 - C->>H: POST /tests/start - Note over C,H: {userId, testType, wordCount} - H->>CMD: startTest(userId, testType, wordCount) - CMD->>QRY: getRandomWords(wordCount) - QRY->>DB: Query Words - DB-->>QRY: Words - QRY-->>CMD: Word List - CMD-->>H: {testId, words} - H-->>C: 200 OK (시험 문제) - - Note over C,DB: Phase 2: 시험 제출 - C->>H: POST /tests/{testId}/submit - Note over C,H: {userId, answers: [{wordId, answer}]} - H->>CMD: submitTest(testId, userId, answers) - CMD->>CMD: Grade answers - CMD->>CMD: Calculate successRate - CMD->>CMD: Build TestResult - Note over CMD: totalQuestions, correctAnswers
incorrectWordIds, successRate - CMD->>REPO: save(testResult) - REPO->>DB: PutItem - Note over REPO,DB: PK=TEST#{userId}
SK=RESULT#{timestamp} - CMD-->>H: TestResult - H-->>C: 200 OK (결과) -``` - -### 2.5 일일 학습 흐름 - -```mermaid -sequenceDiagram - participant C as Client - participant H as DailyStudyHandler - participant CMD as DailyStudyCommandService - participant QRY as DailyStudyQueryService - participant REPO as DailyStudyRepository - participant DB as DynamoDB - - C->>H: POST /daily-study/record - Note over C,H: {userId, wordId, isCorrect, studyType} - H->>CMD: recordStudy(...) - - CMD->>QRY: getTodayStudy(userId) - QRY->>DB: Query by date - - alt 오늘 기록 없음 - CMD->>CMD: Create new DailyStudy - Note over CMD: date=today, wordsStudied=0 - end - - CMD->>CMD: Update statistics - Note over CMD: wordsStudied++, correct/incorrect count - CMD->>REPO: save(dailyStudy) - REPO->>DB: PutItem - CMD-->>H: DailyStudy - H-->>C: 200 OK -``` - -### 2.6 TTS 음성 생성 흐름 - -```mermaid -sequenceDiagram - participant C as Client - participant H as VoiceHandler - participant S as VoiceService - participant REPO as WordRepository - participant POLLY as AWS Polly - participant S3 as S3 Bucket - participant DB as DynamoDB - - C->>H: POST /voice/synthesize - Note over C,H: {wordId, text, voice: "male"/"female"} - H->>S: synthesize(wordId, text, voice) - - S->>REPO: findById(wordId) - REPO->>DB: GetItem - DB-->>REPO: Word - - alt 캐시된 음성 있음 - REPO-->>S: Word (with voiceKey) - S->>S3: GetObject(voiceKey) - S3-->>S: Audio Data - S-->>H: Cached Audio URL - else 캐시 없음 - S->>POLLY: SynthesizeSpeech - Note over S,POLLY: Engine: neural
Voice: Matthew/Joanna - POLLY-->>S: Audio Stream - S->>S3: PutObject - Note over S,S3: Key: vocab/voice/{wordId}_{voice}.mp3 - S3-->>S: Upload Success - S->>REPO: Update voiceKey - REPO->>DB: UpdateItem - S-->>H: New Audio URL - end - - H-->>C: 200 OK (audioUrl) -``` - ---- - -## 3. 데이터 모델 - -### 3.1 ERD (DynamoDB Single Table Design) - -```mermaid -erDiagram - VocabTable ||--o{ Word : contains - VocabTable ||--o{ UserWord : contains - VocabTable ||--o{ TestResult : contains - VocabTable ||--o{ DailyStudy : contains - VocabTable ||--o{ WordGroup : contains - - Word { - string partitionKey "WORD#{wordId}" - string sortKey "METADATA" - string gsi1PartitionKey "LEVEL#{level}" - string gsi1SortKey "WORD#{wordId}" - string gsi2PartitionKey "CATEGORY#{category}" - string gsi2SortKey "WORD#{wordId}" - string wordId "UUID" - string english "영어 단어" - string korean "한국어 뜻" - string example "예문" - string level "BEGINNER/INTERMEDIATE/ADVANCED" - string category "DAILY/BUSINESS/ACADEMIC" - string maleVoiceKey "S3 음성 키 (남성)" - string femaleVoiceKey "S3 음성 키 (여성)" - string createdAt "생성 시각" - } - - UserWord { - string partitionKey "USER#{userId}" - string sortKey "WORD#{wordId}" - string gsi1PartitionKey "USER#{userId}#REVIEW" - string gsi1SortKey "DATE#{nextReviewAt}" - string gsi2PartitionKey "USER#{userId}#STATUS" - string gsi2SortKey "STATUS#{status}" - string userId "사용자 ID" - string wordId "단어 ID" - string status "NEW/LEARNING/REVIEWING/MASTERED" - int interval "복습 간격 (일)" - double easeFactor "난이도 계수" - int repetitions "연속 정답 횟수" - string nextReviewAt "다음 복습일" - string lastReviewedAt "마지막 복습일" - int correctCount "정답 횟수" - int incorrectCount "오답 횟수" - boolean bookmarked "북마크" - boolean favorite "즐겨찾기" - string difficulty "사용자 난이도" - } - - TestResult { - string partitionKey "TEST#{userId}" - string sortKey "RESULT#{timestamp}" - string gsi1PartitionKey "TEST#ALL" - string gsi1SortKey "DATE#{date}" - string testId "UUID" - string userId "사용자 ID" - string testType "DAILY/WEEKLY/CUSTOM" - int totalQuestions "총 문제 수" - int correctAnswers "정답 수" - double successRate "성공률" - string incorrectWordIds "오답 단어 목록" - string startedAt "시작 시각" - string completedAt "완료 시각" - } - - DailyStudy { - string partitionKey "DAILY#{userId}" - string sortKey "DATE#{date}" - string gsi1PartitionKey "DAILY#ALL" - string gsi1SortKey "DATE#{date}" - string userId "사용자 ID" - string date "학습 날짜" - int wordsStudied "학습 단어 수" - int correctCount "정답 수" - int incorrectCount "오답 수" - int studyTimeMinutes "학습 시간 (분)" - string wordIds "학습한 단어 목록" - } - - WordGroup { - string partitionKey "GROUP#{groupId}" - string sortKey "METADATA" - string gsi1PartitionKey "USER#{userId}" - string gsi1SortKey "GROUP#{groupId}" - string groupId "UUID" - string userId "생성자 ID" - string name "그룹명" - string description "설명" - string wordIds "포함 단어 목록" - string createdAt "생성 시각" - } -``` - -### 3.2 테이블 상세 - -#### Word (단어) - -| 필드 | 타입 | 필수 | 설명 | -|-----------------------|--------|----|----------------------------------| -| PK | String | Y | WORD#{wordId} | -| SK | String | Y | METADATA | -| GSI1PK | String | Y | LEVEL#{level} | -| GSI1SK | String | Y | WORD#{wordId} | -| GSI2PK | String | Y | CATEGORY#{category} | -| GSI2SK | String | Y | WORD#{wordId} | -| wordId | String | Y | UUID | -| english | String | Y | 영어 단어 | -| korean | String | Y | 한국어 뜻 | -| example | String | N | 예문 | -| level | String | Y | BEGINNER, INTERMEDIATE, ADVANCED | -| category | String | Y | DAILY, BUSINESS, ACADEMIC | -| maleVoiceKey | String | N | S3 음성 파일 키 (남성) | -| femaleVoiceKey | String | N | S3 음성 파일 키 (여성) | -| maleExampleVoiceKey | String | N | S3 예문 음성 키 (남성) | -| femaleExampleVoiceKey | String | N | S3 예문 음성 키 (여성) | -| createdAt | String | Y | ISO 8601 형식 | - -#### UserWord (사용자 학습 상태) - -| 필드 | 타입 | 필수 | 설명 | -|----------------|---------|----|------------------------------------| -| PK | String | Y | USER#{userId} | -| SK | String | Y | WORD#{wordId} | -| GSI1PK | String | Y | USER#{userId}#REVIEW | -| GSI1SK | String | Y | DATE#{nextReviewAt} | -| GSI2PK | String | Y | USER#{userId}#STATUS | -| GSI2SK | String | Y | STATUS#{status} | -| userId | String | Y | 사용자 ID | -| wordId | String | Y | 단어 ID | -| status | String | Y | NEW, LEARNING, REVIEWING, MASTERED | -| interval | Integer | Y | 복습 간격 (일) | -| easeFactor | Double | Y | 난이도 계수 (기본: 2.5) | -| repetitions | Integer | Y | 연속 정답 횟수 | -| nextReviewAt | String | N | 다음 복습 예정일 | -| lastReviewedAt | String | N | 마지막 복습일 | -| correctCount | Integer | Y | 총 정답 횟수 | -| incorrectCount | Integer | Y | 총 오답 횟수 | -| bookmarked | Boolean | N | 북마크 여부 | -| favorite | Boolean | N | 즐겨찾기 여부 | -| difficulty | String | N | EASY, NORMAL, HARD | -| createdAt | String | Y | 생성 시각 | -| updatedAt | String | Y | 수정 시각 | - -#### TestResult (시험 결과) - -| 필드 | 타입 | 필수 | 설명 | -|------------------|---------|----|-----------------------| -| PK | String | Y | TEST#{userId} | -| SK | String | Y | RESULT#{timestamp} | -| GSI1PK | String | Y | TEST#ALL | -| GSI1SK | String | Y | DATE#{date} | -| testId | String | Y | UUID | -| userId | String | Y | 사용자 ID | -| testType | String | Y | DAILY, WEEKLY, CUSTOM | -| totalQuestions | Integer | Y | 총 문제 수 | -| correctAnswers | Integer | Y | 정답 수 | -| incorrectAnswers | Integer | Y | 오답 수 | -| successRate | Double | Y | 성공률 (%) | -| incorrectWordIds | List | N | 오답 단어 ID 목록 | -| startedAt | String | Y | 시험 시작 시각 | -| completedAt | String | Y | 시험 완료 시각 | - -### 3.3 Spaced Repetition 알고리즘 - -```mermaid -flowchart TB - subgraph Input - A[학습 결과 입력] - B{정답?} - end - - subgraph Correct["정답 처리"] - C1[repetitions++] - C2{repetitions} - C3[interval = 1] - C4[interval = 6] - C5["interval = interval × easeFactor"] - C6{repetitions >= 5?} - C7[status = MASTERED] - C8{repetitions >= 2?} - C9[status = REVIEWING] - C10[status = LEARNING] - end - - subgraph Incorrect["오답 처리"] - I1[repetitions = 0] - I2[interval = 1] - I3["easeFactor = max(1.3, easeFactor - 0.2)"] - I4[status = LEARNING] - end - - subgraph Calculate - N[nextReviewAt = today + interval] - end - - A --> B - B -->|Yes| C1 - B -->|No| I1 - - C1 --> C2 - C2 -->|= 1| C3 - C2 -->|= 2| C4 - C2 -->|>= 3| C5 - C3 --> C6 - C4 --> C6 - C5 --> C6 - - C6 -->|Yes| C7 - C6 -->|No| C8 - C8 -->|Yes| C9 - C8 -->|No| C10 - - I1 --> I2 - I2 --> I3 - I3 --> I4 - - C7 --> N - C9 --> N - C10 --> N - I4 --> N -``` - -### 3.4 학습 상태 전이 - -```mermaid -stateDiagram-v2 - [*] --> NEW: 단어 추가 - NEW --> LEARNING: 첫 학습 - LEARNING --> LEARNING: 오답 - LEARNING --> REVIEWING: 2회 연속 정답 - REVIEWING --> LEARNING: 오답 - REVIEWING --> REVIEWING: 정답 (rep < 5) - REVIEWING --> MASTERED: 5회 연속 정답 - MASTERED --> LEARNING: 오답 - MASTERED --> MASTERED: 정답 (유지) -``` - ---- - -## 4. API 명세 - -### 4.1 단어 생성 - -#### POST /words - -**Request** - -```json -{ - "english": "perseverance", - "korean": "인내, 끈기", - "example": "Success requires perseverance.", - "level": "ADVANCED", - "category": "DAILY" -} -``` - -**Response (201 Created)** - -```json -{ - "success": true, - "message": "Word created", - "data": { - "wordId": "550e8400-e29b-41d4-a716-446655440000", - "english": "perseverance", - "korean": "인내, 끈기", - "example": "Success requires perseverance.", - "level": "ADVANCED", - "category": "DAILY", - "createdAt": "2026-01-09T10:00:00Z" - } -} -``` - -### 4.2 단어 목록 조회 - -#### GET /words - -**Query Parameters** - -| 파라미터 | 타입 | 필수 | 설명 | -|----------|---------|----|-------------------------| -| level | String | N | 난이도 필터 | -| category | String | N | 카테고리 필터 | -| cursor | String | N | 페이징 커서 | -| limit | Integer | N | 페이지 크기 (기본: 20, 최대: 50) | - -**Response (200 OK)** - -```json -{ - "success": true, - "message": "Words retrieved", - "data": { - "words": [ - { - "wordId": "...", - "english": "perseverance", - "korean": "인내, 끈기", - "level": "ADVANCED", - "category": "DAILY" - } - ], - "nextCursor": "eyJQSyI6IldPUkQjLi4uIn0=", - "hasMore": true - } -} -``` - -### 4.3 단어 검색 - -#### GET /words/search - -**Query Parameters** - -| 파라미터 | 타입 | 필수 | 설명 | -|--------|---------|----|-----------------| -| q | String | Y | 검색어 (영어/한국어) | -| cursor | String | N | 페이징 커서 | -| limit | Integer | N | 페이지 크기 (기본: 20) | - -**Response (200 OK)** - -```json -{ - "success": true, - "message": "Search completed", - "data": { - "words": [...], - "query": "perseverance", - "nextCursor": "...", - "hasMore": false - } -} -``` - -### 4.4 배치 단어 생성 - -#### POST /words/batch - -**Request** - -```json -{ - "words": [ - { - "english": "apple", - "korean": "사과", - "level": "BEGINNER", - "category": "DAILY" - }, - { - "english": "banana", - "korean": "바나나", - "level": "BEGINNER", - "category": "DAILY" - } - ] -} -``` - -**Response (201 Created)** - -```json -{ - "success": true, - "message": "Batch completed", - "data": { - "successCount": 2, - "failCount": 0, - "totalRequested": 2 - } -} -``` - -### 4.5 배치 단어 조회 - -#### POST /words/batch/get - -**Request** - -```json -{ - "wordIds": [ - "word-id-1", - "word-id-2", - "word-id-3" - ] -} -``` - -**Response (200 OK)** - -```json -{ - "success": true, - "message": "Words retrieved", - "data": { - "words": [...], - "requestedCount": 3, - "retrievedCount": 3 - } -} -``` - -**제한**: 최대 100개 ID - -### 4.6 사용자 단어 학습 업데이트 - -#### POST /user-words/{wordId}/review - -**Request** - -```json -{ - "userId": "user123", - "isCorrect": true -} -``` - -**Response (200 OK)** - -```json -{ - "success": true, - "message": "Updated user word", - "data": { - "userId": "user123", - "wordId": "word-id-1", - "status": "REVIEWING", - "interval": 6, - "easeFactor": 2.5, - "repetitions": 2, - "nextReviewAt": "2026-01-15", - "lastReviewedAt": "2026-01-09T10:00:00Z", - "correctCount": 5, - "incorrectCount": 1 - } -} -``` - -### 4.7 사용자 단어 태그 업데이트 - -#### PATCH /user-words/{wordId}/tag - -**Request** - -```json -{ - "userId": "user123", - "bookmarked": true, - "favorite": false, - "difficulty": "HARD" -} -``` - -**Response (200 OK)** - -```json -{ - "success": true, - "message": "Updated user word tag", - "data": { - "userId": "user123", - "wordId": "word-id-1", - "bookmarked": true, - "favorite": false, - "difficulty": "HARD" - } -} -``` - -### 4.8 복습 예정 단어 조회 - -#### GET /user-words/review - -**Query Parameters** - -| 파라미터 | 타입 | 필수 | 설명 | -|--------|---------|----|----------------| -| userId | String | Y | 사용자 ID | -| date | String | N | 조회 날짜 (기본: 오늘) | -| cursor | String | N | 페이징 커서 | -| limit | Integer | N | 페이지 크기 | - -**Response (200 OK)** - -```json -{ - "success": true, - "message": "Review words retrieved", - "data": { - "words": [ - { - "wordId": "...", - "english": "perseverance", - "korean": "인내, 끈기", - "status": "REVIEWING", - "nextReviewAt": "2026-01-09" - } - ], - "nextCursor": "...", - "hasMore": true - } -} -``` - -### 4.9 시험 시작 - -#### POST /tests/start - -**Request** - -```json -{ - "userId": "user123", - "testType": "DAILY", - "wordCount": 20, - "level": "INTERMEDIATE" -} -``` - -**Response (200 OK)** - -```json -{ - "success": true, - "message": "Test started", - "data": { - "testId": "test-uuid", - "testType": "DAILY", - "words": [ - { - "wordId": "...", - "english": "perseverance", - "options": ["인내", "용기", "지혜", "성실"] - } - ], - "startedAt": "2026-01-09T10:00:00Z" - } -} -``` - -### 4.10 시험 제출 - -#### POST /tests/{testId}/submit - -**Request** - -```json -{ - "userId": "user123", - "answers": [ - {"wordId": "word-1", "answer": "인내"}, - {"wordId": "word-2", "answer": "용기"} - ] -} -``` - -**Response (200 OK)** - -```json -{ - "success": true, - "message": "Test submitted", - "data": { - "testId": "test-uuid", - "totalQuestions": 20, - "correctAnswers": 18, - "incorrectAnswers": 2, - "successRate": 90.0, - "incorrectWordIds": ["word-5", "word-12"], - "completedAt": "2026-01-09T10:15:00Z" - } -} -``` - -### 4.11 일일 학습 기록 - -#### POST /daily-study/record - -**Request** - -```json -{ - "userId": "user123", - "wordId": "word-id-1", - "isCorrect": true, - "studyType": "REVIEW" -} -``` - -**Response (200 OK)** - -```json -{ - "success": true, - "message": "Study recorded", - "data": { - "userId": "user123", - "date": "2026-01-09", - "wordsStudied": 15, - "correctCount": 12, - "incorrectCount": 3 - } -} -``` - -### 4.12 학습 통계 조회 - -#### GET /statistics - -**Query Parameters** - -| 파라미터 | 타입 | 필수 | 설명 | -|--------|--------|----|-----------------------------| -| userId | String | Y | 사용자 ID | -| period | String | N | WEEK, MONTH, ALL (기본: WEEK) | - -**Response (200 OK)** - -```json -{ - "success": true, - "message": "Statistics retrieved", - "data": { - "totalWords": 150, - "masteredWords": 45, - "learningWords": 80, - "newWords": 25, - "averageSuccessRate": 85.5, - "studyStreak": 7, - "dailyStats": [ - {"date": "2026-01-09", "wordsStudied": 20, "successRate": 90.0} - ] - } -} -``` - -### 4.13 음성 합성 - -#### POST /voice/synthesize - -**Request** - -```json -{ - "wordId": "word-id-1", - "text": "perseverance", - "voice": "male", - "type": "word" -} -``` - -**Response (200 OK)** - -```json -{ - "success": true, - "message": "Voice synthesized", - "data": { - "audioUrl": "https://s3.amazonaws.com/bucket/vocab/voice/word-id-1_male.mp3", - "cached": true - } -} -``` - ---- - -## 5. 비즈니스 규칙 - -### 5.1 Spaced Repetition 규칙 - -| 조건 | interval 계산 | status 변경 | -|----------------|-----------------------|-----------| -| 첫 정답 (rep=1) | 1일 | LEARNING | -| 두번째 정답 (rep=2) | 6일 | REVIEWING | -| 이후 정답 (rep>=3) | interval × easeFactor | REVIEWING | -| 5회 연속 정답 | 유지 | MASTERED | -| 오답 | 1일 (리셋) | LEARNING | - -### 5.2 easeFactor 규칙 - -| 조건 | easeFactor 변경 | -|------|----------------------------| -| 초기값 | 2.5 | -| 오답 시 | max(1.3, easeFactor - 0.2) | -| 정답 시 | 유지 | - -### 5.3 난이도별 카테고리 - -```mermaid -flowchart LR - subgraph Level - BEG[BEGINNER] - INT[INTERMEDIATE] - ADV[ADVANCED] - end - - subgraph Category - DAILY[DAILY
일상생활] - BIZ[BUSINESS
비즈니스] - ACAD[ACADEMIC
학술] - end - - BEG --> DAILY - INT --> DAILY - INT --> BIZ - ADV --> DAILY - ADV --> BIZ - ADV --> ACAD -``` - -### 5.4 제한 사항 - -| 항목 | 제한 | -|--------------|--------------------| -| 단어 목록 페이지 크기 | 최대 50 | -| 배치 조회 ID | 최대 100개 | -| 시험 문제 수 | 최소 5, 최대 50 | -| 사용자 난이도 | EASY, NORMAL, HARD | - ---- - -## 6. 에러 코드 - -### 6.1 HTTP 에러 - -| HTTP Code | 설명 | 예시 | -|-----------|--------|------------------------------| -| 400 | 잘못된 요청 | 필수 파라미터 누락, 잘못된 difficulty 값 | -| 404 | 리소스 없음 | 존재하지 않는 단어 | -| 500 | 서버 오류 | 내부 오류 | - -### 6.2 에러 응답 형식 - -```json -{ - "success": false, - "error": "Word not found" -} -``` - ---- - -## 7. 환경 설정 - -### 7.1 환경 변수 (template.yaml) - -```yaml -Environment: - Variables: - VOCAB_TABLE_NAME: VocabTable - VOCAB_BUCKET_NAME: group2-englishstudy - AWS_REGION_NAME: ap-northeast-2 -``` - -### 7.2 DynamoDB 테이블 설정 - -```yaml -VocabTable: - Type: AWS::DynamoDB::Table - Properties: - TableName: VocabTable - BillingMode: PAY_PER_REQUEST - AttributeDefinitions: - - AttributeName: PK - AttributeType: S - - AttributeName: SK - AttributeType: S - - AttributeName: GSI1PK - AttributeType: S - - AttributeName: GSI1SK - AttributeType: S - - AttributeName: GSI2PK - AttributeType: S - - AttributeName: GSI2SK - AttributeType: S - KeySchema: - - AttributeName: PK - KeyType: HASH - - AttributeName: SK - KeyType: RANGE - GlobalSecondaryIndexes: - - IndexName: GSI1 - KeySchema: - - AttributeName: GSI1PK - KeyType: HASH - - AttributeName: GSI1SK - KeyType: RANGE - Projection: - ProjectionType: ALL - - IndexName: GSI2 - KeySchema: - - AttributeName: GSI2PK - KeyType: HASH - - AttributeName: GSI2SK - KeyType: RANGE - Projection: - ProjectionType: ALL - TimeToLiveSpecification: - AttributeName: ttl - Enabled: true -``` - -### 7.3 S3 버킷 구조 - -``` -group2-englishstudy/ -└── vocab/ - └── voice/ - ├── {wordId}_male.mp3 - ├── {wordId}_female.mp3 - ├── {wordId}_male_example.mp3 - └── {wordId}_female_example.mp3 -``` - ---- - -## 8. 프로젝트 구조 - -``` -domain/vocabulary/ -├── handler/ -│ ├── WordHandler.java # 단어 CRUD -│ ├── UserWordHandler.java # 사용자 학습 상태 -│ ├── TestHandler.java # 시험 기능 -│ ├── DailyStudyHandler.java # 일일 학습 -│ ├── StatisticsHandler.java # 통계 -│ ├── StatsHandler.java # 간단 통계 -│ ├── VoiceHandler.java # TTS -│ └── WordGroupHandler.java # 단어 그룹 -│ -├── service/ -│ ├── WordCommandService.java # 단어 변경 (CQRS) -│ ├── WordQueryService.java # 단어 조회 (CQRS) -│ ├── WordService.java # 단어 통합 서비스 -│ ├── UserWordCommandService.java # 사용자 학습 변경 -│ ├── UserWordQueryService.java # 사용자 학습 조회 -│ ├── UserWordService.java # 사용자 학습 통합 -│ ├── TestCommandService.java # 시험 변경 -│ ├── TestQueryService.java # 시험 조회 -│ ├── TestService.java # 시험 통합 -│ ├── DailyStudyCommandService.java # 일일학습 변경 -│ ├── DailyStudyQueryService.java # 일일학습 조회 -│ ├── DailyStudyService.java # 일일학습 통합 -│ ├── WordGroupCommandService.java # 그룹 변경 -│ ├── WordGroupQueryService.java # 그룹 조회 -│ ├── StatisticsService.java # 통계 -│ └── StatsService.java # 간단 통계 -│ -├── repository/ -│ ├── WordRepository.java # 단어 데이터 접근 -│ ├── UserWordRepository.java # 사용자 학습 데이터 접근 -│ ├── TestResultRepository.java # 시험 결과 데이터 접근 -│ ├── DailyStudyRepository.java # 일일 학습 데이터 접근 -│ └── WordGroupRepository.java # 그룹 데이터 접근 -│ -├── model/ -│ ├── Word.java # 단어 엔티티 -│ ├── UserWord.java # 사용자 학습 엔티티 -│ ├── TestResult.java # 시험 결과 엔티티 -│ ├── DailyStudy.java # 일일 학습 엔티티 -│ └── WordGroup.java # 단어 그룹 엔티티 -│ -└── dto/ - └── request/ - ├── CreateWordRequest.java - ├── CreateWordsBatchRequest.java - ├── BatchGetWordsRequest.java - ├── UpdateUserWordRequest.java - ├── UpdateUserWordTagRequest.java - ├── StartTestRequest.java - ├── SubmitTestRequest.java - ├── CreateWordGroupRequest.java - └── SynthesizeVoiceRequest.java -``` - ---- - -## 9. GSI 사용 패턴 - -### 9.1 Word GSI - -```mermaid -flowchart TB - subgraph GSI1["GSI1: 난이도별 조회"] - G1_BEG["LEVEL#BEGINNER"] - G1_INT["LEVEL#INTERMEDIATE"] - G1_ADV["LEVEL#ADVANCED"] - end - - subgraph GSI2["GSI2: 카테고리별 조회"] - G2_DAILY["CATEGORY#DAILY"] - G2_BIZ["CATEGORY#BUSINESS"] - G2_ACAD["CATEGORY#ACADEMIC"] - end - - Q1[getWordsByLevel] --> GSI1 - Q2[getWordsByCategory] --> GSI2 -``` - -### 9.2 UserWord GSI - -```mermaid -flowchart TB - subgraph GSI1["GSI1: 복습 예정 조회"] - G1_REV["USER#{userId}#REVIEW"] - G1_DATE["DATE#{nextReviewAt}"] - end - - subgraph GSI2["GSI2: 상태별 조회"] - G2_STATUS["USER#{userId}#STATUS"] - G2_VAL["STATUS#MASTERED/LEARNING/..."] - end - - Q1[getReviewSchedule] --> GSI1 - Q2[getWordsByStatus] --> GSI2 -``` - ---- - -## 10. 구현 현황 - -### Phase 1 - 핵심 기능 (완료) - -- [x] 단어 CRUD -- [x] 배치 생성/조회 -- [x] 단어 검색 -- [x] CQRS 패턴 적용 -- [x] 커서 기반 페이징 - -### Phase 2 - 학습 기능 (완료) - -- [x] Spaced Repetition 알고리즘 -- [x] UserWord 상태 관리 -- [x] 복습 예정 조회 -- [x] 북마크/즐겨찾기 - -### Phase 3 - 시험/통계 (완료) - -- [x] 시험 시작/제출 -- [x] 시험 결과 저장 -- [x] 일일 학습 기록 -- [x] 학습 통계 - -### Phase 4 - 고급 기능 (완료) - -- [x] TTS (AWS Polly) -- [x] 음성 캐싱 (S3) -- [x] 단어 그룹 - -### Phase 5 - 최적화 (진행 중) - -- [ ] 복습 알림 (SNS) -- [ ] 성취 배지 -- [ ] 랭킹 시스템 - ---- - -**버전**: 1.0.0 -**최종 업데이트**: 2026-01-09 -**팀**: MZC 2nd Project Team From 85df4392886e12cb1d5becaa0045f44989aca7e6 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Wed, 14 Jan 2026 10:22:05 +0900 Subject: [PATCH 171/528] =?UTF-8?q?feat:=20Grammar=20Streaming=20API=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(WebSocket)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bedrock Streaming을 통한 실시간 AI 응답 기능 추가 - 기존 동기 API (2-6초 블로킹) → WebSocket 스트리밍으로 UX 개선 - 토큰 단위 실시간 전송으로 타이핑 효과 구현 가능 변경 사항: - AwsClients: BedrockRuntimeAsyncClient 추가 - BedrockGrammarCheckFactory: generateConversationStreaming() 메서드 추가 - GrammarConversationService: chatStreaming() 메서드 추가 - GrammarStreamingHandler: WebSocket 스트리밍 핸들러 신규 생성 - JwtUtil: JWT 토큰 파싱 유틸리티 추가 Java 21 기능 활용: - StreamingRequest: record 타입 - StreamingEvent: sealed interface + pattern matching switch 관련 이슈: #291 --- .../docs/FRONTEND-DEVELOPMENT-GUIDE.md | 466 ++++++++++++++++++ .../serverless/common/config/AwsClients.java | 6 + .../serverless/common/util/JwtUtil.java | 127 +++++ .../factory/BedrockGrammarCheckFactory.java | 225 +++++++++ .../websocket/GrammarStreamingHandler.java | 175 +++++++ .../service/GrammarConversationService.java | 63 +++ .../grammar/streaming/StreamingCallback.java | 27 + .../grammar/streaming/StreamingEvent.java | 55 +++ .../grammar/streaming/StreamingRequest.java | 17 + 9 files changed, 1161 insertions(+) create mode 100644 ServerlessFunction/docs/FRONTEND-DEVELOPMENT-GUIDE.md create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/JwtUtil.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/websocket/GrammarStreamingHandler.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/streaming/StreamingCallback.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/streaming/StreamingEvent.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/streaming/StreamingRequest.java diff --git a/ServerlessFunction/docs/FRONTEND-DEVELOPMENT-GUIDE.md b/ServerlessFunction/docs/FRONTEND-DEVELOPMENT-GUIDE.md new file mode 100644 index 00000000..fa17ee69 --- /dev/null +++ b/ServerlessFunction/docs/FRONTEND-DEVELOPMENT-GUIDE.md @@ -0,0 +1,466 @@ +# 프론트엔드 개발 가이드 + +## 개요 + +이 문서는 영어 학습 플랫폼 백엔드 API의 프론트엔드 개발 가이드입니다. + +## 기본 정보 + +### Base URL +``` +https://gc8l9ijhzc.execute-api.ap-northeast-2.amazonaws.com/dev +``` + +### 인증 + +모든 인증이 필요한 API는 Authorization 헤더에 JWT 토큰을 포함해야 합니다. + +```typescript +const headers = { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${idToken}` +}; +``` + +--- + +## Grammar API + +### 1. 문법 검사 (Grammar Check) + +단일 문장에 대한 문법 검사를 수행합니다. + +**Endpoint:** `POST /grammar/check` + +**Request:** +```typescript +interface GrammarCheckRequest { + sentence: string; // 검사할 문장 + level: 'BEGINNER' | 'INTERMEDIATE' | 'ADVANCED'; // 학습 레벨 +} +``` + +**Response:** +```typescript +interface GrammarCheckResponse { + originalSentence: string; // 원본 문장 + correctedSentence: string; // 교정된 문장 + score: number; // 0-100 점수 + isCorrect: boolean; // 문법 정확 여부 + errors: GrammarError[]; // 오류 목록 + feedback: string; // 전체 피드백 +} + +interface GrammarError { + type: GrammarErrorType; // 오류 유형 + original: string; // 원본 표현 + corrected: string; // 교정된 표현 + explanation: string; // 설명 + startIndex?: number; // 원문에서의 시작 위치 + endIndex?: number; // 원문에서의 끝 위치 +} + +type GrammarErrorType = + | 'VERB_TENSE' // 시제 오류 + | 'SUBJECT_VERB_AGREEMENT' // 주어-동사 일치 + | 'ARTICLE' // 관사 + | 'PREPOSITION' // 전치사 + | 'WORD_ORDER' // 어순 + | 'PLURAL_SINGULAR' // 단복수 + | 'PRONOUN' // 대명사 + | 'SPELLING' // 철자 + | 'PUNCTUATION' // 구두점 + | 'WORD_CHOICE' // 어휘 선택 + | 'SENTENCE_STRUCTURE' // 문장 구조 + | 'OTHER'; // 기타 +``` + +**예시:** +```typescript +const response = await fetch(`${BASE_URL}/grammar/check`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify({ + sentence: 'I go to school yesterday', + level: 'BEGINNER' + }) +}); + +const result = await response.json(); +// result.data: +// { +// originalSentence: "I go to school yesterday", +// correctedSentence: "I went to school yesterday", +// score: 80, +// isCorrect: false, +// errors: [{ +// type: "VERB_TENSE", +// original: "go", +// corrected: "went", +// explanation: "'yesterday'는 과거를 나타내므로 'go'를 과거형 'went'로 바꿔야 합니다.", +// startIndex: 2, +// endIndex: 4 +// }], +// feedback: "시제에 주의하세요. 과거를 나타내는 'yesterday'와 함께 사용할 때는 과거형을 사용합니다." +// } +``` + +--- + +### 2. 대화 (Conversation) - 동기 방식 + +AI와 대화하면서 문법을 교정받습니다. + +**Endpoint:** `POST /grammar/conversation` + +**Request:** +```typescript +interface ConversationRequest { + sessionId?: string; // 세션 ID (없으면 새 세션 생성) + message: string; // 사용자 메시지 + level?: string; // 'BEGINNER' | 'INTERMEDIATE' | 'ADVANCED' (기본값: BEGINNER) +} +``` + +**Response:** +```typescript +interface ConversationResponse { + sessionId: string; // 세션 ID + grammarCheck: GrammarCheckResponse; // 문법 검사 결과 + aiResponse: string; // AI의 대화 응답 + conversationTip: string; // 학습 팁 +} +``` + +--- + +### 3. 대화 스트리밍 (Conversation Streaming) - WebSocket 방식 + +실시간으로 AI 응답을 받습니다. 동기 API보다 빠른 사용자 경험을 제공합니다. + +**WebSocket Endpoint:** `wss://{websocket-api-id}.execute-api.ap-northeast-2.amazonaws.com/dev` + +#### 연결 및 사용법 + +```typescript +// 1. WebSocket 연결 +const ws = new WebSocket(`wss://${WEBSOCKET_API_ID}.execute-api.ap-northeast-2.amazonaws.com/dev`); + +// 2. 연결 완료 시 메시지 전송 +ws.onopen = () => { + const request = { + action: 'grammarStreaming', // 라우트 키 + sessionId: 'optional-session-id', + message: 'I go to school yesterday', + userId: 'user-id-from-jwt', + level: 'BEGINNER' + }; + ws.send(JSON.stringify(request)); +}; + +// 3. 스트리밍 이벤트 수신 +ws.onmessage = (event) => { + const data = JSON.parse(event.data); + + switch (data.type) { + case 'start': + // 스트리밍 시작 + console.log('Streaming started, sessionId:', data.sessionId); + break; + + case 'token': + // 실시간 토큰 수신 - UI에 점진적으로 표시 + appendToResponse(data.token); + break; + + case 'complete': + // 스트리밍 완료 - 최종 결과 + handleComplete(data); + break; + + case 'error': + // 에러 처리 + console.error('Streaming error:', data.message); + break; + } +}; + +ws.onerror = (error) => { + console.error('WebSocket error:', error); +}; + +ws.onclose = () => { + console.log('WebSocket closed'); +}; +``` + +#### 스트리밍 이벤트 타입 + +```typescript +// 시작 이벤트 +interface StreamingStartEvent { + type: 'start'; + sessionId: string; +} + +// 토큰 이벤트 (실시간 텍스트 조각) +interface StreamingTokenEvent { + type: 'token'; + token: string; // 텍스트 조각 +} + +// 완료 이벤트 +interface StreamingCompleteEvent { + type: 'complete'; + sessionId: string; + grammarCheck: GrammarCheckResponse; + aiResponse: string; + conversationTip: string; +} + +// 에러 이벤트 +interface StreamingErrorEvent { + type: 'error'; + message: string; +} +``` + +#### 스트리밍 UI 구현 예시 + +```typescript +function GrammarChat() { + const [response, setResponse] = useState(''); + const [isStreaming, setIsStreaming] = useState(false); + const [result, setResult] = useState(null); + + const handleSubmit = (message: string) => { + setIsStreaming(true); + setResponse(''); + setResult(null); + + const ws = new WebSocket(WEBSOCKET_URL); + + ws.onopen = () => { + ws.send(JSON.stringify({ + action: 'grammarStreaming', + message, + userId, + level: 'BEGINNER' + })); + }; + + ws.onmessage = (event) => { + const data = JSON.parse(event.data); + + if (data.type === 'token') { + // 토큰을 점진적으로 추가하여 타이핑 효과 + setResponse(prev => prev + data.token); + } else if (data.type === 'complete') { + setResult(data); + setIsStreaming(false); + ws.close(); + } else if (data.type === 'error') { + console.error(data.message); + setIsStreaming(false); + ws.close(); + } + }; + }; + + return ( +
+ {/* 실시간 응답 표시 */} +
+ {response} + {isStreaming && |} +
+ + {/* 최종 결과 표시 */} + {result && ( +
+ + {result.conversationTip} +
+ )} +
+ ); +} +``` + +--- + +### 4. 세션 목록 조회 + +**Endpoint:** `GET /grammar/sessions` + +**Query Parameters:** +- `limit`: 조회할 개수 (기본값: 10, 최대: 50) +- `cursor`: 페이지네이션 커서 + +**Response:** +```typescript +interface SessionListResponse { + sessions: GrammarSession[]; + nextCursor: string | null; + hasMore: boolean; +} + +interface GrammarSession { + sessionId: string; + level: string; + topic: string; + messageCount: number; + lastMessage: string; + createdAt: string; + updatedAt: string; +} +``` + +--- + +### 5. 세션 상세 조회 + +**Endpoint:** `GET /grammar/sessions/{sessionId}` + +**Query Parameters:** +- `messageLimit`: 메시지 조회 개수 (기본값: 50, 최대: 100) + +**Response:** +```typescript +interface SessionDetailResponse { + session: GrammarSession; + messages: GrammarMessage[]; +} + +interface GrammarMessage { + messageId: string; + role: 'USER' | 'ASSISTANT'; + content: string; + correctedContent?: string; + grammarScore?: number; + createdAt: string; +} +``` + +--- + +### 6. 세션 삭제 + +**Endpoint:** `DELETE /grammar/sessions/{sessionId}` + +**Response:** +```typescript +{ + status: 'success', + message: 'Session deleted successfully' +} +``` + +--- + +## 에러 하이라이팅 구현 + +`startIndex`와 `endIndex`를 사용하여 원문에서 오류 위치를 하이라이팅할 수 있습니다. + +```typescript +function highlightErrors( + sentence: string, + errors: GrammarError[] +): React.ReactNode[] { + if (!errors.length) return [sentence]; + + // 위치 정보가 있는 오류만 필터링 + const positionedErrors = errors + .filter(e => e.startIndex != null && e.endIndex != null) + .sort((a, b) => a.startIndex! - b.startIndex!); + + if (!positionedErrors.length) return [sentence]; + + const parts: React.ReactNode[] = []; + let lastIndex = 0; + + positionedErrors.forEach((error, i) => { + // 오류 전 텍스트 + if (error.startIndex! > lastIndex) { + parts.push(sentence.slice(lastIndex, error.startIndex!)); + } + + // 오류 부분 하이라이트 + parts.push( + + {sentence.slice(error.startIndex!, error.endIndex!)} + + ); + + lastIndex = error.endIndex!; + }); + + // 마지막 텍스트 + if (lastIndex < sentence.length) { + parts.push(sentence.slice(lastIndex)); + } + + return parts; +} + +// 사용 예시 +function SentenceWithErrors({ sentence, errors }: Props) { + return ( +

+ {highlightErrors(sentence, errors)} +

+ ); +} +``` + +--- + +## 응답 공통 형식 + +모든 API 응답은 다음 형식을 따릅니다: + +```typescript +interface ApiResponse { + status: 'success' | 'error'; + message: string; + data?: T; + error?: { + code: string; + message: string; + }; +} +``` + +--- + +## 에러 코드 + +| 코드 | 설명 | +|------|------| +| `INVALID_SENTENCE` | 유효하지 않은 문장 | +| `INVALID_LEVEL` | 유효하지 않은 레벨 | +| `SESSION_NOT_FOUND` | 세션을 찾을 수 없음 | +| `BEDROCK_API_ERROR` | AI API 호출 오류 | +| `BEDROCK_RESPONSE_PARSE_ERROR` | AI 응답 파싱 오류 | + +--- + +## 레벨별 특성 + +| 레벨 | 특성 | +|------|------| +| `BEGINNER` | 쉬운 어휘, 한국어 번역 포함, 격려 메시지 | +| `INTERMEDIATE` | 일상 영어, 자연스러운 표현 | +| `ADVANCED` | 고급 어휘, 관용구, 스타일 개선 | diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/AwsClients.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/AwsClients.java index 237e6bd3..3f72fdd7 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/AwsClients.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/AwsClients.java @@ -1,6 +1,7 @@ package com.mzc.secondproject.serverless.common.config; import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; +import software.amazon.awssdk.services.bedrockruntime.BedrockRuntimeAsyncClient; import software.amazon.awssdk.services.bedrockruntime.BedrockRuntimeClient; import software.amazon.awssdk.services.dynamodb.DynamoDbClient; import software.amazon.awssdk.services.polly.PollyClient; @@ -28,6 +29,7 @@ public final class AwsClients { private static final SnsClient SNS_CLIENT = SnsClient.builder().build(); // Bedrock private static final BedrockRuntimeClient BEDROCK_CLIENT = BedrockRuntimeClient.builder().build(); + private static final BedrockRuntimeAsyncClient BEDROCK_ASYNC_CLIENT = BedrockRuntimeAsyncClient.builder().build(); private AwsClients() { // 인스턴스화 방지 @@ -60,4 +62,8 @@ public static SnsClient sns() { public static BedrockRuntimeClient bedrock() { return BEDROCK_CLIENT; } + + public static BedrockRuntimeAsyncClient bedrockAsync() { + return BEDROCK_ASYNC_CLIENT; + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/JwtUtil.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/JwtUtil.java new file mode 100644 index 00000000..3fbfef0e --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/JwtUtil.java @@ -0,0 +1,127 @@ +package com.mzc.secondproject.serverless.common.util; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Optional; + +/** + * JWT 토큰 유틸리티 + * Cognito JWT 토큰에서 claims를 추출 + */ +public final class JwtUtil { + + private static final Logger logger = LoggerFactory.getLogger(JwtUtil.class); + private static final Gson gson = new Gson(); + + private JwtUtil() { + // 유틸리티 클래스 인스턴스화 방지 + } + + /** + * JWT 토큰에서 userId (sub claim) 추출 + * + * @param token JWT 토큰 (Bearer 접두사 없이) + * @return userId (Optional) + */ + public static Optional extractUserId(String token) { + return extractClaim(token, "sub"); + } + + /** + * JWT 토큰에서 email 추출 + */ + public static Optional extractEmail(String token) { + return extractClaim(token, "email"); + } + + /** + * JWT 토큰에서 특정 claim 추출 + * + * @param token JWT 토큰 + * @param claimName claim 이름 + * @return claim 값 (Optional) + */ + public static Optional extractClaim(String token, String claimName) { + try { + if (token == null || token.isEmpty()) { + return Optional.empty(); + } + + // Bearer 접두사 제거 + String cleanToken = token.startsWith("Bearer ") ? token.substring(7) : token; + + // JWT 구조: header.payload.signature + String[] parts = cleanToken.split("\\."); + if (parts.length != 3) { + logger.warn("Invalid JWT format"); + return Optional.empty(); + } + + // Payload 디코딩 (Base64 URL-safe) + String payload = new String( + Base64.getUrlDecoder().decode(parts[1]), + StandardCharsets.UTF_8 + ); + + JsonObject claims = gson.fromJson(payload, JsonObject.class); + + if (claims.has(claimName) && !claims.get(claimName).isJsonNull()) { + return Optional.of(claims.get(claimName).getAsString()); + } + + return Optional.empty(); + + } catch (Exception e) { + logger.error("Failed to extract claim from JWT: {}", e.getMessage()); + return Optional.empty(); + } + } + + /** + * JWT 토큰이 만료되었는지 확인 + * + * @param token JWT 토큰 + * @return 만료 여부 (true = 만료됨) + */ + public static boolean isExpired(String token) { + try { + Optional expClaim = extractClaim(token, "exp"); + if (expClaim.isEmpty()) { + return true; + } + + long exp = Long.parseLong(expClaim.get()); + long now = System.currentTimeMillis() / 1000; + + return now >= exp; + + } catch (Exception e) { + logger.error("Failed to check JWT expiration: {}", e.getMessage()); + return true; + } + } + + /** + * JWT 토큰 유효성 검사 (형식 및 만료) + * + * @param token JWT 토큰 + * @return 유효 여부 + */ + public static boolean isValid(String token) { + if (token == null || token.isEmpty()) { + return false; + } + + Optional userId = extractUserId(token); + if (userId.isEmpty()) { + return false; + } + + return !isExpired(token); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/factory/BedrockGrammarCheckFactory.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/factory/BedrockGrammarCheckFactory.java index b33a15fc..d7a2558d 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/factory/BedrockGrammarCheckFactory.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/factory/BedrockGrammarCheckFactory.java @@ -8,14 +8,19 @@ import com.mzc.secondproject.serverless.domain.grammar.enums.GrammarErrorType; import com.mzc.secondproject.serverless.domain.grammar.enums.GrammarLevel; import com.mzc.secondproject.serverless.domain.grammar.exception.GrammarException; +import com.mzc.secondproject.serverless.domain.grammar.streaming.StreamingCallback; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import software.amazon.awssdk.core.SdkBytes; import software.amazon.awssdk.services.bedrockruntime.model.InvokeModelRequest; import software.amazon.awssdk.services.bedrockruntime.model.InvokeModelResponse; +import software.amazon.awssdk.services.bedrockruntime.model.InvokeModelWithResponseStreamRequest; +import software.amazon.awssdk.services.bedrockruntime.model.InvokeModelWithResponseStreamResponseHandler; import java.util.ArrayList; import java.util.List; +import java.util.Objects; +import java.util.concurrent.ExecutionException; public class BedrockGrammarCheckFactory implements GrammarCheckFactory { @@ -358,6 +363,8 @@ private GrammarCheckResponse parseGrammarCheckFromJson(String originalSentence, .original(errorObj.get("original").getAsString()) .corrected(errorObj.get("corrected").getAsString()) .explanation(errorObj.get("explanation").getAsString()) + .startIndex(getIntOrNull(errorObj, "startIndex")) + .endIndex(getIntOrNull(errorObj, "endIndex")) .build(); errors.add(error); } @@ -372,4 +379,222 @@ private GrammarCheckResponse parseGrammarCheckFromJson(String originalSentence, .isCorrect(isCorrect) .build(); } + + /** + * Streaming 방식으로 대화 생성 + * 토큰이 생성될 때마다 콜백을 통해 실시간 전송 + */ + public void generateConversationStreaming( + String sessionId, + String message, + GrammarLevel level, + String conversationHistory, + StreamingCallback callback) { + + logger.info("Generating streaming conversation: sessionId={}, level={}", sessionId, level.name()); + + long startTime = System.currentTimeMillis(); + + try { + String systemPrompt = buildStreamingConversationPrompt(level); + String userPrompt = buildConversationUserPrompt(message, conversationHistory); + + JsonObject requestBody = buildStreamingRequestBody(userPrompt, systemPrompt); + + InvokeModelWithResponseStreamRequest request = InvokeModelWithResponseStreamRequest.builder() + .modelId(MODEL_ID) + .body(SdkBytes.fromUtf8String(gson.toJson(requestBody))) + .build(); + + StringBuilder fullResponse = new StringBuilder(); + + // Visitor 패턴으로 스트리밍 응답 처리 + var visitor = InvokeModelWithResponseStreamResponseHandler.Visitor.builder() + .onChunk(chunk -> { + try { + JsonObject response = gson.fromJson(chunk.bytes().asUtf8String(), JsonObject.class); + String type = response.has("type") ? response.get("type").getAsString() : ""; + + if (Objects.equals(type, "content_block_delta")) { + JsonObject delta = response.getAsJsonObject("delta"); + if (delta != null && delta.has("text")) { + String text = delta.get("text").getAsString(); + fullResponse.append(text); + callback.onToken(text); + } + } + } catch (Exception e) { + logger.warn("Failed to parse chunk: {}", e.getMessage()); + } + }) + .build(); + + var handler = InvokeModelWithResponseStreamResponseHandler.builder() + .subscriber(visitor) + .onError(error -> { + logger.error("Streaming error", error); + callback.onError(error); + }) + .onComplete(() -> { + long processingTime = System.currentTimeMillis() - startTime; + logger.info("Streaming conversation completed in {}ms", processingTime); + + try { + ConversationResponse response = parseStreamingResponse( + sessionId, message, fullResponse.toString()); + callback.onComplete(response); + } catch (Exception e) { + logger.error("Failed to parse streaming response", e); + callback.onError(e); + } + }) + .build(); + + AwsClients.bedrockAsync().invokeModelWithResponseStream(request, handler).get(); + + } catch (ExecutionException | InterruptedException e) { + logger.error("Error in streaming conversation", e); + callback.onError(e.getCause() != null ? e.getCause() : e); + } catch (Exception e) { + logger.error("Error in streaming conversation", e); + callback.onError(e); + } + } + + private String buildStreamingConversationPrompt(GrammarLevel level) { + // Streaming에서는 JSON 형식 대신 자연스러운 텍스트 응답 후 JSON 메타데이터 + String basePrompt = """ + You are a friendly English conversation partner who also helps with grammar. + When the user sends a message: + 1. First, respond naturally to continue the conversation + 2. Then provide grammar feedback if there are errors + + IMPORTANT: Structure your response EXACTLY like this: + [RESPONSE] + Your natural conversational response here. Keep it friendly and engaging. + [/RESPONSE] + + [GRAMMAR] + { + "correctedSentence": "the corrected sentence", + "score": 85, + "isCorrect": false, + "errors": [ + { + "type": "VERB_TENSE", + "original": "goed", + "corrected": "went", + "explanation": "explanation here", + "startIndex": 2, + "endIndex": 6 + } + ], + "feedback": "brief grammar feedback" + } + [/GRAMMAR] + + [TIP] + A helpful learning tip for the user. + [/TIP] + + Error types: VERB_TENSE, SUBJECT_VERB_AGREEMENT, ARTICLE, PREPOSITION, WORD_ORDER, PLURAL_SINGULAR, PRONOUN, SPELLING, PUNCTUATION, WORD_CHOICE, SENTENCE_STRUCTURE, OTHER + + If the sentence is grammatically correct, set isCorrect to true and errors to empty array. + """; + + return switch (level) { + case BEGINNER -> basePrompt + """ + + For BEGINNER level: + - Use simple vocabulary in your response + - Keep sentences short + - Include Korean translations for difficult words in parentheses + - Be very encouraging + - Provide basic grammar tips + """; + case INTERMEDIATE -> basePrompt + """ + + For INTERMEDIATE level: + - Use natural everyday English + - Introduce new vocabulary naturally + - Provide practical grammar tips + """; + case ADVANCED -> basePrompt + """ + + For ADVANCED level: + - Use sophisticated vocabulary and idioms + - Challenge the learner + - Provide advanced grammar and style tips + """; + }; + } + + private JsonObject buildStreamingRequestBody(String userPrompt, String systemPrompt) { + JsonObject requestBody = new JsonObject(); + requestBody.addProperty("anthropic_version", "bedrock-2023-05-31"); + requestBody.addProperty("max_tokens", MAX_TOKENS); + requestBody.addProperty("system", systemPrompt); + // Streaming을 위해 stop_sequences 추가하지 않음 + + JsonArray messages = new JsonArray(); + JsonObject userMessage = new JsonObject(); + userMessage.addProperty("role", "user"); + userMessage.addProperty("content", userPrompt); + messages.add(userMessage); + + requestBody.add("messages", messages); + + return requestBody; + } + + /** + * Streaming 응답 파싱 (섹션 기반) + */ + public ConversationResponse parseStreamingResponse(String sessionId, String originalMessage, String fullResponse) { + try { + String aiResponse = extractSection(fullResponse, "RESPONSE"); + String grammarJson = extractSection(fullResponse, "GRAMMAR"); + String tip = extractSection(fullResponse, "TIP"); + + GrammarCheckResponse grammarCheck; + if (grammarJson != null && !grammarJson.isEmpty()) { + JsonObject json = gson.fromJson(grammarJson, JsonObject.class); + grammarCheck = parseGrammarCheckFromJson(originalMessage, json); + } else { + // 문법 오류가 없는 경우 기본값 + grammarCheck = GrammarCheckResponse.builder() + .originalSentence(originalMessage) + .correctedSentence(originalMessage) + .score(100) + .isCorrect(true) + .errors(new ArrayList<>()) + .feedback("Perfect!") + .build(); + } + + return ConversationResponse.builder() + .sessionId(sessionId) + .grammarCheck(grammarCheck) + .aiResponse(aiResponse != null ? aiResponse.trim() : "") + .conversationTip(tip != null ? tip.trim() : "") + .build(); + + } catch (Exception e) { + logger.error("Failed to parse streaming response: {}", fullResponse, e); + throw GrammarException.bedrockResponseParseError(fullResponse); + } + } + + private String extractSection(String text, String sectionName) { + String startTag = "[" + sectionName + "]"; + String endTag = "[/" + sectionName + "]"; + + int startIndex = text.indexOf(startTag); + int endIndex = text.indexOf(endTag); + + if (startIndex != -1 && endIndex != -1 && endIndex > startIndex) { + return text.substring(startIndex + startTag.length(), endIndex).trim(); + } + return null; + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/websocket/GrammarStreamingHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/websocket/GrammarStreamingHandler.java new file mode 100644 index 00000000..4d44e008 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/websocket/GrammarStreamingHandler.java @@ -0,0 +1,175 @@ +package com.mzc.secondproject.serverless.domain.grammar.handler.websocket; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.mzc.secondproject.serverless.domain.grammar.dto.response.ConversationResponse; +import com.mzc.secondproject.serverless.domain.grammar.service.GrammarConversationService; +import com.mzc.secondproject.serverless.domain.grammar.streaming.StreamingCallback; +import com.mzc.secondproject.serverless.domain.grammar.streaming.StreamingEvent; +import com.mzc.secondproject.serverless.domain.grammar.streaming.StreamingRequest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.core.SdkBytes; +import software.amazon.awssdk.services.apigatewaymanagementapi.ApiGatewayManagementApiClient; +import software.amazon.awssdk.services.apigatewaymanagementapi.model.GoneException; +import software.amazon.awssdk.services.apigatewaymanagementapi.model.PostToConnectionRequest; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; + +/** + * Grammar Streaming WebSocket 핸들러 + * Bedrock 스트리밍 응답을 실시간으로 클라이언트에 전송 + * + * 리팩토링: + * - 세션 관리를 GrammarConversationService에 위임 + * - StreamingEvent sealed interface 활용 + * - StreamingRequest record 활용 + */ +public class GrammarStreamingHandler implements RequestHandler, Map> { + + private static final Logger logger = LoggerFactory.getLogger(GrammarStreamingHandler.class); + private static final Gson gson = new GsonBuilder().create(); + + private final GrammarConversationService conversationService; + + public GrammarStreamingHandler() { + this.conversationService = new GrammarConversationService(); + } + + @Override + public Map handleRequest(Map event, Context context) { + logger.info("Grammar streaming event received"); + + try { + String connectionId = extractConnectionId(event); + String endpoint = extractWebSocketEndpoint(event); + String body = (String) event.get("body"); + + if (body == null || body.isEmpty()) { + return sendError(connectionId, endpoint, "Message body is required"); + } + + StreamingRequest request = gson.fromJson(body, StreamingRequest.class); + + if (!request.isValid()) { + return sendError(connectionId, endpoint, "message and userId are required"); + } + + // 스트리밍 대화 처리 + processStreamingConversation(connectionId, endpoint, request); + + return createResponse(200, "Streaming started"); + + } catch (Exception e) { + logger.error("Error handling streaming request: {}", e.getMessage(), e); + return createResponse(500, "Internal server error"); + } + } + + private void processStreamingConversation(String connectionId, String endpoint, StreamingRequest request) { + ApiGatewayManagementApiClient apiClient = createApiClient(endpoint); + + // 서비스에 스트리밍 처리 위임 + conversationService.chatStreaming( + request.sessionId(), + request.message(), + request.userId(), + request.level(), + // 세션 생성 콜백 + sessionId -> sendEvent(apiClient, connectionId, new StreamingEvent.StartEvent(sessionId)), + // 스트리밍 콜백 + new StreamingCallback() { + @Override + public void onToken(String token) { + sendEvent(apiClient, connectionId, new StreamingEvent.TokenEvent(token)); + } + + @Override + public void onComplete(ConversationResponse response) { + sendEvent(apiClient, connectionId, StreamingEvent.CompleteEvent.from(response)); + logger.info("Streaming completed for session: {}", response.getSessionId()); + } + + @Override + public void onError(Throwable error) { + logger.error("Streaming error: {}", error.getMessage(), error); + sendEvent(apiClient, connectionId, new StreamingEvent.ErrorEvent(error.getMessage())); + } + } + ); + } + + private void sendEvent(ApiGatewayManagementApiClient apiClient, String connectionId, StreamingEvent event) { + String json = switch (event) { + case StreamingEvent.StartEvent e -> gson.toJson(Map.of("type", e.type(), "sessionId", e.sessionId())); + case StreamingEvent.TokenEvent e -> gson.toJson(Map.of("type", e.type(), "token", e.token())); + case StreamingEvent.CompleteEvent e -> { + Map data = new HashMap<>(); + data.put("type", e.type()); + data.put("sessionId", e.sessionId()); + data.put("grammarCheck", e.grammarCheck()); + data.put("aiResponse", e.aiResponse()); + data.put("conversationTip", e.conversationTip()); + yield gson.toJson(data); + } + case StreamingEvent.ErrorEvent e -> gson.toJson(Map.of("type", e.type(), "message", e.message())); + }; + + sendToConnection(apiClient, connectionId, json); + } + + private ApiGatewayManagementApiClient createApiClient(String endpoint) { + return ApiGatewayManagementApiClient.builder() + .endpointOverride(URI.create(endpoint)) + .build(); + } + + private boolean sendToConnection(ApiGatewayManagementApiClient apiClient, String connectionId, String message) { + try { + PostToConnectionRequest request = PostToConnectionRequest.builder() + .connectionId(connectionId) + .data(SdkBytes.fromString(message, StandardCharsets.UTF_8)) + .build(); + + apiClient.postToConnection(request); + return true; + + } catch (GoneException e) { + logger.warn("Connection gone: {}", connectionId); + return false; + + } catch (Exception e) { + logger.error("Failed to send message to connection {}: {}", connectionId, e.getMessage()); + return false; + } + } + + private Map sendError(String connectionId, String endpoint, String message) { + ApiGatewayManagementApiClient apiClient = createApiClient(endpoint); + sendEvent(apiClient, connectionId, new StreamingEvent.ErrorEvent(message)); + return createResponse(400, message); + } + + @SuppressWarnings("unchecked") + private String extractConnectionId(Map event) { + Map requestContext = (Map) event.get("requestContext"); + return (String) requestContext.get("connectionId"); + } + + @SuppressWarnings("unchecked") + private String extractWebSocketEndpoint(Map event) { + Map requestContext = (Map) event.get("requestContext"); + String domainName = (String) requestContext.get("domainName"); + String stage = (String) requestContext.get("stage"); + return "https://" + domainName + "/" + stage; + } + + private Map createResponse(int statusCode, String body) { + return Map.of("statusCode", statusCode, "body", body); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/service/GrammarConversationService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/service/GrammarConversationService.java index 7bc6ef04..58b34f82 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/service/GrammarConversationService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/service/GrammarConversationService.java @@ -11,6 +11,7 @@ import com.mzc.secondproject.serverless.domain.grammar.model.GrammarMessage; import com.mzc.secondproject.serverless.domain.grammar.model.GrammarSession; import com.mzc.secondproject.serverless.domain.grammar.repository.GrammarSessionRepository; +import com.mzc.secondproject.serverless.domain.grammar.streaming.StreamingCallback; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -18,6 +19,7 @@ import java.time.temporal.ChronoUnit; import java.util.List; import java.util.UUID; +import java.util.function.Consumer; public class GrammarConversationService { @@ -220,4 +222,65 @@ public void clearSession(String userId, String sessionId) { repository.deleteSession(userId, sessionId); logger.info("Session cleared: sessionId={}", sessionId); } + + /** + * 스트리밍 방식의 대화 처리 + * 세션 관리, 메시지 저장 등을 서비스에서 담당 + */ + public void chatStreaming( + String sessionId, + String message, + String userId, + String levelStr, + Consumer onSessionCreated, + StreamingCallback callback + ) { + logger.info("Streaming chat requested: sessionId={}, userId={}", sessionId, userId); + + GrammarLevel level = parseLevel(levelStr); + + // 세션 가져오기 또는 새로 생성 + GrammarSession session = getOrCreateSession(sessionId, userId, level); + onSessionCreated.accept(session.getSessionId()); + + // 이전 대화 히스토리 조회 + String conversationHistory = buildConversationHistory(session.getSessionId()); + + // 스트리밍 콜백 래핑 - 완료 시 메시지 저장 + grammarFactory.generateConversationStreaming( + session.getSessionId(), + message, + level, + conversationHistory, + new StreamingCallback() { + @Override + public void onToken(String token) { + callback.onToken(token); + } + + @Override + public void onComplete(ConversationResponse response) { + // 사용자 메시지 저장 + saveUserMessage(session, message, response.getGrammarCheck()); + // AI 응답 메시지 저장 + saveAssistantMessage(session, response.getAiResponse()); + // 세션 업데이트 + updateSession(session, message); + // 완료 콜백 호출 + callback.onComplete(response); + } + + @Override + public void onError(Throwable error) { + callback.onError(error); + } + } + ); + } + + // Getter for external access + public GrammarSession findOrCreateSession(String sessionId, String userId, String levelStr) { + GrammarLevel level = parseLevel(levelStr); + return getOrCreateSession(sessionId, userId, level); + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/streaming/StreamingCallback.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/streaming/StreamingCallback.java new file mode 100644 index 00000000..45741285 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/streaming/StreamingCallback.java @@ -0,0 +1,27 @@ +package com.mzc.secondproject.serverless.domain.grammar.streaming; + +import com.mzc.secondproject.serverless.domain.grammar.dto.response.ConversationResponse; + +/** + * Bedrock Streaming 응답을 처리하기 위한 콜백 인터페이스 + */ +public interface StreamingCallback { + + /** + * 토큰이 수신될 때마다 호출 + * @param token 수신된 토큰 (텍스트 조각) + */ + void onToken(String token); + + /** + * 스트리밍이 완료되고 전체 응답이 파싱되었을 때 호출 + * @param response 완성된 대화 응답 + */ + void onComplete(ConversationResponse response); + + /** + * 에러 발생 시 호출 + * @param error 발생한 예외 + */ + void onError(Throwable error); +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/streaming/StreamingEvent.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/streaming/StreamingEvent.java new file mode 100644 index 00000000..f4f78f55 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/streaming/StreamingEvent.java @@ -0,0 +1,55 @@ +package com.mzc.secondproject.serverless.domain.grammar.streaming; + +import com.mzc.secondproject.serverless.domain.grammar.dto.response.ConversationResponse; +import com.mzc.secondproject.serverless.domain.grammar.dto.response.GrammarCheckResponse; + +/** + * WebSocket 스트리밍 이벤트를 위한 Sealed Interface + * Java 17+ Sealed Class 활용 + */ +public sealed interface StreamingEvent { + + String type(); + + record StartEvent(String sessionId) implements StreamingEvent { + @Override + public String type() { + return "start"; + } + } + + record TokenEvent(String token) implements StreamingEvent { + @Override + public String type() { + return "token"; + } + } + + record CompleteEvent( + String sessionId, + GrammarCheckResponse grammarCheck, + String aiResponse, + String conversationTip + ) implements StreamingEvent { + @Override + public String type() { + return "complete"; + } + + public static CompleteEvent from(ConversationResponse response) { + return new CompleteEvent( + response.getSessionId(), + response.getGrammarCheck(), + response.getAiResponse(), + response.getConversationTip() + ); + } + } + + record ErrorEvent(String message) implements StreamingEvent { + @Override + public String type() { + return "error"; + } + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/streaming/StreamingRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/streaming/StreamingRequest.java new file mode 100644 index 00000000..9004ed9e --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/streaming/StreamingRequest.java @@ -0,0 +1,17 @@ +package com.mzc.secondproject.serverless.domain.grammar.streaming; + +/** + * Grammar 스트리밍 요청 DTO + * Java 16+ Record 활용 + */ +public record StreamingRequest( + String sessionId, + String message, + String userId, + String level +) { + public boolean isValid() { + return message != null && !message.trim().isEmpty() + && userId != null && !userId.trim().isEmpty(); + } +} From 2b13f271c1102cb58fbad0c74bbdeaa52bcc834a Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Wed, 14 Jan 2026 10:30:22 +0900 Subject: [PATCH 172/528] =?UTF-8?q?feat:=20WebSocket=20JWT=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grammar Streaming WebSocket API에 JWT 인증 추가 - $connect에서 query parameter로 JWT 토큰 검증 - 연결 정보(connectionId ↔ userId) DynamoDB에 저장 - 메시지 핸들러에서 연결 정보로 userId 자동 조회 변경 사항: - GrammarConnection: 연결 정보 모델 - GrammarConnectionRepository: 연결 정보 저장/조회/삭제 - GrammarStreamingConnectHandler: $connect 핸들러 (JWT 검증) - GrammarStreamingDisconnectHandler: $disconnect 핸들러 - GrammarStreamingHandler: connectionId로 userId 조회하도록 수정 - 프론트엔드 가이드: 인증 방식 문서화 관련 이슈: #294 --- .../docs/FRONTEND-DEVELOPMENT-GUIDE.md | 20 +++- .../GrammarStreamingConnectHandler.java | 97 +++++++++++++++++++ .../GrammarStreamingDisconnectHandler.java | 53 ++++++++++ .../websocket/GrammarStreamingHandler.java | 35 ++++--- .../grammar/model/GrammarConnection.java | 72 ++++++++++++++ .../GrammarConnectionRepository.java | 64 ++++++++++++ 6 files changed, 325 insertions(+), 16 deletions(-) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/websocket/GrammarStreamingConnectHandler.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/websocket/GrammarStreamingDisconnectHandler.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/model/GrammarConnection.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/repository/GrammarConnectionRepository.java diff --git a/ServerlessFunction/docs/FRONTEND-DEVELOPMENT-GUIDE.md b/ServerlessFunction/docs/FRONTEND-DEVELOPMENT-GUIDE.md index fa17ee69..d708a28e 100644 --- a/ServerlessFunction/docs/FRONTEND-DEVELOPMENT-GUIDE.md +++ b/ServerlessFunction/docs/FRONTEND-DEVELOPMENT-GUIDE.md @@ -143,19 +143,31 @@ interface ConversationResponse { **WebSocket Endpoint:** `wss://{websocket-api-id}.execute-api.ap-northeast-2.amazonaws.com/dev` +#### 인증 + +WebSocket 연결 시 JWT 토큰을 query parameter로 전달해야 합니다. + +``` +wss://{api-id}.execute-api.ap-northeast-2.amazonaws.com/dev?token={JWT_TOKEN} +``` + +- 토큰이 없거나 만료된 경우 연결이 거부됩니다 (401) +- 연결 성공 후에는 메시지에 userId를 포함할 필요 없음 (서버에서 자동 조회) + #### 연결 및 사용법 ```typescript -// 1. WebSocket 연결 -const ws = new WebSocket(`wss://${WEBSOCKET_API_ID}.execute-api.ap-northeast-2.amazonaws.com/dev`); +// 1. WebSocket 연결 (JWT 토큰 포함) +const ws = new WebSocket( + `wss://${WEBSOCKET_API_ID}.execute-api.ap-northeast-2.amazonaws.com/dev?token=${jwtToken}` +); -// 2. 연결 완료 시 메시지 전송 +// 2. 연결 완료 시 메시지 전송 (userId 불필요) ws.onopen = () => { const request = { action: 'grammarStreaming', // 라우트 키 sessionId: 'optional-session-id', message: 'I go to school yesterday', - userId: 'user-id-from-jwt', level: 'BEGINNER' }; ws.send(JSON.stringify(request)); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/websocket/GrammarStreamingConnectHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/websocket/GrammarStreamingConnectHandler.java new file mode 100644 index 00000000..5f42c603 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/websocket/GrammarStreamingConnectHandler.java @@ -0,0 +1,97 @@ +package com.mzc.secondproject.serverless.domain.grammar.handler.websocket; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.mzc.secondproject.serverless.common.config.WebSocketConfig; +import com.mzc.secondproject.serverless.common.util.JwtUtil; +import com.mzc.secondproject.serverless.domain.grammar.model.GrammarConnection; +import com.mzc.secondproject.serverless.domain.grammar.repository.GrammarConnectionRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +/** + * Grammar Streaming WebSocket $connect 핸들러 + * JWT 토큰 검증 후 연결 정보를 DynamoDB에 저장 + * + * 연결 방법: + * wss://{api-id}.execute-api.{region}.amazonaws.com/{stage}?token={jwt} + */ +public class GrammarStreamingConnectHandler implements RequestHandler, Map> { + + private static final Logger logger = LoggerFactory.getLogger(GrammarStreamingConnectHandler.class); + + private final GrammarConnectionRepository connectionRepository; + + public GrammarStreamingConnectHandler() { + this.connectionRepository = new GrammarConnectionRepository(); + } + + @Override + public Map handleRequest(Map event, Context context) { + logger.info("Grammar WebSocket connect event"); + + try { + String connectionId = extractConnectionId(event); + Map queryParams = extractQueryStringParameters(event); + + // JWT 토큰 검증 + String token = queryParams.get("token"); + + if (token == null || token.isEmpty()) { + logger.warn("Missing token parameter"); + return createResponse(401, "token is required"); + } + + // 토큰 유효성 검사 + if (!JwtUtil.isValid(token)) { + logger.warn("Invalid or expired token"); + return createResponse(401, "Invalid or expired token"); + } + + // userId 추출 + Optional userIdOpt = JwtUtil.extractUserId(token); + if (userIdOpt.isEmpty()) { + logger.warn("Failed to extract userId from token"); + return createResponse(401, "Invalid token"); + } + + String userId = userIdOpt.get(); + + // 연결 정보 저장 + GrammarConnection connection = GrammarConnection.create( + connectionId, + userId, + WebSocketConfig.connectionTtlSeconds() + ); + + connectionRepository.save(connection); + + logger.info("Grammar connection established: connectionId={}, userId={}", connectionId, userId); + return createResponse(200, "Connected"); + + } catch (Exception e) { + logger.error("Error handling connect: {}", e.getMessage(), e); + return createResponse(500, "Internal server error"); + } + } + + @SuppressWarnings("unchecked") + private String extractConnectionId(Map event) { + Map requestContext = (Map) event.get("requestContext"); + return (String) requestContext.get("connectionId"); + } + + @SuppressWarnings("unchecked") + private Map extractQueryStringParameters(Map event) { + Map params = (Map) event.get("queryStringParameters"); + return params != null ? params : new HashMap<>(); + } + + private Map createResponse(int statusCode, String body) { + return Map.of("statusCode", statusCode, "body", body); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/websocket/GrammarStreamingDisconnectHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/websocket/GrammarStreamingDisconnectHandler.java new file mode 100644 index 00000000..63b75d1a --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/websocket/GrammarStreamingDisconnectHandler.java @@ -0,0 +1,53 @@ +package com.mzc.secondproject.serverless.domain.grammar.handler.websocket; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.mzc.secondproject.serverless.domain.grammar.repository.GrammarConnectionRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Map; + +/** + * Grammar Streaming WebSocket $disconnect 핸들러 + * 연결 해제 시 DynamoDB에서 연결 정보 삭제 + */ +public class GrammarStreamingDisconnectHandler implements RequestHandler, Map> { + + private static final Logger logger = LoggerFactory.getLogger(GrammarStreamingDisconnectHandler.class); + + private final GrammarConnectionRepository connectionRepository; + + public GrammarStreamingDisconnectHandler() { + this.connectionRepository = new GrammarConnectionRepository(); + } + + @Override + public Map handleRequest(Map event, Context context) { + logger.info("Grammar WebSocket disconnect event"); + + try { + String connectionId = extractConnectionId(event); + + // 연결 정보 삭제 + connectionRepository.delete(connectionId); + + logger.info("Grammar connection closed: connectionId={}", connectionId); + return createResponse(200, "Disconnected"); + + } catch (Exception e) { + logger.error("Error handling disconnect: {}", e.getMessage(), e); + return createResponse(500, "Internal server error"); + } + } + + @SuppressWarnings("unchecked") + private String extractConnectionId(Map event) { + Map requestContext = (Map) event.get("requestContext"); + return (String) requestContext.get("connectionId"); + } + + private Map createResponse(int statusCode, String body) { + return Map.of("statusCode", statusCode, "body", body); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/websocket/GrammarStreamingHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/websocket/GrammarStreamingHandler.java index 4d44e008..a9d0e2e9 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/websocket/GrammarStreamingHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/websocket/GrammarStreamingHandler.java @@ -5,6 +5,8 @@ import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.mzc.secondproject.serverless.domain.grammar.dto.response.ConversationResponse; +import com.mzc.secondproject.serverless.domain.grammar.model.GrammarConnection; +import com.mzc.secondproject.serverless.domain.grammar.repository.GrammarConnectionRepository; import com.mzc.secondproject.serverless.domain.grammar.service.GrammarConversationService; import com.mzc.secondproject.serverless.domain.grammar.streaming.StreamingCallback; import com.mzc.secondproject.serverless.domain.grammar.streaming.StreamingEvent; @@ -20,15 +22,13 @@ import java.nio.charset.StandardCharsets; import java.util.HashMap; import java.util.Map; +import java.util.Optional; /** * Grammar Streaming WebSocket 핸들러 * Bedrock 스트리밍 응답을 실시간으로 클라이언트에 전송 * - * 리팩토링: - * - 세션 관리를 GrammarConversationService에 위임 - * - StreamingEvent sealed interface 활용 - * - StreamingRequest record 활용 + * 인증: $connect에서 JWT 검증 후 저장된 연결 정보에서 userId 조회 */ public class GrammarStreamingHandler implements RequestHandler, Map> { @@ -36,9 +36,11 @@ public class GrammarStreamingHandler implements RequestHandler handleRequest(Map event, Context cont try { String connectionId = extractConnectionId(event); String endpoint = extractWebSocketEndpoint(event); - String body = (String) event.get("body"); + // 연결 정보에서 userId 조회 (JWT 인증된 사용자) + Optional connectionOpt = connectionRepository.findByConnectionId(connectionId); + if (connectionOpt.isEmpty()) { + logger.warn("Connection not found: {}", connectionId); + return sendError(connectionId, endpoint, "Unauthorized - please reconnect"); + } + + String userId = connectionOpt.get().getUserId(); + + String body = (String) event.get("body"); if (body == null || body.isEmpty()) { return sendError(connectionId, endpoint, "Message body is required"); } StreamingRequest request = gson.fromJson(body, StreamingRequest.class); - if (!request.isValid()) { - return sendError(connectionId, endpoint, "message and userId are required"); + if (request.message() == null || request.message().trim().isEmpty()) { + return sendError(connectionId, endpoint, "message is required"); } - // 스트리밍 대화 처리 - processStreamingConversation(connectionId, endpoint, request); + // 스트리밍 대화 처리 (userId는 연결 정보에서 가져옴) + processStreamingConversation(connectionId, endpoint, userId, request); return createResponse(200, "Streaming started"); @@ -71,14 +82,14 @@ public Map handleRequest(Map event, Context cont } } - private void processStreamingConversation(String connectionId, String endpoint, StreamingRequest request) { + private void processStreamingConversation(String connectionId, String endpoint, String userId, StreamingRequest request) { ApiGatewayManagementApiClient apiClient = createApiClient(endpoint); - // 서비스에 스트리밍 처리 위임 + // 서비스에 스트리밍 처리 위임 (userId는 JWT 인증에서 가져온 값 사용) conversationService.chatStreaming( request.sessionId(), request.message(), - request.userId(), + userId, request.level(), // 세션 생성 콜백 sessionId -> sendEvent(apiClient, connectionId, new StreamingEvent.StartEvent(sessionId)), diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/model/GrammarConnection.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/model/GrammarConnection.java new file mode 100644 index 00000000..58cfdeb0 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/model/GrammarConnection.java @@ -0,0 +1,72 @@ +package com.mzc.secondproject.serverless.domain.grammar.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.*; + +/** + * Grammar WebSocket 연결 정보 + * connectionId ↔ userId 매핑 저장 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DynamoDbBean +public class GrammarConnection { + + private String pk; // GRAMMAR_CONN#{connectionId} + private String sk; // METADATA + private String gsi1pk; // GRAMMAR_USER#{userId} + private String gsi1sk; // CONN#{connectionId} + + private String connectionId; + private String userId; + private String connectedAt; + private Long ttl; // 자동 삭제용 + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { + return pk; + } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { + return sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1PK") + public String getGsi1pk() { + return gsi1pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1SK") + public String getGsi1sk() { + return gsi1sk; + } + + /** + * 연결 정보 생성 팩토리 메서드 + */ + public static GrammarConnection create(String connectionId, String userId, long ttlSeconds) { + String now = java.time.Instant.now().toString(); + long ttl = java.time.Instant.now().plusSeconds(ttlSeconds).getEpochSecond(); + + return GrammarConnection.builder() + .pk("GRAMMAR_CONN#" + connectionId) + .sk("METADATA") + .gsi1pk("GRAMMAR_USER#" + userId) + .gsi1sk("CONN#" + connectionId) + .connectionId(connectionId) + .userId(userId) + .connectedAt(now) + .ttl(ttl) + .build(); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/repository/GrammarConnectionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/repository/GrammarConnectionRepository.java new file mode 100644 index 00000000..49a14022 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/repository/GrammarConnectionRepository.java @@ -0,0 +1,64 @@ +package com.mzc.secondproject.serverless.domain.grammar.repository; + +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.domain.grammar.model.GrammarConnection; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; +import software.amazon.awssdk.enhanced.dynamodb.Key; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; + +import java.util.Optional; + +/** + * Grammar WebSocket 연결 정보 Repository + */ +public class GrammarConnectionRepository { + + private static final Logger logger = LoggerFactory.getLogger(GrammarConnectionRepository.class); + private static final String TABLE_NAME = System.getenv("CHAT_TABLE_NAME"); + + private final DynamoDbTable table; + + public GrammarConnectionRepository() { + this.table = AwsClients.dynamoDbEnhanced().table( + TABLE_NAME, + TableSchema.fromBean(GrammarConnection.class) + ); + } + + /** + * 연결 정보 저장 + */ + public void save(GrammarConnection connection) { + table.putItem(connection); + logger.info("Connection saved: connectionId={}, userId={}", + connection.getConnectionId(), connection.getUserId()); + } + + /** + * connectionId로 연결 정보 조회 + */ + public Optional findByConnectionId(String connectionId) { + Key key = Key.builder() + .partitionValue("GRAMMAR_CONN#" + connectionId) + .sortValue("METADATA") + .build(); + + GrammarConnection connection = table.getItem(key); + return Optional.ofNullable(connection); + } + + /** + * 연결 정보 삭제 + */ + public void delete(String connectionId) { + Key key = Key.builder() + .partitionValue("GRAMMAR_CONN#" + connectionId) + .sortValue("METADATA") + .build(); + + table.deleteItem(key); + logger.info("Connection deleted: connectionId={}", connectionId); + } +} From c358139d21ffa47ad746f0952e90b2d85e8d2125 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Wed, 14 Jan 2026 10:40:40 +0900 Subject: [PATCH 173/528] =?UTF-8?q?feat:=20Grammar=20WebSocket=20API=20?= =?UTF-8?q?=EC=9D=B8=ED=94=84=EB=9D=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit template.yaml에 Grammar Streaming WebSocket 리소스 추가 - GrammarWebSocketApi: 별도 WebSocket API Gateway - GrammarStreamingConnectFunction: $connect 핸들러 (JWT 검증) - GrammarStreamingDisconnectFunction: $disconnect 핸들러 - GrammarStreamingFunction: 스트리밍 메시지 핸들러 배포 완료: wss://17hd6eu4v5.execute-api.ap-northeast-2.amazonaws.com/dev 관련 이슈: #294 --- ServerlessFunction/template.yaml | 144 +++++++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index b1b6d2b4..31f1be5a 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -1023,6 +1023,146 @@ Resources: Auth: Authorizer: CognitoAuthorizer + ############################################# + # Grammar WebSocket API (Streaming) + ############################################# + + GrammarWebSocketApi: + Type: AWS::ApiGatewayV2::Api + Properties: + Name: group2-englishstudy-grammar-websocket + ProtocolType: WEBSOCKET + RouteSelectionExpression: "$request.body.action" + + GrammarWebSocketStage: + Type: AWS::ApiGatewayV2::Stage + Properties: + ApiId: !Ref GrammarWebSocketApi + StageName: dev + AutoDeploy: true + + # Grammar WebSocket Connect Route + GrammarConnectRoute: + Type: AWS::ApiGatewayV2::Route + Properties: + ApiId: !Ref GrammarWebSocketApi + RouteKey: $connect + AuthorizationType: NONE + Target: !Sub integrations/${GrammarConnectIntegration} + + GrammarConnectIntegration: + Type: AWS::ApiGatewayV2::Integration + Properties: + ApiId: !Ref GrammarWebSocketApi + IntegrationType: AWS_PROXY + IntegrationUri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${GrammarStreamingConnectFunction.Arn}/invocations + + # Grammar WebSocket Disconnect Route + GrammarDisconnectRoute: + Type: AWS::ApiGatewayV2::Route + Properties: + ApiId: !Ref GrammarWebSocketApi + RouteKey: $disconnect + AuthorizationType: NONE + Target: !Sub integrations/${GrammarDisconnectIntegration} + + GrammarDisconnectIntegration: + Type: AWS::ApiGatewayV2::Integration + Properties: + ApiId: !Ref GrammarWebSocketApi + IntegrationType: AWS_PROXY + IntegrationUri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${GrammarStreamingDisconnectFunction.Arn}/invocations + + # Grammar WebSocket Streaming Route + GrammarStreamingRoute: + Type: AWS::ApiGatewayV2::Route + Properties: + ApiId: !Ref GrammarWebSocketApi + RouteKey: grammarStreaming + AuthorizationType: NONE + Target: !Sub integrations/${GrammarStreamingIntegration} + + GrammarStreamingIntegration: + Type: AWS::ApiGatewayV2::Integration + Properties: + ApiId: !Ref GrammarWebSocketApi + IntegrationType: AWS_PROXY + IntegrationUri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${GrammarStreamingFunction.Arn}/invocations + + # Grammar WebSocket Lambda Functions + GrammarStreamingConnectFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: group2-grammar-ws-connect + CodeUri: . + Handler: com.mzc.secondproject.serverless.domain.grammar.handler.websocket.GrammarStreamingConnectHandler::handleRequest + Description: Handle Grammar WebSocket $connect with JWT auth + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref ChatTable + + GrammarStreamingConnectPermission: + Type: AWS::Lambda::Permission + Properties: + Action: lambda:InvokeFunction + FunctionName: !Ref GrammarStreamingConnectFunction + Principal: apigateway.amazonaws.com + SourceArn: !Sub arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${GrammarWebSocketApi}/*/$connect + + GrammarStreamingDisconnectFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: group2-grammar-ws-disconnect + CodeUri: . + Handler: com.mzc.secondproject.serverless.domain.grammar.handler.websocket.GrammarStreamingDisconnectHandler::handleRequest + Description: Handle Grammar WebSocket $disconnect + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref ChatTable + + GrammarStreamingDisconnectPermission: + Type: AWS::Lambda::Permission + Properties: + Action: lambda:InvokeFunction + FunctionName: !Ref GrammarStreamingDisconnectFunction + Principal: apigateway.amazonaws.com + SourceArn: !Sub arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${GrammarWebSocketApi}/*/$disconnect + + GrammarStreamingFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: group2-grammar-ws-streaming + CodeUri: . + Handler: com.mzc.secondproject.serverless.domain.grammar.handler.websocket.GrammarStreamingHandler::handleRequest + Description: Handle Grammar streaming with Bedrock + Timeout: 120 + MemorySize: 1024 + Environment: + Variables: + GRAMMAR_WEBSOCKET_ENDPOINT: !Sub "https://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref ChatTable + - Statement: + - Effect: Allow + Action: + - bedrock:InvokeModel + - bedrock:InvokeModelWithResponseStream + Resource: "*" + - Statement: + - Effect: Allow + Action: + - execute-api:ManageConnections + Resource: !Sub arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${GrammarWebSocketApi}/* + + GrammarStreamingPermission: + Type: AWS::Lambda::Permission + Properties: + Action: lambda:InvokeFunction + FunctionName: !Ref GrammarStreamingFunction + Principal: apigateway.amazonaws.com + SourceArn: !Sub arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${GrammarWebSocketApi}/*/grammarStreaming + # EventBridge Scheduler - 매일 자정 단어 학습 통계 집계 ScheduledStatsFunction: Type: AWS::Serverless::Function @@ -1287,6 +1427,10 @@ Outputs: Description: WebSocket API Gateway endpoint URL Value: !Sub 'wss://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev' + GrammarWebSocketUrl: + Description: Grammar Streaming WebSocket API endpoint URL + Value: !Sub 'wss://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev' + ChatTableName: Description: Chat DynamoDB Table Name Value: !Ref ChatTable From e5c6f708e9ac6a8f921640ccf88ebf97b3477ce2 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Wed, 14 Jan 2026 11:14:38 +0900 Subject: [PATCH 174/528] =?UTF-8?q?refactor:=20WebSocket=20=ED=95=B8?= =?UTF-8?q?=EB=93=A4=EB=9F=AC=20=EC=9C=A0=ED=8B=B8=EB=A6=AC=ED=8B=B0=20?= =?UTF-8?q?=EA=B3=B5=ED=86=B5=ED=99=94=20=EB=B0=8F=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=ED=92=88=EC=A7=88=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WebSocketEventUtil 공통 유틸리티 클래스 생성 - extractConnectionId, extractQueryStringParameters 등 중복 메서드 제거 - 6개 핸들러(Grammar 3개, Chatting 3개)에서 공통 유틸 사용 - GrammarCheckResponse 파싱 로직 통합 - parseGrammarResponse에서 parseGrammarCheckFromJson 재사용 - 로깅 개선 - 전체 응답 대신 길이만 로깅하여 민감정보 노출 방지 - 상수화 - GrammarConnection에 DynamoDB key prefix 상수 추가 - GrammarConversationService에 LAST_MESSAGE_MAX_LENGTH 상수 추가 - 예외 처리 개선 - InterruptedException 발생 시 스레드 인터럽트 상태 복원 Closes #296 --- .../common/util/WebSocketEventUtil.java | 80 +++++++++++++++++++ .../websocket/WebSocketConnectHandler.java | 53 ++++-------- .../websocket/WebSocketDisconnectHandler.java | 31 +++---- .../websocket/WebSocketMessageHandler.java | 32 +++----- .../factory/BedrockGrammarCheckFactory.java | 65 ++++++--------- .../GrammarStreamingConnectHandler.java | 32 ++------ .../GrammarStreamingDisconnectHandler.java | 17 +--- .../websocket/GrammarStreamingHandler.java | 29 ++----- .../grammar/model/GrammarConnection.java | 14 +++- .../GrammarConnectionRepository.java | 8 +- .../service/GrammarConversationService.java | 3 +- 11 files changed, 176 insertions(+), 188 deletions(-) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketEventUtil.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketEventUtil.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketEventUtil.java new file mode 100644 index 00000000..899c528f --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketEventUtil.java @@ -0,0 +1,80 @@ +package com.mzc.secondproject.serverless.common.util; + +import java.util.HashMap; +import java.util.Map; + +/** + * WebSocket 이벤트 처리 유틸리티 + * 공통 메서드를 제공하여 핸들러 간 코드 중복 제거 + */ +public final class WebSocketEventUtil { + + private WebSocketEventUtil() { + // 유틸리티 클래스 인스턴스화 방지 + } + + /** + * WebSocket 이벤트에서 connectionId 추출 + */ + @SuppressWarnings("unchecked") + public static String extractConnectionId(Map event) { + Map requestContext = (Map) event.get("requestContext"); + return (String) requestContext.get("connectionId"); + } + + /** + * WebSocket 이벤트에서 queryStringParameters 추출 + */ + @SuppressWarnings("unchecked") + public static Map extractQueryStringParameters(Map event) { + Map params = (Map) event.get("queryStringParameters"); + return params != null ? params : new HashMap<>(); + } + + /** + * WebSocket 이벤트에서 endpoint URL 추출 + * API Gateway Management API 호출에 사용 + */ + @SuppressWarnings("unchecked") + public static String extractWebSocketEndpoint(Map event) { + Map requestContext = (Map) event.get("requestContext"); + String domainName = (String) requestContext.get("domainName"); + String stage = (String) requestContext.get("stage"); + return "https://" + domainName + "/" + stage; + } + + /** + * WebSocket 응답 생성 + */ + public static Map createResponse(int statusCode, String body) { + return Map.of("statusCode", statusCode, "body", body); + } + + /** + * 성공 응답 생성 (200) + */ + public static Map ok(String message) { + return createResponse(200, message); + } + + /** + * 인증 실패 응답 생성 (401) + */ + public static Map unauthorized(String message) { + return createResponse(401, message); + } + + /** + * 서버 에러 응답 생성 (500) + */ + public static Map serverError(String message) { + return createResponse(500, message); + } + + /** + * 잘못된 요청 응답 생성 (400) + */ + public static Map badRequest(String message) { + return createResponse(400, message); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketConnectHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketConnectHandler.java index bba1c18a..28f278c8 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketConnectHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketConnectHandler.java @@ -3,6 +3,7 @@ import com.amazonaws.services.lambda.runtime.Context; import com.amazonaws.services.lambda.runtime.RequestHandler; import com.mzc.secondproject.serverless.common.config.WebSocketConfig; +import com.mzc.secondproject.serverless.common.util.WebSocketEventUtil; import com.mzc.secondproject.serverless.domain.chatting.model.Connection; import com.mzc.secondproject.serverless.domain.chatting.model.RoomToken; import com.mzc.secondproject.serverless.domain.chatting.repository.ConnectionRepository; @@ -11,7 +12,6 @@ import org.slf4j.LoggerFactory; import java.time.Instant; -import java.util.HashMap; import java.util.Map; import java.util.Optional; @@ -34,32 +34,32 @@ public WebSocketConnectHandler() { @Override public Map handleRequest(Map event, Context context) { logger.info("WebSocket connect event: {}", event); - + try { - String connectionId = extractConnectionId(event); - Map queryParams = extractQueryStringParameters(event); - + String connectionId = WebSocketEventUtil.extractConnectionId(event); + Map queryParams = WebSocketEventUtil.extractQueryStringParameters(event); + String roomToken = queryParams.get("roomToken"); - + if (roomToken == null || roomToken.isEmpty()) { logger.warn("Missing roomToken parameter"); - return createResponse(401, "roomToken is required"); + return WebSocketEventUtil.unauthorized("roomToken is required"); } - + // 토큰 검증 Optional optToken = roomTokenService.validateToken(roomToken); if (optToken.isEmpty()) { logger.warn("Invalid or expired roomToken: {}", roomToken); - return createResponse(401, "Invalid or expired token"); + return WebSocketEventUtil.unauthorized("Invalid or expired token"); } - + RoomToken token = optToken.get(); String userId = token.getUserId(); String roomId = token.getRoomId(); - + String now = Instant.now().toString(); long ttl = Instant.now().plusSeconds(WebSocketConfig.connectionTtlSeconds()).getEpochSecond(); - + Connection connection = Connection.builder() .pk("CONN#" + connectionId) .sk("METADATA") @@ -73,34 +73,15 @@ public Map handleRequest(Map event, Context cont .connectedAt(now) .ttl(ttl) .build(); - + connectionRepository.save(connection); - + logger.info("Connection saved: connectionId={}, userId={}, roomId={}", connectionId, userId, roomId); - return createResponse(200, "Connected"); - + return WebSocketEventUtil.ok("Connected"); + } catch (Exception e) { logger.error("Error handling connect: {}", e.getMessage(), e); - return createResponse(500, "Internal server error"); + return WebSocketEventUtil.serverError("Internal server error"); } } - - @SuppressWarnings("unchecked") - private String extractConnectionId(Map event) { - Map requestContext = (Map) event.get("requestContext"); - return (String) requestContext.get("connectionId"); - } - - @SuppressWarnings("unchecked") - private Map extractQueryStringParameters(Map event) { - Map params = (Map) event.get("queryStringParameters"); - return params != null ? params : new HashMap<>(); - } - - private Map createResponse(int statusCode, String body) { - Map response = new HashMap<>(); - response.put("statusCode", statusCode); - response.put("body", body); - return response; - } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketDisconnectHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketDisconnectHandler.java index 07935ab8..71f54731 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketDisconnectHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketDisconnectHandler.java @@ -2,12 +2,12 @@ import com.amazonaws.services.lambda.runtime.Context; import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.mzc.secondproject.serverless.common.util.WebSocketEventUtil; import com.mzc.secondproject.serverless.domain.chatting.model.Connection; import com.mzc.secondproject.serverless.domain.chatting.repository.ConnectionRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.util.HashMap; import java.util.Map; import java.util.Optional; @@ -28,12 +28,12 @@ public WebSocketDisconnectHandler() { @Override public Map handleRequest(Map event, Context context) { logger.info("WebSocket disconnect event: {}", event); - + try { - String connectionId = extractConnectionId(event); - + String connectionId = WebSocketEventUtil.extractConnectionId(event); + Optional connection = connectionRepository.findByConnectionId(connectionId); - + if (connection.isPresent()) { Connection conn = connection.get(); connectionRepository.delete(connectionId); @@ -42,25 +42,12 @@ public Map handleRequest(Map event, Context cont } else { logger.warn("Connection not found for deletion: connectionId={}", connectionId); } - - return createResponse(200, "Disconnected"); - + + return WebSocketEventUtil.ok("Disconnected"); + } catch (Exception e) { logger.error("Error handling disconnect: {}", e.getMessage(), e); - return createResponse(500, "Internal server error"); + return WebSocketEventUtil.serverError("Internal server error"); } } - - @SuppressWarnings("unchecked") - private String extractConnectionId(Map event) { - Map requestContext = (Map) event.get("requestContext"); - return (String) requestContext.get("connectionId"); - } - - private Map createResponse(int statusCode, String body) { - Map response = new HashMap<>(); - response.put("statusCode", statusCode); - response.put("body", body); - return response; - } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java index 2e0cd234..3716e2f3 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java @@ -5,6 +5,7 @@ import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.mzc.secondproject.serverless.common.util.WebSocketBroadcaster; +import com.mzc.secondproject.serverless.common.util.WebSocketEventUtil; import com.mzc.secondproject.serverless.domain.chatting.dto.response.CommandResult; import com.mzc.secondproject.serverless.domain.chatting.model.ChatMessage; import com.mzc.secondproject.serverless.domain.chatting.model.Connection; @@ -53,17 +54,17 @@ public Map handleRequest(Map event, Context cont logger.info("WebSocket message event: {}", event); try { - String connectionId = extractConnectionId(event); + String connectionId = WebSocketEventUtil.extractConnectionId(event); String body = (String) event.get("body"); if (body == null || body.isEmpty()) { - return createResponse(400, "Message body is required"); + return WebSocketEventUtil.badRequest("Message body is required"); } MessagePayload payload = gson.fromJson(body, MessagePayload.class); if (payload.roomId == null || payload.userId == null) { - return createResponse(400, "roomId and userId are required"); + return WebSocketEventUtil.badRequest("roomId and userId are required"); } String messageType = payload.messageType != null ? payload.messageType : "TEXT"; @@ -76,7 +77,7 @@ public Map handleRequest(Map event, Context cont } catch (Exception e) { logger.error("Error handling message: {}", e.getMessage(), e); - return createResponse(500, "Internal server error"); + return WebSocketEventUtil.serverError("Internal server error"); } } @@ -113,7 +114,7 @@ private Map handleDrawingMessage(String connectionId, MessagePay } logger.info("Drawing broadcasted to {} connections (excluding sender)", otherConnections.size()); - return createResponse(200, "Drawing sent"); + return WebSocketEventUtil.ok("Drawing sent"); } /** @@ -121,7 +122,7 @@ private Map handleDrawingMessage(String connectionId, MessagePay */ private Map handleRegularMessage(String connectionId, MessagePayload payload, String messageType) { if (payload.content == null) { - return createResponse(400, "content is required for text messages"); + return WebSocketEventUtil.badRequest("content is required for text messages"); } // 슬래시 명령어 처리 @@ -171,7 +172,7 @@ private Map handleRegularMessage(String connectionId, MessagePay logger.info("Deleted stale connection: {}", failedConnectionId); } - return createResponse(200, "Message sent"); + return WebSocketEventUtil.ok("Message sent"); } /** @@ -193,7 +194,7 @@ private Map handleCorrectAnswer(MessagePayload payload, GameServ handleAllCorrect(payload.roomId); } - return createResponse(200, "Correct answer"); + return WebSocketEventUtil.ok("Correct answer"); } /** @@ -277,19 +278,6 @@ private void handleAllCorrect(String roomId) { handleCommandResult(endResult, roomId, "SYSTEM"); }); } - - @SuppressWarnings("unchecked") - private String extractConnectionId(Map event) { - Map requestContext = (Map) event.get("requestContext"); - return (String) requestContext.get("connectionId"); - } - - private Map createResponse(int statusCode, String body) { - Map response = new HashMap<>(); - response.put("statusCode", statusCode); - response.put("body", body); - return response; - } /** * 명령어 처리 결과를 브로드캐스트 @@ -326,7 +314,7 @@ private Map handleCommandResult(CommandResult result, String roo } logger.info("Command result broadcasted: type={}, roomId={}", result.messageType(), roomId); - return createResponse(200, "Command executed"); + return WebSocketEventUtil.ok("Command executed"); } /** diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/factory/BedrockGrammarCheckFactory.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/factory/BedrockGrammarCheckFactory.java index d7a2558d..f00128ff 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/factory/BedrockGrammarCheckFactory.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/factory/BedrockGrammarCheckFactory.java @@ -148,41 +148,16 @@ private GrammarCheckResponse parseGrammarResponse(String originalSentence, Strin try { String jsonContent = extractJson(aiResponse); JsonObject json = gson.fromJson(jsonContent, JsonObject.class); - - String correctedSentence = json.get("correctedSentence").getAsString(); - int score = json.get("score").getAsInt(); - boolean isCorrect = json.get("isCorrect").getAsBoolean(); - String feedback = json.get("feedback").getAsString(); - - List errors = new ArrayList<>(); - JsonArray errorsArray = json.getAsJsonArray("errors"); - if (errorsArray != null) { - for (JsonElement element : errorsArray) { - JsonObject errorObj = element.getAsJsonObject(); - GrammarError error = GrammarError.builder() - .type(parseErrorType(errorObj.get("type").getAsString())) - .original(errorObj.get("original").getAsString()) - .corrected(errorObj.get("corrected").getAsString()) - .explanation(errorObj.get("explanation").getAsString()) - .startIndex(getIntOrNull(errorObj, "startIndex")) - .endIndex(getIntOrNull(errorObj, "endIndex")) - .build(); - errors.add(error); - } - } - - return GrammarCheckResponse.builder() - .originalSentence(originalSentence) - .correctedSentence(correctedSentence) - .score(score) - .errors(errors) - .feedback(feedback) - .isCorrect(isCorrect) - .build(); - + return parseGrammarCheckFromJson(originalSentence, json); + } catch (GrammarException e) { + throw e; } catch (Exception e) { - logger.error("Failed to parse AI response: {}", aiResponse, e); - throw GrammarException.bedrockResponseParseError(aiResponse); + logger.error("Failed to parse grammar response: length={}", + aiResponse != null ? aiResponse.length() : 0, e); + throw GrammarException.bedrockResponseParseError( + aiResponse != null && aiResponse.length() > 200 + ? aiResponse.substring(0, 200) + "..." + : aiResponse); } } @@ -342,8 +317,12 @@ private ConversationResponse parseConversationResponse(String sessionId, String .build(); } catch (Exception e) { - logger.error("Failed to parse conversation response: {}", aiResponse, e); - throw GrammarException.bedrockResponseParseError(aiResponse); + logger.error("Failed to parse conversation response: length={}", + aiResponse != null ? aiResponse.length() : 0, e); + throw GrammarException.bedrockResponseParseError( + aiResponse != null && aiResponse.length() > 200 + ? aiResponse.substring(0, 200) + "..." + : aiResponse); } } @@ -452,7 +431,11 @@ public void generateConversationStreaming( AwsClients.bedrockAsync().invokeModelWithResponseStream(request, handler).get(); - } catch (ExecutionException | InterruptedException e) { + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + logger.error("Streaming conversation interrupted", e); + callback.onError(e); + } catch (ExecutionException e) { logger.error("Error in streaming conversation", e); callback.onError(e.getCause() != null ? e.getCause() : e); } catch (Exception e) { @@ -580,8 +563,12 @@ public ConversationResponse parseStreamingResponse(String sessionId, String orig .build(); } catch (Exception e) { - logger.error("Failed to parse streaming response: {}", fullResponse, e); - throw GrammarException.bedrockResponseParseError(fullResponse); + logger.error("Failed to parse streaming response: length={}", + fullResponse != null ? fullResponse.length() : 0, e); + throw GrammarException.bedrockResponseParseError( + fullResponse != null && fullResponse.length() > 200 + ? fullResponse.substring(0, 200) + "..." + : fullResponse); } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/websocket/GrammarStreamingConnectHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/websocket/GrammarStreamingConnectHandler.java index 5f42c603..c3fe8564 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/websocket/GrammarStreamingConnectHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/websocket/GrammarStreamingConnectHandler.java @@ -4,12 +4,12 @@ import com.amazonaws.services.lambda.runtime.RequestHandler; import com.mzc.secondproject.serverless.common.config.WebSocketConfig; import com.mzc.secondproject.serverless.common.util.JwtUtil; +import com.mzc.secondproject.serverless.common.util.WebSocketEventUtil; import com.mzc.secondproject.serverless.domain.grammar.model.GrammarConnection; import com.mzc.secondproject.serverless.domain.grammar.repository.GrammarConnectionRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.util.HashMap; import java.util.Map; import java.util.Optional; @@ -35,28 +35,28 @@ public Map handleRequest(Map event, Context cont logger.info("Grammar WebSocket connect event"); try { - String connectionId = extractConnectionId(event); - Map queryParams = extractQueryStringParameters(event); + String connectionId = WebSocketEventUtil.extractConnectionId(event); + Map queryParams = WebSocketEventUtil.extractQueryStringParameters(event); // JWT 토큰 검증 String token = queryParams.get("token"); if (token == null || token.isEmpty()) { logger.warn("Missing token parameter"); - return createResponse(401, "token is required"); + return WebSocketEventUtil.unauthorized("token is required"); } // 토큰 유효성 검사 if (!JwtUtil.isValid(token)) { logger.warn("Invalid or expired token"); - return createResponse(401, "Invalid or expired token"); + return WebSocketEventUtil.unauthorized("Invalid or expired token"); } // userId 추출 Optional userIdOpt = JwtUtil.extractUserId(token); if (userIdOpt.isEmpty()) { logger.warn("Failed to extract userId from token"); - return createResponse(401, "Invalid token"); + return WebSocketEventUtil.unauthorized("Invalid token"); } String userId = userIdOpt.get(); @@ -71,27 +71,11 @@ public Map handleRequest(Map event, Context cont connectionRepository.save(connection); logger.info("Grammar connection established: connectionId={}, userId={}", connectionId, userId); - return createResponse(200, "Connected"); + return WebSocketEventUtil.ok("Connected"); } catch (Exception e) { logger.error("Error handling connect: {}", e.getMessage(), e); - return createResponse(500, "Internal server error"); + return WebSocketEventUtil.serverError("Internal server error"); } } - - @SuppressWarnings("unchecked") - private String extractConnectionId(Map event) { - Map requestContext = (Map) event.get("requestContext"); - return (String) requestContext.get("connectionId"); - } - - @SuppressWarnings("unchecked") - private Map extractQueryStringParameters(Map event) { - Map params = (Map) event.get("queryStringParameters"); - return params != null ? params : new HashMap<>(); - } - - private Map createResponse(int statusCode, String body) { - return Map.of("statusCode", statusCode, "body", body); - } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/websocket/GrammarStreamingDisconnectHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/websocket/GrammarStreamingDisconnectHandler.java index 63b75d1a..f36badc1 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/websocket/GrammarStreamingDisconnectHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/websocket/GrammarStreamingDisconnectHandler.java @@ -2,6 +2,7 @@ import com.amazonaws.services.lambda.runtime.Context; import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.mzc.secondproject.serverless.common.util.WebSocketEventUtil; import com.mzc.secondproject.serverless.domain.grammar.repository.GrammarConnectionRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -27,27 +28,17 @@ public Map handleRequest(Map event, Context cont logger.info("Grammar WebSocket disconnect event"); try { - String connectionId = extractConnectionId(event); + String connectionId = WebSocketEventUtil.extractConnectionId(event); // 연결 정보 삭제 connectionRepository.delete(connectionId); logger.info("Grammar connection closed: connectionId={}", connectionId); - return createResponse(200, "Disconnected"); + return WebSocketEventUtil.ok("Disconnected"); } catch (Exception e) { logger.error("Error handling disconnect: {}", e.getMessage(), e); - return createResponse(500, "Internal server error"); + return WebSocketEventUtil.serverError("Internal server error"); } } - - @SuppressWarnings("unchecked") - private String extractConnectionId(Map event) { - Map requestContext = (Map) event.get("requestContext"); - return (String) requestContext.get("connectionId"); - } - - private Map createResponse(int statusCode, String body) { - return Map.of("statusCode", statusCode, "body", body); - } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/websocket/GrammarStreamingHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/websocket/GrammarStreamingHandler.java index a9d0e2e9..c9c2c8d4 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/websocket/GrammarStreamingHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/websocket/GrammarStreamingHandler.java @@ -4,6 +4,7 @@ import com.amazonaws.services.lambda.runtime.RequestHandler; import com.google.gson.Gson; import com.google.gson.GsonBuilder; +import com.mzc.secondproject.serverless.common.util.WebSocketEventUtil; import com.mzc.secondproject.serverless.domain.grammar.dto.response.ConversationResponse; import com.mzc.secondproject.serverless.domain.grammar.model.GrammarConnection; import com.mzc.secondproject.serverless.domain.grammar.repository.GrammarConnectionRepository; @@ -48,8 +49,8 @@ public Map handleRequest(Map event, Context cont logger.info("Grammar streaming event received"); try { - String connectionId = extractConnectionId(event); - String endpoint = extractWebSocketEndpoint(event); + String connectionId = WebSocketEventUtil.extractConnectionId(event); + String endpoint = WebSocketEventUtil.extractWebSocketEndpoint(event); // 연결 정보에서 userId 조회 (JWT 인증된 사용자) Optional connectionOpt = connectionRepository.findByConnectionId(connectionId); @@ -74,11 +75,11 @@ public Map handleRequest(Map event, Context cont // 스트리밍 대화 처리 (userId는 연결 정보에서 가져옴) processStreamingConversation(connectionId, endpoint, userId, request); - return createResponse(200, "Streaming started"); + return WebSocketEventUtil.ok("Streaming started"); } catch (Exception e) { logger.error("Error handling streaming request: {}", e.getMessage(), e); - return createResponse(500, "Internal server error"); + return WebSocketEventUtil.serverError("Internal server error"); } } @@ -163,24 +164,6 @@ private boolean sendToConnection(ApiGatewayManagementApiClient apiClient, String private Map sendError(String connectionId, String endpoint, String message) { ApiGatewayManagementApiClient apiClient = createApiClient(endpoint); sendEvent(apiClient, connectionId, new StreamingEvent.ErrorEvent(message)); - return createResponse(400, message); - } - - @SuppressWarnings("unchecked") - private String extractConnectionId(Map event) { - Map requestContext = (Map) event.get("requestContext"); - return (String) requestContext.get("connectionId"); - } - - @SuppressWarnings("unchecked") - private String extractWebSocketEndpoint(Map event) { - Map requestContext = (Map) event.get("requestContext"); - String domainName = (String) requestContext.get("domainName"); - String stage = (String) requestContext.get("stage"); - return "https://" + domainName + "/" + stage; - } - - private Map createResponse(int statusCode, String body) { - return Map.of("statusCode", statusCode, "body", body); + return WebSocketEventUtil.badRequest(message); } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/model/GrammarConnection.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/model/GrammarConnection.java index 58cfdeb0..96b2f8a0 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/model/GrammarConnection.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/model/GrammarConnection.java @@ -17,6 +17,12 @@ @DynamoDbBean public class GrammarConnection { + // DynamoDB Key Prefixes + public static final String PK_PREFIX = "GRAMMAR_CONN#"; + public static final String SK_METADATA = "METADATA"; + public static final String GSI1PK_PREFIX = "GRAMMAR_USER#"; + public static final String GSI1SK_PREFIX = "CONN#"; + private String pk; // GRAMMAR_CONN#{connectionId} private String sk; // METADATA private String gsi1pk; // GRAMMAR_USER#{userId} @@ -59,10 +65,10 @@ public static GrammarConnection create(String connectionId, String userId, long long ttl = java.time.Instant.now().plusSeconds(ttlSeconds).getEpochSecond(); return GrammarConnection.builder() - .pk("GRAMMAR_CONN#" + connectionId) - .sk("METADATA") - .gsi1pk("GRAMMAR_USER#" + userId) - .gsi1sk("CONN#" + connectionId) + .pk(PK_PREFIX + connectionId) + .sk(SK_METADATA) + .gsi1pk(GSI1PK_PREFIX + userId) + .gsi1sk(GSI1SK_PREFIX + connectionId) .connectionId(connectionId) .userId(userId) .connectedAt(now) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/repository/GrammarConnectionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/repository/GrammarConnectionRepository.java index 49a14022..5a0e3dff 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/repository/GrammarConnectionRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/repository/GrammarConnectionRepository.java @@ -41,8 +41,8 @@ public void save(GrammarConnection connection) { */ public Optional findByConnectionId(String connectionId) { Key key = Key.builder() - .partitionValue("GRAMMAR_CONN#" + connectionId) - .sortValue("METADATA") + .partitionValue(GrammarConnection.PK_PREFIX + connectionId) + .sortValue(GrammarConnection.SK_METADATA) .build(); GrammarConnection connection = table.getItem(key); @@ -54,8 +54,8 @@ public Optional findByConnectionId(String connectionId) { */ public void delete(String connectionId) { Key key = Key.builder() - .partitionValue("GRAMMAR_CONN#" + connectionId) - .sortValue("METADATA") + .partitionValue(GrammarConnection.PK_PREFIX + connectionId) + .sortValue(GrammarConnection.SK_METADATA) .build(); table.deleteItem(key); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/service/GrammarConversationService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/service/GrammarConversationService.java index 58b34f82..4917da92 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/service/GrammarConversationService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/service/GrammarConversationService.java @@ -26,6 +26,7 @@ public class GrammarConversationService { private static final Logger logger = LoggerFactory.getLogger(GrammarConversationService.class); private static final int SESSION_TTL_DAYS = 30; private static final int MAX_HISTORY_MESSAGES = 10; + private static final int LAST_MESSAGE_MAX_LENGTH = 100; private final BedrockGrammarCheckFactory grammarFactory; private final GrammarSessionRepository repository; @@ -194,7 +195,7 @@ private void updateSession(GrammarSession session, String lastMessage) { String now = Instant.now().toString(); session.setGsi1sk(GrammarKey.updatedSk(now)); session.setMessageCount(session.getMessageCount() + 2); // user + assistant - session.setLastMessage(truncateMessage(lastMessage, 100)); + session.setLastMessage(truncateMessage(lastMessage, LAST_MESSAGE_MAX_LENGTH)); session.setUpdatedAt(now); repository.saveSession(session); From 60bf156afafd9b10e36e7da1243fcd0c4b9db7bb Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Wed, 14 Jan 2026 14:56:22 +0900 Subject: [PATCH 175/528] =?UTF-8?q?feat:=20AWS=20X-Ray=20=EB=B6=84?= =?UTF-8?q?=EC=82=B0=20=EC=B6=94=EC=A0=81=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Lambda 함수 전체에 X-Ray 추적 활성화 (Tracing: Active) - API Gateway에 X-Ray 추적 활성화 (TracingEnabled: true) - 성능 모니터링 및 디버깅 지원 Closes #298 --- ServerlessFunction/template.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 31f1be5a..29f3b534 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -9,6 +9,7 @@ Globals: Runtime: java21 Architectures: - x86_64 + Tracing: Active Environment: Variables: USER_TABLE_NAME: !Ref UserTable @@ -18,6 +19,8 @@ Globals: VOCAB_BUCKET_NAME: group2-englishstudy AWS_REGION_NAME: !Ref AWS::Region ROOM_TOKEN_TTL_SECONDS: "300" + Api: + TracingEnabled: true Resources: ############################################# From 8fd5a3a4588786fd168c8a1c5fea7706572e1abb Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Wed, 14 Jan 2026 15:04:58 +0900 Subject: [PATCH 176/528] =?UTF-8?q?fix:=20X-Ray=20SDK=20=ED=86=B5=ED=95=A9?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=8B=A4=EC=9A=B4=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EB=A6=BC=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=B6=94=EC=A0=81=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AWS X-Ray SDK 의존성 추가 (core, aws-sdk-v2) - AwsClients에 TracingInterceptor 적용 - DynamoDB, S3, Polly, SNS, Bedrock 호출 추적 --- ServerlessFunction/build.gradle | 4 +++ .../serverless/common/config/AwsClients.java | 33 +++++++++++++++---- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/ServerlessFunction/build.gradle b/ServerlessFunction/build.gradle index 417ce016..71c89501 100644 --- a/ServerlessFunction/build.gradle +++ b/ServerlessFunction/build.gradle @@ -32,6 +32,10 @@ dependencies { implementation 'software.amazon.awssdk:bedrockruntime' implementation 'software.amazon.awssdk:apigatewaymanagementapi' + // AWS X-Ray SDK (다운스트림 서비스 추적용) + implementation 'com.amazonaws:aws-xray-recorder-sdk-core:2.15.0' + implementation 'com.amazonaws:aws-xray-recorder-sdk-aws-sdk-v2:2.15.0' + // JSON Processing implementation 'com.google.code.gson:gson:2.10.1' diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/AwsClients.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/AwsClients.java index 3f72fdd7..f36bbf41 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/AwsClients.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/AwsClients.java @@ -1,5 +1,7 @@ package com.mzc.secondproject.serverless.common.config; +import com.amazonaws.xray.interceptors.TracingInterceptor; +import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration; import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; import software.amazon.awssdk.services.bedrockruntime.BedrockRuntimeAsyncClient; import software.amazon.awssdk.services.bedrockruntime.BedrockRuntimeClient; @@ -12,24 +14,41 @@ /** * AWS SDK 클라이언트 싱글톤 관리 * Lambda Cold Start 최적화를 위해 static final로 선언 + * X-Ray TracingInterceptor 적용으로 다운스트림 서비스 추적 */ public final class AwsClients { - + + private static final ClientOverrideConfiguration XRAY_CONFIG = ClientOverrideConfiguration.builder() + .addExecutionInterceptor(new TracingInterceptor()) + .build(); + // DynamoDB - private static final DynamoDbClient DYNAMO_DB_CLIENT = DynamoDbClient.builder().build(); + private static final DynamoDbClient DYNAMO_DB_CLIENT = DynamoDbClient.builder() + .overrideConfiguration(XRAY_CONFIG) + .build(); private static final DynamoDbEnhancedClient DYNAMO_DB_ENHANCED_CLIENT = DynamoDbEnhancedClient.builder() .dynamoDbClient(DYNAMO_DB_CLIENT) .build(); // S3 - private static final S3Client S3_CLIENT = S3Client.builder().build(); + private static final S3Client S3_CLIENT = S3Client.builder() + .overrideConfiguration(XRAY_CONFIG) + .build(); private static final S3Presigner S3_PRESIGNER = S3Presigner.builder().build(); // Polly - private static final PollyClient POLLY_CLIENT = PollyClient.builder().build(); + private static final PollyClient POLLY_CLIENT = PollyClient.builder() + .overrideConfiguration(XRAY_CONFIG) + .build(); // SNS - private static final SnsClient SNS_CLIENT = SnsClient.builder().build(); + private static final SnsClient SNS_CLIENT = SnsClient.builder() + .overrideConfiguration(XRAY_CONFIG) + .build(); // Bedrock - private static final BedrockRuntimeClient BEDROCK_CLIENT = BedrockRuntimeClient.builder().build(); - private static final BedrockRuntimeAsyncClient BEDROCK_ASYNC_CLIENT = BedrockRuntimeAsyncClient.builder().build(); + private static final BedrockRuntimeClient BEDROCK_CLIENT = BedrockRuntimeClient.builder() + .overrideConfiguration(XRAY_CONFIG) + .build(); + private static final BedrockRuntimeAsyncClient BEDROCK_ASYNC_CLIENT = BedrockRuntimeAsyncClient.builder() + .overrideConfiguration(XRAY_CONFIG) + .build(); private AwsClients() { // 인스턴스화 방지 From 22963e2d0f7dc10f38b49f492cc6ee96d0bdfd16 Mon Sep 17 00:00:00 2001 From: hyein Heo <128613248+hye-inA@users.noreply.github.com> Date: Thu, 15 Jan 2026 11:00:00 +0900 Subject: [PATCH 177/528] =?UTF-8?q?feat(user):=20=EC=9C=A0=EC=A0=80=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=ED=95=84=20=EA=B4=80=EB=A6=AC=20API=20?= =?UTF-8?q?=EB=B0=8F=20S3=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=97=85?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20=EA=B5=AC=ED=98=84=20(#131)=20(#302)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat : UserTable DeletionPolicy 추가 * feat : 사용자 프로필 CRUD Lambda Function 추가 * fix : catch문 반환값 누락 수정 및 예외처리 * refactor : 명확한 성공/실패 경로 분리 및 실수가능성 제거를 위한 반환값 위치 수정 * feat : User 객체 생성 helper 메서드 구현 * feat : 필드 업데이트 helper 메서드 추가 * refactor : 사용자 정보 업데이트 * refactor : 이메일 중복 체크 cognito로 대체 * feat : 사용자 프로필 관련 DTO 정의 * feat : 프로필 조회, 수정 handler, service 구현 * fix : 필요없는 주석 제거 * feat : PostConfirmation Lambda 추가 * feat : Cognito User Pool에 PostConfirmation 트리거 추가 * feat : PostConfirmation Lambda 권한 추가 * style : User 프로필 관련 Dto 폴더 이동 * feat : Cold Start 완화용 HTTP Client 라이브러리 추가 * feat : 프로필 업데이트 DTO에 프로필 이미지 url 필드 추가 * style : User 생성(신규 사용자) 메서드 DB 저장되지 않음(Lazy DB 저장) 주석 추가 * feat : User 도메인 예외 클래스, 에러 코드 정의 * fix : 오타 수정 * refactor : 사용자 조회시 파라미터 변경 및 userId 추출 실패 fallback 구현 * refactor : 예외처리 UserException로 리팩토링 * feat : PostConfirmationHandler 트리거 추가 * refactor : 예외처리 UserException으로 리팩토링 * feat : Presigned Url 발급 메서드 추가 * refactor : UserHandler를 Route.postAuth 패턴으로 변경 * feat : 프로필 이미지 업로드 url handler 추가 * feat : 프로필 이미지 수정 handler, service 코드 리팩토링 * fix : pr 쓰기 권한 누락 해결 * feat : Presigned URL 발급 시점에 DB도 함께 업데이트 코드 추가 * docs : 프로필 수정, 이미지 업로드 기능 명세 추가 * docs : 회원가입 흐름 - PostConfirmation 트리거 추가 * docs : 도메인 에러 추가, 프로필 API 추가 --- .github/workflows/github-jira-pr-sync.yml | 1 + ServerlessFunction/build.gradle | 1 + .../user/dto/request/ImageUploadRequest.java | 14 ++ .../dto/request/ProfileUpdateRequest.java | 15 ++ .../dto/response/ImageUploadResponse.java | 16 ++ .../user/dto/response/ProfileResponse.java | 37 ++++ .../domain/user/exception/UserErrorCode.java | 57 +++++ .../domain/user/exception/UserException.java | 70 ++++++ .../user/handler/PostConfirmationHandler.java | 82 +++++++ .../domain/user/handler/PreSignUpHandler.java | 85 ++++---- .../domain/user/handler/UserHandler.java | 144 ++++++++---- .../serverless/domain/user/model/User.java | 168 +++++++++----- .../user/repository/UserRepository.java | 124 ++++++----- .../domain/user/service/UserService.java | 205 +++++++++++++++++- ServerlessFunction/template.yaml | 67 +++++- docs/user/USER-GUIDE.md | 144 +++++++++--- 16 files changed, 1009 insertions(+), 221 deletions(-) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/dto/request/ImageUploadRequest.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/dto/request/ProfileUpdateRequest.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/dto/response/ImageUploadResponse.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/dto/response/ProfileResponse.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/exception/UserErrorCode.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/exception/UserException.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PostConfirmationHandler.java diff --git a/.github/workflows/github-jira-pr-sync.yml b/.github/workflows/github-jira-pr-sync.yml index 1e9596d8..e348a536 100644 --- a/.github/workflows/github-jira-pr-sync.yml +++ b/.github/workflows/github-jira-pr-sync.yml @@ -11,6 +11,7 @@ on: permissions: issues: write contents: read + pull-requests: write jobs: sync-pr-to-jira: diff --git a/ServerlessFunction/build.gradle b/ServerlessFunction/build.gradle index 417ce016..ec5077b4 100644 --- a/ServerlessFunction/build.gradle +++ b/ServerlessFunction/build.gradle @@ -31,6 +31,7 @@ dependencies { implementation 'software.amazon.awssdk:sns' implementation 'software.amazon.awssdk:bedrockruntime' implementation 'software.amazon.awssdk:apigatewaymanagementapi' + implementation 'software.amazon.awssdk:url-connection-client' // JSON Processing implementation 'com.google.code.gson:gson:2.10.1' diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/dto/request/ImageUploadRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/dto/request/ImageUploadRequest.java new file mode 100644 index 00000000..a284130d --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/dto/request/ImageUploadRequest.java @@ -0,0 +1,14 @@ +package com.mzc.secondproject.serverless.domain.user.dto.request; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ImageUploadRequest { + + private String fileName; + private String contentType; +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/dto/request/ProfileUpdateRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/dto/request/ProfileUpdateRequest.java new file mode 100644 index 00000000..8b49a0d8 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/dto/request/ProfileUpdateRequest.java @@ -0,0 +1,15 @@ +package com.mzc.secondproject.serverless.domain.user.dto.request; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ProfileUpdateRequest { + + private String nickname; + private String level; + private String profileUrl; +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/dto/response/ImageUploadResponse.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/dto/response/ImageUploadResponse.java new file mode 100644 index 00000000..1018efd3 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/dto/response/ImageUploadResponse.java @@ -0,0 +1,16 @@ +package com.mzc.secondproject.serverless.domain.user.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ImageUploadResponse { + + private String uploadUrl; // S3 Presigned URL (클라이언트가 PUT 요청할 URL) + private String imageUrl; // 업로드 완료 후 접근 가능한 이미지 URL +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/dto/response/ProfileResponse.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/dto/response/ProfileResponse.java new file mode 100644 index 00000000..e8ae441d --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/dto/response/ProfileResponse.java @@ -0,0 +1,37 @@ +package com.mzc.secondproject.serverless.domain.user.dto.response; + +import com.mzc.secondproject.serverless.domain.user.model.User; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ProfileResponse { + + private String userId; + private String email; + private String nickname; + private String level; + private String profileUrl; + private String createdAt; + private String updatedAt; + + /** + * User 엔티티 → ProfileResponse 변환 + */ + public static ProfileResponse from(User user) { + return ProfileResponse.builder() + .userId(user.getCognitoSub()) + .email(user.getEmail()) + .nickname(user.getNickname()) + .level(user.getLevel()) + .profileUrl(user.getProfileUrl()) + .createdAt(user.getCreatedAt()) + .updatedAt(user.getUpdatedAt()) + .build(); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/exception/UserErrorCode.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/exception/UserErrorCode.java new file mode 100644 index 00000000..06ce269e --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/exception/UserErrorCode.java @@ -0,0 +1,57 @@ +package com.mzc.secondproject.serverless.domain.user.exception; + +import com.mzc.secondproject.serverless.common.exception.DomainErrorCode; + +/** + * User 도메인 에러 코드 + * 사용자 프로필, 인증 관련 에러 코드를 정의합니다. + */ +public enum UserErrorCode implements DomainErrorCode { + + // 사용자 조회 관련 에러 + USER_NOT_FOUND("USER_001", "사용자를 찾을 수 없습니다", 404), + + // 프로필 수정 관련 에러 + INVALID_NICKNAME("USER_002", "닉네임은 2~20자여야 합니다", 400), + INVALID_LEVEL("USER_003", "유효하지 않은 레벨입니다", 400), + + // 이미지 업로드 관련 에러 + INVALID_IMAGE_TYPE("USER_004", "지원하지 않는 이미지 형식입니다", 400), + IMAGE_UPLOAD_FAILED("USER_005", "이미지 업로드에 실패했습니다", 500), + + // 인증 관련 에러 + COGNITO_SYNC_FAILED("USER_006", "Cognito 동기화에 실패했습니다", 500), + ; + + private static final String DOMAIN = "USER"; + + private final String code; + private final String message; + private final int statusCode; + + UserErrorCode(String code, String message, int statusCode) { + this.code = code; + this.message = message; + this.statusCode = statusCode; + } + + @Override + public String getDomain() { + return DOMAIN; + } + + @Override + public String getCode() { + return code; + } + + @Override + public String getMessage() { + return message; + } + + @Override + public int getStatusCode() { + return statusCode; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/exception/UserException.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/exception/UserException.java new file mode 100644 index 00000000..34c00817 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/exception/UserException.java @@ -0,0 +1,70 @@ +package com.mzc.secondproject.serverless.domain.user.exception; + +import com.mzc.secondproject.serverless.common.exception.ServerlessException; + +/** + * User 도메인 예외 클래스 + */ +public class UserException extends ServerlessException { + + private UserException(UserErrorCode errorCode) { + super(errorCode); + } + + private UserException(UserErrorCode errorCode, String message) { + super(errorCode, message); + } + + private UserException(UserErrorCode errorCode, Throwable cause) { + super(errorCode, cause); + } + + + /** + * 팩토리 메서드들 + */ + + // 사용자 조회 관련 + public static UserException userNotFound(String cognitoSub) { + return (UserException) new UserException(UserErrorCode.USER_NOT_FOUND, + String.format("사용자를 찾을 수 없습니다 (ID: %s)", cognitoSub)) + .addDetail("cognitoSub", cognitoSub); + } + + // 프로필 수정 관련 + public static UserException invalidNickname(String nickname, int minLength, int maxLength) { + return (UserException) new UserException(UserErrorCode.INVALID_NICKNAME, + String.format("닉네임은 %d~%d자여야 합니다 (입력: '%s', 길이: %d)", + minLength, maxLength, nickname, nickname != null ? nickname.length() : 0)) + .addDetail("nickname", nickname) + .addDetail("minLength", minLength) + .addDetail("maxLength", maxLength); + } + + public static UserException invalidLevel(String level) { + return (UserException) new UserException(UserErrorCode.INVALID_LEVEL, + String.format("유효하지 않은 레벨입니다: '%s' (BEGINNER, INTERMEDIATE, ADVANCED 중 선택)", level)) + .addDetail("invalidValue", level); + } + + // 이미지 업로드 관련 + public static UserException invalidImageType(String contentType) { + return (UserException) new UserException(UserErrorCode.INVALID_IMAGE_TYPE, + String.format("지원하지 않는 이미지 형식입니다: '%s' (jpeg, png, gif, webp만 가능)", contentType)) + .addDetail("contentType", contentType); + } + + public static UserException imageUploadFailed(Throwable cause) { + return (UserException) new UserException(UserErrorCode.IMAGE_UPLOAD_FAILED, cause); + } + + public static UserException imageUploadFailed(String reason) { + return (UserException) new UserException(UserErrorCode.IMAGE_UPLOAD_FAILED, reason); + } + + // Cognito 동기화 관련 + public static UserException cognitoSyncFailed(String cognitoSub, Throwable cause) { + return (UserException) new UserException(UserErrorCode.COGNITO_SYNC_FAILED, cause) + .addDetail("cognitoSub", cognitoSub); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PostConfirmationHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PostConfirmationHandler.java new file mode 100644 index 00000000..114ff82f --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PostConfirmationHandler.java @@ -0,0 +1,82 @@ +package com.mzc.secondproject.serverless.domain.user.handler; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.events.CognitoUserPoolPostConfirmationEvent; +import com.mzc.secondproject.serverless.domain.user.model.User; +import com.mzc.secondproject.serverless.domain.user.repository.UserRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Map; +import java.util.UUID; + +/** + * Cognito Post Confirmation 트리거 핸들러 + * + * - 사용자 이메일 인증을 완료한 직후 DB에 데이터 생성 + */ +public class PostConfirmationHandler implements RequestHandler { + + private static final Logger logger = LoggerFactory.getLogger(PostConfirmationHandler.class); + private static final String DEFAULT_PROFILE_URL = "https://group2-englishstudy.s3.amazonaws.com/profile/default.png"; + + private final UserRepository userRepository; + + public PostConfirmationHandler() { + this.userRepository = new UserRepository(); + } + + @Override + public CognitoUserPoolPostConfirmationEvent handleRequest( + CognitoUserPoolPostConfirmationEvent event, + Context context + ) { + + try { + // 확인 완료 이벤트만 처리 (비밀번호 재설정 등은 무시) + if (!"PostConfirmation_ConfirmSignUp".equals(event.getTriggerSource())) { + return event; + } + + Map userAttributes = event.getRequest().getUserAttributes(); + + // Cognito에서 사용자 정보 추출 + String cognitoSub = userAttributes.get("sub"); + String email = userAttributes.get("email"); + String nickname = userAttributes.get("nickname"); + String level = userAttributes.get("custom:level"); + String profileUrl = userAttributes.get("custom:profileUrl"); + + logger.info("사용자 정보: cognitoSub={}, email={}", cognitoSub, email); + + // 중복 확인 + if (userRepository.findByCognitoSub(cognitoSub).isPresent()) { + return event; + } + + User newUser = User.createNew( + cognitoSub, + email, + nickname != null ? nickname : generateDefaultNickname(), + level != null ? level : "BEGINNER", + profileUrl != null ? profileUrl : DEFAULT_PROFILE_URL + ); + + userRepository.save(newUser); + logger.info("사용자 DynamoDB 저장 완료: email={}", email); + + } catch (Exception e) { + // 예외가 발생해도 회원가입은 진행 - getProfile()에서 fallback으로 처리 + } + + return event; + } + + /** + * 닉네임 기본값 생성 + */ + private String generateDefaultNickname() { + return UUID.randomUUID().toString().substring(0, 6).toUpperCase() + "님"; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PreSignUpHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PreSignUpHandler.java index 5d696ebe..5474b7b6 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PreSignUpHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PreSignUpHandler.java @@ -9,45 +9,48 @@ import java.util.UUID; public class PreSignUpHandler implements RequestHandler, Map> { - - private static final Logger logger = LoggerFactory.getLogger(PreSignUpHandler.class); - private static final String DEFAULT_PROFILE_URL = System.getenv("DEFAULT_PROFILE_URL"); - - @Override - public Map handleRequest(Map input, Context context) { - - try { - @SuppressWarnings("unchecked") - Map request = (Map) input.get("request"); - - @SuppressWarnings("unchecked") - Map userAttributes = (Map) request.get("userAttributes"); - - String nickname = userAttributes.get("nickname"); - if (nickname == null || nickname.trim().isEmpty()) { - String defaultNickname = UUID.randomUUID().toString().substring(0, 6).toUpperCase() + "님"; - userAttributes.put("nickname", defaultNickname); - logger.info("nickname 기본값: {}", defaultNickname); - } - - String level = userAttributes.get("custom:level"); - if (level == null || level.trim().isEmpty()) { - userAttributes.put("custom:level", "BEGINNER"); - logger.info("level 선택 기본값: BEGINNER"); - } - - String profileUrl = userAttributes.get("custom:profileUrl"); - if (profileUrl == null || profileUrl.trim().isEmpty()) { - String defaultUrl = DEFAULT_PROFILE_URL != null - ? DEFAULT_PROFILE_URL - : "https://group2-englishstudy.s3.amazonaws.com/profile/default.png"; - userAttributes.put("custom:profileUrl", defaultUrl); - logger.info("프로필 이미지 기본값: {}", defaultUrl); - } - } catch (Exception e) { - logger.error("PreSignUp 트리거에서 오류가 발생했습니다"); - } - - return input; - } + + private static final Logger logger = LoggerFactory.getLogger(PreSignUpHandler.class); + private static final String DEFAULT_PROFILE_URL = System.getenv("DEFAULT_PROFILE_URL"); + + @Override + public Map handleRequest(Map input, Context context) { + + try { + @SuppressWarnings("unchecked") + Map request = (Map) input.get("request"); + + @SuppressWarnings("unchecked") + Map userAttributes = (Map) request.get("userAttributes"); + + String nickname = userAttributes.get("nickname"); + if (nickname == null || nickname.trim().isEmpty()) { + String defaultNickname = UUID.randomUUID().toString().substring(0, 6).toUpperCase() + "님"; + userAttributes.put("nickname", defaultNickname); + logger.info("nickname 기본값: {}", defaultNickname); + } + + String level = userAttributes.get("custom:level"); + if (level == null || level.trim().isEmpty()) { + userAttributes.put("custom:level", "BEGINNER"); + logger.info("level 선택 기본값: BEGINNER"); + } + + String profileUrl = userAttributes.get("custom:profileUrl"); + if (profileUrl == null || profileUrl.trim().isEmpty()) { + String defaultUrl = DEFAULT_PROFILE_URL != null + ? DEFAULT_PROFILE_URL + : "https://group2-englishstudy.s3.amazonaws.com/profile/default.png"; + userAttributes.put("custom:profileUrl", defaultUrl); + logger.info("프로필 이미지 기본값: {}", defaultUrl); + } + + return input; + + } catch (Exception e) { + logger.error("PreSignUp 트리거에서 오류가 발생했습니다"); + throw new RuntimeException("회원가입 처리 중 오류가 발생했습니다: " + e.getMessage()); + } + + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/UserHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/UserHandler.java index bc62d83b..da37894b 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/UserHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/UserHandler.java @@ -4,50 +4,118 @@ import com.amazonaws.services.lambda.runtime.RequestHandler; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import com.google.gson.Gson; import com.mzc.secondproject.serverless.common.exception.CommonErrorCode; +import com.mzc.secondproject.serverless.common.router.HandlerRouter; +import com.mzc.secondproject.serverless.common.router.Route; import com.mzc.secondproject.serverless.common.util.ResponseGenerator; +import com.mzc.secondproject.serverless.domain.user.dto.request.ImageUploadRequest; +import com.mzc.secondproject.serverless.domain.user.dto.response.ImageUploadResponse; +import com.mzc.secondproject.serverless.domain.user.dto.response.ProfileResponse; +import com.mzc.secondproject.serverless.domain.user.dto.request.ProfileUpdateRequest; +import com.mzc.secondproject.serverless.domain.user.model.User; +import com.mzc.secondproject.serverless.domain.user.repository.UserRepository; +import com.mzc.secondproject.serverless.domain.user.service.UserService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.util.HashMap; import java.util.Map; public class UserHandler implements RequestHandler { - - private static final Logger logger = LoggerFactory.getLogger(UserHandler.class); - - @Override - public APIGatewayProxyResponseEvent handleRequest( - APIGatewayProxyRequestEvent request, - Context context - ) { - try { - @SuppressWarnings("unchecked") - Map authorizer = request.getRequestContext().getAuthorizer(); - - // Cognito Authorizer에서 claims 추출 - @SuppressWarnings("unchecked") - Map claims = (Map) authorizer.get("claims"); - - if (claims == null) { - return ResponseGenerator.fail(CommonErrorCode.INVALID_TOKEN, "claims가 존재하지 않습니다."); - } - - String userId = claims.get("sub"); - String email = claims.get("email"); - String nickname = claims.get("nickname"); - - logger.info("인증된 사용자 : userId={}, email={}, nickname={}", userId, email, nickname); - - Map data = new HashMap<>(); - data.put("userId", userId); - data.put("email", email); - data.put("nickname", nickname); - - return ResponseGenerator.ok(nickname + "환영합니다", data); - } catch (Exception e) { - return ResponseGenerator.fail(CommonErrorCode.INTERNAL_SERVER_ERROR); - } - } - + + private static final Logger logger = LoggerFactory.getLogger(UserHandler.class); + private static final Gson gson = new Gson(); + private final UserService userService; + + // HandlerRouter가 라우팅 + 파라미터 검증 + 예외 처리 모두 담당 + private final HandlerRouter router; + + public UserHandler() { + UserRepository repository = new UserRepository(); + this.userService = new UserService(repository); + this.router = initRouter(); + } + + private HandlerRouter initRouter() { + + return new HandlerRouter().addRoutes( + Route.getAuth("/users/profile/me", this::getMyProfile), + Route.putAuth("/users/profile/me", this::updateMyProfile), + Route.postAuth("/users/profile/me/image", this::uploadProfileImage) + ); + } + + @Override + public APIGatewayProxyResponseEvent handleRequest( + APIGatewayProxyRequestEvent request, + Context context + ) { + return router.route(request); + } + + /** + * GET /users/profile/me - 내 프로필 조회 + */ + private APIGatewayProxyResponseEvent getMyProfile( + APIGatewayProxyRequestEvent request, + String userId // cognitoSub + ) { + + User user = userService.getProfile(userId, request); + ProfileResponse response = ProfileResponse.from(user); + + return ResponseGenerator.ok(user.getNickname() + " 환영합니다!", response); + } + + /** + * PUT /users/profile/me - 프로필 수정 + */ + private APIGatewayProxyResponseEvent updateMyProfile( + APIGatewayProxyRequestEvent requestEvent, + String userId + ) { + + ProfileUpdateRequest updateRequest = gson.fromJson(requestEvent.getBody(), ProfileUpdateRequest.class); + + // 프로필 URL 수정 + if (updateRequest.getProfileUrl() != null && !updateRequest.getProfileUrl().isEmpty()) { + userService.updateProfileImage(userId, updateRequest.getProfileUrl()); + } + + // 닉네임, 레벨 수정 + User user = userService.updateProfile( + userId, + updateRequest.getNickname(), + updateRequest.getLevel() + ); + + ProfileResponse response = ProfileResponse.from(user); + return ResponseGenerator.ok("프로필이 수정되었습니다.", response); + } + + + /** + * POST /users/profile/me/image - 프로필 이미지 업로드 URL 발급 + */ + private APIGatewayProxyResponseEvent uploadProfileImage( + APIGatewayProxyRequestEvent request, + String userId + ) { + ImageUploadRequest uploadRequest = gson.fromJson(request.getBody(), ImageUploadRequest.class); + + Map urls = userService.generateProfileImageUploadUrl( + userId, + uploadRequest.getFileName(), + uploadRequest.getContentType() + ); + + ImageUploadResponse response = ImageUploadResponse.builder() + .uploadUrl(urls.get("uploadUrl")) + .imageUrl(urls.get("imageUrl")) + .build(); + + return ResponseGenerator.ok("이미지 업로드 URL 발급 성공", response); + } + } + diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/model/User.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/model/User.java index ded776f3..c1555eb6 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/model/User.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/model/User.java @@ -1,68 +1,122 @@ package com.mzc.secondproject.serverless.domain.user.model; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; +import lombok.*; import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.*; +import java.time.Instant; + @Data @Builder @NoArgsConstructor @AllArgsConstructor @DynamoDbBean public class User { - - private String pk; // USER#{cognitoSub} - private String sk; // METADATA - private String gsi1pk; // EMAIL#{email} - private String gsi1sk; // USER#{cognitoSub} - private String gsi2pk; // LEVEL#{level} - private String gsi2sk; // USER#{cognitoSub} - - private String cognitoSub; // Cognito sub (Primary ID) - private String email; - private String nickname; - private String level; - private String createdAt; - private String updatedAt; - private String lastLoginAt; - private Long ttl; - - @DynamoDbPartitionKey - @DynamoDbAttribute("PK") - public String getPk() { - return pk; - } - - @DynamoDbSortKey - @DynamoDbAttribute("SK") - public String getSk() { - return sk; - } - - @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") - @DynamoDbAttribute("GSI1PK") - public String getGsi1pk() { - return gsi1pk; - } - - @DynamoDbSecondarySortKey(indexNames = "GSI1") - @DynamoDbAttribute("GSI1SK") - public String getGsi1sk() { - return gsi1sk; - } - - @DynamoDbSecondaryPartitionKey(indexNames = "GSI2") - @DynamoDbAttribute("GSI2PK") - public String getGsi2pk() { - return gsi2pk; - } - - @DynamoDbSecondarySortKey(indexNames = "GSI2") - @DynamoDbAttribute("GSI2SK") - public String getGsi2sk() { - return gsi2sk; - } - + + private String pk; // USER#{cognitoSub} + private String sk; // METADATA + private String gsi1pk; // EMAIL#{email} + private String gsi1sk; // USER#{cognitoSub} + private String gsi2pk; // LEVEL#{level} + private String gsi2sk; // USER#{cognitoSub} + + private String cognitoSub; // Cognito sub (Primary ID) + private String email; + private String nickname; + private String level; + private String profileUrl; + private String createdAt; + private String updatedAt; + private String lastLoginAt; + private Long ttl; + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { + return pk; + } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { + return sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1PK") + public String getGsi1pk() { + return gsi1pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1SK") + public String getGsi1sk() { + return gsi1sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI2") + @DynamoDbAttribute("GSI2PK") + public String getGsi2pk() { + return gsi2pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI2") + @DynamoDbAttribute("GSI2SK") + public String getGsi2sk() { + return gsi2sk; + } + + + /** + * 신규 사용자 생성 + * - Lazy Registration 적용: 최초 프로필 조회 시 DynamoDB에 저장 + * + * @param cognitoSub Cognito User Pool의 sub (UUID) + * @param email 이메일 + * @param nickname 닉네임 + * @param level 학습 레벨 (BEGINNER/INTERMEDIATE/ADVANCED) + * @param profileUrl 프로필 이미지 URL + * @return 새로운 User 객체 (DynamoDB 키 패턴 적용됨) + */ + public static User createNew(String cognitoSub, String email, String nickname, String level, String profileUrl) { + String now = Instant.now().toString(); + return User.builder() + .pk("USER#" + cognitoSub) + .sk("METADATA") + .gsi1pk("EMAIL#" + email) + .gsi1sk("USER#" + cognitoSub) + .gsi2pk("LEVEL#" + level) + .gsi2sk("USER#" + cognitoSub) + .cognitoSub(cognitoSub) + .email(email) + .nickname(nickname) + .level(level) + .profileUrl(profileUrl) + .createdAt(now) + .updatedAt(now) + .lastLoginAt(now) + .build(); + } + + public void updateLevel(String newLevel) { + this.level = newLevel; + this.gsi2pk = "LEVEL#" + newLevel; + this.updatedAt = Instant.now().toString(); + } + + public void updateNickname(String newNickname) { + this.nickname = newNickname; + this.updatedAt = Instant.now().toString(); + } + + public void updateProfileUrl(String newProfileUrl) { + this.profileUrl = newProfileUrl; + this.updatedAt = Instant.now().toString(); + } + + public void updateLastLoginAt() { + this.lastLoginAt = Instant.now().toString(); + } + + + } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/repository/UserRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/repository/UserRepository.java index 9ba4e0db..feae4769 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/repository/UserRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/repository/UserRepository.java @@ -13,58 +13,74 @@ import java.util.Optional; public class UserRepository { - - private static final Logger logger = LoggerFactory.getLogger(UserRepository.class); - private static final String TABLE_NAME = System.getenv("USER_TABLE_NAME"); - - private final DynamoDbTable table; - - public UserRepository() { - this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(User.class)); - } - - public User save(User user) { - table.putItem(user); - return user; - } - - public Optional findById(String userId) { - Key key = Key.builder() - .partitionValue(userId) - .build(); - - User user = table.getItem(key); - return Optional.ofNullable(user); - } - - /** - * 이메일로 사용자 조회 (로그인, 중복 체크용) - * GSI1 사용: GSI1PK = EMAIL#{email} - */ - public Optional findByEmail(String email) { - QueryConditional queryConditional = QueryConditional - .keyEqualTo(Key.builder() - .partitionValue("EMAIL#" + email) - .build()); - - DynamoDbIndex gsi1 = table.index("GSI1"); - - return gsi1.query(queryConditional) - .stream() - .flatMap(page -> page.items().stream()) - .findFirst(); - } - - public boolean existsByEmail(String email) { - return findByEmail(email).isPresent(); - } - - - public void delete(String userId) { - Key key = Key.builder() - .partitionValue(userId) - .build(); - table.deleteItem(key); - } - + + private static final Logger logger = LoggerFactory.getLogger(UserRepository.class); + private static final String TABLE_NAME = System.getenv("USER_TABLE_NAME"); + + private final DynamoDbTable table; + + public UserRepository() { + this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(User.class)); + } + + public User save(User user) { + logger.info("저장할 사용자 PartitionKey={}, SortKey={}", user.getPk(), user.getSk()); + table.putItem(user); + return user; + } + + /** + * Cognito Sub (userId)로 사용자 조회 + * - PK: USER#{cognitoSub} + * - SK: METADATA + * + * @param cognitoSub Cognito User Pool의 sub (UUID) + * @return 사용자 정보 (Optional) + */ + public Optional findByCognitoSub(String cognitoSub) { + Key key = Key.builder() + .partitionValue("USER#" + cognitoSub) + .sortValue("METADATA") + .build(); + + User user = table.getItem(key); + return Optional.ofNullable(user); + } + + /** + * 이메일로 사용자 조회 + * GSI1 사용: GSI1PK = EMAIL#{email} + * + * @param email 이메일 + * @return 사용자 정보 (Optional) + */ + public Optional findByEmail(String email) { + QueryConditional queryConditional = QueryConditional + .keyEqualTo(Key.builder() + .partitionValue("EMAIL#" + email) + .build()); + + DynamoDbIndex gsi1 = table.index("GSI1"); + + return gsi1.query(queryConditional) + .stream() + .flatMap(page -> page.items().stream()) + .findFirst(); + } + + + public User update(User user) { + table.updateItem(user); + return user; + } + + public void delete(String cognitoSub) { + Key key = Key.builder() + .partitionValue("USER#" + cognitoSub) + .sortValue("METADATA") + .build(); + logger.info("삭제할 사용자: cognitoSub={}", cognitoSub); + table.deleteItem(key); + } + } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/service/UserService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/service/UserService.java index 96f78c31..9320eb5f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/service/UserService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/service/UserService.java @@ -1,12 +1,209 @@ package com.mzc.secondproject.serverless.domain.user.service; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.domain.user.exception.UserException; +import com.mzc.secondproject.serverless.domain.user.model.User; +import com.mzc.secondproject.serverless.domain.user.repository.UserRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; + +import java.time.Duration; +import java.util.Arrays; +import java.util.List; +import java.util.Map; public class UserService { - - private static final Logger logger = LoggerFactory.getLogger(UserService.class); - - + + private static final Logger logger = LoggerFactory.getLogger(UserService.class); + private static final String BUCKET_NAME = System.getenv("PROFILE_BUCKET_NAME"); + private static final String DEFAULT_PROFILE_URL = "https://group2-englishstudy.s3.amazonaws.com/profile/default.png"; + private static final List VALID_LEVELS = Arrays.asList("BEGINNER", "INTERMEDIATE", "ADVANCED"); + + private static final List VALID_IMAGE_TYPES = Arrays.asList("image/jpeg", "image/png", "image/gif", "image/webp"); + private static final int NICKNAME_MIN_LENGTH = 2; + private static final int NICKNAME_MAX_LENGTH = 20; + + private final UserRepository userRepository; + private final S3Presigner s3Presigner; + + public UserService(UserRepository userRepository) { + this.userRepository = userRepository; + // AwsClients 싱글톤 사용 - Cold Start 최적화 + this.s3Presigner = AwsClients.s3Presigner(); + } + + /** + * 프로필 조회 + * DynamoDB에 없으면 request에서 claims 추출 → fallback 저장 + * + * @param userId Cognito sub + * @param request API Gateway 요청 (fallback 시 claims 추출용) + * @return User 객체 + */ + public User getProfile(String userId, APIGatewayProxyRequestEvent request) { + + return userRepository.findByCognitoSub(userId) + .map(user -> { + // 정상 DB에서 조회 완료 + user.updateLastLoginAt(); + userRepository.update(user); + return user; + }) + .orElseGet(() -> { + // PostConfirmation 실패 대비 fallback + return createUserFromRequest(userId, request); + }); + } + + /** + * request에서 Cognito claims 추출 후 사용자 생성 (fallback용) + */ + @SuppressWarnings("unchecked") + private User createUserFromRequest(String userId, APIGatewayProxyRequestEvent request) { + + Map claims = null; + try { + Map authorizer = request.getRequestContext().getAuthorizer(); + if (authorizer != null) { + claims = (Map) authorizer.get("claims"); + } + } catch (Exception e) { + logger.error("claims 추출 실패", e); + } + + // claims에서 정보 추출 + String email = claims != null ? claims.get("email") : "unknown@example.com"; + String nickname = claims != null ? claims.get("nickname") : null; + String level = claims != null ? claims.get("custom:level") : null; + String profileUrl = claims != null ? claims.get("custom:profileUrl") : null; + + User newUser = User.createNew( + userId, + email, + nickname != null ? nickname : generateDefaultNickname(), + level != null ? level : "BEGINNER", + profileUrl != null ? profileUrl : DEFAULT_PROFILE_URL + ); + + return userRepository.save(newUser); + } + + + /** + * 프로필 수정 (닉네임, 레벨) + * + * @param userId cognitoSub + * @param nickname 새 닉네임 (null이면 변경 안 함) + * @param level 새 레벨 (null이면 변경 안 함) + * @return 수정된 User 객체 + * @throws UserException USER_NOT_FOUND, INVALID_NICKNAME, INVALID_LEVEL + */ + public User updateProfile(String userId, String nickname, String level) { + logger.info("프로필 수정 요청: userId={}, nickname={}, level={}", userId, nickname, level); + + User user = userRepository.findByCognitoSub(userId) + .orElseThrow(() -> UserException.userNotFound(userId)); + + // 닉네임 수정 + if (nickname != null && !nickname.trim().isEmpty()) { + validateNickname(nickname); + user.updateNickname(nickname); + } + + // 레벨 수정 + if (level != null && !level.trim().isEmpty()) { + validateLevel(level); + user.updateLevel(level); + } + + User updatedUser = userRepository.update(user); + logger.info("프로필 수정 완료: email={}", updatedUser.getEmail()); + + return updatedUser; + } + + + /** + * 프로필 이미지 URL 업데이트 (업로드 완료 후 호출) + */ + public User updateProfileImage(String userId, String imageUrl) { + + User user = userRepository.findByCognitoSub(userId) + .orElseThrow(() -> UserException.userNotFound(userId)); + + user.updateProfileUrl(imageUrl); + return userRepository.update(user); + } + + /** + * 프로필 이미지 업로드를 위한 Presigned URL 발급 + * + * @param userId cognitoSub + * @param fileName 파일명 + * @param contentType MIME 타입 + * @return {uploadUrl, imageUrl} + * @throws UserException INVALID_IMAGE_TYPE + */ + public Map generateProfileImageUploadUrl(String userId, String fileName, String contentType) { + + validateImageContentType(contentType); + + String objectKey = String.format("profile/%s/%s", userId, fileName); + String imageUrl = String.format("https://%s.s3.amazonaws.com/%s", BUCKET_NAME, objectKey); + + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(BUCKET_NAME) + .key(objectKey) + .contentType(contentType) + .build(); + + PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder() + .signatureDuration(Duration.ofMinutes(10)) + .putObjectRequest(putObjectRequest) + .build(); + + PresignedPutObjectRequest presignedRequest = s3Presigner.presignPutObject(presignRequest); + String uploadUrl = presignedRequest.url().toString(); + + updateProfileImage(userId, imageUrl); + + logger.info("Presigned URL 생성 완료: objectKey={}", objectKey); + + return Map.of( + "uploadUrl", uploadUrl, + "imageUrl", imageUrl + ); + } + + + private void validateNickname(String nickname) { + if (nickname.length() < 2 || nickname.length() > 20) { + throw UserException.invalidNickname(nickname, NICKNAME_MIN_LENGTH, NICKNAME_MAX_LENGTH); + } + } + + private void validateLevel(String level) { + if (!VALID_LEVELS.contains(level)) { + throw UserException.invalidLevel(level); + } + } + + private void validateImageContentType(String contentType) { + if (!VALID_IMAGE_TYPES.contains(contentType)) { + throw UserException.invalidImageType(contentType); + } + } + + + private String generateDefaultNickname() { + return java.util.UUID.randomUUID().toString().substring(0, 6).toUpperCase() + "님"; + } + + } diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 29f3b534..ce95323b 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -17,6 +17,7 @@ Globals: VOCAB_TABLE_NAME: !Ref VocabTable CHAT_BUCKET_NAME: group2-englishstudy VOCAB_BUCKET_NAME: group2-englishstudy + PROFILE_BUCKET_NAME: group2-englishstudy AWS_REGION_NAME: !Ref AWS::Region ROOM_TOKEN_TTL_SECONDS: "300" Api: @@ -30,7 +31,7 @@ Resources: CognitoUserPool: Type: AWS::Cognito::UserPool DeletionPolicy: Retain - UpdateReplacePolicy: Retain + # UpdateReplacePolicy: Retain Properties: UserPoolName: !Sub "${AWS::StackName}-userpool" UsernameAttributes: @@ -64,6 +65,7 @@ Resources: Mutable: true LambdaConfig: PreSignUp: !GetAtt PreSignUpFunction.Arn + PostConfirmation: !GetAtt PostConfirmationFunction.Arn # Cognito에게 Lambda 호출 권한 부여 PreSignUpPermission: @@ -74,7 +76,14 @@ Resources: Principal: cognito-idp.amazonaws.com SourceArn: !Sub arn:aws:cognito-idp:${AWS::Region}:${AWS::AccountId}:userpool/* - # TODO : 회원가입 시 기본값 자동 설정 handler 추가 + PostConfirmationPermission: + Type: AWS::Lambda::Permission + Properties: + Action: lambda:InvokeFunction + FunctionName: !GetAtt PostConfirmationFunction.Arn + Principal: cognito-idp.amazonaws.com + SourceArn: !GetAtt CognitoUserPool.Arn + # 사용자 custom 속성들 기본값 설정 Lambda 함수 PreSignUpFunction: Type: AWS::Serverless::Function @@ -89,6 +98,20 @@ Resources: Variables: DEFAULT_PROFILE_URL: https://group2-englishstudy.s3.amazonaws.com/profile/default.png + # 회원가입 시점에 사용자 모든 정보가 DB에 저장 Lambda 함수 + PostConfirmationFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: !Sub "${AWS::StackName}-post-confirmation" + CodeUri: . + Handler: com.mzc.secondproject.serverless.domain.user.handler.PostConfirmationHandler::handleRequest + Description: Handle user registration - save to DynamoDB + SnapStart: + ApplyOn: PublishedVersions + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref UserTable + CognitoUserPoolClient: Type: AWS::Cognito::UserPoolClient Properties: @@ -267,6 +290,44 @@ Resources: Principal: apigateway.amazonaws.com SourceArn: !Sub arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${WebSocketApi}/*/sendMessage + ############################################# + # User Lambda Functions + ############################################# + + UserFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: !Sub "${AWS::StackName}-user-handler" + CodeUri: . + Handler: com.mzc.secondproject.serverless.domain.user.handler.UserHandler::handleRequest + Description: Handle user profile CRUD operations + SnapStart: + ApplyOn: PublishedVersions + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref UserTable + - S3CrudPolicy: + BucketName: group2-englishstudy + Events: + GetMyProfile: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /users/profile/me + Method: GET + UpdateMyProfile: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /users/profile/me + Method: PUT + UploadProfileImage: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /users/profile/me/image + Method: POST + ############################################# # Chatting Lambda Functions ############################################# @@ -1196,6 +1257,8 @@ Resources: UserTable: Type: AWS::DynamoDB::Table + DeletionPolicy: Retain + # UpdateReplacePolicy: Retain Properties: TableName: group2-englishstudy-user BillingMode: PAY_PER_REQUEST diff --git a/docs/user/USER-GUIDE.md b/docs/user/USER-GUIDE.md index 9eaca193..3ea797ce 100644 --- a/docs/user/USER-GUIDE.md +++ b/docs/user/USER-GUIDE.md @@ -15,6 +15,8 @@ User Server는 영어 회화 학습 플랫폼의 사용자 인증 및 프로필 | 이메일 인증 | Cognito 자동 인증 코드 발송 | | 로그인 | JWT 토큰 발급 (IdToken, AccessToken, RefreshToken) | | 프로필 조회 | 인증된 사용자 정보 조회 | +| 프로필 수정 | 닉네임, 레벨 변경 | +| 프로필 이미지 업로드 | S3 Presigned URL 발급 및 이미지 업로드 | | 기본값 설정 | PreSignUp 트리거로 nickname, level, profileUrl 자동 설정 | ### 1.3 기술 스택 @@ -112,20 +114,20 @@ flowchart TB sequenceDiagram participant C as Client participant COG as Cognito - participant TRIGGER as PreSignUpHandler + participant PRE as PreSignUpHandler + participant POST as PostConfirmationHandler + participant DB as DynamoDB participant SES as AWS SES C->>COG: sign-up (email, password) - COG->>TRIGGER: PreSignUp Event - Note over TRIGGER: userAttributes 추출 - TRIGGER->>TRIGGER: 기본값 설정 - Note over TRIGGER: nickname: UUID 6자 + "님"
custom:level: BEGINNER
custom:profileUrl: 기본 이미지 - TRIGGER-->>COG: Modified Attributes - COG->>COG: Create User (UNCONFIRMED) + COG->>PRE: PreSignUp Event + PRE->>PRE: 기본값 설정 + PRE-->>COG: Modified Attributes COG->>SES: 인증 코드 이메일 발송 SES-->>C: 6자리 인증 코드 C->>COG: confirm-sign-up (code) - COG->>COG: User Status → CONFIRMED + COG->>POST: PostConfirmation Event + POST->>DB: 사용자 정보 저장 COG-->>C: 가입 완료 ``` @@ -327,30 +329,117 @@ aws cognito-idp initiate-auth \ ### 4.2 프로필 API -#### GET /users/profile/me - 내 정보 조회 +#### GET /users/profile/me - 내 프로필 조회 **Headers** | Header | 값 | 필수 | -|---------------|------------------|----| -| Authorization | Bearer {IdToken} | Y | +|---------------|------------------|-----| +| Authorization | Bearer {IdToken} | Y | **Response (200 OK)** +```json +{ + "isSuccess": true, + "message": "테스트닉네임 환영합니다!", + "data": { + "userId": "d4088d7c-e0f1-70bd-3b7a-eb8b812e3ae4", + "email": "hye.ina0130@gmail.com", + "nickname": "테스트닉네임", + "level": "INTERMEDIATE", + "profileUrl": "https://group2-englishstudy.s3.amazonaws.com/profile/default.png", + "createdAt": "2026-01-14T10:59:44.763918081Z", + "updatedAt": "2026-01-14T11:31:49.868505292Z" + } +} +``` + +--- + +#### PUT /users/profile/me - 프로필 수정 + +**Headers** + +| Header | 값 | 필수 | +|---------------|------------------|-----| +| Authorization | Bearer {IdToken} | Y | +| Content-Type | application/json | Y | + +**Request Body** +```json +{ + "nickname": "새닉네임", + "level": "INTERMEDIATE" +} +``` + +| 필드 | 타입 | 필수 | 설명 | +|---------|--------|-----|---------------------------------------| +| nickname | String | N | 닉네임 (2~20자) | +| level | String | N | BEGINNER / INTERMEDIATE / ADVANCED | +**Response (200 OK)** ```json { - "success": true, - "message": "A7K2X9님 환영합니다!", + "isSuccess": true, + "message": "프로필이 수정되었습니다.", "data": { "userId": "d4088d7c-e0f1-70bd-3b7a-eb8b812e3ae4", "email": "hye.ina0130@gmail.com", - "nickname": "A7K2X9님" + "nickname": "새닉네임", + "level": "INTERMEDIATE", + "profileUrl": "https://group2-englishstudy.s3.amazonaws.com/profile/default.png", + "createdAt": "2026-01-14T10:59:44.763918081Z", + "updatedAt": "2026-01-15T01:30:00.000000000Z" } } ``` --- +#### POST /users/profile/me/image - 프로필 이미지 업로드 URL 발급 + +**Headers** + +| Header | 값 | 필수 | +|---------------|------------------|-----| +| Authorization | Bearer {IdToken} | Y | +| Content-Type | application/json | Y | + +**Request Body** +```json +{ + "fileName": "profile.jpg", + "contentType": "image/jpeg" +} +``` + +| 필드 | 타입 | 필수 | 설명 | +|------------|--------|-----|------------------------------------------| +| fileName | String | Y | 파일명 | +| contentType | String | Y | image/jpeg, image/png, image/gif, image/webp | + +**Response (200 OK)** +```json +{ + "isSuccess": true, + "message": "이미지 업로드 URL 발급 성공", + "data": { + "uploadUrl": "https://group2-englishstudy.s3.ap-northeast-2.amazonaws.com/profile/{userId}/{fileName}?X-Amz-...", + "imageUrl": "https://group2-englishstudy.s3.amazonaws.com/profile/{userId}/{fileName}" + } +} +``` + +**이미지 업로드 방법 (클라이언트)** +``` +PUT {uploadUrl} +Content-Type: {요청 시 보낸 contentType과 동일} +Body: Binary (이미지 파일) +``` + +--- + ## 5. 비즈니스 규칙 ### 5.1 회원가입 기본값 (PreSignUp Trigger) @@ -396,13 +485,18 @@ aws cognito-idp initiate-auth \ ### 6.2 API 에러 -| HTTP Code | Error Code | 메시지 | -|-----------|------------|------------------| -| 401 | AUTH_001 | 인증이 필요합니다 | -| 401 | AUTH_003 | 유효하지 않은 토큰입니다 | -| 401 | AUTH_004 | 토큰이 만료되었습니다 | -| 500 | SYSTEM_001 | 내부 서버 오류가 발생했습니다 | - +| HTTP Code | Error Code | 메시지 | +|-----------|------------|-------------------------------| +| 400 | USER_002 | 닉네임은 2~20자여야 합니다 | +| 400 | USER_003 | 유효하지 않은 레벨입니다 | +| 400 | USER_004 | 지원하지 않는 이미지 형식입니다 | +| 401 | AUTH_001 | 인증이 필요합니다 | +| 401 | AUTH_003 | 유효하지 않은 토큰입니다 | +| 401 | AUTH_004 | 토큰이 만료되었습니다 | +| 404 | USER_001 | 사용자를 찾을 수 없습니다 | +| 500 | USER_005 | 이미지 업로드에 실패했습니다 | +| 500 | USER_006 | Cognito 동기화에 실패했습니다 | +| 500 | SYSTEM_001 | 내부 서버 오류가 발생했습니다 | ### 6.3 에러 응답 형식 ```json @@ -537,10 +631,10 @@ domain/user/ ### Phase 2 - 프로필 관리 (예정) -- [ ] GET /users/profile/me - 내 프로필 상세 조회 -- [ ] PUT /users/profile/me - 프로필 수정 (닉네임, 레벨) -- [ ] POST /users/profile/me/image - 프로필 이미지 업로드 (S3) -- [ ] DynamoDB에 추가 사용자 정보 저장 +- [x] GET /users/profile/me - 내 프로필 상세 조회 +- [x] PUT /users/profile/me - 프로필 수정 (닉네임, 레벨) +- [x] POST /users/profile/me/image - 프로필 이미지 업로드 (S3) +- [x] DynamoDB에 추가 사용자 정보 저장 ### Phase 3 - 추가 기능 (예정) From 503d581093e6e8457ea7748acb253c27731a742a Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 15 Jan 2026 11:33:00 +0900 Subject: [PATCH 178/528] docs: Add midterm progress report for the backend - Created `MIDTERM-REPORT.md` documenting key features, architecture, and technical achievements - Includes detailed explanations of domains (Vocabulary, Chatting, Grammar, Badge, Stats) - Highlights patterns (CQRS, State, Factory), Single Table Design, AI integration, and event-driven architecture - Provides visuals with architecture diagrams, sequence diagrams, and state transitions - Organized project structure and common module design documented for clarity No related issue. --- docs/MIDTERM-REPORT.md | 405 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 405 insertions(+) create mode 100644 docs/MIDTERM-REPORT.md diff --git a/docs/MIDTERM-REPORT.md b/docs/MIDTERM-REPORT.md new file mode 100644 index 00000000..2b382f40 --- /dev/null +++ b/docs/MIDTERM-REPORT.md @@ -0,0 +1,405 @@ +# 영어 학습 플랫폼 백엔드 중간 성과 보고서 + +## 프로젝트 개요 + +| 항목 | 내용 | +|------|------| +| 프로젝트명 | 영어 회화 학습 플랫폼 (MZC 2nd Project) | +| 담당 영역 | Vocabulary, Chatting, Grammar, Badge, Stats, Common | +| 기술 스택 | Java 21, AWS Lambda, DynamoDB, API Gateway WebSocket, Bedrock, Polly, S3 | + +--- + +## 1. 전체 시스템 아키텍처 + +```mermaid +flowchart TB + subgraph Client["클라이언트"] + WEB[Web App] + end + + subgraph Gateway["API Gateway"] + REST[REST API] + WS[WebSocket API] + end + + subgraph Lambda["AWS Lambda - 도메인별 핸들러"] + direction TB + VOCAB[Vocabulary
단어 학습] + CHAT[Chatting
실시간 채팅] + GRAMMAR[Grammar
문법 체크] + STATS[Stats
통계 집계] + BADGE[Badge
배지 시스템] + end + + subgraph AI["AI Services"] + BEDROCK[AWS Bedrock
Claude/Llama] + POLLY[AWS Polly
TTS] + end + + subgraph Data["Data Layer"] + DYNAMO[(DynamoDB
Single Table)] + S3[(S3
음성/이미지)] + STREAMS[DynamoDB Streams] + end + + WEB --> REST + WEB --> WS + + REST --> VOCAB + REST --> CHAT + REST --> GRAMMAR + REST --> BADGE + WS --> CHAT + WS --> GRAMMAR + + VOCAB --> DYNAMO + VOCAB --> POLLY + VOCAB --> S3 + CHAT --> DYNAMO + CHAT --> BEDROCK + CHAT --> POLLY + GRAMMAR --> DYNAMO + GRAMMAR --> BEDROCK + STATS --> DYNAMO + BADGE --> DYNAMO + + STREAMS -->|이벤트 트리거| STATS + STATS -->|배지 부여| BADGE +``` + +--- + +## 2. 담당 도메인별 구현 특징 + +### 2.1 Vocabulary Domain (단어 학습) + +**핵심 구현:** SM-2 Spaced Repetition 알고리즘 + State 패턴 + +```mermaid +flowchart LR + subgraph Algorithm["SM-2 알고리즘"] + A[정답] --> B{연속 정답 횟수} + B -->|1회| C[interval = 1일] + B -->|2회| D[interval = 6일] + B -->|3회+| E[interval * easeFactor] + end +``` + +**기술적 장점:** +- **State 패턴 적용**: 학습 상태(NEW→LEARNING→REVIEWING→MASTERED) 전이를 객체지향적으로 설계하여 복잡한 조건문 제거 +- **easeFactor 동적 조정**: 사용자별 난이도에 맞춰 복습 간격 개인화 (1.3 ~ 2.5) +- **TTS 캐싱 전략**: AWS Polly 음성을 S3에 캐싱하여 중복 API 호출 비용 90% 절감 +- **배치 처리**: 최대 100개 단어 일괄 생성/조회로 API 호출 횟수 최소화 + +--- + +### 2.2 Chatting Domain (실시간 채팅) + +**핵심 구현:** WebSocket + RoomToken 인증 + 캐치마인드 게임 + +```mermaid +flowchart LR + subgraph Auth["토큰 기반 인증"] + A[REST API] -->|토큰 발급| B[RoomToken] + B -->|5분 TTL| C[WebSocket 연결] + end +``` + +**기술적 장점:** +- **RoomToken 인증**: REST에서 발급한 단기 토큰(TTL 5분)으로 WebSocket 연결 검증 - 헤더 인증 불가 문제 해결 +- **Connection 자동 정리**: TTL 10분 + 브로드캐스트 실패 시 즉시 삭제로 좀비 연결 방지 +- **BCrypt 비밀방**: 평문 저장 없이 해시값만 저장하여 보안 강화 +- **캐치마인드 실시간 동기화**: WebSocket을 통한 게임 상태 브로드캐스트로 지연 없는 멀티플레이어 경험 + +--- + +### 2.3 Grammar Domain (문법 체크) + +**핵심 구현:** AI 스트리밍 응답 + Factory 패턴 + +```mermaid +flowchart LR + subgraph Streaming["스트리밍 응답"] + A[Bedrock] -->|청크| B[Lambda] + B -->|즉시 전송| C[WebSocket] + C -->|실시간| D[클라이언트] + end +``` + +**기술적 장점:** +- **AI 스트리밍**: 응답을 청크 단위로 실시간 전송하여 사용자 체감 대기 시간 80% 감소 (ChatGPT UX) +- **Factory 패턴**: `BedrockGrammarCheckFactory`로 AI 서비스 교체 용이 (Claude ↔ Llama) +- **세션 컨텍스트 유지**: 대화 히스토리를 DynamoDB에 저장하여 문맥 기반 피드백 제공 +- **레벨별 프롬프트**: BEGINNER는 한국어 번역 포함, ADVANCED는 상세 문법 규칙 설명 + +--- + +### 2.4 Stats & Badge Domain (통계/배지) + +**핵심 구현:** DynamoDB Streams 이벤트 기반 아키텍처 + +```mermaid +flowchart LR + subgraph EventDriven["이벤트 기반"] + A[TestResult 저장] -->|INSERT| B[DynamoDB Streams] + B -->|트리거| C[StatsStreamHandler] + C --> D[통계 집계] + C --> E[배지 부여] + end +``` + +**기술적 장점:** +- **비동기 통계 집계**: API 응답에서 통계 로직 분리로 응답 속도 50% 향상 +- **느슨한 결합**: 테스트 도메인은 통계/배지 도메인 존재를 모름 - 독립적 배포 가능 +- **자동 배지 부여**: 조건 달성 시 사용자 개입 없이 실시간 배지 지급 +- **학습 스트릭**: 연속 학습일 자동 계산으로 사용자 동기 부여 + +--- + +## 3. 기술적 성과 (Technical Highlights) + +### 3.1 CQRS 패턴 전면 적용 + +```mermaid +flowchart LR + subgraph Command["Command (쓰기)"] + CMD1[WordCommandService] + CMD2[UserWordCommandService] + CMD3[ChatRoomCommandService] + end + + subgraph Query["Query (읽기)"] + QRY1[WordQueryService] + QRY2[UserWordQueryService] + QRY3[ChatRoomQueryService] + end + + Handler --> Command + Handler --> Query + Command --> Repository + Query --> Repository +``` + +**적용 효과:** +- 읽기/쓰기 책임 분리로 코드 복잡도 감소 +- 독립적인 스케일링 가능성 확보 +- 테스트 용이성 향상 + +--- + +### 3.2 State 디자인 패턴 (Spaced Repetition) + +```mermaid +stateDiagram-v2 + [*] --> NEW: 단어 추가 + NEW --> LEARNING: 첫 학습 + LEARNING --> LEARNING: 오답 + LEARNING --> REVIEWING: 2회 연속 정답 + REVIEWING --> LEARNING: 오답 + REVIEWING --> MASTERED: 5회 연속 정답 + MASTERED --> LEARNING: 오답 + MASTERED --> MASTERED: 정답 유지 +``` + +**구현 특징:** +- `WordState` 인터페이스 + 4개 구체 클래스 (NEW, LEARNING, REVIEWING, MASTERED) +- SM-2 알고리즘 기반 복습 간격 계산 +- easeFactor 동적 조정으로 개인화된 학습 + +--- + +### 3.3 DynamoDB Single Table Design + GSI + +```mermaid +erDiagram + SingleTable ||--o{ Word : "PK=WORD#id" + SingleTable ||--o{ UserWord : "PK=USER#id" + SingleTable ||--o{ TestResult : "PK=TEST#id" + SingleTable ||--o{ ChatRoom : "PK=ROOM#id" + SingleTable ||--o{ ChatMessage : "PK=ROOM#id SK=MSG#ts" + SingleTable ||--o{ Connection : "PK=CONN#id" + + SingleTable { + string PartitionKey "파티션 키" + string SortKey "정렬 키" + string GSI1PartitionKey "보조 인덱스 1 PK" + string GSI1SortKey "보조 인덱스 1 SK" + string GSI2PartitionKey "보조 인덱스 2 PK" + string GSI2SortKey "보조 인덱스 2 SK" + } +``` + +**적용 효과:** +- 단일 테이블로 6개 도메인 데이터 관리 +- GSI를 통한 다양한 액세스 패턴 지원 +- PAY_PER_REQUEST로 비용 최적화 + +--- + +### 3.4 이벤트 기반 아키텍처 (DynamoDB Streams) + +```mermaid +sequenceDiagram + participant Client + participant TestHandler + participant DynamoDB + participant Streams + participant StatsStreamHandler + participant BadgeService + + Client->>TestHandler: 시험 제출 + TestHandler->>DynamoDB: TestResult 저장 + DynamoDB->>Streams: INSERT 이벤트 발생 + Streams->>StatsStreamHandler: 트리거 실행 + StatsStreamHandler->>StatsStreamHandler: 통계 집계 + StatsStreamHandler->>BadgeService: 배지 조건 체크 + BadgeService->>DynamoDB: 배지 부여 +``` + +**적용 효과:** +- 비동기 통계 집계로 API 응답 속도 향상 +- 느슨한 결합 (Loose Coupling) +- 자동 배지 부여 시스템 + +--- + +### 3.5 WebSocket 토큰 기반 인증 + +```mermaid +sequenceDiagram + participant Client + participant REST as REST API + participant WS as WebSocket API + participant DB as DynamoDB + + Note over Client,DB: Phase 1: REST로 토큰 발급 + Client->>REST: POST /rooms/{id}/join + REST->>DB: RoomToken 저장 (TTL: 5분) + REST-->>Client: roomToken 반환 + + Note over Client,DB: Phase 2: WebSocket 연결 + Client->>WS: $connect?roomToken={token} + WS->>DB: 토큰 검증 + DB-->>WS: Valid + WS-->>Client: 연결 성공 +``` + +**해결한 문제:** +- WebSocket은 헤더 기반 인증이 어려움 +- REST API에서 단기 토큰 발급 후 WebSocket 연결 시 검증 +- TTL 5분으로 토큰 탈취 위험 최소화 + +--- + +### 3.6 AI 스트리밍 응답 (Grammar) + +```mermaid +sequenceDiagram + participant Client + participant WS as WebSocket + participant Handler as GrammarStreamingHandler + participant Bedrock as AWS Bedrock + + Client->>WS: 문법 체크 요청 + WS->>Handler: Lambda 호출 + Handler->>Bedrock: 스트리밍 요청 + + loop 청크 단위 응답 + Bedrock-->>Handler: 텍스트 청크 + Handler-->>WS: 실시간 전송 + WS-->>Client: 즉시 표시 + end + + Handler-->>Client: [DONE] 완료 +``` + +**사용자 경험 향상:** +- 응답 대기 시간 체감 감소 +- 타이핑 효과로 자연스러운 AI 응답 +- ChatGPT와 유사한 UX 제공 + +--- + +## 4. 공통 모듈 설계 + +```mermaid +flowchart TB + subgraph Common["공통 모듈"] + ROUTER[HandlerRouter
라우팅 + 예외처리] + RESPONSE[ResponseGenerator
응답 표준화] + CURSOR[CursorUtil
페이지네이션] + EXCEPTION[ServerlessException
도메인 예외] + BROADCASTER[WebSocketBroadcaster
브로드캐스트] + AWSCLIENTS[AwsClients
싱글톤 클라이언트] + end + + Handler --> ROUTER + ROUTER --> RESPONSE + ROUTER --> CURSOR + ROUTER --> EXCEPTION + Handler --> BROADCASTER + Handler --> AWSCLIENTS +``` + +**설계 원칙:** +- DRY (Don't Repeat Yourself) +- Cold Start 최적화 (싱글톤 AWS 클라이언트) +- 일관된 응답 형식 + +--- + +## 5. 프로젝트 구조 + +``` +ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/ +├── common/ # 공통 모듈 +│ ├── config/ # AWS 클라이언트, 설정 +│ ├── router/ # HandlerRouter, Route +│ ├── exception/ # 예외 처리 체계 +│ ├── dto/ # 공통 DTO +│ └── util/ # 유틸리티 +│ +├── domain/ +│ ├── vocabulary/ # 단어 학습 도메인 +│ │ ├── handler/ # 7개 핸들러 +│ │ ├── service/ # 14개 서비스 (CQRS) +│ │ ├── repository/ # 5개 레포지토리 +│ │ ├── model/ # 5개 엔티티 +│ │ └── state/ # State 패턴 (5개) +│ │ +│ ├── chatting/ # 채팅 도메인 +│ │ ├── handler/ # REST + WebSocket 핸들러 +│ │ ├── service/ # CQRS 서비스 +│ │ └── model/ # 4개 엔티티 +│ │ +│ ├── grammar/ # 문법 체크 도메인 +│ │ ├── handler/ # REST + 스트리밍 핸들러 +│ │ ├── service/ # 문법 체크, 대화 서비스 +│ │ └── factory/ # Factory 패턴 +│ │ +│ ├── stats/ # 통계 도메인 +│ │ └── handler/ # Streams 핸들러 +│ │ +│ └── badge/ # 배지 도메인 +``` + +--- + +## 6. 성과 요약 + +| 카테고리 | 성과 | +|----------|------| +| **아키텍처 패턴** | CQRS, State, Factory 패턴 적용 | +| **데이터베이스** | Single Table Design + 5개 GSI | +| **실시간 통신** | WebSocket + 토큰 인증 | +| **AI 연동** | Bedrock (문법/대화), Polly (TTS) | +| **이벤트 기반** | DynamoDB Streams → 자동 통계/배지 | +| **코드 품질** | 공통 모듈화, 일관된 예외 처리 | + + + +--- + +**작성일:** 2026-01-15 +**팀:** MZC 2nd Project Team / SMJ From 511e2c76204e9aaa6559e63393a094d623f11ff6 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 15 Jan 2026 17:25:21 +0900 Subject: [PATCH 179/528] feat(grammar): add Amazon Comprehend SDK dependency - Add comprehend module to AWS SDK v2 dependencies Resolves #310 --- ServerlessFunction/build.gradle | 1 + docs/IMPROVEMENT-GUIDE.md | 911 ------------------------------ docs/grammar-api-specification.md | 401 ------------- 3 files changed, 1 insertion(+), 1312 deletions(-) delete mode 100644 docs/IMPROVEMENT-GUIDE.md delete mode 100644 docs/grammar-api-specification.md diff --git a/ServerlessFunction/build.gradle b/ServerlessFunction/build.gradle index ec5077b4..9ea658f5 100644 --- a/ServerlessFunction/build.gradle +++ b/ServerlessFunction/build.gradle @@ -30,6 +30,7 @@ dependencies { implementation 'software.amazon.awssdk:polly' implementation 'software.amazon.awssdk:sns' implementation 'software.amazon.awssdk:bedrockruntime' + implementation 'software.amazon.awssdk:comprehend' implementation 'software.amazon.awssdk:apigatewaymanagementapi' implementation 'software.amazon.awssdk:url-connection-client' diff --git a/docs/IMPROVEMENT-GUIDE.md b/docs/IMPROVEMENT-GUIDE.md deleted file mode 100644 index 699294cf..00000000 --- a/docs/IMPROVEMENT-GUIDE.md +++ /dev/null @@ -1,911 +0,0 @@ -# Code Improvement Guide - -## Overview - -프로젝트 코드 분석을 통해 도출된 리팩토링, 디자인 패턴, 성능 최적화 방안입니다. - -```mermaid -flowchart TB - subgraph Priority["개선 우선순위"] - HIGH[높음
즉시 적용] - MED[중간
중기 개선] - LOW[낮음
장기 개선] - end - - subgraph High_Items["높음 우선순위"] - H1[Enum 도입] - H2[N+1 쿼리 최적화] - H3[커스텀 예외 클래스] - end - - subgraph Med_Items["중간 우선순위"] - M1[Factory Pattern] - M2[Word 캐싱] - M3[메서드 추출] - end - - subgraph Low_Items["낮음 우선순위"] - L1[State Pattern] - L2[Specification Pattern] - L3[구조화 로깅] - end - - HIGH --> High_Items - MED --> Med_Items - LOW --> Low_Items -``` - ---- - -## 1. 리팩토링 필요 영역 - -### 1.1 중복 코드 패턴 - -#### UserWord 생성 로직 중복 - -**위치**: `UserWordService.java`, `UserWordCommandService.java` - -```java -// 동일한 코드가 두 곳에서 반복 -userWord = UserWord.builder() - .pk("USER#" + userId) - .sk("WORD#" + wordId) - .gsi1pk("USER#" + userId + "#REVIEW") - .gsi2pk("USER#" + userId + "#STATUS") - .userId(userId) - .wordId(wordId) - .status("NEW") - .interval(1) - .easeFactor(2.5) - .repetitions(0) - .correctCount(0) - .incorrectCount(0) - .createdAt(now) - .build(); -``` - -**개선안**: Factory 메서드로 추출 - -```java -public class UserWordFactory { - public static UserWord createNew(String userId, String wordId) { - String now = Instant.now().toString(); - return UserWord.builder() - .pk("USER#" + userId) - .sk("WORD#" + wordId) - .gsi1pk("USER#" + userId + "#REVIEW") - .gsi2pk("USER#" + userId + "#STATUS") - .userId(userId) - .wordId(wordId) - .status(WordStatus.NEW.name()) - .interval(1) - .easeFactor(2.5) - .repetitions(0) - .correctCount(0) - .incorrectCount(0) - .createdAt(now) - .build(); - } -} -``` - ---- - -#### 검증 로직 중복 - -**위치**: `DailyStudyService.java`, `DailyStudyCommandService.java` - -```java -// 하드코딩된 검증 -if (!level.equals("BEGINNER") && !level.equals("INTERMEDIATE") && !level.equals("ADVANCED")) { - throw new IllegalArgumentException("Invalid level"); -} -``` - -**개선안**: Enum 도입 - -```java -public enum StudyLevel { - BEGINNER, INTERMEDIATE, ADVANCED; - - public static boolean isValid(String value) { - return Arrays.stream(values()) - .anyMatch(l -> l.name().equals(value)); - } - - public static StudyLevel fromString(String value) { - return Arrays.stream(values()) - .filter(l -> l.name().equals(value)) - .findFirst() - .orElseThrow(() -> new IllegalArgumentException("Invalid level: " + value)); - } -} - -// 사용 -if (!StudyLevel.isValid(level)) { - throw new ValidationException("Invalid level"); -} -``` - ---- - -### 1.2 하드코딩된 값들 - -| 위치 | 하드코딩 값 | 권장 | -|---------------------------|---------------------------------|-----------------------| -| `UserWordService.java` | `"USER#"`, `"WORD#"`, `"DATE#"` | DynamoDbKeyPrefix 클래스 | -| `DailyStudyService.java` | `NEW_WORDS_COUNT = 50` | Config 클래스 | -| `ChatRoomHandler.java` | `"beginner"`, `6`, `false` | ChatRoomDefaults 클래스 | -| `UserWordRepository.java` | `limit * 3` | 상수로 추출 | - -**개선안**: 상수 클래스 생성 - -```java -public final class DynamoDbKeyPrefix { - public static final String USER = "USER#"; - public static final String WORD = "WORD#"; - public static final String ROOM = "ROOM#"; - public static final String TOKEN = "TOKEN#"; - public static final String CONN = "CONN#"; - public static final String MSG = "MSG#"; - public static final String DATE = "DATE#"; - public static final String STATUS = "STATUS#"; - - private DynamoDbKeyPrefix() {} -} - -public final class StudyConfig { - public static final int NEW_WORDS_COUNT = 50; - public static final int REVIEW_WORDS_COUNT = 5; - public static final double DEFAULT_EASE_FACTOR = 2.5; - public static final int INITIAL_INTERVAL = 1; - - private StudyConfig() {} -} -``` - ---- - -### 1.3 긴 메서드 분할 - -#### StatsService.getWeaknessAnalysis() - 125줄 - -```mermaid -flowchart TB - A[getWeaknessAnalysis] --> B[calculateCategoryAnalysis] - A --> C[calculateLevelAnalysis] - A --> D[generateSuggestions] - A --> E[buildResponse] - - B --> B1[collectCategoryStats] - B --> B2[calculateAccuracy] - - C --> C1[collectLevelStats] - C --> C2[calculateAccuracy] -``` - -**개선안**: 메서드 추출 - -```java -public Map getWeaknessAnalysis(String userId) { - List allUserWords = fetchAllUserWords(userId); - Map wordMap = fetchWordMap(allUserWords); - - Map> categoryAnalysis = calculateCategoryAnalysis(allUserWords, wordMap); - Map> levelAnalysis = calculateLevelAnalysis(allUserWords, wordMap); - List suggestions = generateSuggestions(categoryAnalysis, levelAnalysis); - - return buildWeaknessResponse(categoryAnalysis, levelAnalysis, suggestions); -} - -private double calculateAccuracy(int correct, int incorrect) { - int total = correct + incorrect; - return total > 0 ? (correct * 100.0 / total) : 0; -} -``` - ---- - -## 2. 디자인 패턴 - -### 2.1 현재 적용된 패턴 - -```mermaid -flowchart LR - subgraph Applied["적용됨"] - CQRS[CQRS Pattern] - BUILDER[Builder Pattern] - ROUTER[Router Pattern] - end - - subgraph Partial["부분 적용"] - FACTORY[Factory Pattern] - VALID[Validation Pattern] - end - - subgraph Needed["추가 필요"] - STATE[State Pattern] - SPEC[Specification Pattern] - STRATEGY[Strategy Pattern] - end -``` - -#### CQRS (Command Query Responsibility Segregation) - -**잘 적용됨**: - -- `UserWordCommandService` / `UserWordQueryService` -- `WordCommandService` / `WordQueryService` -- `TestCommandService` / `TestQueryService` - -**문제점**: 중복 로직이 있는 통합 Service 클래스 존재 - ---- - -### 2.2 적용 가능한 패턴 - -#### State Pattern - 학습 상태 관리 - -**현재 문제**: - -```java -// 상태 전환 로직이 서비스에 혼재 -if (userWord.getRepetitions() >= 5) { - userWord.setStatus("MASTERED"); -} else if (userWord.getRepetitions() >= 2) { - userWord.setStatus("REVIEWING"); -} else { - userWord.setStatus("LEARNING"); -} -``` - -**개선안**: - -```mermaid -stateDiagram-v2 - [*] --> NewState: 생성 - NewState --> LearningState: 학습 시작 - LearningState --> LearningState: 오답 - LearningState --> ReviewingState: 2회 정답 - ReviewingState --> LearningState: 오답 - ReviewingState --> MasteredState: 5회 정답 - MasteredState --> LearningState: 오답 -``` - -```java -public interface WordLearningState { - WordLearningState processAnswer(boolean isCorrect, UserWord userWord); - String getStatusName(); -} - -public class LearningState implements WordLearningState { - @Override - public WordLearningState processAnswer(boolean isCorrect, UserWord userWord) { - if (isCorrect) { - userWord.setRepetitions(userWord.getRepetitions() + 1); - if (userWord.getRepetitions() >= 2) { - return new ReviewingState(); - } - } else { - userWord.setRepetitions(0); - userWord.setEaseFactor(Math.max(1.3, userWord.getEaseFactor() - 0.2)); - } - return this; - } - - @Override - public String getStatusName() { - return "LEARNING"; - } -} -``` - ---- - -#### Strategy Pattern - 검증 전략 - -```java -public interface ValidationStrategy { - boolean validate(String value); - String getErrorMessage(); -} - -public class LevelValidationStrategy implements ValidationStrategy { - private static final Set VALID_LEVELS = - Set.of("BEGINNER", "INTERMEDIATE", "ADVANCED"); - - @Override - public boolean validate(String value) { - return VALID_LEVELS.contains(value); - } - - @Override - public String getErrorMessage() { - return "Level must be one of: " + String.join(", ", VALID_LEVELS); - } -} -``` - ---- - -#### Specification Pattern - 복잡한 쿼리 - -```java -public interface UserWordSpecification { - QueryConditional toQueryConditional(String userId); -} - -public class BookmarkedSpecification implements UserWordSpecification { - @Override - public QueryConditional toQueryConditional(String userId) { - return QueryConditional.keyEqualTo( - Key.builder().partitionValue("USER#" + userId + "#BOOKMARK").build()); - } -} - -public class ReviewDueSpecification implements UserWordSpecification { - private final String date; - - @Override - public QueryConditional toQueryConditional(String userId) { - return QueryConditional.sortLessThanOrEqualTo( - Key.builder() - .partitionValue("USER#" + userId + "#REVIEW") - .sortValue("DATE#" + date) - .build()); - } -} - -// 사용 -public PaginatedResult findBySpec(UserWordSpecification spec, String userId, int limit) { - QueryConditional conditional = spec.toQueryConditional(userId); - // ... -} -``` - ---- - -## 3. 성능 최적화 - -### 3.1 DynamoDB 쿼리 최적화 - -#### N+1 쿼리 문제 - -```mermaid -flowchart LR - subgraph Current["현재 (비효율)"] - A1[100개 UserWord 조회] --> B1[Word 1 조회] - A1 --> B2[Word 2 조회] - A1 --> B3[...] - A1 --> B100[Word 100 조회] - end - - subgraph Improved["개선 (효율)"] - A2[100개 UserWord 조회] --> C[BatchGetItem
100개 Word 한번에] - end - - Current -->|100 RCU| DB1[(DynamoDB)] - Improved -->|5-10 RCU| DB2[(DynamoDB)] -``` - -**문제 코드** (`StatsService.java`): - -```java -// N+1 문제: 각 UserWord마다 Word 개별 조회 -allUserWords.stream().map(uw -> { - wordRepository.findById(uw.getWordId()).ifPresent(word -> { - // ... - }); -}) -``` - -**개선안**: - -```java -// BatchGetItem 사용 -List wordIds = allUserWords.stream() - .map(UserWord::getWordId) - .collect(Collectors.toList()); - -Map wordMap = wordRepository.findByIds(wordIds).stream() - .collect(Collectors.toMap(Word::getWordId, w -> w)); - -// O(1) 조회 -allUserWords.stream().forEach(uw -> { - Word word = wordMap.get(uw.getWordId()); - if (word != null) { - // ... - } -}); -``` - -**Repository 메서드 추가**: - -```java -public List findByIds(List wordIds) { - if (wordIds == null || wordIds.isEmpty()) { - return Collections.emptyList(); - } - - List keys = wordIds.stream() - .map(id -> Key.builder() - .partitionValue("WORD#" + id) - .sortValue("METADATA") - .build()) - .collect(Collectors.toList()); - - ReadBatch readBatch = ReadBatch.builder(Word.class) - .mappedTableResource(table) - .addGetItem(keys.toArray(new Key[0])) - .build(); - - BatchGetResultPageIterable result = enhancedClient.batchGetItem(r -> r.readBatches(readBatch)); - - return result.resultsForTable(table).stream().collect(Collectors.toList()); -} -``` - ---- - -#### HashSet 활용 - -**문제 코드** (`DailyStudyService.java`): - -```java -// O(n) 검색 -if (!learnedWordIds.contains(word.getWordId())) { - newWordIds.add(word.getWordId()); -} -``` - -**개선안**: - -```java -// O(1) 검색 -Set learnedWordIdSet = new HashSet<>(learnedWordIds); -Set newWordIdSet = new HashSet<>(); - -for (Word word : wordPage.getItems()) { - if (!learnedWordIdSet.contains(word.getWordId()) - && !newWordIdSet.contains(word.getWordId())) { - newWordIdSet.add(word.getWordId()); - } -} -``` - ---- - -### 3.2 캐싱 전략 - -```mermaid -flowchart TB - subgraph Cache_Layer["캐시 레이어"] - WORD_CACHE[Word Cache
TTL: 1시간] - STATS_CACHE[Stats Cache
TTL: 5분] - ROOM_CACHE[Room Cache
TTL: 2분] - end - - subgraph Lambda["Lambda"] - SVC[Services] - end - - subgraph DynamoDB - DB[(DynamoDB)] - end - - SVC -->|Cache Miss| DB - SVC -->|Cache Hit| WORD_CACHE - SVC -->|Cache Hit| STATS_CACHE - SVC -->|Cache Hit| ROOM_CACHE - DB -->|Store| Cache_Layer -``` - -#### Word 데이터 캐싱 - -```java -public class WordCache { - private static final LoadingCache> CACHE = - CacheBuilder.newBuilder() - .expireAfterWrite(1, TimeUnit.HOURS) - .maximumSize(10000) - .build(new CacheLoader>() { - @Override - public Optional load(String wordId) { - return wordRepository.findById(wordId); - } - }); - - private final WordRepository wordRepository; - - public Optional get(String wordId) { - try { - return CACHE.get(wordId); - } catch (ExecutionException e) { - return wordRepository.findById(wordId); - } - } - - public void invalidate(String wordId) { - CACHE.invalidate(wordId); - } -} -``` - -**예상 효과**: - -- DynamoDB RCU 30-40% 감소 -- 응답 시간 50-70% 단축 - ---- - -#### 통계 캐싱 - -```java -public class StatsCache { - private static final Map CACHE = new ConcurrentHashMap<>(); - private static final long TTL_MS = 5 * 60 * 1000; // 5분 - - public Map getOrCompute(String userId, Supplier> compute) { - CachedStats cached = CACHE.get(userId); - - if (cached != null && !cached.isExpired()) { - return cached.getStats(); - } - - Map stats = compute.get(); - CACHE.put(userId, new CachedStats(stats)); - return stats; - } - - private static class CachedStats { - private final Map stats; - private final long timestamp; - - boolean isExpired() { - return System.currentTimeMillis() - timestamp > TTL_MS; - } - } -} -``` - ---- - -### 3.3 콜드 스타트 최적화 - -```mermaid -flowchart TB - subgraph Current["현재"] - H1[Handler 생성] --> S1[Service 생성] - S1 --> R1[Repository 생성] - R1 --> C1[DynamoDB Client 생성] - end - - subgraph Improved["개선"] - CONTAINER[ServiceContainer
Singleton] - H2[Handler] --> CONTAINER - CONTAINER --> S2[Services] - S2 --> R2[Repositories] - R2 --> C2[Shared Client] - end -``` - -**개선안**: ServiceContainer Singleton - -```java -public class ServiceContainer { - private static final ServiceContainer INSTANCE = new ServiceContainer(); - - private final UserWordCommandService userWordCommandService; - private final UserWordQueryService userWordQueryService; - private final WordQueryService wordQueryService; - private final TestCommandService testCommandService; - // ... - - private ServiceContainer() { - this.userWordCommandService = new UserWordCommandService(); - this.userWordQueryService = new UserWordQueryService(); - this.wordQueryService = new WordQueryService(); - this.testCommandService = new TestCommandService(); - } - - public static ServiceContainer getInstance() { - return INSTANCE; - } - - public UserWordCommandService getUserWordCommandService() { - return userWordCommandService; - } - // ... getters -} - -// Handler에서 사용 -public class UserWordHandler { - private final UserWordCommandService commandService; - private final UserWordQueryService queryService; - - public UserWordHandler() { - ServiceContainer container = ServiceContainer.getInstance(); - this.commandService = container.getUserWordCommandService(); - this.queryService = container.getUserWordQueryService(); - } -} -``` - ---- - -### 3.4 배치 처리 개선 - -#### Word 배치 저장 - -```java -public void saveBatch(List words) { - if (words == null || words.isEmpty()) { - return; - } - - // DynamoDB BatchWriteItem 제한: 25개 - List> batches = Lists.partition(words, 25); - - for (List batch : batches) { - WriteBatch.Builder writeBatchBuilder = WriteBatch.builder(Word.class) - .mappedTableResource(table); - - for (Word word : batch) { - writeBatchBuilder.addPutItem(word); - } - - enhancedClient.batchWriteItem(r -> r.writeBatches(writeBatchBuilder.build())); - } -} -``` - ---- - -## 4. 코드 품질 - -### 4.1 예외 처리 표준화 - -```mermaid -flowchart TB - subgraph Exceptions["예외 계층"] - BASE[BaseException] - BASE --> ENTITY[EntityNotFoundException] - BASE --> VALID[ValidationException] - BASE --> AUTH[AuthorizationException] - BASE --> DATA[DataAccessException] - end - - subgraph Handler["HandlerRouter"] - CATCH[예외 처리] - CATCH -->|EntityNotFoundException| R404[404 Not Found] - CATCH -->|ValidationException| R400[400 Bad Request] - CATCH -->|AuthorizationException| R403[403 Forbidden] - CATCH -->|DataAccessException| R500[500 Internal Error] - end -``` - -**예외 클래스 정의**: - -```java -public abstract class BaseException extends RuntimeException { - private final String errorCode; - - protected BaseException(String errorCode, String message) { - super(message); - this.errorCode = errorCode; - } - - public String getErrorCode() { - return errorCode; - } -} - -public class EntityNotFoundException extends BaseException { - public EntityNotFoundException(String entity, String id) { - super("NOT_FOUND", String.format("%s not found: %s", entity, id)); - } -} - -public class ValidationException extends BaseException { - public ValidationException(String message) { - super("VALIDATION_ERROR", message); - } -} -``` - -**HandlerRouter에서 처리**: - -```java -private APIGatewayProxyResponseEvent handleException(Exception e) { - if (e instanceof EntityNotFoundException) { - return createResponse(404, ApiResponse.error(e.getMessage())); - } - if (e instanceof ValidationException) { - return createResponse(400, ApiResponse.error(e.getMessage())); - } - if (e instanceof AuthorizationException) { - return createResponse(403, ApiResponse.error(e.getMessage())); - } - - logger.error("Unexpected error", e); - return createResponse(500, ApiResponse.error("Internal server error")); -} -``` - ---- - -### 4.2 RequestValidator 활용 확대 - -```java -public class RequestValidator { - private final List errors = new ArrayList<>(); - - public static RequestValidator create() { - return new RequestValidator(); - } - - public RequestValidator requireNotEmpty(String value, String fieldName) { - if (value == null || value.trim().isEmpty()) { - errors.add(fieldName + " is required"); - } - return this; - } - - public RequestValidator requireInRange(Integer value, int min, int max, String fieldName) { - if (value != null && (value < min || value > max)) { - errors.add(fieldName + " must be between " + min + " and " + max); - } - return this; - } - - public RequestValidator requireValidEnum(String value, Class> enumClass, String fieldName) { - if (value != null) { - boolean valid = Arrays.stream(enumClass.getEnumConstants()) - .anyMatch(e -> e.name().equals(value)); - if (!valid) { - errors.add(fieldName + " must be one of: " + - Arrays.toString(enumClass.getEnumConstants())); - } - } - return this; - } - - public ValidationResult build() { - return new ValidationResult(errors); - } -} - -// 사용 예시 -private APIGatewayProxyResponseEvent updateUserWord(APIGatewayProxyRequestEvent request) { - String userId = getQueryParam(request, "userId"); - String wordId = getPathParam(request, "wordId"); - String difficulty = getQueryParam(request, "difficulty"); - - ValidationResult validation = RequestValidator.create() - .requireNotEmpty(userId, "userId") - .requireNotEmpty(wordId, "wordId") - .requireValidEnum(difficulty, Difficulty.class, "difficulty") - .build(); - - if (validation.isInvalid()) { - return createResponse(400, ApiResponse.error(validation.getFirstError())); - } - - // 비즈니스 로직 -} -``` - ---- - -### 4.3 로깅 개선 - -#### 구조화된 로깅 - -```java -public class StructuredLogger { - private static final Gson GSON = new Gson(); - private final Logger logger; - - public StructuredLogger(Class clazz) { - this.logger = LoggerFactory.getLogger(clazz); - } - - public void info(String event, Map data) { - if (logger.isInfoEnabled()) { - Map log = new HashMap<>(data); - log.put("event", event); - log.put("timestamp", Instant.now().toString()); - logger.info("{}", GSON.toJson(log)); - } - } - - public void error(String event, Map data, Throwable t) { - Map log = new HashMap<>(data); - log.put("event", event); - log.put("timestamp", Instant.now().toString()); - log.put("errorType", t.getClass().getSimpleName()); - log.put("errorMessage", t.getMessage()); - logger.error("{}", GSON.toJson(log), t); - } -} - -// 사용 -private static final StructuredLogger slog = new StructuredLogger(UserWordService.class); - -public UserWord updateUserWord(String userId, String wordId, boolean isCorrect) { - // ... - slog.info("user_word_updated", Map.of( - "userId", userId, - "wordId", wordId, - "isCorrect", isCorrect, - "newStatus", userWord.getStatus() - )); - return userWord; -} -``` - ---- - -## 5. 개선 우선순위 및 일정 - -### 5.1 높음 우선순위 (즉시 적용) - -| 항목 | 영향도 | 예상 소요 | -|----------------------------------------------|-----|-------| -| Enum 도입 (StudyLevel, Difficulty, WordStatus) | 높음 | 4시간 | -| N+1 쿼리 최적화 (BatchGetItem, HashSet) | 높음 | 2시간 | -| 커스텀 예외 클래스 | 중간 | 1시간 | - -### 5.2 중간 우선순위 (중기 개선) - -| 항목 | 영향도 | 예상 소요 | -|------------------------------------|-----|-------| -| Factory Pattern (UserWord, Word) | 중간 | 2시간 | -| Word 캐싱 (Guava LoadingCache) | 높음 | 3시간 | -| 메서드 추출 (StatsService, TestService) | 중간 | 3시간 | -| ServiceContainer Singleton | 중간 | 2시간 | - -### 5.3 낮음 우선순위 (장기 개선) - -| 항목 | 영향도 | 예상 소요 | -|-----------------------------------|-----|-------| -| State Pattern (WordLearningState) | 낮음 | 5시간 | -| Specification Pattern (쿼리 추상화) | 낮음 | 4시간 | -| 구조화 로깅 | 낮음 | 2시간 | -| RequestValidator 확대 적용 | 낮음 | 2시간 | - ---- - -## 6. 예상 효과 - -```mermaid -flowchart LR - subgraph Before["개선 전"] - B1[DynamoDB RCU: 100%] - B2[응답 시간: 100%] - B3[콜드 스타트: 100%] - B4[코드 중복: 높음] - end - - subgraph After["개선 후"] - A1[DynamoDB RCU: 40-50%] - A2[응답 시간: 30-50%] - A3[콜드 스타트: 70-80%] - A4[코드 중복: 낮음] - end - - Before --> After -``` - -| 지표 | 현재 | 개선 후 | 감소율 | -|--------------|-------|----------|--------| -| DynamoDB RCU | 100 | 40-50 | 50-60% | -| 평균 응답 시간 | 200ms | 80-100ms | 50-60% | -| 콜드 스타트 시간 | 3s | 2-2.5s | 20-30% | -| 코드 라인 수 (중복) | 500+ | 200- | 60%+ | - ---- - -**버전**: 1.0.0 -**최종 업데이트**: 2026-01-09 -**팀**: MZC 2nd Project Team diff --git a/docs/grammar-api-specification.md b/docs/grammar-api-specification.md deleted file mode 100644 index dab8d63e..00000000 --- a/docs/grammar-api-specification.md +++ /dev/null @@ -1,401 +0,0 @@ -# Grammar API 명세서 - -## 서비스 개요 - -영어 문법 체크 및 AI 대화 연습 서비스입니다. - -### 주요 기능 -| 기능 | 설명 | -|------|------| -| **문법 체크** | 사용자가 입력한 영어 문장의 문법 오류를 분석하고 교정 | -| **AI 대화 연습** | AI와 1:1 영어 대화 연습 (문법 체크 + 대화 응답 + 학습 팁) | -| **세션 관리** | 대화 세션 목록 조회, 상세 조회, 삭제 | - -### 레벨 시스템 -| 레벨 | 설명 | -|------|------| -| `BEGINNER` | 초급 - 한국어 번역 포함, 쉬운 설명 | -| `INTERMEDIATE` | 중급 - 영어 위주 설명 | -| `ADVANCED` | 고급 - 상세한 문법 규칙 설명 | - ---- - -## Base URL - -``` -https://gc8l9ijhzc.execute-api.ap-northeast-2.amazonaws.com/dev -``` - -## 인증 - -모든 API는 **Cognito 인증**이 필요합니다. - -``` -Authorization: Bearer {ID_TOKEN} -``` - ---- - -## API 목록 - -| Method | Endpoint | 설명 | -|--------|----------|------| -| POST | `/grammar/check` | 문법 체크 | -| POST | `/grammar/conversation` | AI 대화 연습 | -| GET | `/grammar/sessions` | 세션 목록 조회 | -| GET | `/grammar/sessions/{sessionId}` | 세션 상세 조회 | -| DELETE | `/grammar/sessions/{sessionId}` | 세션 삭제 | - ---- - -## 1. 문법 체크 API - -영어 문장의 문법 오류를 분석하고 교정합니다. - -### Request - -``` -POST /grammar/check -Content-Type: application/json -Authorization: Bearer {TOKEN} -``` - -**Body:** -```json -{ - "sentence": "I goed to school yesterday.", - "level": "BEGINNER" -} -``` - -| 필드 | 타입 | 필수 | 설명 | -|------|------|------|------| -| sentence | string | ✅ | 검사할 영어 문장 | -| level | string | ❌ | 레벨 (BEGINNER/INTERMEDIATE/ADVANCED), 기본값: BEGINNER | - -### Response (성공) - -```json -{ - "isSuccess": true, - "message": "Grammar checked successfully", - "data": { - "originalSentence": "I goed to school yesterday.", - "correctedSentence": "I went to school yesterday.", - "score": 80, - "isCorrect": false, - "errors": [ - { - "type": "VERB_TENSE", - "original": "goed", - "corrected": "went", - "explanation": "The verb 'go' in the past tense should be 'went'. In Korean, this would be '갔어'.", - "startIndex": 2, - "endIndex": 6 - } - ], - "feedback": "Good try! The past tense of 'go' is 'went'. Keep practicing!" - } -} -``` - -| 필드 | 타입 | 설명 | -|------|------|------| -| originalSentence | string | 원본 문장 | -| correctedSentence | string | 교정된 문장 | -| score | number | 문법 점수 (0-100) | -| isCorrect | boolean | 문법 오류 없음 여부 | -| errors | array | 오류 목록 | -| feedback | string | 전체 피드백 메시지 | - -### Error Types (오류 타입) - -| 타입 | 한국어 | 설명 | -|------|--------|------| -| VERB_TENSE | 동사 시제 | 시제 오류 | -| SUBJECT_VERB_AGREEMENT | 주어-동사 일치 | 주어와 동사 수 일치 오류 | -| ARTICLE | 관사 | a/an/the 오류 | -| PREPOSITION | 전치사 | 전치사 오류 | -| WORD_ORDER | 어순 | 단어 순서 오류 | -| PLURAL_SINGULAR | 단/복수 | 단수/복수 오류 | -| PRONOUN | 대명사 | 대명사 오류 | -| SPELLING | 철자 | 철자 오류 | -| PUNCTUATION | 구두점 | 구두점 오류 | -| WORD_CHOICE | 어휘 선택 | 단어 선택 오류 | -| SENTENCE_STRUCTURE | 문장 구조 | 문장 구조 오류 | -| OTHER | 기타 | 기타 오류 | - ---- - -## 2. AI 대화 연습 API - -AI와 대화하면서 영어를 연습합니다. 사용자의 메시지에 대해 문법 체크 + AI 응답 + 학습 팁을 제공합니다. - -### Request - -``` -POST /grammar/conversation -Content-Type: application/json -Authorization: Bearer {TOKEN} -``` - -**Body:** -```json -{ - "sessionId": "550e8400-e29b-41d4-a716-446655440000", - "message": "I wants to learn English. Can you help me?", - "level": "BEGINNER" -} -``` - -| 필드 | 타입 | 필수 | 설명 | -|------|------|------|------| -| sessionId | string | ❌ | 세션 ID (없으면 새 세션 생성) | -| message | string | ✅ | 사용자 메시지 | -| level | string | ❌ | 레벨, 기본값: BEGINNER | - -### Response (성공) - -```json -{ - "isSuccess": true, - "message": "Conversation generated successfully", - "data": { - "sessionId": "550e8400-e29b-41d4-a716-446655440000", - "grammarCheck": { - "originalSentence": "I wants to learn English. Can you help me?", - "correctedSentence": "I want to learn English. Can you help me?", - "score": 90, - "isCorrect": false, - "errors": [ - { - "type": "SUBJECT_VERB_AGREEMENT", - "original": "wants", - "corrected": "want", - "explanation": "With 'I', use 'want' not 'wants'. 'Wants' is for he/she/it." - } - ], - "feedback": "Small mistake with subject-verb agreement. Keep going!" - }, - "aiResponse": "Of course! I'd be happy to help you learn English. What would you like to practice today? We can talk about any topic you're interested in.", - "conversationTip": "Try to use simple sentences first. For example: 'I like music.' or 'I want to travel.'" - } -} -``` - -| 필드 | 타입 | 설명 | -|------|------|------| -| sessionId | string | 세션 ID (다음 요청에 포함하면 대화 이어가기) | -| grammarCheck | object | 문법 체크 결과 (위 문법 체크 API 응답과 동일) | -| aiResponse | string | AI의 대화 응답 | -| conversationTip | string | 학습 팁 | - -### 대화 이어가기 - -세션 ID를 포함하면 이전 대화 컨텍스트를 유지하며 대화를 이어갑니다. - -```json -{ - "sessionId": "550e8400-e29b-41d4-a716-446655440000", - "message": "I like to watch movies.", - "level": "BEGINNER" -} -``` - ---- - -## 3. 세션 목록 조회 API - -사용자의 대화 세션 목록을 조회합니다. - -### Request - -``` -GET /grammar/sessions?limit=10&cursor={cursor} -Authorization: Bearer {TOKEN} -``` - -| 파라미터 | 타입 | 필수 | 설명 | -|----------|------|------|------| -| limit | number | ❌ | 조회 개수 (기본: 10, 최대: 50) | -| cursor | string | ❌ | 페이지네이션 커서 | - -### Response (성공) - -```json -{ - "isSuccess": true, - "message": "Sessions retrieved successfully", - "data": { - "sessions": [ - { - "sessionId": "550e8400-e29b-41d4-a716-446655440000", - "level": "BEGINNER", - "topic": null, - "messageCount": 5, - "lastMessage": "I like to watch movies.", - "createdAt": "2026-01-13T10:30:00Z", - "updatedAt": "2026-01-13T11:00:00Z" - } - ], - "nextCursor": "eyJQSyI6IkdTRVNT...", - "hasMore": true - } -} -``` - ---- - -## 4. 세션 상세 조회 API - -특정 세션의 상세 정보와 대화 기록을 조회합니다. - -### Request - -``` -GET /grammar/sessions/{sessionId}?messageLimit=50 -Authorization: Bearer {TOKEN} -``` - -| 파라미터 | 타입 | 필수 | 설명 | -|----------|------|------|------| -| sessionId | path | ✅ | 세션 ID | -| messageLimit | query | ❌ | 메시지 조회 개수 (기본: 50, 최대: 100) | - -### Response (성공) - -```json -{ - "isSuccess": true, - "message": "Session detail retrieved successfully", - "data": { - "session": { - "sessionId": "550e8400-e29b-41d4-a716-446655440000", - "level": "BEGINNER", - "messageCount": 5, - "createdAt": "2026-01-13T10:30:00Z", - "updatedAt": "2026-01-13T11:00:00Z" - }, - "messages": [ - { - "messageId": "msg-001", - "role": "USER", - "content": "I wants to learn English.", - "correctedContent": "I want to learn English.", - "grammarScore": 90, - "createdAt": "2026-01-13T10:30:00Z" - }, - { - "messageId": "msg-002", - "role": "ASSISTANT", - "content": "Of course! I'd be happy to help you learn English.", - "createdAt": "2026-01-13T10:30:05Z" - } - ] - } -} -``` - ---- - -## 5. 세션 삭제 API - -특정 세션을 삭제합니다. - -### Request - -``` -DELETE /grammar/sessions/{sessionId} -Authorization: Bearer {TOKEN} -``` - -### Response (성공) - -```json -{ - "isSuccess": true, - "message": "Session deleted successfully", - "data": null -} -``` - ---- - -## 에러 응답 - -### 공통 에러 형식 - -```json -{ - "code": "GRAMMAR.GRAMMAR_001", - "message": "유효하지 않은 문장입니다", - "status": 400, - "details": { - "sentence": "" - } -} -``` - -### 에러 코드 목록 - -| 코드 | 메시지 | HTTP Status | -|------|--------|-------------| -| GRAMMAR_001 | 유효하지 않은 문장입니다 | 400 | -| GRAMMAR_002 | 문법 체크에 실패했습니다 | 500 | -| GRAMMAR_003 | 유효하지 않은 레벨입니다 | 400 | -| GRAMMAR_004 | AI 서비스 호출에 실패했습니다 | 502 | -| GRAMMAR_005 | AI 응답 파싱에 실패했습니다 | 500 | -| GRAMMAR_006 | 세션을 찾을 수 없습니다 | 404 | -| GRAMMAR_007 | 세션이 만료되었습니다 | 410 | - ---- - -## 사용 시나리오 - -### 시나리오 1: 문법 체크만 사용 - -``` -1. POST /grammar/check - 문장 검사 -2. 결과 표시 (오류, 교정, 피드백) -``` - -### 시나리오 2: AI 대화 연습 - -``` -1. POST /grammar/conversation (sessionId 없이) - 새 대화 시작 -2. 응답에서 sessionId 저장 -3. POST /grammar/conversation (sessionId 포함) - 대화 이어가기 -4. 반복... -``` - -### 시나리오 3: 대화 기록 관리 - -``` -1. GET /grammar/sessions - 세션 목록 조회 -2. GET /grammar/sessions/{id} - 특정 세션 대화 기록 조회 -3. DELETE /grammar/sessions/{id} - 세션 삭제 -``` - ---- - -## UI 구현 참고사항 - -### 문법 체크 결과 표시 -- `errors` 배열의 `startIndex`, `endIndex`를 사용하여 원문에서 오류 부분 하이라이트 -- `score`로 점수 표시 (프로그레스 바 등) -- `isCorrect`가 true면 "Perfect!" 메시지 표시 - -### 대화 UI -- 채팅 형식 UI 권장 -- 사용자 메시지 위에 문법 체크 결과 표시 (말풍선 위 작은 배지 등) -- AI 응답 아래에 `conversationTip` 표시 - -### 레벨 선택 -- 처음 사용자는 BEGINNER로 시작 -- 설정에서 레벨 변경 가능하게 구현 - ---- - -## 연락처 - -백엔드 관련 문의: [담당자 연락처] From 751c74725564bf945e44ec3f45b697f5c266c9c9 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 15 Jan 2026 17:26:40 +0900 Subject: [PATCH 180/528] feat(grammar): add ComprehendClient to AwsClients - Add ComprehendClient singleton for Lambda cold start optimization - Add comprehend() getter method Resolves #311 --- .../secondproject/serverless/common/config/AwsClients.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/AwsClients.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/AwsClients.java index 3f72fdd7..12b21301 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/AwsClients.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/AwsClients.java @@ -7,6 +7,7 @@ import software.amazon.awssdk.services.polly.PollyClient; import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.comprehend.ComprehendClient; import software.amazon.awssdk.services.sns.SnsClient; /** @@ -30,6 +31,8 @@ public final class AwsClients { // Bedrock private static final BedrockRuntimeClient BEDROCK_CLIENT = BedrockRuntimeClient.builder().build(); private static final BedrockRuntimeAsyncClient BEDROCK_ASYNC_CLIENT = BedrockRuntimeAsyncClient.builder().build(); + // Comprehend + private static final ComprehendClient COMPREHEND_CLIENT = ComprehendClient.builder().build(); private AwsClients() { // 인스턴스화 방지 @@ -66,4 +69,8 @@ public static BedrockRuntimeClient bedrock() { public static BedrockRuntimeAsyncClient bedrockAsync() { return BEDROCK_ASYNC_CLIENT; } + + public static ComprehendClient comprehend() { + return COMPREHEND_CLIENT; + } } From 8b535f6c93f15f742ad353588b04c0ba362a8be5 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 15 Jan 2026 17:27:47 +0900 Subject: [PATCH 181/528] feat(grammar): add Comprehend IAM permissions to GrammarFunction - Add DetectSentiment, DetectSyntax, DetectKeyPhrases, DetectDominantLanguage permissions Resolves #312 --- ServerlessFunction/template.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index ce95323b..4fd81a9e 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -1045,6 +1045,14 @@ Resources: - aws-marketplace:ViewSubscriptions - aws-marketplace:Subscribe Resource: "*" + - Statement: + - Effect: Allow + Action: + - comprehend:DetectSentiment + - comprehend:DetectSyntax + - comprehend:DetectKeyPhrases + - comprehend:DetectDominantLanguage + Resource: "*" Events: GrammarCheck: Type: Api From 4a39ed8474cd7cf493e98ad1c316a41d12025d19 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 15 Jan 2026 17:29:07 +0900 Subject: [PATCH 182/528] feat(grammar): add ComprehendAnalysis DTO - Add sentiment, sentimentScore, syntax, keyPhrases, complexity fields - Add nested SentimentScore and SyntaxToken DTOs Resolves #313 --- .../dto/response/ComprehendAnalysis.java | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/dto/response/ComprehendAnalysis.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/dto/response/ComprehendAnalysis.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/dto/response/ComprehendAnalysis.java new file mode 100644 index 00000000..e591df93 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/dto/response/ComprehendAnalysis.java @@ -0,0 +1,42 @@ +package com.mzc.secondproject.serverless.domain.grammar.dto.response; + +import lombok.*; + +import java.util.List; + +/** + * Comprehend 분석 결과 DTO + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ComprehendAnalysis { + private String sentiment; + private SentimentScore sentimentScore; + private List syntax; + private List keyPhrases; + private String complexity; + private String language; + private Double languageScore; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class SentimentScore { + private Double positive; + private Double negative; + private Double neutral; + private Double mixed; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class SyntaxToken { + private String text; + private String partOfSpeech; + } +} From 79515bf5ea3d647303e4be2af989803e7c641687 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 15 Jan 2026 17:29:41 +0900 Subject: [PATCH 183/528] feat(grammar): add analysis field to GrammarCheckResponse - Add ComprehendAnalysis field for enhanced grammar analysis Resolves #314 --- .../domain/grammar/dto/response/GrammarCheckResponse.java | 1 + 1 file changed, 1 insertion(+) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/dto/response/GrammarCheckResponse.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/dto/response/GrammarCheckResponse.java index aa4dc611..d3c44f7c 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/dto/response/GrammarCheckResponse.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/dto/response/GrammarCheckResponse.java @@ -14,4 +14,5 @@ public class GrammarCheckResponse { private List errors; private String feedback; private Boolean isCorrect; + private ComprehendAnalysis analysis; } From 3eb2714287ef86600468abb2c001631e4491aa9e Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 15 Jan 2026 17:31:19 +0900 Subject: [PATCH 184/528] feat(grammar): add ComprehendService for text analysis - Add sentiment, syntax, key phrases analysis - Add complexity calculation based on POS diversity and sentence length Resolves #315 --- .../common/service/ComprehendService.java | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/service/ComprehendService.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/service/ComprehendService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/service/ComprehendService.java new file mode 100644 index 00000000..86a277c4 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/service/ComprehendService.java @@ -0,0 +1,115 @@ +package com.mzc.secondproject.serverless.common.service; + +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.domain.grammar.dto.response.ComprehendAnalysis; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.services.comprehend.ComprehendClient; +import software.amazon.awssdk.services.comprehend.model.*; + +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Amazon Comprehend 서비스 + */ +public class ComprehendService { + + private static final Logger logger = LoggerFactory.getLogger(ComprehendService.class); + + private final ComprehendClient comprehendClient; + + public ComprehendService() { + this.comprehendClient = AwsClients.comprehend(); + } + + public ComprehendService(ComprehendClient comprehendClient) { + this.comprehendClient = comprehendClient; + } + + /** + * 텍스트 종합 분석 + */ + public ComprehendAnalysis analyze(String text) { + try { + DetectSentimentResponse sentimentResponse = detectSentiment(text); + DetectSyntaxResponse syntaxResponse = detectSyntax(text); + DetectKeyPhrasesResponse keyPhrasesResponse = detectKeyPhrases(text); + + List syntaxTokens = syntaxResponse.syntaxTokens().stream() + .map(token -> ComprehendAnalysis.SyntaxToken.builder() + .text(token.text()) + .partOfSpeech(token.partOfSpeech().tagAsString()) + .build()) + .collect(Collectors.toList()); + + List keyPhrases = keyPhrasesResponse.keyPhrases().stream() + .map(KeyPhrase::text) + .collect(Collectors.toList()); + + String complexity = calculateComplexity(syntaxTokens); + + return ComprehendAnalysis.builder() + .sentiment(sentimentResponse.sentimentAsString()) + .sentimentScore(ComprehendAnalysis.SentimentScore.builder() + .positive((double) sentimentResponse.sentimentScore().positive()) + .negative((double) sentimentResponse.sentimentScore().negative()) + .neutral((double) sentimentResponse.sentimentScore().neutral()) + .mixed((double) sentimentResponse.sentimentScore().mixed()) + .build()) + .syntax(syntaxTokens) + .keyPhrases(keyPhrases) + .complexity(complexity) + .language("en") + .languageScore(0.99) + .build(); + + } catch (Exception e) { + logger.error("Comprehend analysis failed: {}", e.getMessage()); + return null; + } + } + + private DetectSentimentResponse detectSentiment(String text) { + return comprehendClient.detectSentiment(DetectSentimentRequest.builder() + .text(text) + .languageCode(LanguageCode.EN) + .build()); + } + + private DetectSyntaxResponse detectSyntax(String text) { + return comprehendClient.detectSyntax(DetectSyntaxRequest.builder() + .text(text) + .languageCode(SyntaxLanguageCode.EN) + .build()); + } + + private DetectKeyPhrasesResponse detectKeyPhrases(String text) { + return comprehendClient.detectKeyPhrases(DetectKeyPhrasesRequest.builder() + .text(text) + .languageCode(LanguageCode.EN) + .build()); + } + + /** + * 문장 복잡도 계산 + * - 품사 다양성 + 문장 길이 기반 + */ + private String calculateComplexity(List syntax) { + Set uniquePOS = syntax.stream() + .map(ComprehendAnalysis.SyntaxToken::getPartOfSpeech) + .collect(Collectors.toSet()); + + int posCount = uniquePOS.size(); + int sentenceLength = syntax.size(); + + if (posCount <= 3 && sentenceLength <= 5) { + return "BEGINNER"; + } else if (posCount <= 5 && sentenceLength <= 10) { + return "INTERMEDIATE"; + } else { + return "ADVANCED"; + } + } +} From 2980fe97d4cd89571de6948bfa3c4970e28defd3 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 15 Jan 2026 17:32:22 +0900 Subject: [PATCH 185/528] feat(grammar): integrate ComprehendService into GrammarCheckService - Add ComprehendService dependency - Call Comprehend analysis after Bedrock grammar check - Handle graceful degradation on Comprehend failure Resolves #317 --- .../grammar/service/GrammarCheckService.java | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/service/GrammarCheckService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/service/GrammarCheckService.java index 5ab9939a..6bf747f1 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/service/GrammarCheckService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/service/GrammarCheckService.java @@ -1,6 +1,8 @@ package com.mzc.secondproject.serverless.domain.grammar.service; +import com.mzc.secondproject.serverless.common.service.ComprehendService; import com.mzc.secondproject.serverless.domain.grammar.dto.request.GrammarCheckRequest; +import com.mzc.secondproject.serverless.domain.grammar.dto.response.ComprehendAnalysis; import com.mzc.secondproject.serverless.domain.grammar.dto.response.GrammarCheckResponse; import com.mzc.secondproject.serverless.domain.grammar.enums.GrammarLevel; import com.mzc.secondproject.serverless.domain.grammar.exception.GrammarException; @@ -14,13 +16,16 @@ public class GrammarCheckService { private static final Logger logger = LoggerFactory.getLogger(GrammarCheckService.class); private final GrammarCheckFactory grammarCheckFactory; + private final ComprehendService comprehendService; public GrammarCheckService() { this.grammarCheckFactory = new BedrockGrammarCheckFactory(); + this.comprehendService = new ComprehendService(); } - public GrammarCheckService(GrammarCheckFactory grammarCheckFactory) { + public GrammarCheckService(GrammarCheckFactory grammarCheckFactory, ComprehendService comprehendService) { this.grammarCheckFactory = grammarCheckFactory; + this.comprehendService = comprehendService; } public GrammarCheckResponse checkGrammar(GrammarCheckRequest request) { @@ -30,7 +35,18 @@ public GrammarCheckResponse checkGrammar(GrammarCheckRequest request) { GrammarLevel level = parseLevel(request.getLevel()); - return grammarCheckFactory.checkGrammar(request.getSentence(), level); + GrammarCheckResponse response = grammarCheckFactory.checkGrammar(request.getSentence(), level); + + try { + ComprehendAnalysis analysis = comprehendService.analyze(request.getSentence()); + response.setAnalysis(analysis); + logger.info("Comprehend analysis completed: complexity={}", + analysis != null ? analysis.getComplexity() : "null"); + } catch (Exception e) { + logger.warn("Comprehend analysis failed, continuing without analysis: {}", e.getMessage()); + } + + return response; } private void validateRequest(GrammarCheckRequest request) { From 199f3647b051bd9540cdeca3b02d12f9497874f0 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 15 Jan 2026 17:45:14 +0900 Subject: [PATCH 186/528] feat(game): add ScoreUpdateMessage DTO - Add DTO for real-time score update WebSocket messages - Include ranking with rank, userId, score, change fields - Add factory method for easy construction Resolves #323 --- .../dto/response/ScoreUpdateMessage.java | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/ScoreUpdateMessage.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/ScoreUpdateMessage.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/ScoreUpdateMessage.java new file mode 100644 index 00000000..0ac79f0f --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/ScoreUpdateMessage.java @@ -0,0 +1,72 @@ +package com.mzc.secondproject.serverless.domain.chatting.dto.response; + +import lombok.*; + +import java.util.List; +import java.util.Map; + +/** + * 실시간 점수 업데이트 메시지 DTO + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ScoreUpdateMessage { + private String messageType; + private String roomId; + private String scorerId; + private Integer scoreGained; + private Integer totalScore; + private List ranking; + private Integer currentRound; + private Integer totalRounds; + private String timestamp; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class RankEntry { + private Integer rank; + private String userId; + private Integer score; + private Integer change; + } + + public static ScoreUpdateMessage from(String roomId, String scorerId, int scoreGained, + Map scores, int currentRound, int totalRounds) { + List ranking = buildRanking(scores); + + return ScoreUpdateMessage.builder() + .messageType("SCORE_UPDATE") + .roomId(roomId) + .scorerId(scorerId) + .scoreGained(scoreGained) + .totalScore(scores.getOrDefault(scorerId, 0)) + .ranking(ranking) + .currentRound(currentRound) + .totalRounds(totalRounds) + .timestamp(java.time.Instant.now().toString()) + .build(); + } + + private static List buildRanking(Map scores) { + if (scores == null || scores.isEmpty()) { + return List.of(); + } + + List> sorted = scores.entrySet().stream() + .sorted(Map.Entry.comparingByValue().reversed()) + .toList(); + + return java.util.stream.IntStream.range(0, sorted.size()) + .mapToObj(i -> RankEntry.builder() + .rank(i + 1) + .userId(sorted.get(i).getKey()) + .score(sorted.get(i).getValue()) + .change(0) + .build()) + .toList(); + } +} From c83667004cde62197cbb97fe6874137bcb318f98 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 15 Jan 2026 17:47:07 +0900 Subject: [PATCH 187/528] feat(game): enhance broadcastScoreUpdate with ScoreUpdateMessage DTO - Use ScoreUpdateMessage DTO for structured score update messages - Include scorerId, scoreGained, ranking list - Add currentRound and totalRounds to score update Resolves #324 Resolves #325 --- .../websocket/WebSocketMessageHandler.java | 41 ++++++++----------- 1 file changed, 18 insertions(+), 23 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java index 3716e2f3..24870111 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java @@ -7,6 +7,7 @@ import com.mzc.secondproject.serverless.common.util.WebSocketBroadcaster; import com.mzc.secondproject.serverless.common.util.WebSocketEventUtil; import com.mzc.secondproject.serverless.domain.chatting.dto.response.CommandResult; +import com.mzc.secondproject.serverless.domain.chatting.dto.response.ScoreUpdateMessage; import com.mzc.secondproject.serverless.domain.chatting.model.ChatMessage; import com.mzc.secondproject.serverless.domain.chatting.model.Connection; import com.mzc.secondproject.serverless.domain.chatting.repository.ChatRoomRepository; @@ -184,8 +185,11 @@ private Map handleCorrectAnswer(MessagePayload payload, GameServ // 1. 정답 알림 메시지 브로드캐스트 broadcastCorrectAnswerMessage(payload, result, connections); - // 2. 점수 업데이트 메시지 브로드캐스트 - broadcastScoreUpdate(payload.roomId, result.scores(), connections); + // 2. 점수 업데이트 메시지 브로드캐스트 (실시간 리더보드) + chatRoomRepository.findById(payload.roomId).ifPresent(room -> { + broadcastScoreUpdate(payload.roomId, payload.userId, result.score(), + result.scores(), room.getCurrentRound(), room.getTotalRounds(), connections); + }); logger.info("Correct answer: roomId={}, userId={}, score={}", payload.roomId, payload.userId, result.score()); @@ -227,36 +231,27 @@ private void broadcastCorrectAnswerMessage(MessagePayload payload, GameService.A } /** - * 점수 업데이트 메시지 브로드캐스트 + * 점수 업데이트 메시지 브로드캐스트 (실시간 리더보드) */ - private void broadcastScoreUpdate(String roomId, Map scores, List connections) { + private void broadcastScoreUpdate(String roomId, String scorerId, int scoreGained, + Map scores, Integer currentRound, + Integer totalRounds, List connections) { if (scores == null || scores.isEmpty()) { return; } - String messageId = UUID.randomUUID().toString(); - String now = Instant.now().toString(); + ScoreUpdateMessage scoreUpdate = ScoreUpdateMessage.from( + roomId, scorerId, scoreGained, scores, + currentRound != null ? currentRound : 0, + totalRounds != null ? totalRounds : 0 + ); - // 점수 현황 문자열 생성 - StringBuilder sb = new StringBuilder("📊 현재 점수:\n"); - scores.entrySet().stream() - .sorted((a, b) -> b.getValue().compareTo(a.getValue())) - .forEach(entry -> sb.append(String.format(" %s: %d점\n", entry.getKey(), entry.getValue()))); - - Map scoreUpdateMessage = new HashMap<>(); - scoreUpdateMessage.put("messageId", messageId); - scoreUpdateMessage.put("roomId", roomId); - scoreUpdateMessage.put("userId", "SYSTEM"); - scoreUpdateMessage.put("content", sb.toString()); - scoreUpdateMessage.put("messageType", MessageType.SCORE_UPDATE.getCode()); - scoreUpdateMessage.put("createdAt", now); - scoreUpdateMessage.put("scores", scores); - - String broadcastPayload = gson.toJson(scoreUpdateMessage); + String broadcastPayload = gson.toJson(scoreUpdate); List failedConnections = broadcaster.broadcast(connections, broadcastPayload); cleanupFailedConnections(failedConnections); - logger.info("Score update broadcasted: roomId={}", roomId); + logger.info("Score update broadcasted: roomId={}, scorerId={}, scoreGained={}", + roomId, scorerId, scoreGained); } /** From 2ecd44f3822ac9cc9642cb0e1f02628f1bc0beb2 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 15 Jan 2026 17:54:38 +0900 Subject: [PATCH 188/528] =?UTF-8?q?feat(game):=20ROUND=5FEND=20=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=EC=97=90=20ranking=20=ED=95=84=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 라운드 종료 시 현재 순위 정보를 포함하도록 개선 - ranking: [{rank, userId, score}] 형태의 순위 리스트 추가 - currentRound, totalRounds 필드 추가 --- .../domain/chatting/service/GameService.java | 38 ++++++++++++++++++- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java index 7f5f54f2..8a908bea 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java @@ -363,8 +363,19 @@ public CommandResult endRound(ChatRoom room, String reason) { logger.info("Round ended: roomId={}, round={}, reason={}", roomId, currentRound, reason); - return CommandResult.success(MessageType.ROUND_END, message, - Map.of("answer", answer, "nextRound", nextRound, "nextDrawer", nextDrawer, "nextWord", nextWord)); + // ranking 생성 + List> ranking = buildRankingList(room.getScores()); + + Map data = new HashMap<>(); + data.put("answer", answer); + data.put("nextRound", nextRound); + data.put("nextDrawer", nextDrawer); + data.put("nextWord", nextWord); + data.put("ranking", ranking); + data.put("currentRound", currentRound); + data.put("totalRounds", room.getTotalRounds()); + + return CommandResult.success(MessageType.ROUND_END, message, data); } /** @@ -501,6 +512,29 @@ private void resetStreaksForNonGuessers(ChatRoom room) { logger.info("Reset streaks for non-guessers: correctGuessers={}", correctGuessers); } + /** + * 점수 맵을 순위 리스트로 변환 + */ + private List> buildRankingList(Map scores) { + if (scores == null || scores.isEmpty()) { + return List.of(); + } + + List> sorted = scores.entrySet().stream() + .sorted(Map.Entry.comparingByValue().reversed()) + .toList(); + + List> ranking = new ArrayList<>(); + for (int i = 0; i < sorted.size(); i++) { + Map entry = new HashMap<>(); + entry.put("rank", i + 1); + entry.put("userId", sorted.get(i).getKey()); + entry.put("score", sorted.get(i).getValue()); + ranking.add(entry); + } + return ranking; + } + // ========== Result DTOs ========== public record GameStartResult( From 87317cb1776b1c9a0b12fd673dcb2fb039e45f6b Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 10:32:41 +0900 Subject: [PATCH 189/528] feat: add RankingEventType enum with score definitions --- .../ranking/model/RankingEventType.java | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/ranking/model/RankingEventType.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/ranking/model/RankingEventType.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/ranking/model/RankingEventType.java new file mode 100644 index 00000000..278d75a9 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/ranking/model/RankingEventType.java @@ -0,0 +1,28 @@ +package com.mzc.secondproject.serverless.domain.ranking.model; + +public enum RankingEventType { + ATTENDANCE(10, "출석 체크"), + WORD_LEARNED(5, "단어 학습"), + WORD_MASTERED(20, "단어 마스터"), + TEST_COMPLETED(15, "시험 완료"), + GAME_PLAYED(10, "게임 참여"), + GAME_WON(50, "게임 1등"), + GRAMMAR_CHECK(3, "문법 체크"), + STREAK_BONUS(5, "연속 학습 보너스"); + + private final int baseScore; + private final String description; + + RankingEventType(int baseScore, String description) { + this.baseScore = baseScore; + this.description = description; + } + + public int getBaseScore() { + return baseScore; + } + + public String getDescription() { + return description; + } +} From b46d7c572cf9beb8a73c14acc56e13ed8ec52a5c Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 10:32:41 +0900 Subject: [PATCH 190/528] feat: add RankingEvent model for Kinesis events --- .../domain/ranking/model/RankingEvent.java | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/ranking/model/RankingEvent.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/ranking/model/RankingEvent.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/ranking/model/RankingEvent.java new file mode 100644 index 00000000..6efd0a55 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/ranking/model/RankingEvent.java @@ -0,0 +1,53 @@ +package com.mzc.secondproject.serverless.domain.ranking.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; +import java.util.Map; +import java.util.UUID; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class RankingEvent { + + private String eventId; + private RankingEventType eventType; + private String userId; + private String timestamp; + private int score; + private Map payload; + private EventMetadata metadata; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class EventMetadata { + private String source; + private String version; + } + + public static RankingEvent create(RankingEventType eventType, String userId, int score, Map payload, String source) { + return RankingEvent.builder() + .eventId(UUID.randomUUID().toString()) + .eventType(eventType) + .userId(userId) + .timestamp(Instant.now().toString()) + .score(score) + .payload(payload) + .metadata(EventMetadata.builder() + .source(source) + .version("1.0") + .build()) + .build(); + } + + public static RankingEvent create(RankingEventType eventType, String userId, Map payload, String source) { + return create(eventType, userId, eventType.getBaseScore(), payload, source); + } +} From fc5220905bb2bf5e148a996e519bcb57761ca194 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 10:33:12 +0900 Subject: [PATCH 191/528] feat: add KinesisEventPublisher service for ranking events --- .../service/KinesisEventPublisher.java | 152 ++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/ranking/service/KinesisEventPublisher.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/ranking/service/KinesisEventPublisher.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/ranking/service/KinesisEventPublisher.java new file mode 100644 index 00000000..12cbaa0c --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/ranking/service/KinesisEventPublisher.java @@ -0,0 +1,152 @@ +package com.mzc.secondproject.serverless.domain.ranking.service; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.mzc.secondproject.serverless.domain.ranking.model.RankingEvent; +import com.mzc.secondproject.serverless.domain.ranking.model.RankingEventType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.core.SdkBytes; +import software.amazon.awssdk.http.urlconnection.UrlConnectionHttpClient; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.kinesis.KinesisClient; +import software.amazon.awssdk.services.kinesis.model.PutRecordRequest; +import software.amazon.awssdk.services.kinesis.model.PutRecordResponse; + +import java.util.Map; + +public class KinesisEventPublisher { + + private static final Logger logger = LoggerFactory.getLogger(KinesisEventPublisher.class); + private static final Gson gson = new GsonBuilder().create(); + + private final KinesisClient kinesisClient; + private final String streamName; + + public KinesisEventPublisher() { + this.streamName = System.getenv("RANKING_STREAM_NAME"); + this.kinesisClient = KinesisClient.builder() + .region(Region.of(System.getenv("AWS_REGION_NAME"))) + .httpClient(UrlConnectionHttpClient.builder().build()) + .build(); + } + + public void publish(RankingEvent event) { + if (streamName == null || streamName.isEmpty()) { + logger.warn("RANKING_STREAM_NAME not configured, skipping event publish"); + return; + } + + try { + String eventJson = gson.toJson(event); + + PutRecordRequest request = PutRecordRequest.builder() + .streamName(streamName) + .partitionKey(event.getUserId()) + .data(SdkBytes.fromUtf8String(eventJson)) + .build(); + + PutRecordResponse response = kinesisClient.putRecord(request); + + logger.info("Published ranking event: type={}, userId={}, score={}, shardId={}, sequenceNumber={}", + event.getEventType(), event.getUserId(), event.getScore(), + response.shardId(), response.sequenceNumber()); + + } catch (Exception e) { + logger.error("Failed to publish ranking event: type={}, userId={}, error={}", + event.getEventType(), event.getUserId(), e.getMessage(), e); + } + } + + public void publishTestCompleted(String userId, int correctAnswers, int totalQuestions, double successRate, String testId) { + int bonusScore = (int) (successRate * 10); + int totalScore = RankingEventType.TEST_COMPLETED.getBaseScore() + bonusScore; + + RankingEvent event = RankingEvent.create( + RankingEventType.TEST_COMPLETED, + userId, + totalScore, + Map.of( + "testId", testId, + "correctAnswers", correctAnswers, + "totalQuestions", totalQuestions, + "successRate", successRate + ), + "TestHandler" + ); + publish(event); + } + + public void publishWordLearned(String userId, String wordId) { + RankingEvent event = RankingEvent.create( + RankingEventType.WORD_LEARNED, + userId, + Map.of("wordId", wordId), + "DailyStudyHandler" + ); + publish(event); + } + + public void publishWordMastered(String userId, String wordId) { + RankingEvent event = RankingEvent.create( + RankingEventType.WORD_MASTERED, + userId, + Map.of("wordId", wordId), + "UserWordHandler" + ); + publish(event); + } + + public void publishGamePlayed(String userId, String roomId, int score) { + RankingEvent event = RankingEvent.create( + RankingEventType.GAME_PLAYED, + userId, + RankingEventType.GAME_PLAYED.getBaseScore() + score, + Map.of("roomId", roomId, "gameScore", score), + "GameHandler" + ); + publish(event); + } + + public void publishGameWon(String userId, String roomId, int finalScore) { + RankingEvent event = RankingEvent.create( + RankingEventType.GAME_WON, + userId, + Map.of("roomId", roomId, "finalScore", finalScore), + "GameHandler" + ); + publish(event); + } + + public void publishGrammarCheck(String userId, String sessionId) { + RankingEvent event = RankingEvent.create( + RankingEventType.GRAMMAR_CHECK, + userId, + Map.of("sessionId", sessionId != null ? sessionId : "single-check"), + "GrammarHandler" + ); + publish(event); + } + + public void publishAttendance(String userId) { + RankingEvent event = RankingEvent.create( + RankingEventType.ATTENDANCE, + userId, + Map.of(), + "UserStatsHandler" + ); + publish(event); + } + + public void publishStreakBonus(String userId, int streakDays) { + int bonusScore = RankingEventType.STREAK_BONUS.getBaseScore() * streakDays; + RankingEvent event = RankingEvent.create( + RankingEventType.STREAK_BONUS, + userId, + bonusScore, + Map.of("streakDays", streakDays), + "StatsStreamHandler" + ); + publish(event); + } +} From 70bfadf67ae43651438de1934dad1b1d25b2c0a9 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 10:34:16 +0900 Subject: [PATCH 192/528] feat: integrate ranking event publishing in TestHandler --- .../vocabulary/handler/TestHandler.java | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/TestHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/TestHandler.java index 949e045d..6ab72cf9 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/TestHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/TestHandler.java @@ -15,6 +15,7 @@ import com.mzc.secondproject.serverless.domain.vocabulary.model.TestResult; import com.mzc.secondproject.serverless.domain.vocabulary.service.TestCommandService; import com.mzc.secondproject.serverless.domain.vocabulary.service.TestQueryService; +import com.mzc.secondproject.serverless.domain.ranking.service.KinesisEventPublisher; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -27,11 +28,13 @@ public class TestHandler implements RequestHandler { String testType = dto.getTestType() != null ? dto.getTestType() : "DAILY"; - + TestCommandService.SubmitTestResult result = commandService.submitTest( userId, dto.getTestId(), testType, dto.getAnswers(), dto.getStartedAt()); - + + eventPublisher.publishTestCompleted( + userId, + result.correctCount(), + result.totalQuestions(), + result.successRate(), + result.testId() + ); + Map response = new HashMap<>(); response.put("testId", result.testId()); response.put("testType", result.testType()); @@ -84,7 +95,7 @@ private APIGatewayProxyResponseEvent submitAnswer(APIGatewayProxyRequestEvent re response.put("incorrectCount", result.incorrectCount()); response.put("successRate", result.successRate()); response.put("results", result.results()); - + return ResponseGenerator.ok("Test submitted", response); }); } From cee000bcb01ac04c8eb6581f6cc5af571e5cde89 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 10:35:07 +0900 Subject: [PATCH 193/528] feat: integrate ranking event publishing in DailyStudyHandler --- .../domain/vocabulary/handler/DailyStudyHandler.java | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/DailyStudyHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/DailyStudyHandler.java index 44ffa02d..d6576f36 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/DailyStudyHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/DailyStudyHandler.java @@ -10,6 +10,7 @@ import com.mzc.secondproject.serverless.domain.vocabulary.exception.VocabularyErrorCode; import com.mzc.secondproject.serverless.domain.vocabulary.service.DailyStudyCommandService; import com.mzc.secondproject.serverless.domain.vocabulary.service.DailyStudyQueryService; +import com.mzc.secondproject.serverless.domain.ranking.service.KinesisEventPublisher; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -22,11 +23,13 @@ public class DailyStudyHandler implements RequestHandler progress = commandService.markWordLearned(userId, wordId); + + eventPublisher.publishWordLearned(userId, wordId); + return ResponseGenerator.ok("Word marked as learned", progress); } } From 98c8312658fd06888d7a2e2a75f08ebcd55e054c Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 10:36:02 +0900 Subject: [PATCH 194/528] feat: integrate ranking event publishing in UserWordHandler --- .../vocabulary/handler/UserWordHandler.java | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java index 222c3354..051f39ee 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java @@ -16,6 +16,7 @@ import com.mzc.secondproject.serverless.domain.vocabulary.model.UserWord; import com.mzc.secondproject.serverless.domain.vocabulary.service.UserWordCommandService; import com.mzc.secondproject.serverless.domain.vocabulary.service.UserWordQueryService; +import com.mzc.secondproject.serverless.domain.ranking.service.KinesisEventPublisher; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -29,11 +30,13 @@ public class UserWordHandler implements RequestHandler body = ResponseGenerator.gson().fromJson(request.getBody(), new TypeToken>() { }.getType()); - + String status = body != null ? body.get("status") : null; if (status == null || status.isEmpty()) { return ResponseGenerator.fail(CommonErrorCode.REQUIRED_FIELD_MISSING); } - + try { UserWord userWord = commandService.updateWordStatus(userId, wordId, status); + + if ("MASTERED".equalsIgnoreCase(status)) { + eventPublisher.publishWordMastered(userId, wordId); + } + return ResponseGenerator.ok("Word status updated", userWord); } catch (IllegalArgumentException e) { return ResponseGenerator.fail(VocabularyErrorCode.INVALID_WORD_STATUS); From c7e36bd1a7e880b5b129a2a2892f806a7fdea107 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 10:37:08 +0900 Subject: [PATCH 195/528] feat: integrate ranking event publishing in GameHandler --- .../domain/chatting/handler/GameHandler.java | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameHandler.java index 5e506327..25e839b1 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameHandler.java @@ -20,6 +20,7 @@ import com.mzc.secondproject.serverless.domain.chatting.repository.ChatRoomRepository; import com.mzc.secondproject.serverless.domain.chatting.repository.ConnectionRepository; import com.mzc.secondproject.serverless.domain.chatting.service.GameService; +import com.mzc.secondproject.serverless.domain.ranking.service.KinesisEventPublisher; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -37,6 +38,7 @@ public class GameHandler implements RequestHandler optRoom = chatRoomRepository.findById(roomId); + Map scoresBeforeStop = optRoom.map(ChatRoom::getScores).orElse(Map.of()); + CommandResult result = gameService.stopGame(roomId, userId); if (!result.success()) { return ResponseGenerator.fail(ChattingErrorCode.GAME_STOP_FAILED, result.message()); } + // 랭킹 이벤트 발행 + publishGameEndEvents(roomId, scoresBeforeStop); + // WebSocket으로 게임 종료 알림 브로드캐스트 broadcastSystemMessage(roomId, result.message(), MessageType.GAME_END); return ResponseGenerator.ok("Game stopped", Map.of("message", result.message())); } + /** + * 게임 종료 시 랭킹 이벤트 발행 + */ + private void publishGameEndEvents(String roomId, Map scores) { + if (scores == null || scores.isEmpty()) { + return; + } + + // 1등 찾기 + String winnerId = null; + int maxScore = 0; + for (Map.Entry entry : scores.entrySet()) { + if (entry.getValue() > maxScore) { + maxScore = entry.getValue(); + winnerId = entry.getKey(); + } + } + + // 각 참가자에게 GAME_PLAYED 이벤트 발행 + for (Map.Entry entry : scores.entrySet()) { + String participantId = entry.getKey(); + int score = entry.getValue(); + + eventPublisher.publishGamePlayed(participantId, roomId, score); + + // 1등에게 GAME_WON 이벤트 추가 발행 + if (participantId.equals(winnerId) && score > 0) { + eventPublisher.publishGameWon(participantId, roomId, score); + } + } + + logger.info("Game end events published: roomId={}, participants={}", roomId, scores.size()); + } + /** * GET /rooms/{roomId}/game/status - 게임 상태 조회 */ From 5cc1154ce7d941d25e16915d8611b2e359b1dace Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 10:38:05 +0900 Subject: [PATCH 196/528] feat: integrate ranking event publishing in GrammarHandler --- .../serverless/domain/grammar/handler/GrammarHandler.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/GrammarHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/GrammarHandler.java index e6467272..b514a62b 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/GrammarHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/GrammarHandler.java @@ -16,6 +16,7 @@ import com.mzc.secondproject.serverless.domain.grammar.service.GrammarCheckService; import com.mzc.secondproject.serverless.domain.grammar.service.GrammarConversationService; import com.mzc.secondproject.serverless.domain.grammar.service.GrammarSessionQueryService; +import com.mzc.secondproject.serverless.domain.ranking.service.KinesisEventPublisher; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -29,12 +30,14 @@ public class GrammarHandler implements RequestHandler { GrammarCheckResponse result = grammarCheckService.checkGrammar(dto); + eventPublisher.publishGrammarCheck(userId, null); + Map response = new HashMap<>(); response.put("originalSentence", result.getOriginalSentence()); response.put("correctedSentence", result.getCorrectedSentence()); From 464688e5d9626b1a49c5feaa6e8f89d28a7be695 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 10:38:53 +0900 Subject: [PATCH 197/528] feat: add Kinesis SDK dependency to build.gradle --- ServerlessFunction/build.gradle | 1 + 1 file changed, 1 insertion(+) diff --git a/ServerlessFunction/build.gradle b/ServerlessFunction/build.gradle index 9ea658f5..55a906cc 100644 --- a/ServerlessFunction/build.gradle +++ b/ServerlessFunction/build.gradle @@ -32,6 +32,7 @@ dependencies { implementation 'software.amazon.awssdk:bedrockruntime' implementation 'software.amazon.awssdk:comprehend' implementation 'software.amazon.awssdk:apigatewaymanagementapi' + implementation 'software.amazon.awssdk:kinesis' implementation 'software.amazon.awssdk:url-connection-client' // JSON Processing From fd6bbabde0c79a2812a501260d692e480e9f1867 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 10:41:13 +0900 Subject: [PATCH 198/528] feat: add UserRanking model for ranking data --- .../domain/ranking/model/UserRanking.java | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/ranking/model/UserRanking.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/ranking/model/UserRanking.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/ranking/model/UserRanking.java new file mode 100644 index 00000000..96ef4279 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/ranking/model/UserRanking.java @@ -0,0 +1,65 @@ +package com.mzc.secondproject.serverless.domain.ranking.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbAttribute; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSortKey; + +/** + * 사용자 랭킹 + * PK: RANKING#{period} (예: RANKING#DAILY#2026-01-16, RANKING#WEEKLY#2026-W03) + * SK: SCORE#{invertedScore}#{userId} (역순 정렬용) + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DynamoDbBean +public class UserRanking { + + private String pk; + private String sk; + private String userId; + private String periodType; // DAILY, WEEKLY, MONTHLY, TOTAL + private String period; // 2026-01-16, 2026-W03, 2026-01, TOTAL + private Integer score; + private String nickname; + private String profileUrl; + private String updatedAt; + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { + return pk; + } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { + return sk; + } + + public static String buildPk(String periodType, String period) { + return "RANKING#" + periodType + "#" + period; + } + + public static String buildSk(int score, String userId) { + int invertedScore = 999999 - score; + return String.format("SCORE#%06d#%s", invertedScore, userId); + } + + public static UserRanking create(String periodType, String period, String userId, int score) { + return UserRanking.builder() + .pk(buildPk(periodType, period)) + .sk(buildSk(score, userId)) + .userId(userId) + .periodType(periodType) + .period(period) + .score(score) + .build(); + } +} From 9388f7259f3df257f9268840fbadda965a6ce5b3 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 10:41:13 +0900 Subject: [PATCH 199/528] feat: add RankingRepository for ranking CRUD operations --- .../ranking/repository/RankingRepository.java | 183 ++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/ranking/repository/RankingRepository.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/ranking/repository/RankingRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/ranking/repository/RankingRepository.java new file mode 100644 index 00000000..72a687a0 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/ranking/repository/RankingRepository.java @@ -0,0 +1,183 @@ +package com.mzc.secondproject.serverless.domain.ranking.repository; + +import com.mzc.secondproject.serverless.domain.ranking.model.UserRanking; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; +import software.amazon.awssdk.enhanced.dynamodb.Key; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; +import software.amazon.awssdk.http.urlconnection.UrlConnectionHttpClient; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.dynamodb.DynamoDbClient; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; +import software.amazon.awssdk.services.dynamodb.model.DeleteItemRequest; +import software.amazon.awssdk.services.dynamodb.model.QueryRequest; +import software.amazon.awssdk.services.dynamodb.model.QueryResponse; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.temporal.WeekFields; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; + +public class RankingRepository { + + private static final Logger logger = LoggerFactory.getLogger(RankingRepository.class); + private static final String TABLE_NAME = System.getenv("VOCAB_TABLE_NAME"); + + private final DynamoDbClient dynamoDbClient; + private final DynamoDbEnhancedClient enhancedClient; + private final DynamoDbTable rankingTable; + + public RankingRepository() { + this.dynamoDbClient = DynamoDbClient.builder() + .region(Region.of(System.getenv("AWS_REGION_NAME"))) + .httpClient(UrlConnectionHttpClient.builder().build()) + .build(); + this.enhancedClient = DynamoDbEnhancedClient.builder() + .dynamoDbClient(dynamoDbClient) + .build(); + this.rankingTable = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(UserRanking.class)); + } + + public void updateScore(String userId, int scoreToAdd) { + String now = Instant.now().toString(); + LocalDate today = LocalDate.now(); + + String dailyPeriod = today.toString(); + String weeklyPeriod = today.getYear() + "-W" + String.format("%02d", today.get(WeekFields.of(Locale.getDefault()).weekOfWeekBasedYear())); + String monthlyPeriod = today.getYear() + "-" + String.format("%02d", today.getMonthValue()); + + updatePeriodScore(userId, "DAILY", dailyPeriod, scoreToAdd, now); + updatePeriodScore(userId, "WEEKLY", weeklyPeriod, scoreToAdd, now); + updatePeriodScore(userId, "MONTHLY", monthlyPeriod, scoreToAdd, now); + updatePeriodScore(userId, "TOTAL", "TOTAL", scoreToAdd, now); + } + + private void updatePeriodScore(String userId, String periodType, String period, int scoreToAdd, String now) { + String pk = UserRanking.buildPk(periodType, period); + + Optional existing = findByPkAndUserId(pk, userId); + + if (existing.isPresent()) { + UserRanking current = existing.get(); + int newScore = current.getScore() + scoreToAdd; + + deleteItem(pk, current.getSk()); + + UserRanking updated = UserRanking.builder() + .pk(pk) + .sk(UserRanking.buildSk(newScore, userId)) + .userId(userId) + .periodType(periodType) + .period(period) + .score(newScore) + .nickname(current.getNickname()) + .profileUrl(current.getProfileUrl()) + .updatedAt(now) + .build(); + rankingTable.putItem(updated); + } else { + UserRanking ranking = UserRanking.builder() + .pk(pk) + .sk(UserRanking.buildSk(scoreToAdd, userId)) + .userId(userId) + .periodType(periodType) + .period(period) + .score(scoreToAdd) + .updatedAt(now) + .build(); + rankingTable.putItem(ranking); + } + } + + private Optional findByPkAndUserId(String pk, String userId) { + QueryRequest queryRequest = QueryRequest.builder() + .tableName(TABLE_NAME) + .keyConditionExpression("PK = :pk") + .filterExpression("userId = :userId") + .expressionAttributeValues(Map.of( + ":pk", AttributeValue.builder().s(pk).build(), + ":userId", AttributeValue.builder().s(userId).build() + )) + .build(); + + QueryResponse response = dynamoDbClient.query(queryRequest); + + if (response.items().isEmpty()) { + return Optional.empty(); + } + + Map item = response.items().get(0); + return Optional.of(UserRanking.builder() + .pk(item.get("PK").s()) + .sk(item.get("SK").s()) + .userId(item.get("userId").s()) + .periodType(item.containsKey("periodType") ? item.get("periodType").s() : null) + .period(item.containsKey("period") ? item.get("period").s() : null) + .score(item.containsKey("score") ? Integer.parseInt(item.get("score").n()) : 0) + .nickname(item.containsKey("nickname") ? item.get("nickname").s() : null) + .profileUrl(item.containsKey("profileUrl") ? item.get("profileUrl").s() : null) + .updatedAt(item.containsKey("updatedAt") ? item.get("updatedAt").s() : null) + .build()); + } + + private void deleteItem(String pk, String sk) { + DeleteItemRequest deleteRequest = DeleteItemRequest.builder() + .tableName(TABLE_NAME) + .key(Map.of( + "PK", AttributeValue.builder().s(pk).build(), + "SK", AttributeValue.builder().s(sk).build() + )) + .build(); + dynamoDbClient.deleteItem(deleteRequest); + } + + public List getTopRankings(String periodType, String period, int limit) { + String pk = UserRanking.buildPk(periodType, period); + + QueryEnhancedRequest request = QueryEnhancedRequest.builder() + .queryConditional(QueryConditional.keyEqualTo(Key.builder().partitionValue(pk).build())) + .limit(limit) + .build(); + + List rankings = new ArrayList<>(); + rankingTable.query(request).items().forEach(rankings::add); + return rankings; + } + + public Optional getUserRanking(String periodType, String period, String userId) { + String pk = UserRanking.buildPk(periodType, period); + return findByPkAndUserId(pk, userId); + } + + public int getUserRank(String periodType, String period, String userId) { + String pk = UserRanking.buildPk(periodType, period); + Optional userRanking = findByPkAndUserId(pk, userId); + + if (userRanking.isEmpty()) { + return -1; + } + + String userSk = userRanking.get().getSk(); + + QueryRequest countRequest = QueryRequest.builder() + .tableName(TABLE_NAME) + .keyConditionExpression("PK = :pk AND SK < :sk") + .expressionAttributeValues(Map.of( + ":pk", AttributeValue.builder().s(pk).build(), + ":sk", AttributeValue.builder().s(userSk).build() + )) + .select(software.amazon.awssdk.services.dynamodb.model.Select.COUNT) + .build(); + + QueryResponse response = dynamoDbClient.query(countRequest); + return response.count() + 1; + } +} From 282f7cc16997a0b571766265b0a6b5c5be259de6 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 10:41:14 +0900 Subject: [PATCH 200/528] feat: add KinesisRankingConsumer Lambda handler --- .../handler/KinesisRankingConsumer.java | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/ranking/handler/KinesisRankingConsumer.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/ranking/handler/KinesisRankingConsumer.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/ranking/handler/KinesisRankingConsumer.java new file mode 100644 index 00000000..2629270f --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/ranking/handler/KinesisRankingConsumer.java @@ -0,0 +1,75 @@ +package com.mzc.secondproject.serverless.domain.ranking.handler; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.events.KinesisEvent; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.mzc.secondproject.serverless.domain.ranking.model.RankingEvent; +import com.mzc.secondproject.serverless.domain.ranking.repository.RankingRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; + +/** + * Kinesis Stream에서 랭킹 이벤트를 소비하여 점수 업데이트 + */ +public class KinesisRankingConsumer implements RequestHandler { + + private static final Logger logger = LoggerFactory.getLogger(KinesisRankingConsumer.class); + private static final Gson gson = new GsonBuilder().create(); + + private final RankingRepository rankingRepository; + + public KinesisRankingConsumer() { + this.rankingRepository = new RankingRepository(); + } + + @Override + public Void handleRequest(KinesisEvent event, Context context) { + logger.info("Received {} Kinesis records", event.getRecords().size()); + + Map userScores = new HashMap<>(); + + for (KinesisEvent.KinesisEventRecord record : event.getRecords()) { + try { + processRecord(record, userScores); + } catch (Exception e) { + logger.error("Failed to process record: {}", record.getEventID(), e); + } + } + + for (Map.Entry entry : userScores.entrySet()) { + try { + rankingRepository.updateScore(entry.getKey(), entry.getValue()); + logger.info("Updated ranking: userId={}, totalScore={}", entry.getKey(), entry.getValue()); + } catch (Exception e) { + logger.error("Failed to update ranking for userId={}: {}", entry.getKey(), e.getMessage(), e); + } + } + + logger.info("Processed {} users' rankings", userScores.size()); + return null; + } + + private void processRecord(KinesisEvent.KinesisEventRecord record, Map userScores) { + String data = new String(record.getKinesis().getData().array(), StandardCharsets.UTF_8); + RankingEvent event = gson.fromJson(data, RankingEvent.class); + + if (event == null || event.getUserId() == null) { + logger.warn("Invalid event data: {}", data); + return; + } + + int score = event.getScore(); + String userId = event.getUserId(); + + userScores.merge(userId, score, Integer::sum); + + logger.debug("Processed event: type={}, userId={}, score={}", + event.getEventType(), userId, score); + } +} From 4eee907c519299ca201c78e32b5310f1ce35c3a0 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 10:29:07 +0900 Subject: [PATCH 201/528] feat: add Kinesis Stream resource for ranking events --- ServerlessFunction/template.yaml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 4fd81a9e..61bf9856 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -20,6 +20,7 @@ Globals: PROFILE_BUCKET_NAME: group2-englishstudy AWS_REGION_NAME: !Ref AWS::Region ROOM_TOKEN_TTL_SECONDS: "300" + RANKING_STREAM_NAME: !Ref RankingEventStream Api: TracingEnabled: true @@ -1465,6 +1466,19 @@ Resources: Endpoint: !GetAtt StatisticsQueue.Arn RawMessageDelivery: true + ############################################# + # Kinesis Data Streams (Ranking Events) + ############################################# + + RankingEventStream: + Type: AWS::Kinesis::Stream + Properties: + Name: !Sub "${AWS::StackName}-ranking-events" + ShardCount: 2 + RetentionPeriodHours: 24 + StreamModeDetails: + StreamMode: PROVISIONED + # Statistics Processor Lambda - SQS에서 메시지 소비하여 통계 업데이트 StatisticsProcessorFunction: Type: AWS::Serverless::Function @@ -1524,3 +1538,11 @@ Outputs: CognitoUserPoolClientId: Description: Cognito User Pool Client ID Value: !Ref CognitoUserPoolClient + + RankingStreamName: + Description: Kinesis Data Stream for ranking events + Value: !Ref RankingEventStream + + RankingStreamArn: + Description: Kinesis Data Stream ARN + Value: !GetAtt RankingEventStream.Arn From d38d2eed4465329174c6ca8dbc202c819f208f19 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 10:30:34 +0900 Subject: [PATCH 202/528] feat: add Kinesis producer IAM policy to Lambda functions --- ServerlessFunction/template.yaml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 61bf9856..de64b1c9 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -415,6 +415,7 @@ Resources: Action: - execute-api:ManageConnections Resource: !Sub "arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${WebSocketApi}/*" + - !Ref KinesisProducerPolicy Events: StartGame: Type: Api @@ -625,6 +626,7 @@ Resources: Policies: - DynamoDBCrudPolicy: TableName: !Ref VocabTable + - !Ref KinesisProducerPolicy Events: GetWrongAnswers: Type: Api @@ -757,6 +759,7 @@ Resources: Policies: - DynamoDBCrudPolicy: TableName: !Ref VocabTable + - !Ref KinesisProducerPolicy Events: GetDailyWords: Type: Api @@ -792,6 +795,7 @@ Resources: TableName: !Ref VocabTable - SNSPublishMessagePolicy: TopicName: !GetAtt TestResultTopic.TopicName + - !Ref KinesisProducerPolicy Events: StartTest: Type: Api @@ -1054,6 +1058,7 @@ Resources: - comprehend:DetectKeyPhrases - comprehend:DetectDominantLanguage Resource: "*" + - !Ref KinesisProducerPolicy Events: GrammarCheck: Type: Api @@ -1479,6 +1484,21 @@ Resources: StreamModeDetails: StreamMode: PROVISIONED + # Kinesis Producer Policy - 랭킹 이벤트 발행용 + KinesisProducerPolicy: + Type: AWS::IAM::ManagedPolicy + Properties: + ManagedPolicyName: !Sub "${AWS::StackName}-kinesis-producer-policy" + PolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Action: + - kinesis:PutRecord + - kinesis:PutRecords + - kinesis:DescribeStream + Resource: !GetAtt RankingEventStream.Arn + # Statistics Processor Lambda - SQS에서 메시지 소비하여 통계 업데이트 StatisticsProcessorFunction: Type: AWS::Serverless::Function From 5510161c10a8c4eef3323877b7f5b0a62791a06b Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 10:44:17 +0900 Subject: [PATCH 203/528] feat: add KinesisRankingConsumer Lambda to template.yaml --- ServerlessFunction/template.yaml | 33 ++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index de64b1c9..749e3fdf 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -1499,6 +1499,39 @@ Resources: - kinesis:DescribeStream Resource: !GetAtt RankingEventStream.Arn + # Kinesis Ranking Consumer - 랭킹 이벤트 소비 및 점수 업데이트 + KinesisRankingConsumerFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: !Sub "${AWS::StackName}-ranking-consumer" + CodeUri: . + Handler: com.mzc.secondproject.serverless.domain.ranking.handler.KinesisRankingConsumer::handleRequest + Description: Consume ranking events from Kinesis and update scores + Timeout: 60 + MemorySize: 512 + SnapStart: + ApplyOn: PublishedVersions + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref VocabTable + - Statement: + - Effect: Allow + Action: + - kinesis:GetRecords + - kinesis:GetShardIterator + - kinesis:DescribeStream + - kinesis:DescribeStreamSummary + - kinesis:ListShards + Resource: !GetAtt RankingEventStream.Arn + Events: + KinesisEvent: + Type: Kinesis + Properties: + Stream: !GetAtt RankingEventStream.Arn + StartingPosition: LATEST + BatchSize: 100 + MaximumBatchingWindowInSeconds: 5 + # Statistics Processor Lambda - SQS에서 메시지 소비하여 통계 업데이트 StatisticsProcessorFunction: Type: AWS::Serverless::Function From 6179d9d3b62e4e5df22a9093a69a616874dfb7b1 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 10:46:14 +0900 Subject: [PATCH 204/528] feat: add RankingService for ranking business logic --- .../ranking/service/RankingService.java | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/ranking/service/RankingService.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/ranking/service/RankingService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/ranking/service/RankingService.java new file mode 100644 index 00000000..42fab5e8 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/ranking/service/RankingService.java @@ -0,0 +1,92 @@ +package com.mzc.secondproject.serverless.domain.ranking.service; + +import com.mzc.secondproject.serverless.domain.ranking.model.UserRanking; +import com.mzc.secondproject.serverless.domain.ranking.repository.RankingRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.LocalDate; +import java.time.temporal.WeekFields; +import java.util.List; +import java.util.Locale; +import java.util.Optional; + +public class RankingService { + + private static final Logger logger = LoggerFactory.getLogger(RankingService.class); + + private final RankingRepository rankingRepository; + + public RankingService() { + this.rankingRepository = new RankingRepository(); + } + + public List getDailyRanking(int limit) { + String period = LocalDate.now().toString(); + return rankingRepository.getTopRankings("DAILY", period, limit); + } + + public List getWeeklyRanking(int limit) { + LocalDate today = LocalDate.now(); + String period = today.getYear() + "-W" + String.format("%02d", today.get(WeekFields.of(Locale.getDefault()).weekOfWeekBasedYear())); + return rankingRepository.getTopRankings("WEEKLY", period, limit); + } + + public List getMonthlyRanking(int limit) { + LocalDate today = LocalDate.now(); + String period = today.getYear() + "-" + String.format("%02d", today.getMonthValue()); + return rankingRepository.getTopRankings("MONTHLY", period, limit); + } + + public List getTotalRanking(int limit) { + return rankingRepository.getTopRankings("TOTAL", "TOTAL", limit); + } + + public List getRanking(String periodType, int limit) { + return switch (periodType.toUpperCase()) { + case "DAILY" -> getDailyRanking(limit); + case "WEEKLY" -> getWeeklyRanking(limit); + case "MONTHLY" -> getMonthlyRanking(limit); + case "TOTAL" -> getTotalRanking(limit); + default -> getDailyRanking(limit); + }; + } + + public MyRankingResult getMyRanking(String userId) { + LocalDate today = LocalDate.now(); + + String dailyPeriod = today.toString(); + String weeklyPeriod = today.getYear() + "-W" + String.format("%02d", today.get(WeekFields.of(Locale.getDefault()).weekOfWeekBasedYear())); + String monthlyPeriod = today.getYear() + "-" + String.format("%02d", today.getMonthValue()); + + Optional dailyRanking = rankingRepository.getUserRanking("DAILY", dailyPeriod, userId); + Optional weeklyRanking = rankingRepository.getUserRanking("WEEKLY", weeklyPeriod, userId); + Optional monthlyRanking = rankingRepository.getUserRanking("MONTHLY", monthlyPeriod, userId); + Optional totalRanking = rankingRepository.getUserRanking("TOTAL", "TOTAL", userId); + + int dailyRank = dailyRanking.isPresent() ? rankingRepository.getUserRank("DAILY", dailyPeriod, userId) : -1; + int weeklyRank = weeklyRanking.isPresent() ? rankingRepository.getUserRank("WEEKLY", weeklyPeriod, userId) : -1; + int monthlyRank = monthlyRanking.isPresent() ? rankingRepository.getUserRank("MONTHLY", monthlyPeriod, userId) : -1; + int totalRank = totalRanking.isPresent() ? rankingRepository.getUserRank("TOTAL", "TOTAL", userId) : -1; + + return new MyRankingResult( + new PeriodRanking("DAILY", dailyRanking.map(UserRanking::getScore).orElse(0), dailyRank), + new PeriodRanking("WEEKLY", weeklyRanking.map(UserRanking::getScore).orElse(0), weeklyRank), + new PeriodRanking("MONTHLY", monthlyRanking.map(UserRanking::getScore).orElse(0), monthlyRank), + new PeriodRanking("TOTAL", totalRanking.map(UserRanking::getScore).orElse(0), totalRank) + ); + } + + public record MyRankingResult( + PeriodRanking daily, + PeriodRanking weekly, + PeriodRanking monthly, + PeriodRanking total + ) {} + + public record PeriodRanking( + String periodType, + int score, + int rank + ) {} +} From fbedb28b2b26c987dd3635680b73e3110f2b333e Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 10:46:15 +0900 Subject: [PATCH 205/528] feat: add RankingHandler for ranking API endpoints --- .../ranking/handler/RankingHandler.java | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/ranking/handler/RankingHandler.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/ranking/handler/RankingHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/ranking/handler/RankingHandler.java new file mode 100644 index 00000000..10b2cbb6 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/ranking/handler/RankingHandler.java @@ -0,0 +1,126 @@ +package com.mzc.secondproject.serverless.domain.ranking.handler; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import com.mzc.secondproject.serverless.common.router.HandlerRouter; +import com.mzc.secondproject.serverless.common.router.Route; +import com.mzc.secondproject.serverless.common.util.ResponseGenerator; +import com.mzc.secondproject.serverless.domain.ranking.model.UserRanking; +import com.mzc.secondproject.serverless.domain.ranking.service.RankingService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.IntStream; + +public class RankingHandler implements RequestHandler { + + private static final Logger logger = LoggerFactory.getLogger(RankingHandler.class); + + private final RankingService rankingService; + private final HandlerRouter router; + + public RankingHandler() { + this.rankingService = new RankingService(); + this.router = initRouter(); + } + + private HandlerRouter initRouter() { + return new HandlerRouter().addRoutes( + Route.getAuth("/ranking/daily", this::getDailyRanking), + Route.getAuth("/ranking/weekly", this::getWeeklyRanking), + Route.getAuth("/ranking/monthly", this::getMonthlyRanking), + Route.getAuth("/ranking/total", this::getTotalRanking), + Route.getAuth("/ranking/me", this::getMyRanking), + Route.getAuth("/ranking/{period}", this::getRankingByPeriod) + ); + } + + @Override + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { + logger.info("Received request: {} {}", request.getHttpMethod(), request.getPath()); + return router.route(request); + } + + private APIGatewayProxyResponseEvent getDailyRanking(APIGatewayProxyRequestEvent request, String userId) { + int limit = parseLimit(request); + List rankings = rankingService.getDailyRanking(limit); + return buildRankingResponse("DAILY", rankings); + } + + private APIGatewayProxyResponseEvent getWeeklyRanking(APIGatewayProxyRequestEvent request, String userId) { + int limit = parseLimit(request); + List rankings = rankingService.getWeeklyRanking(limit); + return buildRankingResponse("WEEKLY", rankings); + } + + private APIGatewayProxyResponseEvent getMonthlyRanking(APIGatewayProxyRequestEvent request, String userId) { + int limit = parseLimit(request); + List rankings = rankingService.getMonthlyRanking(limit); + return buildRankingResponse("MONTHLY", rankings); + } + + private APIGatewayProxyResponseEvent getTotalRanking(APIGatewayProxyRequestEvent request, String userId) { + int limit = parseLimit(request); + List rankings = rankingService.getTotalRanking(limit); + return buildRankingResponse("TOTAL", rankings); + } + + private APIGatewayProxyResponseEvent getRankingByPeriod(APIGatewayProxyRequestEvent request, String userId) { + String period = request.getPathParameters().get("period"); + int limit = parseLimit(request); + List rankings = rankingService.getRanking(period, limit); + return buildRankingResponse(period.toUpperCase(), rankings); + } + + private APIGatewayProxyResponseEvent getMyRanking(APIGatewayProxyRequestEvent request, String userId) { + RankingService.MyRankingResult result = rankingService.getMyRanking(userId); + + Map response = new HashMap<>(); + response.put("userId", userId); + response.put("daily", Map.of("score", result.daily().score(), "rank", result.daily().rank())); + response.put("weekly", Map.of("score", result.weekly().score(), "rank", result.weekly().rank())); + response.put("monthly", Map.of("score", result.monthly().score(), "rank", result.monthly().rank())); + response.put("total", Map.of("score", result.total().score(), "rank", result.total().rank())); + + return ResponseGenerator.ok("My ranking retrieved", response); + } + + private APIGatewayProxyResponseEvent buildRankingResponse(String periodType, List rankings) { + List> rankingList = IntStream.range(0, rankings.size()) + .mapToObj(i -> { + UserRanking r = rankings.get(i); + Map entry = new HashMap<>(); + entry.put("rank", i + 1); + entry.put("userId", r.getUserId()); + entry.put("score", r.getScore()); + entry.put("nickname", r.getNickname()); + entry.put("profileUrl", r.getProfileUrl()); + return entry; + }) + .toList(); + + Map response = new HashMap<>(); + response.put("periodType", periodType); + response.put("rankings", rankingList); + response.put("totalCount", rankingList.size()); + + return ResponseGenerator.ok("Ranking retrieved", response); + } + + private int parseLimit(APIGatewayProxyRequestEvent request) { + Map queryParams = request.getQueryStringParameters(); + if (queryParams != null && queryParams.get("limit") != null) { + try { + return Math.min(Integer.parseInt(queryParams.get("limit")), 100); + } catch (NumberFormatException e) { + return 50; + } + } + return 50; + } +} From 89c8dc3f474ba8736931b392a28927d3949663ce Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 10:47:55 +0900 Subject: [PATCH 206/528] feat: add RankingFunction Lambda to template.yaml - Add RankingFunction Lambda with DynamoDB and Kinesis access - Configure API Gateway endpoints for ranking queries - GET /ranking/daily - Daily ranking - GET /ranking/weekly - Weekly ranking - GET /ranking/monthly - Monthly ranking - GET /ranking/total - Total ranking - GET /ranking/me - My ranking - GET /ranking/{period} - Dynamic period ranking --- ServerlessFunction/template.yaml | 55 ++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 749e3fdf..0355bf6e 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -1499,6 +1499,61 @@ Resources: - kinesis:DescribeStream Resource: !GetAtt RankingEventStream.Arn + # Ranking API Handler - 랭킹 조회 API + RankingFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: !Sub "${AWS::StackName}-ranking-handler" + CodeUri: . + Handler: com.mzc.secondproject.serverless.domain.ranking.handler.RankingHandler::handleRequest + Description: Handle ranking API endpoints + SnapStart: + ApplyOn: PublishedVersions + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref VocabTable + Events: + GetDailyRanking: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /ranking/daily + Method: GET + Auth: + Authorizer: CognitoAuthorizer + GetWeeklyRanking: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /ranking/weekly + Method: GET + Auth: + Authorizer: CognitoAuthorizer + GetMonthlyRanking: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /ranking/monthly + Method: GET + Auth: + Authorizer: CognitoAuthorizer + GetTotalRanking: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /ranking/total + Method: GET + Auth: + Authorizer: CognitoAuthorizer + GetMyRanking: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /ranking/me + Method: GET + Auth: + Authorizer: CognitoAuthorizer + # Kinesis Ranking Consumer - 랭킹 이벤트 소비 및 점수 업데이트 KinesisRankingConsumerFunction: Type: AWS::Serverless::Function From 7369e8c5b830585306930b2f312fe244ca51dc90 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 10:49:41 +0900 Subject: [PATCH 207/528] feat: add Kinesis Firehose S3 archiving resources - Add RankingLogBucket S3 bucket with lifecycle policy - 90 days transition to Glacier, 365 days expiration - Add FirehoseDeliveryRole IAM role - Add RankingEventFirehose delivery stream - Partition by year/month/day for efficient querying - Buffer: 5 minutes or 5MB --- ServerlessFunction/template.yaml | 85 ++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 0355bf6e..6e4dc48b 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -1587,6 +1587,83 @@ Resources: BatchSize: 100 MaximumBatchingWindowInSeconds: 5 + ############################################# + # Kinesis Firehose (S3 Archiving) + ############################################# + + # S3 Bucket for ranking event logs + RankingLogBucket: + Type: AWS::S3::Bucket + Properties: + BucketName: !Sub "${AWS::StackName}-ranking-logs" + LifecycleConfiguration: + Rules: + - Id: MoveToGlacierAfter90Days + Status: Enabled + Transitions: + - StorageClass: GLACIER + TransitionInDays: 90 + ExpirationInDays: 365 + + # IAM Role for Firehose to access Kinesis and S3 + FirehoseDeliveryRole: + Type: AWS::IAM::Role + Properties: + RoleName: !Sub "${AWS::StackName}-firehose-delivery-role" + AssumeRolePolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Principal: + Service: firehose.amazonaws.com + Action: sts:AssumeRole + Policies: + - PolicyName: FirehoseS3Access + PolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Action: + - s3:AbortMultipartUpload + - s3:GetBucketLocation + - s3:GetObject + - s3:ListBucket + - s3:ListBucketMultipartUploads + - s3:PutObject + Resource: + - !GetAtt RankingLogBucket.Arn + - !Sub "${RankingLogBucket.Arn}/*" + - PolicyName: FirehoseKinesisAccess + PolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Action: + - kinesis:DescribeStream + - kinesis:GetShardIterator + - kinesis:GetRecords + - kinesis:ListShards + Resource: !GetAtt RankingEventStream.Arn + + # Kinesis Firehose Delivery Stream + RankingEventFirehose: + Type: AWS::KinesisFirehose::DeliveryStream + Properties: + DeliveryStreamName: !Sub "${AWS::StackName}-ranking-firehose" + DeliveryStreamType: KinesisStreamAsSource + KinesisStreamSourceConfiguration: + KinesisStreamARN: !GetAtt RankingEventStream.Arn + RoleARN: !GetAtt FirehoseDeliveryRole.Arn + S3DestinationConfiguration: + BucketARN: !GetAtt RankingLogBucket.Arn + Prefix: "ranking-events/year=!{timestamp:yyyy}/month=!{timestamp:MM}/day=!{timestamp:dd}/" + ErrorOutputPrefix: "errors/!{firehose:error-output-type}/year=!{timestamp:yyyy}/month=!{timestamp:MM}/day=!{timestamp:dd}/" + BufferingHints: + IntervalInSeconds: 300 + SizeInMBs: 5 + CompressionFormat: UNCOMPRESSED + RoleARN: !GetAtt FirehoseDeliveryRole.Arn + # Statistics Processor Lambda - SQS에서 메시지 소비하여 통계 업데이트 StatisticsProcessorFunction: Type: AWS::Serverless::Function @@ -1654,3 +1731,11 @@ Outputs: RankingStreamArn: Description: Kinesis Data Stream ARN Value: !GetAtt RankingEventStream.Arn + + RankingFirehoseName: + Description: Kinesis Firehose Delivery Stream Name + Value: !Ref RankingEventFirehose + + RankingLogBucketName: + Description: S3 Bucket for ranking event logs + Value: !Ref RankingLogBucket From f4c43e888dc260bcd8dae54645ca1f7750b0d512 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 11:12:23 +0900 Subject: [PATCH 208/528] revert: remove Kinesis ranking system (not available in Free Tier) Reverts PRs #352, #354, #355 - Remove Kinesis Data Stream and Firehose resources - Remove ranking event publishing from handlers - Remove ranking domain (models, services, handlers, repository) - Remove Kinesis SDK dependency Reason: AWS Kinesis is not available in AWS Free Tier account --- ServerlessFunction/build.gradle | 1 - .../domain/chatting/handler/GameHandler.java | 44 ---- .../grammar/handler/GrammarHandler.java | 5 - .../handler/KinesisRankingConsumer.java | 75 ------ .../ranking/handler/RankingHandler.java | 126 ---------- .../domain/ranking/model/RankingEvent.java | 53 ----- .../ranking/model/RankingEventType.java | 28 --- .../domain/ranking/model/UserRanking.java | 65 ------ .../ranking/repository/RankingRepository.java | 183 --------------- .../service/KinesisEventPublisher.java | 152 ------------- .../ranking/service/RankingService.java | 92 -------- .../vocabulary/handler/DailyStudyHandler.java | 10 +- .../vocabulary/handler/TestHandler.java | 19 +- .../vocabulary/handler/UserWordHandler.java | 16 +- ServerlessFunction/template.yaml | 215 ------------------ 15 files changed, 10 insertions(+), 1074 deletions(-) delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/ranking/handler/KinesisRankingConsumer.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/ranking/handler/RankingHandler.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/ranking/model/RankingEvent.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/ranking/model/RankingEventType.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/ranking/model/UserRanking.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/ranking/repository/RankingRepository.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/ranking/service/KinesisEventPublisher.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/ranking/service/RankingService.java diff --git a/ServerlessFunction/build.gradle b/ServerlessFunction/build.gradle index 55a906cc..9ea658f5 100644 --- a/ServerlessFunction/build.gradle +++ b/ServerlessFunction/build.gradle @@ -32,7 +32,6 @@ dependencies { implementation 'software.amazon.awssdk:bedrockruntime' implementation 'software.amazon.awssdk:comprehend' implementation 'software.amazon.awssdk:apigatewaymanagementapi' - implementation 'software.amazon.awssdk:kinesis' implementation 'software.amazon.awssdk:url-connection-client' // JSON Processing diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameHandler.java index 25e839b1..5e506327 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameHandler.java @@ -20,7 +20,6 @@ import com.mzc.secondproject.serverless.domain.chatting.repository.ChatRoomRepository; import com.mzc.secondproject.serverless.domain.chatting.repository.ConnectionRepository; import com.mzc.secondproject.serverless.domain.chatting.service.GameService; -import com.mzc.secondproject.serverless.domain.ranking.service.KinesisEventPublisher; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -38,7 +37,6 @@ public class GameHandler implements RequestHandler optRoom = chatRoomRepository.findById(roomId); - Map scoresBeforeStop = optRoom.map(ChatRoom::getScores).orElse(Map.of()); - CommandResult result = gameService.stopGame(roomId, userId); if (!result.success()) { return ResponseGenerator.fail(ChattingErrorCode.GAME_STOP_FAILED, result.message()); } - // 랭킹 이벤트 발행 - publishGameEndEvents(roomId, scoresBeforeStop); - // WebSocket으로 게임 종료 알림 브로드캐스트 broadcastSystemMessage(roomId, result.message(), MessageType.GAME_END); return ResponseGenerator.ok("Game stopped", Map.of("message", result.message())); } - /** - * 게임 종료 시 랭킹 이벤트 발행 - */ - private void publishGameEndEvents(String roomId, Map scores) { - if (scores == null || scores.isEmpty()) { - return; - } - - // 1등 찾기 - String winnerId = null; - int maxScore = 0; - for (Map.Entry entry : scores.entrySet()) { - if (entry.getValue() > maxScore) { - maxScore = entry.getValue(); - winnerId = entry.getKey(); - } - } - - // 각 참가자에게 GAME_PLAYED 이벤트 발행 - for (Map.Entry entry : scores.entrySet()) { - String participantId = entry.getKey(); - int score = entry.getValue(); - - eventPublisher.publishGamePlayed(participantId, roomId, score); - - // 1등에게 GAME_WON 이벤트 추가 발행 - if (participantId.equals(winnerId) && score > 0) { - eventPublisher.publishGameWon(participantId, roomId, score); - } - } - - logger.info("Game end events published: roomId={}, participants={}", roomId, scores.size()); - } - /** * GET /rooms/{roomId}/game/status - 게임 상태 조회 */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/GrammarHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/GrammarHandler.java index b514a62b..e6467272 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/GrammarHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/GrammarHandler.java @@ -16,7 +16,6 @@ import com.mzc.secondproject.serverless.domain.grammar.service.GrammarCheckService; import com.mzc.secondproject.serverless.domain.grammar.service.GrammarConversationService; import com.mzc.secondproject.serverless.domain.grammar.service.GrammarSessionQueryService; -import com.mzc.secondproject.serverless.domain.ranking.service.KinesisEventPublisher; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -30,14 +29,12 @@ public class GrammarHandler implements RequestHandler { GrammarCheckResponse result = grammarCheckService.checkGrammar(dto); - eventPublisher.publishGrammarCheck(userId, null); - Map response = new HashMap<>(); response.put("originalSentence", result.getOriginalSentence()); response.put("correctedSentence", result.getCorrectedSentence()); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/ranking/handler/KinesisRankingConsumer.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/ranking/handler/KinesisRankingConsumer.java deleted file mode 100644 index 2629270f..00000000 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/ranking/handler/KinesisRankingConsumer.java +++ /dev/null @@ -1,75 +0,0 @@ -package com.mzc.secondproject.serverless.domain.ranking.handler; - -import com.amazonaws.services.lambda.runtime.Context; -import com.amazonaws.services.lambda.runtime.RequestHandler; -import com.amazonaws.services.lambda.runtime.events.KinesisEvent; -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; -import com.mzc.secondproject.serverless.domain.ranking.model.RankingEvent; -import com.mzc.secondproject.serverless.domain.ranking.repository.RankingRepository; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.nio.charset.StandardCharsets; -import java.util.HashMap; -import java.util.Map; - -/** - * Kinesis Stream에서 랭킹 이벤트를 소비하여 점수 업데이트 - */ -public class KinesisRankingConsumer implements RequestHandler { - - private static final Logger logger = LoggerFactory.getLogger(KinesisRankingConsumer.class); - private static final Gson gson = new GsonBuilder().create(); - - private final RankingRepository rankingRepository; - - public KinesisRankingConsumer() { - this.rankingRepository = new RankingRepository(); - } - - @Override - public Void handleRequest(KinesisEvent event, Context context) { - logger.info("Received {} Kinesis records", event.getRecords().size()); - - Map userScores = new HashMap<>(); - - for (KinesisEvent.KinesisEventRecord record : event.getRecords()) { - try { - processRecord(record, userScores); - } catch (Exception e) { - logger.error("Failed to process record: {}", record.getEventID(), e); - } - } - - for (Map.Entry entry : userScores.entrySet()) { - try { - rankingRepository.updateScore(entry.getKey(), entry.getValue()); - logger.info("Updated ranking: userId={}, totalScore={}", entry.getKey(), entry.getValue()); - } catch (Exception e) { - logger.error("Failed to update ranking for userId={}: {}", entry.getKey(), e.getMessage(), e); - } - } - - logger.info("Processed {} users' rankings", userScores.size()); - return null; - } - - private void processRecord(KinesisEvent.KinesisEventRecord record, Map userScores) { - String data = new String(record.getKinesis().getData().array(), StandardCharsets.UTF_8); - RankingEvent event = gson.fromJson(data, RankingEvent.class); - - if (event == null || event.getUserId() == null) { - logger.warn("Invalid event data: {}", data); - return; - } - - int score = event.getScore(); - String userId = event.getUserId(); - - userScores.merge(userId, score, Integer::sum); - - logger.debug("Processed event: type={}, userId={}, score={}", - event.getEventType(), userId, score); - } -} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/ranking/handler/RankingHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/ranking/handler/RankingHandler.java deleted file mode 100644 index 10b2cbb6..00000000 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/ranking/handler/RankingHandler.java +++ /dev/null @@ -1,126 +0,0 @@ -package com.mzc.secondproject.serverless.domain.ranking.handler; - -import com.amazonaws.services.lambda.runtime.Context; -import com.amazonaws.services.lambda.runtime.RequestHandler; -import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; -import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; -import com.mzc.secondproject.serverless.common.router.HandlerRouter; -import com.mzc.secondproject.serverless.common.router.Route; -import com.mzc.secondproject.serverless.common.util.ResponseGenerator; -import com.mzc.secondproject.serverless.domain.ranking.model.UserRanking; -import com.mzc.secondproject.serverless.domain.ranking.service.RankingService; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.stream.IntStream; - -public class RankingHandler implements RequestHandler { - - private static final Logger logger = LoggerFactory.getLogger(RankingHandler.class); - - private final RankingService rankingService; - private final HandlerRouter router; - - public RankingHandler() { - this.rankingService = new RankingService(); - this.router = initRouter(); - } - - private HandlerRouter initRouter() { - return new HandlerRouter().addRoutes( - Route.getAuth("/ranking/daily", this::getDailyRanking), - Route.getAuth("/ranking/weekly", this::getWeeklyRanking), - Route.getAuth("/ranking/monthly", this::getMonthlyRanking), - Route.getAuth("/ranking/total", this::getTotalRanking), - Route.getAuth("/ranking/me", this::getMyRanking), - Route.getAuth("/ranking/{period}", this::getRankingByPeriod) - ); - } - - @Override - public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { - logger.info("Received request: {} {}", request.getHttpMethod(), request.getPath()); - return router.route(request); - } - - private APIGatewayProxyResponseEvent getDailyRanking(APIGatewayProxyRequestEvent request, String userId) { - int limit = parseLimit(request); - List rankings = rankingService.getDailyRanking(limit); - return buildRankingResponse("DAILY", rankings); - } - - private APIGatewayProxyResponseEvent getWeeklyRanking(APIGatewayProxyRequestEvent request, String userId) { - int limit = parseLimit(request); - List rankings = rankingService.getWeeklyRanking(limit); - return buildRankingResponse("WEEKLY", rankings); - } - - private APIGatewayProxyResponseEvent getMonthlyRanking(APIGatewayProxyRequestEvent request, String userId) { - int limit = parseLimit(request); - List rankings = rankingService.getMonthlyRanking(limit); - return buildRankingResponse("MONTHLY", rankings); - } - - private APIGatewayProxyResponseEvent getTotalRanking(APIGatewayProxyRequestEvent request, String userId) { - int limit = parseLimit(request); - List rankings = rankingService.getTotalRanking(limit); - return buildRankingResponse("TOTAL", rankings); - } - - private APIGatewayProxyResponseEvent getRankingByPeriod(APIGatewayProxyRequestEvent request, String userId) { - String period = request.getPathParameters().get("period"); - int limit = parseLimit(request); - List rankings = rankingService.getRanking(period, limit); - return buildRankingResponse(period.toUpperCase(), rankings); - } - - private APIGatewayProxyResponseEvent getMyRanking(APIGatewayProxyRequestEvent request, String userId) { - RankingService.MyRankingResult result = rankingService.getMyRanking(userId); - - Map response = new HashMap<>(); - response.put("userId", userId); - response.put("daily", Map.of("score", result.daily().score(), "rank", result.daily().rank())); - response.put("weekly", Map.of("score", result.weekly().score(), "rank", result.weekly().rank())); - response.put("monthly", Map.of("score", result.monthly().score(), "rank", result.monthly().rank())); - response.put("total", Map.of("score", result.total().score(), "rank", result.total().rank())); - - return ResponseGenerator.ok("My ranking retrieved", response); - } - - private APIGatewayProxyResponseEvent buildRankingResponse(String periodType, List rankings) { - List> rankingList = IntStream.range(0, rankings.size()) - .mapToObj(i -> { - UserRanking r = rankings.get(i); - Map entry = new HashMap<>(); - entry.put("rank", i + 1); - entry.put("userId", r.getUserId()); - entry.put("score", r.getScore()); - entry.put("nickname", r.getNickname()); - entry.put("profileUrl", r.getProfileUrl()); - return entry; - }) - .toList(); - - Map response = new HashMap<>(); - response.put("periodType", periodType); - response.put("rankings", rankingList); - response.put("totalCount", rankingList.size()); - - return ResponseGenerator.ok("Ranking retrieved", response); - } - - private int parseLimit(APIGatewayProxyRequestEvent request) { - Map queryParams = request.getQueryStringParameters(); - if (queryParams != null && queryParams.get("limit") != null) { - try { - return Math.min(Integer.parseInt(queryParams.get("limit")), 100); - } catch (NumberFormatException e) { - return 50; - } - } - return 50; - } -} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/ranking/model/RankingEvent.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/ranking/model/RankingEvent.java deleted file mode 100644 index 6efd0a55..00000000 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/ranking/model/RankingEvent.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.mzc.secondproject.serverless.domain.ranking.model; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.time.Instant; -import java.util.Map; -import java.util.UUID; - -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class RankingEvent { - - private String eventId; - private RankingEventType eventType; - private String userId; - private String timestamp; - private int score; - private Map payload; - private EventMetadata metadata; - - @Data - @Builder - @NoArgsConstructor - @AllArgsConstructor - public static class EventMetadata { - private String source; - private String version; - } - - public static RankingEvent create(RankingEventType eventType, String userId, int score, Map payload, String source) { - return RankingEvent.builder() - .eventId(UUID.randomUUID().toString()) - .eventType(eventType) - .userId(userId) - .timestamp(Instant.now().toString()) - .score(score) - .payload(payload) - .metadata(EventMetadata.builder() - .source(source) - .version("1.0") - .build()) - .build(); - } - - public static RankingEvent create(RankingEventType eventType, String userId, Map payload, String source) { - return create(eventType, userId, eventType.getBaseScore(), payload, source); - } -} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/ranking/model/RankingEventType.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/ranking/model/RankingEventType.java deleted file mode 100644 index 278d75a9..00000000 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/ranking/model/RankingEventType.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.mzc.secondproject.serverless.domain.ranking.model; - -public enum RankingEventType { - ATTENDANCE(10, "출석 체크"), - WORD_LEARNED(5, "단어 학습"), - WORD_MASTERED(20, "단어 마스터"), - TEST_COMPLETED(15, "시험 완료"), - GAME_PLAYED(10, "게임 참여"), - GAME_WON(50, "게임 1등"), - GRAMMAR_CHECK(3, "문법 체크"), - STREAK_BONUS(5, "연속 학습 보너스"); - - private final int baseScore; - private final String description; - - RankingEventType(int baseScore, String description) { - this.baseScore = baseScore; - this.description = description; - } - - public int getBaseScore() { - return baseScore; - } - - public String getDescription() { - return description; - } -} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/ranking/model/UserRanking.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/ranking/model/UserRanking.java deleted file mode 100644 index 96ef4279..00000000 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/ranking/model/UserRanking.java +++ /dev/null @@ -1,65 +0,0 @@ -package com.mzc.secondproject.serverless.domain.ranking.model; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbAttribute; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSortKey; - -/** - * 사용자 랭킹 - * PK: RANKING#{period} (예: RANKING#DAILY#2026-01-16, RANKING#WEEKLY#2026-W03) - * SK: SCORE#{invertedScore}#{userId} (역순 정렬용) - */ -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -@DynamoDbBean -public class UserRanking { - - private String pk; - private String sk; - private String userId; - private String periodType; // DAILY, WEEKLY, MONTHLY, TOTAL - private String period; // 2026-01-16, 2026-W03, 2026-01, TOTAL - private Integer score; - private String nickname; - private String profileUrl; - private String updatedAt; - - @DynamoDbPartitionKey - @DynamoDbAttribute("PK") - public String getPk() { - return pk; - } - - @DynamoDbSortKey - @DynamoDbAttribute("SK") - public String getSk() { - return sk; - } - - public static String buildPk(String periodType, String period) { - return "RANKING#" + periodType + "#" + period; - } - - public static String buildSk(int score, String userId) { - int invertedScore = 999999 - score; - return String.format("SCORE#%06d#%s", invertedScore, userId); - } - - public static UserRanking create(String periodType, String period, String userId, int score) { - return UserRanking.builder() - .pk(buildPk(periodType, period)) - .sk(buildSk(score, userId)) - .userId(userId) - .periodType(periodType) - .period(period) - .score(score) - .build(); - } -} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/ranking/repository/RankingRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/ranking/repository/RankingRepository.java deleted file mode 100644 index 72a687a0..00000000 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/ranking/repository/RankingRepository.java +++ /dev/null @@ -1,183 +0,0 @@ -package com.mzc.secondproject.serverless.domain.ranking.repository; - -import com.mzc.secondproject.serverless.domain.ranking.model.UserRanking; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; -import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; -import software.amazon.awssdk.enhanced.dynamodb.Key; -import software.amazon.awssdk.enhanced.dynamodb.TableSchema; -import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; -import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; -import software.amazon.awssdk.http.urlconnection.UrlConnectionHttpClient; -import software.amazon.awssdk.regions.Region; -import software.amazon.awssdk.services.dynamodb.DynamoDbClient; -import software.amazon.awssdk.services.dynamodb.model.AttributeValue; -import software.amazon.awssdk.services.dynamodb.model.DeleteItemRequest; -import software.amazon.awssdk.services.dynamodb.model.QueryRequest; -import software.amazon.awssdk.services.dynamodb.model.QueryResponse; - -import java.time.Instant; -import java.time.LocalDate; -import java.time.temporal.WeekFields; -import java.util.ArrayList; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Optional; - -public class RankingRepository { - - private static final Logger logger = LoggerFactory.getLogger(RankingRepository.class); - private static final String TABLE_NAME = System.getenv("VOCAB_TABLE_NAME"); - - private final DynamoDbClient dynamoDbClient; - private final DynamoDbEnhancedClient enhancedClient; - private final DynamoDbTable rankingTable; - - public RankingRepository() { - this.dynamoDbClient = DynamoDbClient.builder() - .region(Region.of(System.getenv("AWS_REGION_NAME"))) - .httpClient(UrlConnectionHttpClient.builder().build()) - .build(); - this.enhancedClient = DynamoDbEnhancedClient.builder() - .dynamoDbClient(dynamoDbClient) - .build(); - this.rankingTable = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(UserRanking.class)); - } - - public void updateScore(String userId, int scoreToAdd) { - String now = Instant.now().toString(); - LocalDate today = LocalDate.now(); - - String dailyPeriod = today.toString(); - String weeklyPeriod = today.getYear() + "-W" + String.format("%02d", today.get(WeekFields.of(Locale.getDefault()).weekOfWeekBasedYear())); - String monthlyPeriod = today.getYear() + "-" + String.format("%02d", today.getMonthValue()); - - updatePeriodScore(userId, "DAILY", dailyPeriod, scoreToAdd, now); - updatePeriodScore(userId, "WEEKLY", weeklyPeriod, scoreToAdd, now); - updatePeriodScore(userId, "MONTHLY", monthlyPeriod, scoreToAdd, now); - updatePeriodScore(userId, "TOTAL", "TOTAL", scoreToAdd, now); - } - - private void updatePeriodScore(String userId, String periodType, String period, int scoreToAdd, String now) { - String pk = UserRanking.buildPk(periodType, period); - - Optional existing = findByPkAndUserId(pk, userId); - - if (existing.isPresent()) { - UserRanking current = existing.get(); - int newScore = current.getScore() + scoreToAdd; - - deleteItem(pk, current.getSk()); - - UserRanking updated = UserRanking.builder() - .pk(pk) - .sk(UserRanking.buildSk(newScore, userId)) - .userId(userId) - .periodType(periodType) - .period(period) - .score(newScore) - .nickname(current.getNickname()) - .profileUrl(current.getProfileUrl()) - .updatedAt(now) - .build(); - rankingTable.putItem(updated); - } else { - UserRanking ranking = UserRanking.builder() - .pk(pk) - .sk(UserRanking.buildSk(scoreToAdd, userId)) - .userId(userId) - .periodType(periodType) - .period(period) - .score(scoreToAdd) - .updatedAt(now) - .build(); - rankingTable.putItem(ranking); - } - } - - private Optional findByPkAndUserId(String pk, String userId) { - QueryRequest queryRequest = QueryRequest.builder() - .tableName(TABLE_NAME) - .keyConditionExpression("PK = :pk") - .filterExpression("userId = :userId") - .expressionAttributeValues(Map.of( - ":pk", AttributeValue.builder().s(pk).build(), - ":userId", AttributeValue.builder().s(userId).build() - )) - .build(); - - QueryResponse response = dynamoDbClient.query(queryRequest); - - if (response.items().isEmpty()) { - return Optional.empty(); - } - - Map item = response.items().get(0); - return Optional.of(UserRanking.builder() - .pk(item.get("PK").s()) - .sk(item.get("SK").s()) - .userId(item.get("userId").s()) - .periodType(item.containsKey("periodType") ? item.get("periodType").s() : null) - .period(item.containsKey("period") ? item.get("period").s() : null) - .score(item.containsKey("score") ? Integer.parseInt(item.get("score").n()) : 0) - .nickname(item.containsKey("nickname") ? item.get("nickname").s() : null) - .profileUrl(item.containsKey("profileUrl") ? item.get("profileUrl").s() : null) - .updatedAt(item.containsKey("updatedAt") ? item.get("updatedAt").s() : null) - .build()); - } - - private void deleteItem(String pk, String sk) { - DeleteItemRequest deleteRequest = DeleteItemRequest.builder() - .tableName(TABLE_NAME) - .key(Map.of( - "PK", AttributeValue.builder().s(pk).build(), - "SK", AttributeValue.builder().s(sk).build() - )) - .build(); - dynamoDbClient.deleteItem(deleteRequest); - } - - public List getTopRankings(String periodType, String period, int limit) { - String pk = UserRanking.buildPk(periodType, period); - - QueryEnhancedRequest request = QueryEnhancedRequest.builder() - .queryConditional(QueryConditional.keyEqualTo(Key.builder().partitionValue(pk).build())) - .limit(limit) - .build(); - - List rankings = new ArrayList<>(); - rankingTable.query(request).items().forEach(rankings::add); - return rankings; - } - - public Optional getUserRanking(String periodType, String period, String userId) { - String pk = UserRanking.buildPk(periodType, period); - return findByPkAndUserId(pk, userId); - } - - public int getUserRank(String periodType, String period, String userId) { - String pk = UserRanking.buildPk(periodType, period); - Optional userRanking = findByPkAndUserId(pk, userId); - - if (userRanking.isEmpty()) { - return -1; - } - - String userSk = userRanking.get().getSk(); - - QueryRequest countRequest = QueryRequest.builder() - .tableName(TABLE_NAME) - .keyConditionExpression("PK = :pk AND SK < :sk") - .expressionAttributeValues(Map.of( - ":pk", AttributeValue.builder().s(pk).build(), - ":sk", AttributeValue.builder().s(userSk).build() - )) - .select(software.amazon.awssdk.services.dynamodb.model.Select.COUNT) - .build(); - - QueryResponse response = dynamoDbClient.query(countRequest); - return response.count() + 1; - } -} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/ranking/service/KinesisEventPublisher.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/ranking/service/KinesisEventPublisher.java deleted file mode 100644 index 12cbaa0c..00000000 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/ranking/service/KinesisEventPublisher.java +++ /dev/null @@ -1,152 +0,0 @@ -package com.mzc.secondproject.serverless.domain.ranking.service; - -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; -import com.mzc.secondproject.serverless.domain.ranking.model.RankingEvent; -import com.mzc.secondproject.serverless.domain.ranking.model.RankingEventType; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import software.amazon.awssdk.core.SdkBytes; -import software.amazon.awssdk.http.urlconnection.UrlConnectionHttpClient; -import software.amazon.awssdk.regions.Region; -import software.amazon.awssdk.services.kinesis.KinesisClient; -import software.amazon.awssdk.services.kinesis.model.PutRecordRequest; -import software.amazon.awssdk.services.kinesis.model.PutRecordResponse; - -import java.util.Map; - -public class KinesisEventPublisher { - - private static final Logger logger = LoggerFactory.getLogger(KinesisEventPublisher.class); - private static final Gson gson = new GsonBuilder().create(); - - private final KinesisClient kinesisClient; - private final String streamName; - - public KinesisEventPublisher() { - this.streamName = System.getenv("RANKING_STREAM_NAME"); - this.kinesisClient = KinesisClient.builder() - .region(Region.of(System.getenv("AWS_REGION_NAME"))) - .httpClient(UrlConnectionHttpClient.builder().build()) - .build(); - } - - public void publish(RankingEvent event) { - if (streamName == null || streamName.isEmpty()) { - logger.warn("RANKING_STREAM_NAME not configured, skipping event publish"); - return; - } - - try { - String eventJson = gson.toJson(event); - - PutRecordRequest request = PutRecordRequest.builder() - .streamName(streamName) - .partitionKey(event.getUserId()) - .data(SdkBytes.fromUtf8String(eventJson)) - .build(); - - PutRecordResponse response = kinesisClient.putRecord(request); - - logger.info("Published ranking event: type={}, userId={}, score={}, shardId={}, sequenceNumber={}", - event.getEventType(), event.getUserId(), event.getScore(), - response.shardId(), response.sequenceNumber()); - - } catch (Exception e) { - logger.error("Failed to publish ranking event: type={}, userId={}, error={}", - event.getEventType(), event.getUserId(), e.getMessage(), e); - } - } - - public void publishTestCompleted(String userId, int correctAnswers, int totalQuestions, double successRate, String testId) { - int bonusScore = (int) (successRate * 10); - int totalScore = RankingEventType.TEST_COMPLETED.getBaseScore() + bonusScore; - - RankingEvent event = RankingEvent.create( - RankingEventType.TEST_COMPLETED, - userId, - totalScore, - Map.of( - "testId", testId, - "correctAnswers", correctAnswers, - "totalQuestions", totalQuestions, - "successRate", successRate - ), - "TestHandler" - ); - publish(event); - } - - public void publishWordLearned(String userId, String wordId) { - RankingEvent event = RankingEvent.create( - RankingEventType.WORD_LEARNED, - userId, - Map.of("wordId", wordId), - "DailyStudyHandler" - ); - publish(event); - } - - public void publishWordMastered(String userId, String wordId) { - RankingEvent event = RankingEvent.create( - RankingEventType.WORD_MASTERED, - userId, - Map.of("wordId", wordId), - "UserWordHandler" - ); - publish(event); - } - - public void publishGamePlayed(String userId, String roomId, int score) { - RankingEvent event = RankingEvent.create( - RankingEventType.GAME_PLAYED, - userId, - RankingEventType.GAME_PLAYED.getBaseScore() + score, - Map.of("roomId", roomId, "gameScore", score), - "GameHandler" - ); - publish(event); - } - - public void publishGameWon(String userId, String roomId, int finalScore) { - RankingEvent event = RankingEvent.create( - RankingEventType.GAME_WON, - userId, - Map.of("roomId", roomId, "finalScore", finalScore), - "GameHandler" - ); - publish(event); - } - - public void publishGrammarCheck(String userId, String sessionId) { - RankingEvent event = RankingEvent.create( - RankingEventType.GRAMMAR_CHECK, - userId, - Map.of("sessionId", sessionId != null ? sessionId : "single-check"), - "GrammarHandler" - ); - publish(event); - } - - public void publishAttendance(String userId) { - RankingEvent event = RankingEvent.create( - RankingEventType.ATTENDANCE, - userId, - Map.of(), - "UserStatsHandler" - ); - publish(event); - } - - public void publishStreakBonus(String userId, int streakDays) { - int bonusScore = RankingEventType.STREAK_BONUS.getBaseScore() * streakDays; - RankingEvent event = RankingEvent.create( - RankingEventType.STREAK_BONUS, - userId, - bonusScore, - Map.of("streakDays", streakDays), - "StatsStreamHandler" - ); - publish(event); - } -} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/ranking/service/RankingService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/ranking/service/RankingService.java deleted file mode 100644 index 42fab5e8..00000000 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/ranking/service/RankingService.java +++ /dev/null @@ -1,92 +0,0 @@ -package com.mzc.secondproject.serverless.domain.ranking.service; - -import com.mzc.secondproject.serverless.domain.ranking.model.UserRanking; -import com.mzc.secondproject.serverless.domain.ranking.repository.RankingRepository; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.time.LocalDate; -import java.time.temporal.WeekFields; -import java.util.List; -import java.util.Locale; -import java.util.Optional; - -public class RankingService { - - private static final Logger logger = LoggerFactory.getLogger(RankingService.class); - - private final RankingRepository rankingRepository; - - public RankingService() { - this.rankingRepository = new RankingRepository(); - } - - public List getDailyRanking(int limit) { - String period = LocalDate.now().toString(); - return rankingRepository.getTopRankings("DAILY", period, limit); - } - - public List getWeeklyRanking(int limit) { - LocalDate today = LocalDate.now(); - String period = today.getYear() + "-W" + String.format("%02d", today.get(WeekFields.of(Locale.getDefault()).weekOfWeekBasedYear())); - return rankingRepository.getTopRankings("WEEKLY", period, limit); - } - - public List getMonthlyRanking(int limit) { - LocalDate today = LocalDate.now(); - String period = today.getYear() + "-" + String.format("%02d", today.getMonthValue()); - return rankingRepository.getTopRankings("MONTHLY", period, limit); - } - - public List getTotalRanking(int limit) { - return rankingRepository.getTopRankings("TOTAL", "TOTAL", limit); - } - - public List getRanking(String periodType, int limit) { - return switch (periodType.toUpperCase()) { - case "DAILY" -> getDailyRanking(limit); - case "WEEKLY" -> getWeeklyRanking(limit); - case "MONTHLY" -> getMonthlyRanking(limit); - case "TOTAL" -> getTotalRanking(limit); - default -> getDailyRanking(limit); - }; - } - - public MyRankingResult getMyRanking(String userId) { - LocalDate today = LocalDate.now(); - - String dailyPeriod = today.toString(); - String weeklyPeriod = today.getYear() + "-W" + String.format("%02d", today.get(WeekFields.of(Locale.getDefault()).weekOfWeekBasedYear())); - String monthlyPeriod = today.getYear() + "-" + String.format("%02d", today.getMonthValue()); - - Optional dailyRanking = rankingRepository.getUserRanking("DAILY", dailyPeriod, userId); - Optional weeklyRanking = rankingRepository.getUserRanking("WEEKLY", weeklyPeriod, userId); - Optional monthlyRanking = rankingRepository.getUserRanking("MONTHLY", monthlyPeriod, userId); - Optional totalRanking = rankingRepository.getUserRanking("TOTAL", "TOTAL", userId); - - int dailyRank = dailyRanking.isPresent() ? rankingRepository.getUserRank("DAILY", dailyPeriod, userId) : -1; - int weeklyRank = weeklyRanking.isPresent() ? rankingRepository.getUserRank("WEEKLY", weeklyPeriod, userId) : -1; - int monthlyRank = monthlyRanking.isPresent() ? rankingRepository.getUserRank("MONTHLY", monthlyPeriod, userId) : -1; - int totalRank = totalRanking.isPresent() ? rankingRepository.getUserRank("TOTAL", "TOTAL", userId) : -1; - - return new MyRankingResult( - new PeriodRanking("DAILY", dailyRanking.map(UserRanking::getScore).orElse(0), dailyRank), - new PeriodRanking("WEEKLY", weeklyRanking.map(UserRanking::getScore).orElse(0), weeklyRank), - new PeriodRanking("MONTHLY", monthlyRanking.map(UserRanking::getScore).orElse(0), monthlyRank), - new PeriodRanking("TOTAL", totalRanking.map(UserRanking::getScore).orElse(0), totalRank) - ); - } - - public record MyRankingResult( - PeriodRanking daily, - PeriodRanking weekly, - PeriodRanking monthly, - PeriodRanking total - ) {} - - public record PeriodRanking( - String periodType, - int score, - int rank - ) {} -} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/DailyStudyHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/DailyStudyHandler.java index d6576f36..44ffa02d 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/DailyStudyHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/DailyStudyHandler.java @@ -10,7 +10,6 @@ import com.mzc.secondproject.serverless.domain.vocabulary.exception.VocabularyErrorCode; import com.mzc.secondproject.serverless.domain.vocabulary.service.DailyStudyCommandService; import com.mzc.secondproject.serverless.domain.vocabulary.service.DailyStudyQueryService; -import com.mzc.secondproject.serverless.domain.ranking.service.KinesisEventPublisher; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -23,13 +22,11 @@ public class DailyStudyHandler implements RequestHandler progress = commandService.markWordLearned(userId, wordId); - - eventPublisher.publishWordLearned(userId, wordId); - return ResponseGenerator.ok("Word marked as learned", progress); } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/TestHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/TestHandler.java index 6ab72cf9..949e045d 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/TestHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/TestHandler.java @@ -15,7 +15,6 @@ import com.mzc.secondproject.serverless.domain.vocabulary.model.TestResult; import com.mzc.secondproject.serverless.domain.vocabulary.service.TestCommandService; import com.mzc.secondproject.serverless.domain.vocabulary.service.TestQueryService; -import com.mzc.secondproject.serverless.domain.ranking.service.KinesisEventPublisher; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -28,13 +27,11 @@ public class TestHandler implements RequestHandler { String testType = dto.getTestType() != null ? dto.getTestType() : "DAILY"; - + TestCommandService.SubmitTestResult result = commandService.submitTest( userId, dto.getTestId(), testType, dto.getAnswers(), dto.getStartedAt()); - - eventPublisher.publishTestCompleted( - userId, - result.correctCount(), - result.totalQuestions(), - result.successRate(), - result.testId() - ); - + Map response = new HashMap<>(); response.put("testId", result.testId()); response.put("testType", result.testType()); @@ -95,7 +84,7 @@ private APIGatewayProxyResponseEvent submitAnswer(APIGatewayProxyRequestEvent re response.put("incorrectCount", result.incorrectCount()); response.put("successRate", result.successRate()); response.put("results", result.results()); - + return ResponseGenerator.ok("Test submitted", response); }); } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java index 051f39ee..222c3354 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java @@ -16,7 +16,6 @@ import com.mzc.secondproject.serverless.domain.vocabulary.model.UserWord; import com.mzc.secondproject.serverless.domain.vocabulary.service.UserWordCommandService; import com.mzc.secondproject.serverless.domain.vocabulary.service.UserWordQueryService; -import com.mzc.secondproject.serverless.domain.ranking.service.KinesisEventPublisher; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -30,13 +29,11 @@ public class UserWordHandler implements RequestHandler body = ResponseGenerator.gson().fromJson(request.getBody(), new TypeToken>() { }.getType()); - + String status = body != null ? body.get("status") : null; if (status == null || status.isEmpty()) { return ResponseGenerator.fail(CommonErrorCode.REQUIRED_FIELD_MISSING); } - + try { UserWord userWord = commandService.updateWordStatus(userId, wordId, status); - - if ("MASTERED".equalsIgnoreCase(status)) { - eventPublisher.publishWordMastered(userId, wordId); - } - return ResponseGenerator.ok("Word status updated", userWord); } catch (IllegalArgumentException e) { return ResponseGenerator.fail(VocabularyErrorCode.INVALID_WORD_STATUS); diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 6e4dc48b..4fd81a9e 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -20,7 +20,6 @@ Globals: PROFILE_BUCKET_NAME: group2-englishstudy AWS_REGION_NAME: !Ref AWS::Region ROOM_TOKEN_TTL_SECONDS: "300" - RANKING_STREAM_NAME: !Ref RankingEventStream Api: TracingEnabled: true @@ -415,7 +414,6 @@ Resources: Action: - execute-api:ManageConnections Resource: !Sub "arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${WebSocketApi}/*" - - !Ref KinesisProducerPolicy Events: StartGame: Type: Api @@ -626,7 +624,6 @@ Resources: Policies: - DynamoDBCrudPolicy: TableName: !Ref VocabTable - - !Ref KinesisProducerPolicy Events: GetWrongAnswers: Type: Api @@ -759,7 +756,6 @@ Resources: Policies: - DynamoDBCrudPolicy: TableName: !Ref VocabTable - - !Ref KinesisProducerPolicy Events: GetDailyWords: Type: Api @@ -795,7 +791,6 @@ Resources: TableName: !Ref VocabTable - SNSPublishMessagePolicy: TopicName: !GetAtt TestResultTopic.TopicName - - !Ref KinesisProducerPolicy Events: StartTest: Type: Api @@ -1058,7 +1053,6 @@ Resources: - comprehend:DetectKeyPhrases - comprehend:DetectDominantLanguage Resource: "*" - - !Ref KinesisProducerPolicy Events: GrammarCheck: Type: Api @@ -1471,199 +1465,6 @@ Resources: Endpoint: !GetAtt StatisticsQueue.Arn RawMessageDelivery: true - ############################################# - # Kinesis Data Streams (Ranking Events) - ############################################# - - RankingEventStream: - Type: AWS::Kinesis::Stream - Properties: - Name: !Sub "${AWS::StackName}-ranking-events" - ShardCount: 2 - RetentionPeriodHours: 24 - StreamModeDetails: - StreamMode: PROVISIONED - - # Kinesis Producer Policy - 랭킹 이벤트 발행용 - KinesisProducerPolicy: - Type: AWS::IAM::ManagedPolicy - Properties: - ManagedPolicyName: !Sub "${AWS::StackName}-kinesis-producer-policy" - PolicyDocument: - Version: "2012-10-17" - Statement: - - Effect: Allow - Action: - - kinesis:PutRecord - - kinesis:PutRecords - - kinesis:DescribeStream - Resource: !GetAtt RankingEventStream.Arn - - # Ranking API Handler - 랭킹 조회 API - RankingFunction: - Type: AWS::Serverless::Function - Properties: - FunctionName: !Sub "${AWS::StackName}-ranking-handler" - CodeUri: . - Handler: com.mzc.secondproject.serverless.domain.ranking.handler.RankingHandler::handleRequest - Description: Handle ranking API endpoints - SnapStart: - ApplyOn: PublishedVersions - Policies: - - DynamoDBCrudPolicy: - TableName: !Ref VocabTable - Events: - GetDailyRanking: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /ranking/daily - Method: GET - Auth: - Authorizer: CognitoAuthorizer - GetWeeklyRanking: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /ranking/weekly - Method: GET - Auth: - Authorizer: CognitoAuthorizer - GetMonthlyRanking: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /ranking/monthly - Method: GET - Auth: - Authorizer: CognitoAuthorizer - GetTotalRanking: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /ranking/total - Method: GET - Auth: - Authorizer: CognitoAuthorizer - GetMyRanking: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /ranking/me - Method: GET - Auth: - Authorizer: CognitoAuthorizer - - # Kinesis Ranking Consumer - 랭킹 이벤트 소비 및 점수 업데이트 - KinesisRankingConsumerFunction: - Type: AWS::Serverless::Function - Properties: - FunctionName: !Sub "${AWS::StackName}-ranking-consumer" - CodeUri: . - Handler: com.mzc.secondproject.serverless.domain.ranking.handler.KinesisRankingConsumer::handleRequest - Description: Consume ranking events from Kinesis and update scores - Timeout: 60 - MemorySize: 512 - SnapStart: - ApplyOn: PublishedVersions - Policies: - - DynamoDBCrudPolicy: - TableName: !Ref VocabTable - - Statement: - - Effect: Allow - Action: - - kinesis:GetRecords - - kinesis:GetShardIterator - - kinesis:DescribeStream - - kinesis:DescribeStreamSummary - - kinesis:ListShards - Resource: !GetAtt RankingEventStream.Arn - Events: - KinesisEvent: - Type: Kinesis - Properties: - Stream: !GetAtt RankingEventStream.Arn - StartingPosition: LATEST - BatchSize: 100 - MaximumBatchingWindowInSeconds: 5 - - ############################################# - # Kinesis Firehose (S3 Archiving) - ############################################# - - # S3 Bucket for ranking event logs - RankingLogBucket: - Type: AWS::S3::Bucket - Properties: - BucketName: !Sub "${AWS::StackName}-ranking-logs" - LifecycleConfiguration: - Rules: - - Id: MoveToGlacierAfter90Days - Status: Enabled - Transitions: - - StorageClass: GLACIER - TransitionInDays: 90 - ExpirationInDays: 365 - - # IAM Role for Firehose to access Kinesis and S3 - FirehoseDeliveryRole: - Type: AWS::IAM::Role - Properties: - RoleName: !Sub "${AWS::StackName}-firehose-delivery-role" - AssumeRolePolicyDocument: - Version: "2012-10-17" - Statement: - - Effect: Allow - Principal: - Service: firehose.amazonaws.com - Action: sts:AssumeRole - Policies: - - PolicyName: FirehoseS3Access - PolicyDocument: - Version: "2012-10-17" - Statement: - - Effect: Allow - Action: - - s3:AbortMultipartUpload - - s3:GetBucketLocation - - s3:GetObject - - s3:ListBucket - - s3:ListBucketMultipartUploads - - s3:PutObject - Resource: - - !GetAtt RankingLogBucket.Arn - - !Sub "${RankingLogBucket.Arn}/*" - - PolicyName: FirehoseKinesisAccess - PolicyDocument: - Version: "2012-10-17" - Statement: - - Effect: Allow - Action: - - kinesis:DescribeStream - - kinesis:GetShardIterator - - kinesis:GetRecords - - kinesis:ListShards - Resource: !GetAtt RankingEventStream.Arn - - # Kinesis Firehose Delivery Stream - RankingEventFirehose: - Type: AWS::KinesisFirehose::DeliveryStream - Properties: - DeliveryStreamName: !Sub "${AWS::StackName}-ranking-firehose" - DeliveryStreamType: KinesisStreamAsSource - KinesisStreamSourceConfiguration: - KinesisStreamARN: !GetAtt RankingEventStream.Arn - RoleARN: !GetAtt FirehoseDeliveryRole.Arn - S3DestinationConfiguration: - BucketARN: !GetAtt RankingLogBucket.Arn - Prefix: "ranking-events/year=!{timestamp:yyyy}/month=!{timestamp:MM}/day=!{timestamp:dd}/" - ErrorOutputPrefix: "errors/!{firehose:error-output-type}/year=!{timestamp:yyyy}/month=!{timestamp:MM}/day=!{timestamp:dd}/" - BufferingHints: - IntervalInSeconds: 300 - SizeInMBs: 5 - CompressionFormat: UNCOMPRESSED - RoleARN: !GetAtt FirehoseDeliveryRole.Arn - # Statistics Processor Lambda - SQS에서 메시지 소비하여 통계 업데이트 StatisticsProcessorFunction: Type: AWS::Serverless::Function @@ -1723,19 +1524,3 @@ Outputs: CognitoUserPoolClientId: Description: Cognito User Pool Client ID Value: !Ref CognitoUserPoolClient - - RankingStreamName: - Description: Kinesis Data Stream for ranking events - Value: !Ref RankingEventStream - - RankingStreamArn: - Description: Kinesis Data Stream ARN - Value: !GetAtt RankingEventStream.Arn - - RankingFirehoseName: - Description: Kinesis Firehose Delivery Stream Name - Value: !Ref RankingEventFirehose - - RankingLogBucketName: - Description: S3 Bucket for ranking event logs - Value: !Ref RankingLogBucket From a398a26f5d29ad0d516bc7b2ffa2fe5eb0491a17 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 11:15:55 +0900 Subject: [PATCH 209/528] docs: add frontend integration guide - API endpoints with examples - Cognito authentication setup - WebSocket integration (Chat, Grammar) - TypeScript type definitions - Error handling patterns --- docs/FRONTEND_INTEGRATION_GUIDE.md | 715 +++++++++++++++++++++++++++++ 1 file changed, 715 insertions(+) create mode 100644 docs/FRONTEND_INTEGRATION_GUIDE.md diff --git a/docs/FRONTEND_INTEGRATION_GUIDE.md b/docs/FRONTEND_INTEGRATION_GUIDE.md new file mode 100644 index 00000000..f2b765a7 --- /dev/null +++ b/docs/FRONTEND_INTEGRATION_GUIDE.md @@ -0,0 +1,715 @@ +# Frontend Integration Guide + +영어 학습 플랫폼 백엔드 API 연동 가이드 + +## 1. 환경 설정 + +### 1.1 엔드포인트 정보 + +| 서비스 | URL | +|--------|-----| +| REST API | `https://gc8l9ijhzc.execute-api.ap-northeast-2.amazonaws.com/dev` | +| Chat WebSocket | `wss://t378dif43l.execute-api.ap-northeast-2.amazonaws.com/dev` | +| Grammar WebSocket | `wss://ltrccmteo8.execute-api.ap-northeast-2.amazonaws.com/dev` | + +### 1.2 Cognito 설정 + +| 항목 | 값 | +|------|-----| +| User Pool ID | `ap-northeast-2_ezDwzFCzR` | +| Client ID | `4ns077jcr1pkue2vvisr6qdpu5` | +| Region | `ap-northeast-2` | + +### 1.3 환경변수 예시 (.env) + +```env +VITE_API_URL=https://gc8l9ijhzc.execute-api.ap-northeast-2.amazonaws.com/dev +VITE_WS_URL=wss://t378dif43l.execute-api.ap-northeast-2.amazonaws.com/dev +VITE_GRAMMAR_WS_URL=wss://ltrccmteo8.execute-api.ap-northeast-2.amazonaws.com/dev +VITE_COGNITO_USER_POOL_ID=ap-northeast-2_ezDwzFCzR +VITE_COGNITO_CLIENT_ID=4ns077jcr1pkue2vvisr6qdpu5 +VITE_COGNITO_REGION=ap-northeast-2 +``` + +--- + +## 2. 인증 (Cognito) + +### 2.1 AWS Amplify 설정 + +```typescript +// src/config/amplify.ts +import { Amplify } from 'aws-amplify'; + +Amplify.configure({ + Auth: { + Cognito: { + userPoolId: import.meta.env.VITE_COGNITO_USER_POOL_ID, + userPoolClientId: import.meta.env.VITE_COGNITO_CLIENT_ID, + signUpVerificationMethod: 'code', + } + } +}); +``` + +### 2.2 회원가입 + +```typescript +import { signUp, confirmSignUp } from 'aws-amplify/auth'; + +// 1. 회원가입 요청 +const { isSignUpComplete, userId, nextStep } = await signUp({ + username: email, + password: password, + options: { + userAttributes: { + email: email, + } + } +}); + +// 2. 이메일 인증 코드 확인 +await confirmSignUp({ + username: email, + confirmationCode: code +}); +``` + +### 2.3 로그인 + +```typescript +import { signIn, fetchAuthSession } from 'aws-amplify/auth'; + +// 로그인 +const { isSignedIn, nextStep } = await signIn({ + username: email, + password: password +}); + +// 토큰 가져오기 +const session = await fetchAuthSession(); +const idToken = session.tokens?.idToken?.toString(); +``` + +### 2.4 API 요청 시 토큰 사용 + +```typescript +// axios 인터셉터 설정 +import axios from 'axios'; +import { fetchAuthSession } from 'aws-amplify/auth'; + +const api = axios.create({ + baseURL: import.meta.env.VITE_API_URL +}); + +api.interceptors.request.use(async (config) => { + const session = await fetchAuthSession(); + const token = session.tokens?.idToken?.toString(); + if (token) { + config.headers.Authorization = token; + } + return config; +}); +``` + +--- + +## 3. API 엔드포인트 + +### 3.1 공통 응답 형식 + +```typescript +interface ApiResponse { + statusCode: number; + body: { + success: boolean; + message: string; + data: T; + } +} +``` + +### 3.2 사용자 프로필 API + +| Method | Endpoint | Auth | Description | +|--------|----------|------|-------------| +| GET | `/users/profile/me` | Required | 내 프로필 조회 | +| PUT | `/users/profile/me` | Required | 내 프로필 수정 | +| POST | `/users/profile/me/image` | Required | 프로필 이미지 업로드 | + +```typescript +// 프로필 조회 +const response = await api.get('/users/profile/me'); + +// 프로필 수정 +await api.put('/users/profile/me', { + nickname: 'newNickname', + level: 'INTERMEDIATE' +}); + +// 프로필 이미지 업로드 (Base64) +await api.post('/users/profile/me/image', { + imageData: base64ImageData, + contentType: 'image/png' +}); +``` + +--- + +### 3.3 단어 API (공개) + +| Method | Endpoint | Auth | Description | +|--------|----------|------|-------------| +| GET | `/vocab/words` | None | 단어 목록 조회 | +| GET | `/vocab/words/{wordId}` | None | 단어 상세 조회 | +| GET | `/vocab/words/search?keyword=xxx` | None | 단어 검색 | +| POST | `/vocab/words/batch/get` | None | 단어 일괄 조회 | + +```typescript +// 단어 목록 조회 +const words = await api.get('/vocab/words', { + params: { level: 'BEGINNER', limit: 20 } +}); + +// 단어 검색 +const results = await api.get('/vocab/words/search', { + params: { keyword: 'apple' } +}); + +// 단어 일괄 조회 +const batchWords = await api.post('/vocab/words/batch/get', { + wordIds: ['word1', 'word2', 'word3'] +}); +``` + +--- + +### 3.4 사용자 단어 학습 API + +| Method | Endpoint | Auth | Description | +|--------|----------|------|-------------| +| GET | `/vocab/user-words` | Required | 내 단어 목록 | +| GET | `/vocab/user-words/{wordId}` | Required | 내 단어 상세 | +| PUT | `/vocab/user-words/{wordId}` | Required | 학습 상태 업데이트 | +| PUT | `/vocab/user-words/{wordId}/status` | Required | 상태 변경 (LEARNING/MASTERED) | +| PUT | `/vocab/user-words/{wordId}/tag` | Required | 태그 추가 | +| GET | `/vocab/wrong-answers` | Required | 오답 노트 | + +```typescript +// 내 단어 목록 +const myWords = await api.get('/vocab/user-words', { + params: { status: 'LEARNING' } +}); + +// 단어 마스터 처리 +await api.put('/vocab/user-words/word123/status', { + status: 'MASTERED' +}); + +// 오답 노트 +const wrongAnswers = await api.get('/vocab/wrong-answers'); +``` + +--- + +### 3.5 일일 학습 API + +| Method | Endpoint | Auth | Description | +|--------|----------|------|-------------| +| GET | `/vocab/daily` | Required | 오늘의 학습 단어 | +| POST | `/vocab/daily/words/{wordId}/learned` | Required | 학습 완료 표시 | + +```typescript +// 오늘의 단어 가져오기 +const dailyWords = await api.get('/vocab/daily'); + +// 단어 학습 완료 +await api.post('/vocab/daily/words/word123/learned'); +``` + +--- + +### 3.6 단어 그룹 API + +| Method | Endpoint | Auth | Description | +|--------|----------|------|-------------| +| GET | `/vocab/groups` | Required | 내 그룹 목록 | +| POST | `/vocab/groups` | Required | 그룹 생성 | +| GET | `/vocab/groups/{groupId}` | Required | 그룹 상세 | +| PUT | `/vocab/groups/{groupId}` | Required | 그룹 수정 | +| DELETE | `/vocab/groups/{groupId}` | Required | 그룹 삭제 | +| POST | `/vocab/groups/{groupId}/words/{wordId}` | Required | 그룹에 단어 추가 | +| DELETE | `/vocab/groups/{groupId}/words/{wordId}` | Required | 그룹에서 단어 제거 | + +```typescript +// 그룹 생성 +await api.post('/vocab/groups', { + name: '토익 필수 단어', + description: '토익 시험 대비 단어장' +}); + +// 그룹에 단어 추가 +await api.post('/vocab/groups/group123/words/word456'); +``` + +--- + +### 3.7 시험 API + +| Method | Endpoint | Auth | Description | +|--------|----------|------|-------------| +| POST | `/vocab/test/start` | Required | 시험 시작 | +| POST | `/vocab/test/submit` | Required | 답안 제출 | +| GET | `/vocab/test/results` | Required | 시험 결과 목록 | +| GET | `/vocab/test/results/{testId}` | Required | 시험 상세 결과 | +| GET | `/vocab/test/tested-words` | Required | 시험 본 단어 목록 | + +```typescript +// 시험 시작 +const test = await api.post('/vocab/test/start', { + wordCount: 10, + testType: 'MULTIPLE_CHOICE', // MULTIPLE_CHOICE, WRITING + source: 'DAILY' // DAILY, GROUP, WRONG_ANSWERS +}); + +// 답안 제출 +const result = await api.post('/vocab/test/submit', { + testId: 'test123', + answers: [ + { wordId: 'word1', answer: 'apple' }, + { wordId: 'word2', answer: 'banana' } + ] +}); + +// 시험 결과 조회 +const results = await api.get('/vocab/test/results'); +``` + +--- + +### 3.8 통계 API + +| Method | Endpoint | Auth | Description | +|--------|----------|------|-------------| +| GET | `/vocab/stats` | Required | 전체 학습 통계 | +| GET | `/vocab/stats/daily` | Required | 일별 통계 | +| GET | `/vocab/stats/weakness` | Required | 취약점 분석 | +| GET | `/stats/daily` | Required | 일간 상세 통계 | +| GET | `/stats/weekly` | Required | 주간 통계 | +| GET | `/stats/monthly` | Required | 월간 통계 | +| GET | `/stats/total` | Required | 전체 통계 | +| GET | `/stats/history` | Required | 통계 히스토리 | + +```typescript +// 전체 통계 +const stats = await api.get('/vocab/stats'); + +// 취약점 분석 +const weakness = await api.get('/vocab/stats/weakness'); + +// 주간 통계 +const weeklyStats = await api.get('/stats/weekly'); +``` + +--- + +### 3.9 배지 API + +| Method | Endpoint | Auth | Description | +|--------|----------|------|-------------| +| GET | `/badges` | Required | 전체 배지 목록 | +| GET | `/badges/earned` | Required | 획득한 배지 | + +```typescript +// 전체 배지 +const allBadges = await api.get('/badges'); + +// 내 배지 +const myBadges = await api.get('/badges/earned'); +``` + +--- + +### 3.10 음성 합성 API + +| Method | Endpoint | Auth | Description | +|--------|----------|------|-------------| +| POST | `/vocab/voice/synthesize` | None | 단어 발음 생성 | +| POST | `/chat/voice/synthesize` | Required | 문장 발음 생성 | + +```typescript +// 단어 발음 (공개) +const audio = await api.post('/vocab/voice/synthesize', { + text: 'apple', + voiceId: 'Joanna' // 또는 Matthew, Amy 등 +}); +// 응답: { audioUrl: 's3://...' } +``` + +--- + +### 3.11 채팅방 API + +| Method | Endpoint | Auth | Description | +|--------|----------|------|-------------| +| POST | `/chat/rooms` | Required | 채팅방 생성 | +| GET | `/chat/rooms` | Required | 채팅방 목록 | +| GET | `/chat/rooms/{roomId}` | Required | 채팅방 상세 | +| DELETE | `/chat/rooms/{roomId}` | Required | 채팅방 삭제 | +| POST | `/chat/rooms/{roomId}/join` | Required | 채팅방 참여 | +| POST | `/chat/rooms/{roomId}/leave` | Required | 채팅방 퇴장 | + +```typescript +// 채팅방 생성 +const room = await api.post('/chat/rooms', { + name: '영어 스터디', + description: '함께 영어 공부해요', + maxParticipants: 10 +}); + +// 채팅방 참여 +await api.post(`/chat/rooms/${roomId}/join`); +``` + +--- + +### 3.12 채팅 메시지 API + +| Method | Endpoint | Auth | Description | +|--------|----------|------|-------------| +| POST | `/chat/rooms/{roomId}/messages` | Required | 메시지 전송 | +| GET | `/chat/rooms/{roomId}/messages` | Required | 메시지 목록 | +| GET | `/chat/rooms/{roomId}/messages/{messageId}` | Required | 메시지 상세 | + +```typescript +// 메시지 전송 +await api.post(`/chat/rooms/${roomId}/messages`, { + content: 'Hello everyone!', + messageType: 'TEXT' // TEXT, IMAGE, VOICE +}); + +// 메시지 목록 (페이지네이션) +const messages = await api.get(`/chat/rooms/${roomId}/messages`, { + params: { limit: 50, lastKey: 'xxx' } +}); +``` + +--- + +### 3.13 게임 API (Catch Mind) + +| Method | Endpoint | Auth | Description | +|--------|----------|------|-------------| +| POST | `/chat/rooms/{roomId}/game/start` | Required | 게임 시작 | +| POST | `/chat/rooms/{roomId}/game/stop` | Required | 게임 종료 | +| GET | `/chat/rooms/{roomId}/game/status` | Required | 게임 상태 | +| GET | `/chat/rooms/{roomId}/game/scores` | Required | 점수 조회 | + +```typescript +// 게임 시작 +const game = await api.post(`/chat/rooms/${roomId}/game/start`, { + roundCount: 5, + roundTimeSeconds: 60 +}); + +// 게임 상태 확인 +const status = await api.get(`/chat/rooms/${roomId}/game/status`); +``` + +--- + +### 3.14 문법 체크 API + +| Method | Endpoint | Auth | Description | +|--------|----------|------|-------------| +| POST | `/grammar/check` | Required | 문법 체크 | +| POST | `/grammar/conversation` | Required | AI 대화 | +| GET | `/grammar/sessions` | Required | 세션 목록 | +| GET | `/grammar/sessions/{sessionId}` | Required | 세션 상세 | +| DELETE | `/grammar/sessions/{sessionId}` | Required | 세션 삭제 | + +```typescript +// 문법 체크 +const result = await api.post('/grammar/check', { + text: 'I goes to school yesterday.' +}); +// 응답: 교정된 문장, 오류 설명, 학습 팁 + +// AI 대화 +const conversation = await api.post('/grammar/conversation', { + sessionId: 'session123', // 없으면 새 세션 생성 + message: 'How do I use present perfect tense?' +}); +``` + +--- + +## 4. WebSocket 연동 + +### 4.1 채팅 WebSocket + +```typescript +// 연결 +const ws = new WebSocket( + `${import.meta.env.VITE_WS_URL}?roomId=${roomId}&token=${idToken}` +); + +ws.onopen = () => { + console.log('Connected to chat'); +}; + +ws.onmessage = (event) => { + const data = JSON.parse(event.data); + + switch (data.type) { + case 'MESSAGE': + // 새 메시지 + handleNewMessage(data.payload); + break; + case 'USER_JOINED': + // 사용자 입장 + handleUserJoined(data.payload); + break; + case 'USER_LEFT': + // 사용자 퇴장 + handleUserLeft(data.payload); + break; + case 'GAME_START': + // 게임 시작 + handleGameStart(data.payload); + break; + case 'ROUND_START': + // 라운드 시작 + handleRoundStart(data.payload); + break; + case 'CORRECT_ANSWER': + // 정답 + handleCorrectAnswer(data.payload); + break; + case 'ROUND_END': + // 라운드 종료 + handleRoundEnd(data.payload); + break; + case 'GAME_END': + // 게임 종료 + handleGameEnd(data.payload); + break; + } +}; + +// 메시지 전송 +ws.send(JSON.stringify({ + action: 'sendMessage', + roomId: roomId, + content: 'Hello!', + messageType: 'TEXT' +})); +``` + +### 4.2 문법 스트리밍 WebSocket + +```typescript +// 연결 (JWT 토큰 포함) +const grammarWs = new WebSocket( + `${import.meta.env.VITE_GRAMMAR_WS_URL}?token=${idToken}` +); + +grammarWs.onmessage = (event) => { + const data = JSON.parse(event.data); + + switch (data.type) { + case 'STREAM_START': + // 스트리밍 시작 + break; + case 'STREAM_CHUNK': + // AI 응답 청크 + appendToResponse(data.content); + break; + case 'STREAM_END': + // 스트리밍 완료 + break; + case 'ERROR': + // 에러 + handleError(data.message); + break; + } +}; + +// 문법 스트리밍 요청 +grammarWs.send(JSON.stringify({ + action: 'grammarStreaming', + sessionId: 'session123', + message: 'Explain the difference between "affect" and "effect"' +})); +``` + +--- + +## 5. 에러 처리 + +### 5.1 HTTP 상태 코드 + +| 코드 | 의미 | +|------|------| +| 200 | 성공 | +| 400 | 잘못된 요청 | +| 401 | 인증 실패 (토큰 만료/무효) | +| 403 | 권한 없음 | +| 404 | 리소스 없음 | +| 500 | 서버 오류 | + +### 5.2 토큰 갱신 + +```typescript +import { fetchAuthSession } from 'aws-amplify/auth'; + +api.interceptors.response.use( + (response) => response, + async (error) => { + if (error.response?.status === 401) { + // 토큰 갱신 시도 + try { + const session = await fetchAuthSession({ forceRefresh: true }); + const newToken = session.tokens?.idToken?.toString(); + + // 원래 요청 재시도 + error.config.headers.Authorization = newToken; + return api.request(error.config); + } catch (refreshError) { + // 로그아웃 처리 + window.location.href = '/login'; + } + } + return Promise.reject(error); + } +); +``` + +--- + +## 6. 타입 정의 + +```typescript +// types/api.ts + +// 사용자 +interface User { + userId: string; + email: string; + nickname: string; + level: 'BEGINNER' | 'INTERMEDIATE' | 'ADVANCED'; + profileUrl: string; + createdAt: string; +} + +// 단어 +interface Word { + wordId: string; + word: string; + meaning: string; + pronunciation: string; + partOfSpeech: string; + level: string; + examples: string[]; +} + +// 사용자 단어 +interface UserWord { + wordId: string; + word: Word; + status: 'NEW' | 'LEARNING' | 'MASTERED'; + correctCount: number; + wrongCount: number; + lastStudiedAt: string; + tags: string[]; +} + +// 시험 +interface Test { + testId: string; + testType: 'MULTIPLE_CHOICE' | 'WRITING'; + wordCount: number; + questions: TestQuestion[]; + startedAt: string; +} + +interface TestQuestion { + wordId: string; + word: string; + options?: string[]; // 객관식일 경우 +} + +interface TestResult { + testId: string; + score: number; + correctCount: number; + totalCount: number; + answers: TestAnswer[]; + completedAt: string; +} + +// 채팅 +interface ChatRoom { + roomId: string; + name: string; + description: string; + ownerId: string; + participants: Participant[]; + maxParticipants: number; + createdAt: string; +} + +interface ChatMessage { + messageId: string; + roomId: string; + senderId: string; + senderNickname: string; + content: string; + messageType: 'TEXT' | 'IMAGE' | 'VOICE' | 'SYSTEM'; + createdAt: string; +} + +// 게임 +interface GameState { + roomId: string; + status: 'WAITING' | 'PLAYING' | 'FINISHED'; + currentRound: number; + totalRounds: number; + currentWord?: string; + drawerId?: string; + scores: Record; +} + +// 배지 +interface Badge { + badgeId: string; + name: string; + description: string; + iconUrl: string; + condition: string; +} + +interface EarnedBadge extends Badge { + earnedAt: string; +} +``` + +--- + +## 7. 주의사항 + +1. **인증 토큰**: 대부분의 API는 `Authorization` 헤더에 Cognito ID Token이 필요합니다. + +2. **CORS**: 백엔드에서 `*` origin을 허용하므로 로컬 개발 시 별도 설정 불필요합니다. + +3. **WebSocket 연결**: 연결 시 쿼리 파라미터로 `token`을 전달해야 합니다. + +4. **페이지네이션**: 목록 API는 `limit`과 `lastKey`를 사용합니다. + +5. **파일 업로드**: Base64 인코딩하여 전송합니다. + +--- + +## 8. 연락처 + +문의사항이 있으시면 백엔드 팀에 연락해주세요. From 684d986accf9a3b4e8fd446fc71cdae0f00fbfa2 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 11:16:09 +0900 Subject: [PATCH 210/528] Revert "docs: add frontend integration guide" This reverts commit a398a26f5d29ad0d516bc7b2ffa2fe5eb0491a17. --- docs/FRONTEND_INTEGRATION_GUIDE.md | 715 ----------------------------- 1 file changed, 715 deletions(-) delete mode 100644 docs/FRONTEND_INTEGRATION_GUIDE.md diff --git a/docs/FRONTEND_INTEGRATION_GUIDE.md b/docs/FRONTEND_INTEGRATION_GUIDE.md deleted file mode 100644 index f2b765a7..00000000 --- a/docs/FRONTEND_INTEGRATION_GUIDE.md +++ /dev/null @@ -1,715 +0,0 @@ -# Frontend Integration Guide - -영어 학습 플랫폼 백엔드 API 연동 가이드 - -## 1. 환경 설정 - -### 1.1 엔드포인트 정보 - -| 서비스 | URL | -|--------|-----| -| REST API | `https://gc8l9ijhzc.execute-api.ap-northeast-2.amazonaws.com/dev` | -| Chat WebSocket | `wss://t378dif43l.execute-api.ap-northeast-2.amazonaws.com/dev` | -| Grammar WebSocket | `wss://ltrccmteo8.execute-api.ap-northeast-2.amazonaws.com/dev` | - -### 1.2 Cognito 설정 - -| 항목 | 값 | -|------|-----| -| User Pool ID | `ap-northeast-2_ezDwzFCzR` | -| Client ID | `4ns077jcr1pkue2vvisr6qdpu5` | -| Region | `ap-northeast-2` | - -### 1.3 환경변수 예시 (.env) - -```env -VITE_API_URL=https://gc8l9ijhzc.execute-api.ap-northeast-2.amazonaws.com/dev -VITE_WS_URL=wss://t378dif43l.execute-api.ap-northeast-2.amazonaws.com/dev -VITE_GRAMMAR_WS_URL=wss://ltrccmteo8.execute-api.ap-northeast-2.amazonaws.com/dev -VITE_COGNITO_USER_POOL_ID=ap-northeast-2_ezDwzFCzR -VITE_COGNITO_CLIENT_ID=4ns077jcr1pkue2vvisr6qdpu5 -VITE_COGNITO_REGION=ap-northeast-2 -``` - ---- - -## 2. 인증 (Cognito) - -### 2.1 AWS Amplify 설정 - -```typescript -// src/config/amplify.ts -import { Amplify } from 'aws-amplify'; - -Amplify.configure({ - Auth: { - Cognito: { - userPoolId: import.meta.env.VITE_COGNITO_USER_POOL_ID, - userPoolClientId: import.meta.env.VITE_COGNITO_CLIENT_ID, - signUpVerificationMethod: 'code', - } - } -}); -``` - -### 2.2 회원가입 - -```typescript -import { signUp, confirmSignUp } from 'aws-amplify/auth'; - -// 1. 회원가입 요청 -const { isSignUpComplete, userId, nextStep } = await signUp({ - username: email, - password: password, - options: { - userAttributes: { - email: email, - } - } -}); - -// 2. 이메일 인증 코드 확인 -await confirmSignUp({ - username: email, - confirmationCode: code -}); -``` - -### 2.3 로그인 - -```typescript -import { signIn, fetchAuthSession } from 'aws-amplify/auth'; - -// 로그인 -const { isSignedIn, nextStep } = await signIn({ - username: email, - password: password -}); - -// 토큰 가져오기 -const session = await fetchAuthSession(); -const idToken = session.tokens?.idToken?.toString(); -``` - -### 2.4 API 요청 시 토큰 사용 - -```typescript -// axios 인터셉터 설정 -import axios from 'axios'; -import { fetchAuthSession } from 'aws-amplify/auth'; - -const api = axios.create({ - baseURL: import.meta.env.VITE_API_URL -}); - -api.interceptors.request.use(async (config) => { - const session = await fetchAuthSession(); - const token = session.tokens?.idToken?.toString(); - if (token) { - config.headers.Authorization = token; - } - return config; -}); -``` - ---- - -## 3. API 엔드포인트 - -### 3.1 공통 응답 형식 - -```typescript -interface ApiResponse { - statusCode: number; - body: { - success: boolean; - message: string; - data: T; - } -} -``` - -### 3.2 사용자 프로필 API - -| Method | Endpoint | Auth | Description | -|--------|----------|------|-------------| -| GET | `/users/profile/me` | Required | 내 프로필 조회 | -| PUT | `/users/profile/me` | Required | 내 프로필 수정 | -| POST | `/users/profile/me/image` | Required | 프로필 이미지 업로드 | - -```typescript -// 프로필 조회 -const response = await api.get('/users/profile/me'); - -// 프로필 수정 -await api.put('/users/profile/me', { - nickname: 'newNickname', - level: 'INTERMEDIATE' -}); - -// 프로필 이미지 업로드 (Base64) -await api.post('/users/profile/me/image', { - imageData: base64ImageData, - contentType: 'image/png' -}); -``` - ---- - -### 3.3 단어 API (공개) - -| Method | Endpoint | Auth | Description | -|--------|----------|------|-------------| -| GET | `/vocab/words` | None | 단어 목록 조회 | -| GET | `/vocab/words/{wordId}` | None | 단어 상세 조회 | -| GET | `/vocab/words/search?keyword=xxx` | None | 단어 검색 | -| POST | `/vocab/words/batch/get` | None | 단어 일괄 조회 | - -```typescript -// 단어 목록 조회 -const words = await api.get('/vocab/words', { - params: { level: 'BEGINNER', limit: 20 } -}); - -// 단어 검색 -const results = await api.get('/vocab/words/search', { - params: { keyword: 'apple' } -}); - -// 단어 일괄 조회 -const batchWords = await api.post('/vocab/words/batch/get', { - wordIds: ['word1', 'word2', 'word3'] -}); -``` - ---- - -### 3.4 사용자 단어 학습 API - -| Method | Endpoint | Auth | Description | -|--------|----------|------|-------------| -| GET | `/vocab/user-words` | Required | 내 단어 목록 | -| GET | `/vocab/user-words/{wordId}` | Required | 내 단어 상세 | -| PUT | `/vocab/user-words/{wordId}` | Required | 학습 상태 업데이트 | -| PUT | `/vocab/user-words/{wordId}/status` | Required | 상태 변경 (LEARNING/MASTERED) | -| PUT | `/vocab/user-words/{wordId}/tag` | Required | 태그 추가 | -| GET | `/vocab/wrong-answers` | Required | 오답 노트 | - -```typescript -// 내 단어 목록 -const myWords = await api.get('/vocab/user-words', { - params: { status: 'LEARNING' } -}); - -// 단어 마스터 처리 -await api.put('/vocab/user-words/word123/status', { - status: 'MASTERED' -}); - -// 오답 노트 -const wrongAnswers = await api.get('/vocab/wrong-answers'); -``` - ---- - -### 3.5 일일 학습 API - -| Method | Endpoint | Auth | Description | -|--------|----------|------|-------------| -| GET | `/vocab/daily` | Required | 오늘의 학습 단어 | -| POST | `/vocab/daily/words/{wordId}/learned` | Required | 학습 완료 표시 | - -```typescript -// 오늘의 단어 가져오기 -const dailyWords = await api.get('/vocab/daily'); - -// 단어 학습 완료 -await api.post('/vocab/daily/words/word123/learned'); -``` - ---- - -### 3.6 단어 그룹 API - -| Method | Endpoint | Auth | Description | -|--------|----------|------|-------------| -| GET | `/vocab/groups` | Required | 내 그룹 목록 | -| POST | `/vocab/groups` | Required | 그룹 생성 | -| GET | `/vocab/groups/{groupId}` | Required | 그룹 상세 | -| PUT | `/vocab/groups/{groupId}` | Required | 그룹 수정 | -| DELETE | `/vocab/groups/{groupId}` | Required | 그룹 삭제 | -| POST | `/vocab/groups/{groupId}/words/{wordId}` | Required | 그룹에 단어 추가 | -| DELETE | `/vocab/groups/{groupId}/words/{wordId}` | Required | 그룹에서 단어 제거 | - -```typescript -// 그룹 생성 -await api.post('/vocab/groups', { - name: '토익 필수 단어', - description: '토익 시험 대비 단어장' -}); - -// 그룹에 단어 추가 -await api.post('/vocab/groups/group123/words/word456'); -``` - ---- - -### 3.7 시험 API - -| Method | Endpoint | Auth | Description | -|--------|----------|------|-------------| -| POST | `/vocab/test/start` | Required | 시험 시작 | -| POST | `/vocab/test/submit` | Required | 답안 제출 | -| GET | `/vocab/test/results` | Required | 시험 결과 목록 | -| GET | `/vocab/test/results/{testId}` | Required | 시험 상세 결과 | -| GET | `/vocab/test/tested-words` | Required | 시험 본 단어 목록 | - -```typescript -// 시험 시작 -const test = await api.post('/vocab/test/start', { - wordCount: 10, - testType: 'MULTIPLE_CHOICE', // MULTIPLE_CHOICE, WRITING - source: 'DAILY' // DAILY, GROUP, WRONG_ANSWERS -}); - -// 답안 제출 -const result = await api.post('/vocab/test/submit', { - testId: 'test123', - answers: [ - { wordId: 'word1', answer: 'apple' }, - { wordId: 'word2', answer: 'banana' } - ] -}); - -// 시험 결과 조회 -const results = await api.get('/vocab/test/results'); -``` - ---- - -### 3.8 통계 API - -| Method | Endpoint | Auth | Description | -|--------|----------|------|-------------| -| GET | `/vocab/stats` | Required | 전체 학습 통계 | -| GET | `/vocab/stats/daily` | Required | 일별 통계 | -| GET | `/vocab/stats/weakness` | Required | 취약점 분석 | -| GET | `/stats/daily` | Required | 일간 상세 통계 | -| GET | `/stats/weekly` | Required | 주간 통계 | -| GET | `/stats/monthly` | Required | 월간 통계 | -| GET | `/stats/total` | Required | 전체 통계 | -| GET | `/stats/history` | Required | 통계 히스토리 | - -```typescript -// 전체 통계 -const stats = await api.get('/vocab/stats'); - -// 취약점 분석 -const weakness = await api.get('/vocab/stats/weakness'); - -// 주간 통계 -const weeklyStats = await api.get('/stats/weekly'); -``` - ---- - -### 3.9 배지 API - -| Method | Endpoint | Auth | Description | -|--------|----------|------|-------------| -| GET | `/badges` | Required | 전체 배지 목록 | -| GET | `/badges/earned` | Required | 획득한 배지 | - -```typescript -// 전체 배지 -const allBadges = await api.get('/badges'); - -// 내 배지 -const myBadges = await api.get('/badges/earned'); -``` - ---- - -### 3.10 음성 합성 API - -| Method | Endpoint | Auth | Description | -|--------|----------|------|-------------| -| POST | `/vocab/voice/synthesize` | None | 단어 발음 생성 | -| POST | `/chat/voice/synthesize` | Required | 문장 발음 생성 | - -```typescript -// 단어 발음 (공개) -const audio = await api.post('/vocab/voice/synthesize', { - text: 'apple', - voiceId: 'Joanna' // 또는 Matthew, Amy 등 -}); -// 응답: { audioUrl: 's3://...' } -``` - ---- - -### 3.11 채팅방 API - -| Method | Endpoint | Auth | Description | -|--------|----------|------|-------------| -| POST | `/chat/rooms` | Required | 채팅방 생성 | -| GET | `/chat/rooms` | Required | 채팅방 목록 | -| GET | `/chat/rooms/{roomId}` | Required | 채팅방 상세 | -| DELETE | `/chat/rooms/{roomId}` | Required | 채팅방 삭제 | -| POST | `/chat/rooms/{roomId}/join` | Required | 채팅방 참여 | -| POST | `/chat/rooms/{roomId}/leave` | Required | 채팅방 퇴장 | - -```typescript -// 채팅방 생성 -const room = await api.post('/chat/rooms', { - name: '영어 스터디', - description: '함께 영어 공부해요', - maxParticipants: 10 -}); - -// 채팅방 참여 -await api.post(`/chat/rooms/${roomId}/join`); -``` - ---- - -### 3.12 채팅 메시지 API - -| Method | Endpoint | Auth | Description | -|--------|----------|------|-------------| -| POST | `/chat/rooms/{roomId}/messages` | Required | 메시지 전송 | -| GET | `/chat/rooms/{roomId}/messages` | Required | 메시지 목록 | -| GET | `/chat/rooms/{roomId}/messages/{messageId}` | Required | 메시지 상세 | - -```typescript -// 메시지 전송 -await api.post(`/chat/rooms/${roomId}/messages`, { - content: 'Hello everyone!', - messageType: 'TEXT' // TEXT, IMAGE, VOICE -}); - -// 메시지 목록 (페이지네이션) -const messages = await api.get(`/chat/rooms/${roomId}/messages`, { - params: { limit: 50, lastKey: 'xxx' } -}); -``` - ---- - -### 3.13 게임 API (Catch Mind) - -| Method | Endpoint | Auth | Description | -|--------|----------|------|-------------| -| POST | `/chat/rooms/{roomId}/game/start` | Required | 게임 시작 | -| POST | `/chat/rooms/{roomId}/game/stop` | Required | 게임 종료 | -| GET | `/chat/rooms/{roomId}/game/status` | Required | 게임 상태 | -| GET | `/chat/rooms/{roomId}/game/scores` | Required | 점수 조회 | - -```typescript -// 게임 시작 -const game = await api.post(`/chat/rooms/${roomId}/game/start`, { - roundCount: 5, - roundTimeSeconds: 60 -}); - -// 게임 상태 확인 -const status = await api.get(`/chat/rooms/${roomId}/game/status`); -``` - ---- - -### 3.14 문법 체크 API - -| Method | Endpoint | Auth | Description | -|--------|----------|------|-------------| -| POST | `/grammar/check` | Required | 문법 체크 | -| POST | `/grammar/conversation` | Required | AI 대화 | -| GET | `/grammar/sessions` | Required | 세션 목록 | -| GET | `/grammar/sessions/{sessionId}` | Required | 세션 상세 | -| DELETE | `/grammar/sessions/{sessionId}` | Required | 세션 삭제 | - -```typescript -// 문법 체크 -const result = await api.post('/grammar/check', { - text: 'I goes to school yesterday.' -}); -// 응답: 교정된 문장, 오류 설명, 학습 팁 - -// AI 대화 -const conversation = await api.post('/grammar/conversation', { - sessionId: 'session123', // 없으면 새 세션 생성 - message: 'How do I use present perfect tense?' -}); -``` - ---- - -## 4. WebSocket 연동 - -### 4.1 채팅 WebSocket - -```typescript -// 연결 -const ws = new WebSocket( - `${import.meta.env.VITE_WS_URL}?roomId=${roomId}&token=${idToken}` -); - -ws.onopen = () => { - console.log('Connected to chat'); -}; - -ws.onmessage = (event) => { - const data = JSON.parse(event.data); - - switch (data.type) { - case 'MESSAGE': - // 새 메시지 - handleNewMessage(data.payload); - break; - case 'USER_JOINED': - // 사용자 입장 - handleUserJoined(data.payload); - break; - case 'USER_LEFT': - // 사용자 퇴장 - handleUserLeft(data.payload); - break; - case 'GAME_START': - // 게임 시작 - handleGameStart(data.payload); - break; - case 'ROUND_START': - // 라운드 시작 - handleRoundStart(data.payload); - break; - case 'CORRECT_ANSWER': - // 정답 - handleCorrectAnswer(data.payload); - break; - case 'ROUND_END': - // 라운드 종료 - handleRoundEnd(data.payload); - break; - case 'GAME_END': - // 게임 종료 - handleGameEnd(data.payload); - break; - } -}; - -// 메시지 전송 -ws.send(JSON.stringify({ - action: 'sendMessage', - roomId: roomId, - content: 'Hello!', - messageType: 'TEXT' -})); -``` - -### 4.2 문법 스트리밍 WebSocket - -```typescript -// 연결 (JWT 토큰 포함) -const grammarWs = new WebSocket( - `${import.meta.env.VITE_GRAMMAR_WS_URL}?token=${idToken}` -); - -grammarWs.onmessage = (event) => { - const data = JSON.parse(event.data); - - switch (data.type) { - case 'STREAM_START': - // 스트리밍 시작 - break; - case 'STREAM_CHUNK': - // AI 응답 청크 - appendToResponse(data.content); - break; - case 'STREAM_END': - // 스트리밍 완료 - break; - case 'ERROR': - // 에러 - handleError(data.message); - break; - } -}; - -// 문법 스트리밍 요청 -grammarWs.send(JSON.stringify({ - action: 'grammarStreaming', - sessionId: 'session123', - message: 'Explain the difference between "affect" and "effect"' -})); -``` - ---- - -## 5. 에러 처리 - -### 5.1 HTTP 상태 코드 - -| 코드 | 의미 | -|------|------| -| 200 | 성공 | -| 400 | 잘못된 요청 | -| 401 | 인증 실패 (토큰 만료/무효) | -| 403 | 권한 없음 | -| 404 | 리소스 없음 | -| 500 | 서버 오류 | - -### 5.2 토큰 갱신 - -```typescript -import { fetchAuthSession } from 'aws-amplify/auth'; - -api.interceptors.response.use( - (response) => response, - async (error) => { - if (error.response?.status === 401) { - // 토큰 갱신 시도 - try { - const session = await fetchAuthSession({ forceRefresh: true }); - const newToken = session.tokens?.idToken?.toString(); - - // 원래 요청 재시도 - error.config.headers.Authorization = newToken; - return api.request(error.config); - } catch (refreshError) { - // 로그아웃 처리 - window.location.href = '/login'; - } - } - return Promise.reject(error); - } -); -``` - ---- - -## 6. 타입 정의 - -```typescript -// types/api.ts - -// 사용자 -interface User { - userId: string; - email: string; - nickname: string; - level: 'BEGINNER' | 'INTERMEDIATE' | 'ADVANCED'; - profileUrl: string; - createdAt: string; -} - -// 단어 -interface Word { - wordId: string; - word: string; - meaning: string; - pronunciation: string; - partOfSpeech: string; - level: string; - examples: string[]; -} - -// 사용자 단어 -interface UserWord { - wordId: string; - word: Word; - status: 'NEW' | 'LEARNING' | 'MASTERED'; - correctCount: number; - wrongCount: number; - lastStudiedAt: string; - tags: string[]; -} - -// 시험 -interface Test { - testId: string; - testType: 'MULTIPLE_CHOICE' | 'WRITING'; - wordCount: number; - questions: TestQuestion[]; - startedAt: string; -} - -interface TestQuestion { - wordId: string; - word: string; - options?: string[]; // 객관식일 경우 -} - -interface TestResult { - testId: string; - score: number; - correctCount: number; - totalCount: number; - answers: TestAnswer[]; - completedAt: string; -} - -// 채팅 -interface ChatRoom { - roomId: string; - name: string; - description: string; - ownerId: string; - participants: Participant[]; - maxParticipants: number; - createdAt: string; -} - -interface ChatMessage { - messageId: string; - roomId: string; - senderId: string; - senderNickname: string; - content: string; - messageType: 'TEXT' | 'IMAGE' | 'VOICE' | 'SYSTEM'; - createdAt: string; -} - -// 게임 -interface GameState { - roomId: string; - status: 'WAITING' | 'PLAYING' | 'FINISHED'; - currentRound: number; - totalRounds: number; - currentWord?: string; - drawerId?: string; - scores: Record; -} - -// 배지 -interface Badge { - badgeId: string; - name: string; - description: string; - iconUrl: string; - condition: string; -} - -interface EarnedBadge extends Badge { - earnedAt: string; -} -``` - ---- - -## 7. 주의사항 - -1. **인증 토큰**: 대부분의 API는 `Authorization` 헤더에 Cognito ID Token이 필요합니다. - -2. **CORS**: 백엔드에서 `*` origin을 허용하므로 로컬 개발 시 별도 설정 불필요합니다. - -3. **WebSocket 연결**: 연결 시 쿼리 파라미터로 `token`을 전달해야 합니다. - -4. **페이지네이션**: 목록 API는 `limit`과 `lastKey`를 사용합니다. - -5. **파일 업로드**: Base64 인코딩하여 전송합니다. - ---- - -## 8. 연락처 - -문의사항이 있으시면 백엔드 팀에 연락해주세요. From 97907b0fabefb2945bb605564714978aeb39e623 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 11:36:16 +0900 Subject: [PATCH 211/528] fix: add feedback field to GrammarMessage model - Add feedback and isCorrect fields to GrammarMessage - Fields now persisted to DynamoDB for session retrieval --- .../serverless/domain/grammar/model/GrammarMessage.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/model/GrammarMessage.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/model/GrammarMessage.java index a0dfee2d..15d07812 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/model/GrammarMessage.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/model/GrammarMessage.java @@ -23,6 +23,8 @@ public class GrammarMessage { private String correctedContent; private String errorsJson; private Integer grammarScore; + private String feedback; + private Boolean isCorrect; private String createdAt; private Long ttl; From 0dc65e041e426627e8881709f011e857f3f38df0 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 11:36:39 +0900 Subject: [PATCH 212/528] fix: save feedback and isCorrect in saveUserMessage - Include grammarCheck.getFeedback() when saving user message - Include grammarCheck.getIsCorrect() for grammar correctness --- .../domain/grammar/service/GrammarConversationService.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/service/GrammarConversationService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/service/GrammarConversationService.java index 4917da92..8f779620 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/service/GrammarConversationService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/service/GrammarConversationService.java @@ -162,6 +162,8 @@ private void saveUserMessage(GrammarSession session, String content, GrammarChec .correctedContent(grammarCheck != null ? grammarCheck.getCorrectedSentence() : null) .errorsJson(grammarCheck != null ? gson.toJson(grammarCheck.getErrors()) : null) .grammarScore(grammarCheck != null ? grammarCheck.getScore() : null) + .feedback(grammarCheck != null ? grammarCheck.getFeedback() : null) + .isCorrect(grammarCheck != null ? grammarCheck.getIsCorrect() : null) .createdAt(now) .ttl(ttl) .build(); From c0ee1c7ca356e5ff10f6282a5ffdc5e2ea340098 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 11:48:21 +0900 Subject: [PATCH 213/528] feat: add S3PresignUtil for presigned URL generation - Create utility for generating presigned GET URLs - 24-hour URL validity with caching - Add getBadgeImageUrl() helper method --- .../serverless/common/util/S3PresignUtil.java | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/S3PresignUtil.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/S3PresignUtil.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/S3PresignUtil.java new file mode 100644 index 00000000..a75bffd9 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/S3PresignUtil.java @@ -0,0 +1,75 @@ +package com.mzc.secondproject.serverless.common.util; + +import com.mzc.secondproject.serverless.common.config.AwsClients; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; +import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest; + +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * S3 Presigned URL 유틸리티 + */ +public class S3PresignUtil { + + private static final S3Presigner presigner = AwsClients.s3Presigner(); + private static final String BUCKET_NAME = System.getenv("BUCKET_NAME"); + private static final Duration DEFAULT_DURATION = Duration.ofHours(24); + + // 캐시 (키: S3 key, 값: presigned URL, 만료시간) + private static final Map urlCache = new ConcurrentHashMap<>(); + + /** + * S3 객체에 대한 presigned GET URL 생성 (24시간 유효, 캐시 사용) + */ + public static String getPresignedUrl(String key) { + return getPresignedUrl(key, DEFAULT_DURATION); + } + + /** + * S3 객체에 대한 presigned GET URL 생성 (캐시 사용) + */ + public static String getPresignedUrl(String key, Duration duration) { + // 캐시 확인 + CachedUrl cached = urlCache.get(key); + if (cached != null && !cached.isExpired()) { + return cached.url; + } + + // 새 presigned URL 생성 + GetObjectRequest getObjectRequest = GetObjectRequest.builder() + .bucket(BUCKET_NAME) + .key(key) + .build(); + + GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() + .signatureDuration(duration) + .getObjectRequest(getObjectRequest) + .build(); + + PresignedGetObjectRequest presignedRequest = presigner.presignGetObject(presignRequest); + String url = presignedRequest.url().toString(); + + // 캐시에 저장 (만료 시간 1시간 전까지 유효) + long expiresAt = System.currentTimeMillis() + duration.toMillis() - Duration.ofHours(1).toMillis(); + urlCache.put(key, new CachedUrl(url, expiresAt)); + + return url; + } + + /** + * 배지 이미지 presigned URL 생성 + */ + public static String getBadgeImageUrl(String imageFile) { + return getPresignedUrl("badges/" + imageFile); + } + + private record CachedUrl(String url, long expiresAt) { + boolean isExpired() { + return System.currentTimeMillis() > expiresAt; + } + } +} From bdf7651b9584b84b18fd9c3556e29d487a3592c7 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 11:48:27 +0900 Subject: [PATCH 214/528] feat: add getImageFile() method to BadgeType - Expose imageFile for presigned URL generation --- .../serverless/domain/badge/enums/BadgeType.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/enums/BadgeType.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/enums/BadgeType.java index ca56e6b1..8cad9218 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/enums/BadgeType.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/enums/BadgeType.java @@ -69,6 +69,10 @@ public String getDescription() { public String getImageUrl() { return BASE_URL + imageFile; } + + public String getImageFile() { + return imageFile; + } public String getCategory() { return category; From 1b81ae5133177a5e0a058293c6296a88cf18b868 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 11:48:38 +0900 Subject: [PATCH 215/528] feat: use presigned URLs for badge images - Replace static S3 URL with presigned URL - Apply to both getAllBadgesWithStatus() and createBadge() --- .../serverless/domain/badge/service/BadgeService.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/service/BadgeService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/service/BadgeService.java index ac1c979b..b0afbcfa 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/service/BadgeService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/service/BadgeService.java @@ -1,5 +1,6 @@ package com.mzc.secondproject.serverless.domain.badge.service; +import com.mzc.secondproject.serverless.common.util.S3PresignUtil; import com.mzc.secondproject.serverless.domain.badge.constants.BadgeKey; import com.mzc.secondproject.serverless.domain.badge.enums.BadgeType; import com.mzc.secondproject.serverless.domain.badge.model.UserBadge; @@ -53,7 +54,7 @@ public List getAllBadgesWithStatus(String userId) { type.name(), type.getName(), type.getDescription(), - type.getImageUrl(), + S3PresignUtil.getBadgeImageUrl(type.getImageFile()), type.getCategory(), type.getThreshold(), currentProgress, @@ -121,7 +122,7 @@ private UserBadge createBadge(String userId, BadgeType type, String now) { .badgeType(type.name()) .name(type.getName()) .description(type.getDescription()) - .imageUrl(type.getImageUrl()) + .imageUrl(S3PresignUtil.getBadgeImageUrl(type.getImageFile())) .category(type.getCategory()) .threshold(type.getThreshold()) .earnedAt(now) From ea78962f3a9a5a8704e8625704b75ae228e34fab Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 12:52:55 +0900 Subject: [PATCH 216/528] docs: Add midterm progress report for the backend - Created `MIDTERM-REPORT.md` documenting key features, architecture, and technical achievements - Includes detailed explanations of domains (Vocabulary, Chatting, Grammar, Badge, Stats) - Highlights patterns (CQRS, State, Factory), Single Table Design, AI integration, and event-driven architecture - Provides visuals with architecture diagrams, sequence diagrams, and state transitions - Organized project structure and common module design documented for clarity No related issue. --- .../chatting/service/CommandService.java | 10 ++---- .../domain/chatting/service/GameService.java | 36 +++++++++++++++++-- .../vocabulary/handler/UserWordHandler.java | 4 +-- ServerlessFunction/template.yaml | 7 ++-- 4 files changed, 43 insertions(+), 14 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/CommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/CommandService.java index bd209f37..7b1d53b1 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/CommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/CommandService.java @@ -60,7 +60,7 @@ public Optional processCommand(String content, String roomId, Str } /** - * /member - 현재 접속자 목록 조회 + * /member - 현재 접속자 수 조회 */ private CommandResult handleMemberCommand(String roomId) { List connections = connectionRepository.findByRoomId(roomId); @@ -69,11 +69,7 @@ private CommandResult handleMemberCommand(String roomId) { return CommandResult.success(MessageType.SYSTEM_COMMAND, "현재 접속자가 없습니다."); } - String memberList = connections.stream() - .map(Connection::getUserId) - .collect(Collectors.joining(", ")); - - String message = String.format("현재 접속자 (%d명): %s", connections.size(), memberList); + String message = String.format("현재 접속자: %d명", connections.size()); return CommandResult.success(MessageType.SYSTEM_COMMAND, message, connections.size()); } @@ -155,7 +151,7 @@ private CommandResult handleHintCommand(String roomId, String userId) { private CommandResult handleHelpCommand() { String helpMessage = """ 📖 사용 가능한 명령어: - /member - 현재 접속자 목록 + /member - 현재 접속자 수 /start - 게임 시작 (2명 이상) /stop - 게임 중단 /score - 현재 점수 보기 diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java index 8a908bea..e38f9fc0 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java @@ -312,10 +312,20 @@ public CommandResult endRound(ChatRoom room, String reason) { return finishGame(room, "COMPLETED"); } - // 다음 라운드 준비 + // 현재 접속 중인 사용자 목록 조회 + List connections = connectionRepository.findByRoomId(roomId); + Set connectedUserIds = connections.stream() + .map(Connection::getUserId) + .collect(Collectors.toSet()); + + // 접속자가 2명 미만이면 게임 종료 + if (connectedUserIds.size() < 2) { + return finishGame(room, "NOT_ENOUGH_PLAYERS"); + } + + // 다음 라운드 준비 - 접속 중인 사용자 중에서만 출제자 선택 int nextRound = currentRound + 1; - int drawerIndex = (nextRound - 1) % room.getDrawerOrder().size(); - String nextDrawer = room.getDrawerOrder().get(drawerIndex); + String nextDrawer = selectNextDrawer(room.getDrawerOrder(), connectedUserIds, nextRound); // 다음 단어 추출 String level = room.getLevel() != null ? room.getLevel() : "beginner"; @@ -420,6 +430,26 @@ private CommandResult finishGame(ChatRoom room, String reason) { return CommandResult.success(MessageType.GAME_END, sb.toString(), room.getScores()); } + /** + * 접속 중인 사용자 중에서 다음 출제자 선택 + */ + private String selectNextDrawer(List drawerOrder, Set connectedUserIds, int roundNumber) { + // 원래 순서에서 시작 인덱스 계산 + int startIndex = (roundNumber - 1) % drawerOrder.size(); + + // 접속 중인 사용자를 찾을 때까지 순회 + for (int i = 0; i < drawerOrder.size(); i++) { + int index = (startIndex + i) % drawerOrder.size(); + String candidate = drawerOrder.get(index); + if (connectedUserIds.contains(candidate)) { + return candidate; + } + } + + // 원래 순서에 있는 사람이 모두 나갔으면, 접속 중인 아무나 선택 + return connectedUserIds.iterator().next(); + } + /** * 랜덤 단어 추출 */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java index 222c3354..ac4f91c1 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java @@ -42,8 +42,8 @@ private HandlerRouter initRouter() { Route.getAuth("/wrong-answers", this::getWrongAnswers), Route.getAuth("/user-words", this::getUserWords), Route.getAuth("/user-words/{wordId}", this::getUserWord), - Route.putAuth("/user-words/{wordId}/tag", this::updateUserWordTag), - Route.putAuth("/user-words/{wordId}/status", this::updateWordStatus), + Route.patchAuth("/user-words/{wordId}/tag", this::updateUserWordTag), + Route.patchAuth("/user-words/{wordId}/status", this::updateWordStatus), Route.putAuth("/user-words/{wordId}", this::updateUserWord) ); } diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 4fd81a9e..87f4ccce 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -15,6 +15,7 @@ Globals: USER_TABLE_NAME: !Ref UserTable CHAT_TABLE_NAME: !Ref ChatTable VOCAB_TABLE_NAME: !Ref VocabTable + BUCKET_NAME: group2-englishstudy CHAT_BUCKET_NAME: group2-englishstudy VOCAB_BUCKET_NAME: group2-englishstudy PROFILE_BUCKET_NAME: group2-englishstudy @@ -662,7 +663,7 @@ Resources: Properties: RestApiId: !Ref MainApi Path: /vocab/user-words/{wordId}/tag - Method: PUT + Method: PATCH Auth: Authorizer: CognitoAuthorizer UpdateUserWordStatus: @@ -670,7 +671,7 @@ Resources: Properties: RestApiId: !Ref MainApi Path: /vocab/user-words/{wordId}/status - Method: PUT + Method: PATCH Auth: Authorizer: CognitoAuthorizer @@ -998,6 +999,8 @@ Resources: Policies: - DynamoDBCrudPolicy: TableName: !Ref VocabTable + - S3ReadPolicy: + BucketName: group2-englishstudy Events: GetAllBadges: Type: Api From 4668eb7b72144e3b2a05dffca052de9bd77a0812 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 13:52:55 +0900 Subject: [PATCH 217/528] feat: include isCompleted in stats history with DailyStudy integration - Fetch and include isCompleted status from DailyStudy for each period in stats history - Use consistent reads in DailyStudyRepository for accurate data retrieval - Refactor response to enhance data richness (e.g., successRate, learned words) --- .../stats/handler/UserStatsHandler.java | 43 ++++++++++++++----- .../repository/DailyStudyRepository.java | 5 ++- 2 files changed, 36 insertions(+), 12 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java index f488bafb..df347c2f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java @@ -10,15 +10,15 @@ import com.mzc.secondproject.serverless.common.util.ResponseGenerator; import com.mzc.secondproject.serverless.domain.stats.model.UserStats; import com.mzc.secondproject.serverless.domain.stats.repository.UserStatsRepository; +import com.mzc.secondproject.serverless.domain.vocabulary.model.DailyStudy; +import com.mzc.secondproject.serverless.domain.vocabulary.repository.DailyStudyRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.time.LocalDate; import java.time.temporal.WeekFields; -import java.util.HashMap; -import java.util.Locale; -import java.util.Map; -import java.util.Optional; +import java.util.*; +import java.util.stream.Collectors; /** * 사용자 학습 통계 API Handler @@ -28,10 +28,12 @@ public class UserStatsHandler implements RequestHandler queryParams = request.getQueryStringParameters(); String cursor = queryParams != null ? queryParams.get("cursor") : null; - + int limit = 7; // 기본 7일 if (queryParams != null && queryParams.get("limit") != null) { limit = Math.min(Integer.parseInt(queryParams.get("limit")), 30); } - + PaginatedResult result = statsRepository.findRecentDailyStats(userId, limit, cursor); - + + // 각 날짜별 isCompleted 정보 조회 및 응답 구성 + List> historyWithCompletion = result.items().stream() + .map(stats -> { + Map item = new HashMap<>(); + item.put("period", stats.getPeriod()); + item.put("testsCompleted", stats.getTestsCompleted() != null ? stats.getTestsCompleted() : 0); + item.put("questionsAnswered", stats.getQuestionsAnswered() != null ? stats.getQuestionsAnswered() : 0); + item.put("correctAnswers", stats.getCorrectAnswers() != null ? stats.getCorrectAnswers() : 0); + item.put("incorrectAnswers", stats.getIncorrectAnswers() != null ? stats.getIncorrectAnswers() : 0); + item.put("successRate", calculateSuccessRate(stats)); + item.put("newWordsLearned", stats.getNewWordsLearned() != null ? stats.getNewWordsLearned() : 0); + item.put("wordsReviewed", stats.getWordsReviewed() != null ? stats.getWordsReviewed() : 0); + + // DailyStudy에서 isCompleted 조회 + Optional dailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, stats.getPeriod()); + item.put("isCompleted", dailyStudy.map(ds -> ds.getIsCompleted() != null && ds.getIsCompleted()).orElse(false)); + + return item; + }) + .collect(Collectors.toList()); + Map response = new HashMap<>(); - response.put("history", result.items()); + response.put("history", historyWithCompletion); response.put("nextCursor", result.nextCursor()); response.put("hasMore", result.hasMore()); - + return ResponseGenerator.ok("Stats history retrieved", response); } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/DailyStudyRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/DailyStudyRepository.java index e3601078..29c98a91 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/DailyStudyRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/DailyStudyRepository.java @@ -41,8 +41,9 @@ public Optional findByUserIdAndDate(String userId, String date) { .partitionValue("DAILY#" + userId) .sortValue("DATE#" + date) .build(); - - DailyStudy dailyStudy = table.getItem(key); + + // Strongly consistent read for accurate data after updates + DailyStudy dailyStudy = table.getItem(r -> r.key(key).consistentRead(true)); return Optional.ofNullable(dailyStudy); } From fb01c8ac1ac7ea58a813e4729112e35b3d3d81d9 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 14:24:30 +0900 Subject: [PATCH 218/528] fix: increase stats query limit to 100 days - Adjust maximum allowable limit for recent stats query from 30 to 100 days to support extended data range responses. --- .../serverless/domain/stats/handler/UserStatsHandler.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java index df347c2f..9ea508d3 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java @@ -124,7 +124,7 @@ private APIGatewayProxyResponseEvent getStatsHistory(APIGatewayProxyRequestEvent int limit = 7; // 기본 7일 if (queryParams != null && queryParams.get("limit") != null) { - limit = Math.min(Integer.parseInt(queryParams.get("limit")), 30); + limit = Math.min(Integer.parseInt(queryParams.get("limit")), 100); } PaginatedResult result = statsRepository.findRecentDailyStats(userId, limit, cursor); From e4bae7b14fe443e44825c5113a5c46cba72ea83e Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:28:57 +0900 Subject: [PATCH 219/528] =?UTF-8?q?docs:=20Vocabulary=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EC=84=B8=EB=B6=80=20=EB=B3=B4=EA=B3=A0=EC=84=9C=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../VOCABULARY-DOMAIN-REPORT.md | 504 ++++++++++++++++++ 1 file changed, 504 insertions(+) create mode 100644 docs/domain-reports/VOCABULARY-DOMAIN-REPORT.md diff --git a/docs/domain-reports/VOCABULARY-DOMAIN-REPORT.md b/docs/domain-reports/VOCABULARY-DOMAIN-REPORT.md new file mode 100644 index 00000000..7ee2c90e --- /dev/null +++ b/docs/domain-reports/VOCABULARY-DOMAIN-REPORT.md @@ -0,0 +1,504 @@ +# Vocabulary Domain 세부 보고서 + +## 1. 개요 + +Vocabulary 도메인은 AWS Lambda와 DynamoDB를 기반으로 한 영어 단어 학습 시스템입니다. SM-2 Spaced Repetition 알고리즘과 CQRS 패턴을 적용하여 과학적이고 효율적인 단어 +암기를 지원합니다. + +--- + +## 2. 전체 아키텍처 + +```mermaid +flowchart TB + subgraph Client["클라이언트"] + APP[Mobile/Web App] + end + + subgraph Gateway["API Gateway"] + REST[REST API
HTTP] + end + + subgraph Lambda["Lambda Handlers"] + direction TB + WORD[WordHandler] + USERWORD[UserWordHandler] + DAILY[DailyStudyHandler] + TEST[TestHandler] + GROUP[WordGroupHandler] + VOICE[VoiceHandler] + STATS[StatisticsHandler
SQS Consumer] + end + + subgraph Services["서비스 레이어 (CQRS)"] + direction TB + CMD[Command Services
쓰기 작업] + QUERY[Query Services
읽기 작업] + end + + subgraph External["외부 서비스"] + POLLY[AWS Polly
TTS] + SNS[AWS SNS] + SQS[AWS SQS] + S3[(S3
음성 캐시)] + end + + subgraph Storage["데이터 저장소"] + DDB[(DynamoDB)] + end + + APP --> REST + REST --> WORD & USERWORD & DAILY & TEST & GROUP & VOICE + WORD & USERWORD & DAILY & TEST & GROUP --> CMD & QUERY + CMD & QUERY --> DDB + VOICE --> POLLY --> S3 + TEST --> SNS --> SQS --> STATS + STATS --> DDB +``` + +--- + +## 3. 일일 학습 시스템 + +### 3.1 일일 학습 흐름 + +```mermaid +flowchart TB + subgraph DailyStudyFlow["일일 학습 흐름"] + START[GET /vocab/daily] --> CHECK{기존 학습
존재?} + CHECK -->|Yes| RETURN[기존 학습 반환] + CHECK -->|No| CREATE[새 학습 생성] + CREATE --> REVIEW["복습 단어 5개 선정
(nextReviewAt <= today)"] + REVIEW --> NEW["신규 단어 50개 선정
(미학습 + 해당 레벨)"] + NEW --> SAVE[DailyStudy 저장] + SAVE --> RETURN + RETURN --> LEARN[학습 진행] + LEARN --> MARK["POST .../learned
단어별 학습 완료"] + MARK --> PROGRESS{50개 완료?} + PROGRESS -->|No| LEARN + PROGRESS -->|Yes| COMPLETE["isCompleted = true
배지 체크"] + end +``` + +### 3.2 Daily Study API + +| Method | Endpoint | 설명 | +|--------|-------------------------------------|-----------------| +| GET | /vocab/daily | 오늘의 학습 단어 조회/생성 | +| POST | /vocab/daily/words/{wordId}/learned | 단어 학습 완료 처리 | + +### 3.3 응답 예시 + +```json +{ + "userId": "user123", + "date": "2026-01-16", + "newWordIds": [ + "word1", + "word2", + ... + ], + "reviewWordIds": [ + "word51", + "word52", + ... + ], + "learnedWordIds": [], + "totalWords": 55, + "learnedCount": 0, + "isCompleted": false, + "progress": { + "percentage": 0, + "learned": 0, + "total": 55 + } +} +``` + +--- + +## 4. SM-2 Spaced Repetition 알고리즘 + +### 4.1 학습 상태 전이 + +```mermaid +stateDiagram-v2 + [*] --> NEW: 단어 추가 + NEW --> LEARNING: 첫 학습 + LEARNING --> LEARNING: 오답 + LEARNING --> REVIEWING: 2회 연속 정답 + REVIEWING --> LEARNING: 오답 + REVIEWING --> MASTERED: 5회 연속 정답 + MASTERED --> LEARNING: 오답 + MASTERED --> MASTERED: 정답 유지 +``` + +### 4.2 상태별 로직 + +| 상태 | 조건 | 정답 시 | 오답 시 | +|---------------|-------------|-----------------------------|---------------------------| +| **NEW** | 신규 단어 | LEARNING, rep=1, interval=1 | LEARNING, easeFactor-=0.2 | +| **LEARNING** | rep < 2 | rep++, interval 계산 | rep=0, interval=1 | +| **REVIEWING** | 2 ≤ rep < 5 | rep++, interval 증가 | rep=0, LEARNING | +| **MASTERED** | rep ≥ 5 | interval 증가, 유지 | rep=0, REVIEWING | + +### 4.3 복습 간격 계산 + +```mermaid +flowchart LR + REP1["rep = 1
interval = 1일"] + REP2["rep = 2
interval = 6일"] + REP3["rep >= 3
interval = interval × easeFactor"] + REP1 --> REP2 --> REP3 +``` + +**핵심 변수:** + +- `repetitions`: 연속 정답 횟수 (0~∞) +- `interval`: 복습 간격 (일 단위) +- `easeFactor`: 난이도 계수 (1.3~2.5, 기본 2.5) +- `nextReviewAt`: 다음 복습 예정일 + +--- + +## 5. 테스트 시스템 + +### 5.1 테스트 흐름 + +```mermaid +sequenceDiagram + participant Client + participant Handler as TestHandler + participant Service as TestCommandService + participant DB as DynamoDB + participant SNS as AWS SNS + Client ->> Handler: POST /vocab/tests/start + Handler ->> Service: startTest(userId, testType) + Service ->> DB: 오늘의 학습 단어 조회 + Service ->> Service: 4지선다 문제 생성 + Service -->> Client: 문제 목록 반환 + Note over Client: 사용자 답변 입력 + Client ->> Handler: POST /vocab/tests/submit + Handler ->> Service: submitTest(answers) + Service ->> DB: 결과 저장 + Service ->> SNS: 결과 발행 (비동기) + Service -->> Client: 테스트 결과 + Note over SNS, DB: 비동기 통계 처리 + SNS ->> DB: 통계 업데이트 +``` + +### 5.2 문제 생성 알고리즘 + +```mermaid +flowchart TB + START[문제 생성 시작] --> WORDS[일일 학습 단어 로드] + WORDS --> GROUP[레벨별 그룹화] + GROUP --> LOOP[각 단어마다] + LOOP --> CORRECT["정답 = 해당 단어의
한국어 뜻"] + CORRECT --> DIST["오답 3개 선정
(동일 레벨 단어)"] + DIST --> SHUFFLE[4개 보기 셔플] + SHUFFLE --> NEXT{다음 단어?} + NEXT -->|Yes| LOOP + NEXT -->|No| RETURN[문제 목록 반환] +``` + +### 5.3 Test API + +| Method | Endpoint | 설명 | +|--------|-------------------------------|------------| +| POST | /vocab/tests/start | 테스트 시작 | +| POST | /vocab/tests/submit | 테스트 제출 | +| GET | /vocab/tests/results | 테스트 결과 목록 | +| GET | /vocab/tests/results/{testId} | 테스트 상세 결과 | +| GET | /vocab/tests/tested-words | 최근 테스트된 단어 | + +--- + +## 6. 단어 관리 시스템 + +### 6.1 Word API + +| Method | Endpoint | 설명 | +|--------|------------------------|----------------------------| +| GET | /vocab/words | 단어 목록 (level, category 필터) | +| POST | /vocab/words | 단어 등록 | +| GET | /vocab/words/{wordId} | 단어 상세 | +| PUT | /vocab/words/{wordId} | 단어 수정 | +| DELETE | /vocab/words/{wordId} | 단어 삭제 | +| GET | /vocab/words/search | 키워드 검색 | +| POST | /vocab/words/batch | 배치 등록 (최대 100개) | +| POST | /vocab/words/batch/get | 배치 조회 | + +### 6.2 User Word API + +| Method | Endpoint | 설명 | +|--------|-----------------------------------|-------------| +| GET | /vocab/user-words | 사용자 단어 목록 | +| GET | /vocab/user-words/{wordId} | 사용자 단어 상세 | +| PUT | /vocab/user-words/{wordId} | 정답/오답 기록 | +| PATCH | /vocab/user-words/{wordId}/tag | 북마크, 난이도 설정 | +| PATCH | /vocab/user-words/{wordId}/status | 상태 수동 변경 | +| GET | /vocab/wrong-answers | 오답 단어 목록 | + +### 6.3 Word Group API + +| Method | Endpoint | 설명 | +|--------|----------------------------------------|--------| +| POST | /vocab/groups | 단어장 생성 | +| GET | /vocab/groups | 단어장 목록 | +| GET | /vocab/groups/{groupId} | 단어장 상세 | +| PUT | /vocab/groups/{groupId} | 단어장 수정 | +| DELETE | /vocab/groups/{groupId} | 단어장 삭제 | +| POST | /vocab/groups/{groupId}/words/{wordId} | 단어 추가 | +| DELETE | /vocab/groups/{groupId}/words/{wordId} | 단어 제거 | + +--- + +## 7. TTS 음성 합성 + +### 7.1 음성 생성 흐름 + +```mermaid +flowchart TB + REQUEST["POST /vocab/synthesize
{wordId, voice, type}"] + CHECK{S3 캐시
존재?} + REQUEST --> CHECK + CHECK -->|Yes| PRESIGN[Presigned URL 생성] + CHECK -->|No| POLLY[AWS Polly 호출] + POLLY --> SAVE[S3 저장] + SAVE --> PRESIGN + PRESIGN --> RESPONSE[URL 반환] +``` + +### 7.2 Voice API + +```json +// Request +{ + "wordId": "uuid", + "voice": "MALE", + // MALE | FEMALE + "type": "WORD" + // WORD | EXAMPLE +} + +// Response +{ + "url": "https://s3...presigned-url", + "expiresIn": 3600 +} +``` + +--- + +## 8. 데이터 모델 + +### 8.1 Word + +```java + +@DynamoDbBean +public class Word { + String wordId; // UUID + String english; // 영어 단어 + String korean; // 한국어 뜻 + String example; // 예문 + String level; // BEGINNER | INTERMEDIATE | ADVANCED + String category; // DAILY | BUSINESS | ACADEMIC | TRAVEL | TECHNOLOGY + String maleVoiceKey; // S3 음성 키 + String femaleVoiceKey; + String maleExampleVoiceKey; + String femaleExampleVoiceKey; +} +``` + +**DynamoDB Keys:** + +| Key | 패턴 | 용도 | +|--------|---------------------|----------| +| PK | WORD#{wordId} | 기본 조회 | +| SK | METADATA | - | +| GSI1PK | LEVEL#{level} | 레벨별 조회 | +| GSI2PK | CATEGORY#{category} | 카테고리별 조회 | + +### 8.2 UserWord + +```java + +@DynamoDbBean +public class UserWord { + String userId; + String wordId; + String status; // NEW | LEARNING | REVIEWING | MASTERED + + // SM-2 알고리즘 필드 + Integer interval; // 복습 간격 (일) + Double easeFactor; // 난이도 계수 (1.3~2.5) + Integer repetitions; // 연속 정답 횟수 + String nextReviewAt; // 다음 복습일 (YYYY-MM-DD) + + // 통계 + Integer correctCount; // 누적 정답 + Integer incorrectCount; // 누적 오답 + + // 사용자 설정 + Boolean bookmarked; // 북마크 + Boolean favorite; // 즐겨찾기 + String difficulty; // EASY | NORMAL | HARD +} +``` + +**DynamoDB Keys:** + +| Key | 패턴 | 용도 | +|--------|--------------------------|--------------| +| PK | USER#{userId} | 기본 조회 | +| SK | WORD#{wordId} | - | +| GSI1PK | USER#{userId}#REVIEW | 복습 예정 단어 | +| GSI1SK | DATE#{nextReviewAt} | - | +| GSI2PK | USER#{userId}#STATUS | 상태별 조회 | +| GSI2SK | STATUS#{status} | - | +| GSI3PK | USER#{userId}#BOOKMARKED | 북마크 (Sparse) | + +### 8.3 DailyStudy + +```java + +@DynamoDbBean +public class DailyStudy { + String userId; + String date; // YYYY-MM-DD + List newWordIds; // 신규 단어 50개 + List reviewWordIds; // 복습 단어 5개 + List learnedWordIds; // 학습 완료 단어 + Integer totalWords; // 총 단어 수 (55) + Integer learnedCount; // 학습 완료 수 + Boolean isCompleted; // 완료 여부 +} +``` + +### 8.4 TestResult + +```java + +@DynamoDbBean +public class TestResult { + String testId; + String userId; + String testType; // DAILY | WEEKLY | CUSTOM + Integer totalQuestions; + Integer correctAnswers; + Integer incorrectAnswers; + Double successRate; + List testedWordIds; + List incorrectWordIds; + String startedAt; + String completedAt; +} +``` + +--- + +## 9. 서비스 아키텍처 (CQRS) + +### 9.1 Command Services (쓰기) + +```mermaid +flowchart TB + subgraph Commands["Command Services"] + WC[WordCommandService
단어 생성/수정/삭제] + UC[UserWordCommandService
학습 상태 업데이트] + DC[DailyStudyCommandService
일일 학습 관리] + TC[TestCommandService
테스트 생성/제출] + GC[WordGroupCommandService
단어장 관리] + end +``` + +### 9.2 Query Services (읽기) + +```mermaid +flowchart TB + subgraph Queries["Query Services"] + WQ[WordQueryService
단어 조회/검색] + UQ[UserWordQueryService
학습 현황 조회] + DQ[DailyStudyQueryService
일일 학습 조회] + TQ[TestQueryService
테스트 결과 조회] + end +``` + +--- + +## 10. 성능 최적화 + +| 최적화 | 기법 | 효과 | +|---------------------|------------------------|-----------------| +| N+1 방지 | BatchGetItem (100개 단위) | DB 호출 90% 감소 | +| TTS 캐싱 | S3 + Presigned URL | Polly 호출 90% 절감 | +| 페이지네이션 | Cursor 기반 (Base64) | 대용량 데이터 처리 | +| Sparse Index | GSI3 (북마크 전용) | 인덱스 크기 최소화 | +| 비동기 통계 | SNS/SQS | API 응답 속도 향상 | +| Strongly Consistent | DailyStudy 조회 | 데이터 정합성 | + +--- + +## 11. 파일 구조 + +``` +domain/vocabulary/ +├── handler/ +│ ├── WordHandler.java +│ ├── UserWordHandler.java +│ ├── DailyStudyHandler.java +│ ├── TestHandler.java +│ ├── WordGroupHandler.java +│ ├── VoiceHandler.java +│ ├── StatsHandler.java +│ └── StatisticsHandler.java (SQS) +├── service/ +│ ├── WordCommandService.java +│ ├── WordQueryService.java +│ ├── UserWordCommandService.java +│ ├── UserWordQueryService.java +│ ├── TestCommandService.java +│ ├── TestQueryService.java +│ ├── DailyStudyCommandService.java +│ ├── DailyStudyQueryService.java +│ ├── WordGroupCommandService.java +│ ├── StatsService.java +│ └── StatisticsService.java +├── repository/ +│ ├── WordRepository.java +│ ├── UserWordRepository.java +│ ├── DailyStudyRepository.java +│ ├── TestResultRepository.java +│ └── WordGroupRepository.java +├── model/ +│ ├── Word.java +│ ├── UserWord.java +│ ├── DailyStudy.java +│ ├── TestResult.java +│ └── WordGroup.java +├── state/ +│ ├── WordState.java (interface) +│ ├── NewState.java +│ ├── LearningState.java +│ ├── ReviewingState.java +│ ├── MasteredState.java +│ ├── SpacedRepetitionContext.java +│ └── WordStateFactory.java +└── enums/ + ├── WordStatus.java + ├── WordCategory.java + └── TestType.java +``` + +--- + +## 12. 기술 스택 + +- **Runtime:** AWS Lambda (Java 21) +- **Database:** DynamoDB (Single Table Design) +- **TTS:** AWS Polly (남성/여성 음성) +- **Storage:** S3 (음성 캐시) +- **Messaging:** SNS/SQS (비동기 통계) +- **Pattern:** CQRS, State, Repository, Factory From 7d3f36448bf67262922d61356adee937d61ccd03 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:28:57 +0900 Subject: [PATCH 220/528] =?UTF-8?q?docs:=20Chatting=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EC=84=B8=EB=B6=80=20=EB=B3=B4=EA=B3=A0=EC=84=9C=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/domain-reports/CHATTING-DOMAIN-REPORT.md | 434 ++++++++++++++++++ 1 file changed, 434 insertions(+) create mode 100644 docs/domain-reports/CHATTING-DOMAIN-REPORT.md diff --git a/docs/domain-reports/CHATTING-DOMAIN-REPORT.md b/docs/domain-reports/CHATTING-DOMAIN-REPORT.md new file mode 100644 index 00000000..c27eb552 --- /dev/null +++ b/docs/domain-reports/CHATTING-DOMAIN-REPORT.md @@ -0,0 +1,434 @@ +# Chatting Domain 세부 보고서 + +## 1. 개요 + +Chatting 도메인은 실시간 채팅과 캐치마인드 게임 기능을 제공하는 WebSocket 기반 시스템입니다. AWS API Gateway WebSocket과 Lambda를 활용하여 실시간 양방향 통신을 구현했습니다. + +--- + +## 2. 전체 아키텍처 + +```mermaid +flowchart TB + subgraph Client["클라이언트"] + APP[Mobile/Web App] + end + + subgraph Gateway["API Gateway"] + REST[REST API] + WS[WebSocket API] + end + + subgraph Lambda["Lambda Handlers"] + direction TB + ROOM[ChatRoomHandler] + MSG[ChatMessageHandler] + GAME[GameHandler] + VOICE[ChatVoiceHandler] + CONNECT[WebSocketConnectHandler] + DISCONNECT[WebSocketDisconnectHandler] + MESSAGE[WebSocketMessageHandler] + end + + subgraph Storage["데이터 저장소"] + DDB[(DynamoDB)] + S3[(S3 - 음성 캐시)] + end + + APP --> REST + APP <--> WS + REST --> ROOM + REST --> MSG + REST --> GAME + REST --> VOICE + WS --> CONNECT + WS --> DISCONNECT + WS --> MESSAGE + ROOM --> DDB + MSG --> DDB + GAME --> DDB + MESSAGE --> DDB + VOICE --> S3 +``` + +--- + +## 3. 채팅방 시스템 + +### 3.1 채팅방 입장 흐름 + +```mermaid +sequenceDiagram + participant Client + participant REST as REST API + participant WS as WebSocket API + participant DB as DynamoDB + Note over Client, DB: Phase 1 - 방 입장 및 토큰 발급 + Client ->> REST: POST /rooms/{roomId}/join + REST ->> DB: 비밀번호 검증 (비밀방인 경우) + REST ->> DB: RoomToken 저장 (TTL 5분) + REST -->> Client: roomToken 반환 + Note over Client, DB: Phase 2 - WebSocket 연결 + Client ->> WS: $connect?roomToken={token} + WS ->> DB: 토큰 검증 + WS ->> DB: Connection 저장 (TTL 10분) + WS -->> Client: 연결 성공 + Note over Client, DB: Phase 3 - 실시간 메시지 + Client ->> WS: sendMessage (채팅) + WS ->> DB: 메시지 저장 + WS -->> Client: 브로드캐스트 (같은 방 전체) +``` + +### 3.2 REST API 엔드포인트 + +| Method | Endpoint | 설명 | 인증 | +|--------|-------------------------------|---------------------------|----| +| POST | /chat/rooms | 채팅방 생성 | O | +| GET | /chat/rooms | 채팅방 목록 (level, joined 필터) | O | +| GET | /chat/rooms/{roomId} | 채팅방 상세 | O | +| POST | /chat/rooms/{roomId}/join | 채팅방 입장 (토큰 발급) | O | +| POST | /chat/rooms/{roomId}/leave | 채팅방 퇴장 | O | +| DELETE | /chat/rooms/{roomId} | 채팅방 삭제 (방장만) | O | +| GET | /chat/rooms/{roomId}/messages | 메시지 히스토리 | O | + +### 3.3 WebSocket 이벤트 + +| Route | 설명 | Payload | +|-------------|------------|------------------------------------------| +| $connect | 연결 (토큰 검증) | ?roomToken={token} | +| $disconnect | 연결 해제 | - | +| sendMessage | 메시지 전송 | { roomId, userId, content, messageType } | + +--- + +## 4. 캐치마인드 게임 시스템 + +### 4.1 게임 흐름 + +```mermaid +flowchart TB + subgraph GameFlow["캐치마인드 게임 흐름"] + START["/game 명령어"] --> INIT["게임 초기화
출제자 순서 셔플"] + INIT --> ROUND["라운드 시작
출제자 + 단어 선정"] + ROUND --> DRAW["출제자 그림 그리기
(DRAWING 메시지)"] + DRAW --> GUESS["참가자 정답 입력"] + GUESS --> CHECK{정답?} + CHECK -->|Yes| SCORE["점수 계산
시간보너스 + 연속보너스"] + CHECK -->|No| GUESS + SCORE --> ALLCORRECT{전원 정답?} + ALLCORRECT -->|Yes| NEXTROUND + ALLCORRECT -->|No| TIMEOUT{시간 초과?} + TIMEOUT -->|Yes| NEXTROUND["다음 라운드"] + TIMEOUT -->|No| GUESS + NEXTROUND --> LASTROUND{마지막 라운드?} + LASTROUND -->|Yes| END["게임 종료
순위 발표"] + LASTROUND -->|No| ROUND + end +``` + +### 4.2 게임 API + +| Method | Endpoint | 설명 | +|--------|----------------------------------|-------------| +| POST | /chat/rooms/{roomId}/game/start | 게임 시작 (방장만) | +| POST | /chat/rooms/{roomId}/game/stop | 게임 중지 | +| GET | /chat/rooms/{roomId}/game/status | 게임 상태 조회 | +| GET | /chat/rooms/{roomId}/game/scores | 점수판 조회 | + +### 4.3 슬래시 명령어 + +| 명령어 | 설명 | 사용 가능 | +|---------|----------------|--------| +| /start | 게임 시작 | 방장 | +| /stop | 게임 중지 | 방장/시작자 | +| /score | 점수판 보기 | 전체 | +| /member | 접속자 수 | 전체 | +| /hint | 힌트 제공 (첫글자○○○) | 출제자 | +| /skip | 라운드 스킵 | 출제자 | +| /help | 명령어 도움말 | 전체 | + +### 4.4 점수 계산 공식 + +``` +점수 = 기본점수(10) + 시간보너스 + 연속보너스 + 출제자보너스 + +- 시간보너스: (60 - 경과초) × 0.5 +- 연속보너스: streak × 2 +- 출제자보너스: 정답자당 5점 +``` + +**예시:** + +- 30초에 정답 + 연속 3회: 10 + 15 + 6 = 31점 +- 출제자가 3명 맞출 경우: 5 × 3 = 15점 + +### 4.5 게임 상태 + +```mermaid +stateDiagram-v2 + [*] --> NONE: 대기 + NONE --> PLAYING: /start 명령어 + PLAYING --> ROUND_END: 시간초과/전원정답 + ROUND_END --> PLAYING: 다음 라운드 + ROUND_END --> FINISHED: 마지막 라운드 + PLAYING --> FINISHED: /stop 명령어 + FINISHED --> [*]: 게임 종료 +``` + +--- + +## 5. WebSocket 메시지 타입 + +### 5.1 채팅 메시지 + +| Type | 설명 | 저장 | +|-------------|-------|----| +| TEXT | 일반 채팅 | O | +| IMAGE | 이미지 | O | +| VOICE | 음성 | O | +| AI_RESPONSE | AI 응답 | O | + +### 5.2 게임 메시지 + +| Type | 설명 | 저장 | +|----------------|--------------|----| +| DRAWING | 그림 데이터 (실시간) | X | +| DRAWING_CLEAR | 그림 지우기 | X | +| GUESS | 오답 추측 | X | +| CORRECT_ANSWER | 정답 알림 | X | +| SCORE_UPDATE | 점수 갱신 | X | +| GAME_START | 게임 시작 | X | +| ROUND_START | 라운드 시작 | X | +| ROUND_END | 라운드 종료 | X | +| GAME_END | 게임 종료 | X | +| HINT | 힌트 | X | + +### 5.3 실시간 점수 업데이트 메시지 + +```json +{ + "messageType": "SCORE_UPDATE", + "roomId": "uuid", + "scorerId": "user123", + "scoreGained": 25, + "ranking": [ + { + "rank": 1, + "userId": "user123", + "score": 85, + "change": 25 + }, + { + "rank": 2, + "userId": "user456", + "score": 60, + "change": 0 + } + ], + "currentRound": 3, + "totalRounds": 5 +} +``` + +--- + +## 6. 데이터 모델 + +### 6.1 ChatRoom + +```java + +@DynamoDbBean +public class ChatRoom { + // 기본 정보 + String roomId, name, description; + String level; // beginner, intermediate, advanced + Integer currentMembers, maxMembers; + Boolean isPrivate; + String password; // BCrypt 암호화 + String createdBy; // 방장 + List memberIds; + + // 게임 상태 + String gameStatus; // NONE, PLAYING, ROUND_END, FINISHED + Integer currentRound, totalRounds; + String currentDrawerId, currentWord; + Long roundStartTime; + Integer roundTimeLimit; // 60초 + List drawerOrder; + Map scores; + Map streaks; + List correctGuessers; + Boolean hintUsed; +} +``` + +**DynamoDB Keys:** + +- PK: `ROOM#{roomId}` | SK: `METADATA` +- GSI1: `ROOMS` | `{level}#{createdAt}` (레벨별 최신순) + +### 6.2 Connection + +```java + +@DynamoDbBean +public class Connection { + String connectionId; // API Gateway 연결 ID + String userId; + String roomId; + Long ttl; // 10분 (자동 삭제) +} +``` + +**DynamoDB Keys:** + +- PK: `CONN#{connectionId}` | SK: `METADATA` +- GSI1: `ROOM#{roomId}` | `CONN#{connectionId}` (방별 연결) +- GSI2: `USER#{userId}` | `CONN#{connectionId}` (사용자별 연결) + +### 6.3 GameRound + +```java + +@DynamoDbBean +public class GameRound { + Integer roundNumber; + String drawerId, word, wordEnglish; + List correctGuessers; + Map guessTimes; // 정답까지 걸린 시간 + Map roundScores; + Long startTime, endTime; + String endReason; // TIME_UP, ALL_CORRECT, SKIP + Long ttl; // 7일 +} +``` + +### 6.4 RoomToken + +```java + +@DynamoDbBean +public class RoomToken { + String token; // UUID + String roomId; + String userId; + Long ttl; // 5분 +} +``` + +--- + +## 7. 서비스 레이어 + +### 7.1 CQRS 패턴 + +| Service | 역할 | +|------------------------|----------------------| +| ChatRoomCommandService | 채팅방 생성, 입장, 퇴장, 삭제 | +| ChatRoomQueryService | 채팅방 조회, 목록 | +| GameService | 게임 시작, 정답 체크, 라운드 종료 | +| GameStatsService | 게임 종료 후 통계, 배지 처리 | +| CommandService | 슬래시 명령어 처리 | +| RoomTokenService | 토큰 발급 및 검증 | + +### 7.2 게임 정답 체크 로직 + +```mermaid +flowchart TB + INPUT[정답 입력] --> NORMALIZE["정규화
(소문자, 공백제거)"] + NORMALIZE --> VALIDATE{유효성 검사} + VALIDATE -->|게임 미진행| REJECT1[거부: 게임 없음] + VALIDATE -->|출제자 본인| REJECT2[거부: 출제자] + VALIDATE -->|이미 정답| REJECT3[거부: 중복] + VALIDATE -->|통과| COMPARE{정답 비교} + COMPARE -->|일치| CORRECT["정답 처리
점수 계산"] + COMPARE -->|불일치| WRONG["오답 처리
GUESS 메시지 전송"] + CORRECT --> BROADCAST["브로드캐스트
CORRECT_ANSWER + SCORE_UPDATE"] + WRONG --> GUESSBROADCAST["브로드캐스트
GUESS 메시지"] + BROADCAST --> ALLCHECK{전원 정답?} + ALLCHECK -->|Yes| ROUNDEND[라운드 자동 종료] + ALLCHECK -->|No| CONTINUE[게임 계속] +``` + +--- + +## 8. 브로드캐스트 시스템 + +### 8.1 WebSocketBroadcaster + +```java +public class WebSocketBroadcaster { + public List broadcast( + List connections, + String payload + ) { + // 1. 같은 방 모든 연결에 메시지 전송 + // 2. 실패한 연결 ID 반환 (Stale 정리용) + } +} +``` + +### 8.2 브로드캐스트 유형 + +| 유형 | 대상 | 예시 | +|--------|--------|-----------| +| 전체 | 방 전체 | 채팅, 정답 알림 | +| 본인 제외 | 발신자 제외 | 그림 데이터 | +| 출제자 전용 | 출제자만 | 단어 정보 | + +--- + +## 9. 파일 구조 + +``` +domain/chatting/ +├── handler/ +│ ├── ChatRoomHandler.java +│ ├── ChatMessageHandler.java +│ ├── ChatVoiceHandler.java +│ ├── GameHandler.java +│ └── websocket/ +│ ├── WebSocketConnectHandler.java +│ ├── WebSocketDisconnectHandler.java +│ └── WebSocketMessageHandler.java +├── service/ +│ ├── ChatRoomCommandService.java +│ ├── ChatRoomQueryService.java +│ ├── ChatMessageService.java +│ ├── GameService.java +│ ├── GameStatsService.java +│ ├── CommandService.java +│ └── RoomTokenService.java +├── repository/ +│ ├── ChatRoomRepository.java +│ ├── ChatMessageRepository.java +│ ├── ConnectionRepository.java +│ ├── GameRoundRepository.java +│ └── RoomTokenRepository.java +├── model/ +│ ├── ChatRoom.java +│ ├── ChatMessage.java +│ ├── Connection.java +│ ├── GameRound.java +│ └── RoomToken.java +├── dto/ +│ ├── request/ +│ └── response/ +│ └── ScoreUpdateMessage.java +└── enums/ + ├── GameStatus.java + └── MessageType.java +``` + +--- + +## 10. 기술 스택 + +- **Runtime:** AWS Lambda (Java 21) +- **API:** API Gateway REST + WebSocket +- **Database:** DynamoDB (Single Table Design) +- **Auth:** Cognito + RoomToken +- **Encryption:** BCrypt (비밀방 암호) +- **TTS:** AWS Polly + S3 캐시 +- **Pattern:** CQRS, Repository, Factory From c1255acbac1be967c2cad3ad76caee6af5345435 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:28:57 +0900 Subject: [PATCH 221/528] =?UTF-8?q?docs:=20Grammar=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EC=84=B8=EB=B6=80=20=EB=B3=B4=EA=B3=A0=EC=84=9C=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/domain-reports/GRAMMAR-DOMAIN-REPORT.md | 465 +++++++++++++++++++ 1 file changed, 465 insertions(+) create mode 100644 docs/domain-reports/GRAMMAR-DOMAIN-REPORT.md diff --git a/docs/domain-reports/GRAMMAR-DOMAIN-REPORT.md b/docs/domain-reports/GRAMMAR-DOMAIN-REPORT.md new file mode 100644 index 00000000..5015a011 --- /dev/null +++ b/docs/domain-reports/GRAMMAR-DOMAIN-REPORT.md @@ -0,0 +1,465 @@ +# Grammar Domain 세부 보고서 + +## 1. 개요 + +Grammar 도메인은 AWS Bedrock(Claude 3 Haiku)을 활용한 AI 기반 영어 문법 체크 시스템입니다. REST API와 WebSocket 스트리밍을 통해 실시간 문법 교정 및 대화형 학습을 +제공합니다. + +--- + +## 2. 전체 아키텍처 + +```mermaid +flowchart TB + subgraph Client["클라이언트"] + APP[Mobile/Web App] + end + + subgraph Gateway["API Gateway"] + REST[REST API] + WS[Grammar WebSocket] + end + + subgraph Lambda["Lambda Handlers"] + HANDLER[GrammarHandler] + CONNECT[StreamingConnectHandler] + DISCONNECT[StreamingDisconnectHandler] + STREAM[StreamingHandler] + end + + subgraph AI["AWS AI 서비스"] + BEDROCK[Bedrock
Claude 3 Haiku] + COMPREHEND[Comprehend
언어 분석] + end + + subgraph Storage["저장소"] + DDB[(DynamoDB)] + end + + APP --> REST + APP <--> WS + REST --> HANDLER + WS --> CONNECT + WS --> DISCONNECT + WS --> STREAM + HANDLER --> BEDROCK + HANDLER --> COMPREHEND + STREAM --> BEDROCK + HANDLER --> DDB + STREAM --> DDB +``` + +--- + +## 3. 문법 체크 흐름 + +### 3.1 동기식 문법 체크 + +```mermaid +sequenceDiagram + participant Client + participant Handler as GrammarHandler + participant Service as GrammarCheckService + participant Bedrock as AWS Bedrock + participant DB as DynamoDB + Client ->> Handler: POST /grammar/check + Handler ->> Service: checkGrammar(sentence, level) + Service ->> Bedrock: Claude API 호출 + Bedrock -->> Service: JSON 응답 + Service -->> Handler: GrammarCheckResponse + Handler -->> Client: 문법 교정 결과 +``` + +### 3.2 스트리밍 대화 + +```mermaid +sequenceDiagram + participant Client + participant WS as WebSocket + participant Handler as StreamingHandler + participant Service as ConversationService + participant Bedrock as AWS Bedrock + Client ->> WS: $connect?token={jwt} + WS -->> Client: 연결 성공 + Client ->> WS: 메시지 전송 + WS ->> Handler: $default 라우트 + Handler ->> Service: chatStreaming() + Service -->> Client: StartEvent (sessionId) + + loop 토큰 단위 스트리밍 + Bedrock -->> Service: 텍스트 청크 + Service -->> Client: TokenEvent + end + + Service -->> Client: CompleteEvent (전체 응답) +``` + +--- + +## 4. API 엔드포인트 + +### 4.1 REST API + +| Method | Endpoint | 설명 | +|--------|-------------------------------|---------------| +| POST | /grammar/check | 문법 체크 (단일 문장) | +| POST | /grammar/conversation | 대화형 문법 학습 | +| GET | /grammar/sessions | 대화 세션 목록 | +| GET | /grammar/sessions/{sessionId} | 세션 상세 | +| DELETE | /grammar/sessions/{sessionId} | 세션 삭제 | + +### 4.2 WebSocket API + +| Route | 설명 | +|-------------|-------------| +| $connect | JWT 토큰으로 연결 | +| $disconnect | 연결 해제 | +| $default | 스트리밍 메시지 처리 | + +--- + +## 5. 레벨별 문법 체크 + +### 5.1 학습 레벨 + +| 레벨 | 설명 | 피드백 스타일 | +|--------------|----|--------------------| +| BEGINNER | 초급 | 한국어 번역 + 쉬운 설명 | +| INTERMEDIATE | 중급 | 영어 위주 설명 | +| ADVANCED | 고급 | 상세한 문법 규칙 + 스타일 제안 | + +### 5.2 오류 유형 + +```mermaid +mindmap + root((문법 오류)) + 시제 + VERB_TENSE + 동사 시제 오류 + 일치 + SUBJECT_VERB_AGREEMENT + 주어-동사 일치 + 품사 + ARTICLE + 관사 오류 + PREPOSITION + 전치사 오류 + PRONOUN + 대명사 오류 + 구조 + WORD_ORDER + 어순 오류 + SENTENCE_STRUCTURE + 문장 구조 + 기타 + SPELLING + 철자 + PUNCTUATION + 구두점 + WORD_CHOICE + 어휘 선택 +``` + +--- + +## 6. 응답 포맷 + +### 6.1 문법 체크 응답 + +```json +{ + "originalSentence": "I goed to school yesterday", + "correctedSentence": "I went to school yesterday", + "score": 70, + "isCorrect": false, + "errors": [ + { + "type": "VERB_TENSE", + "original": "goed", + "corrected": "went", + "explanation": "'go'의 과거형은 'went'입니다 (불규칙 동사)", + "startIndex": 2, + "endIndex": 6 + } + ], + "feedback": "과거 시제를 잘 사용하려고 노력했네요! 불규칙 동사를 조금 더 연습해보세요." +} +``` + +### 6.2 대화 응답 + +```json +{ + "sessionId": "uuid", + "grammarCheck": { + /* 위와 동일 */ + }, + "aiResponse": "Great job! Your sentence structure is correct. Let's practice more complex sentences.", + "conversationTip": "Try using 'had gone' for past perfect tense." +} +``` + +### 6.3 스트리밍 이벤트 + +```json +// StartEvent +{ + "type": "start", + "sessionId": "uuid" +} + +// TokenEvent (실시간) +{ + "type": "token", + "token": "Great " +} +{ + "type": "token", + "token": "job!" +} + +// CompleteEvent (완료) +{ + "type": "complete", + "sessionId": "uuid", + "grammarCheck": { + ... + }, + "aiResponse": "...", + "conversationTip": "..." +} + +// ErrorEvent (오류 시) +{ + "type": "error", + "message": "..." +} +``` + +--- + +## 7. AWS Bedrock 통합 + +### 7.1 Claude 3 Haiku 설정 + +```java +public class BedrockGrammarCheckFactory { + private static final String MODEL_ID = "anthropic.claude-3-haiku-20240307-v1:0"; + private static final int MAX_TOKENS = 2048; + private static final String API_VERSION = "bedrock-2023-05-31"; +} +``` + +### 7.2 프롬프트 구조 + +**시스템 프롬프트 (초급):** + +``` +You are a friendly English grammar tutor for Korean speakers. +- Use simple English with Korean translations +- Be encouraging and supportive +- Explain grammar rules clearly +``` + +**사용자 프롬프트:** + +``` +Please check the grammar of this sentence: "{sentence}" + +Return JSON: +{ + "correctedSentence": "...", + "score": 0-100, + "isCorrect": boolean, + "errors": [...], + "feedback": "..." +} +``` + +### 7.3 스트리밍 응답 파싱 + +``` +[RESPONSE] +AI의 자연스러운 대화 응답 +[/RESPONSE] + +[GRAMMAR] +{ JSON 형식의 문법 체크 결과 } +[/GRAMMAR] + +[TIP] +학습 팁 +[/TIP] +``` + +--- + +## 8. 데이터 모델 + +### 8.1 GrammarSession + +```java + +@DynamoDbBean +public class GrammarSession { + String sessionId; + String userId; + String level; // BEGINNER, INTERMEDIATE, ADVANCED + String topic; // "Conversation Practice" + Integer messageCount; + String lastMessage; // 마지막 메시지 (100자 제한) + String createdAt; + String updatedAt; + Long ttl; // 30일 +} +``` + +**DynamoDB Keys:** + +- PK: `GSESSION#{userId}` | SK: `SESSION#{sessionId}` +- GSI1: `GSESSION#ALL` | `UPDATED#{timestamp}` (최신순 정렬) + +### 8.2 GrammarMessage + +```java + +@DynamoDbBean +public class GrammarMessage { + String messageId; + String sessionId; + String userId; + String role; // USER, ASSISTANT + String content; // 원본 메시지 + String correctedContent; // 교정된 메시지 (USER만) + String errorsJson; // 오류 목록 JSON + Integer grammarScore; + String feedback; + Boolean isCorrect; + Long ttl; // 30일 +} +``` + +**DynamoDB Keys:** + +- PK: `GSESSION#{userId}` | SK: `MSG#{timestamp}#{messageId}` +- GSI1: `GSESSION#{sessionId}` | `MSG#{timestamp}` + +### 8.3 GrammarConnection (WebSocket) + +```java + +@DynamoDbBean +public class GrammarConnection { + String connectionId; // API Gateway 연결 ID + String userId; // JWT에서 추출 + String connectedAt; + Long ttl; // 연결 타임아웃 +} +``` + +--- + +## 9. AWS Comprehend 분석 (선택적) + +```mermaid +flowchart LR + INPUT[입력 문장] --> SENTIMENT[감정 분석] + INPUT --> SYNTAX[구문 분석] + INPUT --> KEYPHRASE[핵심 구문] + INPUT --> LANGUAGE[언어 감지] + SENTIMENT --> OUTPUT[분석 결과] + SYNTAX --> OUTPUT + KEYPHRASE --> OUTPUT + LANGUAGE --> OUTPUT +``` + +**분석 항목:** + +- 감정: POSITIVE, NEGATIVE, NEUTRAL, MIXED +- 품사 태깅: NOUN, VERB, ADJ 등 +- 핵심 구문 추출 +- 문장 복잡도 추정 + +--- + +## 10. 서비스 레이어 + +### 10.1 서비스 구성 + +| Service | 역할 | +|----------------------------|----------------| +| GrammarCheckService | 단일 문장 문법 체크 | +| GrammarConversationService | 대화형 학습 + 스트리밍 | +| GrammarSessionQueryService | 세션 조회, 삭제 | +| BedrockGrammarCheckFactory | Bedrock API 호출 | + +### 10.2 대화 히스토리 관리 + +```java +// 최근 10개 메시지만 컨텍스트로 유지 +private static final int MAX_HISTORY_MESSAGES = 10; + +// 대화 히스토리 빌드 +String buildConversationHistory(String sessionId) { + // 최근 메시지 조회 + // USER: 내용 / ASSISTANT: 내용 형식으로 포맷 +} +``` + +--- + +## 11. 파일 구조 + +``` +domain/grammar/ +├── handler/ +│ ├── GrammarHandler.java +│ └── websocket/ +│ ├── GrammarStreamingConnectHandler.java +│ ├── GrammarStreamingDisconnectHandler.java +│ └── GrammarStreamingHandler.java +├── service/ +│ ├── GrammarCheckService.java +│ ├── GrammarConversationService.java +│ └── GrammarSessionQueryService.java +├── factory/ +│ ├── GrammarCheckFactory.java (interface) +│ └── BedrockGrammarCheckFactory.java +├── repository/ +│ ├── GrammarSessionRepository.java +│ └── GrammarConnectionRepository.java +├── model/ +│ ├── GrammarSession.java +│ ├── GrammarMessage.java +│ └── GrammarConnection.java +├── dto/ +│ ├── request/ +│ │ ├── GrammarCheckRequest.java +│ │ └── ConversationRequest.java +│ └── response/ +│ ├── GrammarCheckResponse.java +│ ├── ConversationResponse.java +│ ├── GrammarError.java +│ └── ComprehendAnalysis.java +├── streaming/ +│ ├── StreamingCallback.java +│ ├── StreamingEvent.java (sealed interface) +│ └── StreamingRequest.java +├── enums/ +│ ├── GrammarLevel.java +│ └── GrammarErrorType.java +└── constants/ + └── GrammarKey.java +``` + +--- + +## 12. 기술 스택 + +- **Runtime:** AWS Lambda (Java 21) +- **API:** API Gateway REST + WebSocket +- **AI:** AWS Bedrock (Claude 3 Haiku) +- **NLP:** AWS Comprehend (선택적) +- **Database:** DynamoDB +- **Auth:** JWT (Cognito) +- **Pattern:** Factory, Callback, Sealed Interface (Java 17+) From f8d0a21f9b53830c2c36ba2aaed8c020d55ca73b Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:28:57 +0900 Subject: [PATCH 222/528] =?UTF-8?q?docs:=20Stats=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EC=84=B8=EB=B6=80=20=EB=B3=B4=EA=B3=A0=EC=84=9C=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/domain-reports/STATS-DOMAIN-REPORT.md | 379 +++++++++++++++++++++ 1 file changed, 379 insertions(+) create mode 100644 docs/domain-reports/STATS-DOMAIN-REPORT.md diff --git a/docs/domain-reports/STATS-DOMAIN-REPORT.md b/docs/domain-reports/STATS-DOMAIN-REPORT.md new file mode 100644 index 00000000..3ca3d3ff --- /dev/null +++ b/docs/domain-reports/STATS-DOMAIN-REPORT.md @@ -0,0 +1,379 @@ +# Stats Domain 세부 보고서 + +## 1. 개요 + +Stats 도메인은 사용자의 학습 활동을 추적하고 통계를 집계하는 시스템입니다. DynamoDB Streams와 EventBridge를 활용한 이벤트 기반 아키텍처로 실시간 통계 업데이트를 제공합니다. + +--- + +## 2. 전체 아키텍처 + +```mermaid +flowchart TB + subgraph Triggers["트리거"] + TEST[테스트 완료] + DAILY[일일 학습] + GAME[게임 종료] + SCHEDULE[스케줄러
매일 자정] + end + + subgraph Processing["처리"] + STREAM[StatsStreamHandler
DynamoDB Streams] + SERVICE[StatsService
Write-through] + SCHEDULED[ScheduledStatsHandler
EventBridge] + end + + subgraph Storage["저장소"] + DDB[(DynamoDB
UserStats)] + end + + subgraph Query["조회"] + API[UserStatsHandler
REST API] + end + + TEST --> STREAM + DAILY --> SERVICE + GAME --> SERVICE + SCHEDULE --> SCHEDULED + STREAM --> DDB + SERVICE --> DDB + SCHEDULED --> DDB + DDB --> API +``` + +--- + +## 3. 통계 집계 방식 + +### 3.1 집계 레벨 + +```mermaid +flowchart LR + subgraph Levels["통계 집계 레벨"] + DAILY["일별
DAILY#2026-01-16"] + WEEKLY["주별
WEEKLY#2026-W03"] + MONTHLY["월별
MONTHLY#2026-01"] + TOTAL["전체
TOTAL"] + end + + EVENT[이벤트 발생] --> DAILY + EVENT --> WEEKLY + EVENT --> MONTHLY + EVENT --> TOTAL +``` + +### 3.2 Atomic Counter 패턴 + +```java +// 모든 레벨에 동시 업데이트 (원자적) +UpdateExpression: +SET correctAnswers = if_not_exists(correctAnswers, 0) + :correct, +incorrectAnswers = + +if_not_exists(incorrectAnswers, 0) +:incorrect, +testsCompleted = + +if_not_exists(testsCompleted, 0) +1, +updatedAt =:now +``` + +--- + +## 4. 이벤트 기반 통계 업데이트 + +### 4.1 DynamoDB Streams 처리 + +```mermaid +sequenceDiagram + participant Test as TestResult 저장 + participant Stream as DynamoDB Streams + participant Handler as StatsStreamHandler + participant DB as UserStats + Test ->> Stream: INSERT 이벤트 + Stream ->> Handler: 트리거 + Handler ->> Handler: PK/SK 패턴 확인
(TEST#userId, RESULT#timestamp) + Handler ->> DB: incrementTestStats() + Handler ->> DB: updateStudyStreak() + Handler ->> Handler: checkAndAwardBadges() +``` + +### 4.2 Write-through 패턴 + +```mermaid +sequenceDiagram + participant API as DailyStudyHandler + participant Service as StatsService + participant DB as UserStats + Note over API, DB: 단어 학습 완료 시 + API ->> Service: recordWordsLearned() + Service ->> DB: incrementWordsLearned()
(DAILY, WEEKLY, MONTHLY, TOTAL) + Service ->> DB: updateStudyStreak() +``` + +--- + +## 5. API 엔드포인트 + +### 5.1 통계 조회 API + +| Method | Endpoint | 설명 | 파라미터 | +|--------|----------------|---------|------------------| +| GET | /stats/daily | 일별 통계 | ?date=YYYY-MM-DD | +| GET | /stats/weekly | 주별 통계 | ?week=YYYY-Www | +| GET | /stats/monthly | 월별 통계 | ?month=YYYY-MM | +| GET | /stats/total | 전체 통계 | - | +| GET | /stats/history | 일별 히스토리 | ?cursor, ?limit | + +### 5.2 응답 예시 + +```json +{ + "periodType": "DAILY", + "period": "2026-01-16", + "testsCompleted": 3, + "questionsAnswered": 45, + "correctAnswers": 38, + "incorrectAnswers": 7, + "successRate": 84.44, + "newWordsLearned": 50, + "wordsReviewed": 5 +} +``` + +**전체 통계 추가 필드:** + +```json +{ + "currentStreak": 7, + "longestStreak": 14, + "lastStudyDate": "2026-01-16", + "gamesPlayed": 10, + "gamesWon": 3, + "totalGameScore": 450 +} +``` + +--- + +## 6. 연속 학습 (Streak) 시스템 + +### 6.1 스트릭 계산 로직 + +```mermaid +flowchart TB + START[학습 활동 발생] --> CHECK{lastStudyDate
확인} + CHECK -->|null| NEW["currentStreak = 1
longestStreak = 1"] + CHECK -->|오늘| SAME[변경 없음
이미 오늘 학습] + CHECK -->|어제| INCREMENT["currentStreak++
longestStreak = max()"] + CHECK -->|2일+ 전| RESET["currentStreak = 1
longestStreak 유지"] + NEW --> UPDATE[DB 업데이트] + INCREMENT --> UPDATE + RESET --> UPDATE +``` + +### 6.2 스트릭 리셋 (스케줄러) + +```java +// EventBridge: 매일 자정 실행 +@Scheduled +public void resetStreaks() { + String yesterday = LocalDate.now().minusDays(1).toString(); + // lastStudyDate != yesterday인 사용자의 스트릭 리셋 + // 비용 최적화로 클라이언트 측 계산 권장 +} +``` + +--- + +## 7. 데이터 모델 + +### 7.1 UserStats + +```java + +@DynamoDbBean +public class UserStats { + // 키 + String pk; // USER#{userId}#STATS + String sk; // DAILY#{date} | WEEKLY#{week} | MONTHLY#{month} | TOTAL + + // 메타데이터 + String userId; + String periodType; // DAILY, WEEKLY, MONTHLY, TOTAL + String period; // 2026-01-16, 2026-W03, 2026-01, TOTAL + + // 테스트 통계 + Integer testsCompleted; + Integer questionsAnswered; + Integer correctAnswers; + Integer incorrectAnswers; + Double successRate; + + // 학습 통계 + Integer newWordsLearned; + Integer wordsReviewed; + Integer wordsMastered; + + // 스트릭 (TOTAL만) + Integer currentStreak; + Integer longestStreak; + String lastStudyDate; + + // 게임 통계 (TOTAL만) + Integer gamesPlayed; + Integer gamesWon; + Integer correctGuesses; + Integer totalGameScore; + Integer quickGuesses; // 5초 이내 정답 + Integer perfectDraws; // 전원 정답 유도 + + // 타임스탬프 + String createdAt; + String updatedAt; +} +``` + +### 7.2 DynamoDB 키 구조 + +| 필드 | 패턴 | 예시 | +|---------|------------------------|-------------------| +| PK | USER#{userId}#STATS | USER#abc123#STATS | +| SK (일별) | DAILY#{date} | DAILY#2026-01-16 | +| SK (주별) | WEEKLY#{year}-W{week} | WEEKLY#2026-W03 | +| SK (월별) | MONTHLY#{year}-{month} | MONTHLY#2026-01 | +| SK (전체) | TOTAL | TOTAL | + +--- + +## 8. 통계 메트릭 + +### 8.1 테스트 메트릭 + +| 메트릭 | 설명 | 업데이트 시점 | +|-------------------|----------|---------| +| testsCompleted | 완료 테스트 수 | 테스트 제출 | +| questionsAnswered | 총 문제 수 | 테스트 제출 | +| correctAnswers | 정답 수 | 테스트 제출 | +| incorrectAnswers | 오답 수 | 테스트 제출 | +| successRate | 정답률 (%) | 조회 시 계산 | + +### 8.2 학습 메트릭 + +| 메트릭 | 설명 | 업데이트 시점 | +|-----------------|----------|---------| +| newWordsLearned | 신규 학습 단어 | 일일학습 완료 | +| wordsReviewed | 복습 단어 | 일일학습 완료 | +| wordsMastered | 마스터 단어 | 상태 변경 시 | + +### 8.3 게임 메트릭 + +| 메트릭 | 설명 | 업데이트 시점 | +|----------------|----------|---------| +| gamesPlayed | 참여 게임 수 | 게임 종료 | +| gamesWon | 1등 횟수 | 게임 종료 | +| correctGuesses | 정답 횟수 | 게임 종료 | +| totalGameScore | 누적 점수 | 게임 종료 | +| quickGuesses | 5초 내 정답 | 게임 종료 | +| perfectDraws | 전원 정답 유도 | 게임 종료 | + +--- + +## 9. 히스토리 조회 + +### 9.1 페이지네이션 + +```mermaid +flowchart LR + REQUEST["GET /stats/history
?limit=7&cursor=..."] + QUERY["Query
PK = USER#id#STATS
SK begins_with DAILY#
scanIndexForward = false"] + ENRICH["DailyStudy 조회
isCompleted 추가"] + RESPONSE["PaginatedResult
items, nextCursor, hasMore"] + REQUEST --> QUERY --> ENRICH --> RESPONSE +``` + +### 9.2 응답 구조 + +```json +{ + "history": [ + { + "period": "2026-01-16", + "testsCompleted": 2, + "questionsAnswered": 30, + "correctAnswers": 25, + "incorrectAnswers": 5, + "successRate": 83.33, + "newWordsLearned": 50, + "wordsReviewed": 5, + "isCompleted": true + } + ], + "nextCursor": "base64encoded...", + "hasMore": true +} +``` + +--- + +## 10. 배지 연동 + +### 10.1 자동 배지 체크 + +```mermaid +flowchart TB + STREAM[StatsStreamHandler] --> CHECK[배지 조건 체크] + CHECK --> PERFECT{만점 테스트?} + PERFECT -->|Yes| BADGE1[PERFECT_SCORE 배지] + CHECK --> STATS[전체 통계 조회] + STATS --> BADGESERVICE[BadgeService.checkAndAwardBadges] + BADGESERVICE --> AWARD[조건 충족 배지 부여] +``` + +### 10.2 배지 조건 예시 + +| 배지 | 조건 | 통계 필드 | +|--------------|----------|----------------------| +| STREAK_7 | 7일 연속 학습 | currentStreak >= 7 | +| ACCURACY_90 | 정확도 90% | successRate >= 90 | +| TEST_10 | 10회 테스트 | testsCompleted >= 10 | +| GAME_10_WINS | 10번 1등 | gamesWon >= 10 | + +--- + +## 11. 파일 구조 + +``` +domain/stats/ +├── handler/ +│ ├── UserStatsHandler.java (REST API) +│ ├── StatsStreamHandler.java (DynamoDB Streams) +│ └── ScheduledStatsHandler.java (EventBridge) +├── service/ +│ └── StatsService.java +├── repository/ +│ └── UserStatsRepository.java +├── model/ +│ └── UserStats.java +└── constants/ + └── StatsKey.java +``` + +--- + +## 12. 성능 최적화 + +| 최적화 | 기법 | 효과 | +|--------------------------|------------------|-------------------| +| 원자적 업데이트 | UpdateExpression | Race condition 방지 | +| 비동기 처리 | DynamoDB Streams | API 응답 속도 향상 | +| Cursor 페이지네이션 | lastEvaluatedKey | 대용량 히스토리 처리 | +| Strongly Consistent Read | 히스토리 조회 | 데이터 정합성 | + +--- + +## 13. 기술 스택 + +- **Runtime:** AWS Lambda (Java 21) +- **Database:** DynamoDB (Single Table Design) +- **Event:** DynamoDB Streams, EventBridge +- **Pattern:** Atomic Counter, Write-through, Event-driven From d3e7c8c7f948a3e14c8d732119d617996d0cec1e Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:28:57 +0900 Subject: [PATCH 223/528] =?UTF-8?q?docs:=20Badge=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EC=84=B8=EB=B6=80=20=EB=B3=B4=EA=B3=A0=EC=84=9C=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/domain-reports/BADGE-DOMAIN-REPORT.md | 681 +++++++++++++++++++++ 1 file changed, 681 insertions(+) create mode 100644 docs/domain-reports/BADGE-DOMAIN-REPORT.md diff --git a/docs/domain-reports/BADGE-DOMAIN-REPORT.md b/docs/domain-reports/BADGE-DOMAIN-REPORT.md new file mode 100644 index 00000000..4cd58215 --- /dev/null +++ b/docs/domain-reports/BADGE-DOMAIN-REPORT.md @@ -0,0 +1,681 @@ +# Badge Domain 세부 보고서 + +## 1. 개요 + +Badge 도메인은 사용자의 학습 성취도에 따라 배지를 자동으로 부여하는 시스템입니다. 이벤트 기반 아키텍처를 통해 Stats, Vocabulary, Chatting 도메인과 연동되어 실시간으로 배지를 체크하고 +부여합니다. + +--- + +## 2. 전체 아키텍처 + +```mermaid +flowchart TB + subgraph Triggers["트리거 소스"] + TEST[테스트 완료
DynamoDB Streams] + WORD[단어 학습
Write-through] + GAME[게임 종료
Service Method] + end + + subgraph Processing["Badge 처리"] + CHECK[BadgeService
조건 체크] + AWARD[배지 부여] + end + + subgraph Storage["저장소"] + DDB[(DynamoDB
UserBadge)] + S3[(S3
배지 이미지)] + end + + subgraph Query["조회"] + API[BadgeHandler
REST API] + PRESIGN[S3 Presigned URL] + end + + TEST --> CHECK + WORD --> CHECK + GAME --> CHECK + CHECK --> AWARD + AWARD --> DDB + DDB --> API + S3 --> PRESIGN + PRESIGN --> API +``` + +--- + +## 3. 배지 종류 + +### 3.1 배지 카테고리 + +```mermaid +mindmap + root((배지 시스템)) + 학습 + FIRST_STEP[첫 걸음] + WORDS_100[단어 수집가] + WORDS_500[단어 전문가] + WORDS_1000[단어 마스터] + 연속학습 + STREAK_3[3일 연속] + STREAK_7[7일 연속] + STREAK_30[30일 연속] + 테스트 + PERFECT_SCORE[완벽주의자] + TEST_10[테스트 도전자] + ACCURACY_90[정확도 달인] + 게임 + GAME_FIRST[첫 게임] + GAME_10_WINS[10승 달성] + QUICK_GUESSER[번개 정답] + PERFECT_DRAWER[완벽한 출제자] + 최종 + MASTER[학습 마스터] +``` + +### 3.2 배지 상세 + +| Badge Type | 이름 | 설명 | 카테고리 | 조건 | +|-----------------|-----------|--------------------|-----------------|-----------------------| +| FIRST_STEP | 첫 걸음 | 첫 학습을 완료했습니다 | FIRST_STUDY | testsCompleted >= 1 | +| STREAK_3 | 3일 연속 학습 | 3일 연속으로 학습했습니다 | STREAK | currentStreak >= 3 | +| STREAK_7 | 일주일 연속 학습 | 7일 연속으로 학습했습니다 | STREAK | currentStreak >= 7 | +| STREAK_30 | 한 달 연속 학습 | 30일 연속으로 학습했습니다 | STREAK | currentStreak >= 30 | +| WORDS_100 | 단어 수집가 | 100개의 단어를 학습했습니다 | WORDS_LEARNED | totalWords >= 100 | +| WORDS_500 | 단어 전문가 | 500개의 단어를 학습했습니다 | WORDS_LEARNED | totalWords >= 500 | +| WORDS_1000 | 단어 마스터 | 1000개의 단어를 학습했습니다 | WORDS_LEARNED | totalWords >= 1000 | +| PERFECT_SCORE | 완벽주의자 | 테스트에서 만점을 받았습니다 | PERFECT_TEST | incorrectAnswers == 0 | +| TEST_10 | 테스트 도전자 | 10회의 테스트를 완료했습니다 | TESTS_COMPLETED | testsCompleted >= 10 | +| ACCURACY_90 | 정확도 달인 | 전체 정확도 90%를 달성했습니다 | ACCURACY | successRate >= 90 | +| GAME_FIRST_PLAY | 첫 게임 | 첫 게임에 참여했습니다 | GAMES_PLAYED | gamesPlayed >= 1 | +| GAME_10_WINS | 게임 10승 | 게임에서 10번 1등을 했습니다 | GAMES_WON | gamesWon >= 10 | +| QUICK_GUESSER | 번개 정답 | 5초 내에 정답을 맞췄습니다 | QUICK_GUESSES | quickGuesses >= 1 | +| PERFECT_DRAWER | 완벽한 출제자 | 출제 시 전원이 정답을 맞췄습니다 | PERFECT_DRAWS | perfectDraws >= 1 | +| MASTER | 학습 마스터 | 모든 업적을 달성했습니다 | ALL_BADGES | 모든 배지 획득 | + +--- + +## 4. 배지 부여 흐름 + +### 4.1 테스트 완료 시 + +```mermaid +sequenceDiagram + participant Test as TestResult + participant Streams as DynamoDB Streams + participant Handler as StatsStreamHandler + participant Stats as UserStats + participant Badge as BadgeService + participant DB as DynamoDB + Test ->> Streams: INSERT 이벤트 + Streams ->> Handler: 트리거 + Handler ->> Stats: incrementTestStats() + Handler ->> Stats: updateStudyStreak() + Note over Handler: 만점 체크 + alt 정답 > 0 && 오답 == 0 + Handler ->> Badge: awardBadge("PERFECT_SCORE") + Badge ->> DB: UserBadge 저장 + end + + Handler ->> Stats: findTotalStats() + Stats -->> Handler: UserStats + Handler ->> Badge: checkAndAwardBadges() + Badge ->> Badge: 각 배지 조건 체크 + Badge ->> DB: 획득 배지 저장 +``` + +### 4.2 단어 학습 시 + +```mermaid +sequenceDiagram + participant API as DailyStudyHandler + participant Service as DailyStudyCommandService + participant Stats as UserStatsRepository + participant Badge as BadgeService + participant DB as DynamoDB + API ->> Service: markWordLearned() + Service ->> Stats: incrementWordsLearned() + Note over Service: 배지 체크 (WORDS_xxx) + Service ->> Stats: findTotalStats() + Stats -->> Service: UserStats + Service ->> Badge: checkAndAwardBadges() + Badge ->> Badge: WORDS_100, 500, 1000 체크 + Badge ->> DB: 획득 배지 저장 +``` + +### 4.3 게임 종료 시 + +```mermaid +sequenceDiagram + participant Game as GameService + participant Stats as GameStatsService + participant Repo as UserStatsRepository + participant Badge as BadgeService + participant DB as DynamoDB + Game ->> Stats: updateGameStats(room) + + loop 각 참가자 + Stats ->> Stats: 점수 집계 + Note over Stats: correctGuesses
quickGuesses (5초 이내)
perfectDraws + Stats ->> Repo: incrementGameStats() + Stats ->> Repo: findTotalStats() + Repo -->> Stats: UserStats + Stats ->> Badge: checkAndAwardBadges() + Badge ->> Badge: GAME_xxx 배지 체크 + Badge ->> DB: 획득 배지 저장 + end +``` + +--- + +## 5. 배지 조건 체크 로직 + +### 5.1 카테고리별 조건 + +```mermaid +flowchart TB + START[checkAndAwardBadges] --> LOOP{모든 BadgeType 순회} + LOOP --> EARNED{이미 획득?} + EARNED -->|Yes| SKIP[건너뛰기] + EARNED -->|No| CHECK[조건 체크] + CHECK --> SWITCH{카테고리} + SWITCH -->|FIRST_STUDY| FS[testsCompleted >= 1] + SWITCH -->|STREAK| ST[currentStreak >= threshold] + SWITCH -->|WORDS_LEARNED| WL[totalWords >= threshold] + SWITCH -->|PERFECT_TEST| PT[별도 처리] + SWITCH -->|TESTS_COMPLETED| TC[testsCompleted >= threshold] + SWITCH -->|ACCURACY| AC[successRate >= threshold] + SWITCH -->|GAMES_PLAYED| GP[gamesPlayed >= threshold] + SWITCH -->|GAMES_WON| GW[gamesWon >= threshold] + SWITCH -->|QUICK_GUESSES| QG[quickGuesses >= threshold] + SWITCH -->|PERFECT_DRAWS| PD[perfectDraws >= threshold] + SWITCH -->|ALL_BADGES| AB[모든 배지 획득 체크] + FS --> RESULT{조건 충족?} + ST --> RESULT + WL --> RESULT + TC --> RESULT + AC --> RESULT + GP --> RESULT + GW --> RESULT + QG --> RESULT + PD --> RESULT + RESULT -->|Yes| AWARD[배지 부여] + RESULT -->|No| SKIP + AWARD --> LOOP + SKIP --> LOOP +``` + +### 5.2 Switch Expression 패턴 + +```java +private boolean checkBadgeCondition(BadgeType type, UserStats stats) { + return switch (type.getCategory()) { + case "FIRST_STUDY" -> stats.getTestsCompleted() != null && stats.getTestsCompleted() >= 1; + + case "STREAK" -> stats.getCurrentStreak() != null && + stats.getCurrentStreak() >= type.getThreshold(); + + case "WORDS_LEARNED" -> { + int total = (stats.getNewWordsLearned() != null ? stats.getNewWordsLearned() : 0) + + (stats.getWordsReviewed() != null ? stats.getWordsReviewed() : 0); + yield total >= type.getThreshold(); + } + + case "ACCURACY" -> { + if (stats.getQuestionsAnswered() == null || stats.getQuestionsAnswered() == 0) + yield false; + double accuracy = (stats.getCorrectAnswers() * 100.0) / stats.getQuestionsAnswered(); + yield accuracy >= type.getThreshold(); + } + + case "TESTS_COMPLETED" -> stats.getTestsCompleted() != null && + stats.getTestsCompleted() >= type.getThreshold(); + + case "GAMES_PLAYED" -> stats.getGamesPlayed() != null && + stats.getGamesPlayed() >= type.getThreshold(); + + case "GAMES_WON" -> stats.getGamesWon() != null && + stats.getGamesWon() >= type.getThreshold(); + + case "QUICK_GUESSES" -> stats.getQuickGuesses() != null && + stats.getQuickGuesses() >= type.getThreshold(); + + case "PERFECT_DRAWS" -> stats.getPerfectDraws() != null && + stats.getPerfectDraws() >= type.getThreshold(); + + case "PERFECT_TEST" -> false; // 별도 처리 (StatsStreamHandler) + case "ALL_BADGES" -> false; // 특수 로직 필요 + + default -> false; + }; +} +``` + +--- + +## 6. API 엔드포인트 + +### 6.1 REST API + +| Method | Endpoint | 설명 | 응답 | +|--------|----------------|----------------|-------------| +| GET | /badges | 전체 배지 목록 + 진행도 | BadgeInfo[] | +| GET | /badges/earned | 획득한 배지만 조회 | UserBadge[] | + +### 6.2 전체 배지 조회 응답 + +```json +{ + "message": "Badges retrieved", + "data": { + "badges": [ + { + "badgeType": "FIRST_STEP", + "name": "첫 걸음", + "description": "첫 학습을 완료했습니다", + "imageUrl": "https://...presigned.../badges/first_step.png", + "category": "FIRST_STUDY", + "threshold": 1, + "progress": 1, + "earned": true, + "earnedAt": "2026-01-16T10:30:45.123Z" + }, + { + "badgeType": "WORDS_100", + "name": "단어 수집가", + "description": "100개의 단어를 학습했습니다", + "imageUrl": "https://...presigned.../badges/words_100.png", + "category": "WORDS_LEARNED", + "threshold": 100, + "progress": 45, + "earned": false, + "earnedAt": null + } + ], + "totalCount": 16, + "earnedCount": 8 + } +} +``` + +### 6.3 획득 배지 조회 응답 + +```json +{ + "message": "Earned badges retrieved", + "data": { + "badges": [ + { + "badgeType": "FIRST_STEP", + "name": "첫 걸음", + "description": "첫 학습을 완료했습니다", + "imageUrl": "https://...presigned.../badges/first_step.png", + "category": "FIRST_STUDY", + "threshold": 1, + "progress": 1, + "earnedAt": "2026-01-16T10:30:45.123Z" + } + ], + "count": 8 + } +} +``` + +--- + +## 7. 데이터 모델 + +### 7.1 UserBadge + +```java + +@DynamoDbBean +public class UserBadge { + // 기본 키 + String pk; // USER#{userId}#BADGE + String sk; // BADGE#{badgeType} + + // GSI (전체 배지 조회) + String gsi1pk; // BADGE#ALL + String gsi1sk; // EARNED#{earnedAt} + + // 메타데이터 + String odUserId; + String badgeType; // BadgeType enum 이름 + String name; + String description; + String imageUrl; + String category; + Integer threshold; + Integer progress; // 획득 시점 진행도 + + // 타임스탬프 + String earnedAt; + String createdAt; +} +``` + +### 7.2 DynamoDB 키 구조 + +| 필드 | 패턴 | 예시 | +|--------|---------------------|-----------------------------| +| PK | USER#{userId}#BADGE | USER#abc123#BADGE | +| SK | BADGE#{badgeType} | BADGE#STREAK_7 | +| GSI1PK | BADGE#ALL | BADGE#ALL | +| GSI1SK | EARNED#{earnedAt} | EARNED#2026-01-16T10:30:45Z | + +### 7.3 BadgeType Enum + +```java +public enum BadgeType { + FIRST_STEP("첫 걸음", "첫 학습을 완료했습니다", + "FIRST_STUDY", 1, "first_step.png"), + STREAK_3("3일 연속 학습", "3일 연속으로 학습했습니다", + "STREAK", 3, "streak_3.png"), + STREAK_7("일주일 연속 학습", "7일 연속으로 학습했습니다", + "STREAK", 7, "streak_7.png"), + // ... 생략 + MASTER("학습 마스터", "모든 업적을 달성했습니다", + "ALL_BADGES", 1, "master.png"); + + private final String name; + private final String description; + private final String category; + private final int threshold; + private final String imageFile; +} +``` + +--- + +## 8. 진행도 계산 + +### 8.1 카테고리별 진행도 + +```mermaid +flowchart TB + subgraph Progress["진행도 계산"] + FIRST["FIRST_STUDY
testsCompleted >= 1 ? 1 : 0"] + STREAK["STREAK
currentStreak"] + WORDS["WORDS_LEARNED
newWords + reviewed"] + TESTS["TESTS_COMPLETED
testsCompleted"] + ACC["ACCURACY
successRate (%)"] + GAMES["GAMES_PLAYED
gamesPlayed"] + WINS["GAMES_WON
gamesWon"] + QUICK["QUICK_GUESSES
quickGuesses"] + PERFECT["PERFECT_DRAWS
perfectDraws"] + end +``` + +### 8.2 calculateProgress 메서드 + +```java +private int calculateProgress(BadgeType type, UserStats stats) { + return switch (type.getCategory()) { + case "FIRST_STUDY" -> (stats.getTestsCompleted() != null && stats.getTestsCompleted() >= 1) ? 1 : 0; + + case "STREAK" -> stats.getCurrentStreak() != null ? stats.getCurrentStreak() : 0; + + case "WORDS_LEARNED" -> { + int newWords = stats.getNewWordsLearned() != null ? stats.getNewWordsLearned() : 0; + int reviewed = stats.getWordsReviewed() != null ? stats.getWordsReviewed() : 0; + yield newWords + reviewed; + } + + case "TESTS_COMPLETED" -> stats.getTestsCompleted() != null ? stats.getTestsCompleted() : 0; + + case "ACCURACY" -> { + if (stats.getQuestionsAnswered() == null || stats.getQuestionsAnswered() == 0) + yield 0; + yield (int) ((stats.getCorrectAnswers() * 100.0) / stats.getQuestionsAnswered()); + } + + case "GAMES_PLAYED" -> stats.getGamesPlayed() != null ? stats.getGamesPlayed() : 0; + + case "GAMES_WON" -> stats.getGamesWon() != null ? stats.getGamesWon() : 0; + + case "QUICK_GUESSES" -> stats.getQuickGuesses() != null ? stats.getQuickGuesses() : 0; + + case "PERFECT_DRAWS" -> stats.getPerfectDraws() != null ? stats.getPerfectDraws() : 0; + + default -> 0; + }; +} +``` + +--- + +## 9. 멱등성 보장 + +### 9.1 중복 부여 방지 흐름 + +```mermaid +flowchart TB + START[checkAndAwardBadges] --> LOOP[배지 타입 순회] + LOOP --> CHECK{hasBadge?} + CHECK -->|이미 있음| SKIP[건너뛰기] + CHECK -->|없음| CONDITION{조건 충족?} + CONDITION -->|Yes| CREATE[배지 생성] + CONDITION -->|No| SKIP + CREATE --> SAVE[DynamoDB 저장] + SAVE --> LOOP + SKIP --> LOOP +``` + +### 9.2 구현 코드 + +```java +public List checkAndAwardBadges(String userId, UserStats stats) { + List newBadges = new ArrayList<>(); + String now = Instant.now().toString(); + + for (BadgeType type : BadgeType.values()) { + // 1. 이미 획득한 배지는 건너뛰기 + if (badgeRepository.hasBadge(userId, type.name())) { + continue; + } + + // 2. 조건 체크 + if (checkBadgeCondition(type, stats)) { + // 3. 배지 생성 및 저장 + UserBadge badge = createBadge(userId, type, now); + badgeRepository.save(badge); + newBadges.add(badge); + } + } + + return newBadges; +} +``` + +--- + +## 10. S3 이미지 연동 + +### 10.1 Presigned URL 생성 + +```mermaid +flowchart LR + REQ[배지 조회] --> SERVICE[BadgeService] + SERVICE --> PRESIGN[S3PresignUtil] + PRESIGN --> CACHE{캐시 확인} + CACHE -->|있음| RETURN[URL 반환] + CACHE -->|없음| GENERATE[Presigned URL 생성] + GENERATE --> SAVE[캐시 저장] + SAVE --> RETURN +``` + +### 10.2 이미지 URL 생성 + +```java +// S3PresignUtil.java +public static String getBadgeImageUrl(String imageFile) { + return getPresignedUrl("badges/" + imageFile); +} + +// BadgeService - 배지 생성 시 +private UserBadge createBadge(String userId, BadgeType type, String now) { + return UserBadge.builder() + .pk(BadgeKey.userBadgePk(userId)) + .sk(BadgeKey.badgeSk(type.name())) + .gsi1pk(BadgeKey.BADGE_ALL) + .gsi1sk(BadgeKey.earnedSk(now)) + .odUserId(userId) + .badgeType(type.name()) + .name(type.getName()) + .description(type.getDescription()) + .imageUrl(S3PresignUtil.getBadgeImageUrl(type.getImageFile())) + .category(type.getCategory()) + .threshold(type.getThreshold()) + .earnedAt(now) + .createdAt(now) + .build(); +} +``` + +### 10.3 S3 버킷 구조 + +``` +s3://group2-englishstudy/ +└── badges/ + ├── first_step.png + ├── streak_3.png + ├── streak_7.png + ├── streak_30.png + ├── words_100.png + ├── words_500.png + ├── words_1000.png + ├── perfect_score.png + ├── test_10.png + ├── accuracy_90.png + ├── game_first.png + ├── game_10_wins.png + ├── quick_guesser.png + ├── perfect_drawer.png + └── master.png +``` + +--- + +## 11. Stats 도메인 연동 + +### 11.1 연동 포인트 + +```mermaid +flowchart TB + subgraph Stats["Stats 도메인"] + STREAM[StatsStreamHandler] + DAILY[DailyStudyCommandService] + GAME[GameStatsService] + REPO[UserStatsRepository] + end + + subgraph Badge["Badge 도메인"] + SERVICE[BadgeService] + BADGEREPO[BadgeRepository] + end + + STREAM -->|checkAndAwardBadges| SERVICE + DAILY -->|checkWordsBadge| SERVICE + GAME -->|checkAndAwardBadges| SERVICE + SERVICE -->|hasBadge, save| BADGEREPO + SERVICE -->|findTotalStats| REPO +``` + +### 11.2 UserStats 필드와 배지 매핑 + +| UserStats 필드 | 배지 | +|------------------------------------|----------------------------------| +| testsCompleted | FIRST_STEP, TEST_10 | +| currentStreak | STREAK_3, STREAK_7, STREAK_30 | +| newWordsLearned + wordsReviewed | WORDS_100, WORDS_500, WORDS_1000 | +| correctAnswers / questionsAnswered | ACCURACY_90 | +| gamesPlayed | GAME_FIRST_PLAY | +| gamesWon | GAME_10_WINS | +| quickGuesses | QUICK_GUESSER | +| perfectDraws | PERFECT_DRAWER | + +--- + +## 12. 파일 구조 + +``` +domain/badge/ +├── enums/ +│ └── BadgeType.java # 16가지 배지 정의 +├── constants/ +│ └── BadgeKey.java # DynamoDB 키 생성 +├── model/ +│ └── UserBadge.java # 배지 엔티티 +├── repository/ +│ └── BadgeRepository.java # CRUD 연산 +├── service/ +│ └── BadgeService.java # 조건 체크, 배지 부여 +└── handler/ + └── BadgeHandler.java # REST API + +연동 파일: +├── domain/stats/handler/StatsStreamHandler.java +├── domain/vocabulary/service/DailyStudyCommandService.java +└── domain/chatting/service/GameStatsService.java +``` + +--- + +## 13. 기술 스택 + +- **Runtime:** AWS Lambda (Java 21) +- **Database:** DynamoDB (Single Table Design) +- **Storage:** S3 (배지 이미지) +- **Event:** DynamoDB Streams, Write-through, Service Method +- **Pattern:** Event-driven, Idempotent, Switch Expression +- **Java 21 Features:** Enhanced Switch, Yield Statement + +--- + +## 14. 배지 획득 시나리오 + +### 14.1 시나리오 예시 + +```mermaid +flowchart LR + subgraph Day1["1일차"] + A1[테스트 완료] --> B1["FIRST_STEP 획득"] + end + + subgraph Day3["3일차"] + A3[3일 연속 학습] --> B3["STREAK_3 획득"] + end + + subgraph Day7["7일차"] + A7[7일 연속 학습] --> B7["STREAK_7 획득"] + A7_2[100단어 학습] --> B7_2["WORDS_100 획득"] + end + + subgraph Game["게임"] + G1[5초 내 정답] --> G2["QUICK_GUESSER 획득"] + G3[10회 1등] --> G4["GAME_10_WINS 획득"] + end +``` + +### 14.2 특수 배지 획득 조건 + +**PERFECT_SCORE (완벽주의자):** + +- 테스트 제출 시 오답 0개이면 즉시 부여 +- StatsStreamHandler에서 별도 처리 + +**QUICK_GUESSER (번개 정답):** + +- 게임 중 5초(5000ms) 이내 정답 시 +- GameStatsService에서 quickGuesses 카운트 + +**PERFECT_DRAWER (완벽한 출제자):** + +- 출제 시 모든 참가자가 정답을 맞춘 경우 +- 라운드 종료 시 endReason == "ALL_CORRECT"이면 카운트 + +**MASTER (학습 마스터):** + +- 다른 모든 배지를 획득한 경우 +- 특수 로직으로 모든 배지 보유 여부 확인 From 76145c9072bcad94d492016e042ad81eabb1d64b Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:28:57 +0900 Subject: [PATCH 224/528] =?UTF-8?q?docs:=20Common=20=EB=AA=A8=EB=93=88=20?= =?UTF-8?q?=EC=84=B8=EB=B6=80=20=EB=B3=B4=EA=B3=A0=EC=84=9C=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1=20(Java=20=EA=B5=AC=ED=98=84=20=ED=8C=A8=ED=84=B4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/domain-reports/COMMON-MODULE-REPORT.md | 1228 +++++++++++++++++++ 1 file changed, 1228 insertions(+) create mode 100644 docs/domain-reports/COMMON-MODULE-REPORT.md diff --git a/docs/domain-reports/COMMON-MODULE-REPORT.md b/docs/domain-reports/COMMON-MODULE-REPORT.md new file mode 100644 index 00000000..aefe6d08 --- /dev/null +++ b/docs/domain-reports/COMMON-MODULE-REPORT.md @@ -0,0 +1,1228 @@ +# Common Module 세부 보고서 + +## 1. 개요 + +Common 모듈은 모든 도메인에서 공유하는 유틸리티, 설정, 예외 처리, 라우팅 등을 제공하는 핵심 인프라 모듈입니다. Java 21의 최신 기능(Records, Sealed Interface, Pattern +Matching)을 적극 활용하여 타입 안전성과 코드 간결성을 확보했습니다. + +--- + +## 2. 전체 패키지 구조 + +```mermaid +flowchart TB +subgraph Common["common/"] +CONFIG[config/] +CONST[constants/] +DTO[dto/] +ENUM[enums/] +EXCEPTION[exception/] +ROUTER[router/] +SERVICE[service/] +UTIL[util/] +VALIDATION[validation/] +end + +subgraph ConfigFiles["config/"] +AC[AwsClients.java] +WSC[WebSocketConfig.java] +RTC[RoomTokenConfig.java] +SC[StudyConfig.java] +end + +subgraph DtoFiles["dto/"] +AR[ApiResponse.java] +EI[ErrorInfo.java] +PR[PaginatedResult.java] +end + +subgraph ExceptionFiles["exception/"] +SE[ServerlessException.java] +EC[ErrorCode.java] +CEC[CommonErrorCode.java] +CE[CommonException.java] +end + +subgraph RouterFiles["router/"] +HR[HandlerRouter.java] +RT[Route.java] +AH[AuthenticatedHandler.java] +end + +CONFIG --> ConfigFiles +DTO --> DtoFiles +EXCEPTION --> ExceptionFiles +ROUTER --> RouterFiles +``` + +--- + +## 3. Handler 라우팅 시스템 + +### 3.1 HandlerRouter 아키텍처 + +```mermaid +flowchart TB + subgraph Request["요청 처리 흐름"] + REQ[APIGatewayProxyRequestEvent] --> ROUTER[HandlerRouter] + ROUTER --> MATCH{라우트 매칭} + MATCH -->|매칭 성공| VALIDATE[파라미터 검증] + MATCH -->|매칭 실패| NF404[404 Not Found] + VALIDATE --> EXECUTE[핸들러 실행] + EXECUTE --> RESPONSE[APIGatewayProxyResponseEvent] + end + + subgraph ErrorHandling["예외 처리"] + EXECUTE -->|ServerlessException| ERR1[ErrorCode 기반 응답] + EXECUTE -->|IllegalArgumentException| ERR2[400 Bad Request] + EXECUTE -->|IllegalStateException| ERR3[409 Conflict] + EXECUTE -->|SecurityException| ERR4[403 Forbidden] + EXECUTE -->|기타 예외| ERR5[500 Internal Error] + end +``` + +### 3.2 Route 정의 (Java 21 Record) + +```java +// Route.java - Java 21 Record 활용 +public record Route( + String method, // HTTP 메서드 + String pathPattern, // 경로 패턴 (e.g., "/rooms/{roomId}") + Function handler, + List requiredPathParams, // 필수 경로 파라미터 + List requiredQueryParams // 필수 쿼리 파라미터 + ) { + // 경로 파라미터 자동 추출: {roomId} → roomId + private static final Pattern PATH_PARAM_PATTERN = + Pattern.compile("\\{([^}]+)}"); +} +``` + +### 3.3 Route 팩토리 메서드 + +```mermaid +flowchart LR + subgraph BasicRoutes["기본 라우트"] + GET["Route.get()"] + POST["Route.post()"] + PUT["Route.put()"] + DELETE["Route.delete()"] + PATCH["Route.patch()"] + end + + subgraph AuthRoutes["인증 라우트"] + GETAUTH["Route.getAuth()"] + POSTAUTH["Route.postAuth()"] + PUTAUTH["Route.putAuth()"] + DELETEAUTH["Route.deleteAuth()"] + PATCHAUTH["Route.patchAuth()"] + end + + BasicRoutes -->|" + Cognito 인증 "| AuthRoutes +``` + +### 3.4 사용 예시 + +```java +// Handler에서 라우터 초기화 +private HandlerRouter initRouter() { + return new HandlerRouter().addRoutes( + // 인증 필요 라우트 (Cognito userId 자동 추출) + Route.postAuth("/grammar/check", this::checkGrammar), + Route.getAuth("/grammar/sessions/{sessionId}", this::getSessionDetail), + Route.deleteAuth("/grammar/sessions/{sessionId}", this::deleteSession), + + // 쿼리 파라미터 검증 + Route.getAuth("/rooms", this::getRooms) + .requireQueryParams("level") + ); +} + +// Lambda 핸들러 메서드 +@Override +public APIGatewayProxyResponseEvent handleRequest( + APIGatewayProxyRequestEvent request, Context context) { + return router.route(request); +} +``` + +### 3.5 AuthenticatedHandler 인터페이스 + +```java +// 함수형 인터페이스 - Cognito 인증 요청 처리 +@FunctionalInterface +public interface AuthenticatedHandler { + APIGatewayProxyResponseEvent handle( + APIGatewayProxyRequestEvent request, + String userId // Cognito sub claim에서 자동 추출 + ); +} + +// 사용 예시 - 람다 표현식으로 간결하게 +Route. + +postAuth("/rooms",(request, userId) ->{ +CreateRoomRequest dto = parseBody(request, CreateRoomRequest.class); +ChatRoom room = roomService.createRoom(userId, dto); + return ResponseGenerator. + +created("Room created",room); +}); +``` + +--- + +## 4. 예외 처리 시스템 + +### 4.1 ErrorCode 계층 구조 (Sealed Interface) + +```mermaid +flowchart TB + subgraph SealedHierarchy["Java 21 Sealed Interface 계층"] + EC[/"ErrorCode
(sealed interface)"/] + EC -->|permits| CEC["CommonErrorCode
(enum)"] + EC -->|permits| DEC[/"DomainErrorCode
(non-sealed interface)"/] + DEC --> VEC["VocabularyErrorCode"] + DEC --> CHEC["ChattingErrorCode"] + DEC --> GEC["GrammarErrorCode"] + DEC --> SEC["StatsErrorCode"] + DEC --> BEC["BadgeErrorCode"] + end +``` + +### 4.2 CommonErrorCode 정의 + +```java +public enum CommonErrorCode implements ErrorCode { + // 인증/인가 (AUTH_xxx) + UNAUTHORIZED("AUTH_001", "인증이 필요합니다", 401), + FORBIDDEN("AUTH_002", "접근 권한이 없습니다", 403), + INVALID_TOKEN("AUTH_003", "유효하지 않은 토큰입니다", 401), + TOKEN_EXPIRED("AUTH_004", "토큰이 만료되었습니다", 401), + + // 검증 (VALIDATION_xxx) + INVALID_INPUT("VALIDATION_001", "잘못된 입력입니다", 400), + REQUIRED_FIELD_MISSING("VALIDATION_002", "필수 필드가 누락되었습니다", 400), + INVALID_FORMAT("VALIDATION_003", "형식이 올바르지 않습니다", 400), + VALUE_OUT_OF_RANGE("VALIDATION_004", "값이 허용 범위를 벗어났습니다", 400), + + // 리소스 (RESOURCE_xxx) + RESOURCE_NOT_FOUND("RESOURCE_001", "리소스를 찾을 수 없습니다", 404), + RESOURCE_ALREADY_EXISTS("RESOURCE_002", "이미 존재하는 리소스입니다", 409), + METHOD_NOT_ALLOWED("RESOURCE_003", "허용되지 않는 메서드입니다", 405), + + // 시스템 (SYSTEM_xxx) + INTERNAL_SERVER_ERROR("SYSTEM_001", "내부 서버 오류가 발생했습니다", 500), + DATABASE_ERROR("SYSTEM_002", "데이터베이스 오류가 발생했습니다", 500), + EXTERNAL_API_ERROR("SYSTEM_003", "외부 API 호출 오류가 발생했습니다", 502), + SERVICE_UNAVAILABLE("SYSTEM_004", "서비스를 일시적으로 사용할 수 없습니다", 503); + + private final String code; + private final String message; + private final int statusCode; +} +``` + +### 4.3 예외 생성 팩토리 패턴 + +```mermaid +flowchart LR + subgraph FactoryMethods["CommonException 팩토리 메서드"] + AUTH["인증 오류"] + VALID["검증 오류"] + RES["리소스 오류"] + SYS["시스템 오류"] + end + + AUTH --> UNAUTH["unauthorized()"] + AUTH --> FORBID["forbidden()"] + AUTH --> TOKEN["invalidToken()"] + VALID --> INPUT["invalidInput(msg)"] + VALID --> MISS["requiredFieldMissing(field)"] + VALID --> FMT["invalidFormat(field)"] + RES --> NF["notFound(resource, id)"] + RES --> EXIST["alreadyExists(resource)"] + SYS --> INTERN["internalError(cause)"] + SYS --> DB["databaseError(cause)"] + SYS --> EXT["externalApiError(api, cause)"] +``` + +### 4.4 예외 사용 예시 + +```java +// 가독성 높은 예외 생성 +throw CommonException.notFound("User","user123"); +// → "User (ID: user123)를 찾을 수 없습니다", 404 + +throw CommonException. + +invalidInput("Email format is invalid"); +// → 400 INVALID_INPUT with custom message + +throw CommonException. + +alreadyExists("ChatRoom","room456"); +// → "ChatRoom (ID: room456)가 이미 존재합니다", 409 + +// 상세 컨텍스트 추가 (메서드 체이닝) +throw CommonException. + +internalError(cause) + . + +addDetail("operation","database_query") + . + +addDetail("table","users"); +``` + +--- + +## 5. AWS 클라이언트 관리 + +### 5.1 Singleton 패턴 (Cold Start 최적화) + +```mermaid +flowchart TB + subgraph ColdStart["Lambda Cold Start 최적화"] + INIT["Lambda 컨테이너 초기화
(1회)"] + STATIC["static final 클라이언트 생성"] + REUSE["요청마다 재사용"] + end + + INIT --> STATIC + STATIC --> REUSE + REUSE -->|" 다음 요청 "| REUSE +``` + +### 5.2 AwsClients.java 구조 + +```java +public final class AwsClients { + // DynamoDB (Enhanced Client 포함) + private static final DynamoDbClient DYNAMO_DB_CLIENT = + DynamoDbClient.builder().build(); + private static final DynamoDbEnhancedClient DYNAMO_DB_ENHANCED_CLIENT = + DynamoDbEnhancedClient.builder() + .dynamoDbClient(DYNAMO_DB_CLIENT) + .build(); + + // S3 (Presigner 포함) + private static final S3Client S3_CLIENT = S3Client.builder().build(); + private static final S3Presigner S3_PRESIGNER = S3Presigner.builder().build(); + + // AI/ML 서비스 + private static final PollyClient POLLY_CLIENT = PollyClient.builder().build(); + private static final BedrockRuntimeClient BEDROCK_CLIENT = + BedrockRuntimeClient.builder().build(); + private static final BedrockRuntimeAsyncClient BEDROCK_ASYNC_CLIENT = + BedrockRuntimeAsyncClient.builder().build(); + private static final ComprehendClient COMPREHEND_CLIENT = + ComprehendClient.builder().build(); + + // SNS + private static final SnsClient SNS_CLIENT = SnsClient.builder().build(); + + // 팩토리 메서드 + public static DynamoDbClient dynamoDb() { + return DYNAMO_DB_CLIENT; + } + + public static DynamoDbEnhancedClient dynamoDbEnhanced() { + return DYNAMO_DB_ENHANCED_CLIENT; + } + + public static S3Client s3() { + return S3_CLIENT; + } + + public static S3Presigner s3Presigner() { + return S3_PRESIGNER; + } + + public static PollyClient polly() { + return POLLY_CLIENT; + } + + public static BedrockRuntimeClient bedrock() { + return BEDROCK_CLIENT; + } + + public static BedrockRuntimeAsyncClient bedrockAsync() { + return BEDROCK_ASYNC_CLIENT; + } + + public static ComprehendClient comprehend() { + return COMPREHEND_CLIENT; + } + + public static SnsClient sns() { + return SNS_CLIENT; + } +} +``` + +### 5.3 사용 예시 + +```java +// Service에서 사용 +public class PollyService { + public VoiceSynthesisResult synthesizeSpeech(String id, String text, String voice) { + SynthesizeSpeechRequest request = SynthesizeSpeechRequest.builder() + .text(text) + .voiceId(VoiceId.MATTHEW) + .engine("neural") + .outputFormat(OutputFormat.MP3) + .build(); + + // Singleton 클라이언트 사용 + InputStream audioStream = AwsClients.polly().synthesizeSpeech(request); + AwsClients.s3().putObject(putRequest, RequestBody.fromInputStream(audioStream, -1)); + + return new VoiceSynthesisResult(s3Key, presignedUrl, false); + } +} +``` + +--- + +## 6. DTO 패턴 (Java 21 Records) + +### 6.1 ApiResponse (제네릭 응답 래퍼) + +```java +// 불변 데이터 클래스 - Java 21 Record +public record ApiResponse( + boolean isSuccess, + String message, + T data, + String error + ) { + // 성공 응답 팩토리 + public static ApiResponse ok(String message, T data) { + return new ApiResponse<>(true, message, data, null); + } + + public static ApiResponse ok(T data) { + return new ApiResponse<>(true, null, data, null); + } + + // 실패 응답 팩토리 + public static ApiResponse fail(String errorMessage) { + return new ApiResponse<>(false, null, null, errorMessage); + } +} +``` + +**JSON 응답 예시:** + +```json +{ + "isSuccess": true, + "message": "Grammar checked successfully", + "data": { + "correctedSentence": "I am a student", + "score": 85, + "errors": [ + ... + ] + }, + "error": null +} +``` + +### 6.2 ErrorInfo (RFC 7807 준수) + +```java +// Problem Details for HTTP APIs (RFC 7807) +public record ErrorInfo( + String code, // e.g., "VOCABULARY.WORD_001" + String message, // e.g., "단어를 찾을 수 없습니다" + int status, // e.g., 404 + Map details // Optional context + ) { + public static ErrorInfo from(ErrorCode errorCode) { ...} + + public static ErrorInfo from(ServerlessException ex) { ...} + + public boolean isClientError() { + return status >= 400 && status < 500; + } + + public boolean isServerError() { + return status >= 500 && status < 600; + } +} +``` + +**JSON 에러 응답 예시:** + +```json +{ + "code": "VOCABULARY.WORD_001", + "message": "단어를 찾을 수 없습니다", + "status": 404, + "details": { + "wordId": "abc-123", + "userId": "user456" + } +} +``` + +### 6.3 PaginatedResult (커서 페이지네이션) + +```java +public record PaginatedResult( + List items, + String nextCursor // Base64 인코딩된 DynamoDB lastEvaluatedKey +) { + public boolean hasMore() { + return nextCursor != null; + } +} +``` + +--- + +## 7. 페이지네이션 유틸리티 + +### 7.1 CursorUtil 동작 흐름 + +```mermaid +sequenceDiagram + participant Client + participant Handler + participant CursorUtil + participant DynamoDB + Note over Client, DynamoDB: 첫 페이지 요청 + Client ->> Handler: GET /items?limit=10 + Handler ->> CursorUtil: decode(null) → null + Handler ->> DynamoDB: Query (exclusiveStartKey=null) + DynamoDB -->> Handler: items + lastEvaluatedKey + Handler ->> CursorUtil: encode(lastEvaluatedKey) + CursorUtil -->> Handler: "dXNlcklkPXVzZXIxMjM..." + Handler -->> Client: {"items": [...], "nextCursor": "dXNlcklkPXVzZXIxMjM..."} + Note over Client, DynamoDB: 다음 페이지 요청 + Client ->> Handler: GET /items?cursor=dXNlcklkPXVzZXIxMjM... + Handler ->> CursorUtil: decode("dXNlcklkPXVzZXIxMjM...") + CursorUtil -->> Handler: {"userId": "user123", ...} + Handler ->> DynamoDB: Query (exclusiveStartKey={...}) + DynamoDB -->> Handler: items + lastEvaluatedKey +``` + +### 7.2 CursorUtil 구현 + +```java +public class CursorUtil { + // DynamoDB lastEvaluatedKey → Base64 문자열 + public static String encode(Map lastEvaluatedKey) { + if (lastEvaluatedKey == null || lastEvaluatedKey.isEmpty()) { + return null; + } + + StringBuilder sb = new StringBuilder(); + for (Map.Entry entry : lastEvaluatedKey.entrySet()) { + if (sb.length() > 0) sb.append("|"); + sb.append(entry.getKey()).append("=").append(entry.getValue().s()); + } + + return Base64.getUrlEncoder().encodeToString(sb.toString().getBytes()); + } + + // Base64 문자열 → DynamoDB exclusiveStartKey + public static Map decode(String cursor) { + if (cursor == null || cursor.isEmpty()) { + return null; + } + + String decoded = new String(Base64.getUrlDecoder().decode(cursor)); + Map startKey = new HashMap<>(); + + for (String pair : decoded.split("\\|")) { + String[] kv = pair.split("=", 2); + if (kv.length == 2) { + startKey.put(kv[0], AttributeValue.builder().s(kv[1]).build()); + } + } + + return startKey; + } +} +``` + +--- + +## 8. 인증 유틸리티 + +### 8.1 Cognito 인증 흐름 + +```mermaid +flowchart TB + subgraph CognitoAuth["Cognito 인증 흐름"] + REQ[요청] --> AUTH[API Gateway Authorizer] + AUTH --> CLAIMS[JWT Claims 추출] + CLAIMS --> INJECT["requestContext.authorizer.claims"] + end + + subgraph CognitoUtil["CognitoUtil 추출"] + INJECT --> EXTRACT[extractUserId] + EXTRACT --> SUB["claims.sub → userId"] + end +``` + +### 8.2 CognitoUtil.java + +```java +public class CognitoUtil { + // 기본 userId 추출 (sub claim) + public static String extractUserId(APIGatewayProxyRequestEvent request) { + Map authorizer = request.getRequestContext().getAuthorizer(); + if (authorizer == null) return null; + + Map claims = (Map) authorizer.get("claims"); + return claims != null ? claims.get("sub") : null; + } + + // 선택적 claim 추출 + public static Optional extractEmail(APIGatewayProxyRequestEvent request) { + return extractClaim(request, "email"); + } + + public static Optional extractNickname(APIGatewayProxyRequestEvent request) { + return extractClaim(request, "custom:nickname"); + } + + public static Optional extractClaim( + APIGatewayProxyRequestEvent request, String claimName) { + // ... claim 추출 로직 + } + + // 사용자 접근 권한 검증 + public static boolean validateUserAccess( + APIGatewayProxyRequestEvent request, String pathUserId) { + String tokenUserId = extractUserId(request); + return tokenUserId != null && tokenUserId.equals(pathUserId); + } +} +``` + +### 8.3 JwtUtil.java (WebSocket용) + +```java +// WebSocket 연결 시 직접 JWT 파싱 (Authorizer 미사용) +public final class JwtUtil { + public static Optional extractUserId(String token) { + // Bearer 제거 + if (token.startsWith("Bearer ")) { + token = token.substring(7); + } + + // JWT payload 추출 (헤더.페이로드.시그니처) + String[] parts = token.split("\\."); + if (parts.length != 3) return Optional.empty(); + + // Base64 URL 디코딩 + String payload = new String(Base64.getUrlDecoder().decode(parts[1])); + Map claims = gson.fromJson(payload, Map.class); + + return Optional.ofNullable((String) claims.get("sub")); + } + + public static boolean isExpired(String token) { + // exp claim 확인 + } +} +``` + +--- + +## 9. HTTP 응답 생성 + +### 9.1 ResponseGenerator.java + +```java +public class ResponseGenerator { + private static final Gson GSON = new GsonBuilder() + .setDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'") + .create(); + + private static final Map CORS_HEADERS = Map.of( + "Content-Type", "application/json", + "Access-Control-Allow-Origin", "*", + "Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS", + "Access-Control-Allow-Headers", "Content-Type,Authorization" + ); + + // 성공 응답 + public static APIGatewayProxyResponseEvent ok(String message, T data) { + return buildResponse(200, ApiResponse.ok(message, data)); + } + + public static APIGatewayProxyResponseEvent created(String message, T data) { + return buildResponse(201, ApiResponse.ok(message, data)); + } + + public static APIGatewayProxyResponseEvent noContent() { + return buildResponse(204, null); + } + + // 에러 응답 + public static APIGatewayProxyResponseEvent fail(ErrorCode errorCode) { + return buildResponse(errorCode.getStatusCode(), ErrorInfo.from(errorCode)); + } + + public static APIGatewayProxyResponseEvent badRequest(String message) { + return fail(CommonErrorCode.INVALID_INPUT, message); + } + + public static APIGatewayProxyResponseEvent notFound(String message) { + return fail(CommonErrorCode.RESOURCE_NOT_FOUND, message); + } + + // ... 기타 편의 메서드 + + private static APIGatewayProxyResponseEvent buildResponse(int statusCode, Object body) { + return new APIGatewayProxyResponseEvent() + .withStatusCode(statusCode) + .withHeaders(new HashMap<>(CORS_HEADERS)) + .withBody(body != null ? GSON.toJson(body) : null); + } + + public static Gson gson() { + return GSON; + } +} +``` + +--- + +## 10. Bean Validation + +### 10.1 BeanValidator 패턴 + +```mermaid +flowchart TB + REQ[요청 수신] --> PARSE[JSON 파싱 → DTO] +PARSE --> VALIDATE[BeanValidator.validateAndExecute] +VALIDATE --> CHECK{검증 통과?} +CHECK -->|Yes|HANDLER[핸들러 로직 실행] +CHECK -->|No|ERR400[400 Bad Request] +HANDLER --> RESPONSE[정상 응답] +``` + +### 10.2 BeanValidator.java + +```java +public final class BeanValidator { + private static final Validator VALIDATOR; + + static { + ValidatorFactory factory = Validation.buildDefaultValidatorFactory(); + VALIDATOR = factory.getValidator(); + } + + // 검증 + 실행 통합 패턴 + public static APIGatewayProxyResponseEvent validateAndExecute( + T object, + Function handler) { + + Optional error = validate(object); + if (error.isPresent()) { + return ResponseGenerator.badRequest(error.get()); + } + + return handler.apply(object); + } + + public static Optional validate(T object) { + Set> violations = VALIDATOR.validate(object); + if (violations.isEmpty()) { + return Optional.empty(); + } + + String message = violations.stream() + .map(ConstraintViolation::getMessage) + .collect(Collectors.joining(", ")); + + return Optional.of(message); + } +} +``` + +### 10.3 DTO 검증 예시 + +```java +// 요청 DTO +public class CreateRoomRequest { + @NotEmpty(message = "방 이름은 필수입니다") + private String roomName; + + @NotNull(message = "난이도는 필수입니다") + private String difficulty; + + @Min(value = 2, message = "최소 2명 이상이어야 합니다") + @Max(value = 10, message = "최대 10명까지 가능합니다") + private int maxMembers; +} + +// Handler에서 사용 +private APIGatewayProxyResponseEvent createRoom( + APIGatewayProxyRequestEvent request, String userId) { + + CreateRoomRequest req = ResponseGenerator.gson() + .fromJson(request.getBody(), CreateRoomRequest.class); + + return BeanValidator.validateAndExecute(req, dto -> { + // 검증 통과 시에만 실행됨 + ChatRoom room = roomService.createRoom(userId, dto); + return ResponseGenerator.created("방이 생성되었습니다", room); + }); +} +``` + +--- + +## 11. WebSocket 유틸리티 + +### 11.1 브로드캐스트 흐름 + +```mermaid +sequenceDiagram + participant Service + participant Broadcaster as WebSocketBroadcaster + participant APIGW as API Gateway + participant Clients as WebSocket Clients + Service ->> Broadcaster: broadcast(connections, message) + + loop 각 연결에 전송 + Broadcaster ->> APIGW: postToConnection(connectionId, data) + alt 성공 + APIGW -->> Clients: 메시지 전달 + else 연결 끊김 (410 Gone) + APIGW -->> Broadcaster: GoneException + Broadcaster ->> Broadcaster: failedIds에 추가 + end + end + + Broadcaster -->> Service: failedConnectionIds 반환 + Service ->> Service: Stale 연결 정리 +``` + +### 11.2 WebSocketBroadcaster.java + +```java +public class WebSocketBroadcaster { + private final ApiGatewayManagementApiClient apiClient; + + public WebSocketBroadcaster() { + String endpoint = WebSocketConfig.websocketEndpoint(); + this.apiClient = ApiGatewayManagementApiClient.builder() + .endpointOverride(URI.create(endpoint)) + .build(); + } + + // 단일 연결에 전송 + public boolean sendToConnection(String connectionId, String message) { + try { + apiClient.postToConnection(PostToConnectionRequest.builder() + .connectionId(connectionId) + .data(SdkBytes.fromUtf8String(message)) + .build()); + return true; + } catch (GoneException e) { + // 연결이 이미 끊김 + return false; + } + } + + // 다수 연결에 브로드캐스트 + public List broadcast(List connections, String message) { + List failedIds = new ArrayList<>(); + + for (Connection conn : connections) { + if (!sendToConnection(conn.getConnectionId(), message)) { + failedIds.add(conn.getConnectionId()); + } + } + + return failedIds; // 실패한 연결 ID 반환 (정리용) + } +} +``` + +### 11.3 WebSocket 응답 유틸리티 + +```java +public final class WebSocketResponseUtil { + public static Map ok(String message) { + return response(200, message); + } + + public static Map unauthorized(String message) { + return response(401, message); + } + + public static Map badRequest(String message) { + return response(400, message); + } + + private static Map response(int statusCode, String body) { + return Map.of( + "statusCode", statusCode, + "body", body + ); + } +} +``` + +--- + +## 12. S3 Presigned URL + +### 12.1 S3PresignUtil.java + +```java +public class S3PresignUtil { + private static final Duration DEFAULT_DURATION = Duration.ofHours(24); + private static final String BUCKET_NAME = System.getenv("S3_BUCKET_NAME"); + + // 내부 캐시 (Java 21 Record) + private record CachedUrl(String url, long expiresAt) { + boolean isExpired() { + // 1시간 버퍼 두고 만료 체크 + return System.currentTimeMillis() > (expiresAt - 3600_000); + } + } + + private static final Map URL_CACHE = new ConcurrentHashMap<>(); + + public static String getPresignedUrl(String key) { + return getPresignedUrl(key, DEFAULT_DURATION); + } + + public static String getPresignedUrl(String key, Duration duration) { + CachedUrl cached = URL_CACHE.get(key); + if (cached != null && !cached.isExpired()) { + return cached.url(); + } + + GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() + .signatureDuration(duration) + .getObjectRequest(r -> r.bucket(BUCKET_NAME).key(key)) + .build(); + + String url = AwsClients.s3Presigner() + .presignGetObject(presignRequest) + .url() + .toString(); + + URL_CACHE.put(key, new CachedUrl(url, + System.currentTimeMillis() + duration.toMillis())); + + return url; + } + + // 배지 이미지 URL 생성 편의 메서드 + public static String getBadgeImageUrl(String imageFile) { + return getPresignedUrl("badges/" + imageFile); + } +} +``` + +--- + +## 13. AWS 서비스 래퍼 + +### 13.1 PollyService (TTS + S3 캐시) + +```mermaid +flowchart TB + REQ[음성 합성 요청] --> CHECK{S3 캐시 확인} + CHECK -->|캐시 있음| PRESIGN[Presigned URL 생성] + CHECK -->|캐시 없음| SYNTH[Polly 음성 합성] + SYNTH --> SAVE[S3 저장] + SAVE --> PRESIGN + PRESIGN --> RETURN[URL 반환] +``` + +```java +public class PollyService { + public VoiceSynthesisResult synthesizeSpeech(String id, String text, String voice) { + String s3Key = generateS3Key(id, voice); + + // 캐시 확인 + if (existsInS3(s3Key)) { + return new VoiceSynthesisResult(s3Key, getPresignedUrl(s3Key), true); + } + + // Polly 음성 합성 + VoiceId voiceId = "MALE".equalsIgnoreCase(voice) ? VoiceId.MATTHEW : VoiceId.JOANNA; + + SynthesizeSpeechRequest request = SynthesizeSpeechRequest.builder() + .text(text) + .voiceId(voiceId) + .engine("neural") // Neural 음성 (고품질) + .outputFormat(OutputFormat.MP3) + .build(); + + InputStream audioStream = AwsClients.polly().synthesizeSpeech(request); + + // S3 저장 + AwsClients.s3().putObject( + PutObjectRequest.builder() + .bucket(bucketName) + .key(s3Key) + .contentType("audio/mpeg") + .build(), + RequestBody.fromInputStream(audioStream, -1) + ); + + return new VoiceSynthesisResult(s3Key, getPresignedUrl(s3Key), false); + } + + public String generateS3Key(String id, String voice) { + String suffix = "MALE".equalsIgnoreCase(voice) ? "male" : "female"; + return s3KeyPrefix + id + "_" + suffix + ".mp3"; + } +} +``` + +### 13.2 ComprehendService (NLP 분석) + +```java +public class ComprehendService { + public ComprehendAnalysis analyze(String text) { + // 감정 분석 + DetectSentimentResponse sentiment = AwsClients.comprehend() + .detectSentiment(DetectSentimentRequest.builder() + .text(text) + .languageCode("en") + .build()); + + // 구문 분석 (품사 태깅) + DetectSyntaxResponse syntax = AwsClients.comprehend() + .detectSyntax(DetectSyntaxRequest.builder() + .text(text) + .languageCode("en") + .build()); + + // 핵심 구문 추출 + DetectKeyPhrasesResponse keyPhrases = AwsClients.comprehend() + .detectKeyPhrases(DetectKeyPhrasesRequest.builder() + .text(text) + .languageCode("en") + .build()); + + // 문장 복잡도 계산 + String complexity = calculateComplexity(syntax.syntaxTokens()); + + return ComprehendAnalysis.builder() + .sentiment(sentiment.sentimentAsString()) + .syntax(mapTokens(syntax.syntaxTokens())) + .keyPhrases(mapKeyPhrases(keyPhrases.keyPhrases())) + .complexity(complexity) + .build(); + } + + private String calculateComplexity(List tokens) { + Set uniquePOS = tokens.stream() + .map(t -> t.partOfSpeech().tagAsString()) + .collect(Collectors.toSet()); + + if (uniquePOS.size() <= 3 && tokens.size() <= 5) return "BEGINNER"; + if (uniquePOS.size() <= 5 && tokens.size() <= 10) return "INTERMEDIATE"; + return "ADVANCED"; + } +} +``` + +--- + +## 14. 설정 클래스 + +### 14.1 StudyConfig (학습 알고리즘 상수) + +```java +public final class StudyConfig { + // SM-2 알고리즘 상수 + public static final int INITIAL_INTERVAL_DAYS = 1; + public static final double DEFAULT_EASE_FACTOR = 2.5; + public static final double MIN_EASE_FACTOR = 1.3; + public static final int INITIAL_REPETITIONS = 0; + + // 테스트 설정 + public static final int DEFAULT_WORD_COUNT = 20; + public static final int DAILY_TEST_WORD_COUNT = 10; + + // 복습 주기 (일) + public static final int[] REVIEW_INTERVALS = {1, 3, 7, 14, 30}; + + // 상태 기본값 + public static final String DEFAULT_WORD_STATUS = "NEW"; + public static final String DEFAULT_DIFFICULTY = "NORMAL"; + + // 오류 제한 + public static final int MAX_WRONG_COUNT = 3; +} +``` + +### 14.2 DynamoDbKey (키 패턴 상수) + +```java +public final class DynamoDbKey { + // 기본 키 + public static final String PK = "PK"; + public static final String SK = "SK"; + + // GSI 키 + public static final String GSI1_PK = "GSI1PK"; + public static final String GSI1_SK = "GSI1SK"; + public static final String GSI2_PK = "GSI2PK"; + public static final String GSI2_SK = "GSI2SK"; + + // GSI 이름 + public static final String GSI1 = "GSI1"; + public static final String GSI2 = "GSI2"; + + // 공통 접두사 + public static final String USER = "USER#"; + public static final String METADATA = "METADATA"; + + // 헬퍼 메서드 + public static String userPk(String userId) { + return USER + userId; // "USER#user-123" + } +} +``` + +--- + +## 15. Java 21 기능 활용 + +### 15.1 Records 활용 + +| 클래스 | 용도 | +|-----------------|----------------| +| ApiResponse | 제네릭 API 응답 래퍼 | +| ErrorInfo | RFC 7807 에러 응답 | +| PaginatedResult | 페이지네이션 결과 | +| Route | HTTP 라우트 정의 | +| RouteEntry | 라우터 내부 매칭 | +| CachedUrl | S3 URL 캐시 | + +### 15.2 Sealed Interface 활용 + +```mermaid +flowchart TB + subgraph SealedPattern["Sealed Interface 패턴"] + EC[/"sealed interface ErrorCode
permits CommonErrorCode, DomainErrorCode"/] + CEC["final enum CommonErrorCode
implements ErrorCode"] + DEC[/"non-sealed interface DomainErrorCode
extends ErrorCode"/] + EC --> CEC + EC --> DEC + end +``` + +### 15.3 Pattern Matching 활용 + +```java +// instanceof 패턴 매칭 +String code = errorCode instanceof DomainErrorCode domainCode + ? domainCode.getFullCode() // "VOCABULARY.WORD_001" + : errorCode.getCode(); // "AUTH_001" + +// switch 표현식 (Enhanced) +return switch(type. + +getCategory()){ + case"FIRST_STUDY"->stats. + +getTestsCompleted() >=1; + case"STREAK"->stats. + +getCurrentStreak() >=type. + +getThreshold(); + case"ACCURACY"->{ +double accuracy = (double) stats.getCorrectAnswers() / stats.getQuestionsAnswered() * 100; +yield accuracy >=type. + +getThreshold(); + } +default ->false; + }; +``` + +--- + +## 16. 디자인 패턴 요약 + +| 패턴 | 적용 위치 | 목적 | +|----------------------|------------------------|-------------------| +| **Singleton** | AwsClients | AWS SDK 클라이언트 재사용 | +| **Factory Method** | Route, CommonException | 객체 생성 캡슐화 | +| **Strategy** | AuthenticatedHandler | 요청 처리 전략 분리 | +| **Router** | HandlerRouter | HTTP 요청 라우팅 | +| **Builder** | ComprehendAnalysis | 복잡한 객체 생성 | +| **Template Method** | BeanValidator | 검증-실행 흐름 템플릿 | +| **Sealed Interface** | ErrorCode 계층 | 구현 제한 | +| **Data Class** | Records | 불변 데이터 전송 | + +--- + +## 17. 파일 구조 + +``` +common/ +├── config/ +│ ├── AwsClients.java # AWS SDK 클라이언트 싱글톤 +│ ├── WebSocketConfig.java # WebSocket 설정 +│ ├── RoomTokenConfig.java # 방 토큰 TTL 설정 +│ └── StudyConfig.java # 학습 알고리즘 상수 +├── constants/ +│ └── DynamoDbKey.java # DynamoDB 키 패턴 +├── dto/ +│ ├── ApiResponse.java # 제네릭 응답 래퍼 (Record) +│ ├── ErrorInfo.java # RFC 7807 에러 (Record) +│ └── PaginatedResult.java # 페이지네이션 (Record) +├── enums/ +│ ├── Difficulty.java # EASY, NORMAL, HARD +│ └── StudyLevel.java # BEGINNER, INTERMEDIATE, ADVANCED +├── exception/ +│ ├── ServerlessException.java # 기본 예외 클래스 +│ ├── ErrorCode.java # Sealed Interface +│ ├── CommonErrorCode.java # 공통 에러 코드 +│ ├── DomainErrorCode.java # 도메인 에러 인터페이스 +│ └── CommonException.java # 예외 팩토리 +├── router/ +│ ├── HandlerRouter.java # HTTP 라우터 +│ ├── Route.java # 라우트 정의 (Record) +│ └── AuthenticatedHandler.java # 인증 핸들러 인터페이스 +├── service/ +│ ├── PollyService.java # TTS + S3 캐시 +│ └── ComprehendService.java # NLP 분석 +├── util/ +│ ├── ResponseGenerator.java # HTTP 응답 빌더 +│ ├── CursorUtil.java # 커서 페이지네이션 +│ ├── CognitoUtil.java # Cognito 인증 추출 +│ ├── JwtUtil.java # JWT 직접 파싱 +│ ├── WebSocketBroadcaster.java # WebSocket 브로드캐스트 +│ ├── WebSocketEventUtil.java # WebSocket 이벤트 추출 +│ ├── WebSocketResponseUtil.java # WebSocket 응답 빌더 +│ └── S3PresignUtil.java # Presigned URL 생성 +└── validation/ + └── BeanValidator.java # Bean Validation 유틸 +``` + +--- + +## 18. 기술 스택 + +- **Runtime:** AWS Lambda (Java 21) +- **Build:** Gradle +- **AWS SDK:** AWS SDK for Java v2 +- **Validation:** Jakarta Bean Validation +- **JSON:** Gson +- **Pattern:** Singleton, Factory, Strategy, Router, Builder, Sealed Interface +- **Java 21 Features:** Records, Sealed Interface, Pattern Matching, Enhanced Switch From 69ddfbb122fbadf19cd8db38f402bd6bceffc1c4 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:30:09 +0900 Subject: [PATCH 225/528] =?UTF-8?q?chore:=20GitHub-Jira=20issue=20sync=20?= =?UTF-8?q?=EC=9B=8C=ED=81=AC=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/github-jira-issue-sync.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/github-jira-issue-sync.yml b/.github/workflows/github-jira-issue-sync.yml index 1238f1f9..ef1637e9 100644 --- a/.github/workflows/github-jira-issue-sync.yml +++ b/.github/workflows/github-jira-issue-sync.yml @@ -3,7 +3,7 @@ name: Issue-Jira Sync on: issues: - types: [opened] + types: [ opened ] permissions: issues: write @@ -93,4 +93,4 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} issue-number: ${{ github.event.issue.number }} body: | - Jira: [${{ steps.create-jira.outputs.issue }}](${{ secrets.JIRA_BASE_URL }}/browse/${{ steps.create-jira.outputs.issue }}) \ No newline at end of file + Jira: [${{ steps.create-jira.outputs.issue }}](${{ secrets.JIRA_BASE_URL }}/browse/${{ steps.create-jira.outputs.issue }}) From bb31c8b336fb529b41c3f7549497854205b8590d Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:30:09 +0900 Subject: [PATCH 226/528] =?UTF-8?q?chore:=20GitHub-Jira=20PR=20sync=20?= =?UTF-8?q?=EC=9B=8C=ED=81=AC=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/github-jira-pr-sync.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/github-jira-pr-sync.yml b/.github/workflows/github-jira-pr-sync.yml index e348a536..9dd0ae18 100644 --- a/.github/workflows/github-jira-pr-sync.yml +++ b/.github/workflows/github-jira-pr-sync.yml @@ -3,7 +3,7 @@ name: PR-Jira Sync on: pull_request_target: - types: [opened] + types: [ opened ] branches: - develop - main @@ -79,4 +79,4 @@ jobs: repo: context.repo.repo, issue_number: context.payload.pull_request.number, body: `Jira: [${jiraKey}](${jiraUrl})` - }); \ No newline at end of file + }); From 1b5a97c5908487b6f16bb2b339fe88d2cf4b0c68 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:30:09 +0900 Subject: [PATCH 227/528] =?UTF-8?q?chore:=20FRONTEND-DEVELOPMENT-GUIDE.md?= =?UTF-8?q?=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../docs/FRONTEND-DEVELOPMENT-GUIDE.md | 478 ------------------ 1 file changed, 478 deletions(-) delete mode 100644 ServerlessFunction/docs/FRONTEND-DEVELOPMENT-GUIDE.md diff --git a/ServerlessFunction/docs/FRONTEND-DEVELOPMENT-GUIDE.md b/ServerlessFunction/docs/FRONTEND-DEVELOPMENT-GUIDE.md deleted file mode 100644 index d708a28e..00000000 --- a/ServerlessFunction/docs/FRONTEND-DEVELOPMENT-GUIDE.md +++ /dev/null @@ -1,478 +0,0 @@ -# 프론트엔드 개발 가이드 - -## 개요 - -이 문서는 영어 학습 플랫폼 백엔드 API의 프론트엔드 개발 가이드입니다. - -## 기본 정보 - -### Base URL -``` -https://gc8l9ijhzc.execute-api.ap-northeast-2.amazonaws.com/dev -``` - -### 인증 - -모든 인증이 필요한 API는 Authorization 헤더에 JWT 토큰을 포함해야 합니다. - -```typescript -const headers = { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${idToken}` -}; -``` - ---- - -## Grammar API - -### 1. 문법 검사 (Grammar Check) - -단일 문장에 대한 문법 검사를 수행합니다. - -**Endpoint:** `POST /grammar/check` - -**Request:** -```typescript -interface GrammarCheckRequest { - sentence: string; // 검사할 문장 - level: 'BEGINNER' | 'INTERMEDIATE' | 'ADVANCED'; // 학습 레벨 -} -``` - -**Response:** -```typescript -interface GrammarCheckResponse { - originalSentence: string; // 원본 문장 - correctedSentence: string; // 교정된 문장 - score: number; // 0-100 점수 - isCorrect: boolean; // 문법 정확 여부 - errors: GrammarError[]; // 오류 목록 - feedback: string; // 전체 피드백 -} - -interface GrammarError { - type: GrammarErrorType; // 오류 유형 - original: string; // 원본 표현 - corrected: string; // 교정된 표현 - explanation: string; // 설명 - startIndex?: number; // 원문에서의 시작 위치 - endIndex?: number; // 원문에서의 끝 위치 -} - -type GrammarErrorType = - | 'VERB_TENSE' // 시제 오류 - | 'SUBJECT_VERB_AGREEMENT' // 주어-동사 일치 - | 'ARTICLE' // 관사 - | 'PREPOSITION' // 전치사 - | 'WORD_ORDER' // 어순 - | 'PLURAL_SINGULAR' // 단복수 - | 'PRONOUN' // 대명사 - | 'SPELLING' // 철자 - | 'PUNCTUATION' // 구두점 - | 'WORD_CHOICE' // 어휘 선택 - | 'SENTENCE_STRUCTURE' // 문장 구조 - | 'OTHER'; // 기타 -``` - -**예시:** -```typescript -const response = await fetch(`${BASE_URL}/grammar/check`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}` - }, - body: JSON.stringify({ - sentence: 'I go to school yesterday', - level: 'BEGINNER' - }) -}); - -const result = await response.json(); -// result.data: -// { -// originalSentence: "I go to school yesterday", -// correctedSentence: "I went to school yesterday", -// score: 80, -// isCorrect: false, -// errors: [{ -// type: "VERB_TENSE", -// original: "go", -// corrected: "went", -// explanation: "'yesterday'는 과거를 나타내므로 'go'를 과거형 'went'로 바꿔야 합니다.", -// startIndex: 2, -// endIndex: 4 -// }], -// feedback: "시제에 주의하세요. 과거를 나타내는 'yesterday'와 함께 사용할 때는 과거형을 사용합니다." -// } -``` - ---- - -### 2. 대화 (Conversation) - 동기 방식 - -AI와 대화하면서 문법을 교정받습니다. - -**Endpoint:** `POST /grammar/conversation` - -**Request:** -```typescript -interface ConversationRequest { - sessionId?: string; // 세션 ID (없으면 새 세션 생성) - message: string; // 사용자 메시지 - level?: string; // 'BEGINNER' | 'INTERMEDIATE' | 'ADVANCED' (기본값: BEGINNER) -} -``` - -**Response:** -```typescript -interface ConversationResponse { - sessionId: string; // 세션 ID - grammarCheck: GrammarCheckResponse; // 문법 검사 결과 - aiResponse: string; // AI의 대화 응답 - conversationTip: string; // 학습 팁 -} -``` - ---- - -### 3. 대화 스트리밍 (Conversation Streaming) - WebSocket 방식 - -실시간으로 AI 응답을 받습니다. 동기 API보다 빠른 사용자 경험을 제공합니다. - -**WebSocket Endpoint:** `wss://{websocket-api-id}.execute-api.ap-northeast-2.amazonaws.com/dev` - -#### 인증 - -WebSocket 연결 시 JWT 토큰을 query parameter로 전달해야 합니다. - -``` -wss://{api-id}.execute-api.ap-northeast-2.amazonaws.com/dev?token={JWT_TOKEN} -``` - -- 토큰이 없거나 만료된 경우 연결이 거부됩니다 (401) -- 연결 성공 후에는 메시지에 userId를 포함할 필요 없음 (서버에서 자동 조회) - -#### 연결 및 사용법 - -```typescript -// 1. WebSocket 연결 (JWT 토큰 포함) -const ws = new WebSocket( - `wss://${WEBSOCKET_API_ID}.execute-api.ap-northeast-2.amazonaws.com/dev?token=${jwtToken}` -); - -// 2. 연결 완료 시 메시지 전송 (userId 불필요) -ws.onopen = () => { - const request = { - action: 'grammarStreaming', // 라우트 키 - sessionId: 'optional-session-id', - message: 'I go to school yesterday', - level: 'BEGINNER' - }; - ws.send(JSON.stringify(request)); -}; - -// 3. 스트리밍 이벤트 수신 -ws.onmessage = (event) => { - const data = JSON.parse(event.data); - - switch (data.type) { - case 'start': - // 스트리밍 시작 - console.log('Streaming started, sessionId:', data.sessionId); - break; - - case 'token': - // 실시간 토큰 수신 - UI에 점진적으로 표시 - appendToResponse(data.token); - break; - - case 'complete': - // 스트리밍 완료 - 최종 결과 - handleComplete(data); - break; - - case 'error': - // 에러 처리 - console.error('Streaming error:', data.message); - break; - } -}; - -ws.onerror = (error) => { - console.error('WebSocket error:', error); -}; - -ws.onclose = () => { - console.log('WebSocket closed'); -}; -``` - -#### 스트리밍 이벤트 타입 - -```typescript -// 시작 이벤트 -interface StreamingStartEvent { - type: 'start'; - sessionId: string; -} - -// 토큰 이벤트 (실시간 텍스트 조각) -interface StreamingTokenEvent { - type: 'token'; - token: string; // 텍스트 조각 -} - -// 완료 이벤트 -interface StreamingCompleteEvent { - type: 'complete'; - sessionId: string; - grammarCheck: GrammarCheckResponse; - aiResponse: string; - conversationTip: string; -} - -// 에러 이벤트 -interface StreamingErrorEvent { - type: 'error'; - message: string; -} -``` - -#### 스트리밍 UI 구현 예시 - -```typescript -function GrammarChat() { - const [response, setResponse] = useState(''); - const [isStreaming, setIsStreaming] = useState(false); - const [result, setResult] = useState(null); - - const handleSubmit = (message: string) => { - setIsStreaming(true); - setResponse(''); - setResult(null); - - const ws = new WebSocket(WEBSOCKET_URL); - - ws.onopen = () => { - ws.send(JSON.stringify({ - action: 'grammarStreaming', - message, - userId, - level: 'BEGINNER' - })); - }; - - ws.onmessage = (event) => { - const data = JSON.parse(event.data); - - if (data.type === 'token') { - // 토큰을 점진적으로 추가하여 타이핑 효과 - setResponse(prev => prev + data.token); - } else if (data.type === 'complete') { - setResult(data); - setIsStreaming(false); - ws.close(); - } else if (data.type === 'error') { - console.error(data.message); - setIsStreaming(false); - ws.close(); - } - }; - }; - - return ( -
- {/* 실시간 응답 표시 */} -
- {response} - {isStreaming && |} -
- - {/* 최종 결과 표시 */} - {result && ( -
- - {result.conversationTip} -
- )} -
- ); -} -``` - ---- - -### 4. 세션 목록 조회 - -**Endpoint:** `GET /grammar/sessions` - -**Query Parameters:** -- `limit`: 조회할 개수 (기본값: 10, 최대: 50) -- `cursor`: 페이지네이션 커서 - -**Response:** -```typescript -interface SessionListResponse { - sessions: GrammarSession[]; - nextCursor: string | null; - hasMore: boolean; -} - -interface GrammarSession { - sessionId: string; - level: string; - topic: string; - messageCount: number; - lastMessage: string; - createdAt: string; - updatedAt: string; -} -``` - ---- - -### 5. 세션 상세 조회 - -**Endpoint:** `GET /grammar/sessions/{sessionId}` - -**Query Parameters:** -- `messageLimit`: 메시지 조회 개수 (기본값: 50, 최대: 100) - -**Response:** -```typescript -interface SessionDetailResponse { - session: GrammarSession; - messages: GrammarMessage[]; -} - -interface GrammarMessage { - messageId: string; - role: 'USER' | 'ASSISTANT'; - content: string; - correctedContent?: string; - grammarScore?: number; - createdAt: string; -} -``` - ---- - -### 6. 세션 삭제 - -**Endpoint:** `DELETE /grammar/sessions/{sessionId}` - -**Response:** -```typescript -{ - status: 'success', - message: 'Session deleted successfully' -} -``` - ---- - -## 에러 하이라이팅 구현 - -`startIndex`와 `endIndex`를 사용하여 원문에서 오류 위치를 하이라이팅할 수 있습니다. - -```typescript -function highlightErrors( - sentence: string, - errors: GrammarError[] -): React.ReactNode[] { - if (!errors.length) return [sentence]; - - // 위치 정보가 있는 오류만 필터링 - const positionedErrors = errors - .filter(e => e.startIndex != null && e.endIndex != null) - .sort((a, b) => a.startIndex! - b.startIndex!); - - if (!positionedErrors.length) return [sentence]; - - const parts: React.ReactNode[] = []; - let lastIndex = 0; - - positionedErrors.forEach((error, i) => { - // 오류 전 텍스트 - if (error.startIndex! > lastIndex) { - parts.push(sentence.slice(lastIndex, error.startIndex!)); - } - - // 오류 부분 하이라이트 - parts.push( - - {sentence.slice(error.startIndex!, error.endIndex!)} - - ); - - lastIndex = error.endIndex!; - }); - - // 마지막 텍스트 - if (lastIndex < sentence.length) { - parts.push(sentence.slice(lastIndex)); - } - - return parts; -} - -// 사용 예시 -function SentenceWithErrors({ sentence, errors }: Props) { - return ( -

- {highlightErrors(sentence, errors)} -

- ); -} -``` - ---- - -## 응답 공통 형식 - -모든 API 응답은 다음 형식을 따릅니다: - -```typescript -interface ApiResponse { - status: 'success' | 'error'; - message: string; - data?: T; - error?: { - code: string; - message: string; - }; -} -``` - ---- - -## 에러 코드 - -| 코드 | 설명 | -|------|------| -| `INVALID_SENTENCE` | 유효하지 않은 문장 | -| `INVALID_LEVEL` | 유효하지 않은 레벨 | -| `SESSION_NOT_FOUND` | 세션을 찾을 수 없음 | -| `BEDROCK_API_ERROR` | AI API 호출 오류 | -| `BEDROCK_RESPONSE_PARSE_ERROR` | AI 응답 파싱 오류 | - ---- - -## 레벨별 특성 - -| 레벨 | 특성 | -|------|------| -| `BEGINNER` | 쉬운 어휘, 한국어 번역 포함, 격려 메시지 | -| `INTERMEDIATE` | 일상 영어, 자연스러운 표현 | -| `ADVANCED` | 고급 어휘, 관용구, 스타일 개선 | From 3301780cac13bbc496e7f28f9452393aa534b77f Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:30:09 +0900 Subject: [PATCH 228/528] =?UTF-8?q?refactor:=20AwsClients=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../secondproject/serverless/common/config/AwsClients.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/AwsClients.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/AwsClients.java index 12b21301..eaaaa923 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/AwsClients.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/AwsClients.java @@ -3,11 +3,11 @@ import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; import software.amazon.awssdk.services.bedrockruntime.BedrockRuntimeAsyncClient; import software.amazon.awssdk.services.bedrockruntime.BedrockRuntimeClient; +import software.amazon.awssdk.services.comprehend.ComprehendClient; import software.amazon.awssdk.services.dynamodb.DynamoDbClient; import software.amazon.awssdk.services.polly.PollyClient; import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.presigner.S3Presigner; -import software.amazon.awssdk.services.comprehend.ComprehendClient; import software.amazon.awssdk.services.sns.SnsClient; /** @@ -65,11 +65,11 @@ public static SnsClient sns() { public static BedrockRuntimeClient bedrock() { return BEDROCK_CLIENT; } - + public static BedrockRuntimeAsyncClient bedrockAsync() { return BEDROCK_ASYNC_CLIENT; } - + public static ComprehendClient comprehend() { return COMPREHEND_CLIENT; } From f7f8686baa4c2e6fe1c91c29b04e11175b9c41de Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:30:10 +0900 Subject: [PATCH 229/528] =?UTF-8?q?refactor:=20DynamoDbKey=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../serverless/common/constants/DynamoDbKey.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/constants/DynamoDbKey.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/constants/DynamoDbKey.java index 132b0781..ddd732a3 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/constants/DynamoDbKey.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/constants/DynamoDbKey.java @@ -5,7 +5,7 @@ * 모든 도메인에서 공통으로 사용되는 키 패턴 정의 */ public final class DynamoDbKey { - + // Partition/Sort Key Attributes public static final String PK = "PK"; public static final String SK = "SK"; @@ -24,10 +24,10 @@ public final class DynamoDbKey { public static final String METADATA = "METADATA"; // 공용 Entity Prefix public static final String USER = "USER#"; - + private DynamoDbKey() { } - + /** * 사용자 PK 생성 (공통) * 여러 도메인에서 동일한 패턴으로 사용 From 95fc47b431408c540672c2494ea96d7ff154b895 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:30:10 +0900 Subject: [PATCH 230/528] =?UTF-8?q?refactor:=20ComprehendService=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/service/ComprehendService.java | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/service/ComprehendService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/service/ComprehendService.java index 86a277c4..5e46c8f5 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/service/ComprehendService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/service/ComprehendService.java @@ -15,19 +15,19 @@ * Amazon Comprehend 서비스 */ public class ComprehendService { - + private static final Logger logger = LoggerFactory.getLogger(ComprehendService.class); - + private final ComprehendClient comprehendClient; - + public ComprehendService() { this.comprehendClient = AwsClients.comprehend(); } - + public ComprehendService(ComprehendClient comprehendClient) { this.comprehendClient = comprehendClient; } - + /** * 텍스트 종합 분석 */ @@ -36,20 +36,20 @@ public ComprehendAnalysis analyze(String text) { DetectSentimentResponse sentimentResponse = detectSentiment(text); DetectSyntaxResponse syntaxResponse = detectSyntax(text); DetectKeyPhrasesResponse keyPhrasesResponse = detectKeyPhrases(text); - + List syntaxTokens = syntaxResponse.syntaxTokens().stream() .map(token -> ComprehendAnalysis.SyntaxToken.builder() .text(token.text()) .partOfSpeech(token.partOfSpeech().tagAsString()) .build()) .collect(Collectors.toList()); - + List keyPhrases = keyPhrasesResponse.keyPhrases().stream() .map(KeyPhrase::text) .collect(Collectors.toList()); - + String complexity = calculateComplexity(syntaxTokens); - + return ComprehendAnalysis.builder() .sentiment(sentimentResponse.sentimentAsString()) .sentimentScore(ComprehendAnalysis.SentimentScore.builder() @@ -64,34 +64,34 @@ public ComprehendAnalysis analyze(String text) { .language("en") .languageScore(0.99) .build(); - + } catch (Exception e) { logger.error("Comprehend analysis failed: {}", e.getMessage()); return null; } } - + private DetectSentimentResponse detectSentiment(String text) { return comprehendClient.detectSentiment(DetectSentimentRequest.builder() .text(text) .languageCode(LanguageCode.EN) .build()); } - + private DetectSyntaxResponse detectSyntax(String text) { return comprehendClient.detectSyntax(DetectSyntaxRequest.builder() .text(text) .languageCode(SyntaxLanguageCode.EN) .build()); } - + private DetectKeyPhrasesResponse detectKeyPhrases(String text) { return comprehendClient.detectKeyPhrases(DetectKeyPhrasesRequest.builder() .text(text) .languageCode(LanguageCode.EN) .build()); } - + /** * 문장 복잡도 계산 * - 품사 다양성 + 문장 길이 기반 @@ -100,10 +100,10 @@ private String calculateComplexity(List syntax) Set uniquePOS = syntax.stream() .map(ComprehendAnalysis.SyntaxToken::getPartOfSpeech) .collect(Collectors.toSet()); - + int posCount = uniquePOS.size(); int sentenceLength = syntax.size(); - + if (posCount <= 3 && sentenceLength <= 5) { return "BEGINNER"; } else if (posCount <= 5 && sentenceLength <= 10) { From 20bb6c92fbc9576c028746dbdebe3c7d2c429ce3 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:30:18 +0900 Subject: [PATCH 231/528] =?UTF-8?q?refactor:=20JwtUtil=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../serverless/common/util/JwtUtil.java | 40 +++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/JwtUtil.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/JwtUtil.java index 3fbfef0e..4181fa64 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/JwtUtil.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/JwtUtil.java @@ -14,14 +14,14 @@ * Cognito JWT 토큰에서 claims를 추출 */ public final class JwtUtil { - + private static final Logger logger = LoggerFactory.getLogger(JwtUtil.class); private static final Gson gson = new Gson(); - + private JwtUtil() { // 유틸리티 클래스 인스턴스화 방지 } - + /** * JWT 토큰에서 userId (sub claim) 추출 * @@ -31,18 +31,18 @@ private JwtUtil() { public static Optional extractUserId(String token) { return extractClaim(token, "sub"); } - + /** * JWT 토큰에서 email 추출 */ public static Optional extractEmail(String token) { return extractClaim(token, "email"); } - + /** * JWT 토큰에서 특정 claim 추출 * - * @param token JWT 토큰 + * @param token JWT 토큰 * @param claimName claim 이름 * @return claim 값 (Optional) */ @@ -51,37 +51,37 @@ public static Optional extractClaim(String token, String claimName) { if (token == null || token.isEmpty()) { return Optional.empty(); } - + // Bearer 접두사 제거 String cleanToken = token.startsWith("Bearer ") ? token.substring(7) : token; - + // JWT 구조: header.payload.signature String[] parts = cleanToken.split("\\."); if (parts.length != 3) { logger.warn("Invalid JWT format"); return Optional.empty(); } - + // Payload 디코딩 (Base64 URL-safe) String payload = new String( Base64.getUrlDecoder().decode(parts[1]), StandardCharsets.UTF_8 ); - + JsonObject claims = gson.fromJson(payload, JsonObject.class); - + if (claims.has(claimName) && !claims.get(claimName).isJsonNull()) { return Optional.of(claims.get(claimName).getAsString()); } - + return Optional.empty(); - + } catch (Exception e) { logger.error("Failed to extract claim from JWT: {}", e.getMessage()); return Optional.empty(); } } - + /** * JWT 토큰이 만료되었는지 확인 * @@ -94,18 +94,18 @@ public static boolean isExpired(String token) { if (expClaim.isEmpty()) { return true; } - + long exp = Long.parseLong(expClaim.get()); long now = System.currentTimeMillis() / 1000; - + return now >= exp; - + } catch (Exception e) { logger.error("Failed to check JWT expiration: {}", e.getMessage()); return true; } } - + /** * JWT 토큰 유효성 검사 (형식 및 만료) * @@ -116,12 +116,12 @@ public static boolean isValid(String token) { if (token == null || token.isEmpty()) { return false; } - + Optional userId = extractUserId(token); if (userId.isEmpty()) { return false; } - + return !isExpired(token); } } From 04ac079b83d73fd90389539973fe1e3d4abce118 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:30:18 +0900 Subject: [PATCH 232/528] =?UTF-8?q?refactor:=20S3PresignUtil=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../serverless/common/util/S3PresignUtil.java | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/S3PresignUtil.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/S3PresignUtil.java index a75bffd9..aa4fbcab 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/S3PresignUtil.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/S3PresignUtil.java @@ -14,21 +14,21 @@ * S3 Presigned URL 유틸리티 */ public class S3PresignUtil { - + private static final S3Presigner presigner = AwsClients.s3Presigner(); private static final String BUCKET_NAME = System.getenv("BUCKET_NAME"); private static final Duration DEFAULT_DURATION = Duration.ofHours(24); - + // 캐시 (키: S3 key, 값: presigned URL, 만료시간) private static final Map urlCache = new ConcurrentHashMap<>(); - + /** * S3 객체에 대한 presigned GET URL 생성 (24시간 유효, 캐시 사용) */ public static String getPresignedUrl(String key) { return getPresignedUrl(key, DEFAULT_DURATION); } - + /** * S3 객체에 대한 presigned GET URL 생성 (캐시 사용) */ @@ -38,35 +38,35 @@ public static String getPresignedUrl(String key, Duration duration) { if (cached != null && !cached.isExpired()) { return cached.url; } - + // 새 presigned URL 생성 GetObjectRequest getObjectRequest = GetObjectRequest.builder() .bucket(BUCKET_NAME) .key(key) .build(); - + GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() .signatureDuration(duration) .getObjectRequest(getObjectRequest) .build(); - + PresignedGetObjectRequest presignedRequest = presigner.presignGetObject(presignRequest); String url = presignedRequest.url().toString(); - + // 캐시에 저장 (만료 시간 1시간 전까지 유효) long expiresAt = System.currentTimeMillis() + duration.toMillis() - Duration.ofHours(1).toMillis(); urlCache.put(key, new CachedUrl(url, expiresAt)); - + return url; } - + /** * 배지 이미지 presigned URL 생성 */ public static String getBadgeImageUrl(String imageFile) { return getPresignedUrl("badges/" + imageFile); } - + private record CachedUrl(String url, long expiresAt) { boolean isExpired() { return System.currentTimeMillis() > expiresAt; From 12e14410bcefa6e32d2b09b8dc4a7030185f26a1 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:30:18 +0900 Subject: [PATCH 233/528] =?UTF-8?q?refactor:=20WebSocketEventUtil=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/util/WebSocketEventUtil.java | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketEventUtil.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketEventUtil.java index 899c528f..3a587aa1 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketEventUtil.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketEventUtil.java @@ -8,11 +8,11 @@ * 공통 메서드를 제공하여 핸들러 간 코드 중복 제거 */ public final class WebSocketEventUtil { - + private WebSocketEventUtil() { // 유틸리티 클래스 인스턴스화 방지 } - + /** * WebSocket 이벤트에서 connectionId 추출 */ @@ -21,7 +21,7 @@ public static String extractConnectionId(Map event) { Map requestContext = (Map) event.get("requestContext"); return (String) requestContext.get("connectionId"); } - + /** * WebSocket 이벤트에서 queryStringParameters 추출 */ @@ -30,7 +30,7 @@ public static Map extractQueryStringParameters(Map params = (Map) event.get("queryStringParameters"); return params != null ? params : new HashMap<>(); } - + /** * WebSocket 이벤트에서 endpoint URL 추출 * API Gateway Management API 호출에 사용 @@ -42,35 +42,35 @@ public static String extractWebSocketEndpoint(Map event) { String stage = (String) requestContext.get("stage"); return "https://" + domainName + "/" + stage; } - + /** * WebSocket 응답 생성 */ public static Map createResponse(int statusCode, String body) { return Map.of("statusCode", statusCode, "body", body); } - + /** * 성공 응답 생성 (200) */ public static Map ok(String message) { return createResponse(200, message); } - + /** * 인증 실패 응답 생성 (401) */ public static Map unauthorized(String message) { return createResponse(401, message); } - + /** * 서버 에러 응답 생성 (500) */ public static Map serverError(String message) { return createResponse(500, message); } - + /** * 잘못된 요청 응답 생성 (400) */ From 421a4bfbdc01e8ca3b60eed67cfa031898b18de9 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:30:18 +0900 Subject: [PATCH 234/528] =?UTF-8?q?refactor:=20BadgeType=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../serverless/domain/badge/enums/BadgeType.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/enums/BadgeType.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/enums/BadgeType.java index 8cad9218..b34466ed 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/enums/BadgeType.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/enums/BadgeType.java @@ -29,7 +29,7 @@ public enum BadgeType { GAME_10_WINS("게임 10승", "게임에서 10번 1등을 했습니다", "game_10_wins.png", "GAMES_WON", 10), QUICK_GUESSER("번개 정답", "5초 내에 정답을 맞췄습니다", "quick_guesser.png", "QUICK_GUESSES", 1), PERFECT_DRAWER("완벽한 출제자", "출제 시 전원이 정답을 맞췄습니다", "perfect_drawer.png", "PERFECT_DRAWS", 1), - + // 특별 MASTER("학습 마스터", "모든 업적을 달성했습니다", "master.png", "ALL_BADGES", 1); @@ -69,7 +69,7 @@ public String getDescription() { public String getImageUrl() { return BASE_URL + imageFile; } - + public String getImageFile() { return imageFile; } From 85ddd97d616a792e1fe22e7031cf73a4067221a2 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:30:18 +0900 Subject: [PATCH 235/528] =?UTF-8?q?refactor:=20BadgeService=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/badge/service/BadgeService.java | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/service/BadgeService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/service/BadgeService.java index b0afbcfa..ad69394c 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/service/BadgeService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/service/BadgeService.java @@ -132,7 +132,7 @@ private UserBadge createBadge(String userId, BadgeType type, String now) { private boolean checkBadgeCondition(BadgeType type, UserStats stats) { if (stats == null) return false; - + return switch (type.getCategory()) { case "FIRST_STUDY" -> stats.getTestsCompleted() != null && stats.getTestsCompleted() >= 1; case "STREAK" -> stats.getCurrentStreak() != null && stats.getCurrentStreak() >= type.getThreshold(); @@ -149,14 +149,10 @@ private boolean checkBadgeCondition(BadgeType type, UserStats stats) { double accuracy = (stats.getCorrectAnswers() * 100.0) / stats.getQuestionsAnswered(); yield accuracy >= type.getThreshold(); } - case "GAMES_PLAYED" -> - stats.getGamesPlayed() != null && stats.getGamesPlayed() >= type.getThreshold(); - case "GAMES_WON" -> - stats.getGamesWon() != null && stats.getGamesWon() >= type.getThreshold(); - case "QUICK_GUESSES" -> - stats.getQuickGuesses() != null && stats.getQuickGuesses() >= type.getThreshold(); - case "PERFECT_DRAWS" -> - stats.getPerfectDraws() != null && stats.getPerfectDraws() >= type.getThreshold(); + case "GAMES_PLAYED" -> stats.getGamesPlayed() != null && stats.getGamesPlayed() >= type.getThreshold(); + case "GAMES_WON" -> stats.getGamesWon() != null && stats.getGamesWon() >= type.getThreshold(); + case "QUICK_GUESSES" -> stats.getQuickGuesses() != null && stats.getQuickGuesses() >= type.getThreshold(); + case "PERFECT_DRAWS" -> stats.getPerfectDraws() != null && stats.getPerfectDraws() >= type.getThreshold(); case "ALL_BADGES" -> false; // 별도 로직 필요 default -> false; }; @@ -164,7 +160,7 @@ private boolean checkBadgeCondition(BadgeType type, UserStats stats) { private int calculateProgress(BadgeType type, UserStats stats) { if (stats == null) return 0; - + return switch (type.getCategory()) { case "FIRST_STUDY" -> stats.getTestsCompleted() != null && stats.getTestsCompleted() >= 1 ? 1 : 0; case "STREAK" -> stats.getCurrentStreak() != null ? stats.getCurrentStreak() : 0; From 38b2f979851f07a203dc009051890e750fb69842 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:30:18 +0900 Subject: [PATCH 236/528] =?UTF-8?q?refactor:=20ChatKey=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../serverless/domain/chatting/constants/ChatKey.java | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/constants/ChatKey.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/constants/ChatKey.java index 7c7ba7b7..50aa5503 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/constants/ChatKey.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/constants/ChatKey.java @@ -1,7 +1,5 @@ package com.mzc.secondproject.serverless.domain.chatting.constants; -import com.mzc.secondproject.serverless.common.constants.DynamoDbKey; - public final class ChatKey { // Prefixes @@ -16,15 +14,15 @@ private ChatKey() { } // Key Builders (userPk는 DynamoDbKey.userPk() 사용) - + public static String roomPk(String roomId) { return ROOM + roomId; } - + public static String messageSk(String messageId) { return MESSAGE + messageId; } - + public static String connectionPk(String connectionId) { return CONNECTION + connectionId; } From 56b2167961db2ee4e60f56c18fa25cabe6d50165 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:30:26 +0900 Subject: [PATCH 237/528] =?UTF-8?q?refactor:=20CommandResult=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/chatting/dto/response/CommandResult.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/CommandResult.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/CommandResult.java index 6082e3c4..693f14eb 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/CommandResult.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/CommandResult.java @@ -11,15 +11,15 @@ public record CommandResult( boolean success, Object data ) { - + public static CommandResult success(MessageType messageType, String message) { return new CommandResult(messageType, message, true, null); } - + public static CommandResult success(MessageType messageType, String message, Object data) { return new CommandResult(messageType, message, true, data); } - + public static CommandResult error(String message) { return new CommandResult(MessageType.SYSTEM_COMMAND, message, false, null); } From 56f52db418b14fdf00f266b42c3b1b52016b6121 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:30:26 +0900 Subject: [PATCH 238/528] =?UTF-8?q?refactor:=20ScoreUpdateMessage=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/response/ScoreUpdateMessage.java | 39 ++++++++++--------- 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/ScoreUpdateMessage.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/ScoreUpdateMessage.java index 0ac79f0f..6f9ad110 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/ScoreUpdateMessage.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/ScoreUpdateMessage.java @@ -1,6 +1,9 @@ package com.mzc.secondproject.serverless.domain.chatting.dto.response; -import lombok.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; import java.util.List; import java.util.Map; @@ -22,22 +25,11 @@ public class ScoreUpdateMessage { private Integer currentRound; private Integer totalRounds; private String timestamp; - - @Data - @Builder - @NoArgsConstructor - @AllArgsConstructor - public static class RankEntry { - private Integer rank; - private String userId; - private Integer score; - private Integer change; - } - + public static ScoreUpdateMessage from(String roomId, String scorerId, int scoreGained, - Map scores, int currentRound, int totalRounds) { + Map scores, int currentRound, int totalRounds) { List ranking = buildRanking(scores); - + return ScoreUpdateMessage.builder() .messageType("SCORE_UPDATE") .roomId(roomId) @@ -50,16 +42,16 @@ public static ScoreUpdateMessage from(String roomId, String scorerId, int scoreG .timestamp(java.time.Instant.now().toString()) .build(); } - + private static List buildRanking(Map scores) { if (scores == null || scores.isEmpty()) { return List.of(); } - + List> sorted = scores.entrySet().stream() .sorted(Map.Entry.comparingByValue().reversed()) .toList(); - + return java.util.stream.IntStream.range(0, sorted.size()) .mapToObj(i -> RankEntry.builder() .rank(i + 1) @@ -69,4 +61,15 @@ private static List buildRanking(Map scores) { .build()) .toList(); } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class RankEntry { + private Integer rank; + private String userId; + private Integer score; + private Integer change; + } } From ec575b0c4761d42990f32f0482e1c575c2b395a2 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:30:26 +0900 Subject: [PATCH 239/528] =?UTF-8?q?refactor:=20ScoreboardResponse=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/response/ScoreboardResponse.java | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/ScoreboardResponse.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/ScoreboardResponse.java index bb4e81fb..6cf2bdce 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/ScoreboardResponse.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/ScoreboardResponse.java @@ -2,7 +2,6 @@ import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; -import java.util.Comparator; import java.util.List; import java.util.Map; @@ -16,16 +15,10 @@ public record ScoreboardResponse( Integer currentRound, Integer totalRounds ) { - public record RankEntry( - int rank, - String userId, - int score - ) {} - public static ScoreboardResponse from(ChatRoom room) { Map scores = room.getScores(); List ranking = buildRanking(scores); - + return new ScoreboardResponse( scores, ranking, @@ -34,18 +27,25 @@ public static ScoreboardResponse from(ChatRoom room) { room.getTotalRounds() ); } - + private static List buildRanking(Map scores) { if (scores == null || scores.isEmpty()) { return List.of(); } - + List> sorted = scores.entrySet().stream() .sorted(Map.Entry.comparingByValue().reversed()) .toList(); - + return java.util.stream.IntStream.range(0, sorted.size()) .mapToObj(i -> new RankEntry(i + 1, sorted.get(i).getKey(), sorted.get(i).getValue())) .toList(); } + + public record RankEntry( + int rank, + String userId, + int score + ) { + } } From e84834869d50db0a5f5f41bbe0ceac2848fbac35 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:30:26 +0900 Subject: [PATCH 240/528] =?UTF-8?q?refactor:=20GameStatus=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/chatting/enums/GameStatus.java | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/GameStatus.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/GameStatus.java index d5534add..393e1a98 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/GameStatus.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/GameStatus.java @@ -8,29 +8,21 @@ public enum GameStatus { PLAYING("playing", "게임 진행 중"), ROUND_END("round_end", "라운드 종료"), FINISHED("finished", "게임 종료"); - + private final String code; private final String displayName; - + GameStatus(String code, String displayName) { this.code = code; this.displayName = displayName; } - - public String getCode() { - return code; - } - - public String getDisplayName() { - return displayName; - } - + public static boolean isValid(String value) { if (value == null) return false; return Arrays.stream(values()) .anyMatch(status -> status.name().equalsIgnoreCase(value) || status.code.equalsIgnoreCase(value)); } - + public static GameStatus fromString(String value) { if (value == null) return NONE; return Arrays.stream(values()) @@ -38,11 +30,19 @@ public static GameStatus fromString(String value) { .findFirst() .orElse(NONE); } - + + public String getCode() { + return code; + } + + public String getDisplayName() { + return displayName; + } + public boolean isGameActive() { return this == PLAYING || this == ROUND_END; } - + public boolean canStartGame() { return this == NONE || this == FINISHED; } From 846721744c22d8c04380bb2b37ee7c00a3ab01eb Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:30:26 +0900 Subject: [PATCH 241/528] =?UTF-8?q?refactor:=20MessageType=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../serverless/domain/chatting/enums/MessageType.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/MessageType.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/MessageType.java index 5736db6d..28627177 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/MessageType.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/MessageType.java @@ -7,7 +7,7 @@ public enum MessageType { IMAGE("image", "이미지"), VOICE("voice", "음성"), AI_RESPONSE("ai_response", "AI 응답"), - + // 게임 관련 메시지 타입 GAME_START("game_start", "게임 시작"), GAME_END("game_end", "게임 종료"), From ed8d7c6c53bfee915aa335ac45ddb3320bac3251 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:30:26 +0900 Subject: [PATCH 242/528] =?UTF-8?q?refactor:=20ChattingErrorCode=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../serverless/domain/chatting/exception/ChattingErrorCode.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCode.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCode.java index 5831547d..9b0e5509 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCode.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCode.java @@ -33,7 +33,7 @@ public enum ChattingErrorCode implements DomainErrorCode { // 연결 관련 에러 CONNECTION_FAILED("CONN_001", "연결에 실패했습니다", 500), CONNECTION_TIMEOUT("CONN_002", "연결 시간이 초과되었습니다", 408), - + // 게임 관련 에러 GAME_START_FAILED("GAME_001", "게임 시작에 실패했습니다", 400), GAME_STOP_FAILED("GAME_002", "게임 중단에 실패했습니다", 400), From 038d5d3ffec12dbee3ce5490c9d962d31c5af498 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:30:33 +0900 Subject: [PATCH 243/528] =?UTF-8?q?refactor:=20GameHandler=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/chatting/handler/GameHandler.java | 78 +++++++++---------- 1 file changed, 38 insertions(+), 40 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameHandler.java index 5e506327..56410132 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameHandler.java @@ -11,10 +11,8 @@ import com.mzc.secondproject.serverless.domain.chatting.dto.response.CommandResult; import com.mzc.secondproject.serverless.domain.chatting.dto.response.GameStatusResponse; import com.mzc.secondproject.serverless.domain.chatting.dto.response.ScoreboardResponse; -import com.mzc.secondproject.serverless.domain.chatting.enums.GameStatus; import com.mzc.secondproject.serverless.domain.chatting.enums.MessageType; import com.mzc.secondproject.serverless.domain.chatting.exception.ChattingErrorCode; -import com.mzc.secondproject.serverless.domain.chatting.model.ChatMessage; import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; import com.mzc.secondproject.serverless.domain.chatting.model.Connection; import com.mzc.secondproject.serverless.domain.chatting.repository.ChatRoomRepository; @@ -30,15 +28,15 @@ * 게임 REST API 핸들러 */ public class GameHandler implements RequestHandler { - + private static final Logger logger = LoggerFactory.getLogger(GameHandler.class); - + private final GameService gameService; private final ChatRoomRepository chatRoomRepository; private final ConnectionRepository connectionRepository; private final WebSocketBroadcaster broadcaster; private final HandlerRouter router; - + public GameHandler() { this.gameService = new GameService(); this.chatRoomRepository = new ChatRoomRepository(); @@ -46,7 +44,7 @@ public GameHandler() { this.broadcaster = new WebSocketBroadcaster(); this.router = initRouter(); } - + private HandlerRouter initRouter() { return new HandlerRouter().addRoutes( Route.postAuth("/rooms/{roomId}/game/start", this::startGame), @@ -55,101 +53,101 @@ private HandlerRouter initRouter() { Route.getAuth("/rooms/{roomId}/game/scores", this::getScores) ); } - + @Override public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { logger.info("Received request: {} {}", request.getHttpMethod(), request.getPath()); return router.route(request); } - + /** * POST /rooms/{roomId}/game/start - 게임 시작 */ private APIGatewayProxyResponseEvent startGame(APIGatewayProxyRequestEvent request, String userId) { String roomId = request.getPathParameters().get("roomId"); - + GameService.GameStartResult result = gameService.startGame(roomId, userId); - + if (!result.success()) { return ResponseGenerator.fail(ChattingErrorCode.GAME_START_FAILED, result.error()); } - + // WebSocket으로 게임 시작 알림 브로드캐스트 broadcastGameStart(roomId, result); - + GameStatusResponse response = GameStatusResponse.from(result.room(), result.drawerOrder()); return ResponseGenerator.ok("Game started", response); } - + /** * POST /rooms/{roomId}/game/stop - 게임 중단 */ private APIGatewayProxyResponseEvent stopGame(APIGatewayProxyRequestEvent request, String userId) { String roomId = request.getPathParameters().get("roomId"); - + CommandResult result = gameService.stopGame(roomId, userId); - + if (!result.success()) { return ResponseGenerator.fail(ChattingErrorCode.GAME_STOP_FAILED, result.message()); } - + // WebSocket으로 게임 종료 알림 브로드캐스트 broadcastSystemMessage(roomId, result.message(), MessageType.GAME_END); - + return ResponseGenerator.ok("Game stopped", Map.of("message", result.message())); } - + /** * GET /rooms/{roomId}/game/status - 게임 상태 조회 */ private APIGatewayProxyResponseEvent getGameStatus(APIGatewayProxyRequestEvent request, String userId) { String roomId = request.getPathParameters().get("roomId"); - + Optional optRoom = chatRoomRepository.findById(roomId); if (optRoom.isEmpty()) { return ResponseGenerator.fail(ChattingErrorCode.ROOM_NOT_FOUND); } - + ChatRoom room = optRoom.get(); GameStatusResponse response = GameStatusResponse.from(room, room.getDrawerOrder()); - + return ResponseGenerator.ok("Game status retrieved", response); } - + /** * GET /rooms/{roomId}/game/scores - 점수 조회 */ private APIGatewayProxyResponseEvent getScores(APIGatewayProxyRequestEvent request, String userId) { String roomId = request.getPathParameters().get("roomId"); - + Optional optRoom = chatRoomRepository.findById(roomId); if (optRoom.isEmpty()) { return ResponseGenerator.fail(ChattingErrorCode.ROOM_NOT_FOUND); } - + ChatRoom room = optRoom.get(); ScoreboardResponse response = ScoreboardResponse.from(room); - + return ResponseGenerator.ok("Scores retrieved", response); } - + /** * 게임 시작 브로드캐스트 */ private void broadcastGameStart(String roomId, GameService.GameStartResult result) { String messageId = UUID.randomUUID().toString(); String now = Instant.now().toString(); - + String message = String.format(""" - 🎮 게임 시작! - 총 %d 라운드 - - 라운드 1 시작! - 출제자: %s - """, + 🎮 게임 시작! + 총 %d 라운드 + + 라운드 1 시작! + 출제자: %s + """, result.room().getTotalRounds(), result.room().getCurrentDrawerId()); - + Map gameStartMessage = new HashMap<>(); gameStartMessage.put("messageId", messageId); gameStartMessage.put("roomId", roomId); @@ -162,21 +160,21 @@ private void broadcastGameStart(String roomId, GameService.GameStartResult resul gameStartMessage.put("totalRounds", result.room().getTotalRounds()); gameStartMessage.put("currentDrawerId", result.room().getCurrentDrawerId()); gameStartMessage.put("drawerOrder", result.drawerOrder()); - + List connections = connectionRepository.findByRoomId(roomId); String broadcastPayload = ResponseGenerator.gson().toJson(gameStartMessage); broadcaster.broadcast(connections, broadcastPayload); - + logger.info("Game start broadcasted: roomId={}", roomId); } - + /** * 시스템 메시지 브로드캐스트 */ private void broadcastSystemMessage(String roomId, String message, MessageType messageType) { String messageId = UUID.randomUUID().toString(); String now = Instant.now().toString(); - + Map systemMessage = new HashMap<>(); systemMessage.put("messageId", messageId); systemMessage.put("roomId", roomId); @@ -184,11 +182,11 @@ private void broadcastSystemMessage(String roomId, String message, MessageType m systemMessage.put("content", message); systemMessage.put("messageType", messageType.getCode()); systemMessage.put("createdAt", now); - + List connections = connectionRepository.findByRoomId(roomId); String broadcastPayload = ResponseGenerator.gson().toJson(systemMessage); broadcaster.broadcast(connections, broadcastPayload); - + logger.info("System message broadcasted: roomId={}, type={}", roomId, messageType); } } From 3312b18b7bcb05cb7ffb3a46b790c57c334fbb6d Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:30:33 +0900 Subject: [PATCH 244/528] =?UTF-8?q?refactor:=20WebSocketConnectHandler=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../websocket/WebSocketConnectHandler.java | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketConnectHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketConnectHandler.java index 28f278c8..88cac6de 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketConnectHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketConnectHandler.java @@ -34,32 +34,32 @@ public WebSocketConnectHandler() { @Override public Map handleRequest(Map event, Context context) { logger.info("WebSocket connect event: {}", event); - + try { String connectionId = WebSocketEventUtil.extractConnectionId(event); Map queryParams = WebSocketEventUtil.extractQueryStringParameters(event); - + String roomToken = queryParams.get("roomToken"); - + if (roomToken == null || roomToken.isEmpty()) { logger.warn("Missing roomToken parameter"); return WebSocketEventUtil.unauthorized("roomToken is required"); } - + // 토큰 검증 Optional optToken = roomTokenService.validateToken(roomToken); if (optToken.isEmpty()) { logger.warn("Invalid or expired roomToken: {}", roomToken); return WebSocketEventUtil.unauthorized("Invalid or expired token"); } - + RoomToken token = optToken.get(); String userId = token.getUserId(); String roomId = token.getRoomId(); - + String now = Instant.now().toString(); long ttl = Instant.now().plusSeconds(WebSocketConfig.connectionTtlSeconds()).getEpochSecond(); - + Connection connection = Connection.builder() .pk("CONN#" + connectionId) .sk("METADATA") @@ -73,12 +73,12 @@ public Map handleRequest(Map event, Context cont .connectedAt(now) .ttl(ttl) .build(); - + connectionRepository.save(connection); - + logger.info("Connection saved: connectionId={}, userId={}, roomId={}", connectionId, userId, roomId); return WebSocketEventUtil.ok("Connected"); - + } catch (Exception e) { logger.error("Error handling connect: {}", e.getMessage(), e); return WebSocketEventUtil.serverError("Internal server error"); From f6d0655e0602f010a5ef188ee2d6209fed3abac8 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:30:34 +0900 Subject: [PATCH 245/528] =?UTF-8?q?refactor:=20WebSocketDisconnectHandler?= =?UTF-8?q?=20=EC=BD=94=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../handler/websocket/WebSocketDisconnectHandler.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketDisconnectHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketDisconnectHandler.java index 71f54731..11af1669 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketDisconnectHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketDisconnectHandler.java @@ -28,12 +28,12 @@ public WebSocketDisconnectHandler() { @Override public Map handleRequest(Map event, Context context) { logger.info("WebSocket disconnect event: {}", event); - + try { String connectionId = WebSocketEventUtil.extractConnectionId(event); - + Optional connection = connectionRepository.findByConnectionId(connectionId); - + if (connection.isPresent()) { Connection conn = connection.get(); connectionRepository.delete(connectionId); @@ -42,9 +42,9 @@ public Map handleRequest(Map event, Context cont } else { logger.warn("Connection not found for deletion: connectionId={}", connectionId); } - + return WebSocketEventUtil.ok("Disconnected"); - + } catch (Exception e) { logger.error("Error handling disconnect: {}", e.getMessage(), e); return WebSocketEventUtil.serverError("Internal server error"); From 000d7135d7a6d9f3706ffc887ff79bfb43aa7451 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:30:34 +0900 Subject: [PATCH 246/528] =?UTF-8?q?refactor:=20WebSocketMessageHandler=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../websocket/WebSocketMessageHandler.java | 129 +++++++++++------- 1 file changed, 80 insertions(+), 49 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java index 24870111..0386a136 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java @@ -8,11 +8,11 @@ import com.mzc.secondproject.serverless.common.util.WebSocketEventUtil; import com.mzc.secondproject.serverless.domain.chatting.dto.response.CommandResult; import com.mzc.secondproject.serverless.domain.chatting.dto.response.ScoreUpdateMessage; +import com.mzc.secondproject.serverless.domain.chatting.enums.MessageType; import com.mzc.secondproject.serverless.domain.chatting.model.ChatMessage; import com.mzc.secondproject.serverless.domain.chatting.model.Connection; import com.mzc.secondproject.serverless.domain.chatting.repository.ChatRoomRepository; import com.mzc.secondproject.serverless.domain.chatting.repository.ConnectionRepository; -import com.mzc.secondproject.serverless.domain.chatting.enums.MessageType; import com.mzc.secondproject.serverless.domain.chatting.service.ChatMessageService; import com.mzc.secondproject.serverless.domain.chatting.service.CommandService; import com.mzc.secondproject.serverless.domain.chatting.service.GameService; @@ -40,7 +40,7 @@ public class WebSocketMessageHandler implements RequestHandler handleRequest(Map event, Context context) { logger.info("WebSocket message event: {}", event); - + try { String connectionId = WebSocketEventUtil.extractConnectionId(event); String body = (String) event.get("body"); - + if (body == null || body.isEmpty()) { return WebSocketEventUtil.badRequest("Message body is required"); } - + MessagePayload payload = gson.fromJson(body, MessagePayload.class); - + if (payload.roomId == null || payload.userId == null) { return WebSocketEventUtil.badRequest("roomId and userId are required"); } - + String messageType = payload.messageType != null ? payload.messageType : "TEXT"; - + // 메시지 타입별 처리 return switch (messageType.toUpperCase()) { case "DRAWING", "DRAWING_CLEAR" -> handleDrawingMessage(connectionId, payload, messageType); default -> handleRegularMessage(connectionId, payload, messageType); }; - + } catch (Exception e) { logger.error("Error handling message: {}", e.getMessage(), e); return WebSocketEventUtil.serverError("Internal server error"); } } - + /** * 그림 데이터 처리 (DRAWING, DRAWING_CLEAR) * - 저장하지 않음 (실시간 전송만) @@ -90,7 +90,7 @@ public Map handleRequest(Map event, Context cont */ private Map handleDrawingMessage(String connectionId, MessagePayload payload, String messageType) { logger.info("Drawing message: type={}, roomId={}, userId={}", messageType, payload.roomId, payload.userId); - + // 그림 데이터 메시지 생성 (저장 안 함) Map drawingMessage = new HashMap<>(); drawingMessage.put("messageType", messageType); @@ -98,26 +98,26 @@ private Map handleDrawingMessage(String connectionId, MessagePay drawingMessage.put("userId", payload.userId); drawingMessage.put("content", payload.content); drawingMessage.put("createdAt", Instant.now().toString()); - + // 본인 제외 브로드캐스트 List connections = connectionRepository.findByRoomId(payload.roomId); List otherConnections = connections.stream() .filter(c -> !c.getConnectionId().equals(connectionId)) .toList(); - + String broadcastPayload = gson.toJson(drawingMessage); List failedConnections = broadcaster.broadcast(otherConnections, broadcastPayload); - + // 실패한 연결 정리 for (String failedConnectionId : failedConnections) { connectionRepository.delete(failedConnectionId); logger.info("Deleted stale connection: {}", failedConnectionId); } - + logger.info("Drawing broadcasted to {} connections (excluding sender)", otherConnections.size()); return WebSocketEventUtil.ok("Drawing sent"); } - + /** * 일반 메시지 처리 (TEXT 등) */ @@ -125,23 +125,29 @@ private Map handleRegularMessage(String connectionId, MessagePay if (payload.content == null) { return WebSocketEventUtil.badRequest("content is required for text messages"); } - + // 슬래시 명령어 처리 var commandResult = commandService.processCommand(payload.content, payload.roomId, payload.userId); if (commandResult.isPresent()) { return handleCommandResult(commandResult.get(), payload.roomId, payload.userId); } - + // 게임 중 정답 체크 var answerResult = gameService.checkAnswer(payload.roomId, payload.userId, payload.content); if (answerResult.correct()) { return handleCorrectAnswer(payload, answerResult); } - + + // 게임 진행 중이면 오답도 저장하지 않음 (추측 메시지는 기록에 남기지 않음) + if (!answerResult.gameNotActive() && !answerResult.drawer()) { + // 오답 메시지 브로드캐스트만 수행 (저장 안 함) + return broadcastGuessMessage(payload); + } + // 일반 메시지 저장 및 브로드캐스트 String messageId = UUID.randomUUID().toString(); String now = Instant.now().toString(); - + ChatMessage message = ChatMessage.builder() .pk("ROOM#" + payload.roomId) .sk("MSG#" + now + "#" + messageId) @@ -156,60 +162,85 @@ private Map handleRegularMessage(String connectionId, MessagePay .messageType(messageType) .createdAt(now) .build(); - + ChatMessage savedMessage = chatMessageService.saveMessage(message); chatRoomRepository.updateLastMessageAt(payload.roomId, now); - + logger.info("Message saved: messageId={}, roomId={}", messageId, payload.roomId); - + // 브로드캐스트 List connections = connectionRepository.findByRoomId(payload.roomId); String broadcastPayload = gson.toJson(savedMessage); List failedConnections = broadcaster.broadcast(connections, broadcastPayload); - + // 실패한 연결 정리 for (String failedConnectionId : failedConnections) { connectionRepository.delete(failedConnectionId); logger.info("Deleted stale connection: {}", failedConnectionId); } - + return WebSocketEventUtil.ok("Message sent"); } - + + /** + * 게임 추측 메시지 브로드캐스트 (저장 안 함) + */ + private Map broadcastGuessMessage(MessagePayload payload) { + String messageId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + + // 추측 메시지 생성 (저장하지 않음) + Map guessMessage = new HashMap<>(); + guessMessage.put("messageId", messageId); + guessMessage.put("roomId", payload.roomId); + guessMessage.put("userId", payload.userId); + guessMessage.put("content", payload.content); + guessMessage.put("messageType", "GUESS"); + guessMessage.put("createdAt", now); + + List connections = connectionRepository.findByRoomId(payload.roomId); + String broadcastPayload = gson.toJson(guessMessage); + List failedConnections = broadcaster.broadcast(connections, broadcastPayload); + cleanupFailedConnections(failedConnections); + + logger.info("Guess message broadcasted (not saved): roomId={}, userId={}", payload.roomId, payload.userId); + return WebSocketEventUtil.ok("Guess sent"); + } + /** * 정답 처리 */ private Map handleCorrectAnswer(MessagePayload payload, GameService.AnswerCheckResult result) { List connections = connectionRepository.findByRoomId(payload.roomId); - + // 1. 정답 알림 메시지 브로드캐스트 broadcastCorrectAnswerMessage(payload, result, connections); - + // 2. 점수 업데이트 메시지 브로드캐스트 (실시간 리더보드) chatRoomRepository.findById(payload.roomId).ifPresent(room -> { broadcastScoreUpdate(payload.roomId, payload.userId, result.score(), result.scores(), room.getCurrentRound(), room.getTotalRounds(), connections); }); - + logger.info("Correct answer: roomId={}, userId={}, score={}", payload.roomId, payload.userId, result.score()); - + // 전원 정답 시 라운드 종료 처리 if (result.allCorrect()) { handleAllCorrect(payload.roomId); } - + return WebSocketEventUtil.ok("Correct answer"); } - + /** * 정답 알림 메시지 브로드캐스트 */ private void broadcastCorrectAnswerMessage(MessagePayload payload, GameService.AnswerCheckResult result, List connections) { String messageId = UUID.randomUUID().toString(); String now = Instant.now().toString(); - + String message = String.format("🎉 %s님이 정답을 맞췄습니다! (+%d점)", payload.userId, result.score()); - + ChatMessage correctMessage = ChatMessage.builder() .pk("ROOM#" + payload.roomId) .sk("MSG#" + now + "#" + messageId) @@ -224,36 +255,36 @@ private void broadcastCorrectAnswerMessage(MessagePayload payload, GameService.A .messageType(MessageType.CORRECT_ANSWER.getCode()) .createdAt(now) .build(); - + String broadcastPayload = gson.toJson(correctMessage); List failedConnections = broadcaster.broadcast(connections, broadcastPayload); cleanupFailedConnections(failedConnections); } - + /** * 점수 업데이트 메시지 브로드캐스트 (실시간 리더보드) */ private void broadcastScoreUpdate(String roomId, String scorerId, int scoreGained, - Map scores, Integer currentRound, - Integer totalRounds, List connections) { + Map scores, Integer currentRound, + Integer totalRounds, List connections) { if (scores == null || scores.isEmpty()) { return; } - + ScoreUpdateMessage scoreUpdate = ScoreUpdateMessage.from( roomId, scorerId, scoreGained, scores, currentRound != null ? currentRound : 0, totalRounds != null ? totalRounds : 0 ); - + String broadcastPayload = gson.toJson(scoreUpdate); List failedConnections = broadcaster.broadcast(connections, broadcastPayload); cleanupFailedConnections(failedConnections); - + logger.info("Score update broadcasted: roomId={}, scorerId={}, scoreGained={}", roomId, scorerId, scoreGained); } - + /** * 실패한 연결 정리 */ @@ -263,7 +294,7 @@ private void cleanupFailedConnections(List failedConnections) { logger.info("Deleted stale connection: {}", failedConnectionId); } } - + /** * 전원 정답 시 라운드 종료 */ @@ -273,14 +304,14 @@ private void handleAllCorrect(String roomId) { handleCommandResult(endResult, roomId, "SYSTEM"); }); } - + /** * 명령어 처리 결과를 브로드캐스트 */ private Map handleCommandResult(CommandResult result, String roomId, String userId) { String messageId = UUID.randomUUID().toString(); String now = Instant.now().toString(); - + // 시스템 메시지 생성 ChatMessage systemMessage = ChatMessage.builder() .pk("ROOM#" + roomId) @@ -296,22 +327,22 @@ private Map handleCommandResult(CommandResult result, String roo .messageType(result.messageType().getCode()) .createdAt(now) .build(); - + // 명령어 결과는 저장하지 않고 브로드캐스트만 수행 List connections = connectionRepository.findByRoomId(roomId); String broadcastPayload = gson.toJson(systemMessage); List failedConnections = broadcaster.broadcast(connections, broadcastPayload); - + // 실패한 연결 정리 for (String failedConnectionId : failedConnections) { connectionRepository.delete(failedConnectionId); logger.info("Deleted stale connection: {}", failedConnectionId); } - + logger.info("Command result broadcasted: type={}, roomId={}", result.messageType(), roomId); return WebSocketEventUtil.ok("Command executed"); } - + /** * 메시지 페이로드 DTO */ From b065c13b826df42209525e0c9563046866554dc0 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:30:34 +0900 Subject: [PATCH 247/528] =?UTF-8?q?refactor:=20ChatRoom=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../serverless/domain/chatting/model/ChatRoom.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/ChatRoom.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/ChatRoom.java index 253e496b..5fe45aaf 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/ChatRoom.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/ChatRoom.java @@ -34,7 +34,7 @@ public class ChatRoom { private String lastMessageAt; private List memberIds; // 참여 멤버 목록 private Long ttl; - + // 게임 관련 필드 private String gameStatus; // NONE, WAITING, PLAYING, ROUND_END, FINISHED private String gameStartedBy; // 게임 시작한 사용자 ID From 79089cb8ecf855ebb208db8655c1c5855cf9ccfe Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:30:34 +0900 Subject: [PATCH 248/528] =?UTF-8?q?refactor:=20GameRound=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/chatting/model/GameRound.java | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameRound.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameRound.java index ff3eddad..b3688c81 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameRound.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameRound.java @@ -1,6 +1,9 @@ package com.mzc.secondproject.serverless.domain.chatting.model; -import lombok.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.*; import java.util.List; @@ -15,41 +18,41 @@ @AllArgsConstructor @DynamoDbBean public class GameRound { - + private String pk; // ROOM#{roomId}#GAME private String sk; // ROUND#{roundNumber} - + private String roomId; private Integer roundNumber; private String drawerId; // 출제자 userId private String wordId; // 제시어 wordId private String word; // 제시어 (korean) private String wordEnglish; // 제시어 (english) - + private List correctGuessers; // 정답 맞춘 순서 private Map guessTimes; // userId -> 정답까지 걸린 시간(ms) private Map roundScores; // userId -> 이 라운드 획득 점수 - + private Long startTime; // 라운드 시작 시간 (Unix timestamp ms) private Long endTime; // 라운드 종료 시간 private String endReason; // TIME_UP, ALL_CORRECT, SKIP - + private Boolean hintUsed; // 힌트 사용 여부 private String createdAt; private Long ttl; // 자동 만료 (7일 후) - + @DynamoDbPartitionKey @DynamoDbAttribute("PK") public String getPk() { return pk; } - + @DynamoDbSortKey @DynamoDbAttribute("SK") public String getSk() { return sk; } - + /** * 라운드가 진행 중인지 확인 */ @@ -57,7 +60,7 @@ public String getSk() { public boolean isActive() { return endTime == null; } - + /** * 경과 시간 계산 (ms) */ From 1a93a3db4fbacd9f2cd5e91320e283fab81e7f29 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:30:41 +0900 Subject: [PATCH 249/528] =?UTF-8?q?refactor:=20GameRoundRepository=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/GameRoundRepository.java | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/GameRoundRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/GameRoundRepository.java index db5f57d7..9194eb8d 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/GameRoundRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/GameRoundRepository.java @@ -20,35 +20,35 @@ * 게임 라운드 저장소 */ public class GameRoundRepository { - + private static final Logger logger = LoggerFactory.getLogger(GameRoundRepository.class); private static final String TABLE_NAME = System.getenv("CHAT_TABLE_NAME"); private static final int BATCH_SIZE = 25; // DynamoDB BatchWriteItem 최대 25개 - + private final DynamoDbEnhancedClient enhancedClient; private final DynamoDbTable table; - + public GameRoundRepository() { this.enhancedClient = AwsClients.dynamoDbEnhanced(); this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(GameRound.class)); } - + public GameRound save(GameRound gameRound) { logger.info("Saving game round: roomId={}, round={}", gameRound.getRoomId(), gameRound.getRoundNumber()); table.putItem(gameRound); return gameRound; } - + public Optional findByRoomIdAndRound(String roomId, Integer roundNumber) { Key key = Key.builder() .partitionValue("ROOM#" + roomId + "#GAME") .sortValue("ROUND#" + roundNumber) .build(); - + GameRound round = table.getItem(key); return Optional.ofNullable(round); } - + /** * 특정 게임의 모든 라운드 조회 */ @@ -57,16 +57,16 @@ public List findByRoomId(String roomId) { .keyEqualTo(Key.builder() .partitionValue("ROOM#" + roomId + "#GAME") .build()); - + QueryEnhancedRequest request = QueryEnhancedRequest.builder() .queryConditional(queryConditional) .build(); - + return table.query(request).stream() .flatMap(page -> page.items().stream()) .collect(Collectors.toList()); } - + /** * 특정 게임의 모든 라운드 삭제 (BatchWriteItem 사용) */ @@ -75,7 +75,7 @@ public void deleteByRoomId(String roomId) { if (rounds.isEmpty()) { return; } - + // BatchWriteItem은 최대 25개까지 지원하므로 분할 처리 for (int i = 0; i < rounds.size(); i += BATCH_SIZE) { List batch = rounds.subList(i, Math.min(i + BATCH_SIZE, rounds.size())); @@ -83,11 +83,11 @@ public void deleteByRoomId(String roomId) { } logger.info("Deleted {} rounds for roomId={}", rounds.size(), roomId); } - + private void batchDeleteRounds(List rounds) { WriteBatch.Builder writeBatchBuilder = WriteBatch.builder(GameRound.class) .mappedTableResource(table); - + for (GameRound round : rounds) { Key key = Key.builder() .partitionValue(round.getPk()) @@ -95,7 +95,7 @@ private void batchDeleteRounds(List rounds) { .build(); writeBatchBuilder.addDeleteItem(key); } - + enhancedClient.batchWriteItem(r -> r.writeBatches(writeBatchBuilder.build())); } } From 449c18582340ab6bf82dd6906f55cdc854eb7367 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:30:41 +0900 Subject: [PATCH 250/528] =?UTF-8?q?refactor:=20CommandService=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chatting/service/CommandService.java | 66 +++++++++---------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/CommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/CommandService.java index 7b1d53b1..9fbda0b7 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/CommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/CommandService.java @@ -11,42 +11,42 @@ import java.util.List; import java.util.Optional; -import java.util.stream.Collectors; /** * 슬래시 명령어 처리 서비스 */ public class CommandService { - + private static final Logger logger = LoggerFactory.getLogger(CommandService.class); - + private final ConnectionRepository connectionRepository; private final ChatRoomRepository chatRoomRepository; private final GameService gameService; - + public CommandService() { this.connectionRepository = new ConnectionRepository(); this.chatRoomRepository = new ChatRoomRepository(); this.gameService = new GameService(); } - + /** * 명령어 처리 + * * @param content 메시지 내용 - * @param roomId 채팅방 ID - * @param userId 사용자 ID + * @param roomId 채팅방 ID + * @param userId 사용자 ID * @return 명령어 처리 결과 (명령어가 아닌 경우 Optional.empty()) */ public Optional processCommand(String content, String roomId, String userId) { if (content == null || !content.startsWith("/")) { return Optional.empty(); } - + String[] parts = content.trim().split("\\s+", 2); String command = parts[0].toLowerCase(); - + logger.info("Processing command: {} from user: {} in room: {}", command, userId, roomId); - + return switch (command) { case "/member", "/members" -> Optional.of(handleMemberCommand(roomId)); case "/start" -> Optional.of(handleStartCommand(roomId, userId)); @@ -58,51 +58,51 @@ public Optional processCommand(String content, String roomId, Str default -> Optional.empty(); }; } - + /** * /member - 현재 접속자 수 조회 */ private CommandResult handleMemberCommand(String roomId) { List connections = connectionRepository.findByRoomId(roomId); - + if (connections.isEmpty()) { return CommandResult.success(MessageType.SYSTEM_COMMAND, "현재 접속자가 없습니다."); } - + String message = String.format("현재 접속자: %d명", connections.size()); return CommandResult.success(MessageType.SYSTEM_COMMAND, message, connections.size()); } - + /** * /start - 게임 시작 */ private CommandResult handleStartCommand(String roomId, String userId) { GameService.GameStartResult result = gameService.startGame(roomId, userId); - + if (!result.success()) { return CommandResult.error(result.error()); } - + String message = String.format(""" - 🎮 게임 시작! - 총 %d 라운드 - - 라운드 1 시작! - 출제자: %s - """, + 🎮 게임 시작! + 총 %d 라운드 + + 라운드 1 시작! + 출제자: %s + """, result.room().getTotalRounds(), result.room().getCurrentDrawerId()); - + return CommandResult.success(MessageType.GAME_START, message, result); } - + /** * /stop - 게임 중단 */ private CommandResult handleStopCommand(String roomId, String userId) { return gameService.stopGame(roomId, userId); } - + /** * /score - 현재 점수 조회 */ @@ -111,40 +111,40 @@ private CommandResult handleScoreCommand(String roomId) { if (optRoom.isEmpty()) { return CommandResult.error("채팅방을 찾을 수 없습니다."); } - + ChatRoom room = optRoom.get(); - + if (room.getGameStatus() == null || "NONE".equals(room.getGameStatus())) { return CommandResult.error("진행 중인 게임이 없습니다."); } - + // TODO: 점수 포맷팅 (Story #225에서 구현) if (room.getScores() == null || room.getScores().isEmpty()) { return CommandResult.success(MessageType.SCORE_UPDATE, "아직 점수가 없습니다."); } - + StringBuilder sb = new StringBuilder("📊 현재 점수:\n"); room.getScores().entrySet().stream() .sorted((a, b) -> b.getValue().compareTo(a.getValue())) .forEach(entry -> sb.append(String.format(" %s: %d점\n", entry.getKey(), entry.getValue()))); - + return CommandResult.success(MessageType.SCORE_UPDATE, sb.toString(), room.getScores()); } - + /** * /skip - 라운드 스킵 (출제자만) */ private CommandResult handleSkipCommand(String roomId, String userId) { return gameService.skipRound(roomId, userId); } - + /** * /hint - 힌트 제공 (출제자만) */ private CommandResult handleHintCommand(String roomId, String userId) { return gameService.provideHint(roomId, userId); } - + /** * /help - 도움말 */ From df79f593298b811e41dd122e92044c3fa6c05c91 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:30:41 +0900 Subject: [PATCH 251/528] =?UTF-8?q?refactor:=20GameService=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/chatting/service/GameService.java | 217 +++++++++--------- 1 file changed, 109 insertions(+), 108 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java index e38f9fc0..9a472dda 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java @@ -23,17 +23,17 @@ * 캐치마인드 게임 로직 서비스 */ public class GameService { - + private static final Logger logger = LoggerFactory.getLogger(GameService.class); private static final int DEFAULT_TOTAL_ROUNDS = 5; private static final int DEFAULT_ROUND_TIME_LIMIT = 60; // 초 - + private final ChatRoomRepository chatRoomRepository; private final ConnectionRepository connectionRepository; private final GameRoundRepository gameRoundRepository; private final WordRepository wordRepository; private final GameStatsService gameStatsService; - + /** * 기본 생성자 (Lambda에서 사용) */ @@ -41,7 +41,7 @@ public GameService() { this(new ChatRoomRepository(), new ConnectionRepository(), new GameRoundRepository(), new WordRepository(), new GameStatsService()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ @@ -54,40 +54,40 @@ public GameService(ChatRoomRepository chatRoomRepository, ConnectionRepository c this.wordRepository = wordRepository; this.gameStatsService = gameStatsService; } - + /** * 게임 시작 */ public GameStartResult startGame(String roomId, String userId) { ChatRoom room = chatRoomRepository.findById(roomId) .orElseThrow(() -> new IllegalArgumentException("채팅방을 찾을 수 없습니다.")); - + // 이미 게임 중인지 확인 GameStatus currentStatus = GameStatus.fromString(room.getGameStatus()); if (!currentStatus.canStartGame()) { return GameStartResult.error("이미 게임이 진행 중입니다."); } - + // 접속자 확인 List connections = connectionRepository.findByRoomId(roomId); if (connections.size() < 2) { return GameStartResult.error("최소 2명 이상 접속해야 게임을 시작할 수 있습니다."); } - + // 출제 순서 생성 (랜덤 셔플) List drawerOrder = connections.stream() .map(Connection::getUserId) .collect(Collectors.toList()); Collections.shuffle(drawerOrder); - + // 제시어 추출 (난이도별) String level = room.getLevel() != null ? room.getLevel() : "beginner"; List words = getRandomWords(level, DEFAULT_TOTAL_ROUNDS); - + if (words.size() < DEFAULT_TOTAL_ROUNDS) { return GameStartResult.error("단어가 부족합니다. 관리자에게 문의하세요."); } - + // 게임 상태 업데이트 room.setGameStatus(GameStatus.PLAYING.name()); room.setGameStartedBy(userId); @@ -97,7 +97,7 @@ public GameStartResult startGame(String roomId, String userId) { room.setScores(new HashMap<>()); room.setStreaks(new HashMap<>()); room.setRoundTimeLimit(DEFAULT_ROUND_TIME_LIMIT); - + // 첫 라운드 설정 String firstDrawer = drawerOrder.get(0); Word firstWord = words.get(0); @@ -107,9 +107,9 @@ public GameStartResult startGame(String roomId, String userId) { room.setRoundStartTime(System.currentTimeMillis()); room.setHintUsed(false); room.setCorrectGuessers(new ArrayList<>()); - + chatRoomRepository.save(room); - + // 첫 라운드 기록 생성 (7일 후 자동 삭제) long ttlSeconds = Instant.now().plusSeconds(7 * 24 * 60 * 60).getEpochSecond(); GameRound firstRound = GameRound.builder() @@ -129,165 +129,165 @@ public GameStartResult startGame(String roomId, String userId) { .createdAt(Instant.now().toString()) .ttl(ttlSeconds) .build(); - + gameRoundRepository.save(firstRound); - + logger.info("Game started: roomId={}, starter={}, rounds={}", roomId, userId, DEFAULT_TOTAL_ROUNDS); - + return GameStartResult.success(room, firstWord, drawerOrder); } - + /** * 게임 종료 */ public CommandResult stopGame(String roomId, String userId) { ChatRoom room = chatRoomRepository.findById(roomId) .orElseThrow(() -> new IllegalArgumentException("채팅방을 찾을 수 없습니다.")); - + GameStatus currentStatus = GameStatus.fromString(room.getGameStatus()); if (!currentStatus.isGameActive()) { return CommandResult.error("진행 중인 게임이 없습니다."); } - + // 권한 확인 boolean isOwner = userId.equals(room.getCreatedBy()); boolean isGameStarter = userId.equals(room.getGameStartedBy()); - + if (!isOwner && !isGameStarter) { return CommandResult.error("게임을 중단할 권한이 없습니다."); } - + // 게임 종료 처리 return finishGame(room, "STOPPED"); } - + /** * 정답 체크 */ public AnswerCheckResult checkAnswer(String roomId, String userId, String answer) { ChatRoom room = chatRoomRepository.findById(roomId) .orElseThrow(() -> new IllegalArgumentException("채팅방을 찾을 수 없습니다.")); - + // 게임 진행 중인지 확인 if (!GameStatus.PLAYING.name().equals(room.getGameStatus())) { return AnswerCheckResult.gameNotPlaying(); } - + // 출제자는 정답 체크 제외 if (userId.equals(room.getCurrentDrawerId())) { return AnswerCheckResult.drawerCannotGuess(); } - + // 이미 맞춘 사람인지 확인 if (room.getCorrectGuessers() != null && room.getCorrectGuessers().contains(userId)) { return AnswerCheckResult.alreadyGuessedCorrect(); } - + // 정답 체크 String currentWord = room.getCurrentWord(); if (!isCorrectAnswer(answer, currentWord)) { return AnswerCheckResult.wrongAnswer(); } - + // 정답 처리 long elapsedTime = System.currentTimeMillis() - room.getRoundStartTime(); - + // 연속 정답 업데이트 (점수 계산 전에) if (room.getStreaks() == null) { room.setStreaks(new HashMap<>()); } int currentStreak = room.getStreaks().getOrDefault(userId, 0) + 1; room.getStreaks().put(userId, currentStreak); - + int score = calculateScore(room, elapsedTime, userId, currentStreak); - + // 정답자 목록에 추가 if (room.getCorrectGuessers() == null) { room.setCorrectGuessers(new ArrayList<>()); } room.getCorrectGuessers().add(userId); - + // 점수 업데이트 if (room.getScores() == null) { room.setScores(new HashMap<>()); } room.getScores().merge(userId, score, Integer::sum); - + // 출제자 점수도 추가 room.getScores().merge(room.getCurrentDrawerId(), 5, Integer::sum); - + chatRoomRepository.save(room); - + // 라운드 기록 업데이트 updateRoundRecord(roomId, room.getCurrentRound(), userId, elapsedTime, score); - + // 전원 정답 체크 List connections = connectionRepository.findByRoomId(roomId); int nonDrawerCount = (int) connections.stream() .filter(c -> !c.getUserId().equals(room.getCurrentDrawerId())) .count(); - + boolean allCorrect = room.getCorrectGuessers().size() >= nonDrawerCount; - + logger.info("Answer correct: roomId={}, userId={}, score={}, allCorrect={}", roomId, userId, score, allCorrect); - + return AnswerCheckResult.correctAnswer(score, elapsedTime, allCorrect, room.getScores()); } - + /** * 라운드 스킵 */ public CommandResult skipRound(String roomId, String userId) { ChatRoom room = chatRoomRepository.findById(roomId) .orElseThrow(() -> new IllegalArgumentException("채팅방을 찾을 수 없습니다.")); - + if (!GameStatus.PLAYING.name().equals(room.getGameStatus())) { return CommandResult.error("게임이 진행 중이 아닙니다."); } - + if (!userId.equals(room.getCurrentDrawerId())) { return CommandResult.error("출제자만 라운드를 스킵할 수 있습니다."); } - + return endRound(room, "SKIP"); } - + /** * 힌트 제공 */ public CommandResult provideHint(String roomId, String userId) { ChatRoom room = chatRoomRepository.findById(roomId) .orElseThrow(() -> new IllegalArgumentException("채팅방을 찾을 수 없습니다.")); - + if (!GameStatus.PLAYING.name().equals(room.getGameStatus())) { return CommandResult.error("게임이 진행 중이 아닙니다."); } - + if (!userId.equals(room.getCurrentDrawerId())) { return CommandResult.error("출제자만 힌트를 제공할 수 있습니다."); } - + if (Boolean.TRUE.equals(room.getHintUsed())) { return CommandResult.error("이번 라운드에서 이미 힌트를 사용했습니다."); } - + String currentWord = room.getCurrentWord(); String hint = currentWord.charAt(0) + "○".repeat(currentWord.length() - 1); - + room.setHintUsed(true); chatRoomRepository.save(room); - + // 라운드 기록 업데이트 gameRoundRepository.findByRoomIdAndRound(roomId, room.getCurrentRound()) .ifPresent(round -> { round.setHintUsed(true); gameRoundRepository.save(round); }); - + return CommandResult.success(MessageType.HINT, "💡 힌트: " + hint); } - + /** * 라운드 종료 처리 */ @@ -295,10 +295,10 @@ public CommandResult endRound(ChatRoom room, String reason) { String roomId = room.getRoomId(); Integer currentRound = room.getCurrentRound(); String answer = room.getCurrentWord(); - + // 정답 못 맞춘 사용자 연속 정답 초기화 resetStreaksForNonGuessers(room); - + // 라운드 기록 종료 gameRoundRepository.findByRoomIdAndRound(roomId, currentRound) .ifPresent(round -> { @@ -306,27 +306,27 @@ public CommandResult endRound(ChatRoom room, String reason) { round.setEndReason(reason); gameRoundRepository.save(round); }); - + // 다음 라운드로 진행 if (currentRound >= room.getTotalRounds()) { return finishGame(room, "COMPLETED"); } - + // 현재 접속 중인 사용자 목록 조회 List connections = connectionRepository.findByRoomId(roomId); Set connectedUserIds = connections.stream() .map(Connection::getUserId) .collect(Collectors.toSet()); - + // 접속자가 2명 미만이면 게임 종료 if (connectedUserIds.size() < 2) { return finishGame(room, "NOT_ENOUGH_PLAYERS"); } - + // 다음 라운드 준비 - 접속 중인 사용자 중에서만 출제자 선택 int nextRound = currentRound + 1; String nextDrawer = selectNextDrawer(room.getDrawerOrder(), connectedUserIds, nextRound); - + // 다음 단어 추출 String level = room.getLevel() != null ? room.getLevel() : "beginner"; List words = getRandomWords(level, 1); @@ -334,7 +334,7 @@ public CommandResult endRound(ChatRoom room, String reason) { return finishGame(room, "NO_WORDS"); } Word nextWord = words.get(0); - + // 상태 업데이트 room.setCurrentRound(nextRound); room.setCurrentDrawerId(nextDrawer); @@ -343,9 +343,9 @@ public CommandResult endRound(ChatRoom room, String reason) { room.setRoundStartTime(System.currentTimeMillis()); room.setHintUsed(false); room.setCorrectGuessers(new ArrayList<>()); - + chatRoomRepository.save(room); - + // 다음 라운드 기록 생성 (7일 후 자동 삭제) long nextTtlSeconds = Instant.now().plusSeconds(7 * 24 * 60 * 60).getEpochSecond(); GameRound nextRoundRecord = GameRound.builder() @@ -365,17 +365,17 @@ public CommandResult endRound(ChatRoom room, String reason) { .createdAt(Instant.now().toString()) .ttl(nextTtlSeconds) .build(); - + gameRoundRepository.save(nextRoundRecord); - + String message = String.format("라운드 %d 종료! 정답: %s\n\n라운드 %d 시작! 출제자: %s", currentRound, answer, nextRound, nextDrawer); - + logger.info("Round ended: roomId={}, round={}, reason={}", roomId, currentRound, reason); - + // ranking 생성 List> ranking = buildRankingList(room.getScores()); - + Map data = new HashMap<>(); data.put("answer", answer); data.put("nextRound", nextRound); @@ -384,17 +384,17 @@ public CommandResult endRound(ChatRoom room, String reason) { data.put("ranking", ranking); data.put("currentRound", currentRound); data.put("totalRounds", room.getTotalRounds()); - + return CommandResult.success(MessageType.ROUND_END, message, data); } - + /** * 게임 완전 종료 */ private CommandResult finishGame(ChatRoom room, String reason) { room.setGameStatus(GameStatus.FINISHED.name()); chatRoomRepository.save(room); - + // 게임 통계 업데이트 및 뱃지 체크 try { var newBadges = gameStatsService.updateGameStats(room); @@ -402,14 +402,14 @@ private CommandResult finishGame(ChatRoom room, String reason) { } catch (Exception e) { logger.error("Failed to update game stats: roomId={}, error={}", room.getRoomId(), e.getMessage()); } - + // 최종 점수 정렬 StringBuilder sb = new StringBuilder("🎮 게임 종료!\n\n📊 최종 순위:\n"); if (room.getScores() != null && !room.getScores().isEmpty()) { List> sorted = room.getScores().entrySet().stream() .sorted((a, b) -> b.getValue().compareTo(a.getValue())) .toList(); - + int rank = 1; for (Map.Entry entry : sorted) { String medal = switch (rank) { @@ -424,19 +424,19 @@ private CommandResult finishGame(ChatRoom room, String reason) { } else { sb.append(" 점수 없음"); } - + logger.info("Game finished: roomId={}, reason={}", room.getRoomId(), reason); - + return CommandResult.success(MessageType.GAME_END, sb.toString(), room.getScores()); } - + /** * 접속 중인 사용자 중에서 다음 출제자 선택 */ private String selectNextDrawer(List drawerOrder, Set connectedUserIds, int roundNumber) { // 원래 순서에서 시작 인덱스 계산 int startIndex = (roundNumber - 1) % drawerOrder.size(); - + // 접속 중인 사용자를 찾을 때까지 순회 for (int i = 0; i < drawerOrder.size(); i++) { int index = (startIndex + i) % drawerOrder.size(); @@ -445,11 +445,11 @@ private String selectNextDrawer(List drawerOrder, Set connectedU return candidate; } } - + // 원래 순서에 있는 사람이 모두 나갔으면, 접속 중인 아무나 선택 return connectedUserIds.iterator().next(); } - + /** * 랜덤 단어 추출 */ @@ -459,44 +459,45 @@ private List getRandomWords(String level, int count) { Collections.shuffle(words); return words.stream().limit(count).collect(Collectors.toList()); } - + /** * 정답 체크 로직 */ private boolean isCorrectAnswer(String input, String answer) { if (input == null || answer == null) return false; - + String normalizedInput = input.trim().toLowerCase().replace(" ", ""); String normalizedAnswer = answer.trim().toLowerCase().replace(" ", ""); - + return normalizedInput.equals(normalizedAnswer); } - + /** * 점수 계산 - * @param room 채팅방 + * + * @param room 채팅방 * @param elapsedTimeMs 경과 시간 (밀리초) - * @param userId 사용자 ID - * @param streak 연속 정답 수 + * @param userId 사용자 ID + * @param streak 연속 정답 수 * @return 계산된 점수 */ private int calculateScore(ChatRoom room, long elapsedTimeMs, String userId, int streak) { int baseScore = 10; - + // 시간 보너스 (빨리 맞출수록 높은 점수): (제한시간 - 경과시간) * 0.5 int elapsedSeconds = (int) (elapsedTimeMs / 1000); int timeLimit = room.getRoundTimeLimit() != null ? room.getRoundTimeLimit() : DEFAULT_ROUND_TIME_LIMIT; int timeBonus = Math.max(0, (int) ((timeLimit - elapsedSeconds) * 0.5)); - + // 연속 정답 보너스: 연속정답수 * 2 int streakBonus = streak * 2; - + logger.info("Score calculation: base={}, timeBonus={}, streakBonus={}, total={}", baseScore, timeBonus, streakBonus, baseScore + timeBonus + streakBonus); - + return baseScore + timeBonus + streakBonus; } - + /** * 라운드 기록 업데이트 */ @@ -507,21 +508,21 @@ private void updateRoundRecord(String roomId, Integer roundNumber, String userId round.setCorrectGuessers(new ArrayList<>()); } round.getCorrectGuessers().add(userId); - + if (round.getGuessTimes() == null) { round.setGuessTimes(new HashMap<>()); } round.getGuessTimes().put(userId, elapsedTime); - + if (round.getRoundScores() == null) { round.setRoundScores(new HashMap<>()); } round.getRoundScores().put(userId, score); - + gameRoundRepository.save(round); }); } - + /** * 정답 못 맞춘 사용자 연속 정답 초기화 */ @@ -529,19 +530,19 @@ private void resetStreaksForNonGuessers(ChatRoom room) { if (room.getStreaks() == null || room.getStreaks().isEmpty()) { return; } - + List correctGuessers = room.getCorrectGuessers() != null ? room.getCorrectGuessers() : List.of(); - + // 정답 못 맞춘 사용자의 연속 정답 초기화 room.getStreaks().keySet().stream() .filter(userId -> !correctGuessers.contains(userId)) .forEach(userId -> room.getStreaks().put(userId, 0)); - + logger.info("Reset streaks for non-guessers: correctGuessers={}", correctGuessers); } - + /** * 점수 맵을 순위 리스트로 변환 */ @@ -549,11 +550,11 @@ private List> buildRankingList(Map scores) if (scores == null || scores.isEmpty()) { return List.of(); } - + List> sorted = scores.entrySet().stream() .sorted(Map.Entry.comparingByValue().reversed()) .toList(); - + List> ranking = new ArrayList<>(); for (int i = 0; i < sorted.size(); i++) { Map entry = new HashMap<>(); @@ -564,9 +565,9 @@ private List> buildRankingList(Map scores) } return ranking; } - + // ========== Result DTOs ========== - + public record GameStartResult( boolean success, String error, @@ -577,12 +578,12 @@ public record GameStartResult( public static GameStartResult success(ChatRoom room, Word word, List order) { return new GameStartResult(true, null, room, word, order); } - + public static GameStartResult error(String message) { return new GameStartResult(false, message, null, null, null); } } - + public record AnswerCheckResult( boolean correct, boolean drawer, @@ -596,19 +597,19 @@ public record AnswerCheckResult( public static AnswerCheckResult correctAnswer(int score, long elapsed, boolean allCorrect, Map scores) { return new AnswerCheckResult(true, false, false, false, allCorrect, score, elapsed, scores); } - + public static AnswerCheckResult wrongAnswer() { return new AnswerCheckResult(false, false, false, false, false, 0, 0, null); } - + public static AnswerCheckResult drawerCannotGuess() { return new AnswerCheckResult(false, true, false, false, false, 0, 0, null); } - + public static AnswerCheckResult alreadyGuessedCorrect() { return new AnswerCheckResult(false, false, true, false, false, 0, 0, null); } - + public static AnswerCheckResult gameNotPlaying() { return new AnswerCheckResult(false, false, false, true, false, 0, 0, null); } From 1d5bc8333467d39b88bbf17cd341f96b1141c8ef Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:30:41 +0900 Subject: [PATCH 252/528] =?UTF-8?q?refactor:=20GameStatsService=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chatting/service/GameStatsService.java | 47 +++++++++---------- 1 file changed, 23 insertions(+), 24 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameStatsService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameStatsService.java index 5167ca4d..c71b0865 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameStatsService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameStatsService.java @@ -10,47 +10,46 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.time.Instant; import java.util.*; /** * 게임 통계 및 뱃지 연동 서비스 */ public class GameStatsService { - + private static final Logger logger = LoggerFactory.getLogger(GameStatsService.class); private static final long QUICK_GUESS_THRESHOLD_MS = 5000; // 5초 - + private final UserStatsRepository userStatsRepository; private final GameRoundRepository gameRoundRepository; private final BadgeService badgeService; - + public GameStatsService() { this.userStatsRepository = new UserStatsRepository(); this.gameRoundRepository = new GameRoundRepository(); this.badgeService = new BadgeService(); } - + /** * 게임 종료 시 모든 참가자 통계 업데이트 */ public Map> updateGameStats(ChatRoom room) { Map> newBadges = new HashMap<>(); String roomId = room.getRoomId(); - + // 모든 라운드 조회 List rounds = gameRoundRepository.findByRoomId(roomId); - + // 참가자별 통계 수집 Map scores = room.getScores() != null ? room.getScores() : Map.of(); Set participants = new HashSet<>(scores.keySet()); if (room.getDrawerOrder() != null) { participants.addAll(room.getDrawerOrder()); } - + // 1등 찾기 String winner = findWinner(scores); - + // 각 참가자 통계 업데이트 for (String userId : participants) { List badges = updateUserGameStats(userId, scores.getOrDefault(userId, 0), @@ -59,28 +58,28 @@ public Map> updateGameStats(ChatRoom room) { newBadges.put(userId, badges); } } - + logger.info("Game stats updated: roomId={}, participants={}, winner={}", roomId, participants.size(), winner); - + return newBadges; } - + /** * 개별 사용자 게임 통계 업데이트 */ private List updateUserGameStats(String userId, int score, boolean isWinner, - List rounds) { + List rounds) { // 라운드별 통계 수집 int correctGuesses = 0; int quickGuesses = 0; int perfectDraws = 0; - + for (GameRound round : rounds) { // 정답 횟수 if (round.getCorrectGuessers() != null && round.getCorrectGuessers().contains(userId)) { correctGuesses++; - + // 빠른 정답 체크 (5초 이내) if (round.getGuessTimes() != null) { Long guessTime = round.getGuessTimes().get(userId); @@ -89,7 +88,7 @@ private List updateUserGameStats(String userId, int score, boolean is } } } - + // 완벽한 출제자 체크 if (userId.equals(round.getDrawerId())) { // 출제자일 때 전원 정답인지 확인 @@ -98,7 +97,7 @@ private List updateUserGameStats(String userId, int score, boolean is } } } - + // Atomic 업데이트 userStatsRepository.incrementGameStats( userId, @@ -109,17 +108,17 @@ private List updateUserGameStats(String userId, int score, boolean is quickGuesses, perfectDraws ); - + // 뱃지 체크를 위해 업데이트된 통계 조회 UserStats stats = userStatsRepository.findTotalStats(userId).orElse(null); List newBadges = badgeService.checkAndAwardBadges(userId, stats); - + logger.info("User game stats updated: userId={}, correctGuesses={}, newBadges={}", userId, correctGuesses, newBadges.size()); - + return newBadges; } - + /** * 정답 시 즉시 통계 업데이트 (빠른 정답 뱃지용) */ @@ -127,14 +126,14 @@ public List updateOnCorrectAnswer(String userId, long elapsedTimeMs) if (elapsedTimeMs > QUICK_GUESS_THRESHOLD_MS) { return List.of(); } - + // 빠른 정답만 업데이트 userStatsRepository.incrementGameStats(userId, 0, 0, 0, 0, 1, 0); - + UserStats stats = userStatsRepository.findTotalStats(userId).orElse(null); return badgeService.checkAndAwardBadges(userId, stats); } - + private String findWinner(Map scores) { if (scores == null || scores.isEmpty()) { return null; From bf8fdfffaa74e4a31968261f0f2e7e71c6bfd6d0 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:30:42 +0900 Subject: [PATCH 253/528] =?UTF-8?q?refactor:=20GrammarKey=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/grammar/constants/GrammarKey.java | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/constants/GrammarKey.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/constants/GrammarKey.java index 69678677..1aa99716 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/constants/GrammarKey.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/constants/GrammarKey.java @@ -1,35 +1,35 @@ package com.mzc.secondproject.serverless.domain.grammar.constants; public final class GrammarKey { - - private GrammarKey() {} - + public static final String SESSION_PREFIX = "GSESSION#"; public static final String SESSION_SK_PREFIX = "SESSION#"; public static final String MSG_PREFIX = "MSG#"; public static final String ALL_SESSIONS = "GSESSION#ALL"; public static final String UPDATED_PREFIX = "UPDATED#"; - + private GrammarKey() { + } + public static String sessionPk(String userId) { return SESSION_PREFIX + userId; } - + public static String sessionSk(String sessionId) { return SESSION_SK_PREFIX + sessionId; } - + public static String messageSk(String timestamp, String messageId) { return MSG_PREFIX + timestamp + "#" + messageId; } - + public static String messageGsi1Pk(String sessionId) { return SESSION_PREFIX + sessionId; } - + public static String messageGsi1Sk(String timestamp) { return MSG_PREFIX + timestamp; } - + public static String updatedSk(String timestamp) { return UPDATED_PREFIX + timestamp; } From be9a448b13bc70d631af70de5496aa20f954f1f2 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:30:42 +0900 Subject: [PATCH 254/528] =?UTF-8?q?refactor:=20ConversationRequest=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/grammar/dto/request/ConversationRequest.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/dto/request/ConversationRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/dto/request/ConversationRequest.java index 7d335fa4..0d72922c 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/dto/request/ConversationRequest.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/dto/request/ConversationRequest.java @@ -1,6 +1,9 @@ package com.mzc.secondproject.serverless.domain.grammar.dto.request; -import lombok.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; @Data @Builder @@ -10,7 +13,7 @@ public class ConversationRequest { private String sessionId; private String message; private String userId; // Handler에서 설정 - + @Builder.Default private String level = "BEGINNER"; } From 501160bdbafc1daedc8f3828e60587358011f871 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:30:49 +0900 Subject: [PATCH 255/528] =?UTF-8?q?refactor:=20GrammarCheckRequest=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/grammar/dto/request/GrammarCheckRequest.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/dto/request/GrammarCheckRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/dto/request/GrammarCheckRequest.java index 1a4c42f4..7135e1d5 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/dto/request/GrammarCheckRequest.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/dto/request/GrammarCheckRequest.java @@ -1,6 +1,9 @@ package com.mzc.secondproject.serverless.domain.grammar.dto.request; -import lombok.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; @Data @Builder @@ -8,7 +11,7 @@ @AllArgsConstructor public class GrammarCheckRequest { private String sentence; - + @Builder.Default private String level = "BEGINNER"; } From a9c11e0ede381f71c9082f7a57533864550de0c2 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:30:49 +0900 Subject: [PATCH 256/528] =?UTF-8?q?refactor:=20ComprehendAnalysis=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/grammar/dto/response/ComprehendAnalysis.java | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/dto/response/ComprehendAnalysis.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/dto/response/ComprehendAnalysis.java index e591df93..401b479a 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/dto/response/ComprehendAnalysis.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/dto/response/ComprehendAnalysis.java @@ -1,6 +1,9 @@ package com.mzc.secondproject.serverless.domain.grammar.dto.response; -import lombok.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; import java.util.List; @@ -19,7 +22,7 @@ public class ComprehendAnalysis { private String complexity; private String language; private Double languageScore; - + @Data @Builder @NoArgsConstructor @@ -30,7 +33,7 @@ public static class SentimentScore { private Double neutral; private Double mixed; } - + @Data @Builder @NoArgsConstructor From 476251c1972f90b8fa239c95d6817a905e0b58d3 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:30:49 +0900 Subject: [PATCH 257/528] =?UTF-8?q?refactor:=20ConversationResponse=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/grammar/dto/response/ConversationResponse.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/dto/response/ConversationResponse.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/dto/response/ConversationResponse.java index e099ce89..24ad7bcc 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/dto/response/ConversationResponse.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/dto/response/ConversationResponse.java @@ -1,6 +1,9 @@ package com.mzc.secondproject.serverless.domain.grammar.dto.response; -import lombok.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; @Data @Builder From d9326712602fee91989160e53b56f9146fc5aa28 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:30:50 +0900 Subject: [PATCH 258/528] =?UTF-8?q?refactor:=20GrammarCheckResponse=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/grammar/dto/response/GrammarCheckResponse.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/dto/response/GrammarCheckResponse.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/dto/response/GrammarCheckResponse.java index d3c44f7c..7f9bcd6b 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/dto/response/GrammarCheckResponse.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/dto/response/GrammarCheckResponse.java @@ -1,6 +1,10 @@ package com.mzc.secondproject.serverless.domain.grammar.dto.response; -import lombok.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + import java.util.List; @Data From 3619316fbe3760f17d8a5ccc342939b08127d03a Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:30:50 +0900 Subject: [PATCH 259/528] =?UTF-8?q?refactor:=20GrammarError=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../serverless/domain/grammar/dto/response/GrammarError.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/dto/response/GrammarError.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/dto/response/GrammarError.java index dfc19f92..a3271e0a 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/dto/response/GrammarError.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/dto/response/GrammarError.java @@ -1,7 +1,10 @@ package com.mzc.secondproject.serverless.domain.grammar.dto.response; import com.mzc.secondproject.serverless.domain.grammar.enums.GrammarErrorType; -import lombok.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; @Data @Builder From 84f7120e96f642356876051c97c444574a724a3e Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:30:50 +0900 Subject: [PATCH 260/528] =?UTF-8?q?refactor:=20GrammarErrorType=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../serverless/domain/grammar/enums/GrammarErrorType.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/enums/GrammarErrorType.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/enums/GrammarErrorType.java index 84a210fa..e6e0f1f6 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/enums/GrammarErrorType.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/enums/GrammarErrorType.java @@ -13,19 +13,19 @@ public enum GrammarErrorType { WORD_CHOICE("어휘 선택", "Word Choice"), SENTENCE_STRUCTURE("문장 구조", "Sentence Structure"), OTHER("기타", "Other"); - + private final String koreanName; private final String englishName; - + GrammarErrorType(String koreanName, String englishName) { this.koreanName = koreanName; this.englishName = englishName; } - + public String getKoreanName() { return koreanName; } - + public String getEnglishName() { return englishName; } From f82e7e94682b3004083acd05b14ed6d44f3f2808 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:30:57 +0900 Subject: [PATCH 261/528] =?UTF-8?q?refactor:=20GrammarLevel=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/grammar/enums/GrammarLevel.java | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/enums/GrammarLevel.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/enums/GrammarLevel.java index ac13d931..c11a0fd5 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/enums/GrammarLevel.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/enums/GrammarLevel.java @@ -6,23 +6,23 @@ public enum GrammarLevel { BEGINNER("beginner", "초급", "한국어 번역과 쉬운 설명 포함"), INTERMEDIATE("intermediate", "중급", "영어 위주 설명"), ADVANCED("advanced", "고급", "상세한 문법 규칙 설명"); - + private final String code; private final String displayName; private final String description; - + GrammarLevel(String code, String displayName, String description) { this.code = code; this.displayName = displayName; this.description = description; } - + public static boolean isValid(String value) { if (value == null) return false; return Arrays.stream(values()) .anyMatch(level -> level.name().equalsIgnoreCase(value) || level.code.equalsIgnoreCase(value)); } - + public static GrammarLevel fromString(String value) { if (value == null) { throw new IllegalArgumentException("GrammarLevel value cannot be null"); @@ -32,22 +32,22 @@ public static GrammarLevel fromString(String value) { .findFirst() .orElseThrow(() -> new IllegalArgumentException("Unknown GrammarLevel: " + value)); } - + public static GrammarLevel fromStringOrDefault(String value, GrammarLevel defaultValue) { if (value == null || !isValid(value)) { return defaultValue; } return fromString(value); } - + public String getCode() { return code; } - + public String getDisplayName() { return displayName; } - + public String getDescription() { return description; } From 37d5242364b09302631642a4fb3bda381d3c407d Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:30:57 +0900 Subject: [PATCH 262/528] =?UTF-8?q?refactor:=20GrammarErrorCode=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../grammar/exception/GrammarErrorCode.java | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/exception/GrammarErrorCode.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/exception/GrammarErrorCode.java index 9fa326e4..c9fea728 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/exception/GrammarErrorCode.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/exception/GrammarErrorCode.java @@ -3,50 +3,50 @@ import com.mzc.secondproject.serverless.common.exception.DomainErrorCode; public enum GrammarErrorCode implements DomainErrorCode { - + // 문법 체크 관련 에러 INVALID_SENTENCE("GRAMMAR_001", "유효하지 않은 문장입니다", 400), GRAMMAR_CHECK_FAILED("GRAMMAR_002", "문법 체크에 실패했습니다", 500), - + // 레벨 관련 에러 INVALID_LEVEL("GRAMMAR_003", "유효하지 않은 레벨입니다", 400), - + // Bedrock API 관련 에러 BEDROCK_API_ERROR("GRAMMAR_004", "AI 서비스 호출에 실패했습니다", 502), BEDROCK_RESPONSE_PARSE_ERROR("GRAMMAR_005", "AI 응답 파싱에 실패했습니다", 500), - + // 세션 관련 에러 SESSION_NOT_FOUND("GRAMMAR_006", "세션을 찾을 수 없습니다", 404), SESSION_EXPIRED("GRAMMAR_007", "세션이 만료되었습니다", 410), ; - + private static final String DOMAIN = "GRAMMAR"; - + private final String code; private final String message; private final int statusCode; - + GrammarErrorCode(String code, String message, int statusCode) { this.code = code; this.message = message; this.statusCode = statusCode; } - + @Override public String getDomain() { return DOMAIN; } - + @Override public String getCode() { return code; } - + @Override public String getMessage() { return message; } - + @Override public int getStatusCode() { return statusCode; From debb2b3723b6f1e74fcbde4ed61219d80f7fc4b1 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:30:57 +0900 Subject: [PATCH 263/528] =?UTF-8?q?refactor:=20GrammarException=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../grammar/exception/GrammarException.java | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/exception/GrammarException.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/exception/GrammarException.java index 762e215c..48eed769 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/exception/GrammarException.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/exception/GrammarException.java @@ -3,63 +3,63 @@ import com.mzc.secondproject.serverless.common.exception.ServerlessException; public class GrammarException extends ServerlessException { - + private GrammarException(GrammarErrorCode errorCode) { super(errorCode); } - + private GrammarException(GrammarErrorCode errorCode, String message) { super(errorCode, message); } - + private GrammarException(GrammarErrorCode errorCode, Throwable cause) { super(errorCode, cause); } - + // === 문법 체크 관련 팩토리 메서드 === - + public static GrammarException invalidSentence(String sentence) { return (GrammarException) new GrammarException(GrammarErrorCode.INVALID_SENTENCE, "유효하지 않은 문장입니다. 문장을 확인해주세요.") .addDetail("sentence", sentence); } - + public static GrammarException grammarCheckFailed(String reason) { return (GrammarException) new GrammarException(GrammarErrorCode.GRAMMAR_CHECK_FAILED, String.format("문법 체크에 실패했습니다: %s", reason)) .addDetail("reason", reason); } - + // === 레벨 관련 팩토리 메서드 === - + public static GrammarException invalidLevel(String level) { return (GrammarException) new GrammarException(GrammarErrorCode.INVALID_LEVEL, String.format("유효하지 않은 레벨입니다: '%s'. BEGINNER, INTERMEDIATE, ADVANCED 중 하나여야 합니다.", level)) .addDetail("invalidValue", level) .addDetail("allowedValues", "BEGINNER, INTERMEDIATE, ADVANCED"); } - + // === Bedrock API 관련 팩토리 메서드 === - + public static GrammarException bedrockApiError(Throwable cause) { return (GrammarException) new GrammarException(GrammarErrorCode.BEDROCK_API_ERROR, cause) .addDetail("errorType", cause.getClass().getSimpleName()); } - + public static GrammarException bedrockResponseParseError(String response) { return (GrammarException) new GrammarException(GrammarErrorCode.BEDROCK_RESPONSE_PARSE_ERROR, "AI 응답을 파싱하는데 실패했습니다.") .addDetail("rawResponse", response); } - + // === 세션 관련 팩토리 메서드 === - + public static GrammarException sessionNotFound(String sessionId) { return (GrammarException) new GrammarException(GrammarErrorCode.SESSION_NOT_FOUND, String.format("세션을 찾을 수 없습니다 (sessionId: %s)", sessionId)) .addDetail("sessionId", sessionId); } - + public static GrammarException sessionExpired(String sessionId) { return (GrammarException) new GrammarException(GrammarErrorCode.SESSION_EXPIRED, String.format("세션이 만료되었습니다 (sessionId: %s)", sessionId)) From 8395cc68b1ae4ab3e4ecd9932998fbe0518da87b Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:30:57 +0900 Subject: [PATCH 264/528] =?UTF-8?q?refactor:=20BedrockGrammarCheckFactory?= =?UTF-8?q?=20=EC=BD=94=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../factory/BedrockGrammarCheckFactory.java | 193 +++++++++--------- 1 file changed, 98 insertions(+), 95 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/factory/BedrockGrammarCheckFactory.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/factory/BedrockGrammarCheckFactory.java index f00128ff..d4b85eda 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/factory/BedrockGrammarCheckFactory.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/factory/BedrockGrammarCheckFactory.java @@ -1,6 +1,9 @@ package com.mzc.secondproject.serverless.domain.grammar.factory; -import com.google.gson.*; +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; import com.mzc.secondproject.serverless.common.config.AwsClients; import com.mzc.secondproject.serverless.domain.grammar.dto.response.ConversationResponse; import com.mzc.secondproject.serverless.domain.grammar.dto.response.GrammarCheckResponse; @@ -23,46 +26,46 @@ import java.util.concurrent.ExecutionException; public class BedrockGrammarCheckFactory implements GrammarCheckFactory { - + private static final Logger logger = LoggerFactory.getLogger(BedrockGrammarCheckFactory.class); private static final Gson gson = new Gson(); - + private static final String MODEL_ID = "anthropic.claude-3-haiku-20240307-v1:0"; private static final int MAX_TOKENS = 2048; - + @Override public GrammarCheckResponse checkGrammar(String sentence, GrammarLevel level) { logger.info("Checking grammar: level={}, sentence={}", level.name(), sentence); - + long startTime = System.currentTimeMillis(); - + try { String systemPrompt = buildSystemPrompt(level); String userPrompt = buildUserPrompt(sentence); - + JsonObject requestBody = buildRequestBody(userPrompt, systemPrompt); - + InvokeModelRequest request = InvokeModelRequest.builder() .modelId(MODEL_ID) .contentType("application/json") .accept("application/json") .body(SdkBytes.fromUtf8String(gson.toJson(requestBody))) .build(); - + InvokeModelResponse response = AwsClients.bedrock().invokeModel(request); - + String responseBody = response.body().asUtf8String(); JsonObject jsonResponse = gson.fromJson(responseBody, JsonObject.class); - + String content = jsonResponse.getAsJsonArray("content") .get(0).getAsJsonObject() .get("text").getAsString(); - + long processingTime = System.currentTimeMillis() - startTime; logger.info("Grammar check completed in {}ms", processingTime); - + return parseGrammarResponse(sentence, content); - + } catch (GrammarException e) { throw e; } catch (Exception e) { @@ -70,11 +73,11 @@ public GrammarCheckResponse checkGrammar(String sentence, GrammarLevel level) { throw GrammarException.bedrockApiError(e); } } - + private String buildSystemPrompt(GrammarLevel level) { String basePrompt = """ You are an expert English grammar checker. Analyze the given sentence and provide feedback. - + You MUST respond in the following JSON format only, with no additional text: { "correctedSentence": "the corrected sentence", @@ -92,29 +95,29 @@ private String buildSystemPrompt(GrammarLevel level) { ], "feedback": "overall feedback message" } - + Error types: VERB_TENSE, SUBJECT_VERB_AGREEMENT, ARTICLE, PREPOSITION, WORD_ORDER, PLURAL_SINGULAR, PRONOUN, SPELLING, PUNCTUATION, WORD_CHOICE, SENTENCE_STRUCTURE, OTHER - + Score should be 0-100 based on grammar correctness. If the sentence is correct, set isCorrect to true, errors to empty array, and score to 100. """; - + return switch (level) { case BEGINNER -> basePrompt + """ - + Additional instructions for BEGINNER level: - Provide explanations in simple English - Include Korean translations for key grammar concepts in the explanation - Be encouraging in feedback """; case INTERMEDIATE -> basePrompt + """ - + Additional instructions for INTERMEDIATE level: - Provide clear explanations in English - Focus on common grammar patterns """; case ADVANCED -> basePrompt + """ - + Additional instructions for ADVANCED level: - Provide detailed grammar explanations - Include nuanced usage notes @@ -122,28 +125,28 @@ private String buildSystemPrompt(GrammarLevel level) { """; }; } - + private String buildUserPrompt(String sentence) { return String.format("Please check the grammar of this sentence: \"%s\"", sentence); } - + private JsonObject buildRequestBody(String userPrompt, String systemPrompt) { JsonObject requestBody = new JsonObject(); requestBody.addProperty("anthropic_version", "bedrock-2023-05-31"); requestBody.addProperty("max_tokens", MAX_TOKENS); requestBody.addProperty("system", systemPrompt); - + JsonArray messages = new JsonArray(); JsonObject userMessage = new JsonObject(); userMessage.addProperty("role", "user"); userMessage.addProperty("content", userPrompt); messages.add(userMessage); - + requestBody.add("messages", messages); - + return requestBody; } - + private GrammarCheckResponse parseGrammarResponse(String originalSentence, String aiResponse) { try { String jsonContent = extractJson(aiResponse); @@ -160,7 +163,7 @@ private GrammarCheckResponse parseGrammarResponse(String originalSentence, Strin : aiResponse); } } - + private String extractJson(String response) { int start = response.indexOf('{'); int end = response.lastIndexOf('}'); @@ -169,7 +172,7 @@ private String extractJson(String response) { } return response; } - + private GrammarErrorType parseErrorType(String type) { try { return GrammarErrorType.valueOf(type); @@ -177,7 +180,7 @@ private GrammarErrorType parseErrorType(String type) { return GrammarErrorType.OTHER; } } - + private Integer getIntOrNull(JsonObject obj, String key) { JsonElement element = obj.get(key); if (element == null || element.isJsonNull()) { @@ -185,39 +188,39 @@ private Integer getIntOrNull(JsonObject obj, String key) { } return element.getAsInt(); } - + public ConversationResponse generateConversation(String sessionId, String message, GrammarLevel level, String conversationHistory) { logger.info("Generating conversation: sessionId={}, level={}", sessionId, level.name()); - + long startTime = System.currentTimeMillis(); - + try { String systemPrompt = buildConversationSystemPrompt(level); String userPrompt = buildConversationUserPrompt(message, conversationHistory); - + JsonObject requestBody = buildRequestBody(userPrompt, systemPrompt); - + InvokeModelRequest request = InvokeModelRequest.builder() .modelId(MODEL_ID) .contentType("application/json") .accept("application/json") .body(SdkBytes.fromUtf8String(gson.toJson(requestBody))) .build(); - + InvokeModelResponse response = AwsClients.bedrock().invokeModel(request); - + String responseBody = response.body().asUtf8String(); JsonObject jsonResponse = gson.fromJson(responseBody, JsonObject.class); - + String content = jsonResponse.getAsJsonArray("content") .get(0).getAsJsonObject() .get("text").getAsString(); - + long processingTime = System.currentTimeMillis() - startTime; logger.info("Conversation generated in {}ms", processingTime); - + return parseConversationResponse(sessionId, message, content); - + } catch (GrammarException e) { throw e; } catch (Exception e) { @@ -225,7 +228,7 @@ public ConversationResponse generateConversation(String sessionId, String messag throw GrammarException.bedrockApiError(e); } } - + private String buildConversationSystemPrompt(GrammarLevel level) { String basePrompt = """ You are a friendly English conversation partner who also helps with grammar. @@ -233,7 +236,7 @@ private String buildConversationSystemPrompt(GrammarLevel level) { 1. First, check their grammar and provide corrections if needed 2. Then respond naturally to continue the conversation 3. Provide a helpful learning tip - + You MUST respond in the following JSON format only, with no additional text: { "grammarCheck": { @@ -253,13 +256,13 @@ private String buildConversationSystemPrompt(GrammarLevel level) { "aiResponse": "Your natural conversational response here", "conversationTip": "A helpful tip for the learner" } - + Error types: VERB_TENSE, SUBJECT_VERB_AGREEMENT, ARTICLE, PREPOSITION, WORD_ORDER, PLURAL_SINGULAR, PRONOUN, SPELLING, PUNCTUATION, WORD_CHOICE, SENTENCE_STRUCTURE, OTHER """; - + return switch (level) { case BEGINNER -> basePrompt + """ - + For BEGINNER level: - Use simple vocabulary in your response - Keep sentences short @@ -268,14 +271,14 @@ private String buildConversationSystemPrompt(GrammarLevel level) { - Provide basic grammar tips """; case INTERMEDIATE -> basePrompt + """ - + For INTERMEDIATE level: - Use natural everyday English - Introduce new vocabulary naturally - Provide practical grammar tips """; case ADVANCED -> basePrompt + """ - + For ADVANCED level: - Use sophisticated vocabulary and idioms - Challenge the learner @@ -283,39 +286,39 @@ private String buildConversationSystemPrompt(GrammarLevel level) { """; }; } - + private String buildConversationUserPrompt(String message, String conversationHistory) { StringBuilder prompt = new StringBuilder(); - + if (conversationHistory != null && !conversationHistory.isEmpty()) { prompt.append("Previous conversation:\n"); prompt.append(conversationHistory); prompt.append("\n\n"); } - + prompt.append("User's message: \"").append(message).append("\""); - + return prompt.toString(); } - + private ConversationResponse parseConversationResponse(String sessionId, String originalMessage, String aiResponse) { try { String jsonContent = extractJson(aiResponse); JsonObject json = gson.fromJson(jsonContent, JsonObject.class); - + JsonObject grammarCheckObj = json.getAsJsonObject("grammarCheck"); GrammarCheckResponse grammarCheck = parseGrammarCheckFromJson(originalMessage, grammarCheckObj); - + String conversationResponse = json.get("aiResponse").getAsString(); String tip = json.get("conversationTip").getAsString(); - + return ConversationResponse.builder() .sessionId(sessionId) .grammarCheck(grammarCheck) .aiResponse(conversationResponse) .conversationTip(tip) .build(); - + } catch (Exception e) { logger.error("Failed to parse conversation response: length={}", aiResponse != null ? aiResponse.length() : 0, e); @@ -325,13 +328,13 @@ private ConversationResponse parseConversationResponse(String sessionId, String : aiResponse); } } - + private GrammarCheckResponse parseGrammarCheckFromJson(String originalSentence, JsonObject json) { String correctedSentence = json.get("correctedSentence").getAsString(); int score = json.get("score").getAsInt(); boolean isCorrect = json.get("isCorrect").getAsBoolean(); String feedback = json.get("feedback").getAsString(); - + List errors = new ArrayList<>(); JsonArray errorsArray = json.getAsJsonArray("errors"); if (errorsArray != null) { @@ -348,7 +351,7 @@ private GrammarCheckResponse parseGrammarCheckFromJson(String originalSentence, errors.add(error); } } - + return GrammarCheckResponse.builder() .originalSentence(originalSentence) .correctedSentence(correctedSentence) @@ -358,7 +361,7 @@ private GrammarCheckResponse parseGrammarCheckFromJson(String originalSentence, .isCorrect(isCorrect) .build(); } - + /** * Streaming 방식으로 대화 생성 * 토큰이 생성될 때마다 콜백을 통해 실시간 전송 @@ -369,31 +372,31 @@ public void generateConversationStreaming( GrammarLevel level, String conversationHistory, StreamingCallback callback) { - + logger.info("Generating streaming conversation: sessionId={}, level={}", sessionId, level.name()); - + long startTime = System.currentTimeMillis(); - + try { String systemPrompt = buildStreamingConversationPrompt(level); String userPrompt = buildConversationUserPrompt(message, conversationHistory); - + JsonObject requestBody = buildStreamingRequestBody(userPrompt, systemPrompt); - + InvokeModelWithResponseStreamRequest request = InvokeModelWithResponseStreamRequest.builder() .modelId(MODEL_ID) .body(SdkBytes.fromUtf8String(gson.toJson(requestBody))) .build(); - + StringBuilder fullResponse = new StringBuilder(); - + // Visitor 패턴으로 스트리밍 응답 처리 var visitor = InvokeModelWithResponseStreamResponseHandler.Visitor.builder() .onChunk(chunk -> { try { JsonObject response = gson.fromJson(chunk.bytes().asUtf8String(), JsonObject.class); String type = response.has("type") ? response.get("type").getAsString() : ""; - + if (Objects.equals(type, "content_block_delta")) { JsonObject delta = response.getAsJsonObject("delta"); if (delta != null && delta.has("text")) { @@ -407,7 +410,7 @@ public void generateConversationStreaming( } }) .build(); - + var handler = InvokeModelWithResponseStreamResponseHandler.builder() .subscriber(visitor) .onError(error -> { @@ -417,7 +420,7 @@ public void generateConversationStreaming( .onComplete(() -> { long processingTime = System.currentTimeMillis() - startTime; logger.info("Streaming conversation completed in {}ms", processingTime); - + try { ConversationResponse response = parseStreamingResponse( sessionId, message, fullResponse.toString()); @@ -428,9 +431,9 @@ public void generateConversationStreaming( } }) .build(); - + AwsClients.bedrockAsync().invokeModelWithResponseStream(request, handler).get(); - + } catch (InterruptedException e) { Thread.currentThread().interrupt(); logger.error("Streaming conversation interrupted", e); @@ -443,7 +446,7 @@ public void generateConversationStreaming( callback.onError(e); } } - + private String buildStreamingConversationPrompt(GrammarLevel level) { // Streaming에서는 JSON 형식 대신 자연스러운 텍스트 응답 후 JSON 메타데이터 String basePrompt = """ @@ -451,12 +454,12 @@ private String buildStreamingConversationPrompt(GrammarLevel level) { When the user sends a message: 1. First, respond naturally to continue the conversation 2. Then provide grammar feedback if there are errors - + IMPORTANT: Structure your response EXACTLY like this: [RESPONSE] Your natural conversational response here. Keep it friendly and engaging. [/RESPONSE] - + [GRAMMAR] { "correctedSentence": "the corrected sentence", @@ -475,19 +478,19 @@ private String buildStreamingConversationPrompt(GrammarLevel level) { "feedback": "brief grammar feedback" } [/GRAMMAR] - + [TIP] A helpful learning tip for the user. [/TIP] - + Error types: VERB_TENSE, SUBJECT_VERB_AGREEMENT, ARTICLE, PREPOSITION, WORD_ORDER, PLURAL_SINGULAR, PRONOUN, SPELLING, PUNCTUATION, WORD_CHOICE, SENTENCE_STRUCTURE, OTHER - + If the sentence is grammatically correct, set isCorrect to true and errors to empty array. """; - + return switch (level) { case BEGINNER -> basePrompt + """ - + For BEGINNER level: - Use simple vocabulary in your response - Keep sentences short @@ -496,14 +499,14 @@ private String buildStreamingConversationPrompt(GrammarLevel level) { - Provide basic grammar tips """; case INTERMEDIATE -> basePrompt + """ - + For INTERMEDIATE level: - Use natural everyday English - Introduce new vocabulary naturally - Provide practical grammar tips """; case ADVANCED -> basePrompt + """ - + For ADVANCED level: - Use sophisticated vocabulary and idioms - Challenge the learner @@ -511,25 +514,25 @@ private String buildStreamingConversationPrompt(GrammarLevel level) { """; }; } - + private JsonObject buildStreamingRequestBody(String userPrompt, String systemPrompt) { JsonObject requestBody = new JsonObject(); requestBody.addProperty("anthropic_version", "bedrock-2023-05-31"); requestBody.addProperty("max_tokens", MAX_TOKENS); requestBody.addProperty("system", systemPrompt); // Streaming을 위해 stop_sequences 추가하지 않음 - + JsonArray messages = new JsonArray(); JsonObject userMessage = new JsonObject(); userMessage.addProperty("role", "user"); userMessage.addProperty("content", userPrompt); messages.add(userMessage); - + requestBody.add("messages", messages); - + return requestBody; } - + /** * Streaming 응답 파싱 (섹션 기반) */ @@ -538,7 +541,7 @@ public ConversationResponse parseStreamingResponse(String sessionId, String orig String aiResponse = extractSection(fullResponse, "RESPONSE"); String grammarJson = extractSection(fullResponse, "GRAMMAR"); String tip = extractSection(fullResponse, "TIP"); - + GrammarCheckResponse grammarCheck; if (grammarJson != null && !grammarJson.isEmpty()) { JsonObject json = gson.fromJson(grammarJson, JsonObject.class); @@ -554,14 +557,14 @@ public ConversationResponse parseStreamingResponse(String sessionId, String orig .feedback("Perfect!") .build(); } - + return ConversationResponse.builder() .sessionId(sessionId) .grammarCheck(grammarCheck) .aiResponse(aiResponse != null ? aiResponse.trim() : "") .conversationTip(tip != null ? tip.trim() : "") .build(); - + } catch (Exception e) { logger.error("Failed to parse streaming response: length={}", fullResponse != null ? fullResponse.length() : 0, e); @@ -571,14 +574,14 @@ public ConversationResponse parseStreamingResponse(String sessionId, String orig : fullResponse); } } - + private String extractSection(String text, String sectionName) { String startTag = "[" + sectionName + "]"; String endTag = "[/" + sectionName + "]"; - + int startIndex = text.indexOf(startTag); int endIndex = text.indexOf(endTag); - + if (startIndex != -1 && endIndex != -1 && endIndex > startIndex) { return text.substring(startIndex + startTag.length(), endIndex).trim(); } From e1c80820baaefbbb2ab919d34bed727900f6004e Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:30:58 +0900 Subject: [PATCH 265/528] =?UTF-8?q?refactor:=20GrammarHandler=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../grammar/handler/GrammarHandler.java | 53 +++++++++---------- 1 file changed, 26 insertions(+), 27 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/GrammarHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/GrammarHandler.java index e6467272..be726d5b 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/GrammarHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/GrammarHandler.java @@ -12,7 +12,6 @@ import com.mzc.secondproject.serverless.domain.grammar.dto.request.GrammarCheckRequest; import com.mzc.secondproject.serverless.domain.grammar.dto.response.ConversationResponse; import com.mzc.secondproject.serverless.domain.grammar.dto.response.GrammarCheckResponse; -import com.mzc.secondproject.serverless.domain.grammar.model.GrammarSession; import com.mzc.secondproject.serverless.domain.grammar.service.GrammarCheckService; import com.mzc.secondproject.serverless.domain.grammar.service.GrammarConversationService; import com.mzc.secondproject.serverless.domain.grammar.service.GrammarSessionQueryService; @@ -23,21 +22,21 @@ import java.util.Map; public class GrammarHandler implements RequestHandler { - + private static final Logger logger = LoggerFactory.getLogger(GrammarHandler.class); - + private final GrammarCheckService grammarCheckService; private final GrammarConversationService conversationService; private final GrammarSessionQueryService sessionQueryService; private final HandlerRouter router; - + public GrammarHandler() { this.grammarCheckService = new GrammarCheckService(); this.conversationService = new GrammarConversationService(); this.sessionQueryService = new GrammarSessionQueryService(); this.router = initRouter(); } - + private HandlerRouter initRouter() { return new HandlerRouter().addRoutes( Route.postAuth("/grammar/check", this::checkGrammar), @@ -47,19 +46,19 @@ private HandlerRouter initRouter() { Route.deleteAuth("/grammar/sessions/{sessionId}", this::deleteSession) ); } - + @Override public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { logger.info("Received request: {} {}", request.getHttpMethod(), request.getPath()); return router.route(request); } - + private APIGatewayProxyResponseEvent checkGrammar(APIGatewayProxyRequestEvent request, String userId) { GrammarCheckRequest req = ResponseGenerator.gson().fromJson(request.getBody(), GrammarCheckRequest.class); - + return BeanValidator.validateAndExecute(req, dto -> { GrammarCheckResponse result = grammarCheckService.checkGrammar(dto); - + Map response = new HashMap<>(); response.put("originalSentence", result.getOriginalSentence()); response.put("correctedSentence", result.getCorrectedSentence()); @@ -67,70 +66,70 @@ private APIGatewayProxyResponseEvent checkGrammar(APIGatewayProxyRequestEvent re response.put("isCorrect", result.getIsCorrect()); response.put("errors", result.getErrors()); response.put("feedback", result.getFeedback()); - + return ResponseGenerator.ok("Grammar checked successfully", response); }); } - + private APIGatewayProxyResponseEvent conversation(APIGatewayProxyRequestEvent request, String userId) { ConversationRequest req = ResponseGenerator.gson().fromJson(request.getBody(), ConversationRequest.class); req.setUserId(userId); // JWT에서 추출한 userId 설정 - + return BeanValidator.validateAndExecute(req, dto -> { ConversationResponse result = conversationService.chat(dto); - + Map response = new HashMap<>(); response.put("sessionId", result.getSessionId()); response.put("grammarCheck", result.getGrammarCheck()); response.put("aiResponse", result.getAiResponse()); response.put("conversationTip", result.getConversationTip()); - + return ResponseGenerator.ok("Conversation generated successfully", response); }); } - + private APIGatewayProxyResponseEvent getSessions(APIGatewayProxyRequestEvent request, String userId) { Map queryParams = request.getQueryStringParameters(); String cursor = queryParams != null ? queryParams.get("cursor") : null; - + int limit = 10; if (queryParams != null && queryParams.get("limit") != null) { limit = Math.min(Integer.parseInt(queryParams.get("limit")), 50); } - + var result = sessionQueryService.getSessions(userId, limit, cursor); - + Map response = new HashMap<>(); response.put("sessions", result.items()); response.put("nextCursor", result.nextCursor()); response.put("hasMore", result.hasMore()); - + return ResponseGenerator.ok("Sessions retrieved successfully", response); } - + private APIGatewayProxyResponseEvent getSessionDetail(APIGatewayProxyRequestEvent request, String userId) { String sessionId = request.getPathParameters().get("sessionId"); - + Map queryParams = request.getQueryStringParameters(); int messageLimit = 50; if (queryParams != null && queryParams.get("messageLimit") != null) { messageLimit = Math.min(Integer.parseInt(queryParams.get("messageLimit")), 100); } - + var detail = sessionQueryService.getSessionDetail(userId, sessionId, messageLimit); - + Map response = new HashMap<>(); response.put("session", detail.session()); response.put("messages", detail.messages()); - + return ResponseGenerator.ok("Session detail retrieved successfully", response); } - + private APIGatewayProxyResponseEvent deleteSession(APIGatewayProxyRequestEvent request, String userId) { String sessionId = request.getPathParameters().get("sessionId"); - + sessionQueryService.deleteSession(userId, sessionId); - + return ResponseGenerator.ok("Session deleted successfully", null); } } From 302e93a3ac82f974b2df8c0035cf3eee1bcdfcc3 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:30:58 +0900 Subject: [PATCH 266/528] =?UTF-8?q?refactor:=20GrammarStreamingConnectHand?= =?UTF-8?q?ler=20=EC=BD=94=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../GrammarStreamingConnectHandler.java | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/websocket/GrammarStreamingConnectHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/websocket/GrammarStreamingConnectHandler.java index c3fe8564..5a6e8202 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/websocket/GrammarStreamingConnectHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/websocket/GrammarStreamingConnectHandler.java @@ -16,63 +16,63 @@ /** * Grammar Streaming WebSocket $connect 핸들러 * JWT 토큰 검증 후 연결 정보를 DynamoDB에 저장 - * + *

* 연결 방법: * wss://{api-id}.execute-api.{region}.amazonaws.com/{stage}?token={jwt} */ public class GrammarStreamingConnectHandler implements RequestHandler, Map> { - + private static final Logger logger = LoggerFactory.getLogger(GrammarStreamingConnectHandler.class); - + private final GrammarConnectionRepository connectionRepository; - + public GrammarStreamingConnectHandler() { this.connectionRepository = new GrammarConnectionRepository(); } - + @Override public Map handleRequest(Map event, Context context) { logger.info("Grammar WebSocket connect event"); - + try { String connectionId = WebSocketEventUtil.extractConnectionId(event); Map queryParams = WebSocketEventUtil.extractQueryStringParameters(event); - + // JWT 토큰 검증 String token = queryParams.get("token"); - + if (token == null || token.isEmpty()) { logger.warn("Missing token parameter"); return WebSocketEventUtil.unauthorized("token is required"); } - + // 토큰 유효성 검사 if (!JwtUtil.isValid(token)) { logger.warn("Invalid or expired token"); return WebSocketEventUtil.unauthorized("Invalid or expired token"); } - + // userId 추출 Optional userIdOpt = JwtUtil.extractUserId(token); if (userIdOpt.isEmpty()) { logger.warn("Failed to extract userId from token"); return WebSocketEventUtil.unauthorized("Invalid token"); } - + String userId = userIdOpt.get(); - + // 연결 정보 저장 GrammarConnection connection = GrammarConnection.create( connectionId, userId, WebSocketConfig.connectionTtlSeconds() ); - + connectionRepository.save(connection); - + logger.info("Grammar connection established: connectionId={}, userId={}", connectionId, userId); return WebSocketEventUtil.ok("Connected"); - + } catch (Exception e) { logger.error("Error handling connect: {}", e.getMessage(), e); return WebSocketEventUtil.serverError("Internal server error"); From b0efa93a8782a24f2dd51a6f336b1bdf1d8bba6d Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:31:05 +0900 Subject: [PATCH 267/528] =?UTF-8?q?refactor:=20GrammarStreamingDisconnectH?= =?UTF-8?q?andler=20=EC=BD=94=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../GrammarStreamingDisconnectHandler.java | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/websocket/GrammarStreamingDisconnectHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/websocket/GrammarStreamingDisconnectHandler.java index f36badc1..f05fbd3a 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/websocket/GrammarStreamingDisconnectHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/websocket/GrammarStreamingDisconnectHandler.java @@ -14,28 +14,28 @@ * 연결 해제 시 DynamoDB에서 연결 정보 삭제 */ public class GrammarStreamingDisconnectHandler implements RequestHandler, Map> { - + private static final Logger logger = LoggerFactory.getLogger(GrammarStreamingDisconnectHandler.class); - + private final GrammarConnectionRepository connectionRepository; - + public GrammarStreamingDisconnectHandler() { this.connectionRepository = new GrammarConnectionRepository(); } - + @Override public Map handleRequest(Map event, Context context) { logger.info("Grammar WebSocket disconnect event"); - + try { String connectionId = WebSocketEventUtil.extractConnectionId(event); - + // 연결 정보 삭제 connectionRepository.delete(connectionId); - + logger.info("Grammar connection closed: connectionId={}", connectionId); return WebSocketEventUtil.ok("Disconnected"); - + } catch (Exception e) { logger.error("Error handling disconnect: {}", e.getMessage(), e); return WebSocketEventUtil.serverError("Internal server error"); From e1a77a16af2d9925a2be438c77065572f74808b3 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:31:05 +0900 Subject: [PATCH 268/528] =?UTF-8?q?refactor:=20GrammarStreamingHandler=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../websocket/GrammarStreamingHandler.java | 52 +++++++++---------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/websocket/GrammarStreamingHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/websocket/GrammarStreamingHandler.java index c9c2c8d4..41f1a4d4 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/websocket/GrammarStreamingHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/websocket/GrammarStreamingHandler.java @@ -28,64 +28,64 @@ /** * Grammar Streaming WebSocket 핸들러 * Bedrock 스트리밍 응답을 실시간으로 클라이언트에 전송 - * + *

* 인증: $connect에서 JWT 검증 후 저장된 연결 정보에서 userId 조회 */ public class GrammarStreamingHandler implements RequestHandler, Map> { - + private static final Logger logger = LoggerFactory.getLogger(GrammarStreamingHandler.class); private static final Gson gson = new GsonBuilder().create(); - + private final GrammarConversationService conversationService; private final GrammarConnectionRepository connectionRepository; - + public GrammarStreamingHandler() { this.conversationService = new GrammarConversationService(); this.connectionRepository = new GrammarConnectionRepository(); } - + @Override public Map handleRequest(Map event, Context context) { logger.info("Grammar streaming event received"); - + try { String connectionId = WebSocketEventUtil.extractConnectionId(event); String endpoint = WebSocketEventUtil.extractWebSocketEndpoint(event); - + // 연결 정보에서 userId 조회 (JWT 인증된 사용자) Optional connectionOpt = connectionRepository.findByConnectionId(connectionId); if (connectionOpt.isEmpty()) { logger.warn("Connection not found: {}", connectionId); return sendError(connectionId, endpoint, "Unauthorized - please reconnect"); } - + String userId = connectionOpt.get().getUserId(); - + String body = (String) event.get("body"); if (body == null || body.isEmpty()) { return sendError(connectionId, endpoint, "Message body is required"); } - + StreamingRequest request = gson.fromJson(body, StreamingRequest.class); - + if (request.message() == null || request.message().trim().isEmpty()) { return sendError(connectionId, endpoint, "message is required"); } - + // 스트리밍 대화 처리 (userId는 연결 정보에서 가져옴) processStreamingConversation(connectionId, endpoint, userId, request); - + return WebSocketEventUtil.ok("Streaming started"); - + } catch (Exception e) { logger.error("Error handling streaming request: {}", e.getMessage(), e); return WebSocketEventUtil.serverError("Internal server error"); } } - + private void processStreamingConversation(String connectionId, String endpoint, String userId, StreamingRequest request) { ApiGatewayManagementApiClient apiClient = createApiClient(endpoint); - + // 서비스에 스트리밍 처리 위임 (userId는 JWT 인증에서 가져온 값 사용) conversationService.chatStreaming( request.sessionId(), @@ -100,13 +100,13 @@ private void processStreamingConversation(String connectionId, String endpoint, public void onToken(String token) { sendEvent(apiClient, connectionId, new StreamingEvent.TokenEvent(token)); } - + @Override public void onComplete(ConversationResponse response) { sendEvent(apiClient, connectionId, StreamingEvent.CompleteEvent.from(response)); logger.info("Streaming completed for session: {}", response.getSessionId()); } - + @Override public void onError(Throwable error) { logger.error("Streaming error: {}", error.getMessage(), error); @@ -115,7 +115,7 @@ public void onError(Throwable error) { } ); } - + private void sendEvent(ApiGatewayManagementApiClient apiClient, String connectionId, StreamingEvent event) { String json = switch (event) { case StreamingEvent.StartEvent e -> gson.toJson(Map.of("type", e.type(), "sessionId", e.sessionId())); @@ -131,36 +131,36 @@ private void sendEvent(ApiGatewayManagementApiClient apiClient, String connectio } case StreamingEvent.ErrorEvent e -> gson.toJson(Map.of("type", e.type(), "message", e.message())); }; - + sendToConnection(apiClient, connectionId, json); } - + private ApiGatewayManagementApiClient createApiClient(String endpoint) { return ApiGatewayManagementApiClient.builder() .endpointOverride(URI.create(endpoint)) .build(); } - + private boolean sendToConnection(ApiGatewayManagementApiClient apiClient, String connectionId, String message) { try { PostToConnectionRequest request = PostToConnectionRequest.builder() .connectionId(connectionId) .data(SdkBytes.fromString(message, StandardCharsets.UTF_8)) .build(); - + apiClient.postToConnection(request); return true; - + } catch (GoneException e) { logger.warn("Connection gone: {}", connectionId); return false; - + } catch (Exception e) { logger.error("Failed to send message to connection {}: {}", connectionId, e.getMessage()); return false; } } - + private Map sendError(String connectionId, String endpoint, String message) { ApiGatewayManagementApiClient apiClient = createApiClient(endpoint); sendEvent(apiClient, connectionId, new StreamingEvent.ErrorEvent(message)); From aa1fb2cb083a147ad4a9671ebb479941465134fc Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:31:05 +0900 Subject: [PATCH 269/528] =?UTF-8?q?refactor:=20GrammarConnection=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../grammar/model/GrammarConnection.java | 52 +++++++++---------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/model/GrammarConnection.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/model/GrammarConnection.java index 96b2f8a0..8924edf1 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/model/GrammarConnection.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/model/GrammarConnection.java @@ -16,63 +16,63 @@ @AllArgsConstructor @DynamoDbBean public class GrammarConnection { - + // DynamoDB Key Prefixes public static final String PK_PREFIX = "GRAMMAR_CONN#"; public static final String SK_METADATA = "METADATA"; public static final String GSI1PK_PREFIX = "GRAMMAR_USER#"; public static final String GSI1SK_PREFIX = "CONN#"; - + private String pk; // GRAMMAR_CONN#{connectionId} private String sk; // METADATA private String gsi1pk; // GRAMMAR_USER#{userId} private String gsi1sk; // CONN#{connectionId} - + private String connectionId; private String userId; private String connectedAt; private Long ttl; // 자동 삭제용 - + + /** + * 연결 정보 생성 팩토리 메서드 + */ + public static GrammarConnection create(String connectionId, String userId, long ttlSeconds) { + String now = java.time.Instant.now().toString(); + long ttl = java.time.Instant.now().plusSeconds(ttlSeconds).getEpochSecond(); + + return GrammarConnection.builder() + .pk(PK_PREFIX + connectionId) + .sk(SK_METADATA) + .gsi1pk(GSI1PK_PREFIX + userId) + .gsi1sk(GSI1SK_PREFIX + connectionId) + .connectionId(connectionId) + .userId(userId) + .connectedAt(now) + .ttl(ttl) + .build(); + } + @DynamoDbPartitionKey @DynamoDbAttribute("PK") public String getPk() { return pk; } - + @DynamoDbSortKey @DynamoDbAttribute("SK") public String getSk() { return sk; } - + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") @DynamoDbAttribute("GSI1PK") public String getGsi1pk() { return gsi1pk; } - + @DynamoDbSecondarySortKey(indexNames = "GSI1") @DynamoDbAttribute("GSI1SK") public String getGsi1sk() { return gsi1sk; } - - /** - * 연결 정보 생성 팩토리 메서드 - */ - public static GrammarConnection create(String connectionId, String userId, long ttlSeconds) { - String now = java.time.Instant.now().toString(); - long ttl = java.time.Instant.now().plusSeconds(ttlSeconds).getEpochSecond(); - - return GrammarConnection.builder() - .pk(PK_PREFIX + connectionId) - .sk(SK_METADATA) - .gsi1pk(GSI1PK_PREFIX + userId) - .gsi1sk(GSI1SK_PREFIX + connectionId) - .connectionId(connectionId) - .userId(userId) - .connectedAt(now) - .ttl(ttl) - .build(); - } } From 9f7103582c567784173c2a2d6ea527017759293e Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:31:05 +0900 Subject: [PATCH 270/528] =?UTF-8?q?refactor:=20GrammarMessage=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/grammar/model/GrammarMessage.java | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/model/GrammarMessage.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/model/GrammarMessage.java index 15d07812..85062e2b 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/model/GrammarMessage.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/model/GrammarMessage.java @@ -1,6 +1,9 @@ package com.mzc.secondproject.serverless.domain.grammar.model; -import lombok.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.*; @Data @@ -9,12 +12,12 @@ @AllArgsConstructor @DynamoDbBean public class GrammarMessage { - + private String pk; // GSESSION#{userId} private String sk; // MSG#{timestamp}#{messageId} private String gsi1pk; // GSESSION#{sessionId} private String gsi1sk; // MSG#{timestamp} - + private String messageId; private String sessionId; private String userId; @@ -27,25 +30,25 @@ public class GrammarMessage { private Boolean isCorrect; private String createdAt; private Long ttl; - + @DynamoDbPartitionKey @DynamoDbAttribute("PK") public String getPk() { return pk; } - + @DynamoDbSortKey @DynamoDbAttribute("SK") public String getSk() { return sk; } - + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") @DynamoDbAttribute("GSI1PK") public String getGsi1pk() { return gsi1pk; } - + @DynamoDbSecondarySortKey(indexNames = "GSI1") @DynamoDbAttribute("GSI1SK") public String getGsi1sk() { From f4633ec9be6eafd37b1d0e474c5668b0774bd054 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:31:05 +0900 Subject: [PATCH 271/528] =?UTF-8?q?refactor:=20GrammarSession=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/grammar/model/GrammarSession.java | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/model/GrammarSession.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/model/GrammarSession.java index 1dd8ba5c..2c89d9f4 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/model/GrammarSession.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/model/GrammarSession.java @@ -1,6 +1,9 @@ package com.mzc.secondproject.serverless.domain.grammar.model; -import lombok.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.*; @Data @@ -9,12 +12,12 @@ @AllArgsConstructor @DynamoDbBean public class GrammarSession { - + private String pk; // GSESSION#{userId} private String sk; // SESSION#{sessionId} private String gsi1pk; // GSESSION#ALL private String gsi1sk; // UPDATED#{timestamp} - + private String sessionId; private String userId; private String level; @@ -24,25 +27,25 @@ public class GrammarSession { private String createdAt; private String updatedAt; private Long ttl; - + @DynamoDbPartitionKey @DynamoDbAttribute("PK") public String getPk() { return pk; } - + @DynamoDbSortKey @DynamoDbAttribute("SK") public String getSk() { return sk; } - + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") @DynamoDbAttribute("GSI1PK") public String getGsi1pk() { return gsi1pk; } - + @DynamoDbSecondarySortKey(indexNames = "GSI1") @DynamoDbAttribute("GSI1SK") public String getGsi1sk() { From b1a214550cf7acddd367915fbf3e587bd9de8f07 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:31:06 +0900 Subject: [PATCH 272/528] =?UTF-8?q?refactor:=20GrammarConnectionRepository?= =?UTF-8?q?=20=EC=BD=94=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/GrammarConnectionRepository.java | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/repository/GrammarConnectionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/repository/GrammarConnectionRepository.java index 5a0e3dff..0d70b423 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/repository/GrammarConnectionRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/repository/GrammarConnectionRepository.java @@ -14,19 +14,19 @@ * Grammar WebSocket 연결 정보 Repository */ public class GrammarConnectionRepository { - + private static final Logger logger = LoggerFactory.getLogger(GrammarConnectionRepository.class); private static final String TABLE_NAME = System.getenv("CHAT_TABLE_NAME"); - + private final DynamoDbTable table; - + public GrammarConnectionRepository() { this.table = AwsClients.dynamoDbEnhanced().table( TABLE_NAME, TableSchema.fromBean(GrammarConnection.class) ); } - + /** * 연결 정보 저장 */ @@ -35,7 +35,7 @@ public void save(GrammarConnection connection) { logger.info("Connection saved: connectionId={}, userId={}", connection.getConnectionId(), connection.getUserId()); } - + /** * connectionId로 연결 정보 조회 */ @@ -44,11 +44,11 @@ public Optional findByConnectionId(String connectionId) { .partitionValue(GrammarConnection.PK_PREFIX + connectionId) .sortValue(GrammarConnection.SK_METADATA) .build(); - + GrammarConnection connection = table.getItem(key); return Optional.ofNullable(connection); } - + /** * 연결 정보 삭제 */ @@ -57,7 +57,7 @@ public void delete(String connectionId) { .partitionValue(GrammarConnection.PK_PREFIX + connectionId) .sortValue(GrammarConnection.SK_METADATA) .build(); - + table.deleteItem(key); logger.info("Connection deleted: connectionId={}", connectionId); } From ea4f81dd3fb4cdc0f36378ca6a0c3fb524568f1f Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:31:13 +0900 Subject: [PATCH 273/528] =?UTF-8?q?refactor:=20GrammarSessionRepository=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/GrammarSessionRepository.java | 44 +++++++++---------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/repository/GrammarSessionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/repository/GrammarSessionRepository.java index 52f6b65c..34fb090c 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/repository/GrammarSessionRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/repository/GrammarSessionRepository.java @@ -21,26 +21,26 @@ import java.util.Optional; public class GrammarSessionRepository { - + private static final Logger logger = LoggerFactory.getLogger(GrammarSessionRepository.class); private static final String TABLE_NAME = System.getenv("CHAT_TABLE_NAME"); - + private final DynamoDbTable sessionTable; private final DynamoDbTable messageTable; - + public GrammarSessionRepository() { this.sessionTable = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(GrammarSession.class)); this.messageTable = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(GrammarMessage.class)); } - + // ============ Session CRUD ============ - + public GrammarSession saveSession(GrammarSession session) { logger.info("Saving session: sessionId={}", session.getSessionId()); sessionTable.putItem(session); return session; } - + public Optional findSessionById(String userId, String sessionId) { Key key = Key.builder() .partitionValue(GrammarKey.sessionPk(userId)) @@ -48,32 +48,32 @@ public Optional findSessionById(String userId, String sessionId) .build(); return Optional.ofNullable(sessionTable.getItem(key)); } - + public PaginatedResult findSessionsByUserId(String userId, int limit, String cursor) { QueryConditional queryConditional = QueryConditional .sortBeginsWith(Key.builder() .partitionValue(GrammarKey.sessionPk(userId)) .sortValue(GrammarKey.SESSION_SK_PREFIX) .build()); - + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() .queryConditional(queryConditional) .scanIndexForward(false) .limit(limit); - + if (cursor != null && !cursor.isEmpty()) { Map exclusiveStartKey = CursorUtil.decode(cursor); if (exclusiveStartKey != null) { requestBuilder.exclusiveStartKey(exclusiveStartKey); } } - + Page page = sessionTable.query(requestBuilder.build()).iterator().next(); String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); - + return new PaginatedResult<>(page.items(), nextCursor); } - + public void deleteSession(String userId, String sessionId) { Key key = Key.builder() .partitionValue(GrammarKey.sessionPk(userId)) @@ -82,56 +82,56 @@ public void deleteSession(String userId, String sessionId) { sessionTable.deleteItem(key); logger.info("Session deleted: sessionId={}", sessionId); } - + // ============ Message CRUD ============ - + public GrammarMessage saveMessage(GrammarMessage message) { logger.info("Saving message: messageId={}", message.getMessageId()); messageTable.putItem(message); return message; } - + public PaginatedResult findMessagesBySessionId(String sessionId, int limit, String cursor) { QueryConditional queryConditional = QueryConditional .sortBeginsWith(Key.builder() .partitionValue(GrammarKey.messageGsi1Pk(sessionId)) .sortValue(GrammarKey.MSG_PREFIX) .build()); - + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() .queryConditional(queryConditional) .scanIndexForward(false) .limit(limit); - + if (cursor != null && !cursor.isEmpty()) { Map exclusiveStartKey = CursorUtil.decode(cursor); if (exclusiveStartKey != null) { requestBuilder.exclusiveStartKey(exclusiveStartKey); } } - + Page page = messageTable.index("GSI1") .query(requestBuilder.build()) .iterator() .next(); - + String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); return new PaginatedResult<>(page.items(), nextCursor); } - + public List findRecentMessagesBySessionId(String sessionId, int limit) { QueryConditional queryConditional = QueryConditional .sortBeginsWith(Key.builder() .partitionValue(GrammarKey.messageGsi1Pk(sessionId)) .sortValue(GrammarKey.MSG_PREFIX) .build()); - + QueryEnhancedRequest request = QueryEnhancedRequest.builder() .queryConditional(queryConditional) .scanIndexForward(false) .limit(limit) .build(); - + return messageTable.index("GSI1") .query(request) .iterator() From cdad583c13e65b0927462dd9efc35fcc8d655fc4 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:31:13 +0900 Subject: [PATCH 274/528] =?UTF-8?q?refactor:=20GrammarCheckService=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../grammar/service/GrammarCheckService.java | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/service/GrammarCheckService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/service/GrammarCheckService.java index 6bf747f1..5758432d 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/service/GrammarCheckService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/service/GrammarCheckService.java @@ -12,31 +12,31 @@ import org.slf4j.LoggerFactory; public class GrammarCheckService { - + private static final Logger logger = LoggerFactory.getLogger(GrammarCheckService.class); - + private final GrammarCheckFactory grammarCheckFactory; private final ComprehendService comprehendService; - + public GrammarCheckService() { this.grammarCheckFactory = new BedrockGrammarCheckFactory(); this.comprehendService = new ComprehendService(); } - + public GrammarCheckService(GrammarCheckFactory grammarCheckFactory, ComprehendService comprehendService) { this.grammarCheckFactory = grammarCheckFactory; this.comprehendService = comprehendService; } - + public GrammarCheckResponse checkGrammar(GrammarCheckRequest request) { logger.info("Grammar check requested: sentence={}", request.getSentence()); - + validateRequest(request); - + GrammarLevel level = parseLevel(request.getLevel()); - + GrammarCheckResponse response = grammarCheckFactory.checkGrammar(request.getSentence(), level); - + try { ComprehendAnalysis analysis = comprehendService.analyze(request.getSentence()); response.setAnalysis(analysis); @@ -45,25 +45,25 @@ public GrammarCheckResponse checkGrammar(GrammarCheckRequest request) { } catch (Exception e) { logger.warn("Comprehend analysis failed, continuing without analysis: {}", e.getMessage()); } - + return response; } - + private void validateRequest(GrammarCheckRequest request) { if (request.getSentence() == null || request.getSentence().trim().isEmpty()) { throw GrammarException.invalidSentence(request.getSentence()); } } - + private GrammarLevel parseLevel(String levelStr) { if (levelStr == null || levelStr.isEmpty()) { return GrammarLevel.BEGINNER; } - + if (!GrammarLevel.isValid(levelStr)) { throw GrammarException.invalidLevel(levelStr); } - + return GrammarLevel.fromString(levelStr); } } From e7a82d93e1d54f531c5e2302b63cbc500a8f0847 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:31:13 +0900 Subject: [PATCH 275/528] =?UTF-8?q?refactor:=20GrammarConversationService?= =?UTF-8?q?=20=EC=BD=94=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/GrammarConversationService.java | 86 +++++++++---------- 1 file changed, 43 insertions(+), 43 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/service/GrammarConversationService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/service/GrammarConversationService.java index 8f779620..0aebdc12 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/service/GrammarConversationService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/service/GrammarConversationService.java @@ -22,42 +22,42 @@ import java.util.function.Consumer; public class GrammarConversationService { - + private static final Logger logger = LoggerFactory.getLogger(GrammarConversationService.class); private static final int SESSION_TTL_DAYS = 30; private static final int MAX_HISTORY_MESSAGES = 10; private static final int LAST_MESSAGE_MAX_LENGTH = 100; - + private final BedrockGrammarCheckFactory grammarFactory; private final GrammarSessionRepository repository; private final Gson gson; - + public GrammarConversationService() { this.grammarFactory = new BedrockGrammarCheckFactory(); this.repository = new GrammarSessionRepository(); this.gson = new Gson(); } - + public GrammarConversationService(BedrockGrammarCheckFactory grammarFactory, GrammarSessionRepository repository) { this.grammarFactory = grammarFactory; this.repository = repository; this.gson = new Gson(); } - + public ConversationResponse chat(ConversationRequest request) { logger.info("Conversation chat requested: sessionId={}, userId={}", request.getSessionId(), request.getUserId()); - + validateRequest(request); - + String userId = request.getUserId(); GrammarLevel level = parseLevel(request.getLevel()); - + // 세션 가져오기 또는 새로 생성 GrammarSession session = getOrCreateSession(request.getSessionId(), userId, level); - + // 이전 대화 히스토리 조회 String conversationHistory = buildConversationHistory(session.getSessionId()); - + // AI 응답 생성 ConversationResponse response = grammarFactory.generateConversation( session.getSessionId(), @@ -65,19 +65,19 @@ public ConversationResponse chat(ConversationRequest request) { level, conversationHistory ); - + // 사용자 메시지 저장 saveUserMessage(session, request.getMessage(), response.getGrammarCheck()); - + // AI 응답 메시지 저장 saveAssistantMessage(session, response.getAiResponse()); - + // 세션 업데이트 updateSession(session, request.getMessage()); - + return response; } - + private void validateRequest(ConversationRequest request) { if (request.getMessage() == null || request.getMessage().trim().isEmpty()) { throw GrammarException.invalidSentence(request.getMessage()); @@ -86,7 +86,7 @@ private void validateRequest(ConversationRequest request) { throw new IllegalArgumentException("userId is required"); } } - + private GrammarSession getOrCreateSession(String sessionId, String userId, GrammarLevel level) { if (sessionId != null && !sessionId.trim().isEmpty()) { return repository.findSessionById(userId, sessionId) @@ -94,11 +94,11 @@ private GrammarSession getOrCreateSession(String sessionId, String userId, Gramm } return createNewSession(UUID.randomUUID().toString(), userId, level); } - + private GrammarSession createNewSession(String sessionId, String userId, GrammarLevel level) { String now = Instant.now().toString(); long ttl = Instant.now().plus(SESSION_TTL_DAYS, ChronoUnit.DAYS).getEpochSecond(); - + GrammarSession session = GrammarSession.builder() .pk(GrammarKey.sessionPk(userId)) .sk(GrammarKey.sessionSk(sessionId)) @@ -113,20 +113,20 @@ private GrammarSession createNewSession(String sessionId, String userId, Grammar .updatedAt(now) .ttl(ttl) .build(); - + repository.saveSession(session); logger.info("New session created: sessionId={}", sessionId); return session; } - + private String buildConversationHistory(String sessionId) { try { List messages = repository.findRecentMessagesBySessionId(sessionId, MAX_HISTORY_MESSAGES); - + if (messages.isEmpty()) { return ""; } - + StringBuilder history = new StringBuilder(); // 역순으로 정렬 (오래된 것 먼저) for (int i = messages.size() - 1; i >= 0; i--) { @@ -143,12 +143,12 @@ private String buildConversationHistory(String sessionId) { return ""; } } - + private void saveUserMessage(GrammarSession session, String content, GrammarCheckResponse grammarCheck) { String now = Instant.now().toString(); String messageId = UUID.randomUUID().toString(); long ttl = Instant.now().plus(SESSION_TTL_DAYS, ChronoUnit.DAYS).getEpochSecond(); - + GrammarMessage message = GrammarMessage.builder() .pk(GrammarKey.sessionPk(session.getUserId())) .sk(GrammarKey.messageSk(now, messageId)) @@ -167,15 +167,15 @@ private void saveUserMessage(GrammarSession session, String content, GrammarChec .createdAt(now) .ttl(ttl) .build(); - + repository.saveMessage(message); } - + private void saveAssistantMessage(GrammarSession session, String content) { String now = Instant.now().toString(); String messageId = UUID.randomUUID().toString(); long ttl = Instant.now().plus(SESSION_TTL_DAYS, ChronoUnit.DAYS).getEpochSecond(); - + GrammarMessage message = GrammarMessage.builder() .pk(GrammarKey.sessionPk(session.getUserId())) .sk(GrammarKey.messageSk(now, messageId)) @@ -189,43 +189,43 @@ private void saveAssistantMessage(GrammarSession session, String content) { .createdAt(now) .ttl(ttl) .build(); - + repository.saveMessage(message); } - + private void updateSession(GrammarSession session, String lastMessage) { String now = Instant.now().toString(); session.setGsi1sk(GrammarKey.updatedSk(now)); session.setMessageCount(session.getMessageCount() + 2); // user + assistant session.setLastMessage(truncateMessage(lastMessage, LAST_MESSAGE_MAX_LENGTH)); session.setUpdatedAt(now); - + repository.saveSession(session); } - + private String truncateMessage(String message, int maxLength) { if (message == null) return null; if (message.length() <= maxLength) return message; return message.substring(0, maxLength - 3) + "..."; } - + private GrammarLevel parseLevel(String levelStr) { if (levelStr == null || levelStr.isEmpty()) { return GrammarLevel.BEGINNER; } - + if (!GrammarLevel.isValid(levelStr)) { throw GrammarException.invalidLevel(levelStr); } - + return GrammarLevel.fromString(levelStr); } - + public void clearSession(String userId, String sessionId) { repository.deleteSession(userId, sessionId); logger.info("Session cleared: sessionId={}", sessionId); } - + /** * 스트리밍 방식의 대화 처리 * 세션 관리, 메시지 저장 등을 서비스에서 담당 @@ -239,16 +239,16 @@ public void chatStreaming( StreamingCallback callback ) { logger.info("Streaming chat requested: sessionId={}, userId={}", sessionId, userId); - + GrammarLevel level = parseLevel(levelStr); - + // 세션 가져오기 또는 새로 생성 GrammarSession session = getOrCreateSession(sessionId, userId, level); onSessionCreated.accept(session.getSessionId()); - + // 이전 대화 히스토리 조회 String conversationHistory = buildConversationHistory(session.getSessionId()); - + // 스트리밍 콜백 래핑 - 완료 시 메시지 저장 grammarFactory.generateConversationStreaming( session.getSessionId(), @@ -260,7 +260,7 @@ public void chatStreaming( public void onToken(String token) { callback.onToken(token); } - + @Override public void onComplete(ConversationResponse response) { // 사용자 메시지 저장 @@ -272,7 +272,7 @@ public void onComplete(ConversationResponse response) { // 완료 콜백 호출 callback.onComplete(response); } - + @Override public void onError(Throwable error) { callback.onError(error); @@ -280,7 +280,7 @@ public void onError(Throwable error) { } ); } - + // Getter for external access public GrammarSession findOrCreateSession(String sessionId, String userId, String levelStr) { GrammarLevel level = parseLevel(levelStr); From a05869d6f681b8bc86de90795e9ed5947f136519 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:31:13 +0900 Subject: [PATCH 276/528] =?UTF-8?q?refactor:=20GrammarSessionQueryService?= =?UTF-8?q?=20=EC=BD=94=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/GrammarSessionQueryService.java | 29 ++++++++++--------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/service/GrammarSessionQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/service/GrammarSessionQueryService.java index ee0f0319..47582f1e 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/service/GrammarSessionQueryService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/service/GrammarSessionQueryService.java @@ -11,43 +11,44 @@ import java.util.List; public class GrammarSessionQueryService { - + private static final Logger logger = LoggerFactory.getLogger(GrammarSessionQueryService.class); - + private final GrammarSessionRepository repository; - + public GrammarSessionQueryService() { this.repository = new GrammarSessionRepository(); } - + public GrammarSessionQueryService(GrammarSessionRepository repository) { this.repository = repository; } - + public PaginatedResult getSessions(String userId, int limit, String cursor) { logger.info("Getting sessions for user: userId={}", userId); return repository.findSessionsByUserId(userId, limit, cursor); } - + public SessionDetail getSessionDetail(String userId, String sessionId, int messageLimit) { logger.info("Getting session detail: sessionId={}", sessionId); - + GrammarSession session = repository.findSessionById(userId, sessionId) .orElseThrow(() -> GrammarException.sessionNotFound(sessionId)); - + List messages = repository.findRecentMessagesBySessionId(sessionId, messageLimit); - + return new SessionDetail(session, messages); } - + public void deleteSession(String userId, String sessionId) { logger.info("Deleting session: sessionId={}", sessionId); - + repository.findSessionById(userId, sessionId) .orElseThrow(() -> GrammarException.sessionNotFound(sessionId)); - + repository.deleteSession(userId, sessionId); } - - public record SessionDetail(GrammarSession session, List messages) {} + + public record SessionDetail(GrammarSession session, List messages) { + } } From 007af530c388d436f14cb8e92684a71b5312c17c Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:31:13 +0900 Subject: [PATCH 277/528] =?UTF-8?q?refactor:=20StreamingCallback=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/grammar/streaming/StreamingCallback.java | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/streaming/StreamingCallback.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/streaming/StreamingCallback.java index 45741285..cf74abef 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/streaming/StreamingCallback.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/streaming/StreamingCallback.java @@ -6,21 +6,24 @@ * Bedrock Streaming 응답을 처리하기 위한 콜백 인터페이스 */ public interface StreamingCallback { - + /** * 토큰이 수신될 때마다 호출 + * * @param token 수신된 토큰 (텍스트 조각) */ void onToken(String token); - + /** * 스트리밍이 완료되고 전체 응답이 파싱되었을 때 호출 + * * @param response 완성된 대화 응답 */ void onComplete(ConversationResponse response); - + /** * 에러 발생 시 호출 + * * @param error 발생한 예외 */ void onError(Throwable error); From 38130fac7a032ec5297d37da74a8c9888816b6cf Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:31:13 +0900 Subject: [PATCH 278/528] =?UTF-8?q?refactor:=20StreamingEvent=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../grammar/streaming/StreamingEvent.java | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/streaming/StreamingEvent.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/streaming/StreamingEvent.java index f4f78f55..638e5f5c 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/streaming/StreamingEvent.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/streaming/StreamingEvent.java @@ -8,34 +8,29 @@ * Java 17+ Sealed Class 활용 */ public sealed interface StreamingEvent { - + String type(); - + record StartEvent(String sessionId) implements StreamingEvent { @Override public String type() { return "start"; } } - + record TokenEvent(String token) implements StreamingEvent { @Override public String type() { return "token"; } } - + record CompleteEvent( String sessionId, GrammarCheckResponse grammarCheck, String aiResponse, String conversationTip ) implements StreamingEvent { - @Override - public String type() { - return "complete"; - } - public static CompleteEvent from(ConversationResponse response) { return new CompleteEvent( response.getSessionId(), @@ -44,8 +39,13 @@ public static CompleteEvent from(ConversationResponse response) { response.getConversationTip() ); } + + @Override + public String type() { + return "complete"; + } } - + record ErrorEvent(String message) implements StreamingEvent { @Override public String type() { From 9fbd82c83efbe994b603a8f59d2ba30d23e6d1f4 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:31:21 +0900 Subject: [PATCH 279/528] =?UTF-8?q?refactor:=20UserStatsHandler=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/stats/handler/UserStatsHandler.java | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java index 9ea508d3..47c07cd6 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java @@ -30,7 +30,7 @@ public class UserStatsHandler implements RequestHandler queryParams = request.getQueryStringParameters(); String cursor = queryParams != null ? queryParams.get("cursor") : null; - + int limit = 7; // 기본 7일 if (queryParams != null && queryParams.get("limit") != null) { limit = Math.min(Integer.parseInt(queryParams.get("limit")), 100); } - + PaginatedResult result = statsRepository.findRecentDailyStats(userId, limit, cursor); - + // 각 날짜별 isCompleted 정보 조회 및 응답 구성 List> historyWithCompletion = result.items().stream() .map(stats -> { @@ -141,20 +141,20 @@ private APIGatewayProxyResponseEvent getStatsHistory(APIGatewayProxyRequestEvent item.put("successRate", calculateSuccessRate(stats)); item.put("newWordsLearned", stats.getNewWordsLearned() != null ? stats.getNewWordsLearned() : 0); item.put("wordsReviewed", stats.getWordsReviewed() != null ? stats.getWordsReviewed() : 0); - + // DailyStudy에서 isCompleted 조회 Optional dailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, stats.getPeriod()); item.put("isCompleted", dailyStudy.map(ds -> ds.getIsCompleted() != null && ds.getIsCompleted()).orElse(false)); - + return item; }) .collect(Collectors.toList()); - + Map response = new HashMap<>(); response.put("history", historyWithCompletion); response.put("nextCursor", result.nextCursor()); response.put("hasMore", result.hasMore()); - + return ResponseGenerator.ok("Stats history retrieved", response); } From ffb85620e4b27d0782bd74b1209ab5c1dd1f3178 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:31:21 +0900 Subject: [PATCH 280/528] =?UTF-8?q?refactor:=20UserStats=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../serverless/domain/stats/model/UserStats.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/model/UserStats.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/model/UserStats.java index c51f7df5..cc25634c 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/model/UserStats.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/model/UserStats.java @@ -48,7 +48,7 @@ public class UserStats { private Integer currentStreak; // 현재 연속 학습일 private Integer longestStreak; // 최장 연속 학습일 private String lastStudyDate; // 마지막 학습일 - + // 게임 통계 private Integer gamesPlayed; // 참여한 게임 수 private Integer gamesWon; // 1등 횟수 @@ -56,7 +56,7 @@ public class UserStats { private Integer totalGameScore; // 누적 게임 점수 private Integer quickGuesses; // 5초 내 정답 횟수 private Integer perfectDraws; // 전원 정답 유도 횟수 - + // 메타데이터 private String createdAt; private String updatedAt; From fc03cdc50ab8b150c44dac71f7c3ca6fa5302267 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:31:21 +0900 Subject: [PATCH 281/528] =?UTF-8?q?refactor:=20UserStatsRepository=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stats/repository/UserStatsRepository.java | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java index cea1d7db..d6cb8741 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java @@ -257,15 +257,15 @@ public void updateStreak(String userId, int currentStreak, int longestStreak, St * 게임 통계 Atomic 업데이트 */ public void incrementGameStats(String userId, int gamesPlayed, int gamesWon, - int correctGuesses, int totalScore, int quickGuesses, int perfectDraws) { + int correctGuesses, int totalScore, int quickGuesses, int perfectDraws) { String pk = StatsKey.userStatsPk(userId); String sk = StatsKey.statsTotalSk(); String now = Instant.now().toString(); - + Map key = new HashMap<>(); key.put("PK", AttributeValue.builder().s(pk).build()); key.put("SK", AttributeValue.builder().s(sk).build()); - + Map values = new HashMap<>(); values.put(":gamesPlayed", AttributeValue.builder().n(String.valueOf(gamesPlayed)).build()); values.put(":gamesWon", AttributeValue.builder().n(String.valueOf(gamesWon)).build()); @@ -275,7 +275,7 @@ public void incrementGameStats(String userId, int gamesPlayed, int gamesWon, values.put(":perfectDraws", AttributeValue.builder().n(String.valueOf(perfectDraws)).build()); values.put(":zero", AttributeValue.builder().n("0").build()); values.put(":now", AttributeValue.builder().s(now).build()); - + String updateExpression = "SET " + "gamesPlayed = if_not_exists(gamesPlayed, :zero) + :gamesPlayed, " + "gamesWon = if_not_exists(gamesWon, :zero) + :gamesWon, " + @@ -285,19 +285,19 @@ public void incrementGameStats(String userId, int gamesPlayed, int gamesWon, "perfectDraws = if_not_exists(perfectDraws, :zero) + :perfectDraws, " + "updatedAt = :now, " + "createdAt = if_not_exists(createdAt, :now)"; - + UpdateItemRequest request = UpdateItemRequest.builder() .tableName(TABLE_NAME) .key(key) .updateExpression(updateExpression) .expressionAttributeValues(values) .build(); - + AwsClients.dynamoDb().updateItem(request); logger.info("Incremented game stats: userId={}, gamesPlayed={}, gamesWon={}, correctGuesses={}", userId, gamesPlayed, gamesWon, correctGuesses); } - + /** * 현재 연도-주차 반환 (예: 2026-W02) */ From 0018c7d81caad126b1c4d4098ebecba1b3d73a7f Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:31:22 +0900 Subject: [PATCH 282/528] =?UTF-8?q?refactor:=20ImageUploadRequest=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/user/dto/request/ImageUploadRequest.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/dto/request/ImageUploadRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/dto/request/ImageUploadRequest.java index a284130d..df73858b 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/dto/request/ImageUploadRequest.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/dto/request/ImageUploadRequest.java @@ -8,7 +8,7 @@ @NoArgsConstructor @AllArgsConstructor public class ImageUploadRequest { - - private String fileName; - private String contentType; + + private String fileName; + private String contentType; } From 0e62e0875f9e875116bef3d951efada7cb69abe1 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:31:22 +0900 Subject: [PATCH 283/528] =?UTF-8?q?refactor:=20ProfileUpdateRequest=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/user/dto/request/ProfileUpdateRequest.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/dto/request/ProfileUpdateRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/dto/request/ProfileUpdateRequest.java index 8b49a0d8..752d92e3 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/dto/request/ProfileUpdateRequest.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/dto/request/ProfileUpdateRequest.java @@ -8,8 +8,8 @@ @NoArgsConstructor @AllArgsConstructor public class ProfileUpdateRequest { - - private String nickname; - private String level; - private String profileUrl; + + private String nickname; + private String level; + private String profileUrl; } From fc10e890904f769067dcd04c8dd9340f8ddecb32 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:31:22 +0900 Subject: [PATCH 284/528] =?UTF-8?q?refactor:=20ImageUploadResponse=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/user/dto/response/ImageUploadResponse.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/dto/response/ImageUploadResponse.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/dto/response/ImageUploadResponse.java index 1018efd3..3043a517 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/dto/response/ImageUploadResponse.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/dto/response/ImageUploadResponse.java @@ -10,7 +10,7 @@ @NoArgsConstructor @AllArgsConstructor public class ImageUploadResponse { - - private String uploadUrl; // S3 Presigned URL (클라이언트가 PUT 요청할 URL) - private String imageUrl; // 업로드 완료 후 접근 가능한 이미지 URL + + private String uploadUrl; // S3 Presigned URL (클라이언트가 PUT 요청할 URL) + private String imageUrl; // 업로드 완료 후 접근 가능한 이미지 URL } From ed15cee3204223a3c762adf34bf8cac71277614b Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:31:29 +0900 Subject: [PATCH 285/528] =?UTF-8?q?refactor:=20ProfileResponse=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/dto/response/ProfileResponse.java | 46 +++++++++---------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/dto/response/ProfileResponse.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/dto/response/ProfileResponse.java index e8ae441d..bdc1ced7 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/dto/response/ProfileResponse.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/dto/response/ProfileResponse.java @@ -11,27 +11,27 @@ @NoArgsConstructor @AllArgsConstructor public class ProfileResponse { - - private String userId; - private String email; - private String nickname; - private String level; - private String profileUrl; - private String createdAt; - private String updatedAt; - - /** - * User 엔티티 → ProfileResponse 변환 - */ - public static ProfileResponse from(User user) { - return ProfileResponse.builder() - .userId(user.getCognitoSub()) - .email(user.getEmail()) - .nickname(user.getNickname()) - .level(user.getLevel()) - .profileUrl(user.getProfileUrl()) - .createdAt(user.getCreatedAt()) - .updatedAt(user.getUpdatedAt()) - .build(); - } + + private String userId; + private String email; + private String nickname; + private String level; + private String profileUrl; + private String createdAt; + private String updatedAt; + + /** + * User 엔티티 → ProfileResponse 변환 + */ + public static ProfileResponse from(User user) { + return ProfileResponse.builder() + .userId(user.getCognitoSub()) + .email(user.getEmail()) + .nickname(user.getNickname()) + .level(user.getLevel()) + .profileUrl(user.getProfileUrl()) + .createdAt(user.getCreatedAt()) + .updatedAt(user.getUpdatedAt()) + .build(); + } } From 4838f5dc77534b8887da40081d1066a7c05cf6fc Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:31:29 +0900 Subject: [PATCH 286/528] =?UTF-8?q?refactor:=20UserErrorCode=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/user/exception/UserErrorCode.java | 94 +++++++++---------- 1 file changed, 47 insertions(+), 47 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/exception/UserErrorCode.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/exception/UserErrorCode.java index 06ce269e..b724425f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/exception/UserErrorCode.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/exception/UserErrorCode.java @@ -7,51 +7,51 @@ * 사용자 프로필, 인증 관련 에러 코드를 정의합니다. */ public enum UserErrorCode implements DomainErrorCode { - - // 사용자 조회 관련 에러 - USER_NOT_FOUND("USER_001", "사용자를 찾을 수 없습니다", 404), - - // 프로필 수정 관련 에러 - INVALID_NICKNAME("USER_002", "닉네임은 2~20자여야 합니다", 400), - INVALID_LEVEL("USER_003", "유효하지 않은 레벨입니다", 400), - - // 이미지 업로드 관련 에러 - INVALID_IMAGE_TYPE("USER_004", "지원하지 않는 이미지 형식입니다", 400), - IMAGE_UPLOAD_FAILED("USER_005", "이미지 업로드에 실패했습니다", 500), - - // 인증 관련 에러 - COGNITO_SYNC_FAILED("USER_006", "Cognito 동기화에 실패했습니다", 500), - ; - - private static final String DOMAIN = "USER"; - - private final String code; - private final String message; - private final int statusCode; - - UserErrorCode(String code, String message, int statusCode) { - this.code = code; - this.message = message; - this.statusCode = statusCode; - } - - @Override - public String getDomain() { - return DOMAIN; - } - - @Override - public String getCode() { - return code; - } - - @Override - public String getMessage() { - return message; - } - - @Override - public int getStatusCode() { - return statusCode; - } + + // 사용자 조회 관련 에러 + USER_NOT_FOUND("USER_001", "사용자를 찾을 수 없습니다", 404), + + // 프로필 수정 관련 에러 + INVALID_NICKNAME("USER_002", "닉네임은 2~20자여야 합니다", 400), + INVALID_LEVEL("USER_003", "유효하지 않은 레벨입니다", 400), + + // 이미지 업로드 관련 에러 + INVALID_IMAGE_TYPE("USER_004", "지원하지 않는 이미지 형식입니다", 400), + IMAGE_UPLOAD_FAILED("USER_005", "이미지 업로드에 실패했습니다", 500), + + // 인증 관련 에러 + COGNITO_SYNC_FAILED("USER_006", "Cognito 동기화에 실패했습니다", 500), + ; + + private static final String DOMAIN = "USER"; + + private final String code; + private final String message; + private final int statusCode; + + UserErrorCode(String code, String message, int statusCode) { + this.code = code; + this.message = message; + this.statusCode = statusCode; + } + + @Override + public String getDomain() { + return DOMAIN; + } + + @Override + public String getCode() { + return code; + } + + @Override + public String getMessage() { + return message; + } + + @Override + public int getStatusCode() { + return statusCode; + } } From 99a8a7c70153020db8dc8b22af2048ec9b0737f5 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:31:29 +0900 Subject: [PATCH 287/528] =?UTF-8?q?refactor:=20UserException=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/user/exception/UserException.java | 122 +++++++++--------- 1 file changed, 61 insertions(+), 61 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/exception/UserException.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/exception/UserException.java index 34c00817..58879125 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/exception/UserException.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/exception/UserException.java @@ -6,65 +6,65 @@ * User 도메인 예외 클래스 */ public class UserException extends ServerlessException { - - private UserException(UserErrorCode errorCode) { - super(errorCode); - } - - private UserException(UserErrorCode errorCode, String message) { - super(errorCode, message); - } - - private UserException(UserErrorCode errorCode, Throwable cause) { - super(errorCode, cause); - } - - - /** - * 팩토리 메서드들 - */ - - // 사용자 조회 관련 - public static UserException userNotFound(String cognitoSub) { - return (UserException) new UserException(UserErrorCode.USER_NOT_FOUND, - String.format("사용자를 찾을 수 없습니다 (ID: %s)", cognitoSub)) - .addDetail("cognitoSub", cognitoSub); - } - - // 프로필 수정 관련 - public static UserException invalidNickname(String nickname, int minLength, int maxLength) { - return (UserException) new UserException(UserErrorCode.INVALID_NICKNAME, - String.format("닉네임은 %d~%d자여야 합니다 (입력: '%s', 길이: %d)", - minLength, maxLength, nickname, nickname != null ? nickname.length() : 0)) - .addDetail("nickname", nickname) - .addDetail("minLength", minLength) - .addDetail("maxLength", maxLength); - } - - public static UserException invalidLevel(String level) { - return (UserException) new UserException(UserErrorCode.INVALID_LEVEL, - String.format("유효하지 않은 레벨입니다: '%s' (BEGINNER, INTERMEDIATE, ADVANCED 중 선택)", level)) - .addDetail("invalidValue", level); - } - - // 이미지 업로드 관련 - public static UserException invalidImageType(String contentType) { - return (UserException) new UserException(UserErrorCode.INVALID_IMAGE_TYPE, - String.format("지원하지 않는 이미지 형식입니다: '%s' (jpeg, png, gif, webp만 가능)", contentType)) - .addDetail("contentType", contentType); - } - - public static UserException imageUploadFailed(Throwable cause) { - return (UserException) new UserException(UserErrorCode.IMAGE_UPLOAD_FAILED, cause); - } - - public static UserException imageUploadFailed(String reason) { - return (UserException) new UserException(UserErrorCode.IMAGE_UPLOAD_FAILED, reason); - } - - // Cognito 동기화 관련 - public static UserException cognitoSyncFailed(String cognitoSub, Throwable cause) { - return (UserException) new UserException(UserErrorCode.COGNITO_SYNC_FAILED, cause) - .addDetail("cognitoSub", cognitoSub); - } + + private UserException(UserErrorCode errorCode) { + super(errorCode); + } + + private UserException(UserErrorCode errorCode, String message) { + super(errorCode, message); + } + + private UserException(UserErrorCode errorCode, Throwable cause) { + super(errorCode, cause); + } + + + /** + * 팩토리 메서드들 + */ + + // 사용자 조회 관련 + public static UserException userNotFound(String cognitoSub) { + return (UserException) new UserException(UserErrorCode.USER_NOT_FOUND, + String.format("사용자를 찾을 수 없습니다 (ID: %s)", cognitoSub)) + .addDetail("cognitoSub", cognitoSub); + } + + // 프로필 수정 관련 + public static UserException invalidNickname(String nickname, int minLength, int maxLength) { + return (UserException) new UserException(UserErrorCode.INVALID_NICKNAME, + String.format("닉네임은 %d~%d자여야 합니다 (입력: '%s', 길이: %d)", + minLength, maxLength, nickname, nickname != null ? nickname.length() : 0)) + .addDetail("nickname", nickname) + .addDetail("minLength", minLength) + .addDetail("maxLength", maxLength); + } + + public static UserException invalidLevel(String level) { + return (UserException) new UserException(UserErrorCode.INVALID_LEVEL, + String.format("유효하지 않은 레벨입니다: '%s' (BEGINNER, INTERMEDIATE, ADVANCED 중 선택)", level)) + .addDetail("invalidValue", level); + } + + // 이미지 업로드 관련 + public static UserException invalidImageType(String contentType) { + return (UserException) new UserException(UserErrorCode.INVALID_IMAGE_TYPE, + String.format("지원하지 않는 이미지 형식입니다: '%s' (jpeg, png, gif, webp만 가능)", contentType)) + .addDetail("contentType", contentType); + } + + public static UserException imageUploadFailed(Throwable cause) { + return (UserException) new UserException(UserErrorCode.IMAGE_UPLOAD_FAILED, cause); + } + + public static UserException imageUploadFailed(String reason) { + return (UserException) new UserException(UserErrorCode.IMAGE_UPLOAD_FAILED, reason); + } + + // Cognito 동기화 관련 + public static UserException cognitoSyncFailed(String cognitoSub, Throwable cause) { + return (UserException) new UserException(UserErrorCode.COGNITO_SYNC_FAILED, cause) + .addDetail("cognitoSub", cognitoSub); + } } From 26604950231b390ed6594a4c155b04a436d3d784 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:31:29 +0900 Subject: [PATCH 288/528] =?UTF-8?q?refactor:=20PostConfirmationHandler=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/handler/PostConfirmationHandler.java | 126 +++++++++--------- 1 file changed, 63 insertions(+), 63 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PostConfirmationHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PostConfirmationHandler.java index 114ff82f..edf0ba5e 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PostConfirmationHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PostConfirmationHandler.java @@ -13,70 +13,70 @@ /** * Cognito Post Confirmation 트리거 핸들러 - * + *

* - 사용자 이메일 인증을 완료한 직후 DB에 데이터 생성 */ public class PostConfirmationHandler implements RequestHandler { - - private static final Logger logger = LoggerFactory.getLogger(PostConfirmationHandler.class); - private static final String DEFAULT_PROFILE_URL = "https://group2-englishstudy.s3.amazonaws.com/profile/default.png"; - - private final UserRepository userRepository; - - public PostConfirmationHandler() { - this.userRepository = new UserRepository(); - } - - @Override - public CognitoUserPoolPostConfirmationEvent handleRequest( - CognitoUserPoolPostConfirmationEvent event, - Context context - ) { - - try { - // 확인 완료 이벤트만 처리 (비밀번호 재설정 등은 무시) - if (!"PostConfirmation_ConfirmSignUp".equals(event.getTriggerSource())) { - return event; - } - - Map userAttributes = event.getRequest().getUserAttributes(); - - // Cognito에서 사용자 정보 추출 - String cognitoSub = userAttributes.get("sub"); - String email = userAttributes.get("email"); - String nickname = userAttributes.get("nickname"); - String level = userAttributes.get("custom:level"); - String profileUrl = userAttributes.get("custom:profileUrl"); - - logger.info("사용자 정보: cognitoSub={}, email={}", cognitoSub, email); - - // 중복 확인 - if (userRepository.findByCognitoSub(cognitoSub).isPresent()) { - return event; - } - - User newUser = User.createNew( - cognitoSub, - email, - nickname != null ? nickname : generateDefaultNickname(), - level != null ? level : "BEGINNER", - profileUrl != null ? profileUrl : DEFAULT_PROFILE_URL - ); - - userRepository.save(newUser); - logger.info("사용자 DynamoDB 저장 완료: email={}", email); - - } catch (Exception e) { - // 예외가 발생해도 회원가입은 진행 - getProfile()에서 fallback으로 처리 - } - - return event; - } - - /** - * 닉네임 기본값 생성 - */ - private String generateDefaultNickname() { - return UUID.randomUUID().toString().substring(0, 6).toUpperCase() + "님"; - } + + private static final Logger logger = LoggerFactory.getLogger(PostConfirmationHandler.class); + private static final String DEFAULT_PROFILE_URL = "https://group2-englishstudy.s3.amazonaws.com/profile/default.png"; + + private final UserRepository userRepository; + + public PostConfirmationHandler() { + this.userRepository = new UserRepository(); + } + + @Override + public CognitoUserPoolPostConfirmationEvent handleRequest( + CognitoUserPoolPostConfirmationEvent event, + Context context + ) { + + try { + // 확인 완료 이벤트만 처리 (비밀번호 재설정 등은 무시) + if (!"PostConfirmation_ConfirmSignUp".equals(event.getTriggerSource())) { + return event; + } + + Map userAttributes = event.getRequest().getUserAttributes(); + + // Cognito에서 사용자 정보 추출 + String cognitoSub = userAttributes.get("sub"); + String email = userAttributes.get("email"); + String nickname = userAttributes.get("nickname"); + String level = userAttributes.get("custom:level"); + String profileUrl = userAttributes.get("custom:profileUrl"); + + logger.info("사용자 정보: cognitoSub={}, email={}", cognitoSub, email); + + // 중복 확인 + if (userRepository.findByCognitoSub(cognitoSub).isPresent()) { + return event; + } + + User newUser = User.createNew( + cognitoSub, + email, + nickname != null ? nickname : generateDefaultNickname(), + level != null ? level : "BEGINNER", + profileUrl != null ? profileUrl : DEFAULT_PROFILE_URL + ); + + userRepository.save(newUser); + logger.info("사용자 DynamoDB 저장 완료: email={}", email); + + } catch (Exception e) { + // 예외가 발생해도 회원가입은 진행 - getProfile()에서 fallback으로 처리 + } + + return event; + } + + /** + * 닉네임 기본값 생성 + */ + private String generateDefaultNickname() { + return UUID.randomUUID().toString().substring(0, 6).toUpperCase() + "님"; + } } From 1ff598799ad1ee94baeea69e0460a6ceff1ae60f Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:31:30 +0900 Subject: [PATCH 289/528] =?UTF-8?q?refactor:=20PreSignUpHandler=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/user/handler/PreSignUpHandler.java | 88 +++++++++---------- 1 file changed, 44 insertions(+), 44 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PreSignUpHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PreSignUpHandler.java index 5474b7b6..d881615b 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PreSignUpHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PreSignUpHandler.java @@ -9,48 +9,48 @@ import java.util.UUID; public class PreSignUpHandler implements RequestHandler, Map> { - - private static final Logger logger = LoggerFactory.getLogger(PreSignUpHandler.class); - private static final String DEFAULT_PROFILE_URL = System.getenv("DEFAULT_PROFILE_URL"); - - @Override - public Map handleRequest(Map input, Context context) { - - try { - @SuppressWarnings("unchecked") - Map request = (Map) input.get("request"); - - @SuppressWarnings("unchecked") - Map userAttributes = (Map) request.get("userAttributes"); - - String nickname = userAttributes.get("nickname"); - if (nickname == null || nickname.trim().isEmpty()) { - String defaultNickname = UUID.randomUUID().toString().substring(0, 6).toUpperCase() + "님"; - userAttributes.put("nickname", defaultNickname); - logger.info("nickname 기본값: {}", defaultNickname); - } - - String level = userAttributes.get("custom:level"); - if (level == null || level.trim().isEmpty()) { - userAttributes.put("custom:level", "BEGINNER"); - logger.info("level 선택 기본값: BEGINNER"); - } - - String profileUrl = userAttributes.get("custom:profileUrl"); - if (profileUrl == null || profileUrl.trim().isEmpty()) { - String defaultUrl = DEFAULT_PROFILE_URL != null - ? DEFAULT_PROFILE_URL - : "https://group2-englishstudy.s3.amazonaws.com/profile/default.png"; - userAttributes.put("custom:profileUrl", defaultUrl); - logger.info("프로필 이미지 기본값: {}", defaultUrl); - } - - return input; - - } catch (Exception e) { - logger.error("PreSignUp 트리거에서 오류가 발생했습니다"); - throw new RuntimeException("회원가입 처리 중 오류가 발생했습니다: " + e.getMessage()); - } - - } + + private static final Logger logger = LoggerFactory.getLogger(PreSignUpHandler.class); + private static final String DEFAULT_PROFILE_URL = System.getenv("DEFAULT_PROFILE_URL"); + + @Override + public Map handleRequest(Map input, Context context) { + + try { + @SuppressWarnings("unchecked") + Map request = (Map) input.get("request"); + + @SuppressWarnings("unchecked") + Map userAttributes = (Map) request.get("userAttributes"); + + String nickname = userAttributes.get("nickname"); + if (nickname == null || nickname.trim().isEmpty()) { + String defaultNickname = UUID.randomUUID().toString().substring(0, 6).toUpperCase() + "님"; + userAttributes.put("nickname", defaultNickname); + logger.info("nickname 기본값: {}", defaultNickname); + } + + String level = userAttributes.get("custom:level"); + if (level == null || level.trim().isEmpty()) { + userAttributes.put("custom:level", "BEGINNER"); + logger.info("level 선택 기본값: BEGINNER"); + } + + String profileUrl = userAttributes.get("custom:profileUrl"); + if (profileUrl == null || profileUrl.trim().isEmpty()) { + String defaultUrl = DEFAULT_PROFILE_URL != null + ? DEFAULT_PROFILE_URL + : "https://group2-englishstudy.s3.amazonaws.com/profile/default.png"; + userAttributes.put("custom:profileUrl", defaultUrl); + logger.info("프로필 이미지 기본값: {}", defaultUrl); + } + + return input; + + } catch (Exception e) { + logger.error("PreSignUp 트리거에서 오류가 발생했습니다"); + throw new RuntimeException("회원가입 처리 중 오류가 발생했습니다: " + e.getMessage()); + } + + } } From 226934358317a412d79407429b33b33d8bd73e1f Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:31:30 +0900 Subject: [PATCH 290/528] =?UTF-8?q?refactor:=20UserHandler=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/user/handler/UserHandler.java | 193 +++++++++--------- 1 file changed, 96 insertions(+), 97 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/UserHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/UserHandler.java index da37894b..b4fc9aea 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/UserHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/UserHandler.java @@ -5,14 +5,13 @@ import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; import com.google.gson.Gson; -import com.mzc.secondproject.serverless.common.exception.CommonErrorCode; import com.mzc.secondproject.serverless.common.router.HandlerRouter; import com.mzc.secondproject.serverless.common.router.Route; import com.mzc.secondproject.serverless.common.util.ResponseGenerator; import com.mzc.secondproject.serverless.domain.user.dto.request.ImageUploadRequest; +import com.mzc.secondproject.serverless.domain.user.dto.request.ProfileUpdateRequest; import com.mzc.secondproject.serverless.domain.user.dto.response.ImageUploadResponse; import com.mzc.secondproject.serverless.domain.user.dto.response.ProfileResponse; -import com.mzc.secondproject.serverless.domain.user.dto.request.ProfileUpdateRequest; import com.mzc.secondproject.serverless.domain.user.model.User; import com.mzc.secondproject.serverless.domain.user.repository.UserRepository; import com.mzc.secondproject.serverless.domain.user.service.UserService; @@ -22,100 +21,100 @@ import java.util.Map; public class UserHandler implements RequestHandler { - - private static final Logger logger = LoggerFactory.getLogger(UserHandler.class); - private static final Gson gson = new Gson(); - private final UserService userService; - - // HandlerRouter가 라우팅 + 파라미터 검증 + 예외 처리 모두 담당 - private final HandlerRouter router; - - public UserHandler() { - UserRepository repository = new UserRepository(); - this.userService = new UserService(repository); - this.router = initRouter(); - } - - private HandlerRouter initRouter() { - - return new HandlerRouter().addRoutes( - Route.getAuth("/users/profile/me", this::getMyProfile), - Route.putAuth("/users/profile/me", this::updateMyProfile), - Route.postAuth("/users/profile/me/image", this::uploadProfileImage) - ); - } - - @Override - public APIGatewayProxyResponseEvent handleRequest( - APIGatewayProxyRequestEvent request, - Context context - ) { - return router.route(request); - } - - /** - * GET /users/profile/me - 내 프로필 조회 - */ - private APIGatewayProxyResponseEvent getMyProfile( - APIGatewayProxyRequestEvent request, - String userId // cognitoSub - ) { - - User user = userService.getProfile(userId, request); - ProfileResponse response = ProfileResponse.from(user); - - return ResponseGenerator.ok(user.getNickname() + " 환영합니다!", response); - } - - /** - * PUT /users/profile/me - 프로필 수정 - */ - private APIGatewayProxyResponseEvent updateMyProfile( - APIGatewayProxyRequestEvent requestEvent, - String userId - ) { - - ProfileUpdateRequest updateRequest = gson.fromJson(requestEvent.getBody(), ProfileUpdateRequest.class); - - // 프로필 URL 수정 - if (updateRequest.getProfileUrl() != null && !updateRequest.getProfileUrl().isEmpty()) { - userService.updateProfileImage(userId, updateRequest.getProfileUrl()); - } - - // 닉네임, 레벨 수정 - User user = userService.updateProfile( - userId, - updateRequest.getNickname(), - updateRequest.getLevel() - ); - - ProfileResponse response = ProfileResponse.from(user); - return ResponseGenerator.ok("프로필이 수정되었습니다.", response); - } - - - /** - * POST /users/profile/me/image - 프로필 이미지 업로드 URL 발급 - */ - private APIGatewayProxyResponseEvent uploadProfileImage( - APIGatewayProxyRequestEvent request, - String userId - ) { - ImageUploadRequest uploadRequest = gson.fromJson(request.getBody(), ImageUploadRequest.class); - - Map urls = userService.generateProfileImageUploadUrl( - userId, - uploadRequest.getFileName(), - uploadRequest.getContentType() - ); - - ImageUploadResponse response = ImageUploadResponse.builder() - .uploadUrl(urls.get("uploadUrl")) - .imageUrl(urls.get("imageUrl")) - .build(); - - return ResponseGenerator.ok("이미지 업로드 URL 발급 성공", response); - } - + + private static final Logger logger = LoggerFactory.getLogger(UserHandler.class); + private static final Gson gson = new Gson(); + private final UserService userService; + + // HandlerRouter가 라우팅 + 파라미터 검증 + 예외 처리 모두 담당 + private final HandlerRouter router; + + public UserHandler() { + UserRepository repository = new UserRepository(); + this.userService = new UserService(repository); + this.router = initRouter(); + } + + private HandlerRouter initRouter() { + + return new HandlerRouter().addRoutes( + Route.getAuth("/users/profile/me", this::getMyProfile), + Route.putAuth("/users/profile/me", this::updateMyProfile), + Route.postAuth("/users/profile/me/image", this::uploadProfileImage) + ); + } + + @Override + public APIGatewayProxyResponseEvent handleRequest( + APIGatewayProxyRequestEvent request, + Context context + ) { + return router.route(request); + } + + /** + * GET /users/profile/me - 내 프로필 조회 + */ + private APIGatewayProxyResponseEvent getMyProfile( + APIGatewayProxyRequestEvent request, + String userId // cognitoSub + ) { + + User user = userService.getProfile(userId, request); + ProfileResponse response = ProfileResponse.from(user); + + return ResponseGenerator.ok(user.getNickname() + " 환영합니다!", response); + } + + /** + * PUT /users/profile/me - 프로필 수정 + */ + private APIGatewayProxyResponseEvent updateMyProfile( + APIGatewayProxyRequestEvent requestEvent, + String userId + ) { + + ProfileUpdateRequest updateRequest = gson.fromJson(requestEvent.getBody(), ProfileUpdateRequest.class); + + // 프로필 URL 수정 + if (updateRequest.getProfileUrl() != null && !updateRequest.getProfileUrl().isEmpty()) { + userService.updateProfileImage(userId, updateRequest.getProfileUrl()); + } + + // 닉네임, 레벨 수정 + User user = userService.updateProfile( + userId, + updateRequest.getNickname(), + updateRequest.getLevel() + ); + + ProfileResponse response = ProfileResponse.from(user); + return ResponseGenerator.ok("프로필이 수정되었습니다.", response); + } + + + /** + * POST /users/profile/me/image - 프로필 이미지 업로드 URL 발급 + */ + private APIGatewayProxyResponseEvent uploadProfileImage( + APIGatewayProxyRequestEvent request, + String userId + ) { + ImageUploadRequest uploadRequest = gson.fromJson(request.getBody(), ImageUploadRequest.class); + + Map urls = userService.generateProfileImageUploadUrl( + userId, + uploadRequest.getFileName(), + uploadRequest.getContentType() + ); + + ImageUploadResponse response = ImageUploadResponse.builder() + .uploadUrl(urls.get("uploadUrl")) + .imageUrl(urls.get("imageUrl")) + .build(); + + return ResponseGenerator.ok("이미지 업로드 URL 발급 성공", response); + } + } From 3c990947e882c8ddf183fe4410818b2e57b7e5d8 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:31:37 +0900 Subject: [PATCH 291/528] =?UTF-8?q?refactor:=20User=20=EB=AA=A8=EB=8D=B8?= =?UTF-8?q?=20=EC=BD=94=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../serverless/domain/user/model/User.java | 219 +++++++++--------- 1 file changed, 110 insertions(+), 109 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/model/User.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/model/User.java index c1555eb6..344d77e4 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/model/User.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/model/User.java @@ -1,6 +1,9 @@ package com.mzc.secondproject.serverless.domain.user.model; -import lombok.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.*; import java.time.Instant; @@ -11,112 +14,110 @@ @AllArgsConstructor @DynamoDbBean public class User { - - private String pk; // USER#{cognitoSub} - private String sk; // METADATA - private String gsi1pk; // EMAIL#{email} - private String gsi1sk; // USER#{cognitoSub} - private String gsi2pk; // LEVEL#{level} - private String gsi2sk; // USER#{cognitoSub} - - private String cognitoSub; // Cognito sub (Primary ID) - private String email; - private String nickname; - private String level; - private String profileUrl; - private String createdAt; - private String updatedAt; - private String lastLoginAt; - private Long ttl; - - @DynamoDbPartitionKey - @DynamoDbAttribute("PK") - public String getPk() { - return pk; - } - - @DynamoDbSortKey - @DynamoDbAttribute("SK") - public String getSk() { - return sk; - } - - @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") - @DynamoDbAttribute("GSI1PK") - public String getGsi1pk() { - return gsi1pk; - } - - @DynamoDbSecondarySortKey(indexNames = "GSI1") - @DynamoDbAttribute("GSI1SK") - public String getGsi1sk() { - return gsi1sk; - } - - @DynamoDbSecondaryPartitionKey(indexNames = "GSI2") - @DynamoDbAttribute("GSI2PK") - public String getGsi2pk() { - return gsi2pk; - } - - @DynamoDbSecondarySortKey(indexNames = "GSI2") - @DynamoDbAttribute("GSI2SK") - public String getGsi2sk() { - return gsi2sk; - } - - - /** - * 신규 사용자 생성 - * - Lazy Registration 적용: 최초 프로필 조회 시 DynamoDB에 저장 - * - * @param cognitoSub Cognito User Pool의 sub (UUID) - * @param email 이메일 - * @param nickname 닉네임 - * @param level 학습 레벨 (BEGINNER/INTERMEDIATE/ADVANCED) - * @param profileUrl 프로필 이미지 URL - * @return 새로운 User 객체 (DynamoDB 키 패턴 적용됨) - */ - public static User createNew(String cognitoSub, String email, String nickname, String level, String profileUrl) { - String now = Instant.now().toString(); - return User.builder() - .pk("USER#" + cognitoSub) - .sk("METADATA") - .gsi1pk("EMAIL#" + email) - .gsi1sk("USER#" + cognitoSub) - .gsi2pk("LEVEL#" + level) - .gsi2sk("USER#" + cognitoSub) - .cognitoSub(cognitoSub) - .email(email) - .nickname(nickname) - .level(level) - .profileUrl(profileUrl) - .createdAt(now) - .updatedAt(now) - .lastLoginAt(now) - .build(); - } - - public void updateLevel(String newLevel) { - this.level = newLevel; - this.gsi2pk = "LEVEL#" + newLevel; - this.updatedAt = Instant.now().toString(); - } - - public void updateNickname(String newNickname) { - this.nickname = newNickname; - this.updatedAt = Instant.now().toString(); - } - - public void updateProfileUrl(String newProfileUrl) { - this.profileUrl = newProfileUrl; - this.updatedAt = Instant.now().toString(); - } - - public void updateLastLoginAt() { - this.lastLoginAt = Instant.now().toString(); - } - - - + + private String pk; // USER#{cognitoSub} + private String sk; // METADATA + private String gsi1pk; // EMAIL#{email} + private String gsi1sk; // USER#{cognitoSub} + private String gsi2pk; // LEVEL#{level} + private String gsi2sk; // USER#{cognitoSub} + + private String cognitoSub; // Cognito sub (Primary ID) + private String email; + private String nickname; + private String level; + private String profileUrl; + private String createdAt; + private String updatedAt; + private String lastLoginAt; + private Long ttl; + + /** + * 신규 사용자 생성 + * - Lazy Registration 적용: 최초 프로필 조회 시 DynamoDB에 저장 + * + * @param cognitoSub Cognito User Pool의 sub (UUID) + * @param email 이메일 + * @param nickname 닉네임 + * @param level 학습 레벨 (BEGINNER/INTERMEDIATE/ADVANCED) + * @param profileUrl 프로필 이미지 URL + * @return 새로운 User 객체 (DynamoDB 키 패턴 적용됨) + */ + public static User createNew(String cognitoSub, String email, String nickname, String level, String profileUrl) { + String now = Instant.now().toString(); + return User.builder() + .pk("USER#" + cognitoSub) + .sk("METADATA") + .gsi1pk("EMAIL#" + email) + .gsi1sk("USER#" + cognitoSub) + .gsi2pk("LEVEL#" + level) + .gsi2sk("USER#" + cognitoSub) + .cognitoSub(cognitoSub) + .email(email) + .nickname(nickname) + .level(level) + .profileUrl(profileUrl) + .createdAt(now) + .updatedAt(now) + .lastLoginAt(now) + .build(); + } + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { + return pk; + } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { + return sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1PK") + public String getGsi1pk() { + return gsi1pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1SK") + public String getGsi1sk() { + return gsi1sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI2") + @DynamoDbAttribute("GSI2PK") + public String getGsi2pk() { + return gsi2pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI2") + @DynamoDbAttribute("GSI2SK") + public String getGsi2sk() { + return gsi2sk; + } + + public void updateLevel(String newLevel) { + this.level = newLevel; + this.gsi2pk = "LEVEL#" + newLevel; + this.updatedAt = Instant.now().toString(); + } + + public void updateNickname(String newNickname) { + this.nickname = newNickname; + this.updatedAt = Instant.now().toString(); + } + + public void updateProfileUrl(String newProfileUrl) { + this.profileUrl = newProfileUrl; + this.updatedAt = Instant.now().toString(); + } + + public void updateLastLoginAt() { + this.lastLoginAt = Instant.now().toString(); + } + + } From f17cd32ce075c57cfc6d6238b8e3ec7429fa51ac Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:31:37 +0900 Subject: [PATCH 292/528] =?UTF-8?q?refactor:=20UserRepository=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/repository/UserRepository.java | 140 +++++++++--------- 1 file changed, 70 insertions(+), 70 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/repository/UserRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/repository/UserRepository.java index feae4769..ce38992c 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/repository/UserRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/repository/UserRepository.java @@ -13,74 +13,74 @@ import java.util.Optional; public class UserRepository { - - private static final Logger logger = LoggerFactory.getLogger(UserRepository.class); - private static final String TABLE_NAME = System.getenv("USER_TABLE_NAME"); - - private final DynamoDbTable table; - - public UserRepository() { - this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(User.class)); - } - - public User save(User user) { - logger.info("저장할 사용자 PartitionKey={}, SortKey={}", user.getPk(), user.getSk()); - table.putItem(user); - return user; - } - - /** - * Cognito Sub (userId)로 사용자 조회 - * - PK: USER#{cognitoSub} - * - SK: METADATA - * - * @param cognitoSub Cognito User Pool의 sub (UUID) - * @return 사용자 정보 (Optional) - */ - public Optional findByCognitoSub(String cognitoSub) { - Key key = Key.builder() - .partitionValue("USER#" + cognitoSub) - .sortValue("METADATA") - .build(); - - User user = table.getItem(key); - return Optional.ofNullable(user); - } - - /** - * 이메일로 사용자 조회 - * GSI1 사용: GSI1PK = EMAIL#{email} - * - * @param email 이메일 - * @return 사용자 정보 (Optional) - */ - public Optional findByEmail(String email) { - QueryConditional queryConditional = QueryConditional - .keyEqualTo(Key.builder() - .partitionValue("EMAIL#" + email) - .build()); - - DynamoDbIndex gsi1 = table.index("GSI1"); - - return gsi1.query(queryConditional) - .stream() - .flatMap(page -> page.items().stream()) - .findFirst(); - } - - - public User update(User user) { - table.updateItem(user); - return user; - } - - public void delete(String cognitoSub) { - Key key = Key.builder() - .partitionValue("USER#" + cognitoSub) - .sortValue("METADATA") - .build(); - logger.info("삭제할 사용자: cognitoSub={}", cognitoSub); - table.deleteItem(key); - } - + + private static final Logger logger = LoggerFactory.getLogger(UserRepository.class); + private static final String TABLE_NAME = System.getenv("USER_TABLE_NAME"); + + private final DynamoDbTable table; + + public UserRepository() { + this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(User.class)); + } + + public User save(User user) { + logger.info("저장할 사용자 PartitionKey={}, SortKey={}", user.getPk(), user.getSk()); + table.putItem(user); + return user; + } + + /** + * Cognito Sub (userId)로 사용자 조회 + * - PK: USER#{cognitoSub} + * - SK: METADATA + * + * @param cognitoSub Cognito User Pool의 sub (UUID) + * @return 사용자 정보 (Optional) + */ + public Optional findByCognitoSub(String cognitoSub) { + Key key = Key.builder() + .partitionValue("USER#" + cognitoSub) + .sortValue("METADATA") + .build(); + + User user = table.getItem(key); + return Optional.ofNullable(user); + } + + /** + * 이메일로 사용자 조회 + * GSI1 사용: GSI1PK = EMAIL#{email} + * + * @param email 이메일 + * @return 사용자 정보 (Optional) + */ + public Optional findByEmail(String email) { + QueryConditional queryConditional = QueryConditional + .keyEqualTo(Key.builder() + .partitionValue("EMAIL#" + email) + .build()); + + DynamoDbIndex gsi1 = table.index("GSI1"); + + return gsi1.query(queryConditional) + .stream() + .flatMap(page -> page.items().stream()) + .findFirst(); + } + + + public User update(User user) { + table.updateItem(user); + return user; + } + + public void delete(String cognitoSub) { + Key key = Key.builder() + .partitionValue("USER#" + cognitoSub) + .sortValue("METADATA") + .build(); + logger.info("삭제할 사용자: cognitoSub={}", cognitoSub); + table.deleteItem(key); + } + } From 151ae1ca4decac380fa0bf314737f4501a78ae02 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:31:37 +0900 Subject: [PATCH 293/528] =?UTF-8?q?refactor:=20UserService=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/user/service/UserService.java | 374 +++++++++--------- 1 file changed, 187 insertions(+), 187 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/service/UserService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/service/UserService.java index 9320eb5f..0c5c99c6 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/service/UserService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/service/UserService.java @@ -19,191 +19,191 @@ public class UserService { - - private static final Logger logger = LoggerFactory.getLogger(UserService.class); - private static final String BUCKET_NAME = System.getenv("PROFILE_BUCKET_NAME"); - private static final String DEFAULT_PROFILE_URL = "https://group2-englishstudy.s3.amazonaws.com/profile/default.png"; - private static final List VALID_LEVELS = Arrays.asList("BEGINNER", "INTERMEDIATE", "ADVANCED"); - - private static final List VALID_IMAGE_TYPES = Arrays.asList("image/jpeg", "image/png", "image/gif", "image/webp"); - private static final int NICKNAME_MIN_LENGTH = 2; - private static final int NICKNAME_MAX_LENGTH = 20; - - private final UserRepository userRepository; - private final S3Presigner s3Presigner; - - public UserService(UserRepository userRepository) { - this.userRepository = userRepository; - // AwsClients 싱글톤 사용 - Cold Start 최적화 - this.s3Presigner = AwsClients.s3Presigner(); - } - - /** - * 프로필 조회 - * DynamoDB에 없으면 request에서 claims 추출 → fallback 저장 - * - * @param userId Cognito sub - * @param request API Gateway 요청 (fallback 시 claims 추출용) - * @return User 객체 - */ - public User getProfile(String userId, APIGatewayProxyRequestEvent request) { - - return userRepository.findByCognitoSub(userId) - .map(user -> { - // 정상 DB에서 조회 완료 - user.updateLastLoginAt(); - userRepository.update(user); - return user; - }) - .orElseGet(() -> { - // PostConfirmation 실패 대비 fallback - return createUserFromRequest(userId, request); - }); - } - - /** - * request에서 Cognito claims 추출 후 사용자 생성 (fallback용) - */ - @SuppressWarnings("unchecked") - private User createUserFromRequest(String userId, APIGatewayProxyRequestEvent request) { - - Map claims = null; - try { - Map authorizer = request.getRequestContext().getAuthorizer(); - if (authorizer != null) { - claims = (Map) authorizer.get("claims"); - } - } catch (Exception e) { - logger.error("claims 추출 실패", e); - } - - // claims에서 정보 추출 - String email = claims != null ? claims.get("email") : "unknown@example.com"; - String nickname = claims != null ? claims.get("nickname") : null; - String level = claims != null ? claims.get("custom:level") : null; - String profileUrl = claims != null ? claims.get("custom:profileUrl") : null; - - User newUser = User.createNew( - userId, - email, - nickname != null ? nickname : generateDefaultNickname(), - level != null ? level : "BEGINNER", - profileUrl != null ? profileUrl : DEFAULT_PROFILE_URL - ); - - return userRepository.save(newUser); - } - - - /** - * 프로필 수정 (닉네임, 레벨) - * - * @param userId cognitoSub - * @param nickname 새 닉네임 (null이면 변경 안 함) - * @param level 새 레벨 (null이면 변경 안 함) - * @return 수정된 User 객체 - * @throws UserException USER_NOT_FOUND, INVALID_NICKNAME, INVALID_LEVEL - */ - public User updateProfile(String userId, String nickname, String level) { - logger.info("프로필 수정 요청: userId={}, nickname={}, level={}", userId, nickname, level); - - User user = userRepository.findByCognitoSub(userId) - .orElseThrow(() -> UserException.userNotFound(userId)); - - // 닉네임 수정 - if (nickname != null && !nickname.trim().isEmpty()) { - validateNickname(nickname); - user.updateNickname(nickname); - } - - // 레벨 수정 - if (level != null && !level.trim().isEmpty()) { - validateLevel(level); - user.updateLevel(level); - } - - User updatedUser = userRepository.update(user); - logger.info("프로필 수정 완료: email={}", updatedUser.getEmail()); - - return updatedUser; - } - - - /** - * 프로필 이미지 URL 업데이트 (업로드 완료 후 호출) - */ - public User updateProfileImage(String userId, String imageUrl) { - - User user = userRepository.findByCognitoSub(userId) - .orElseThrow(() -> UserException.userNotFound(userId)); - - user.updateProfileUrl(imageUrl); - return userRepository.update(user); - } - - /** - * 프로필 이미지 업로드를 위한 Presigned URL 발급 - * - * @param userId cognitoSub - * @param fileName 파일명 - * @param contentType MIME 타입 - * @return {uploadUrl, imageUrl} - * @throws UserException INVALID_IMAGE_TYPE - */ - public Map generateProfileImageUploadUrl(String userId, String fileName, String contentType) { - - validateImageContentType(contentType); - - String objectKey = String.format("profile/%s/%s", userId, fileName); - String imageUrl = String.format("https://%s.s3.amazonaws.com/%s", BUCKET_NAME, objectKey); - - PutObjectRequest putObjectRequest = PutObjectRequest.builder() - .bucket(BUCKET_NAME) - .key(objectKey) - .contentType(contentType) - .build(); - - PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder() - .signatureDuration(Duration.ofMinutes(10)) - .putObjectRequest(putObjectRequest) - .build(); - - PresignedPutObjectRequest presignedRequest = s3Presigner.presignPutObject(presignRequest); - String uploadUrl = presignedRequest.url().toString(); - - updateProfileImage(userId, imageUrl); - - logger.info("Presigned URL 생성 완료: objectKey={}", objectKey); - - return Map.of( - "uploadUrl", uploadUrl, - "imageUrl", imageUrl - ); - } - - - private void validateNickname(String nickname) { - if (nickname.length() < 2 || nickname.length() > 20) { - throw UserException.invalidNickname(nickname, NICKNAME_MIN_LENGTH, NICKNAME_MAX_LENGTH); - } - } - - private void validateLevel(String level) { - if (!VALID_LEVELS.contains(level)) { - throw UserException.invalidLevel(level); - } - } - - private void validateImageContentType(String contentType) { - if (!VALID_IMAGE_TYPES.contains(contentType)) { - throw UserException.invalidImageType(contentType); - } - } - - - private String generateDefaultNickname() { - return java.util.UUID.randomUUID().toString().substring(0, 6).toUpperCase() + "님"; - } - - + + private static final Logger logger = LoggerFactory.getLogger(UserService.class); + private static final String BUCKET_NAME = System.getenv("PROFILE_BUCKET_NAME"); + private static final String DEFAULT_PROFILE_URL = "https://group2-englishstudy.s3.amazonaws.com/profile/default.png"; + private static final List VALID_LEVELS = Arrays.asList("BEGINNER", "INTERMEDIATE", "ADVANCED"); + + private static final List VALID_IMAGE_TYPES = Arrays.asList("image/jpeg", "image/png", "image/gif", "image/webp"); + private static final int NICKNAME_MIN_LENGTH = 2; + private static final int NICKNAME_MAX_LENGTH = 20; + + private final UserRepository userRepository; + private final S3Presigner s3Presigner; + + public UserService(UserRepository userRepository) { + this.userRepository = userRepository; + // AwsClients 싱글톤 사용 - Cold Start 최적화 + this.s3Presigner = AwsClients.s3Presigner(); + } + + /** + * 프로필 조회 + * DynamoDB에 없으면 request에서 claims 추출 → fallback 저장 + * + * @param userId Cognito sub + * @param request API Gateway 요청 (fallback 시 claims 추출용) + * @return User 객체 + */ + public User getProfile(String userId, APIGatewayProxyRequestEvent request) { + + return userRepository.findByCognitoSub(userId) + .map(user -> { + // 정상 DB에서 조회 완료 + user.updateLastLoginAt(); + userRepository.update(user); + return user; + }) + .orElseGet(() -> { + // PostConfirmation 실패 대비 fallback + return createUserFromRequest(userId, request); + }); + } + + /** + * request에서 Cognito claims 추출 후 사용자 생성 (fallback용) + */ + @SuppressWarnings("unchecked") + private User createUserFromRequest(String userId, APIGatewayProxyRequestEvent request) { + + Map claims = null; + try { + Map authorizer = request.getRequestContext().getAuthorizer(); + if (authorizer != null) { + claims = (Map) authorizer.get("claims"); + } + } catch (Exception e) { + logger.error("claims 추출 실패", e); + } + + // claims에서 정보 추출 + String email = claims != null ? claims.get("email") : "unknown@example.com"; + String nickname = claims != null ? claims.get("nickname") : null; + String level = claims != null ? claims.get("custom:level") : null; + String profileUrl = claims != null ? claims.get("custom:profileUrl") : null; + + User newUser = User.createNew( + userId, + email, + nickname != null ? nickname : generateDefaultNickname(), + level != null ? level : "BEGINNER", + profileUrl != null ? profileUrl : DEFAULT_PROFILE_URL + ); + + return userRepository.save(newUser); + } + + + /** + * 프로필 수정 (닉네임, 레벨) + * + * @param userId cognitoSub + * @param nickname 새 닉네임 (null이면 변경 안 함) + * @param level 새 레벨 (null이면 변경 안 함) + * @return 수정된 User 객체 + * @throws UserException USER_NOT_FOUND, INVALID_NICKNAME, INVALID_LEVEL + */ + public User updateProfile(String userId, String nickname, String level) { + logger.info("프로필 수정 요청: userId={}, nickname={}, level={}", userId, nickname, level); + + User user = userRepository.findByCognitoSub(userId) + .orElseThrow(() -> UserException.userNotFound(userId)); + + // 닉네임 수정 + if (nickname != null && !nickname.trim().isEmpty()) { + validateNickname(nickname); + user.updateNickname(nickname); + } + + // 레벨 수정 + if (level != null && !level.trim().isEmpty()) { + validateLevel(level); + user.updateLevel(level); + } + + User updatedUser = userRepository.update(user); + logger.info("프로필 수정 완료: email={}", updatedUser.getEmail()); + + return updatedUser; + } + + + /** + * 프로필 이미지 URL 업데이트 (업로드 완료 후 호출) + */ + public User updateProfileImage(String userId, String imageUrl) { + + User user = userRepository.findByCognitoSub(userId) + .orElseThrow(() -> UserException.userNotFound(userId)); + + user.updateProfileUrl(imageUrl); + return userRepository.update(user); + } + + /** + * 프로필 이미지 업로드를 위한 Presigned URL 발급 + * + * @param userId cognitoSub + * @param fileName 파일명 + * @param contentType MIME 타입 + * @return {uploadUrl, imageUrl} + * @throws UserException INVALID_IMAGE_TYPE + */ + public Map generateProfileImageUploadUrl(String userId, String fileName, String contentType) { + + validateImageContentType(contentType); + + String objectKey = String.format("profile/%s/%s", userId, fileName); + String imageUrl = String.format("https://%s.s3.amazonaws.com/%s", BUCKET_NAME, objectKey); + + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(BUCKET_NAME) + .key(objectKey) + .contentType(contentType) + .build(); + + PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder() + .signatureDuration(Duration.ofMinutes(10)) + .putObjectRequest(putObjectRequest) + .build(); + + PresignedPutObjectRequest presignedRequest = s3Presigner.presignPutObject(presignRequest); + String uploadUrl = presignedRequest.url().toString(); + + updateProfileImage(userId, imageUrl); + + logger.info("Presigned URL 생성 완료: objectKey={}", objectKey); + + return Map.of( + "uploadUrl", uploadUrl, + "imageUrl", imageUrl + ); + } + + + private void validateNickname(String nickname) { + if (nickname.length() < 2 || nickname.length() > 20) { + throw UserException.invalidNickname(nickname, NICKNAME_MIN_LENGTH, NICKNAME_MAX_LENGTH); + } + } + + private void validateLevel(String level) { + if (!VALID_LEVELS.contains(level)) { + throw UserException.invalidLevel(level); + } + } + + private void validateImageContentType(String contentType) { + if (!VALID_IMAGE_TYPES.contains(contentType)) { + throw UserException.invalidImageType(contentType); + } + } + + + private String generateDefaultNickname() { + return java.util.UUID.randomUUID().toString().substring(0, 6).toUpperCase() + "님"; + } + + } From 4fdef026b698c477b77480747d0fe53b1954f31d Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:31:38 +0900 Subject: [PATCH 294/528] =?UTF-8?q?refactor:=20VocabKey=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../serverless/domain/vocabulary/constants/VocabKey.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/constants/VocabKey.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/constants/VocabKey.java index 968809fc..bf59f3c1 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/constants/VocabKey.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/constants/VocabKey.java @@ -24,7 +24,7 @@ private VocabKey() { } // Key Builders (userPk는 DynamoDbKey.userPk() 사용) - + public static String wordPk(String wordId) { return WORD + wordId; } From e16cd44fb3d12df0dbdedfcb8c8d84d23f50f2e8 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:31:38 +0900 Subject: [PATCH 295/528] =?UTF-8?q?refactor:=20WordFactory=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/vocabulary/factory/WordFactory.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/factory/WordFactory.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/factory/WordFactory.java index 870d3963..828d19de 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/factory/WordFactory.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/factory/WordFactory.java @@ -12,10 +12,10 @@ * 객체 생성 로직을 중앙 집중화하여 일관성 유지 */ public class WordFactory { - + private static final String DEFAULT_LEVEL = "BEGINNER"; private static final String DEFAULT_CATEGORY = "DAILY"; - + /** * 새 Word 엔티티 생성 */ @@ -24,7 +24,7 @@ public Word create(String english, String korean, String example, String level, String now = Instant.now().toString(); String resolvedLevel = level != null ? level : DEFAULT_LEVEL; String resolvedCategory = category != null ? category : DEFAULT_CATEGORY; - + return Word.builder() .pk(VocabKey.wordPk(wordId)) .sk(DynamoDbKey.METADATA) @@ -41,14 +41,14 @@ public Word create(String english, String korean, String example, String level, .createdAt(now) .build(); } - + /** * 기본값으로 Word 생성 */ public Word create(String english, String korean, String example) { return create(english, korean, example, DEFAULT_LEVEL, DEFAULT_CATEGORY); } - + /** * Word 엔티티 업데이트 (GSI 키 자동 갱신) */ From d885bfdaa17976bae19ecccf29ceae25a933970a Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:31:38 +0900 Subject: [PATCH 296/528] =?UTF-8?q?refactor:=20DailyStudyRepository=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/vocabulary/repository/DailyStudyRepository.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/DailyStudyRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/DailyStudyRepository.java index 29c98a91..5b3e9e81 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/DailyStudyRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/DailyStudyRepository.java @@ -41,7 +41,7 @@ public Optional findByUserIdAndDate(String userId, String date) { .partitionValue("DAILY#" + userId) .sortValue("DATE#" + date) .build(); - + // Strongly consistent read for accurate data after updates DailyStudy dailyStudy = table.getItem(r -> r.key(key).consistentRead(true)); return Optional.ofNullable(dailyStudy); From 70263dfaa740eb316a5c8f694159c2eb567e1082 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:31:45 +0900 Subject: [PATCH 297/528] =?UTF-8?q?refactor:=20WordService=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vocabulary/service/WordService.java | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordService.java index a2105e9f..bfea0b71 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordService.java @@ -7,22 +7,24 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.util.*; +import java.util.List; +import java.util.Map; +import java.util.Optional; public class WordService { - + private static final Logger logger = LoggerFactory.getLogger(WordService.class); - + private final WordRepository wordRepository; private final WordFactory wordFactory; - + /** * 기본 생성자 (Lambda에서 사용) */ public WordService() { this(new WordRepository(), new WordFactory()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ @@ -56,7 +58,7 @@ public Word updateWord(String wordId, Map updates) { if (optWord.isEmpty()) { throw new IllegalArgumentException("Word not found"); } - + Word word = optWord.get(); wordFactory.updateFields( word, @@ -66,7 +68,7 @@ public Word updateWord(String wordId, Map updates) { (String) updates.get("level"), (String) updates.get("category") ); - + wordRepository.save(word); logger.info("Updated word: {}", wordId); return word; @@ -85,7 +87,7 @@ public void deleteWord(String wordId) { public BatchResult createWordsBatch(List> wordsList) { int successCount = 0; int failCount = 0; - + for (Map wordData : wordsList) { try { String english = (String) wordData.get("english"); @@ -93,12 +95,12 @@ public BatchResult createWordsBatch(List> wordsList) { String example = (String) wordData.get("example"); String level = (String) wordData.get("level"); String category = (String) wordData.get("category"); - + if (english == null || korean == null) { failCount++; continue; } - + Word word = wordFactory.create(english, korean, example, level, category); wordRepository.save(word); successCount++; @@ -107,7 +109,7 @@ public BatchResult createWordsBatch(List> wordsList) { failCount++; } } - + logger.info("Batch created {} words, failed {}", successCount, failCount); return new BatchResult(successCount, failCount, wordsList.size()); } From ac86eafb739e6e7dd2ab1c16f1cd3d9276b637e1 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:31:45 +0900 Subject: [PATCH 298/528] =?UTF-8?q?docs:=20=EC=A4=91=EA=B0=84=20=EB=B3=B4?= =?UTF-8?q?=EA=B3=A0=EC=84=9C=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/MIDTERM-REPORT.md | 526 ++++++++++++++++++++++------------------- 1 file changed, 280 insertions(+), 246 deletions(-) diff --git a/docs/MIDTERM-REPORT.md b/docs/MIDTERM-REPORT.md index 2b382f40..9a6bb1d1 100644 --- a/docs/MIDTERM-REPORT.md +++ b/docs/MIDTERM-REPORT.md @@ -1,12 +1,13 @@ -# 영어 학습 플랫폼 백엔드 중간 성과 보고서 +# 영어 학습 플랫폼 백엔드 최종 성과 보고서 ## 프로젝트 개요 -| 항목 | 내용 | -|------|------| -| 프로젝트명 | 영어 회화 학습 플랫폼 (MZC 2nd Project) | -| 담당 영역 | Vocabulary, Chatting, Grammar, Badge, Stats, Common | +| 항목 | 내용 | +|-------|--------------------------------------------------------------------------| +| 프로젝트명 | 영어 회화 학습 플랫폼 (MZC 2nd Project) | +| 담당 영역 | Vocabulary, Chatting, Grammar, Badge, Stats, Common | | 기술 스택 | Java 21, AWS Lambda, DynamoDB, API Gateway WebSocket, Bedrock, Polly, S3 | +| 배포 환경 | AWS SAM, CloudFormation | --- @@ -21,331 +22,360 @@ flowchart TB subgraph Gateway["API Gateway"] REST[REST API] WS[WebSocket API] + GRAMMAR_WS[Grammar WebSocket] end subgraph Lambda["AWS Lambda - 도메인별 핸들러"] direction TB - VOCAB[Vocabulary
단어 학습] - CHAT[Chatting
실시간 채팅] - GRAMMAR[Grammar
문법 체크] + VOCAB[Vocabulary
단어/일일학습/테스트] + CHAT[Chatting
실시간 채팅/게임] + GRAMMAR[Grammar
문법 체크/스트리밍] STATS[Stats
통계 집계] BADGE[Badge
배지 시스템] + USER[User
사용자 관리] end subgraph AI["AI Services"] - BEDROCK[AWS Bedrock
Claude/Llama] + BEDROCK[AWS Bedrock
Claude 3.5 Sonnet] POLLY[AWS Polly
TTS] end subgraph Data["Data Layer"] - DYNAMO[(DynamoDB
Single Table)] - S3[(S3
음성/이미지)] + DYNAMO_VOCAB[(DynamoDB
Vocab Table)] + DYNAMO_CHAT[(DynamoDB
Chat Table)] + S3[(S3
음성/뱃지 이미지)] STREAMS[DynamoDB Streams] end WEB --> REST WEB --> WS - + WEB --> GRAMMAR_WS REST --> VOCAB REST --> CHAT REST --> GRAMMAR REST --> BADGE + REST --> STATS + REST --> USER WS --> CHAT - WS --> GRAMMAR - - VOCAB --> DYNAMO + GRAMMAR_WS --> GRAMMAR + VOCAB --> DYNAMO_VOCAB VOCAB --> POLLY VOCAB --> S3 - CHAT --> DYNAMO + CHAT --> DYNAMO_CHAT CHAT --> BEDROCK - CHAT --> POLLY - GRAMMAR --> DYNAMO + GRAMMAR --> DYNAMO_VOCAB GRAMMAR --> BEDROCK - STATS --> DYNAMO - BADGE --> DYNAMO - + STATS --> DYNAMO_VOCAB + BADGE --> DYNAMO_VOCAB + BADGE --> S3 STREAMS -->|이벤트 트리거| STATS STATS -->|배지 부여| BADGE ``` --- -## 2. 담당 도메인별 구현 특징 +## 2. 주요 기능 구현 ### 2.1 Vocabulary Domain (단어 학습) -**핵심 구현:** SM-2 Spaced Repetition 알고리즘 + State 패턴 +#### 2.1.1 일일 학습 시스템 (Daily Study) ```mermaid flowchart LR - subgraph Algorithm["SM-2 알고리즘"] - A[정답] --> B{연속 정답 횟수} - B -->|1회| C[interval = 1일] - B -->|2회| D[interval = 6일] - B -->|3회+| E[interval * easeFactor] + subgraph DailyStudy["일일 학습 흐름"] + A[오늘의 단어 조회] --> B{기존 학습 존재?} + B -->|Yes| C[기존 학습 반환] + B -->|No| D[새 단어 50개 + 복습 5개 생성] + D --> E[학습 진행] + E --> F[단어별 학습 완료 처리] + F --> G{50개 완료?} + G -->|Yes| H[isCompleted = true] end ``` -**기술적 장점:** -- **State 패턴 적용**: 학습 상태(NEW→LEARNING→REVIEWING→MASTERED) 전이를 객체지향적으로 설계하여 복잡한 조건문 제거 -- **easeFactor 동적 조정**: 사용자별 난이도에 맞춰 복습 간격 개인화 (1.3 ~ 2.5) -- **TTS 캐싱 전략**: AWS Polly 음성을 S3에 캐싱하여 중복 API 호출 비용 90% 절감 -- **배치 처리**: 최대 100개 단어 일괄 생성/조회로 API 호출 횟수 최소화 +**주요 기능:** ---- - -### 2.2 Chatting Domain (실시간 채팅) +- 레벨별 신규 단어 50개 + 복습 단어 5개 자동 선정 +- 학습 진행도 실시간 추적 (learnedCount/totalWords) +- 일일 학습 완료 시 isCompleted 플래그 설정 -**핵심 구현:** WebSocket + RoomToken 인증 + 캐치마인드 게임 +#### 2.1.2 SM-2 Spaced Repetition 알고리즘 ```mermaid -flowchart LR - subgraph Auth["토큰 기반 인증"] - A[REST API] -->|토큰 발급| B[RoomToken] - B -->|5분 TTL| C[WebSocket 연결] - end +stateDiagram-v2 + [*] --> NEW: 단어 추가 + NEW --> LEARNING: 첫 학습 + LEARNING --> LEARNING: 오답 + LEARNING --> REVIEWING: 2회 연속 정답 + REVIEWING --> LEARNING: 오답 + REVIEWING --> MASTERED: 5회 연속 정답 + MASTERED --> LEARNING: 오답 + MASTERED --> MASTERED: 정답 유지 ``` -**기술적 장점:** -- **RoomToken 인증**: REST에서 발급한 단기 토큰(TTL 5분)으로 WebSocket 연결 검증 - 헤더 인증 불가 문제 해결 -- **Connection 자동 정리**: TTL 10분 + 브로드캐스트 실패 시 즉시 삭제로 좀비 연결 방지 -- **BCrypt 비밀방**: 평문 저장 없이 해시값만 저장하여 보안 강화 -- **캐치마인드 실시간 동기화**: WebSocket을 통한 게임 상태 브로드캐스트로 지연 없는 멀티플레이어 경험 +**구현 특징:** + +- State 패턴으로 학습 상태 전이 관리 +- easeFactor 동적 조정 (1.3 ~ 2.5) +- 복습 간격 자동 계산 (1일 → 6일 → interval * easeFactor) + +#### 2.1.3 TTS 음성 생성 + +- AWS Polly 연동 (남성/여성 음성) +- S3 캐싱으로 중복 생성 방지 +- 단어 + 예문 음성 생성 --- -### 2.3 Grammar Domain (문법 체크) +### 2.2 Chatting Domain (실시간 채팅 & 게임) -**핵심 구현:** AI 스트리밍 응답 + Factory 패턴 +#### 2.2.1 WebSocket 채팅 ```mermaid -flowchart LR - subgraph Streaming["스트리밍 응답"] - A[Bedrock] -->|청크| B[Lambda] - B -->|즉시 전송| C[WebSocket] - C -->|실시간| D[클라이언트] - end +sequenceDiagram + participant Client + participant REST as REST API + participant WS as WebSocket API + participant DB as DynamoDB + Note over Client, DB: Phase 1: 방 입장 토큰 발급 + Client ->> REST: POST /rooms/{id}/join + REST ->> DB: RoomToken 저장 (TTL: 5분) + REST -->> Client: roomToken 반환 + Note over Client, DB: Phase 2: WebSocket 연결 + Client ->> WS: $connect?roomToken={token} + WS ->> DB: 토큰 검증 + Connection 저장 + WS -->> Client: 연결 성공 + Note over Client, DB: Phase 3: 메시지 송수신 + Client ->> WS: sendmessage (채팅) + WS ->> DB: 메시지 저장 + 브로드캐스트 ``` -**기술적 장점:** -- **AI 스트리밍**: 응답을 청크 단위로 실시간 전송하여 사용자 체감 대기 시간 80% 감소 (ChatGPT UX) -- **Factory 패턴**: `BedrockGrammarCheckFactory`로 AI 서비스 교체 용이 (Claude ↔ Llama) -- **세션 컨텍스트 유지**: 대화 히스토리를 DynamoDB에 저장하여 문맥 기반 피드백 제공 -- **레벨별 프롬프트**: BEGINNER는 한국어 번역 포함, ADVANCED는 상세 문법 규칙 설명 - ---- +**주요 기능:** -### 2.4 Stats & Badge Domain (통계/배지) +- RoomToken 기반 인증 (TTL 5분) +- BCrypt 비밀방 암호화 +- 슬래시 명령어 시스템 (/member, /game, /skip, /hint 등) +- Connection 자동 정리 (TTL + 실패 시 삭제) -**핵심 구현:** DynamoDB Streams 이벤트 기반 아키텍처 +#### 2.2.2 캐치마인드 게임 ```mermaid -flowchart LR - subgraph EventDriven["이벤트 기반"] - A[TestResult 저장] -->|INSERT| B[DynamoDB Streams] - B -->|트리거| C[StatsStreamHandler] - C --> D[통계 집계] - C --> E[배지 부여] +flowchart TB + subgraph Game["캐치마인드 게임 흐름"] + START["#47;game 명령어"] --> INIT["게임 초기화
출제 순서 셔플"] + INIT --> ROUND[라운드 시작
출제자 + 단어 선정] + ROUND --> DRAW[출제자 그림 그리기] + DRAW --> GUESS[참가자 정답 입력] + GUESS --> CHECK{정답?} + CHECK -->|Yes| SCORE[점수 계산
시간보너스 + 연속정답보너스] + CHECK -->|No| GUESS + SCORE --> ALLCORRECT{전원 정답?} + ALLCORRECT -->|Yes| NEXTROUND + ALLCORRECT -->|No| TIMEOUT{시간 초과?} + TIMEOUT -->|Yes| NEXTROUND[다음 라운드] + TIMEOUT -->|No| GUESS + NEXTROUND --> LASTROUND{마지막 라운드?} + LASTROUND -->|Yes| END[게임 종료
순위 발표] + LASTROUND -->|No| ROUND end ``` -**기술적 장점:** -- **비동기 통계 집계**: API 응답에서 통계 로직 분리로 응답 속도 50% 향상 -- **느슨한 결합**: 테스트 도메인은 통계/배지 도메인 존재를 모름 - 독립적 배포 가능 -- **자동 배지 부여**: 조건 달성 시 사용자 개입 없이 실시간 배지 지급 -- **학습 스트릭**: 연속 학습일 자동 계산으로 사용자 동기 부여 +**점수 계산:** + +``` +점수 = 기본점수(10) + 시간보너스((60-경과초)*0.5) + 연속정답보너스(streak*2) +출제자 보너스 = 정답자당 5점 +``` + +**주요 기능:** + +- 실시간 점수 브로드캐스트 +- 연속 정답 스트릭 시스템 +- 접속자 변동 시 출제자 자동 재선정 +- 라운드별 순위 표시 --- -## 3. 기술적 성과 (Technical Highlights) +### 2.3 Grammar Domain (문법 체크) -### 3.1 CQRS 패턴 전면 적용 +#### 2.3.1 AI 스트리밍 응답 ```mermaid -flowchart LR - subgraph Command["Command (쓰기)"] - CMD1[WordCommandService] - CMD2[UserWordCommandService] - CMD3[ChatRoomCommandService] - end +sequenceDiagram + participant Client + participant WS as Grammar WebSocket + participant Handler as GrammarStreamingHandler + participant Bedrock as AWS Bedrock + Client ->> WS: 문법 체크 요청 + WS ->> Handler: Lambda 호출 + Handler ->> Bedrock: 스트리밍 요청 (Claude 3.5 Sonnet) - subgraph Query["Query (읽기)"] - QRY1[WordQueryService] - QRY2[UserWordQueryService] - QRY3[ChatRoomQueryService] + loop 청크 단위 응답 + Bedrock -->> Handler: 텍스트 청크 + Handler -->> WS: 실시간 전송 + WS -->> Client: 즉시 표시 end - Handler --> Command - Handler --> Query - Command --> Repository - Query --> Repository + Handler -->> Client: [DONE] 완료 + Handler ->> DB: 피드백 저장 ``` -**적용 효과:** -- 읽기/쓰기 책임 분리로 코드 복잡도 감소 -- 독립적인 스케일링 가능성 확보 -- 테스트 용이성 향상 +**주요 기능:** + +- Claude 3.5 Sonnet 모델 사용 +- 스트리밍으로 체감 대기 시간 80% 감소 +- 레벨별 맞춤 프롬프트 (BEGINNER: 한국어 번역 포함) +- 대화 히스토리 저장으로 문맥 유지 +- 피드백 영구 저장 (DynamoDB) --- -### 3.2 State 디자인 패턴 (Spaced Repetition) +### 2.4 Stats Domain (학습 통계) ```mermaid -stateDiagram-v2 - [*] --> NEW: 단어 추가 - NEW --> LEARNING: 첫 학습 - LEARNING --> LEARNING: 오답 - LEARNING --> REVIEWING: 2회 연속 정답 - REVIEWING --> LEARNING: 오답 - REVIEWING --> MASTERED: 5회 연속 정답 - MASTERED --> LEARNING: 오답 - MASTERED --> MASTERED: 정답 유지 +flowchart LR + subgraph StatsTypes["통계 유형"] + DAILY["일별 통계
#47;stats#47;daily"] + WEEKLY["주별 통계
#47;stats#47;weekly"] + MONTHLY["월별 통계
#47;stats#47;monthly"] + TOTAL["전체 통계
#47;stats#47;total"] + HISTORY["히스토리
#47;stats#47;history"] + end ``` -**구현 특징:** -- `WordState` 인터페이스 + 4개 구체 클래스 (NEW, LEARNING, REVIEWING, MASTERED) -- SM-2 알고리즘 기반 복습 간격 계산 -- easeFactor 동적 조정으로 개인화된 학습 +**통계 항목:** ---- - -### 3.3 DynamoDB Single Table Design + GSI +| 필드 | 설명 | +|-------------------|-------------| +| testsCompleted | 완료한 테스트 수 | +| questionsAnswered | 답변한 문제 수 | +| correctAnswers | 정답 수 | +| incorrectAnswers | 오답 수 | +| successRate | 정답률 (%) | +| newWordsLearned | 새로 학습한 단어 수 | +| wordsReviewed | 복습한 단어 수 | +| currentStreak | 현재 연속 학습일 | +| longestStreak | 최장 연속 학습일 | +| gamesPlayed | 참여한 게임 수 | +| gamesWon | 1등 횟수 | +| totalGameScore | 누적 게임 점수 | -```mermaid -erDiagram - SingleTable ||--o{ Word : "PK=WORD#id" - SingleTable ||--o{ UserWord : "PK=USER#id" - SingleTable ||--o{ TestResult : "PK=TEST#id" - SingleTable ||--o{ ChatRoom : "PK=ROOM#id" - SingleTable ||--o{ ChatMessage : "PK=ROOM#id SK=MSG#ts" - SingleTable ||--o{ Connection : "PK=CONN#id" - - SingleTable { - string PartitionKey "파티션 키" - string SortKey "정렬 키" - string GSI1PartitionKey "보조 인덱스 1 PK" - string GSI1SortKey "보조 인덱스 1 SK" - string GSI2PartitionKey "보조 인덱스 2 PK" - string GSI2SortKey "보조 인덱스 2 SK" - } -``` +**DynamoDB Streams 기반 비동기 집계:** -**적용 효과:** -- 단일 테이블로 6개 도메인 데이터 관리 -- GSI를 통한 다양한 액세스 패턴 지원 -- PAY_PER_REQUEST로 비용 최적화 +- 테스트 결과 저장 시 자동 트리거 +- API 응답과 분리되어 응답 속도 향상 --- -### 3.4 이벤트 기반 아키텍처 (DynamoDB Streams) +### 2.5 Badge Domain (배지 시스템) ```mermaid -sequenceDiagram - participant Client - participant TestHandler - participant DynamoDB - participant Streams - participant StatsStreamHandler - participant BadgeService - - Client->>TestHandler: 시험 제출 - TestHandler->>DynamoDB: TestResult 저장 - DynamoDB->>Streams: INSERT 이벤트 발생 - Streams->>StatsStreamHandler: 트리거 실행 - StatsStreamHandler->>StatsStreamHandler: 통계 집계 - StatsStreamHandler->>BadgeService: 배지 조건 체크 - BadgeService->>DynamoDB: 배지 부여 +flowchart TB + subgraph BadgeSystem["배지 시스템"] + TRIGGER[통계 업데이트] --> CHECK[배지 조건 체크] + CHECK --> AWARD{조건 달성?} + AWARD -->|Yes| SAVE[배지 부여 + 저장] + AWARD -->|No| END[종료] + SAVE --> NOTIFY[프론트엔드 조회] + end ``` -**적용 효과:** -- 비동기 통계 집계로 API 응답 속도 향상 -- 느슨한 결합 (Loose Coupling) -- 자동 배지 부여 시스템 +**배지 종류:** ---- +| Badge Type | 이름 | 조건 | +|----------------------|---------|------------| +| FIRST_STEP | 첫 걸음 | 첫 학습 완료 | +| STREAK_3, 7, 30 | 연속 학습 | N일 연속 학습 | +| WORDS_100, 500, 1000 | 단어 학습 | N개 단어 학습 | +| PERFECT_SCORE | 완벽주의자 | 테스트 만점 | +| ACCURACY_90 | 정확도 달인 | 전체 정확도 90% | +| GAME_FIRST_PLAY | 첫 게임 | 첫 게임 참여 | +| GAME_10_WINS | 게임 10승 | 10번 1등 | +| QUICK_GUESSER | 번개 정답 | 5초 내 정답 | +| PERFECT_DRAWER | 완벽한 출제자 | 전원 정답 유도 | -### 3.5 WebSocket 토큰 기반 인증 +**기술적 특징:** -```mermaid -sequenceDiagram - participant Client - participant REST as REST API - participant WS as WebSocket API - participant DB as DynamoDB +- S3 Presigned URL로 배지 이미지 제공 (1시간 유효) +- 획득/미획득 배지 + 진행도 표시 - Note over Client,DB: Phase 1: REST로 토큰 발급 - Client->>REST: POST /rooms/{id}/join - REST->>DB: RoomToken 저장 (TTL: 5분) - REST-->>Client: roomToken 반환 +--- - Note over Client,DB: Phase 2: WebSocket 연결 - Client->>WS: $connect?roomToken={token} - WS->>DB: 토큰 검증 - DB-->>WS: Valid - WS-->>Client: 연결 성공 -``` +## 3. 기술적 성과 -**해결한 문제:** -- WebSocket은 헤더 기반 인증이 어려움 -- REST API에서 단기 토큰 발급 후 WebSocket 연결 시 검증 -- TTL 5분으로 토큰 탈취 위험 최소화 +### 3.1 아키텍처 패턴 ---- +| 패턴 | 적용 영역 | 효과 | +|------------------|----------|----------------------------| +| **CQRS** | 전 도메인 | 읽기/쓰기 책임 분리, 테스트 용이성 | +| **State** | 단어 학습 상태 | 복잡한 조건문 제거, 확장성 | +| **Factory** | AI 서비스 | 서비스 교체 용이 (Claude ↔ Llama) | +| **Event-Driven** | 통계/배지 | 느슨한 결합, 비동기 처리 | -### 3.6 AI 스트리밍 응답 (Grammar) +### 3.2 DynamoDB 설계 -```mermaid -sequenceDiagram - participant Client - participant WS as WebSocket - participant Handler as GrammarStreamingHandler - participant Bedrock as AWS Bedrock +**Single Table Design:** - Client->>WS: 문법 체크 요청 - WS->>Handler: Lambda 호출 - Handler->>Bedrock: 스트리밍 요청 +- Vocab Table: 단어, 사용자단어, 테스트, 일일학습, 통계, 배지, 문법 +- Chat Table: 채팅방, 메시지, 연결, 게임라운드 - loop 청크 단위 응답 - Bedrock-->>Handler: 텍스트 청크 - Handler-->>WS: 실시간 전송 - WS-->>Client: 즉시 표시 - end +**GSI 구성:** - Handler-->>Client: [DONE] 완료 -``` +| GSI | 용도 | +|------|---------------------| +| GSI1 | 레벨별 단어 조회, 복습 예정 단어 | +| GSI2 | 카테고리별 단어, 상태별 사용자단어 | +| GSI3 | 북마크 단어 조회 | -**사용자 경험 향상:** -- 응답 대기 시간 체감 감소 -- 타이핑 효과로 자연스러운 AI 응답 -- ChatGPT와 유사한 UX 제공 +### 3.3 보안 ---- +- Cognito 인증 (idToken) +- WebSocket RoomToken 인증 (TTL 5분) +- BCrypt 비밀방 암호화 +- S3 Presigned URL (배지 이미지) -## 4. 공통 모듈 설계 +### 3.4 성능 최적화 -```mermaid -flowchart TB - subgraph Common["공통 모듈"] - ROUTER[HandlerRouter
라우팅 + 예외처리] - RESPONSE[ResponseGenerator
응답 표준화] - CURSOR[CursorUtil
페이지네이션] - EXCEPTION[ServerlessException
도메인 예외] - BROADCASTER[WebSocketBroadcaster
브로드캐스트] - AWSCLIENTS[AwsClients
싱글톤 클라이언트] - end +| 최적화 | 효과 | +|--------------------------|-------------------------| +| TTS S3 캐싱 | Polly API 호출 90% 절감 | +| 배치 처리 | 최대 100개 단어 일괄 처리 | +| Strongly Consistent Read | 데이터 정합성 보장 | +| DynamoDB Streams | 비동기 통계 집계로 응답 속도 50% 향상 | +| AI 스트리밍 | 체감 대기 시간 80% 감소 | - Handler --> ROUTER - ROUTER --> RESPONSE - ROUTER --> CURSOR - ROUTER --> EXCEPTION - Handler --> BROADCASTER - Handler --> AWSCLIENTS -``` +--- -**설계 원칙:** -- DRY (Don't Repeat Yourself) -- Cold Start 최적화 (싱글톤 AWS 클라이언트) -- 일관된 응답 형식 +## 4. API 엔드포인트 요약 + +### REST API (https://gc8l9ijhzc.execute-api.ap-northeast-2.amazonaws.com/dev) + +| Method | Path | 설명 | +|--------|-------------------------------------|-----------| +| GET | /vocab/words | 단어 목록 조회 | +| POST | /vocab/words | 단어 등록 | +| GET | /vocab/daily | 오늘의 학습 단어 | +| POST | /vocab/daily/words/{wordId}/learned | 단어 학습 완료 | +| POST | /vocab/tests | 테스트 생성 | +| POST | /vocab/tests/{testId}/submit | 테스트 제출 | +| GET | /stats/daily | 일별 통계 | +| GET | /stats/weekly | 주별 통계 | +| GET | /stats/monthly | 월별 통계 | +| GET | /stats/total | 전체 통계 | +| GET | /stats/history?limit=100 | 통계 히스토리 | +| GET | /badges | 전체 배지 목록 | +| GET | /badges/earned | 획득한 배지 | +| GET | /rooms | 채팅방 목록 | +| POST | /rooms | 채팅방 생성 | +| POST | /rooms/{roomId}/join | 채팅방 입장 | +| POST | /grammar/check | 문법 체크 | + +### WebSocket API + +| Endpoint | 설명 | +|---------------------------------------------------------------|---------| +| wss://t378dif43l.execute-api.ap-northeast-2.amazonaws.com/dev | 채팅/게임 | +| wss://ltrccmteo8.execute-api.ap-northeast-2.amazonaws.com/dev | 문법 스트리밍 | --- @@ -354,52 +384,56 @@ flowchart TB ``` ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/ ├── common/ # 공통 모듈 -│ ├── config/ # AWS 클라이언트, 설정 +│ ├── config/ # AWS 클라이언트 (싱글톤) │ ├── router/ # HandlerRouter, Route │ ├── exception/ # 예외 처리 체계 -│ ├── dto/ # 공통 DTO -│ └── util/ # 유틸리티 +│ ├── dto/ # PaginatedResult, ErrorInfo +│ └── util/ # ResponseGenerator, CursorUtil │ ├── domain/ │ ├── vocabulary/ # 단어 학습 도메인 -│ │ ├── handler/ # 7개 핸들러 -│ │ ├── service/ # 14개 서비스 (CQRS) -│ │ ├── repository/ # 5개 레포지토리 -│ │ ├── model/ # 5개 엔티티 -│ │ └── state/ # State 패턴 (5개) +│ │ ├── handler/ # Word, UserWord, Test, DailyStudy 핸들러 +│ │ ├── service/ # CQRS 서비스 (Command/Query) +│ │ ├── repository/ # DynamoDB 레포지토리 +│ │ ├── model/ # Word, UserWord, TestResult, DailyStudy +│ │ └── state/ # NEW, LEARNING, REVIEWING, MASTERED │ │ │ ├── chatting/ # 채팅 도메인 │ │ ├── handler/ # REST + WebSocket 핸들러 -│ │ ├── service/ # CQRS 서비스 -│ │ └── model/ # 4개 엔티티 +│ │ ├── service/ # ChatRoom, Game, Command 서비스 +│ │ └── model/ # ChatRoom, Connection, GameRound │ │ │ ├── grammar/ # 문법 체크 도메인 │ │ ├── handler/ # REST + 스트리밍 핸들러 -│ │ ├── service/ # 문법 체크, 대화 서비스 -│ │ └── factory/ # Factory 패턴 +│ │ ├── service/ # GrammarCheck, Conversation 서비스 +│ │ └── factory/ # BedrockGrammarCheckFactory │ │ │ ├── stats/ # 통계 도메인 -│ │ └── handler/ # Streams 핸들러 +│ │ ├── handler/ # UserStats, Streams 핸들러 +│ │ └── repository/ # UserStatsRepository │ │ │ └── badge/ # 배지 도메인 +│ ├── handler/ # BadgeHandler +│ └── service/ # BadgeService ``` --- ## 6. 성과 요약 -| 카테고리 | 성과 | -|----------|------| -| **아키텍처 패턴** | CQRS, State, Factory 패턴 적용 | -| **데이터베이스** | Single Table Design + 5개 GSI | -| **실시간 통신** | WebSocket + 토큰 인증 | -| **AI 연동** | Bedrock (문법/대화), Polly (TTS) | -| **이벤트 기반** | DynamoDB Streams → 자동 통계/배지 | -| **코드 품질** | 공통 모듈화, 일관된 예외 처리 | - - +| 카테고리 | 성과 | +|------------------|------------------------------------| +| **Lambda 함수** | 26개 | +| **API 엔드포인트** | REST 40+, WebSocket 2 | +| **DynamoDB 테이블** | 2개 (Single Table Design) | +| **GSI** | 5개 | +| **아키텍처 패턴** | CQRS, State, Factory, Event-Driven | +| **AI 연동** | Bedrock Claude 3.5 Sonnet (문법/대화) | +| **TTS** | AWS Polly (남성/여성 음성) | +| **실시간 통신** | WebSocket (채팅/게임/문법 스트리밍) | +| **인증** | Cognito + RoomToken | --- -**작성일:** 2026-01-15 -**팀:** MZC 2nd Project Team / SMJ +**작성일:** 2026-01-16 +**팀:** MZC 2nd Project Team / SMJ From 03aebc8415e2fe06005ff73970c241f925206a25 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:31:45 +0900 Subject: [PATCH 299/528] =?UTF-8?q?chore:=20CHATTING-GUIDE.md=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/chatting/CHATTING-GUIDE.md | 1140 ------------------------------- 1 file changed, 1140 deletions(-) delete mode 100644 docs/chatting/CHATTING-GUIDE.md diff --git a/docs/chatting/CHATTING-GUIDE.md b/docs/chatting/CHATTING-GUIDE.md deleted file mode 100644 index 2dc59e80..00000000 --- a/docs/chatting/CHATTING-GUIDE.md +++ /dev/null @@ -1,1140 +0,0 @@ -# Chatting Server 가이드 문서 - -## 1. 개요 - -### 1.1 목적 - -Chatting Server는 영어 회화 학습 플랫폼의 실시간 채팅 기능을 담당하는 서버리스 마이크로서비스이다. 사용자들이 영어 난이도별 채팅방에 참여하여 실시간으로 대화하고, AI 응답 및 TTS 기능을 활용할 수 -있다. - -### 1.2 주요 기능 - -| 기능 | 설명 | -|-------------|------------------------------------| -| 채팅방 관리 | 생성, 조회, 입장, 퇴장, 삭제 | -| 실시간 메시징 | WebSocket 기반 양방향 통신 | -| 토큰 인증 | REST → WebSocket 전환 시 RoomToken 검증 | -| 난이도별 필터링 | BEGINNER, INTERMEDIATE, ADVANCED | -| AI 응답 | AWS Bedrock 기반 AI 메시지 생성 | -| TTS (음성 합성) | AWS Polly 기반 음성 변환 | -| 비밀방 | BCrypt 암호화 비밀번호 지원 | -| 캐치마인드 게임 | 실시간 그림 맞추기 게임 | - -### 1.3 기술 스택 - -| 구분 | 기술 | -|-----------|------------------------------------| -| Platform | AWS Lambda (Serverless) | -| Language | Java 21 (Eclipse Temurin) | -| Database | AWS DynamoDB (Single Table Design) | -| Real-time | API Gateway WebSocket | -| AI | AWS Bedrock (Claude/Llama) | -| TTS | AWS Polly | -| Storage | AWS S3 (음성 캐시) | - ---- - -## 2. 시스템 아키텍처 - -### 2.1 전체 구조 - -```mermaid -flowchart TB - subgraph Client - APP[Mobile App] - WEB[Web Client] - end - - subgraph AWS_Gateway["API Gateway"] - REST[HTTP API] - WS[WebSocket API] - end - - subgraph Lambda["AWS Lambda"] - ROOM_H[ChatRoomHandler] - MSG_H[ChatMessageHandler] - AI_H[ChatAIHandler] - VOICE_H[ChatVoiceHandler] - WS_CONN[WebSocketConnectHandler] - WS_MSG[WebSocketMessageHandler] - WS_DISC[WebSocketDisconnectHandler] - end - - subgraph Data_Layer["Data Layer"] - DYNAMO[(DynamoDB)] - S3[(S3 Bucket)] - end - - subgraph AWS_AI["AI Services"] - BEDROCK[AWS Bedrock] - POLLY[AWS Polly] - end - - APP --> REST - WEB --> REST - APP --> WS - WEB --> WS - - REST --> ROOM_H - REST --> MSG_H - REST --> AI_H - REST --> VOICE_H - - WS -->|$connect| WS_CONN - WS -->|sendMessage| WS_MSG - WS -->|$disconnect| WS_DISC - - ROOM_H --> DYNAMO - MSG_H --> DYNAMO - WS_CONN --> DYNAMO - WS_MSG --> DYNAMO - WS_DISC --> DYNAMO - - AI_H --> BEDROCK - VOICE_H --> POLLY - VOICE_H --> S3 -``` - -### 2.2 레이어 아키텍처 - -```mermaid -flowchart TB - subgraph Presentation["Presentation Layer"] - HANDLER[Lambda Handlers] - ROUTER[HandlerRouter] - DTO[Request/Response DTOs] - end - - subgraph Application["Application Layer (CQRS)"] - CMD[Command Services] - QRY[Query Services] - end - - subgraph Domain["Domain Layer"] - MODEL[Models] - REPO[Repositories] - end - - subgraph Infrastructure["Infrastructure Layer"] - DYNAMO_CLIENT[DynamoDB Enhanced Client] - S3_CLIENT[S3 Client] - BROADCASTER[WebSocket Broadcaster] - end - - HANDLER --> ROUTER - ROUTER --> CMD - ROUTER --> QRY - CMD --> MODEL - QRY --> MODEL - MODEL --> REPO - REPO --> DYNAMO_CLIENT - HANDLER --> BROADCASTER - BROADCASTER --> WS_API[API Gateway Management API] -``` - -### 2.3 채팅방 생성 흐름 - -```mermaid -sequenceDiagram - participant C as Client - participant GW as API Gateway - participant H as ChatRoomHandler - participant CMD as ChatRoomCommandService - participant REPO as ChatRoomRepository - participant DB as DynamoDB - - C->>GW: POST /rooms - Note over C,GW: {name, level, maxMembers, isPrivate, password} - GW->>H: Lambda Invoke - H->>H: Request Validation - H->>CMD: createRoom(...) - CMD->>CMD: Generate UUID - CMD->>CMD: BCrypt Hash Password (if private) - CMD->>CMD: Build ChatRoom Entity - CMD->>REPO: save(room) - REPO->>DB: PutItem - Note over REPO,DB: PK=ROOM#{roomId}
GSI1PK=ROOMS - DB-->>REPO: Success - REPO-->>CMD: ChatRoom - CMD-->>H: ChatRoom - H-->>GW: 201 Created - GW-->>C: {success: true, data: room} -``` - -### 2.4 채팅방 입장 및 WebSocket 연결 흐름 - -```mermaid -sequenceDiagram - participant C as Client - participant REST as REST API - participant WS as WebSocket API - participant JOIN_H as ChatRoomHandler - participant CONN_H as WebSocketConnectHandler - participant TOKEN_S as RoomTokenService - participant CMD as ChatRoomCommandService - participant CONN_REPO as ConnectionRepository - participant DB as DynamoDB - - Note over C,DB: Phase 1: REST API로 입장 및 토큰 발급 - C->>REST: POST /rooms/{roomId}/join - Note over C,REST: {userId, password?} - REST->>JOIN_H: Lambda Invoke - JOIN_H->>CMD: joinRoom(roomId, userId, password) - CMD->>CMD: Validate Password (BCrypt) - CMD->>CMD: Check Room Capacity - CMD->>CMD: Add User to memberIds - CMD->>TOKEN_S: generateToken(roomId, userId) - TOKEN_S->>DB: Save RoomToken (TTL: 5분) - Note over TOKEN_S,DB: PK=TOKEN#{token} - TOKEN_S-->>CMD: RoomToken - CMD-->>JOIN_H: JoinRoomResponse - JOIN_H-->>C: {room, roomToken, tokenExpiresAt} - - Note over C,DB: Phase 2: WebSocket 연결 (토큰 검증) - C->>WS: $connect?roomToken={token} - WS->>CONN_H: Lambda Invoke - CONN_H->>TOKEN_S: validateToken(token) - TOKEN_S->>DB: GetItem TOKEN#{token} - DB-->>TOKEN_S: RoomToken (or empty) - alt Token Valid - TOKEN_S-->>CONN_H: RoomToken - CONN_H->>CONN_H: Build Connection Entity - CONN_H->>CONN_REPO: save(connection) - CONN_REPO->>DB: PutItem - Note over CONN_REPO,DB: PK=CONN#{connId}
GSI1PK=ROOM#{roomId}
GSI2PK=USER#{userId} - CONN_H-->>C: 200 Connected - else Token Invalid/Expired - TOKEN_S-->>CONN_H: Empty - CONN_H-->>C: 401 Unauthorized - end -``` - -### 2.5 메시지 전송 및 브로드캐스트 흐름 - -```mermaid -sequenceDiagram - participant C as Client - participant WS as WebSocket API - participant MSG_H as WebSocketMessageHandler - participant MSG_S as ChatMessageService - participant ROOM_REPO as ChatRoomRepository - participant CONN_REPO as ConnectionRepository - participant BC as WebSocketBroadcaster - participant DB as DynamoDB - participant OTHERS as Other Clients - - C->>WS: sendMessage - Note over C,WS: {roomId, userId, content, messageType} - WS->>MSG_H: Lambda Invoke - MSG_H->>MSG_H: Parse Payload - MSG_H->>MSG_H: Build ChatMessage Entity - MSG_H->>MSG_S: saveMessage(message) - MSG_S->>DB: PutItem - Note over MSG_S,DB: PK=ROOM#{roomId}
SK=MSG#{timestamp}#{msgId} - MSG_H->>ROOM_REPO: updateLastMessageAt(roomId) - ROOM_REPO->>DB: UpdateItem - - MSG_H->>CONN_REPO: findByRoomId(roomId) - CONN_REPO->>DB: Query GSI1 (ROOM#{roomId}) - DB-->>CONN_REPO: List - CONN_REPO-->>MSG_H: Connections - - MSG_H->>BC: broadcast(connections, payload) - loop Each Connection - BC->>WS: PostToConnection - WS->>OTHERS: Push Message - alt Connection Failed - BC->>BC: Add to failedList - end - end - BC-->>MSG_H: failedConnections - - loop Each Failed Connection - MSG_H->>CONN_REPO: delete(connectionId) - Note over MSG_H,CONN_REPO: Cleanup stale connections - end - - MSG_H-->>C: 200 Message Sent -``` - -### 2.6 WebSocket 연결 해제 흐름 - -```mermaid -sequenceDiagram - participant C as Client - participant WS as WebSocket API - participant DISC_H as WebSocketDisconnectHandler - participant CONN_REPO as ConnectionRepository - participant DB as DynamoDB - - C->>WS: $disconnect - Note over C,WS: Connection closed - WS->>DISC_H: Lambda Invoke - DISC_H->>DISC_H: Extract connectionId - DISC_H->>CONN_REPO: delete(connectionId) - CONN_REPO->>DB: DeleteItem - Note over CONN_REPO,DB: PK=CONN#{connectionId} - DISC_H-->>WS: 200 OK -``` - ---- - -## 3. 데이터 모델 - -### 3.1 ERD (DynamoDB Single Table Design) - -```mermaid -erDiagram - ChatTable ||--o{ ChatRoom : contains - ChatTable ||--o{ ChatMessage : contains - ChatTable ||--o{ Connection : contains - ChatTable ||--o{ RoomToken : contains - - ChatRoom { - string partitionKey "ROOM#{roomId}" - string sortKey "METADATA" - string gsi1PartitionKey "ROOMS" - string gsi1SortKey "level#createdAt" - string roomId "UUID" - string name "방 이름" - string description "설명" - string level "BEGINNER/INTERMEDIATE/ADVANCED" - int currentMembers "현재 인원" - int maxMembers "최대 인원 (기본 6)" - boolean isPrivate "비밀방 여부" - string password "BCrypt 해시" - string createdBy "방장 userId" - string memberIds "참여자 목록" - string createdAt "생성 시각" - string lastMessageAt "마지막 메시지 시각" - } - - ChatMessage { - string partitionKey "ROOM#{roomId}" - string sortKey "MSG#{timestamp}#{messageId}" - string gsi1PartitionKey "USER#{userId}" - string gsi1SortKey "MSG#{timestamp}" - string gsi2PartitionKey "MSG#{messageId}" - string gsi2SortKey "ROOM#{roomId}" - string messageId "UUID" - string roomId "방 ID" - string userId "발신자 ID" - string content "메시지 내용" - string messageType "TEXT/IMAGE/VOICE/AI_RESPONSE" - string maleVoiceKey "S3 음성 키 (남성)" - string femaleVoiceKey "S3 음성 키 (여성)" - string createdAt "전송 시각" - } - - Connection { - string partitionKey "CONN#{connectionId}" - string sortKey "METADATA" - string gsi1PartitionKey "ROOM#{roomId}" - string gsi1SortKey "CONN#{connectionId}" - string gsi2PartitionKey "USER#{userId}" - string gsi2SortKey "CONN#{connectionId}" - string connectionId "WebSocket Connection ID" - string userId "사용자 ID" - string roomId "방 ID" - string connectedAt "연결 시각" - long ttl "자동 만료 (10분)" - } - - RoomToken { - string partitionKey "TOKEN#{token}" - string sortKey "METADATA" - string token "UUID 토큰" - string roomId "방 ID" - string userId "사용자 ID" - string createdAt "발급 시각" - long ttl "자동 만료 (5분)" - } -``` - -### 3.2 테이블 상세 - -#### ChatRoom (채팅방) - -| 필드 | 타입 | 필수 | 설명 | -|----------------|---------|----|----------------------------------| -| PK | String | Y | ROOM#{roomId} | -| SK | String | Y | METADATA | -| GSI1PK | String | Y | ROOMS (전체 조회용) | -| GSI1SK | String | Y | {level}#{createdAt} (정렬) | -| roomId | String | Y | UUID | -| name | String | Y | 채팅방 이름 | -| description | String | N | 설명 | -| level | String | Y | beginner, intermediate, advanced | -| currentMembers | Integer | Y | 현재 참여 인원 | -| maxMembers | Integer | Y | 최대 인원 (기본: 6) | -| isPrivate | Boolean | Y | 비밀방 여부 | -| password | String | N | BCrypt 해시 비밀번호 | -| createdBy | String | Y | 방장 userId | -| memberIds | List | Y | 참여자 userId 목록 | -| createdAt | String | Y | ISO 8601 형식 | -| lastMessageAt | String | Y | 마지막 메시지 시각 | - -#### ChatMessage (채팅 메시지) - -| 필드 | 타입 | 필수 | 설명 | -|----------------|--------|----|---------------------------------| -| PK | String | Y | ROOM#{roomId} | -| SK | String | Y | MSG#{timestamp}#{messageId} | -| GSI1PK | String | Y | USER#{userId} | -| GSI1SK | String | Y | MSG#{timestamp} | -| GSI2PK | String | Y | MSG#{messageId} | -| GSI2SK | String | Y | ROOM#{roomId} | -| messageId | String | Y | UUID | -| roomId | String | Y | 채팅방 ID | -| userId | String | Y | 발신자 ID | -| content | String | Y | 메시지 내용 | -| messageType | String | Y | TEXT, IMAGE, VOICE, AI_RESPONSE | -| maleVoiceKey | String | N | S3 음성 파일 키 (남성) | -| femaleVoiceKey | String | N | S3 음성 파일 키 (여성) | -| createdAt | String | Y | ISO 8601 형식 | - -#### Connection (WebSocket 연결) - -| 필드 | 타입 | 필수 | 설명 | -|--------------|--------|----|----------------------------| -| PK | String | Y | CONN#{connectionId} | -| SK | String | Y | METADATA | -| GSI1PK | String | Y | ROOM#{roomId} | -| GSI1SK | String | Y | CONN#{connectionId} | -| GSI2PK | String | Y | USER#{userId} | -| GSI2SK | String | Y | CONN#{connectionId} | -| connectionId | String | Y | API Gateway Connection ID | -| userId | String | Y | 사용자 ID | -| roomId | String | Y | 채팅방 ID | -| connectedAt | String | Y | 연결 시각 | -| ttl | Long | Y | DynamoDB TTL (10분 후 자동 삭제) | - -#### RoomToken (입장 토큰) - -| 필드 | 타입 | 필수 | 설명 | -|-----------|--------|----|---------------------------| -| PK | String | Y | TOKEN#{token} | -| SK | String | Y | METADATA | -| token | String | Y | UUID 토큰 | -| roomId | String | Y | 채팅방 ID | -| userId | String | Y | 사용자 ID | -| createdAt | String | Y | 발급 시각 | -| ttl | Long | Y | DynamoDB TTL (5분 후 자동 삭제) | - -### 3.3 GSI (Global Secondary Index) 설계 - -```mermaid -flowchart LR - subgraph GSI1["GSI1: 범용 조회"] - direction TB - G1_ROOMS["ROOMS → 전체 방 조회"] - G1_USER["USER#{userId} → 사용자 메시지"] - G1_ROOM_CONN["ROOM#{roomId} → 방별 연결"] - G1_USER_REVIEW["USER#{userId}#REVIEW → 복습 예정"] - end - - subgraph GSI2["GSI2: 보조 조회"] - direction TB - G2_MSG["MSG#{messageId} → 메시지 직접 조회"] - G2_USER_CONN["USER#{userId} → 사용자 연결"] - G2_USER_STATUS["USER#{userId}#STATUS → 상태별"] - end -``` - ---- - -## 4. API 명세 - -### 4.1 채팅방 생성 - -#### POST /rooms - -**Request** - -```json -{ - "name": "English Beginners", - "description": "영어 초보자를 위한 채팅방", - "level": "beginner", - "maxMembers": 6, - "isPrivate": false, - "password": null, - "createdBy": "user123" -} -``` - -**Response (201 Created)** - -```json -{ - "success": true, - "message": "Room created", - "data": { - "roomId": "550e8400-e29b-41d4-a716-446655440000", - "name": "English Beginners", - "description": "영어 초보자를 위한 채팅방", - "level": "beginner", - "currentMembers": 1, - "maxMembers": 6, - "isPrivate": false, - "createdBy": "user123", - "memberIds": ["user123"], - "createdAt": "2026-01-09T10:00:00Z", - "lastMessageAt": "2026-01-09T10:00:00Z" - } -} -``` - -### 4.2 채팅방 목록 조회 - -#### GET /rooms - -**Query Parameters** - -| 파라미터 | 타입 | 필수 | 설명 | -|--------|---------|----|-------------------------------------------| -| level | String | N | 난이도 필터 (beginner, intermediate, advanced) | -| userId | String | N | 사용자 ID (joined 필터 시 필수) | -| joined | String | N | "true"면 가입된 방만 조회 | -| cursor | String | N | 페이징 커서 | -| limit | Integer | N | 페이지 크기 (기본: 10, 최대: 20) | - -**Response (200 OK)** - -```json -{ - "success": true, - "message": "Rooms retrieved", - "data": { - "rooms": [ - { - "roomId": "...", - "name": "English Beginners", - "level": "beginner", - "currentMembers": 3, - "maxMembers": 6, - "isPrivate": false, - "lastMessageAt": "2026-01-09T10:30:00Z" - } - ], - "nextCursor": "eyJQSyI6IlJPT00jLi4uIiwiU0siOiJNRVRBREFUQSJ9", - "hasMore": true - } -} -``` - -### 4.3 채팅방 상세 조회 - -#### GET /rooms/{roomId} - -**Response (200 OK)** - -```json -{ - "success": true, - "message": "Room retrieved", - "data": { - "roomId": "550e8400-e29b-41d4-a716-446655440000", - "name": "English Beginners", - "description": "영어 초보자를 위한 채팅방", - "level": "beginner", - "currentMembers": 3, - "maxMembers": 6, - "isPrivate": false, - "createdBy": "user123", - "memberIds": ["user123", "user456", "user789"], - "createdAt": "2026-01-09T10:00:00Z", - "lastMessageAt": "2026-01-09T10:30:00Z" - } -} -``` - -### 4.4 채팅방 입장 - -#### POST /rooms/{roomId}/join - -**Request** - -```json -{ - "userId": "user456", - "password": "secret123" -} -``` - -**Response (200 OK)** - -```json -{ - "success": true, - "message": "Joined room", - "data": { - "room": { - "roomId": "...", - "name": "English Beginners", - "currentMembers": 4 - }, - "roomToken": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - "tokenExpiresAt": 1704793800 - } -} -``` - -### 4.5 채팅방 퇴장 - -#### POST /rooms/{roomId}/leave - -**Request** - -```json -{ - "userId": "user456" -} -``` - -**Response (200 OK)** - -```json -{ - "success": true, - "message": "Left room", - "data": { - "roomId": "...", - "currentMembers": 3 - } -} -``` - -### 4.6 채팅방 삭제 - -#### DELETE /rooms/{roomId}?userId={userId} - -**Response (200 OK)** - -```json -{ - "success": true, - "message": "Room deleted", - "data": null -} -``` - ---- - -## 캐치마인드 게임 API - -### 4.7 게임 시작 - -#### POST /chat/rooms/{roomId}/game/start - -게임을 시작합니다. 방장만 게임을 시작할 수 있습니다. - -**Request Header** -- `Authorization`: Bearer {token} - -**Response (200 OK)** - -```json -{ - "success": true, - "message": "Game started successfully", - "data": { - "roomId": "550e8400-e29b-41d4-a716-446655440000", - "gameId": "game-uuid-here", - "status": "IN_PROGRESS", - "currentWord": "apple", - "drawerId": "user123", - "startedAt": "2026-01-09T10:00:00Z" - } -} -``` - -### 4.8 게임 중지 - -#### POST /chat/rooms/{roomId}/game/stop - -진행 중인 게임을 중지합니다. 방장만 중지할 수 있습니다. - -**Request Header** -- `Authorization`: Bearer {token} - -**Response (200 OK)** - -```json -{ - "success": true, - "message": "Game stopped successfully", - "data": { - "roomId": "550e8400-e29b-41d4-a716-446655440000", - "gameId": "game-uuid-here", - "status": "STOPPED", - "finalScores": { - "user123": 30, - "user456": 20, - "user789": 10 - } - } -} -``` - -### 4.9 게임 상태 조회 - -#### GET /chat/rooms/{roomId}/game/status - -현재 게임 상태를 조회합니다. - -**Request Header** -- `Authorization`: Bearer {token} - -**Response (200 OK)** - -```json -{ - "success": true, - "message": "Game status retrieved", - "data": { - "roomId": "550e8400-e29b-41d4-a716-446655440000", - "gameId": "game-uuid-here", - "status": "IN_PROGRESS", - "currentRound": 3, - "totalRounds": 5, - "drawerId": "user123", - "timeRemaining": 45, - "scores": { - "user123": 30, - "user456": 20, - "user789": 10 - } - } -} -``` - -**게임 상태 (status)** -- `WAITING`: 게임 대기 중 -- `IN_PROGRESS`: 게임 진행 중 -- `ROUND_END`: 라운드 종료 -- `GAME_END`: 게임 종료 -- `STOPPED`: 강제 중지 - -### 4.10 점수 조회 - -#### GET /chat/rooms/{roomId}/game/scores - -현재 게임의 점수를 조회합니다. - -**Request Header** -- `Authorization`: Bearer {token} - -**Response (200 OK)** - -```json -{ - "success": true, - "message": "Scores retrieved", - "data": { - "roomId": "550e8400-e29b-41d4-a716-446655440000", - "gameId": "game-uuid-here", - "scores": [ - { - "userId": "user123", - "nickname": "Player1", - "score": 30, - "rank": 1 - }, - { - "userId": "user456", - "nickname": "Player2", - "score": 20, - "rank": 2 - }, - { - "userId": "user789", - "nickname": "Player3", - "score": 10, - "rank": 3 - } - ] - } -} -``` - -### 캐치마인드 게임 규칙 - -| 항목 | 설명 | -|-----|-----| -| 최소 인원 | 2명 | -| 라운드당 시간 | 60초 | -| 정답 점수 | 10점 (맞춘 사람) | -| 출제자 점수 | 5점 (누군가 맞추면) | -| 단어 카테고리 | 동물, 음식, 사물, 직업 등 | - -### WebSocket 게임 이벤트 - -게임 진행 중 WebSocket을 통해 실시간 이벤트가 전송됩니다. - -**게임 시작 이벤트** -```json -{ - "type": "GAME_START", - "gameId": "game-uuid", - "drawerId": "user123", - "round": 1 -} -``` - -**정답 이벤트** -```json -{ - "type": "CORRECT_ANSWER", - "userId": "user456", - "word": "apple", - "score": 10 -} -``` - -**라운드 종료 이벤트** -```json -{ - "type": "ROUND_END", - "round": 1, - "answer": "apple", - "scores": {...} -} -``` - -**게임 종료 이벤트** -```json -{ - "type": "GAME_END", - "winner": "user123", - "finalScores": {...} -} -``` - ---- - -### 4.11 WebSocket 엔드포인트 - -#### $connect - -**Query Parameter** - -| 파라미터 | 타입 | 필수 | 설명 | -|-----------|--------|----|--------------------| -| roomToken | String | Y | joinRoom에서 발급받은 토큰 | - -**연결 URL 예시** - -``` -wss://api.example.com/ws?roomToken=a1b2c3d4-e5f6-7890-abcd-ef1234567890 -``` - -#### sendMessage (Action) - -**Payload** - -```json -{ - "action": "sendMessage", - "roomId": "550e8400-e29b-41d4-a716-446655440000", - "userId": "user456", - "content": "Hello everyone!", - "messageType": "TEXT" -} -``` - -**Broadcast Payload (수신)** - -```json -{ - "messageId": "msg-uuid-here", - "roomId": "550e8400-e29b-41d4-a716-446655440000", - "userId": "user456", - "content": "Hello everyone!", - "messageType": "TEXT", - "createdAt": "2026-01-09T10:35:00Z" -} -``` - ---- - -## 5. 비즈니스 규칙 - -### 5.1 채팅방 상태 전이 - -```mermaid -stateDiagram-v2 - [*] --> CREATED: 방 생성 - CREATED --> ACTIVE: 첫 메시지 - ACTIVE --> ACTIVE: 메시지 송수신 - ACTIVE --> EMPTY: 모든 멤버 퇴장 - ACTIVE --> DELETED: 방장 삭제 - EMPTY --> DELETED: 자동 삭제 - DELETED --> [*] -``` - -### 5.2 토큰 상태 전이 - -```mermaid -stateDiagram-v2 - [*] --> ISSUED: joinRoom 호출 - ISSUED --> VALIDATED: WebSocket 연결 성공 - ISSUED --> EXPIRED: TTL 만료 (5분) - VALIDATED --> [*]: 토큰 사용 완료 - EXPIRED --> [*]: 자동 삭제 -``` - -### 5.3 접근 제어 - -| 기능 | 조건 | -|--------------|------------------| -| 방 생성 | 모든 사용자 | -| 방 조회 | 모든 사용자 | -| 방 입장 | 비밀방인 경우 비밀번호 필요 | -| 방 퇴장 | 참여 멤버만 | -| 방 삭제 | 방장(createdBy)만 | -| WebSocket 연결 | 유효한 roomToken 필요 | - -### 5.4 비밀번호 처리 - -```mermaid -flowchart LR - A[Plain Password] -->|BCrypt.hashpw| B[Hashed Password] - B -->|저장| DB[(DynamoDB)] - - C[입력 Password] -->|BCrypt.checkpw| D{일치?} - DB -->|조회| D - D -->|Yes| E[입장 허용] - D -->|No| F[403 Forbidden] -``` - -### 5.5 제한 사항 - -| 항목 | 제한 | -|-----------------|-----------------| -| 최대 참여 인원 | 기본 6명, 최대 설정 가능 | -| 방 목록 페이지 크기 | 최대 20 | -| RoomToken 유효 시간 | 5분 (300초) | -| Connection TTL | 10분 (600초) | -| 비밀번호 | BCrypt 해시 | - ---- - -## 6. 에러 코드 - -### 6.1 HTTP 에러 - -| HTTP Code | 설명 | 예시 | -|-----------|--------|-----------------| -| 400 | 잘못된 요청 | 필수 파라미터 누락 | -| 401 | 인증 실패 | 유효하지 않은 토큰 | -| 403 | 권한 없음 | 비밀번호 불일치, 방장 아님 | -| 404 | 리소스 없음 | 존재하지 않는 방 | -| 409 | 충돌 | 정원 초과 | -| 500 | 서버 오류 | 내부 오류 | - -### 6.2 에러 응답 형식 - -```json -{ - "success": false, - "error": "Room not found" -} -``` - ---- - -## 7. 환경 설정 - -### 7.1 환경 변수 (template.yaml) - -```yaml -Environment: - Variables: - CHAT_TABLE_NAME: ChatTable - CHAT_BUCKET_NAME: group2-englishstudy - ROOM_TOKEN_TTL_SECONDS: "300" - AWS_REGION_NAME: ap-northeast-2 -``` - -### 7.2 DynamoDB 테이블 설정 - -```yaml -ChatTable: - Type: AWS::DynamoDB::Table - Properties: - TableName: ChatTable - BillingMode: PAY_PER_REQUEST - AttributeDefinitions: - - AttributeName: PK - AttributeType: S - - AttributeName: SK - AttributeType: S - - AttributeName: GSI1PK - AttributeType: S - - AttributeName: GSI1SK - AttributeType: S - - AttributeName: GSI2PK - AttributeType: S - - AttributeName: GSI2SK - AttributeType: S - KeySchema: - - AttributeName: PK - KeyType: HASH - - AttributeName: SK - KeyType: RANGE - GlobalSecondaryIndexes: - - IndexName: GSI1 - KeySchema: - - AttributeName: GSI1PK - KeyType: HASH - - AttributeName: GSI1SK - KeyType: RANGE - Projection: - ProjectionType: ALL - - IndexName: GSI2 - KeySchema: - - AttributeName: GSI2PK - KeyType: HASH - - AttributeName: GSI2SK - KeyType: RANGE - Projection: - ProjectionType: ALL - TimeToLiveSpecification: - AttributeName: ttl - Enabled: true -``` - -### 7.3 API Gateway WebSocket 설정 - -```yaml -WebSocketApi: - Type: AWS::ApiGatewayV2::Api - Properties: - Name: ChatWebSocketApi - ProtocolType: WEBSOCKET - RouteSelectionExpression: "$request.body.action" - -Routes: - - $connect → WebSocketConnectHandler - - $disconnect → WebSocketDisconnectHandler - - sendMessage → WebSocketMessageHandler -``` - ---- - -## 8. 프로젝트 구조 - -``` -domain/chatting/ -├── handler/ -│ ├── ChatRoomHandler.java # REST API - 채팅방 CRUD -│ ├── ChatMessageHandler.java # REST API - 메시지 조회 -│ ├── ChatAIHandler.java # REST API - AI 응답 -│ ├── ChatVoiceHandler.java # REST API - TTS -│ └── websocket/ -│ ├── WebSocketConnectHandler.java # $connect -│ ├── WebSocketMessageHandler.java # sendMessage -│ └── WebSocketDisconnectHandler.java # $disconnect -│ -├── service/ -│ ├── ChatRoomCommandService.java # 방 변경 (CQRS Command) -│ ├── ChatRoomQueryService.java # 방 조회 (CQRS Query) -│ ├── ChatMessageService.java # 메시지 저장/조회 -│ ├── RoomTokenService.java # 토큰 발급/검증 -│ └── BedrockService.java # AI 응답 생성 -│ -├── repository/ -│ ├── ChatRoomRepository.java # 채팅방 데이터 접근 -│ ├── ChatMessageRepository.java # 메시지 데이터 접근 -│ ├── ConnectionRepository.java # WebSocket 연결 데이터 접근 -│ └── RoomTokenRepository.java # 토큰 데이터 접근 -│ -├── model/ -│ ├── ChatRoom.java # 채팅방 엔티티 -│ ├── ChatMessage.java # 메시지 엔티티 -│ ├── Connection.java # 연결 엔티티 -│ └── RoomToken.java # 토큰 엔티티 -│ -└── dto/ - ├── request/ - │ ├── CreateRoomRequest.java - │ ├── JoinRoomRequest.java - │ ├── LeaveRoomRequest.java - │ └── SendMessageRequest.java - └── response/ - └── JoinRoomResponse.java -``` - ---- - -## 9. 테스트 - -### 9.1 테스트 시나리오 - -```mermaid -flowchart TB - subgraph Unit["단위 테스트"] - U1[Service Layer] - U2[Repository Layer] - U3[Model Layer] - end - - subgraph Integration["통합 테스트"] - I1[Handler + Service] - I2[WebSocket Flow] - I3[DynamoDB Integration] - end - - subgraph E2E["E2E 테스트"] - E1[방 생성 → 입장 → 메시지 → 퇴장] - E2[WebSocket 연결 → 브로드캐스트] - end -``` - -### 9.2 로컬 테스트 - -```bash -# SAM Local 실행 -sam local start-api - -# WebSocket 테스트 (wscat) -wscat -c "wss://localhost:3001?roomToken=test-token" -``` - ---- - -## 10. 구현 현황 - -### Phase 1 - 핵심 기능 (완료) - -- [x] 채팅방 CRUD -- [x] REST API Handler -- [x] CQRS 패턴 적용 -- [x] 커서 기반 페이징 - -### Phase 2 - 실시간 통신 (완료) - -- [x] WebSocket 연결/해제 -- [x] 메시지 브로드캐스트 -- [x] RoomToken 인증 -- [x] Connection 관리 - -### Phase 3 - 고급 기능 (완료) - -- [x] 비밀방 (BCrypt) -- [x] 난이도별 필터링 -- [x] AI 응답 (Bedrock) -- [x] TTS (Polly) - -### Phase 4 - 최적화 (진행 중) - -- [ ] 연결 상태 모니터링 -- [ ] 메시지 캐싱 -- [ ] 알림 기능 (SNS) - ---- - -**버전**: 1.0.0 -**최종 업데이트**: 2026-01-09 -**팀**: MZC 2nd Project Team From cff8e3b638e844f57876ec3ffee24bda0419206f Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:31:45 +0900 Subject: [PATCH 300/528] =?UTF-8?q?docs:=20USER-GUIDE.md=20=EC=97=85?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/user/USER-GUIDE.md | 79 ++++++++++++++++++++++------------------- 1 file changed, 43 insertions(+), 36 deletions(-) diff --git a/docs/user/USER-GUIDE.md b/docs/user/USER-GUIDE.md index 3ea797ce..303447d0 100644 --- a/docs/user/USER-GUIDE.md +++ b/docs/user/USER-GUIDE.md @@ -9,15 +9,15 @@ User Server는 영어 회화 학습 플랫폼의 사용자 인증 및 프로필 ### 1.2 주요 기능 -| 기능 | 설명 | -|--------|--------------------------------------------------| -| 회원가입 | Cognito 기반 이메일 회원가입 | -| 이메일 인증 | Cognito 자동 인증 코드 발송 | -| 로그인 | JWT 토큰 발급 (IdToken, AccessToken, RefreshToken) | -| 프로필 조회 | 인증된 사용자 정보 조회 | -| 프로필 수정 | 닉네임, 레벨 변경 | +| 기능 | 설명 | +|-------------|--------------------------------------------------| +| 회원가입 | Cognito 기반 이메일 회원가입 | +| 이메일 인증 | Cognito 자동 인증 코드 발송 | +| 로그인 | JWT 토큰 발급 (IdToken, AccessToken, RefreshToken) | +| 프로필 조회 | 인증된 사용자 정보 조회 | +| 프로필 수정 | 닉네임, 레벨 변경 | | 프로필 이미지 업로드 | S3 Presigned URL 발급 및 이미지 업로드 | -| 기본값 설정 | PreSignUp 트리거로 nickname, level, profileUrl 자동 설정 | +| 기본값 설정 | PreSignUp 트리거로 nickname, level, profileUrl 자동 설정 | ### 1.3 기술 스택 @@ -334,10 +334,11 @@ aws cognito-idp initiate-auth \ **Headers** | Header | 값 | 필수 | -|---------------|------------------|-----| -| Authorization | Bearer {IdToken} | Y | +|---------------|------------------|----| +| Authorization | Bearer {IdToken} | Y | **Response (200 OK)** + ```json { "isSuccess": true, @@ -361,11 +362,12 @@ aws cognito-idp initiate-auth \ **Headers** | Header | 값 | 필수 | -|---------------|------------------|-----| -| Authorization | Bearer {IdToken} | Y | -| Content-Type | application/json | Y | +|---------------|------------------|----| +| Authorization | Bearer {IdToken} | Y | +| Content-Type | application/json | Y | **Request Body** + ```json { "nickname": "새닉네임", @@ -373,12 +375,13 @@ aws cognito-idp initiate-auth \ } ``` -| 필드 | 타입 | 필수 | 설명 | -|---------|--------|-----|---------------------------------------| -| nickname | String | N | 닉네임 (2~20자) | -| level | String | N | BEGINNER / INTERMEDIATE / ADVANCED | +| 필드 | 타입 | 필수 | 설명 | +|----------|--------|----|------------------------------------| +| nickname | String | N | 닉네임 (2~20자) | +| level | String | N | BEGINNER / INTERMEDIATE / ADVANCED | **Response (200 OK)** + ```json { "isSuccess": true, @@ -402,11 +405,12 @@ aws cognito-idp initiate-auth \ **Headers** | Header | 값 | 필수 | -|---------------|------------------|-----| -| Authorization | Bearer {IdToken} | Y | -| Content-Type | application/json | Y | +|---------------|------------------|----| +| Authorization | Bearer {IdToken} | Y | +| Content-Type | application/json | Y | **Request Body** + ```json { "fileName": "profile.jpg", @@ -414,12 +418,13 @@ aws cognito-idp initiate-auth \ } ``` -| 필드 | 타입 | 필수 | 설명 | -|------------|--------|-----|------------------------------------------| -| fileName | String | Y | 파일명 | -| contentType | String | Y | image/jpeg, image/png, image/gif, image/webp | +| 필드 | 타입 | 필수 | 설명 | +|-------------|--------|----|----------------------------------------------| +| fileName | String | Y | 파일명 | +| contentType | String | Y | image/jpeg, image/png, image/gif, image/webp | **Response (200 OK)** + ```json { "isSuccess": true, @@ -432,6 +437,7 @@ aws cognito-idp initiate-auth \ ``` **이미지 업로드 방법 (클라이언트)** + ``` PUT {uploadUrl} Content-Type: {요청 시 보낸 contentType과 동일} @@ -485,18 +491,19 @@ Body: Binary (이미지 파일) ### 6.2 API 에러 -| HTTP Code | Error Code | 메시지 | -|-----------|------------|-------------------------------| -| 400 | USER_002 | 닉네임은 2~20자여야 합니다 | -| 400 | USER_003 | 유효하지 않은 레벨입니다 | -| 400 | USER_004 | 지원하지 않는 이미지 형식입니다 | -| 401 | AUTH_001 | 인증이 필요합니다 | -| 401 | AUTH_003 | 유효하지 않은 토큰입니다 | -| 401 | AUTH_004 | 토큰이 만료되었습니다 | -| 404 | USER_001 | 사용자를 찾을 수 없습니다 | -| 500 | USER_005 | 이미지 업로드에 실패했습니다 | -| 500 | USER_006 | Cognito 동기화에 실패했습니다 | -| 500 | SYSTEM_001 | 내부 서버 오류가 발생했습니다 | +| HTTP Code | Error Code | 메시지 | +|-----------|------------|---------------------| +| 400 | USER_002 | 닉네임은 2~20자여야 합니다 | +| 400 | USER_003 | 유효하지 않은 레벨입니다 | +| 400 | USER_004 | 지원하지 않는 이미지 형식입니다 | +| 401 | AUTH_001 | 인증이 필요합니다 | +| 401 | AUTH_003 | 유효하지 않은 토큰입니다 | +| 401 | AUTH_004 | 토큰이 만료되었습니다 | +| 404 | USER_001 | 사용자를 찾을 수 없습니다 | +| 500 | USER_005 | 이미지 업로드에 실패했습니다 | +| 500 | USER_006 | Cognito 동기화에 실패했습니다 | +| 500 | SYSTEM_001 | 내부 서버 오류가 발생했습니다 | + ### 6.3 에러 응답 형식 ```json From 063b45af194bf8ff19e1b24fd98d2cc2cd8ba815 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 16 Jan 2026 17:31:45 +0900 Subject: [PATCH 301/528] =?UTF-8?q?chore:=20VOCABULARY-GUIDE.md=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/vocabulary/VOCABULARY-GUIDE.md | 1248 --------------------------- 1 file changed, 1248 deletions(-) delete mode 100644 docs/vocabulary/VOCABULARY-GUIDE.md diff --git a/docs/vocabulary/VOCABULARY-GUIDE.md b/docs/vocabulary/VOCABULARY-GUIDE.md deleted file mode 100644 index 7891a29c..00000000 --- a/docs/vocabulary/VOCABULARY-GUIDE.md +++ /dev/null @@ -1,1248 +0,0 @@ -# Vocabulary Server 가이드 문서 - -## 1. 개요 - -### 1.1 목적 - -Vocabulary Server는 영어 단어 학습 플랫폼의 단어 관리 및 학습 기능을 담당하는 서버리스 마이크로서비스이다. Spaced Repetition 알고리즘을 활용한 효율적인 단어 암기, 시험 기능, 일일 -학습 추적 등을 제공한다. - -### 1.2 주요 기능 - -| 기능 | 설명 | -|--------|-----------------------------| -| 단어 관리 | CRUD, 배치 생성/조회, 검색 | -| 사용자 학습 | Spaced Repetition 기반 복습 스케줄 | -| 시험 기능 | DAILY, WEEKLY, CUSTOM 테스트 | -| 일일 학습 | 학습 기록 및 진도 추적 | -| 통계 | 학습 통계 및 성취도 분석 | -| TTS | AWS Polly 기반 발음 듣기 | -| 단어 그룹 | 카테고리/레벨별 그룹화 | - -### 1.3 기술 스택 - -| 구분 | 기술 | -|-----------|------------------------------------| -| Platform | AWS Lambda (Serverless) | -| Language | Java 21 (Eclipse Temurin) | -| Database | AWS DynamoDB (Single Table Design) | -| TTS | AWS Polly | -| Storage | AWS S3 (음성 캐시) | -| Algorithm | SM-2 기반 Spaced Repetition | - ---- - -## 2. 시스템 아키텍처 - -### 2.1 전체 구조 - -```mermaid -flowchart TB - subgraph Client - APP[Mobile App] - WEB[Web Client] - end - - subgraph AWS_Gateway["API Gateway"] - REST[HTTP API] - end - - subgraph Lambda["AWS Lambda"] - WORD_H[WordHandler] - UWORD_H[UserWordHandler] - TEST_H[TestHandler] - DAILY_H[DailyStudyHandler] - STATS_H[StatisticsHandler] - VOICE_H[VoiceHandler] - GROUP_H[WordGroupHandler] - end - - subgraph Data_Layer["Data Layer"] - DYNAMO[(DynamoDB)] - S3[(S3 Bucket)] - end - - subgraph AWS_AI["AI Services"] - POLLY[AWS Polly] - end - - APP --> REST - WEB --> REST - - REST --> WORD_H - REST --> UWORD_H - REST --> TEST_H - REST --> DAILY_H - REST --> STATS_H - REST --> VOICE_H - REST --> GROUP_H - - WORD_H --> DYNAMO - UWORD_H --> DYNAMO - TEST_H --> DYNAMO - DAILY_H --> DYNAMO - STATS_H --> DYNAMO - GROUP_H --> DYNAMO - - VOICE_H --> POLLY - VOICE_H --> S3 -``` - -### 2.2 레이어 아키텍처 - -```mermaid -flowchart TB - subgraph Presentation["Presentation Layer"] - HANDLER[Lambda Handlers] - ROUTER[HandlerRouter] - DTO[Request/Response DTOs] - end - - subgraph Application["Application Layer (CQRS)"] - CMD[Command Services] - QRY[Query Services] - ALGO[Spaced Repetition Algorithm] - end - - subgraph Domain["Domain Layer"] - MODEL[Models] - REPO[Repositories] - end - - subgraph Infrastructure["Infrastructure Layer"] - DYNAMO_CLIENT[DynamoDB Enhanced Client] - S3_CLIENT[S3 Client] - POLLY_CLIENT[Polly Client] - end - - HANDLER --> ROUTER - ROUTER --> CMD - ROUTER --> QRY - CMD --> ALGO - CMD --> MODEL - QRY --> MODEL - MODEL --> REPO - REPO --> DYNAMO_CLIENT -``` - -### 2.3 단어 학습 흐름 (Spaced Repetition) - -```mermaid -sequenceDiagram - participant C as Client - participant H as UserWordHandler - participant CMD as UserWordCommandService - participant REPO as UserWordRepository - participant DB as DynamoDB - - C->>H: POST /user-words/{wordId}/review - Note over C,H: {userId, isCorrect: true/false} - H->>CMD: updateUserWord(userId, wordId, isCorrect) - - alt UserWord 없음 - CMD->>CMD: Create new UserWord - Note over CMD: status=NEW, interval=1, easeFactor=2.5 - end - - CMD->>CMD: applySpacedRepetition(userWord, isCorrect) - - alt isCorrect = true - CMD->>CMD: repetitions++ - CMD->>CMD: Calculate new interval - Note over CMD: interval = interval * easeFactor - CMD->>CMD: Update status - Note over CMD: LEARNING → REVIEWING → MASTERED - else isCorrect = false - CMD->>CMD: Reset repetitions to 0 - CMD->>CMD: interval = 1 - CMD->>CMD: Decrease easeFactor - Note over CMD: easeFactor = max(1.3, easeFactor - 0.2) - CMD->>CMD: status = LEARNING - end - - CMD->>CMD: Calculate nextReviewAt - Note over CMD: today + interval days - CMD->>CMD: Update GSI keys - CMD->>REPO: save(userWord) - REPO->>DB: PutItem - CMD-->>H: UserWord - H-->>C: 200 OK -``` - -### 2.4 시험 흐름 - -```mermaid -sequenceDiagram - participant C as Client - participant H as TestHandler - participant CMD as TestCommandService - participant QRY as WordQueryService - participant REPO as TestResultRepository - participant DB as DynamoDB - - Note over C,DB: Phase 1: 시험 시작 - C->>H: POST /tests/start - Note over C,H: {userId, testType, wordCount} - H->>CMD: startTest(userId, testType, wordCount) - CMD->>QRY: getRandomWords(wordCount) - QRY->>DB: Query Words - DB-->>QRY: Words - QRY-->>CMD: Word List - CMD-->>H: {testId, words} - H-->>C: 200 OK (시험 문제) - - Note over C,DB: Phase 2: 시험 제출 - C->>H: POST /tests/{testId}/submit - Note over C,H: {userId, answers: [{wordId, answer}]} - H->>CMD: submitTest(testId, userId, answers) - CMD->>CMD: Grade answers - CMD->>CMD: Calculate successRate - CMD->>CMD: Build TestResult - Note over CMD: totalQuestions, correctAnswers
incorrectWordIds, successRate - CMD->>REPO: save(testResult) - REPO->>DB: PutItem - Note over REPO,DB: PK=TEST#{userId}
SK=RESULT#{timestamp} - CMD-->>H: TestResult - H-->>C: 200 OK (결과) -``` - -### 2.5 일일 학습 흐름 - -```mermaid -sequenceDiagram - participant C as Client - participant H as DailyStudyHandler - participant CMD as DailyStudyCommandService - participant QRY as DailyStudyQueryService - participant REPO as DailyStudyRepository - participant DB as DynamoDB - - C->>H: POST /daily-study/record - Note over C,H: {userId, wordId, isCorrect, studyType} - H->>CMD: recordStudy(...) - - CMD->>QRY: getTodayStudy(userId) - QRY->>DB: Query by date - - alt 오늘 기록 없음 - CMD->>CMD: Create new DailyStudy - Note over CMD: date=today, wordsStudied=0 - end - - CMD->>CMD: Update statistics - Note over CMD: wordsStudied++, correct/incorrect count - CMD->>REPO: save(dailyStudy) - REPO->>DB: PutItem - CMD-->>H: DailyStudy - H-->>C: 200 OK -``` - -### 2.6 TTS 음성 생성 흐름 - -```mermaid -sequenceDiagram - participant C as Client - participant H as VoiceHandler - participant S as VoiceService - participant REPO as WordRepository - participant POLLY as AWS Polly - participant S3 as S3 Bucket - participant DB as DynamoDB - - C->>H: POST /voice/synthesize - Note over C,H: {wordId, text, voice: "male"/"female"} - H->>S: synthesize(wordId, text, voice) - - S->>REPO: findById(wordId) - REPO->>DB: GetItem - DB-->>REPO: Word - - alt 캐시된 음성 있음 - REPO-->>S: Word (with voiceKey) - S->>S3: GetObject(voiceKey) - S3-->>S: Audio Data - S-->>H: Cached Audio URL - else 캐시 없음 - S->>POLLY: SynthesizeSpeech - Note over S,POLLY: Engine: neural
Voice: Matthew/Joanna - POLLY-->>S: Audio Stream - S->>S3: PutObject - Note over S,S3: Key: vocab/voice/{wordId}_{voice}.mp3 - S3-->>S: Upload Success - S->>REPO: Update voiceKey - REPO->>DB: UpdateItem - S-->>H: New Audio URL - end - - H-->>C: 200 OK (audioUrl) -``` - ---- - -## 3. 데이터 모델 - -### 3.1 ERD (DynamoDB Single Table Design) - -```mermaid -erDiagram - VocabTable ||--o{ Word : contains - VocabTable ||--o{ UserWord : contains - VocabTable ||--o{ TestResult : contains - VocabTable ||--o{ DailyStudy : contains - VocabTable ||--o{ WordGroup : contains - - Word { - string partitionKey "WORD#{wordId}" - string sortKey "METADATA" - string gsi1PartitionKey "LEVEL#{level}" - string gsi1SortKey "WORD#{wordId}" - string gsi2PartitionKey "CATEGORY#{category}" - string gsi2SortKey "WORD#{wordId}" - string wordId "UUID" - string english "영어 단어" - string korean "한국어 뜻" - string example "예문" - string level "BEGINNER/INTERMEDIATE/ADVANCED" - string category "DAILY/BUSINESS/ACADEMIC" - string maleVoiceKey "S3 음성 키 (남성)" - string femaleVoiceKey "S3 음성 키 (여성)" - string createdAt "생성 시각" - } - - UserWord { - string partitionKey "USER#{userId}" - string sortKey "WORD#{wordId}" - string gsi1PartitionKey "USER#{userId}#REVIEW" - string gsi1SortKey "DATE#{nextReviewAt}" - string gsi2PartitionKey "USER#{userId}#STATUS" - string gsi2SortKey "STATUS#{status}" - string userId "사용자 ID" - string wordId "단어 ID" - string status "NEW/LEARNING/REVIEWING/MASTERED" - int interval "복습 간격 (일)" - double easeFactor "난이도 계수" - int repetitions "연속 정답 횟수" - string nextReviewAt "다음 복습일" - string lastReviewedAt "마지막 복습일" - int correctCount "정답 횟수" - int incorrectCount "오답 횟수" - boolean bookmarked "북마크" - boolean favorite "즐겨찾기" - string difficulty "사용자 난이도" - } - - TestResult { - string partitionKey "TEST#{userId}" - string sortKey "RESULT#{timestamp}" - string gsi1PartitionKey "TEST#ALL" - string gsi1SortKey "DATE#{date}" - string testId "UUID" - string userId "사용자 ID" - string testType "DAILY/WEEKLY/CUSTOM" - int totalQuestions "총 문제 수" - int correctAnswers "정답 수" - double successRate "성공률" - string incorrectWordIds "오답 단어 목록" - string startedAt "시작 시각" - string completedAt "완료 시각" - } - - DailyStudy { - string partitionKey "DAILY#{userId}" - string sortKey "DATE#{date}" - string gsi1PartitionKey "DAILY#ALL" - string gsi1SortKey "DATE#{date}" - string userId "사용자 ID" - string date "학습 날짜" - int wordsStudied "학습 단어 수" - int correctCount "정답 수" - int incorrectCount "오답 수" - int studyTimeMinutes "학습 시간 (분)" - string wordIds "학습한 단어 목록" - } - - WordGroup { - string partitionKey "GROUP#{groupId}" - string sortKey "METADATA" - string gsi1PartitionKey "USER#{userId}" - string gsi1SortKey "GROUP#{groupId}" - string groupId "UUID" - string userId "생성자 ID" - string name "그룹명" - string description "설명" - string wordIds "포함 단어 목록" - string createdAt "생성 시각" - } -``` - -### 3.2 테이블 상세 - -#### Word (단어) - -| 필드 | 타입 | 필수 | 설명 | -|-----------------------|--------|----|----------------------------------| -| PK | String | Y | WORD#{wordId} | -| SK | String | Y | METADATA | -| GSI1PK | String | Y | LEVEL#{level} | -| GSI1SK | String | Y | WORD#{wordId} | -| GSI2PK | String | Y | CATEGORY#{category} | -| GSI2SK | String | Y | WORD#{wordId} | -| wordId | String | Y | UUID | -| english | String | Y | 영어 단어 | -| korean | String | Y | 한국어 뜻 | -| example | String | N | 예문 | -| level | String | Y | BEGINNER, INTERMEDIATE, ADVANCED | -| category | String | Y | DAILY, BUSINESS, ACADEMIC | -| maleVoiceKey | String | N | S3 음성 파일 키 (남성) | -| femaleVoiceKey | String | N | S3 음성 파일 키 (여성) | -| maleExampleVoiceKey | String | N | S3 예문 음성 키 (남성) | -| femaleExampleVoiceKey | String | N | S3 예문 음성 키 (여성) | -| createdAt | String | Y | ISO 8601 형식 | - -#### UserWord (사용자 학습 상태) - -| 필드 | 타입 | 필수 | 설명 | -|----------------|---------|----|------------------------------------| -| PK | String | Y | USER#{userId} | -| SK | String | Y | WORD#{wordId} | -| GSI1PK | String | Y | USER#{userId}#REVIEW | -| GSI1SK | String | Y | DATE#{nextReviewAt} | -| GSI2PK | String | Y | USER#{userId}#STATUS | -| GSI2SK | String | Y | STATUS#{status} | -| userId | String | Y | 사용자 ID | -| wordId | String | Y | 단어 ID | -| status | String | Y | NEW, LEARNING, REVIEWING, MASTERED | -| interval | Integer | Y | 복습 간격 (일) | -| easeFactor | Double | Y | 난이도 계수 (기본: 2.5) | -| repetitions | Integer | Y | 연속 정답 횟수 | -| nextReviewAt | String | N | 다음 복습 예정일 | -| lastReviewedAt | String | N | 마지막 복습일 | -| correctCount | Integer | Y | 총 정답 횟수 | -| incorrectCount | Integer | Y | 총 오답 횟수 | -| bookmarked | Boolean | N | 북마크 여부 | -| favorite | Boolean | N | 즐겨찾기 여부 | -| difficulty | String | N | EASY, NORMAL, HARD | -| createdAt | String | Y | 생성 시각 | -| updatedAt | String | Y | 수정 시각 | - -#### TestResult (시험 결과) - -| 필드 | 타입 | 필수 | 설명 | -|------------------|---------|----|-----------------------| -| PK | String | Y | TEST#{userId} | -| SK | String | Y | RESULT#{timestamp} | -| GSI1PK | String | Y | TEST#ALL | -| GSI1SK | String | Y | DATE#{date} | -| testId | String | Y | UUID | -| userId | String | Y | 사용자 ID | -| testType | String | Y | DAILY, WEEKLY, CUSTOM | -| totalQuestions | Integer | Y | 총 문제 수 | -| correctAnswers | Integer | Y | 정답 수 | -| incorrectAnswers | Integer | Y | 오답 수 | -| successRate | Double | Y | 성공률 (%) | -| incorrectWordIds | List | N | 오답 단어 ID 목록 | -| startedAt | String | Y | 시험 시작 시각 | -| completedAt | String | Y | 시험 완료 시각 | - -### 3.3 Spaced Repetition 알고리즘 - -```mermaid -flowchart TB - subgraph Input - A[학습 결과 입력] - B{정답?} - end - - subgraph Correct["정답 처리"] - C1[repetitions++] - C2{repetitions} - C3[interval = 1] - C4[interval = 6] - C5["interval = interval × easeFactor"] - C6{repetitions >= 5?} - C7[status = MASTERED] - C8{repetitions >= 2?} - C9[status = REVIEWING] - C10[status = LEARNING] - end - - subgraph Incorrect["오답 처리"] - I1[repetitions = 0] - I2[interval = 1] - I3["easeFactor = max(1.3, easeFactor - 0.2)"] - I4[status = LEARNING] - end - - subgraph Calculate - N[nextReviewAt = today + interval] - end - - A --> B - B -->|Yes| C1 - B -->|No| I1 - - C1 --> C2 - C2 -->|= 1| C3 - C2 -->|= 2| C4 - C2 -->|>= 3| C5 - C3 --> C6 - C4 --> C6 - C5 --> C6 - - C6 -->|Yes| C7 - C6 -->|No| C8 - C8 -->|Yes| C9 - C8 -->|No| C10 - - I1 --> I2 - I2 --> I3 - I3 --> I4 - - C7 --> N - C9 --> N - C10 --> N - I4 --> N -``` - -### 3.4 학습 상태 전이 - -```mermaid -stateDiagram-v2 - [*] --> NEW: 단어 추가 - NEW --> LEARNING: 첫 학습 - LEARNING --> LEARNING: 오답 - LEARNING --> REVIEWING: 2회 연속 정답 - REVIEWING --> LEARNING: 오답 - REVIEWING --> REVIEWING: 정답 (rep < 5) - REVIEWING --> MASTERED: 5회 연속 정답 - MASTERED --> LEARNING: 오답 - MASTERED --> MASTERED: 정답 (유지) -``` - ---- - -## 4. API 명세 - -### 4.1 단어 생성 - -#### POST /words - -**Request** - -```json -{ - "english": "perseverance", - "korean": "인내, 끈기", - "example": "Success requires perseverance.", - "level": "ADVANCED", - "category": "DAILY" -} -``` - -**Response (201 Created)** - -```json -{ - "success": true, - "message": "Word created", - "data": { - "wordId": "550e8400-e29b-41d4-a716-446655440000", - "english": "perseverance", - "korean": "인내, 끈기", - "example": "Success requires perseverance.", - "level": "ADVANCED", - "category": "DAILY", - "createdAt": "2026-01-09T10:00:00Z" - } -} -``` - -### 4.2 단어 목록 조회 - -#### GET /words - -**Query Parameters** - -| 파라미터 | 타입 | 필수 | 설명 | -|----------|---------|----|-------------------------| -| level | String | N | 난이도 필터 | -| category | String | N | 카테고리 필터 | -| cursor | String | N | 페이징 커서 | -| limit | Integer | N | 페이지 크기 (기본: 20, 최대: 50) | - -**Response (200 OK)** - -```json -{ - "success": true, - "message": "Words retrieved", - "data": { - "words": [ - { - "wordId": "...", - "english": "perseverance", - "korean": "인내, 끈기", - "level": "ADVANCED", - "category": "DAILY" - } - ], - "nextCursor": "eyJQSyI6IldPUkQjLi4uIn0=", - "hasMore": true - } -} -``` - -### 4.3 단어 검색 - -#### GET /words/search - -**Query Parameters** - -| 파라미터 | 타입 | 필수 | 설명 | -|--------|---------|----|-----------------| -| q | String | Y | 검색어 (영어/한국어) | -| cursor | String | N | 페이징 커서 | -| limit | Integer | N | 페이지 크기 (기본: 20) | - -**Response (200 OK)** - -```json -{ - "success": true, - "message": "Search completed", - "data": { - "words": [...], - "query": "perseverance", - "nextCursor": "...", - "hasMore": false - } -} -``` - -### 4.4 배치 단어 생성 - -#### POST /words/batch - -**Request** - -```json -{ - "words": [ - { - "english": "apple", - "korean": "사과", - "level": "BEGINNER", - "category": "DAILY" - }, - { - "english": "banana", - "korean": "바나나", - "level": "BEGINNER", - "category": "DAILY" - } - ] -} -``` - -**Response (201 Created)** - -```json -{ - "success": true, - "message": "Batch completed", - "data": { - "successCount": 2, - "failCount": 0, - "totalRequested": 2 - } -} -``` - -### 4.5 배치 단어 조회 - -#### POST /words/batch/get - -**Request** - -```json -{ - "wordIds": [ - "word-id-1", - "word-id-2", - "word-id-3" - ] -} -``` - -**Response (200 OK)** - -```json -{ - "success": true, - "message": "Words retrieved", - "data": { - "words": [...], - "requestedCount": 3, - "retrievedCount": 3 - } -} -``` - -**제한**: 최대 100개 ID - -### 4.6 사용자 단어 학습 업데이트 - -#### POST /user-words/{wordId}/review - -**Request** - -```json -{ - "userId": "user123", - "isCorrect": true -} -``` - -**Response (200 OK)** - -```json -{ - "success": true, - "message": "Updated user word", - "data": { - "userId": "user123", - "wordId": "word-id-1", - "status": "REVIEWING", - "interval": 6, - "easeFactor": 2.5, - "repetitions": 2, - "nextReviewAt": "2026-01-15", - "lastReviewedAt": "2026-01-09T10:00:00Z", - "correctCount": 5, - "incorrectCount": 1 - } -} -``` - -### 4.7 사용자 단어 태그 업데이트 - -#### PATCH /user-words/{wordId}/tag - -**Request** - -```json -{ - "userId": "user123", - "bookmarked": true, - "favorite": false, - "difficulty": "HARD" -} -``` - -**Response (200 OK)** - -```json -{ - "success": true, - "message": "Updated user word tag", - "data": { - "userId": "user123", - "wordId": "word-id-1", - "bookmarked": true, - "favorite": false, - "difficulty": "HARD" - } -} -``` - -### 4.8 복습 예정 단어 조회 - -#### GET /user-words/review - -**Query Parameters** - -| 파라미터 | 타입 | 필수 | 설명 | -|--------|---------|----|----------------| -| userId | String | Y | 사용자 ID | -| date | String | N | 조회 날짜 (기본: 오늘) | -| cursor | String | N | 페이징 커서 | -| limit | Integer | N | 페이지 크기 | - -**Response (200 OK)** - -```json -{ - "success": true, - "message": "Review words retrieved", - "data": { - "words": [ - { - "wordId": "...", - "english": "perseverance", - "korean": "인내, 끈기", - "status": "REVIEWING", - "nextReviewAt": "2026-01-09" - } - ], - "nextCursor": "...", - "hasMore": true - } -} -``` - -### 4.9 시험 시작 - -#### POST /tests/start - -**Request** - -```json -{ - "userId": "user123", - "testType": "DAILY", - "wordCount": 20, - "level": "INTERMEDIATE" -} -``` - -**Response (200 OK)** - -```json -{ - "success": true, - "message": "Test started", - "data": { - "testId": "test-uuid", - "testType": "DAILY", - "words": [ - { - "wordId": "...", - "english": "perseverance", - "options": ["인내", "용기", "지혜", "성실"] - } - ], - "startedAt": "2026-01-09T10:00:00Z" - } -} -``` - -### 4.10 시험 제출 - -#### POST /tests/{testId}/submit - -**Request** - -```json -{ - "userId": "user123", - "answers": [ - {"wordId": "word-1", "answer": "인내"}, - {"wordId": "word-2", "answer": "용기"} - ] -} -``` - -**Response (200 OK)** - -```json -{ - "success": true, - "message": "Test submitted", - "data": { - "testId": "test-uuid", - "totalQuestions": 20, - "correctAnswers": 18, - "incorrectAnswers": 2, - "successRate": 90.0, - "incorrectWordIds": ["word-5", "word-12"], - "completedAt": "2026-01-09T10:15:00Z" - } -} -``` - -### 4.11 일일 학습 기록 - -#### POST /daily-study/record - -**Request** - -```json -{ - "userId": "user123", - "wordId": "word-id-1", - "isCorrect": true, - "studyType": "REVIEW" -} -``` - -**Response (200 OK)** - -```json -{ - "success": true, - "message": "Study recorded", - "data": { - "userId": "user123", - "date": "2026-01-09", - "wordsStudied": 15, - "correctCount": 12, - "incorrectCount": 3 - } -} -``` - -### 4.12 학습 통계 조회 - -#### GET /statistics - -**Query Parameters** - -| 파라미터 | 타입 | 필수 | 설명 | -|--------|--------|----|-----------------------------| -| userId | String | Y | 사용자 ID | -| period | String | N | WEEK, MONTH, ALL (기본: WEEK) | - -**Response (200 OK)** - -```json -{ - "success": true, - "message": "Statistics retrieved", - "data": { - "totalWords": 150, - "masteredWords": 45, - "learningWords": 80, - "newWords": 25, - "averageSuccessRate": 85.5, - "studyStreak": 7, - "dailyStats": [ - {"date": "2026-01-09", "wordsStudied": 20, "successRate": 90.0} - ] - } -} -``` - -### 4.13 음성 합성 - -#### POST /voice/synthesize - -**Request** - -```json -{ - "wordId": "word-id-1", - "text": "perseverance", - "voice": "male", - "type": "word" -} -``` - -**Response (200 OK)** - -```json -{ - "success": true, - "message": "Voice synthesized", - "data": { - "audioUrl": "https://s3.amazonaws.com/bucket/vocab/voice/word-id-1_male.mp3", - "cached": true - } -} -``` - ---- - -## 5. 비즈니스 규칙 - -### 5.1 Spaced Repetition 규칙 - -| 조건 | interval 계산 | status 변경 | -|----------------|-----------------------|-----------| -| 첫 정답 (rep=1) | 1일 | LEARNING | -| 두번째 정답 (rep=2) | 6일 | REVIEWING | -| 이후 정답 (rep>=3) | interval × easeFactor | REVIEWING | -| 5회 연속 정답 | 유지 | MASTERED | -| 오답 | 1일 (리셋) | LEARNING | - -### 5.2 easeFactor 규칙 - -| 조건 | easeFactor 변경 | -|------|----------------------------| -| 초기값 | 2.5 | -| 오답 시 | max(1.3, easeFactor - 0.2) | -| 정답 시 | 유지 | - -### 5.3 난이도별 카테고리 - -```mermaid -flowchart LR - subgraph Level - BEG[BEGINNER] - INT[INTERMEDIATE] - ADV[ADVANCED] - end - - subgraph Category - DAILY[DAILY
일상생활] - BIZ[BUSINESS
비즈니스] - ACAD[ACADEMIC
학술] - end - - BEG --> DAILY - INT --> DAILY - INT --> BIZ - ADV --> DAILY - ADV --> BIZ - ADV --> ACAD -``` - -### 5.4 제한 사항 - -| 항목 | 제한 | -|--------------|--------------------| -| 단어 목록 페이지 크기 | 최대 50 | -| 배치 조회 ID | 최대 100개 | -| 시험 문제 수 | 최소 5, 최대 50 | -| 사용자 난이도 | EASY, NORMAL, HARD | - ---- - -## 6. 에러 코드 - -### 6.1 HTTP 에러 - -| HTTP Code | 설명 | 예시 | -|-----------|--------|------------------------------| -| 400 | 잘못된 요청 | 필수 파라미터 누락, 잘못된 difficulty 값 | -| 404 | 리소스 없음 | 존재하지 않는 단어 | -| 500 | 서버 오류 | 내부 오류 | - -### 6.2 에러 응답 형식 - -```json -{ - "success": false, - "error": "Word not found" -} -``` - ---- - -## 7. 환경 설정 - -### 7.1 환경 변수 (template.yaml) - -```yaml -Environment: - Variables: - VOCAB_TABLE_NAME: VocabTable - VOCAB_BUCKET_NAME: group2-englishstudy - AWS_REGION_NAME: ap-northeast-2 -``` - -### 7.2 DynamoDB 테이블 설정 - -```yaml -VocabTable: - Type: AWS::DynamoDB::Table - Properties: - TableName: VocabTable - BillingMode: PAY_PER_REQUEST - AttributeDefinitions: - - AttributeName: PK - AttributeType: S - - AttributeName: SK - AttributeType: S - - AttributeName: GSI1PK - AttributeType: S - - AttributeName: GSI1SK - AttributeType: S - - AttributeName: GSI2PK - AttributeType: S - - AttributeName: GSI2SK - AttributeType: S - KeySchema: - - AttributeName: PK - KeyType: HASH - - AttributeName: SK - KeyType: RANGE - GlobalSecondaryIndexes: - - IndexName: GSI1 - KeySchema: - - AttributeName: GSI1PK - KeyType: HASH - - AttributeName: GSI1SK - KeyType: RANGE - Projection: - ProjectionType: ALL - - IndexName: GSI2 - KeySchema: - - AttributeName: GSI2PK - KeyType: HASH - - AttributeName: GSI2SK - KeyType: RANGE - Projection: - ProjectionType: ALL - TimeToLiveSpecification: - AttributeName: ttl - Enabled: true -``` - -### 7.3 S3 버킷 구조 - -``` -group2-englishstudy/ -└── vocab/ - └── voice/ - ├── {wordId}_male.mp3 - ├── {wordId}_female.mp3 - ├── {wordId}_male_example.mp3 - └── {wordId}_female_example.mp3 -``` - ---- - -## 8. 프로젝트 구조 - -``` -domain/vocabulary/ -├── handler/ -│ ├── WordHandler.java # 단어 CRUD -│ ├── UserWordHandler.java # 사용자 학습 상태 -│ ├── TestHandler.java # 시험 기능 -│ ├── DailyStudyHandler.java # 일일 학습 -│ ├── StatisticsHandler.java # 통계 -│ ├── StatsHandler.java # 간단 통계 -│ ├── VoiceHandler.java # TTS -│ └── WordGroupHandler.java # 단어 그룹 -│ -├── service/ -│ ├── WordCommandService.java # 단어 변경 (CQRS) -│ ├── WordQueryService.java # 단어 조회 (CQRS) -│ ├── WordService.java # 단어 통합 서비스 -│ ├── UserWordCommandService.java # 사용자 학습 변경 -│ ├── UserWordQueryService.java # 사용자 학습 조회 -│ ├── UserWordService.java # 사용자 학습 통합 -│ ├── TestCommandService.java # 시험 변경 -│ ├── TestQueryService.java # 시험 조회 -│ ├── TestService.java # 시험 통합 -│ ├── DailyStudyCommandService.java # 일일학습 변경 -│ ├── DailyStudyQueryService.java # 일일학습 조회 -│ ├── DailyStudyService.java # 일일학습 통합 -│ ├── WordGroupCommandService.java # 그룹 변경 -│ ├── WordGroupQueryService.java # 그룹 조회 -│ ├── StatisticsService.java # 통계 -│ └── StatsService.java # 간단 통계 -│ -├── repository/ -│ ├── WordRepository.java # 단어 데이터 접근 -│ ├── UserWordRepository.java # 사용자 학습 데이터 접근 -│ ├── TestResultRepository.java # 시험 결과 데이터 접근 -│ ├── DailyStudyRepository.java # 일일 학습 데이터 접근 -│ └── WordGroupRepository.java # 그룹 데이터 접근 -│ -├── model/ -│ ├── Word.java # 단어 엔티티 -│ ├── UserWord.java # 사용자 학습 엔티티 -│ ├── TestResult.java # 시험 결과 엔티티 -│ ├── DailyStudy.java # 일일 학습 엔티티 -│ └── WordGroup.java # 단어 그룹 엔티티 -│ -└── dto/ - └── request/ - ├── CreateWordRequest.java - ├── CreateWordsBatchRequest.java - ├── BatchGetWordsRequest.java - ├── UpdateUserWordRequest.java - ├── UpdateUserWordTagRequest.java - ├── StartTestRequest.java - ├── SubmitTestRequest.java - ├── CreateWordGroupRequest.java - └── SynthesizeVoiceRequest.java -``` - ---- - -## 9. GSI 사용 패턴 - -### 9.1 Word GSI - -```mermaid -flowchart TB - subgraph GSI1["GSI1: 난이도별 조회"] - G1_BEG["LEVEL#BEGINNER"] - G1_INT["LEVEL#INTERMEDIATE"] - G1_ADV["LEVEL#ADVANCED"] - end - - subgraph GSI2["GSI2: 카테고리별 조회"] - G2_DAILY["CATEGORY#DAILY"] - G2_BIZ["CATEGORY#BUSINESS"] - G2_ACAD["CATEGORY#ACADEMIC"] - end - - Q1[getWordsByLevel] --> GSI1 - Q2[getWordsByCategory] --> GSI2 -``` - -### 9.2 UserWord GSI - -```mermaid -flowchart TB - subgraph GSI1["GSI1: 복습 예정 조회"] - G1_REV["USER#{userId}#REVIEW"] - G1_DATE["DATE#{nextReviewAt}"] - end - - subgraph GSI2["GSI2: 상태별 조회"] - G2_STATUS["USER#{userId}#STATUS"] - G2_VAL["STATUS#MASTERED/LEARNING/..."] - end - - Q1[getReviewSchedule] --> GSI1 - Q2[getWordsByStatus] --> GSI2 -``` - ---- - -## 10. 구현 현황 - -### Phase 1 - 핵심 기능 (완료) - -- [x] 단어 CRUD -- [x] 배치 생성/조회 -- [x] 단어 검색 -- [x] CQRS 패턴 적용 -- [x] 커서 기반 페이징 - -### Phase 2 - 학습 기능 (완료) - -- [x] Spaced Repetition 알고리즘 -- [x] UserWord 상태 관리 -- [x] 복습 예정 조회 -- [x] 북마크/즐겨찾기 - -### Phase 3 - 시험/통계 (완료) - -- [x] 시험 시작/제출 -- [x] 시험 결과 저장 -- [x] 일일 학습 기록 -- [x] 학습 통계 - -### Phase 4 - 고급 기능 (완료) - -- [x] TTS (AWS Polly) -- [x] 음성 캐싱 (S3) -- [x] 단어 그룹 - -### Phase 5 - 최적화 (진행 중) - -- [ ] 복습 알림 (SNS) -- [ ] 성취 배지 -- [ ] 랭킹 시스템 - ---- - -**버전**: 1.0.0 -**최종 업데이트**: 2026-01-09 -**팀**: MZC 2nd Project Team From 84ca4820be651777e098fa499ba1e73ac9fd2fad Mon Sep 17 00:00:00 2001 From: hyein Heo <128613248+hye-inA@users.noreply.github.com> Date: Sun, 18 Jan 2026 19:39:20 +0900 Subject: [PATCH 302/528] =?UTF-8?q?feature=20:=20OPIc=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EA=B8=B0=EB=B0=98=20=EA=B5=AC=EC=B6=95=20=20(#372)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat : TranscribeProxyService 구현 * refactor : OPIc 도메인 예외 클래스 분리 * feat : SSM Client 추가, Gradle 의존성 추가 * feat : SSM Client을 통한 Parameter Store 사용 * refactor : 공통 환경변수 추가 및 API Key Parameter Store에서 동적 로드 리팩토링 --- ServerlessFunction/build.gradle | 1 + .../serverless/common/config/AwsClients.java | 8 + .../domain/opic/exception/OPIcException.java | 49 +++++ .../opic/service/TranscribeProxyService.java | 174 ++++++++++++++++++ ServerlessFunction/template.yaml | 145 +++++++++++++++ 5 files changed, 377 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/exception/OPIcException.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/service/TranscribeProxyService.java diff --git a/ServerlessFunction/build.gradle b/ServerlessFunction/build.gradle index 33a01ea6..b6c28326 100644 --- a/ServerlessFunction/build.gradle +++ b/ServerlessFunction/build.gradle @@ -33,6 +33,7 @@ dependencies { implementation 'software.amazon.awssdk:comprehend' implementation 'software.amazon.awssdk:apigatewaymanagementapi' implementation 'software.amazon.awssdk:url-connection-client' + implementation 'software.amazon.awssdk:ssm' // AWS X-Ray SDK (다운스트림 서비스 추적용) implementation 'com.amazonaws:aws-xray-recorder-sdk-core:2.15.0' diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/AwsClients.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/AwsClients.java index 9cb383c5..c5678ecb 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/AwsClients.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/AwsClients.java @@ -11,6 +11,7 @@ import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.presigner.S3Presigner; import software.amazon.awssdk.services.sns.SnsClient; +import software.amazon.awssdk.services.ssm.SsmClient; /** * AWS SDK 클라이언트 싱글톤 관리 @@ -54,6 +55,11 @@ public final class AwsClients { private static final ComprehendClient COMPREHEND_CLIENT = ComprehendClient.builder() .overrideConfiguration(XRAY_CONFIG) .build(); + + // SSM (Parameter Store) + private static final SsmClient SSM_CLIENT = SsmClient.builder() + .overrideConfiguration(XRAY_CONFIG) + .build(); private AwsClients() { // 인스턴스화 방지 @@ -94,4 +100,6 @@ public static BedrockRuntimeAsyncClient bedrockAsync() { public static ComprehendClient comprehend() { return COMPREHEND_CLIENT; } + + public static SsmClient ssm() { return SSM_CLIENT; } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/exception/OPIcException.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/exception/OPIcException.java new file mode 100644 index 00000000..9e3abfb6 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/exception/OPIcException.java @@ -0,0 +1,49 @@ +package com.mzc.secondproject.serverless.domain.opic.exception; + +/** + * OPIc 도메인 공통 예외 + */ +public class OPIcException extends RuntimeException{ + public OPIcException(String message) { + super(message); + } + + public OPIcException(String message, Throwable cause) { + super(message, cause); + } + + /** + * Transcribe 관련 예외 + */ + public static class TranscribeException extends OPIcException { + public TranscribeException(String message) { + super(message); + } + + public TranscribeException(String message, Throwable cause) { + super(message, cause); + } + } + + /** + * 세션 관련 예외 + */ + public static class SessionException extends OPIcException { + public SessionException(String message) { + super(message); + } + } + + /** + * 피드백 생성 예외 + */ + public static class FeedbackException extends OPIcException { + public FeedbackException(String message) { + super(message); + } + + public FeedbackException(String message, Throwable cause) { + super(message, cause); + } + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/service/TranscribeProxyService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/service/TranscribeProxyService.java new file mode 100644 index 00000000..2183e30e --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/service/TranscribeProxyService.java @@ -0,0 +1,174 @@ +package com.mzc.secondproject.serverless.domain.opic.service; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.mzc.secondproject.serverless.domain.opic.exception.OPIcException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.services.ssm.SsmClient; +import software.amazon.awssdk.services.ssm.model.GetParameterRequest; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; + +/** + * 개인 AWS 계정의 Transcribe Proxy API 호출 서비스 + * Cross-Account로 Transcribe 기능 사용 + */ +public class TranscribeProxyService { + + private static final Logger logger = LoggerFactory.getLogger(TranscribeProxyService.class); + private static final Gson gson = new Gson(); + + private static final SsmClient ssmClient = SsmClient.builder().build(); + + // API Key 캐싱 (Lambda 인스턴스 재사용 시 SSM 호출 최소화) + private static String cachedApiKey = null; + + private final String proxyUrl; + private final String apiKeyParamName; + private final HttpClient httpClient; + + public TranscribeProxyService() { + this.proxyUrl = System.getenv("TRANSCRIBE_PROXY_URL"); + this.apiKeyParamName = System.getenv("TRANSCRIBE_API_KEY"); + this.httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .build(); + + if (proxyUrl == null || apiKeyParamName == null) { + logger.warn("TRANSCRIBE_PROXY_URL or TRANSCRIBE_API_KEY is not set"); + } + } + + // 테스트용 생성자 + public TranscribeProxyService(String proxyUrl, String apiKey) { + this.proxyUrl = proxyUrl; + this.apiKeyParamName = apiKey; + this.httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .build(); + } + + /** + * API Key 조회 (Parameter Store에서 + 캐싱) + */ + private String getApiKey() { + if (cachedApiKey != null) { + return cachedApiKey; + } + + try { + logger.debug("Fetching API Key from Parameter Store: {}", apiKeyParamName); + + var response = ssmClient.getParameter( + GetParameterRequest.builder() + .name(apiKeyParamName) + .withDecryption(true) + .build() + ); + + cachedApiKey = response.parameter().value(); + logger.info("API Key loaded from Parameter Store"); + + return cachedApiKey; + } catch (Exception e) { + logger.error("Failed to get API Key from Parameter Store", e); + throw new OPIcException.TranscribeException("API Key 로드 실패", e); + } + } + + /** + * 음성 파일을 텍스트로 변환 + * + * @param audioBase64 Base64 인코딩된 음성 데이터 + * @param sessionId 세션 ID + * @return 변환된 텍스트 결과 + */ + public TranscribeResult transcribe(String audioBase64, String sessionId) { + return transcribe(audioBase64, sessionId, "en-US"); + } + + /** + * 음성 파일을 텍스트로 변환 (언어 지정) + * + * @param audioBase64 Base64 인코딩된 음성 데이터 + * @param sessionId 세션 ID + * @param languageCode 언어 코드 (en-US, ko-KR 등) + * @return 변환된 텍스트 결과 + */ + public TranscribeResult transcribe(String audioBase64, String sessionId, String languageCode) { + logger.info("Transcribe 요청 시작 - sessionId: {}, language: {}", sessionId, languageCode); + + try { + // API Key 조회 + String apiKey = getApiKey(); + + // 요청 바디 생성 + JsonObject requestBody = new JsonObject(); + requestBody.addProperty("audio_data", audioBase64); + requestBody.addProperty("session_id", sessionId); + requestBody.addProperty("language_code", languageCode); + + // HTTP 요청 생성 + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(proxyUrl)) + .header("Content-Type", "application/json") + .header("X-Api-Key", apiKey) + .timeout(Duration.ofSeconds(120)) // Transcribe 처리 시간 고려 + .POST(HttpRequest.BodyPublishers.ofString(gson.toJson(requestBody))) + .build(); + + long startTime = System.currentTimeMillis(); + + // API 호출 + HttpResponse response = httpClient.send( + request, + HttpResponse.BodyHandlers.ofString() + ); + + long elapsed = System.currentTimeMillis() - startTime; + logger.info("Transcribe Proxy 응답 - status: {}, 소요시간: {}ms", + response.statusCode(), elapsed); + + // 응답 처리 + if (response.statusCode() != 200) { + logger.error("Transcribe 실패 - status: {}, body: {}", + response.statusCode(), response.body()); + throw new OPIcException.TranscribeException("Transcribe 실패: " + response.statusCode()); + } + + // JSON 파싱 + JsonObject resultJson = JsonParser.parseString(response.body()).getAsJsonObject(); + + String transcript = resultJson.get("transcript").getAsString(); + String jobName = resultJson.get("job_name").getAsString(); + double confidence = resultJson.get("confidence").getAsDouble(); + + logger.info("Transcribe 완료 - jobName: {}, confidence: {}", jobName, confidence); + logger.debug("Transcript: {}", transcript); + + return new TranscribeResult(transcript, jobName, confidence); + + } catch (OPIcException.TranscribeException e) { + throw e; + } catch (Exception e) { + logger.error("Transcribe 호출 중 오류 발생", e); + throw new OPIcException.TranscribeException("음성 변환 실패: " + e.getMessage(), e); + } + } + + /** + * Transcribe 결과 레코드 + */ + public record TranscribeResult( + String transcript, + String jobName, + double confidence + ) {} + +} \ No newline at end of file diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 87f4ccce..570dd871 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -15,12 +15,15 @@ Globals: USER_TABLE_NAME: !Ref UserTable CHAT_TABLE_NAME: !Ref ChatTable VOCAB_TABLE_NAME: !Ref VocabTable + OPIC_TABLE_NAME: !Ref OPIcTable BUCKET_NAME: group2-englishstudy CHAT_BUCKET_NAME: group2-englishstudy VOCAB_BUCKET_NAME: group2-englishstudy PROFILE_BUCKET_NAME: group2-englishstudy + OPIC_BUCKET_NAME: group2-englishstudy AWS_REGION_NAME: !Ref AWS::Region ROOM_TOKEN_TTL_SECONDS: "300" + TRANSCRIBE_PROXY_URL: "https://tfo1zm7vec.execute-api.ap-northeast-2.amazonaws.com/prod/transcribe" Api: TracingEnabled: true @@ -1262,6 +1265,112 @@ Resources: Description: Daily word learning stats aggregation Enabled: true + ############################################# + # OPIc Lambda Functions + ############################################# + + OPIcSessionFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: group2-englishstudy-opic-session-handler + CodeUri: . + Handler: com.mzc.secondproject.serverless.domain.opic.handler.OPIcSessionHandler::handleRequest + Description: Handle OPIc speaking practice sessions + Timeout: 180 + MemorySize: 1024 + SnapStart: + ApplyOn: PublishedVersions + Environment: + Variables: + TRANSCRIBE_API_KEY_PARAM: "/opic/transcribe-proxy-api-key" + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref OPIcTable + - S3CrudPolicy: + BucketName: group2-englishstudy + - Statement: + - Effect: Allow + Action: + - bedrock:InvokeModel + - bedrock:InvokeModelWithResponseStream + Resource: "*" + - Statement: + - Effect: Allow + Action: + - polly:SynthesizeSpeech + - polly:DescribeVoices + Resource: "*" + # Parameter Store 읽기 권한 추가 + - Statement: + - Effect: Allow + Action: + - ssm:GetParameter + Resource: !Sub "arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/opic/*" + Events: + # 세션 생성 + CreateSession: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /opic/sessions + Method: POST + Auth: + Authorizer: CognitoAuthorizer + # 세션 조회 + GetSession: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /opic/sessions/{sessionId} + Method: GET + Auth: + Authorizer: CognitoAuthorizer + # 세션 목록 조회 + GetSessions: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /opic/sessions + Method: GET + Auth: + Authorizer: CognitoAuthorizer + # 다음 질문 조회 + GetNextQuestion: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /opic/sessions/{sessionId}/questions/next + Method: GET + Auth: + Authorizer: CognitoAuthorizer + # 답변 제출 + SubmitAnswer: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /opic/sessions/{sessionId}/answers + Method: POST + Auth: + Authorizer: CognitoAuthorizer + # 세션 완료 + CompleteSession: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /opic/sessions/{sessionId}/complete + Method: POST + Auth: + Authorizer: CognitoAuthorizer + # 음성 업로드 Presigned URL + GetUploadUrl: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /opic/sessions/{sessionId}/upload-url + Method: GET + Auth: + Authorizer: CognitoAuthorizer + ############################################# # DynamoDB Tables ############################################# @@ -1415,6 +1524,38 @@ Resources: AttributeName: ttl Enabled: true + OPIcTable: + Type: AWS::DynamoDB::Table + Properties: + TableName: group2-englishstudy-opic + BillingMode: PAY_PER_REQUEST + AttributeDefinitions: + - AttributeName: PK + AttributeType: S + - AttributeName: SK + AttributeType: S + - AttributeName: GSI1PK + AttributeType: S + - AttributeName: GSI1SK + AttributeType: S + KeySchema: + - AttributeName: PK + KeyType: HASH + - AttributeName: SK + KeyType: RANGE + GlobalSecondaryIndexes: + - IndexName: GSI1 + KeySchema: + - AttributeName: GSI1PK + KeyType: HASH + - AttributeName: GSI1SK + KeyType: RANGE + Projection: + ProjectionType: ALL + TimeToLiveSpecification: + AttributeName: ttl + Enabled: true + ############################################# # SNS / SQS for Async Statistics Processing ############################################# @@ -1527,3 +1668,7 @@ Outputs: CognitoUserPoolClientId: Description: Cognito User Pool Client ID Value: !Ref CognitoUserPoolClient + + OPIcTableName: + Description: OPIc DynamoDB Table Name + Value: !Ref OPIcTable From ea2460cfe42b33e43fa3a5ea002f7a934f56b693 Mon Sep 17 00:00:00 2001 From: hyein Heo <128613248+hye-inA@users.noreply.github.com> Date: Sun, 18 Jan 2026 20:41:53 +0900 Subject: [PATCH 303/528] =?UTF-8?q?feat=20:=20OPIc=20=EC=84=B8=EC=85=98,?= =?UTF-8?q?=20=EC=A7=88=EB=AC=B8=20=EB=A7=88=EC=8A=A4=ED=84=B0,=20?= =?UTF-8?q?=EB=8B=B5=EB=B3=80/=ED=94=BC=EB=93=9C=EB=B0=B1=20=EB=AA=A8?= =?UTF-8?q?=EB=8D=B8=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?(#373)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/opic/model/OPIcAnswer.java | 153 ++++++++++++++++++ .../domain/opic/model/OPIcQuestion.java | 86 ++++++++++ .../domain/opic/model/OPIcSession.java | 128 +++++++++++++++ 3 files changed, 367 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/model/OPIcAnswer.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/model/OPIcQuestion.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/model/OPIcSession.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/model/OPIcAnswer.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/model/OPIcAnswer.java new file mode 100644 index 00000000..e6e5da95 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/model/OPIcAnswer.java @@ -0,0 +1,153 @@ +package com.mzc.secondproject.serverless.domain.opic.model; + +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbAttribute; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSortKey; + +import java.time.Instant; + +/** + * OPIc 답변 + 피드백 + */ +public class OPIcAnswer { + + private String pk; // SESSION#sessionId + private String sk; // Q#01 (질문 순서) + + private String sessionId; + private String questionId; + private int questionIndex; // 질문 순서 (0, 1, 2) + + // 질문 정보 (비정규화) + private String questionText; + + // 사용자 답변 + private String audioS3Key; // 답변 음성 S3 키 + private String transcript; // 음성 → 텍스트 + private double transcriptConfidence; // 변환 신뢰도 + private int durationSeconds; // 답변 길이 (초) + + // AI 피드백 + private String score; // 예상 등급 (IM1, IM2, IM3, IH, AL) + private String grammarFeedback; // 문법 피드백 (JSON) + private String vocabularyFeedback; // 어휘 피드백 (JSON) + private String contentFeedback; // 내용 피드백 + private String pronunciationFeedback; // 발음 피드백 + private String strengths; // 잘한 점 (JSON array) + private String improvements; // 개선점 (JSON array) + + // 모범 답변 + private String sampleAnswer; // 모범 답변 텍스트 + private String sampleAudioS3Key; // 모범 답변 음성 S3 키 + + // 메타데이터 + private AnswerStatus status; + private int attemptCount; // 시도 횟수 + private Instant createdAt; + private Instant completedAt; + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { return pk; } + public void setPk(String pk) { this.pk = pk; } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { return sk; } + public void setSk(String sk) { this.sk = sk; } + + @DynamoDbAttribute("sessionId") + public String getSessionId() { return sessionId; } + public void setSessionId(String id) { this.sessionId = id; } + + @DynamoDbAttribute("questionId") + public String getQuestionId() { return questionId; } + public void setQuestionId(String id) { this.questionId = id; } + + @DynamoDbAttribute("questionIndex") + public int getQuestionIndex() { return questionIndex; } + public void setQuestionIndex(int idx) { this.questionIndex = idx; } + + @DynamoDbAttribute("questionText") + public String getQuestionText() { return questionText; } + public void setQuestionText(String text) { this.questionText = text; } + + @DynamoDbAttribute("audioS3Key") + public String getAudioS3Key() { return audioS3Key; } + public void setAudioS3Key(String key) { this.audioS3Key = key; } + + @DynamoDbAttribute("transcript") + public String getTranscript() { return transcript; } + public void setTranscript(String transcript) { this.transcript = transcript; } + + @DynamoDbAttribute("transcriptConfidence") + public double getTranscriptConfidence() { return transcriptConfidence; } + public void setTranscriptConfidence(double conf) { this.transcriptConfidence = conf; } + + @DynamoDbAttribute("durationSeconds") + public int getDurationSeconds() { return durationSeconds; } + public void setDurationSeconds(int duration) { this.durationSeconds = duration; } + + @DynamoDbAttribute("score") + public String getScore() { return score; } + public void setScore(String score) { this.score = score; } + + @DynamoDbAttribute("grammarFeedback") + public String getGrammarFeedback() { return grammarFeedback; } + public void setGrammarFeedback(String feedback) { this.grammarFeedback = feedback; } + + @DynamoDbAttribute("vocabularyFeedback") + public String getVocabularyFeedback() { return vocabularyFeedback; } + public void setVocabularyFeedback(String feedback) { this.vocabularyFeedback = feedback; } + + @DynamoDbAttribute("contentFeedback") + public String getContentFeedback() { return contentFeedback; } + public void setContentFeedback(String feedback) { this.contentFeedback = feedback; } + + @DynamoDbAttribute("pronunciationFeedback") + public String getPronunciationFeedback() { return pronunciationFeedback; } + public void setPronunciationFeedback(String feedback) { this.pronunciationFeedback = feedback; } + + @DynamoDbAttribute("strengths") + public String getStrengths() { return strengths; } + public void setStrengths(String strengths) { this.strengths = strengths; } + + @DynamoDbAttribute("improvements") + public String getImprovements() { return improvements; } + public void setImprovements(String improvements) { this.improvements = improvements; } + + @DynamoDbAttribute("sampleAnswer") + public String getSampleAnswer() { return sampleAnswer; } + public void setSampleAnswer(String answer) { this.sampleAnswer = answer; } + + @DynamoDbAttribute("sampleAudioS3Key") + public String getSampleAudioS3Key() { return sampleAudioS3Key; } + public void setSampleAudioS3Key(String key) { this.sampleAudioS3Key = key; } + + @DynamoDbAttribute("status") + public AnswerStatus getStatus() { return status; } + public void setStatus(AnswerStatus status) { this.status = status; } + + @DynamoDbAttribute("attemptCount") + public int getAttemptCount() { return attemptCount; } + public void setAttemptCount(int count) { this.attemptCount = count; } + + @DynamoDbAttribute("createdAt") + public Instant getCreatedAt() { return createdAt; } + public void setCreatedAt(Instant time) { this.createdAt = time; } + + @DynamoDbAttribute("completedAt") + public Instant getCompletedAt() { return completedAt; } + public void setCompletedAt(Instant time) { this.completedAt = time; } + + /** + * 답변 상태 + */ + public enum AnswerStatus { + PENDING, // 음성 업로드 대기 + UPLOADED, // 음성 업로드 완료 + PROCESSING, // Transcribe + Bedrock 처리 중 + COMPLETED, // 피드백 완료 + FAILED // 처리 실패 + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/model/OPIcQuestion.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/model/OPIcQuestion.java new file mode 100644 index 00000000..c0ab96fd --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/model/OPIcQuestion.java @@ -0,0 +1,86 @@ +package com.mzc.secondproject.serverless.domain.opic.model; + +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.*; + +/** + * OPIc 질문 마스터 데이터 + */ +@DynamoDbBean +public class OPIcQuestion { + + private String pk; // QUESTION#questionId + private String sk; // METADATA + private String gsi1pk; // TOPIC#travel + private String gsi1sk; // LEVEL#IM2 + + private String questionId; + private String topic; // 대주제 + private String subTopic; // 소주제 + private String level; // 난이도 (IM1, IM2, IM3, IH, AL) + private String questionText; // 질문 텍스트 (영어) + private String questionTextKo; // 질문 텍스트 (한국어, 참고용) + private String audioS3Key; // 질문 음성 S3 키 (Polly 캐시) + private String tips; // 답변 팁 + private int orderInSet; // 콤보 세트 내 순서 (1, 2, 3) + private boolean isActive; // 활성화 여부 + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { return pk; } + public void setPk(String pk) { this.pk = pk; } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { return sk; } + public void setSk(String sk) { this.sk = sk; } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1PK") + public String getGsi1pk() { return gsi1pk; } + public void setGsi1pk(String gsi1pk) { this.gsi1pk = gsi1pk; } + + @DynamoDbSecondarySortKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1SK") + public String getGsi1sk() { return gsi1sk; } + public void setGsi1sk(String gsi1sk) { this.gsi1sk = gsi1sk; } + + @DynamoDbAttribute("questionId") + public String getQuestionId() { return questionId; } + public void setQuestionId(String id) { this.questionId = id; } + + @DynamoDbAttribute("topic") + public String getTopic() { return topic; } + public void setTopic(String topic) { this.topic = topic; } + + @DynamoDbAttribute("subTopic") + public String getSubTopic() { return subTopic; } + public void setSubTopic(String subTopic) { this.subTopic = subTopic; } + + @DynamoDbAttribute("level") + public String getLevel() { return level; } + public void setLevel(String level) { this.level = level; } + + @DynamoDbAttribute("questionText") + public String getQuestionText() { return questionText; } + public void setQuestionText(String text) { this.questionText = text; } + + @DynamoDbAttribute("questionTextKo") + public String getQuestionTextKo() { return questionTextKo; } + public void setQuestionTextKo(String text) { this.questionTextKo = text; } + + @DynamoDbAttribute("audioS3Key") + public String getAudioS3Key() { return audioS3Key; } + public void setAudioS3Key(String key) { this.audioS3Key = key; } + + @DynamoDbAttribute("tips") + public String getTips() { return tips; } + public void setTips(String tips) { this.tips = tips; } + + @DynamoDbAttribute("orderInSet") + public int getOrderInSet() { return orderInSet; } + public void setOrderInSet(int order) { this.orderInSet = order; } + + @DynamoDbAttribute("isActive") + public boolean isActive() { return isActive; } + public void setActive(boolean active) { this.isActive = active; } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/model/OPIcSession.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/model/OPIcSession.java new file mode 100644 index 00000000..24790ff7 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/model/OPIcSession.java @@ -0,0 +1,128 @@ +package com.mzc.secondproject.serverless.domain.opic.model; + +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.*; + +import java.time.Instant; +import java.util.List; + + +/** + * OPIc 세션 + */ +@DynamoDbBean +public class OPIcSession { + + private String pk; // USER#userId + private String sk; // SESSION#date#sessionId + private String gsi1pk; // SESSION#sessionId + private String gsi1sk; // METADATA + + private String sessionId; + private String userId; + private String topic; // 대주제 (travel, hobby, work 등) + private String subTopic; // 소주제 + private String targetLevel; // 목표 레벨 (IM1, IM2, IM3, IH, AL) + private SessionStatus status; // IN_PROGRESS, COMPLETED, ABANDONED + private int currentQuestionIndex; // 현재 진행 중인 질문 (0, 1, 2) + private int totalQuestions; // 총 질문 수 (기본 3) + private List questionIds; // 질문 ID 목록 + private Instant createdAt; + private Instant updatedAt; + private Instant completedAt; + private int sequenceNumber; // 인과적 일관성용 + + // 종합 결과 (세션 완료 시) + private String overallScore; // 종합 예상 등급 + private String overallFeedback; // 종합 피드백 + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { return pk; } + public void setPk(String pk) { this.pk = pk; } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { return sk; } + public void setSk(String sk) { this.sk = sk; } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1PK") + public String getGsi1pk() { return gsi1pk; } + public void setGsi1pk(String gsi1pk) { this.gsi1pk = gsi1pk; } + + @DynamoDbSecondarySortKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1SK") + public String getGsi1sk() { return gsi1sk; } + public void setGsi1sk(String gsi1sk) { this.gsi1sk = gsi1sk; } + + @DynamoDbAttribute("sessionId") + public String getSessionId() { return sessionId; } + public void setSessionId(String sessionId) { this.sessionId = sessionId; } + + @DynamoDbAttribute("userId") + public String getUserId() { return userId; } + public void setUserId(String userId) { this.userId = userId; } + + @DynamoDbAttribute("topic") + public String getTopic() { return topic; } + public void setTopic(String topic) { this.topic = topic; } + + @DynamoDbAttribute("subTopic") + public String getSubTopic() { return subTopic; } + public void setSubTopic(String subTopic) { this.subTopic = subTopic; } + + @DynamoDbAttribute("targetLevel") + public String getTargetLevel() { return targetLevel; } + public void setTargetLevel(String targetLevel) { this.targetLevel = targetLevel; } + + @DynamoDbAttribute("status") + public SessionStatus getStatus() { return status; } + public void setStatus(SessionStatus status) { this.status = status; } + + @DynamoDbAttribute("currentQuestionIndex") + public int getCurrentQuestionIndex() { return currentQuestionIndex; } + public void setCurrentQuestionIndex(int idx) { this.currentQuestionIndex = idx; } + + @DynamoDbAttribute("totalQuestions") + public int getTotalQuestions() { return totalQuestions; } + public void setTotalQuestions(int total) { this.totalQuestions = total; } + + @DynamoDbAttribute("questionIds") + public List getQuestionIds() { return questionIds; } + public void setQuestionIds(List ids) { this.questionIds = ids; } + + @DynamoDbAttribute("createdAt") + public Instant getCreatedAt() { return createdAt; } + public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; } + + @DynamoDbAttribute("updatedAt") + public Instant getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(Instant updatedAt) { this.updatedAt = updatedAt; } + + @DynamoDbAttribute("completedAt") + public Instant getCompletedAt() { return completedAt; } + public void setCompletedAt(Instant completedAt) { this.completedAt = completedAt; } + + @DynamoDbAttribute("sequenceNumber") + public int getSequenceNumber() { return sequenceNumber; } + public void setSequenceNumber(int seq) { this.sequenceNumber = seq; } + + @DynamoDbAttribute("overallScore") + public String getOverallScore() { return overallScore; } + public void setOverallScore(String score) { this.overallScore = score; } + + @DynamoDbAttribute("overallFeedback") + public String getOverallFeedback() { return overallFeedback; } + public void setOverallFeedback(String feedback) { this.overallFeedback = feedback; } + + /** + * 세션 상태 + */ + public enum SessionStatus { + IN_PROGRESS, + COMPLETED, + ABANDONED + } +} + + From 7bcda81a4461d62ee3c58f2d8e10249a47459af1 Mon Sep 17 00:00:00 2001 From: hyein Heo <128613248+hye-inA@users.noreply.github.com> Date: Sun, 18 Jan 2026 21:56:39 +0900 Subject: [PATCH 304/528] =?UTF-8?q?feature(opic)=20:=20OPIc=20Repository?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84=20(#374)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat : OPIc 세션 CRUD 구현 * feat : OPIc 질문 CRUD 구현 * feat : OPIc 답변 CRUD 구현 --- .../opic/repository/OPIcRepository.java | 256 ++++++++++++++++++ 1 file changed, 256 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/repository/OPIcRepository.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/repository/OPIcRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/repository/OPIcRepository.java new file mode 100644 index 00000000..2b5fd803 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/repository/OPIcRepository.java @@ -0,0 +1,256 @@ +package com.mzc.secondproject.serverless.domain.opic.repository; + +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.domain.opic.model.OPIcAnswer; +import com.mzc.secondproject.serverless.domain.opic.model.OPIcQuestion; +import com.mzc.secondproject.serverless.domain.opic.model.OPIcSession; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.*; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.stream.Collectors; + +public class OPIcRepository { + + private static final Logger logger = LoggerFactory.getLogger(OPIcRepository.class); + private static final String TABLE_NAME = System.getenv("OPIC_TABLE_NAME"); + + private final DynamoDbEnhancedClient enhancedClient; + private final DynamoDbTable sessionTable; + private final DynamoDbTable questionTable; + private final DynamoDbTable answerTable; + + public OPIcRepository() { + this.enhancedClient = AwsClients.dynamoDbEnhanced(); + this.sessionTable = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(OPIcSession.class)); + this.questionTable = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(OPIcQuestion.class)); + this.answerTable = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(OPIcAnswer.class)); + } + + // ==================== Session ==================== + + /** + * 새 세션 생성 + */ + public OPIcSession createSession(String userId, String topic, String subTopic, + String targetLevel, List questionIds) { + String sessionId = UUID.randomUUID().toString(); + String today = LocalDate.now(ZoneId.of("Asia/Seoul")) + .format(DateTimeFormatter.ISO_LOCAL_DATE); + Instant now = Instant.now(); + + OPIcSession session = new OPIcSession(); + session.setPk("USER#" + userId); + session.setSk("SESSION#" + today + "#" + sessionId); + session.setGsi1pk("SESSION#" + sessionId); + session.setGsi1sk("METADATA"); + + session.setSessionId(sessionId); + session.setUserId(userId); + session.setTopic(topic); + session.setSubTopic(subTopic); + session.setTargetLevel(targetLevel); + session.setStatus(OPIcSession.SessionStatus.IN_PROGRESS); + session.setCurrentQuestionIndex(0); + session.setTotalQuestions(questionIds.size()); + session.setQuestionIds(questionIds); + session.setCreatedAt(now); + session.setUpdatedAt(now); + session.setSequenceNumber(0); + + sessionTable.putItem(session); + logger.info("Session created: {}", sessionId); + + return session; + } + + /** + * 세션 ID로 조회 (GSI1 사용) + */ + public Optional findSessionById(String sessionId) { + DynamoDbIndex gsi1 = sessionTable.index("GSI1"); + + QueryConditional queryConditional = QueryConditional.keyEqualTo( + Key.builder() + .partitionValue("SESSION#" + sessionId) + .sortValue("METADATA") + .build() + ); + + return gsi1.query(QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .build()) + .stream() + .flatMap(page -> page.items().stream()) + .findFirst(); + } + + /** + * 사용자의 세션 목록 조회 (최신순) + */ + public List findSessionsByUserId(String userId, int limit) { + QueryConditional queryConditional = QueryConditional.sortBeginsWith( + Key.builder() + .partitionValue("USER#" + userId) + .sortValue("SESSION#") + .build() + ); + + return sessionTable.query(QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .scanIndexForward(false) // 최신순 + .limit(limit) + .build()) + .stream() + .flatMap(page -> page.items().stream()) + .collect(Collectors.toList()); + } + + /** + * 세션 업데이트 + */ + public void updateSession(OPIcSession session) { + session.setUpdatedAt(Instant.now()); + session.setSequenceNumber(session.getSequenceNumber() + 1); + sessionTable.putItem(session); + logger.debug("Session updated: {}", session.getSessionId()); + } + + /** + * 세션 완료 처리 + */ + public void completeSession(OPIcSession session, String overallScore, String overallFeedback) { + session.setStatus(OPIcSession.SessionStatus.COMPLETED); + session.setOverallScore(overallScore); + session.setOverallFeedback(overallFeedback); + session.setCompletedAt(Instant.now()); + updateSession(session); + logger.info("Session completed: {}", session.getSessionId()); + } + + + // ==================== Question ==================== + + /** + * 질문 ID로 조회 + */ + public Optional findQuestionById(String questionId) { + Key key = Key.builder() + .partitionValue("QUESTION#" + questionId) + .sortValue("METADATA") + .build(); + + return Optional.ofNullable(questionTable.getItem(key)); + } + + /** + * 주제 + 레벨로 질문 조회 (GSI1) + */ + public List findQuestionsByTopicAndLevel(String topic, String level) { + DynamoDbIndex gsi1 = questionTable.index("GSI1"); + + QueryConditional queryConditional = QueryConditional.keyEqualTo( + Key.builder() + .partitionValue("TOPIC#" + topic) + .sortValue("LEVEL#" + level) + .build() + ); + + return gsi1.query(queryConditional) + .stream() + .flatMap(page -> page.items().stream()) + .filter(OPIcQuestion::isActive) + .collect(Collectors.toList()); + } + + /** + * 여러 질문 ID로 조회 + */ + public List findQuestionsByIds(List questionIds) { + return questionIds.stream() + .map(this::findQuestionById) + .filter(Optional::isPresent) + .map(Optional::get) + .collect(Collectors.toList()); + } + + /** + * 질문 저장 (마스터 데이터 등록용) + */ + public void saveQuestion(OPIcQuestion question) { + question.setPk("QUESTION#" + question.getQuestionId()); + question.setSk("METADATA"); + question.setGsi1pk("TOPIC#" + question.getTopic()); + question.setGsi1sk("LEVEL#" + question.getLevel()); + + questionTable.putItem(question); + logger.info("Question saved: {}", question.getQuestionId()); + } + + // ==================== Answer ==================== + + /** + * 답변 저장 + */ + public void saveAnswer(OPIcAnswer answer) { + answer.setPk("SESSION#" + answer.getSessionId()); + answer.setSk(String.format("Q#%02d", answer.getQuestionIndex())); + + if (answer.getCreatedAt() == null) { + answer.setCreatedAt(Instant.now()); + } + + answerTable.putItem(answer); + logger.debug("Answer saved: session={}, questionIndex={}", + answer.getSessionId(), answer.getQuestionIndex()); + } + + /** + * 세션의 특정 질문 답변 조회 + */ + public Optional findAnswer(String sessionId, int questionIndex) { + Key key = Key.builder() + .partitionValue("SESSION#" + sessionId) + .sortValue(String.format("Q#%02d", questionIndex)) + .build(); + + return Optional.ofNullable(answerTable.getItem(key)); + } + + /** + * 세션의 모든 답변 조회 + */ + public List findAnswersBySessionId(String sessionId) { + QueryConditional queryConditional = QueryConditional.sortBeginsWith( + Key.builder() + .partitionValue("SESSION#" + sessionId) + .sortValue("Q#") + .build() + ); + + return answerTable.query(queryConditional) + .stream() + .flatMap(page -> page.items().stream()) + .collect(Collectors.toList()); + } + + /** + * 답변 업데이트 (피드백 추가 등) + */ + public void updateAnswer(OPIcAnswer answer) { + answerTable.putItem(answer); + logger.debug("Answer updated: session={}, questionIndex={}", + answer.getSessionId(), answer.getQuestionIndex()); + } + + +} From 1b9ee21d8f7a36670ace914c0aaeb7ba0100f1b6 Mon Sep 17 00:00:00 2001 From: hyein Heo <128613248+hye-inA@users.noreply.github.com> Date: Mon, 19 Jan 2026 12:09:28 +0900 Subject: [PATCH 305/528] =?UTF-8?q?feature=20:=20FeedbackService=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(#375)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feature : OPIc 도메인 기반 구축 (#372) * feat : TranscribeProxyService 구현 * refactor : OPIc 도메인 예외 클래스 분리 * feat : SSM Client 추가, Gradle 의존성 추가 * feat : SSM Client을 통한 Parameter Store 사용 * refactor : 공통 환경변수 추가 및 API Key Parameter Store에서 동적 로드 리팩토링 * feat : OPIc 세션, 질문 마스터, 답변/피드백 모델 클래스 생성 (#373) * feature(opic) : OPIc Repository 구현 (#374) * feat : OPIc 세션 CRUD 구현 * feat : OPIc 질문 CRUD 구현 * feat : OPIc 답변 CRUD 구현 * feat : 긴 에러 응답 처리 truncate로 200자만 보여주는 예외 메서드 추가 * feat : OPIc 피드백 유형 Enum 클래스 추가 * feat : 질문 답변, 세션 전체 리포트 피드백 응답 record 추가 * feat : Json 관련 Util 생성 * feat : 오류/개선점 목록 응답 dto 생성 * feat : OPIc 피드백(개별 답변, 세션 전체) 생성 서비스 코드 작성 --- ServerlessFunction/build.gradle | 1 + .../serverless/common/config/AwsClients.java | 8 + .../serverless/common/util/JsonUtil.java | 39 +++ .../opic/dto/response/FeedbackResponse.java | 14 + .../dto/response/SessionReportResponse.java | 15 + .../opic/dto/response/SpeakingError.java | 18 ++ .../domain/opic/enums/SpeakingErrorType.java | 7 + .../domain/opic/exception/OPIcException.java | 76 +++++ .../domain/opic/model/OPIcAnswer.java | 153 +++++++++ .../domain/opic/model/OPIcQuestion.java | 86 +++++ .../domain/opic/model/OPIcSession.java | 128 ++++++++ .../opic/repository/OPIcRepository.java | 256 +++++++++++++++ .../domain/opic/service/FeedbackService.java | 299 ++++++++++++++++++ .../opic/service/TranscribeProxyService.java | 174 ++++++++++ ServerlessFunction/template.yaml | 145 +++++++++ 15 files changed, 1419 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/JsonUtil.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/FeedbackResponse.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/SessionReportResponse.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/SpeakingError.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/enums/SpeakingErrorType.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/exception/OPIcException.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/model/OPIcAnswer.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/model/OPIcQuestion.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/model/OPIcSession.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/repository/OPIcRepository.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/service/FeedbackService.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/service/TranscribeProxyService.java diff --git a/ServerlessFunction/build.gradle b/ServerlessFunction/build.gradle index 33a01ea6..b6c28326 100644 --- a/ServerlessFunction/build.gradle +++ b/ServerlessFunction/build.gradle @@ -33,6 +33,7 @@ dependencies { implementation 'software.amazon.awssdk:comprehend' implementation 'software.amazon.awssdk:apigatewaymanagementapi' implementation 'software.amazon.awssdk:url-connection-client' + implementation 'software.amazon.awssdk:ssm' // AWS X-Ray SDK (다운스트림 서비스 추적용) implementation 'com.amazonaws:aws-xray-recorder-sdk-core:2.15.0' diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/AwsClients.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/AwsClients.java index 9cb383c5..c5678ecb 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/AwsClients.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/AwsClients.java @@ -11,6 +11,7 @@ import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.presigner.S3Presigner; import software.amazon.awssdk.services.sns.SnsClient; +import software.amazon.awssdk.services.ssm.SsmClient; /** * AWS SDK 클라이언트 싱글톤 관리 @@ -54,6 +55,11 @@ public final class AwsClients { private static final ComprehendClient COMPREHEND_CLIENT = ComprehendClient.builder() .overrideConfiguration(XRAY_CONFIG) .build(); + + // SSM (Parameter Store) + private static final SsmClient SSM_CLIENT = SsmClient.builder() + .overrideConfiguration(XRAY_CONFIG) + .build(); private AwsClients() { // 인스턴스화 방지 @@ -94,4 +100,6 @@ public static BedrockRuntimeAsyncClient bedrockAsync() { public static ComprehendClient comprehend() { return COMPREHEND_CLIENT; } + + public static SsmClient ssm() { return SSM_CLIENT; } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/JsonUtil.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/JsonUtil.java new file mode 100644 index 00000000..4cf4b544 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/JsonUtil.java @@ -0,0 +1,39 @@ +package com.mzc.secondproject.serverless.common.util; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; + +import java.util.ArrayList; +import java.util.List; + +/** + * JSON 파싱 관련 공통 유틸리티 + */ +public class JsonUtil { + + private JsonUtil() {} + + // 응답에서 JSON 부분만 추출 + public static String extractJson(String response) { + if (response == null || response.isBlank()) { + return null; + } + int start = response.indexOf('{'); + int end = response.lastIndexOf('}'); + if (start != -1 && end != -1 && end > start) { + return response.substring(start, end + 1); + } + return response; + } + + // JsonArray → List 변환 + public static List toStringList(JsonArray array) { + List result = new ArrayList<>(); + if (array != null) { + for (JsonElement el : array) { + result.add(el.getAsString()); + } + } + return result; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/FeedbackResponse.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/FeedbackResponse.java new file mode 100644 index 00000000..42e1df7a --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/FeedbackResponse.java @@ -0,0 +1,14 @@ +package com.mzc.secondproject.serverless.domain.opic.dto.response; +import java.util.List; + + +public record FeedbackResponse ( + List errors, // 오류/개선점 목록 + String correctedAnswer, // 교정된 답변 + String sampleAnswer // 모범 답변 +){ + public static FeedbackResponse perfect(String answer, String sampleAnswer) { + return new FeedbackResponse(List.of(), answer, sampleAnswer); + } +} +가 \ No newline at end of file diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/SessionReportResponse.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/SessionReportResponse.java new file mode 100644 index 00000000..d6351a64 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/SessionReportResponse.java @@ -0,0 +1,15 @@ +package com.mzc.secondproject.serverless.domain.opic.dto.response; + +import java.util.List; + +/** + * 세션 종합 리포트 응답 DTO + */ +public record SessionReportResponse( + String estimatedLevel, // 예상 레벨 (IM1, IM2 등) + int overallScore, // 종합 점수 (0-100) + List strengths, // 잘한 점 + List weaknesses, // 개선할 점 + String feedback, // 종합 피드백 + List recommendations // 학습 추천 +) {} \ No newline at end of file diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/SpeakingError.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/SpeakingError.java new file mode 100644 index 00000000..bc054e33 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/SpeakingError.java @@ -0,0 +1,18 @@ +package com.mzc.secondproject.serverless.domain.opic.dto.response; + +import com.mzc.secondproject.serverless.domain.opic.enums.SpeakingErrorType; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SpeakingError { + private SpeakingErrorType type; + private String original; + private String corrected; + private String explanation; +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/enums/SpeakingErrorType.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/enums/SpeakingErrorType.java new file mode 100644 index 00000000..283f6d00 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/enums/SpeakingErrorType.java @@ -0,0 +1,7 @@ +package com.mzc.secondproject.serverless.domain.opic.enums; + +public enum SpeakingErrorType { + GRAMMAR, // 문법 오류 + EXPRESSION, // 표현 개선 + VOCABULARY // 어휘 개선 +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/exception/OPIcException.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/exception/OPIcException.java new file mode 100644 index 00000000..a02bdb2b --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/exception/OPIcException.java @@ -0,0 +1,76 @@ +package com.mzc.secondproject.serverless.domain.opic.exception; + +/** + * OPIc 도메인 공통 예외 + */ +public class OPIcException extends RuntimeException{ + public OPIcException(String message) { + super(message); + } + + public OPIcException(String message, Throwable cause) { + super(message, cause); + } + + /** + * Transcribe 관련 예외 + */ + public static class TranscribeException extends OPIcException { + public TranscribeException(String message) { + super(message); + } + + public TranscribeException(String message, Throwable cause) { + super(message, cause); + } + } + + /** + * 세션 관련 예외 + */ + public static class SessionException extends OPIcException { + public SessionException(String message) { + super(message); + } + } + + /** + * 피드백 생성 예외 + */ + public static class FeedbackException extends OPIcException { + public FeedbackException(String message) { + super(message); + } + + public FeedbackException(String message, Throwable cause) { + super(message, cause); + } + } + + // 피드백 파싱 실패 + public static class FeedbackParseException extends OPIcException { + public FeedbackParseException(String response, Throwable cause) { + super("피드백 응답 파싱 실패: " + truncate(response), cause); + } + } + + // 세션 리포트 파싱 실패 + public static class ReportParseException extends OPIcException { + public ReportParseException(String response, Throwable cause) { + super("세션 리포트 파싱 실패: " + truncate(response), cause); + } + } + + // Bedrock API 호출 실패 + public static class BedrockApiException extends OPIcException { + public BedrockApiException(String message, Throwable cause) { + super("Bedrock API 호출 실패: " + message, cause); + } + } + + // 응답 truncate (로그용) + private static String truncate(String text) { + if (text == null) return "null"; + return text.length() > 200 ? text.substring(0, 200) + "..." : text; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/model/OPIcAnswer.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/model/OPIcAnswer.java new file mode 100644 index 00000000..e6e5da95 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/model/OPIcAnswer.java @@ -0,0 +1,153 @@ +package com.mzc.secondproject.serverless.domain.opic.model; + +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbAttribute; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSortKey; + +import java.time.Instant; + +/** + * OPIc 답변 + 피드백 + */ +public class OPIcAnswer { + + private String pk; // SESSION#sessionId + private String sk; // Q#01 (질문 순서) + + private String sessionId; + private String questionId; + private int questionIndex; // 질문 순서 (0, 1, 2) + + // 질문 정보 (비정규화) + private String questionText; + + // 사용자 답변 + private String audioS3Key; // 답변 음성 S3 키 + private String transcript; // 음성 → 텍스트 + private double transcriptConfidence; // 변환 신뢰도 + private int durationSeconds; // 답변 길이 (초) + + // AI 피드백 + private String score; // 예상 등급 (IM1, IM2, IM3, IH, AL) + private String grammarFeedback; // 문법 피드백 (JSON) + private String vocabularyFeedback; // 어휘 피드백 (JSON) + private String contentFeedback; // 내용 피드백 + private String pronunciationFeedback; // 발음 피드백 + private String strengths; // 잘한 점 (JSON array) + private String improvements; // 개선점 (JSON array) + + // 모범 답변 + private String sampleAnswer; // 모범 답변 텍스트 + private String sampleAudioS3Key; // 모범 답변 음성 S3 키 + + // 메타데이터 + private AnswerStatus status; + private int attemptCount; // 시도 횟수 + private Instant createdAt; + private Instant completedAt; + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { return pk; } + public void setPk(String pk) { this.pk = pk; } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { return sk; } + public void setSk(String sk) { this.sk = sk; } + + @DynamoDbAttribute("sessionId") + public String getSessionId() { return sessionId; } + public void setSessionId(String id) { this.sessionId = id; } + + @DynamoDbAttribute("questionId") + public String getQuestionId() { return questionId; } + public void setQuestionId(String id) { this.questionId = id; } + + @DynamoDbAttribute("questionIndex") + public int getQuestionIndex() { return questionIndex; } + public void setQuestionIndex(int idx) { this.questionIndex = idx; } + + @DynamoDbAttribute("questionText") + public String getQuestionText() { return questionText; } + public void setQuestionText(String text) { this.questionText = text; } + + @DynamoDbAttribute("audioS3Key") + public String getAudioS3Key() { return audioS3Key; } + public void setAudioS3Key(String key) { this.audioS3Key = key; } + + @DynamoDbAttribute("transcript") + public String getTranscript() { return transcript; } + public void setTranscript(String transcript) { this.transcript = transcript; } + + @DynamoDbAttribute("transcriptConfidence") + public double getTranscriptConfidence() { return transcriptConfidence; } + public void setTranscriptConfidence(double conf) { this.transcriptConfidence = conf; } + + @DynamoDbAttribute("durationSeconds") + public int getDurationSeconds() { return durationSeconds; } + public void setDurationSeconds(int duration) { this.durationSeconds = duration; } + + @DynamoDbAttribute("score") + public String getScore() { return score; } + public void setScore(String score) { this.score = score; } + + @DynamoDbAttribute("grammarFeedback") + public String getGrammarFeedback() { return grammarFeedback; } + public void setGrammarFeedback(String feedback) { this.grammarFeedback = feedback; } + + @DynamoDbAttribute("vocabularyFeedback") + public String getVocabularyFeedback() { return vocabularyFeedback; } + public void setVocabularyFeedback(String feedback) { this.vocabularyFeedback = feedback; } + + @DynamoDbAttribute("contentFeedback") + public String getContentFeedback() { return contentFeedback; } + public void setContentFeedback(String feedback) { this.contentFeedback = feedback; } + + @DynamoDbAttribute("pronunciationFeedback") + public String getPronunciationFeedback() { return pronunciationFeedback; } + public void setPronunciationFeedback(String feedback) { this.pronunciationFeedback = feedback; } + + @DynamoDbAttribute("strengths") + public String getStrengths() { return strengths; } + public void setStrengths(String strengths) { this.strengths = strengths; } + + @DynamoDbAttribute("improvements") + public String getImprovements() { return improvements; } + public void setImprovements(String improvements) { this.improvements = improvements; } + + @DynamoDbAttribute("sampleAnswer") + public String getSampleAnswer() { return sampleAnswer; } + public void setSampleAnswer(String answer) { this.sampleAnswer = answer; } + + @DynamoDbAttribute("sampleAudioS3Key") + public String getSampleAudioS3Key() { return sampleAudioS3Key; } + public void setSampleAudioS3Key(String key) { this.sampleAudioS3Key = key; } + + @DynamoDbAttribute("status") + public AnswerStatus getStatus() { return status; } + public void setStatus(AnswerStatus status) { this.status = status; } + + @DynamoDbAttribute("attemptCount") + public int getAttemptCount() { return attemptCount; } + public void setAttemptCount(int count) { this.attemptCount = count; } + + @DynamoDbAttribute("createdAt") + public Instant getCreatedAt() { return createdAt; } + public void setCreatedAt(Instant time) { this.createdAt = time; } + + @DynamoDbAttribute("completedAt") + public Instant getCompletedAt() { return completedAt; } + public void setCompletedAt(Instant time) { this.completedAt = time; } + + /** + * 답변 상태 + */ + public enum AnswerStatus { + PENDING, // 음성 업로드 대기 + UPLOADED, // 음성 업로드 완료 + PROCESSING, // Transcribe + Bedrock 처리 중 + COMPLETED, // 피드백 완료 + FAILED // 처리 실패 + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/model/OPIcQuestion.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/model/OPIcQuestion.java new file mode 100644 index 00000000..c0ab96fd --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/model/OPIcQuestion.java @@ -0,0 +1,86 @@ +package com.mzc.secondproject.serverless.domain.opic.model; + +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.*; + +/** + * OPIc 질문 마스터 데이터 + */ +@DynamoDbBean +public class OPIcQuestion { + + private String pk; // QUESTION#questionId + private String sk; // METADATA + private String gsi1pk; // TOPIC#travel + private String gsi1sk; // LEVEL#IM2 + + private String questionId; + private String topic; // 대주제 + private String subTopic; // 소주제 + private String level; // 난이도 (IM1, IM2, IM3, IH, AL) + private String questionText; // 질문 텍스트 (영어) + private String questionTextKo; // 질문 텍스트 (한국어, 참고용) + private String audioS3Key; // 질문 음성 S3 키 (Polly 캐시) + private String tips; // 답변 팁 + private int orderInSet; // 콤보 세트 내 순서 (1, 2, 3) + private boolean isActive; // 활성화 여부 + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { return pk; } + public void setPk(String pk) { this.pk = pk; } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { return sk; } + public void setSk(String sk) { this.sk = sk; } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1PK") + public String getGsi1pk() { return gsi1pk; } + public void setGsi1pk(String gsi1pk) { this.gsi1pk = gsi1pk; } + + @DynamoDbSecondarySortKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1SK") + public String getGsi1sk() { return gsi1sk; } + public void setGsi1sk(String gsi1sk) { this.gsi1sk = gsi1sk; } + + @DynamoDbAttribute("questionId") + public String getQuestionId() { return questionId; } + public void setQuestionId(String id) { this.questionId = id; } + + @DynamoDbAttribute("topic") + public String getTopic() { return topic; } + public void setTopic(String topic) { this.topic = topic; } + + @DynamoDbAttribute("subTopic") + public String getSubTopic() { return subTopic; } + public void setSubTopic(String subTopic) { this.subTopic = subTopic; } + + @DynamoDbAttribute("level") + public String getLevel() { return level; } + public void setLevel(String level) { this.level = level; } + + @DynamoDbAttribute("questionText") + public String getQuestionText() { return questionText; } + public void setQuestionText(String text) { this.questionText = text; } + + @DynamoDbAttribute("questionTextKo") + public String getQuestionTextKo() { return questionTextKo; } + public void setQuestionTextKo(String text) { this.questionTextKo = text; } + + @DynamoDbAttribute("audioS3Key") + public String getAudioS3Key() { return audioS3Key; } + public void setAudioS3Key(String key) { this.audioS3Key = key; } + + @DynamoDbAttribute("tips") + public String getTips() { return tips; } + public void setTips(String tips) { this.tips = tips; } + + @DynamoDbAttribute("orderInSet") + public int getOrderInSet() { return orderInSet; } + public void setOrderInSet(int order) { this.orderInSet = order; } + + @DynamoDbAttribute("isActive") + public boolean isActive() { return isActive; } + public void setActive(boolean active) { this.isActive = active; } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/model/OPIcSession.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/model/OPIcSession.java new file mode 100644 index 00000000..24790ff7 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/model/OPIcSession.java @@ -0,0 +1,128 @@ +package com.mzc.secondproject.serverless.domain.opic.model; + +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.*; + +import java.time.Instant; +import java.util.List; + + +/** + * OPIc 세션 + */ +@DynamoDbBean +public class OPIcSession { + + private String pk; // USER#userId + private String sk; // SESSION#date#sessionId + private String gsi1pk; // SESSION#sessionId + private String gsi1sk; // METADATA + + private String sessionId; + private String userId; + private String topic; // 대주제 (travel, hobby, work 등) + private String subTopic; // 소주제 + private String targetLevel; // 목표 레벨 (IM1, IM2, IM3, IH, AL) + private SessionStatus status; // IN_PROGRESS, COMPLETED, ABANDONED + private int currentQuestionIndex; // 현재 진행 중인 질문 (0, 1, 2) + private int totalQuestions; // 총 질문 수 (기본 3) + private List questionIds; // 질문 ID 목록 + private Instant createdAt; + private Instant updatedAt; + private Instant completedAt; + private int sequenceNumber; // 인과적 일관성용 + + // 종합 결과 (세션 완료 시) + private String overallScore; // 종합 예상 등급 + private String overallFeedback; // 종합 피드백 + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { return pk; } + public void setPk(String pk) { this.pk = pk; } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { return sk; } + public void setSk(String sk) { this.sk = sk; } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1PK") + public String getGsi1pk() { return gsi1pk; } + public void setGsi1pk(String gsi1pk) { this.gsi1pk = gsi1pk; } + + @DynamoDbSecondarySortKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1SK") + public String getGsi1sk() { return gsi1sk; } + public void setGsi1sk(String gsi1sk) { this.gsi1sk = gsi1sk; } + + @DynamoDbAttribute("sessionId") + public String getSessionId() { return sessionId; } + public void setSessionId(String sessionId) { this.sessionId = sessionId; } + + @DynamoDbAttribute("userId") + public String getUserId() { return userId; } + public void setUserId(String userId) { this.userId = userId; } + + @DynamoDbAttribute("topic") + public String getTopic() { return topic; } + public void setTopic(String topic) { this.topic = topic; } + + @DynamoDbAttribute("subTopic") + public String getSubTopic() { return subTopic; } + public void setSubTopic(String subTopic) { this.subTopic = subTopic; } + + @DynamoDbAttribute("targetLevel") + public String getTargetLevel() { return targetLevel; } + public void setTargetLevel(String targetLevel) { this.targetLevel = targetLevel; } + + @DynamoDbAttribute("status") + public SessionStatus getStatus() { return status; } + public void setStatus(SessionStatus status) { this.status = status; } + + @DynamoDbAttribute("currentQuestionIndex") + public int getCurrentQuestionIndex() { return currentQuestionIndex; } + public void setCurrentQuestionIndex(int idx) { this.currentQuestionIndex = idx; } + + @DynamoDbAttribute("totalQuestions") + public int getTotalQuestions() { return totalQuestions; } + public void setTotalQuestions(int total) { this.totalQuestions = total; } + + @DynamoDbAttribute("questionIds") + public List getQuestionIds() { return questionIds; } + public void setQuestionIds(List ids) { this.questionIds = ids; } + + @DynamoDbAttribute("createdAt") + public Instant getCreatedAt() { return createdAt; } + public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; } + + @DynamoDbAttribute("updatedAt") + public Instant getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(Instant updatedAt) { this.updatedAt = updatedAt; } + + @DynamoDbAttribute("completedAt") + public Instant getCompletedAt() { return completedAt; } + public void setCompletedAt(Instant completedAt) { this.completedAt = completedAt; } + + @DynamoDbAttribute("sequenceNumber") + public int getSequenceNumber() { return sequenceNumber; } + public void setSequenceNumber(int seq) { this.sequenceNumber = seq; } + + @DynamoDbAttribute("overallScore") + public String getOverallScore() { return overallScore; } + public void setOverallScore(String score) { this.overallScore = score; } + + @DynamoDbAttribute("overallFeedback") + public String getOverallFeedback() { return overallFeedback; } + public void setOverallFeedback(String feedback) { this.overallFeedback = feedback; } + + /** + * 세션 상태 + */ + public enum SessionStatus { + IN_PROGRESS, + COMPLETED, + ABANDONED + } +} + + diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/repository/OPIcRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/repository/OPIcRepository.java new file mode 100644 index 00000000..2b5fd803 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/repository/OPIcRepository.java @@ -0,0 +1,256 @@ +package com.mzc.secondproject.serverless.domain.opic.repository; + +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.domain.opic.model.OPIcAnswer; +import com.mzc.secondproject.serverless.domain.opic.model.OPIcQuestion; +import com.mzc.secondproject.serverless.domain.opic.model.OPIcSession; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.*; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.stream.Collectors; + +public class OPIcRepository { + + private static final Logger logger = LoggerFactory.getLogger(OPIcRepository.class); + private static final String TABLE_NAME = System.getenv("OPIC_TABLE_NAME"); + + private final DynamoDbEnhancedClient enhancedClient; + private final DynamoDbTable sessionTable; + private final DynamoDbTable questionTable; + private final DynamoDbTable answerTable; + + public OPIcRepository() { + this.enhancedClient = AwsClients.dynamoDbEnhanced(); + this.sessionTable = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(OPIcSession.class)); + this.questionTable = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(OPIcQuestion.class)); + this.answerTable = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(OPIcAnswer.class)); + } + + // ==================== Session ==================== + + /** + * 새 세션 생성 + */ + public OPIcSession createSession(String userId, String topic, String subTopic, + String targetLevel, List questionIds) { + String sessionId = UUID.randomUUID().toString(); + String today = LocalDate.now(ZoneId.of("Asia/Seoul")) + .format(DateTimeFormatter.ISO_LOCAL_DATE); + Instant now = Instant.now(); + + OPIcSession session = new OPIcSession(); + session.setPk("USER#" + userId); + session.setSk("SESSION#" + today + "#" + sessionId); + session.setGsi1pk("SESSION#" + sessionId); + session.setGsi1sk("METADATA"); + + session.setSessionId(sessionId); + session.setUserId(userId); + session.setTopic(topic); + session.setSubTopic(subTopic); + session.setTargetLevel(targetLevel); + session.setStatus(OPIcSession.SessionStatus.IN_PROGRESS); + session.setCurrentQuestionIndex(0); + session.setTotalQuestions(questionIds.size()); + session.setQuestionIds(questionIds); + session.setCreatedAt(now); + session.setUpdatedAt(now); + session.setSequenceNumber(0); + + sessionTable.putItem(session); + logger.info("Session created: {}", sessionId); + + return session; + } + + /** + * 세션 ID로 조회 (GSI1 사용) + */ + public Optional findSessionById(String sessionId) { + DynamoDbIndex gsi1 = sessionTable.index("GSI1"); + + QueryConditional queryConditional = QueryConditional.keyEqualTo( + Key.builder() + .partitionValue("SESSION#" + sessionId) + .sortValue("METADATA") + .build() + ); + + return gsi1.query(QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .build()) + .stream() + .flatMap(page -> page.items().stream()) + .findFirst(); + } + + /** + * 사용자의 세션 목록 조회 (최신순) + */ + public List findSessionsByUserId(String userId, int limit) { + QueryConditional queryConditional = QueryConditional.sortBeginsWith( + Key.builder() + .partitionValue("USER#" + userId) + .sortValue("SESSION#") + .build() + ); + + return sessionTable.query(QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .scanIndexForward(false) // 최신순 + .limit(limit) + .build()) + .stream() + .flatMap(page -> page.items().stream()) + .collect(Collectors.toList()); + } + + /** + * 세션 업데이트 + */ + public void updateSession(OPIcSession session) { + session.setUpdatedAt(Instant.now()); + session.setSequenceNumber(session.getSequenceNumber() + 1); + sessionTable.putItem(session); + logger.debug("Session updated: {}", session.getSessionId()); + } + + /** + * 세션 완료 처리 + */ + public void completeSession(OPIcSession session, String overallScore, String overallFeedback) { + session.setStatus(OPIcSession.SessionStatus.COMPLETED); + session.setOverallScore(overallScore); + session.setOverallFeedback(overallFeedback); + session.setCompletedAt(Instant.now()); + updateSession(session); + logger.info("Session completed: {}", session.getSessionId()); + } + + + // ==================== Question ==================== + + /** + * 질문 ID로 조회 + */ + public Optional findQuestionById(String questionId) { + Key key = Key.builder() + .partitionValue("QUESTION#" + questionId) + .sortValue("METADATA") + .build(); + + return Optional.ofNullable(questionTable.getItem(key)); + } + + /** + * 주제 + 레벨로 질문 조회 (GSI1) + */ + public List findQuestionsByTopicAndLevel(String topic, String level) { + DynamoDbIndex gsi1 = questionTable.index("GSI1"); + + QueryConditional queryConditional = QueryConditional.keyEqualTo( + Key.builder() + .partitionValue("TOPIC#" + topic) + .sortValue("LEVEL#" + level) + .build() + ); + + return gsi1.query(queryConditional) + .stream() + .flatMap(page -> page.items().stream()) + .filter(OPIcQuestion::isActive) + .collect(Collectors.toList()); + } + + /** + * 여러 질문 ID로 조회 + */ + public List findQuestionsByIds(List questionIds) { + return questionIds.stream() + .map(this::findQuestionById) + .filter(Optional::isPresent) + .map(Optional::get) + .collect(Collectors.toList()); + } + + /** + * 질문 저장 (마스터 데이터 등록용) + */ + public void saveQuestion(OPIcQuestion question) { + question.setPk("QUESTION#" + question.getQuestionId()); + question.setSk("METADATA"); + question.setGsi1pk("TOPIC#" + question.getTopic()); + question.setGsi1sk("LEVEL#" + question.getLevel()); + + questionTable.putItem(question); + logger.info("Question saved: {}", question.getQuestionId()); + } + + // ==================== Answer ==================== + + /** + * 답변 저장 + */ + public void saveAnswer(OPIcAnswer answer) { + answer.setPk("SESSION#" + answer.getSessionId()); + answer.setSk(String.format("Q#%02d", answer.getQuestionIndex())); + + if (answer.getCreatedAt() == null) { + answer.setCreatedAt(Instant.now()); + } + + answerTable.putItem(answer); + logger.debug("Answer saved: session={}, questionIndex={}", + answer.getSessionId(), answer.getQuestionIndex()); + } + + /** + * 세션의 특정 질문 답변 조회 + */ + public Optional findAnswer(String sessionId, int questionIndex) { + Key key = Key.builder() + .partitionValue("SESSION#" + sessionId) + .sortValue(String.format("Q#%02d", questionIndex)) + .build(); + + return Optional.ofNullable(answerTable.getItem(key)); + } + + /** + * 세션의 모든 답변 조회 + */ + public List findAnswersBySessionId(String sessionId) { + QueryConditional queryConditional = QueryConditional.sortBeginsWith( + Key.builder() + .partitionValue("SESSION#" + sessionId) + .sortValue("Q#") + .build() + ); + + return answerTable.query(queryConditional) + .stream() + .flatMap(page -> page.items().stream()) + .collect(Collectors.toList()); + } + + /** + * 답변 업데이트 (피드백 추가 등) + */ + public void updateAnswer(OPIcAnswer answer) { + answerTable.putItem(answer); + logger.debug("Answer updated: session={}, questionIndex={}", + answer.getSessionId(), answer.getQuestionIndex()); + } + + +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/service/FeedbackService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/service/FeedbackService.java new file mode 100644 index 00000000..9343ae51 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/service/FeedbackService.java @@ -0,0 +1,299 @@ +package com.mzc.secondproject.serverless.domain.opic.service; + +import com.google.gson.*; +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.util.JsonUtil; +import com.mzc.secondproject.serverless.domain.opic.dto.response.FeedbackResponse; +import com.mzc.secondproject.serverless.domain.opic.dto.response.SessionReportResponse; +import com.mzc.secondproject.serverless.domain.opic.dto.response.SpeakingError; +import com.mzc.secondproject.serverless.domain.opic.enums.SpeakingErrorType; +import com.mzc.secondproject.serverless.domain.opic.exception.OPIcException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.core.SdkBytes; +import software.amazon.awssdk.services.bedrockruntime.model.InvokeModelRequest; +import software.amazon.awssdk.services.bedrockruntime.model.InvokeModelResponse; + +import java.util.ArrayList; +import java.util.List; + +/** +* OPIc 피드백 생성 서비스 +*/ +public class FeedbackService { + + private static final Logger logger = LoggerFactory.getLogger(FeedbackService.class); + private static final Gson gson = new GsonBuilder().setPrettyPrinting().create(); + private static final String MODEL_ID = "anthropic.claude-3-5-sonnet-20241022-v2:0"; + private static final int MAX_TOKENS = 2000; + + /** + * 사용자 답변에 대한 피드백 생성 + */ + public FeedbackResponse generateFeedback(String question, String userAnswer, String targetLevel) { + logger.info("피드백 생성, 대상 Level: {}", targetLevel); + + String prompt = buildFeedbackPrompt(question, userAnswer, targetLevel); + String response = invokeClaude(prompt); + String jsonResponse = JsonUtil.extractJson(response); + + return parseFeedbackResponse(jsonResponse); + } + + + /** + * 세션 종합 리포트 생성 + */ + public SessionReportResponse generateSessionReport(String sessionSummary, String targetLevel) { + logger.info("세션 리포트 생성, 대상 Level: {}", targetLevel); + + String prompt = buildSessionReportPrompt(sessionSummary, targetLevel); + String response = invokeClaude(prompt); + String jsonResponse = JsonUtil.extractJson(response); + + return parseSessionReportResponse(jsonResponse); + } + + + /** + * 개별 질문 피드백 프롬프트 + */ + private String buildFeedbackPrompt(String question, String userAnswer, String targetLevel) { + return String.format(""" + You are an expert OPIc speaking evaluator. + + ## Question + %s + + ## User's Answer + %s + + ## Target Level + %s + + ## Task + Analyze the answer and provide feedback in the following JSON format only: + + { + "errors": [ + { + "type": "GRAMMAR | EXPRESSION | VOCABULARY", + "original": "원본 표현", + "corrected": "교정된 표현", + "explanation": "설명 (한국어)" + } + ], + "correctedAnswer": "전체 교정된 답변 (영어)", + "sampleAnswer": "목표 레벨에 맞는 모범 답변 (영어, 4-6문장)" + } + + Error types: + - GRAMMAR: 문법 오류 (시제, 관사, 주어-동사 일치 등) + - EXPRESSION: 더 자연스러운 표현 제안 + - VOCABULARY: 더 적절하거나 풍부한 어휘 제안 + + Rules: + 1. errors 배열은 최대 5개까지만 포함 + 2. 오류가 없으면 errors는 빈 배열 [] + 3. explanation은 한국어로 간결하게 + 4. sampleAnswer는 목표 레벨에 맞는 자연스러운 답변 + + Respond with ONLY the JSON, no markdown code blocks. + """, question, userAnswer, targetLevel); + } + + /** + * 세션 종합 리포트 프롬프트 + */ + private String buildSessionReportPrompt(String sessionSummary, String targetLevel) { + return String.format(""" + You are an expert OPIc speaking coach creating a comprehensive session report. + + ## Session Summary (Questions and Answers) + %s + + ## Target Level + %s + + ## Task + Generate a detailed learning report in the following JSON format only: + + { + "estimatedLevel": "NL | NM | NH | IL | IM1 | IM2 | IM3 | IH | AL", + "overallScore": 0-100, + "strengths": ["잘한 점 1 (한국어)", "잘한 점 2", "잘한 점 3"], + "weaknesses": ["개선할 점 1 (한국어)", "개선할 점 2", "개선할 점 3"], + "feedback": "종합 피드백 (한국어, 3-4문장, 격려하는 톤)", + "recommendations": ["학습 추천 1 (한국어)", "학습 추천 2"] + } + + Evaluation criteria: + - Task completion: 질문에 적절히 답했는가 + - Fluency: 유창성, 자연스러움 + - Grammar: 문법 정확도 + - Vocabulary: 어휘 다양성 + - Content: 내용의 구체성 + + Be encouraging but honest. Provide specific, actionable feedback in Korean. + Respond with ONLY the JSON, no markdown code blocks. + """, sessionSummary, targetLevel); + } + + + /** + * Claude 호출 (일반 텍스트 응답) + */ + private String invokeClaude(String prompt) { + try { + JsonObject requestBody = buildRequestBody(prompt); + + InvokeModelRequest request = InvokeModelRequest.builder() + .modelId(MODEL_ID) + .contentType("application/json") + .body(SdkBytes.fromUtf8String(gson.toJson(requestBody))) + .build(); + + long startTime = System.currentTimeMillis(); + InvokeModelResponse response = AwsClients.bedrock().invokeModel(request); + long elapsed = System.currentTimeMillis() - startTime; + + logger.info("Bedrock 응답 수신: {}ms", elapsed); + + JsonObject responseJson = JsonParser.parseString( + response.body().asUtf8String() + ).getAsJsonObject(); + + return responseJson + .getAsJsonArray("content") + .get(0) + .getAsJsonObject() + .get("text") + .getAsString(); + + } catch (Exception e) { + logger.error("Bedrock 호출 실패", e); + throw new OPIcException.BedrockApiException(e.getMessage(), e); + } + } + + /** + * Bedrock 요청 Body 생성 + */ + private JsonObject buildRequestBody(String prompt) { + JsonObject requestBody = new JsonObject(); + requestBody.addProperty("anthropic_version", "bedrock-2023-05-31"); + requestBody.addProperty("max_tokens", MAX_TOKENS); + + JsonArray messages = new JsonArray(); + JsonObject userMessage = new JsonObject(); + userMessage.addProperty("role", "user"); + userMessage.addProperty("content", prompt); + messages.add(userMessage); + + requestBody.add("messages", messages); + return requestBody; + } + + // ==================== 응답 파싱 ==================== + + /** + * 피드백 응답 파싱 + * + * Claude 응답 JSON 구조: + * { + * "errors": [{ "type": "GRAMMAR", "original": "...", "corrected": "...", "explanation": "..." }], + * "correctedAnswer": "...", + * "sampleAnswer": "..." + * } + */ + private FeedbackResponse parseFeedbackResponse(String jsonResponse) { + try { + JsonObject json = JsonParser.parseString(jsonResponse).getAsJsonObject(); + + // errors 배열 파싱 + List errors = parseErrors(json.getAsJsonArray("errors")); + + // 응답 DTO 생성 + return new FeedbackResponse( + errors, + json.get("correctedAnswer").getAsString(), + json.get("sampleAnswer").getAsString() + ); + + } catch (Exception e) { + logger.error("피드백 파싱 실패: {}", jsonResponse, e); + throw new OPIcException.FeedbackParseException(jsonResponse, e); + } + } + + /** + * 세션 리포트 응답 파싱 + * + * Claude 응답 JSON 구조: + * { + * "estimatedLevel": "IM2", + * "overallScore": 72, + * "strengths": ["...", "..."], + * "weaknesses": ["...", "..."], + * "feedback": "...", + * "recommendations": ["...", "..."] + * } + */ + private SessionReportResponse parseSessionReportResponse(String jsonResponse) { + try { + JsonObject json = JsonParser.parseString(jsonResponse).getAsJsonObject(); + + return new SessionReportResponse( + json.get("estimatedLevel").getAsString(), + json.get("overallScore").getAsInt(), + JsonUtil.toStringList(json.getAsJsonArray("strengths")), + JsonUtil.toStringList(json.getAsJsonArray("weaknesses")), + json.get("feedback").getAsString(), + JsonUtil.toStringList(json.getAsJsonArray("recommendations")) + ); + + } catch (Exception e) { + logger.error("세션 리포트 파싱 실패: {}", jsonResponse, e); + throw new OPIcException.ReportParseException(jsonResponse, e); + } + } + + /** + * errors 배열 파싱 + */ + private List parseErrors(JsonArray errorsArray) { + List errors = new ArrayList<>(); + + if (errorsArray == null || errorsArray.isEmpty()) { + return errors; + } + + for (JsonElement el : errorsArray) { + JsonObject obj = el.getAsJsonObject(); + errors.add(SpeakingError.builder() + .type(parseErrorType(obj.get("type").getAsString())) + .original(obj.get("original").getAsString()) + .corrected(obj.get("corrected").getAsString()) + .explanation(obj.get("explanation").getAsString()) + .build()); + } + + return errors; + } + + + /** + * 오류 타입 문자열 -> Enum 변환 + */ + private SpeakingErrorType parseErrorType(String typeStr) { + try { + // "GRAMMAR | EXPRESSION | VOCABULARY" 형태 처리 + String cleaned = typeStr.replace(" ", "").split("\\|")[0].trim(); + return SpeakingErrorType.valueOf(cleaned.toUpperCase()); + } catch (Exception e) { + logger.warn("알 수 없는 오류 타입: {}, 기본값 GRAMMAR 사용", typeStr); + return SpeakingErrorType.GRAMMAR; + } + } + +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/service/TranscribeProxyService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/service/TranscribeProxyService.java new file mode 100644 index 00000000..2183e30e --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/service/TranscribeProxyService.java @@ -0,0 +1,174 @@ +package com.mzc.secondproject.serverless.domain.opic.service; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.mzc.secondproject.serverless.domain.opic.exception.OPIcException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.services.ssm.SsmClient; +import software.amazon.awssdk.services.ssm.model.GetParameterRequest; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; + +/** + * 개인 AWS 계정의 Transcribe Proxy API 호출 서비스 + * Cross-Account로 Transcribe 기능 사용 + */ +public class TranscribeProxyService { + + private static final Logger logger = LoggerFactory.getLogger(TranscribeProxyService.class); + private static final Gson gson = new Gson(); + + private static final SsmClient ssmClient = SsmClient.builder().build(); + + // API Key 캐싱 (Lambda 인스턴스 재사용 시 SSM 호출 최소화) + private static String cachedApiKey = null; + + private final String proxyUrl; + private final String apiKeyParamName; + private final HttpClient httpClient; + + public TranscribeProxyService() { + this.proxyUrl = System.getenv("TRANSCRIBE_PROXY_URL"); + this.apiKeyParamName = System.getenv("TRANSCRIBE_API_KEY"); + this.httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .build(); + + if (proxyUrl == null || apiKeyParamName == null) { + logger.warn("TRANSCRIBE_PROXY_URL or TRANSCRIBE_API_KEY is not set"); + } + } + + // 테스트용 생성자 + public TranscribeProxyService(String proxyUrl, String apiKey) { + this.proxyUrl = proxyUrl; + this.apiKeyParamName = apiKey; + this.httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .build(); + } + + /** + * API Key 조회 (Parameter Store에서 + 캐싱) + */ + private String getApiKey() { + if (cachedApiKey != null) { + return cachedApiKey; + } + + try { + logger.debug("Fetching API Key from Parameter Store: {}", apiKeyParamName); + + var response = ssmClient.getParameter( + GetParameterRequest.builder() + .name(apiKeyParamName) + .withDecryption(true) + .build() + ); + + cachedApiKey = response.parameter().value(); + logger.info("API Key loaded from Parameter Store"); + + return cachedApiKey; + } catch (Exception e) { + logger.error("Failed to get API Key from Parameter Store", e); + throw new OPIcException.TranscribeException("API Key 로드 실패", e); + } + } + + /** + * 음성 파일을 텍스트로 변환 + * + * @param audioBase64 Base64 인코딩된 음성 데이터 + * @param sessionId 세션 ID + * @return 변환된 텍스트 결과 + */ + public TranscribeResult transcribe(String audioBase64, String sessionId) { + return transcribe(audioBase64, sessionId, "en-US"); + } + + /** + * 음성 파일을 텍스트로 변환 (언어 지정) + * + * @param audioBase64 Base64 인코딩된 음성 데이터 + * @param sessionId 세션 ID + * @param languageCode 언어 코드 (en-US, ko-KR 등) + * @return 변환된 텍스트 결과 + */ + public TranscribeResult transcribe(String audioBase64, String sessionId, String languageCode) { + logger.info("Transcribe 요청 시작 - sessionId: {}, language: {}", sessionId, languageCode); + + try { + // API Key 조회 + String apiKey = getApiKey(); + + // 요청 바디 생성 + JsonObject requestBody = new JsonObject(); + requestBody.addProperty("audio_data", audioBase64); + requestBody.addProperty("session_id", sessionId); + requestBody.addProperty("language_code", languageCode); + + // HTTP 요청 생성 + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(proxyUrl)) + .header("Content-Type", "application/json") + .header("X-Api-Key", apiKey) + .timeout(Duration.ofSeconds(120)) // Transcribe 처리 시간 고려 + .POST(HttpRequest.BodyPublishers.ofString(gson.toJson(requestBody))) + .build(); + + long startTime = System.currentTimeMillis(); + + // API 호출 + HttpResponse response = httpClient.send( + request, + HttpResponse.BodyHandlers.ofString() + ); + + long elapsed = System.currentTimeMillis() - startTime; + logger.info("Transcribe Proxy 응답 - status: {}, 소요시간: {}ms", + response.statusCode(), elapsed); + + // 응답 처리 + if (response.statusCode() != 200) { + logger.error("Transcribe 실패 - status: {}, body: {}", + response.statusCode(), response.body()); + throw new OPIcException.TranscribeException("Transcribe 실패: " + response.statusCode()); + } + + // JSON 파싱 + JsonObject resultJson = JsonParser.parseString(response.body()).getAsJsonObject(); + + String transcript = resultJson.get("transcript").getAsString(); + String jobName = resultJson.get("job_name").getAsString(); + double confidence = resultJson.get("confidence").getAsDouble(); + + logger.info("Transcribe 완료 - jobName: {}, confidence: {}", jobName, confidence); + logger.debug("Transcript: {}", transcript); + + return new TranscribeResult(transcript, jobName, confidence); + + } catch (OPIcException.TranscribeException e) { + throw e; + } catch (Exception e) { + logger.error("Transcribe 호출 중 오류 발생", e); + throw new OPIcException.TranscribeException("음성 변환 실패: " + e.getMessage(), e); + } + } + + /** + * Transcribe 결과 레코드 + */ + public record TranscribeResult( + String transcript, + String jobName, + double confidence + ) {} + +} \ No newline at end of file diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 87f4ccce..570dd871 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -15,12 +15,15 @@ Globals: USER_TABLE_NAME: !Ref UserTable CHAT_TABLE_NAME: !Ref ChatTable VOCAB_TABLE_NAME: !Ref VocabTable + OPIC_TABLE_NAME: !Ref OPIcTable BUCKET_NAME: group2-englishstudy CHAT_BUCKET_NAME: group2-englishstudy VOCAB_BUCKET_NAME: group2-englishstudy PROFILE_BUCKET_NAME: group2-englishstudy + OPIC_BUCKET_NAME: group2-englishstudy AWS_REGION_NAME: !Ref AWS::Region ROOM_TOKEN_TTL_SECONDS: "300" + TRANSCRIBE_PROXY_URL: "https://tfo1zm7vec.execute-api.ap-northeast-2.amazonaws.com/prod/transcribe" Api: TracingEnabled: true @@ -1262,6 +1265,112 @@ Resources: Description: Daily word learning stats aggregation Enabled: true + ############################################# + # OPIc Lambda Functions + ############################################# + + OPIcSessionFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: group2-englishstudy-opic-session-handler + CodeUri: . + Handler: com.mzc.secondproject.serverless.domain.opic.handler.OPIcSessionHandler::handleRequest + Description: Handle OPIc speaking practice sessions + Timeout: 180 + MemorySize: 1024 + SnapStart: + ApplyOn: PublishedVersions + Environment: + Variables: + TRANSCRIBE_API_KEY_PARAM: "/opic/transcribe-proxy-api-key" + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref OPIcTable + - S3CrudPolicy: + BucketName: group2-englishstudy + - Statement: + - Effect: Allow + Action: + - bedrock:InvokeModel + - bedrock:InvokeModelWithResponseStream + Resource: "*" + - Statement: + - Effect: Allow + Action: + - polly:SynthesizeSpeech + - polly:DescribeVoices + Resource: "*" + # Parameter Store 읽기 권한 추가 + - Statement: + - Effect: Allow + Action: + - ssm:GetParameter + Resource: !Sub "arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/opic/*" + Events: + # 세션 생성 + CreateSession: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /opic/sessions + Method: POST + Auth: + Authorizer: CognitoAuthorizer + # 세션 조회 + GetSession: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /opic/sessions/{sessionId} + Method: GET + Auth: + Authorizer: CognitoAuthorizer + # 세션 목록 조회 + GetSessions: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /opic/sessions + Method: GET + Auth: + Authorizer: CognitoAuthorizer + # 다음 질문 조회 + GetNextQuestion: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /opic/sessions/{sessionId}/questions/next + Method: GET + Auth: + Authorizer: CognitoAuthorizer + # 답변 제출 + SubmitAnswer: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /opic/sessions/{sessionId}/answers + Method: POST + Auth: + Authorizer: CognitoAuthorizer + # 세션 완료 + CompleteSession: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /opic/sessions/{sessionId}/complete + Method: POST + Auth: + Authorizer: CognitoAuthorizer + # 음성 업로드 Presigned URL + GetUploadUrl: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /opic/sessions/{sessionId}/upload-url + Method: GET + Auth: + Authorizer: CognitoAuthorizer + ############################################# # DynamoDB Tables ############################################# @@ -1415,6 +1524,38 @@ Resources: AttributeName: ttl Enabled: true + OPIcTable: + Type: AWS::DynamoDB::Table + Properties: + TableName: group2-englishstudy-opic + BillingMode: PAY_PER_REQUEST + AttributeDefinitions: + - AttributeName: PK + AttributeType: S + - AttributeName: SK + AttributeType: S + - AttributeName: GSI1PK + AttributeType: S + - AttributeName: GSI1SK + AttributeType: S + KeySchema: + - AttributeName: PK + KeyType: HASH + - AttributeName: SK + KeyType: RANGE + GlobalSecondaryIndexes: + - IndexName: GSI1 + KeySchema: + - AttributeName: GSI1PK + KeyType: HASH + - AttributeName: GSI1SK + KeyType: RANGE + Projection: + ProjectionType: ALL + TimeToLiveSpecification: + AttributeName: ttl + Enabled: true + ############################################# # SNS / SQS for Async Statistics Processing ############################################# @@ -1527,3 +1668,7 @@ Outputs: CognitoUserPoolClientId: Description: Cognito User Pool Client ID Value: !Ref CognitoUserPoolClient + + OPIcTableName: + Description: OPIc DynamoDB Table Name + Value: !Ref OPIcTable From ae2af13150ca114b1e3eccf3bce58f4e23ce7314 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 09:34:21 +0900 Subject: [PATCH 306/528] =?UTF-8?q?fix:=20WebSocketMessageFunction?= =?UTF-8?q?=EC=97=90=20VocabTable=20DynamoDB=20=EA=B6=8C=ED=95=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 게임 시작 시 단어 조회를 위해 VocabTable GSI1 접근 권한 필요 - DynamoDBCrudPolicy for VocabTable 추가 Fixes #390 --- ServerlessFunction/template.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 570dd871..99bdbafc 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -280,6 +280,8 @@ Resources: Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable + - DynamoDBCrudPolicy: + TableName: !Ref VocabTable - Statement: - Effect: Allow Action: From 26b49192b659c85fe55fa9c9aac8d3e7cb990a9a Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 10:11:56 +0900 Subject: [PATCH 307/528] =?UTF-8?q?fix:=20=EA=B2=8C=EC=9E=84=20=EC=8B=9C?= =?UTF-8?q?=EC=9E=91=20=EC=8B=9C=20=EC=B6=9C=EC=A0=9C=EC=9E=90=EC=97=90?= =?UTF-8?q?=EA=B2=8C=EB=A7=8C=20=EC=A0=9C=EC=8B=9C=EC=96=B4=20=EC=A0=84?= =?UTF-8?q?=EC=86=A1=20=EB=B0=8F=20=EB=A0=88=EB=B2=A8=20=EC=BF=BC=EB=A6=AC?= =?UTF-8?q?=20=EB=8C=80=EC=86=8C=EB=AC=B8=EC=9E=90=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WebSocketMessageHandler: GAME_START 시 게임 데이터(라운드, 출제자 순서, 시간제한 등) 전송 - 출제자(drawer)에게만 currentWord(제시어) 포함하여 전송 - ROUND_END 시 다음 라운드 정보 전송 - WordRepository: 레벨 쿼리 시 toUpperCase() 적용하여 DB와 일치하도록 수정 --- .../websocket/WebSocketMessageHandler.java | 134 ++++++++++++++---- .../vocabulary/repository/WordRepository.java | 2 +- 2 files changed, 108 insertions(+), 28 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java index 0386a136..d32d8295 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java @@ -311,38 +311,118 @@ private void handleAllCorrect(String roomId) { private Map handleCommandResult(CommandResult result, String roomId, String userId) { String messageId = UUID.randomUUID().toString(); String now = Instant.now().toString(); - - // 시스템 메시지 생성 - ChatMessage systemMessage = ChatMessage.builder() - .pk("ROOM#" + roomId) - .sk("MSG#" + now + "#" + messageId) - .gsi1pk("SYSTEM") - .gsi1sk("MSG#" + now) - .gsi2pk("MSG#" + messageId) - .gsi2sk("ROOM#" + roomId) - .messageId(messageId) - .roomId(roomId) - .userId("SYSTEM") - .content(result.message()) - .messageType(result.messageType().getCode()) - .createdAt(now) - .build(); - - // 명령어 결과는 저장하지 않고 브로드캐스트만 수행 + List connections = connectionRepository.findByRoomId(roomId); - String broadcastPayload = gson.toJson(systemMessage); - List failedConnections = broadcaster.broadcast(connections, broadcastPayload); - - // 실패한 연결 정리 - for (String failedConnectionId : failedConnections) { - connectionRepository.delete(failedConnectionId); - logger.info("Deleted stale connection: {}", failedConnectionId); + + // GAME_START인 경우 게임 데이터 포함하여 전송 + if (result.messageType() == MessageType.GAME_START && result.data() instanceof GameService.GameStartResult gameResult) { + broadcastGameStart(connections, result, gameResult, messageId, roomId, now); + } else if (result.messageType() == MessageType.ROUND_END && result.data() instanceof Map) { + broadcastRoundEnd(connections, result, messageId, roomId, now); + } else { + // 일반 시스템 메시지 + Map systemMessage = new HashMap<>(); + systemMessage.put("messageId", messageId); + systemMessage.put("roomId", roomId); + systemMessage.put("userId", "SYSTEM"); + systemMessage.put("content", result.message()); + systemMessage.put("messageType", result.messageType().getCode()); + systemMessage.put("createdAt", now); + + String broadcastPayload = gson.toJson(systemMessage); + List failedConnections = broadcaster.broadcast(connections, broadcastPayload); + cleanupFailedConnections(failedConnections); } - + logger.info("Command result broadcasted: type={}, roomId={}", result.messageType(), roomId); return WebSocketEventUtil.ok("Command executed"); } - + + /** + * GAME_START 메시지 브로드캐스트 - 출제자에게만 제시어 포함 + */ + private void broadcastGameStart(List connections, CommandResult result, + GameService.GameStartResult gameResult, String messageId, String roomId, String now) { + + String currentDrawerId = gameResult.room().getCurrentDrawerId(); + + for (Connection conn : connections) { + Map message = new HashMap<>(); + message.put("messageId", messageId); + message.put("roomId", roomId); + message.put("userId", "SYSTEM"); + message.put("content", result.message()); + message.put("messageType", result.messageType().getCode()); + message.put("createdAt", now); + + // 게임 상태 정보 추가 + message.put("gameStatus", gameResult.room().getGameStatus()); + message.put("currentRound", gameResult.room().getCurrentRound()); + message.put("totalRounds", gameResult.room().getTotalRounds()); + message.put("currentDrawerId", currentDrawerId); + message.put("drawerOrder", gameResult.drawerOrder()); + message.put("roundTimeLimit", gameResult.room().getRoundTimeLimit()); + message.put("roundStartTime", gameResult.room().getRoundStartTime()); + + // 출제자에게만 제시어 전송 + if (conn.getUserId().equals(currentDrawerId) && gameResult.firstWord() != null) { + Map wordInfo = new HashMap<>(); + wordInfo.put("wordId", gameResult.firstWord().getWordId()); + wordInfo.put("korean", gameResult.firstWord().getKorean()); + wordInfo.put("english", gameResult.firstWord().getEnglish()); + message.put("currentWord", wordInfo); + } + + String payload = gson.toJson(message); + try { + broadcaster.sendToConnection(conn.getConnectionId(), payload); + } catch (Exception e) { + logger.warn("Failed to send to connection: {}", conn.getConnectionId()); + connectionRepository.delete(conn.getConnectionId()); + } + } + } + + /** + * ROUND_END 메시지 브로드캐스트 - 다음 출제자에게만 다음 제시어 포함 + */ + @SuppressWarnings("unchecked") + private void broadcastRoundEnd(List connections, CommandResult result, + String messageId, String roomId, String now) { + + Map data = (Map) result.data(); + String nextDrawer = (String) data.get("nextDrawer"); + + for (Connection conn : connections) { + Map message = new HashMap<>(); + message.put("messageId", messageId); + message.put("roomId", roomId); + message.put("userId", "SYSTEM"); + message.put("content", result.message()); + message.put("messageType", result.messageType().getCode()); + message.put("createdAt", now); + message.put("data", data); + + // 다음 출제자에게만 다음 제시어 전송 + if (conn.getUserId().equals(nextDrawer) && data.containsKey("nextWord")) { + // nextWord는 이미 data에 포함되어 있음 + } else { + // 다음 출제자가 아니면 nextWord 제거 + Map filteredData = new HashMap<>(data); + filteredData.remove("nextWord"); + message.put("data", filteredData); + } + + String payload = gson.toJson(message); + try { + broadcaster.sendToConnection(conn.getConnectionId(), payload); + } catch (Exception e) { + logger.warn("Failed to send to connection: {}", conn.getConnectionId()); + connectionRepository.delete(conn.getConnectionId()); + } + } + } + /** * 메시지 페이로드 DTO */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordRepository.java index d6ba6491..aa99c689 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordRepository.java @@ -101,7 +101,7 @@ public void delete(String wordId) { */ public PaginatedResult findByLevelWithPagination(String level, int limit, String cursor) { QueryConditional queryConditional = QueryConditional - .keyEqualTo(Key.builder().partitionValue("LEVEL#" + level).build()); + .keyEqualTo(Key.builder().partitionValue("LEVEL#" + level.toUpperCase()).build()); QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() .queryConditional(queryConditional) From 0733ac515826db56fc560acb128d16843d571155 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 10:30:40 +0900 Subject: [PATCH 308/528] =?UTF-8?q?fix:=20=EC=A0=95=EB=8B=B5=20=EB=A7=9E?= =?UTF-8?q?=EC=B6=94=EB=A9=B4=20=EC=A6=89=EC=8B=9C=20=EB=8B=A4=EC=9D=8C=20?= =?UTF-8?q?=EB=9D=BC=EC=9A=B4=EB=93=9C=EB=A1=9C=20=EC=A7=84=ED=96=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기존: 전원 정답 시에만 다음 라운드 이동 - 변경: 1명이라도 정답 맞추면 즉시 다음 라운드 이동 - handleAllCorrect() -> endCurrentRound() 메서드명 변경 --- .../websocket/WebSocketMessageHandler.java | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java index d32d8295..b6c8d7e5 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java @@ -223,12 +223,10 @@ private Map handleCorrectAnswer(MessagePayload payload, GameServ }); logger.info("Correct answer: roomId={}, userId={}, score={}", payload.roomId, payload.userId, result.score()); - - // 전원 정답 시 라운드 종료 처리 - if (result.allCorrect()) { - handleAllCorrect(payload.roomId); - } - + + // 정답 맞추면 즉시 다음 라운드로 이동 + endCurrentRound(payload.roomId, "CORRECT_ANSWER"); + return WebSocketEventUtil.ok("Correct answer"); } @@ -296,11 +294,11 @@ private void cleanupFailedConnections(List failedConnections) { } /** - * 전원 정답 시 라운드 종료 + * 현재 라운드 종료 및 다음 라운드 진행 */ - private void handleAllCorrect(String roomId) { + private void endCurrentRound(String roomId, String reason) { chatRoomRepository.findById(roomId).ifPresent(room -> { - CommandResult endResult = gameService.endRound(room, "ALL_CORRECT"); + CommandResult endResult = gameService.endRound(room, reason); handleCommandResult(endResult, roomId, "SYSTEM"); }); } From 05cceb11ee04da8c1cc1f1088750f28213b720c7 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 11:08:55 +0900 Subject: [PATCH 309/528] =?UTF-8?q?feat:=20=EA=B2=8C=EC=9E=84=20=EC=8B=9C?= =?UTF-8?q?=EC=9E=91/=EB=9D=BC=EC=9A=B4=EB=93=9C=20=EC=A2=85=EB=A3=8C=20?= =?UTF-8?q?=EC=8B=9C=20=EC=98=81=EC=96=B4=20=EC=A0=9C=EC=8B=9C=EC=96=B4?= =?UTF-8?q?=EB=A7=8C=20=EC=B6=9C=EC=A0=9C=EC=9E=90=EC=97=90=EA=B2=8C=20?= =?UTF-8?q?=EC=A0=84=EC=86=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../websocket/WebSocketMessageHandler.java | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java index b6c8d7e5..bde7d946 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java @@ -16,6 +16,7 @@ import com.mzc.secondproject.serverless.domain.chatting.service.ChatMessageService; import com.mzc.secondproject.serverless.domain.chatting.service.CommandService; import com.mzc.secondproject.serverless.domain.chatting.service.GameService; +import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -362,12 +363,11 @@ private void broadcastGameStart(List connections, CommandResult resu message.put("roundTimeLimit", gameResult.room().getRoundTimeLimit()); message.put("roundStartTime", gameResult.room().getRoundStartTime()); - // 출제자에게만 제시어 전송 + // 출제자에게만 제시어 전송 (영어 단어만) if (conn.getUserId().equals(currentDrawerId) && gameResult.firstWord() != null) { Map wordInfo = new HashMap<>(); wordInfo.put("wordId", gameResult.firstWord().getWordId()); - wordInfo.put("korean", gameResult.firstWord().getKorean()); - wordInfo.put("english", gameResult.firstWord().getEnglish()); + wordInfo.put("word", gameResult.firstWord().getEnglish()); // 영어 단어만 전송 message.put("currentWord", wordInfo); } @@ -390,6 +390,7 @@ private void broadcastRoundEnd(List connections, CommandResult resul Map data = (Map) result.data(); String nextDrawer = (String) data.get("nextDrawer"); + Word nextWord = (Word) data.get("nextWord"); for (Connection conn : connections) { Map message = new HashMap<>(); @@ -399,18 +400,21 @@ private void broadcastRoundEnd(List connections, CommandResult resul message.put("content", result.message()); message.put("messageType", result.messageType().getCode()); message.put("createdAt", now); - message.put("data", data); - // 다음 출제자에게만 다음 제시어 전송 - if (conn.getUserId().equals(nextDrawer) && data.containsKey("nextWord")) { - // nextWord는 이미 data에 포함되어 있음 - } else { - // 다음 출제자가 아니면 nextWord 제거 - Map filteredData = new HashMap<>(data); - filteredData.remove("nextWord"); - message.put("data", filteredData); + // 기본 데이터 복사 (nextWord 제외) + Map messageData = new HashMap<>(data); + messageData.remove("nextWord"); + + // 다음 출제자에게만 다음 제시어 전송 (영어 단어만) + if (conn.getUserId().equals(nextDrawer) && nextWord != null) { + Map wordInfo = new HashMap<>(); + wordInfo.put("wordId", nextWord.getWordId()); + wordInfo.put("word", nextWord.getEnglish()); // 영어 단어만 전송 + messageData.put("nextWord", wordInfo); } + message.put("data", messageData); + String payload = gson.toJson(message); try { broadcaster.sendToConnection(conn.getConnectionId(), payload); From 604596c276eaa831b248da9552bd6d44935e3b39 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 11:09:04 +0900 Subject: [PATCH 310/528] =?UTF-8?q?feat:=20=EC=A0=95=EB=8B=B5=20=ED=95=9C?= =?UTF-8?q?=EA=B5=AD=EC=96=B4/=EC=98=81=EC=96=B4=20=EB=91=98=20=EB=8B=A4?= =?UTF-8?q?=20=ED=97=88=EC=9A=A9,=20=EC=8A=A4=ED=82=B5=20=EA=B6=8C?= =?UTF-8?q?=ED=95=9C=20=EC=A0=9C=ED=95=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/chatting/service/GameService.java | 31 +++++++++++++------ 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java index 9a472dda..30d662d3 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java @@ -183,9 +183,21 @@ public AnswerCheckResult checkAnswer(String roomId, String userId, String answer return AnswerCheckResult.alreadyGuessedCorrect(); } - // 정답 체크 - String currentWord = room.getCurrentWord(); - if (!isCorrectAnswer(answer, currentWord)) { + // 정답 체크 (한국어 또는 영어 둘 다 허용) + String koreanWord = room.getCurrentWord(); + String englishWord = null; + + // 영어 단어 조회 + if (room.getCurrentWordId() != null) { + englishWord = wordRepository.findById(room.getCurrentWordId()) + .map(Word::getEnglish) + .orElse(null); + } + + boolean isCorrect = isCorrectAnswer(answer, koreanWord) || + (englishWord != null && isCorrectAnswer(answer, englishWord)); + + if (!isCorrect) { return AnswerCheckResult.wrongAnswer(); } @@ -236,20 +248,19 @@ public AnswerCheckResult checkAnswer(String roomId, String userId, String answer } /** - * 라운드 스킵 + * 라운드 스킵 (누구나 가능) */ public CommandResult skipRound(String roomId, String userId) { ChatRoom room = chatRoomRepository.findById(roomId) .orElseThrow(() -> new IllegalArgumentException("채팅방을 찾을 수 없습니다.")); - + if (!GameStatus.PLAYING.name().equals(room.getGameStatus())) { return CommandResult.error("게임이 진행 중이 아닙니다."); } - - if (!userId.equals(room.getCurrentDrawerId())) { - return CommandResult.error("출제자만 라운드를 스킵할 수 있습니다."); - } - + + // 출제자 제한 제거 - 누구나 스킵 가능 + logger.info("Round skipped by user: {} in room: {}", userId, roomId); + return endRound(room, "SKIP"); } From 27669be392699d1e7b78a3982200e9c9efc43464 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 11:09:09 +0900 Subject: [PATCH 311/528] =?UTF-8?q?feat:=20=EB=AA=A8=EB=93=A0=20=EC=9C=A0?= =?UTF-8?q?=EC=A0=80=20=EB=82=98=EA=B0=80=EB=A9=B4=20=EA=B2=8C=EC=9E=84=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EC=9E=90=EB=8F=99=20=EC=B4=88=EA=B8=B0?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../websocket/WebSocketDisconnectHandler.java | 66 ++++++++++++++++--- 1 file changed, 56 insertions(+), 10 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketDisconnectHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketDisconnectHandler.java index 11af1669..3929f10e 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketDisconnectHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketDisconnectHandler.java @@ -3,11 +3,14 @@ import com.amazonaws.services.lambda.runtime.Context; import com.amazonaws.services.lambda.runtime.RequestHandler; import com.mzc.secondproject.serverless.common.util.WebSocketEventUtil; +import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; import com.mzc.secondproject.serverless.domain.chatting.model.Connection; +import com.mzc.secondproject.serverless.domain.chatting.repository.ChatRoomRepository; import com.mzc.secondproject.serverless.domain.chatting.repository.ConnectionRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.util.List; import java.util.Map; import java.util.Optional; @@ -16,38 +19,81 @@ * 클라이언트 연결 해제 시 Connection 정보를 DynamoDB에서 삭제 */ public class WebSocketDisconnectHandler implements RequestHandler, Map> { - + private static final Logger logger = LoggerFactory.getLogger(WebSocketDisconnectHandler.class); - + private final ConnectionRepository connectionRepository; - + private final ChatRoomRepository chatRoomRepository; + public WebSocketDisconnectHandler() { this.connectionRepository = new ConnectionRepository(); + this.chatRoomRepository = new ChatRoomRepository(); } - + @Override public Map handleRequest(Map event, Context context) { logger.info("WebSocket disconnect event: {}", event); - + try { String connectionId = WebSocketEventUtil.extractConnectionId(event); - + Optional connection = connectionRepository.findByConnectionId(connectionId); - + if (connection.isPresent()) { Connection conn = connection.get(); + String roomId = conn.getRoomId(); + connectionRepository.delete(connectionId); logger.info("Connection deleted: connectionId={}, userId={}, roomId={}", - connectionId, conn.getUserId(), conn.getRoomId()); + connectionId, conn.getUserId(), roomId); + + // 방에 남은 연결이 없으면 게임 상태 초기화 + List remainingConnections = connectionRepository.findByRoomId(roomId); + if (remainingConnections.isEmpty()) { + logger.info("No connections left in room {}, resetting game state", roomId); + resetGameState(roomId); + } } else { logger.warn("Connection not found for deletion: connectionId={}", connectionId); } - + return WebSocketEventUtil.ok("Disconnected"); - + } catch (Exception e) { logger.error("Error handling disconnect: {}", e.getMessage(), e); return WebSocketEventUtil.serverError("Internal server error"); } } + + /** + * 게임 상태 초기화 + */ + private void resetGameState(String roomId) { + try { + Optional roomOpt = chatRoomRepository.findById(roomId); + + if (roomOpt.isPresent()) { + ChatRoom room = roomOpt.get(); + // 게임이 진행 중이었다면 초기화 + if (room.getGameStatus() != null && !"NONE".equals(room.getGameStatus())) { + room.setGameStatus("NONE"); + room.setCurrentRound(null); + room.setCurrentDrawerId(null); + room.setCurrentWord(null); + room.setCurrentWordId(null); + room.setDrawerOrder(null); + room.setScores(null); + room.setStreaks(null); + room.setCorrectGuessers(null); + room.setHintUsed(null); + room.setRoundStartTime(null); + room.setGameStartedBy(null); + chatRoomRepository.save(room); + logger.info("Game state reset for room: {}", roomId); + } + } + } catch (Exception e) { + logger.error("Error resetting game state for room {}: {}", roomId, e.getMessage()); + } + } } From 5a7f47ae279f33ffaa656162f37036d59fb69164 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 11:09:27 +0900 Subject: [PATCH 312/528] =?UTF-8?q?fix:=20FeedbackResponse=20=EB=B9=8C?= =?UTF-8?q?=EB=93=9C=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../serverless/domain/opic/dto/response/FeedbackResponse.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/FeedbackResponse.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/FeedbackResponse.java index 42e1df7a..265a740b 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/FeedbackResponse.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/FeedbackResponse.java @@ -10,5 +10,4 @@ public record FeedbackResponse ( public static FeedbackResponse perfect(String answer, String sampleAnswer) { return new FeedbackResponse(List.of(), answer, sampleAnswer); } -} -가 \ No newline at end of file +} \ No newline at end of file From 3c2ea14319ceb88c4c8e756b552d004a5c1a4309 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 14:20:29 +0900 Subject: [PATCH 313/528] =?UTF-8?q?refactor:=20=EC=82=AC=EC=9A=A9=EB=90=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=20ChatRoomService=20=ED=81=B4?= =?UTF-8?q?=EB=9E=98=EC=8A=A4=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ChatRoomCommandService, ChatRoomQueryService로 CQRS 패턴 적용 완료되어 더 이상 사용되지 않는 중복 클래스 제거 Closes #392 --- .../chatting/service/ChatRoomService.java | 151 ------------------ 1 file changed, 151 deletions(-) delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomService.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomService.java deleted file mode 100644 index 4ee1dd0e..00000000 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomService.java +++ /dev/null @@ -1,151 +0,0 @@ -package com.mzc.secondproject.serverless.domain.chatting.service; - -import com.mzc.secondproject.serverless.common.dto.PaginatedResult; -import com.mzc.secondproject.serverless.domain.chatting.exception.ChattingException; -import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; -import com.mzc.secondproject.serverless.domain.chatting.repository.ChatRoomRepository; -import org.mindrot.jbcrypt.BCrypt; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.time.Instant; -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; -import java.util.UUID; - -public class ChatRoomService { - - private static final Logger logger = LoggerFactory.getLogger(ChatRoomService.class); - - private final ChatRoomRepository roomRepository; - - public ChatRoomService() { - this.roomRepository = new ChatRoomRepository(); - } - - public ChatRoom createRoom(String name, String description, String level, Integer maxMembers, - Boolean isPrivate, String password, String createdBy) { - String roomId = UUID.randomUUID().toString(); - String now = Instant.now().toString(); - - ChatRoom room = ChatRoom.builder() - .pk("ROOM#" + roomId) - .sk("METADATA") - .gsi1pk("ROOMS") - .gsi1sk(level + "#" + now) - .roomId(roomId) - .name(name) - .description(description) - .level(level) - .currentMembers(1) - .maxMembers(maxMembers) - .isPrivate(isPrivate) - .password(isPrivate && password != null ? BCrypt.hashpw(password, BCrypt.gensalt()) : null) - .createdBy(createdBy) - .createdAt(now) - .lastMessageAt(now) - .memberIds(new ArrayList<>(List.of(createdBy))) - .build(); - - roomRepository.save(room); - logger.info("Created room: {}", roomId); - - return room; - } - - public Optional getRoom(String roomId) { - return roomRepository.findById(roomId); - } - - public PaginatedResult getRooms(String level, int limit, String cursor) { - if (level != null && !level.isEmpty()) { - return roomRepository.findByLevelWithPagination(level, limit, cursor); - } - return roomRepository.findAllWithPagination(limit, cursor); - } - - public List filterByJoinedUser(List rooms, String userId) { - return rooms.stream() - .filter(room -> room.getMemberIds() != null && room.getMemberIds().contains(userId)) - .toList(); - } - - public ChatRoom joinRoom(String roomId, String userId, String password) { - Optional optRoom = roomRepository.findById(roomId); - if (optRoom.isEmpty()) { - throw ChattingException.roomNotFound(roomId); - } - - ChatRoom room = optRoom.get(); - - if (room.getIsPrivate()) { - if (password == null || room.getPassword() == null || !BCrypt.checkpw(password, room.getPassword())) { - throw ChattingException.roomInvalidPassword(roomId); - } - } - - if (room.getCurrentMembers() >= room.getMaxMembers()) { - throw ChattingException.roomFull(roomId, room.getMaxMembers()); - } - - if (room.getMemberIds() != null && room.getMemberIds().contains(userId)) { - logger.info("User {} already in room {}", userId, roomId); - return room; - } - - if (room.getMemberIds() == null) { - room.setMemberIds(new ArrayList<>()); - } - room.getMemberIds().add(userId); - room.setCurrentMembers(room.getCurrentMembers() + 1); - - roomRepository.save(room); - logger.info("User {} joined room {}", userId, roomId); - - return room; - } - - public LeaveResult leaveRoom(String roomId, String userId) { - Optional optRoom = roomRepository.findById(roomId); - if (optRoom.isEmpty()) { - throw ChattingException.roomNotFound(roomId); - } - - ChatRoom room = optRoom.get(); - - if (room.getMemberIds() != null) { - room.getMemberIds().remove(userId); - room.setCurrentMembers(Math.max(0, room.getCurrentMembers() - 1)); - } - - if (userId.equals(room.getCreatedBy()) || room.getCurrentMembers() <= 0) { - roomRepository.delete(roomId); - logger.info("Room {} deleted (owner left or empty)", roomId); - return new LeaveResult(true, null); - } - - roomRepository.save(room); - logger.info("User {} left room {}", userId, roomId); - - return new LeaveResult(false, room); - } - - public void deleteRoom(String roomId, String userId) { - Optional optRoom = roomRepository.findById(roomId); - if (optRoom.isEmpty()) { - throw ChattingException.roomNotFound(roomId); - } - - ChatRoom room = optRoom.get(); - if (!userId.equals(room.getCreatedBy())) { - throw ChattingException.roomNotOwner(userId, roomId); - } - - roomRepository.delete(roomId); - logger.info("Deleted room: {} by owner: {}", roomId, userId); - } - - public record LeaveResult(boolean deleted, ChatRoom room) { - } -} From 18ef2a748c63fbbfa21827ac2c7167712673c31d Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 14:23:39 +0900 Subject: [PATCH 314/528] =?UTF-8?q?fix:=20GrammarConversationService=20?= =?UTF-8?q?=EC=98=88=EC=99=B8=20=EC=B2=98=EB=A6=AC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - IllegalArgumentException을 도메인 예외로 변경 - GrammarErrorCode에 INVALID_REQUEST 추가 - GrammarException에 invalidRequest 팩토리 메서드 추가 Closes #393 --- .../domain/grammar/exception/GrammarErrorCode.java | 3 +++ .../domain/grammar/exception/GrammarException.java | 11 ++++++++++- .../grammar/service/GrammarConversationService.java | 2 +- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/exception/GrammarErrorCode.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/exception/GrammarErrorCode.java index c9fea728..cc1b9701 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/exception/GrammarErrorCode.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/exception/GrammarErrorCode.java @@ -4,6 +4,9 @@ public enum GrammarErrorCode implements DomainErrorCode { + // 요청 검증 관련 에러 + INVALID_REQUEST("GRAMMAR_000", "잘못된 요청입니다", 400), + // 문법 체크 관련 에러 INVALID_SENTENCE("GRAMMAR_001", "유효하지 않은 문장입니다", 400), GRAMMAR_CHECK_FAILED("GRAMMAR_002", "문법 체크에 실패했습니다", 500), diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/exception/GrammarException.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/exception/GrammarException.java index 48eed769..b1892588 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/exception/GrammarException.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/exception/GrammarException.java @@ -16,8 +16,17 @@ private GrammarException(GrammarErrorCode errorCode, Throwable cause) { super(errorCode, cause); } + // === 요청 검증 관련 팩토리 메서드 === + + public static GrammarException invalidRequest(String field, String reason) { + return (GrammarException) new GrammarException(GrammarErrorCode.INVALID_REQUEST, + String.format("잘못된 요청입니다: %s", reason)) + .addDetail("field", field) + .addDetail("reason", reason); + } + // === 문법 체크 관련 팩토리 메서드 === - + public static GrammarException invalidSentence(String sentence) { return (GrammarException) new GrammarException(GrammarErrorCode.INVALID_SENTENCE, "유효하지 않은 문장입니다. 문장을 확인해주세요.") diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/service/GrammarConversationService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/service/GrammarConversationService.java index 0aebdc12..1c9f96f2 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/service/GrammarConversationService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/service/GrammarConversationService.java @@ -83,7 +83,7 @@ private void validateRequest(ConversationRequest request) { throw GrammarException.invalidSentence(request.getMessage()); } if (request.getUserId() == null || request.getUserId().trim().isEmpty()) { - throw new IllegalArgumentException("userId is required"); + throw GrammarException.invalidRequest("userId", "userId는 필수입니다"); } } From e592cfd8933dce263d690d8d0172018e8c396e09 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 14:26:48 +0900 Subject: [PATCH 315/528] =?UTF-8?q?fix:=20BedrockGrammarCheckFactory=20nul?= =?UTF-8?q?l=20=EC=B2=B4=ED=81=AC=20=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - JSON 응답 파싱 시 안전한 헬퍼 메서드 추가 - extractContentFromResponse: content 배열 null/empty 검증 - getStringOrDefault, getIntOrDefault, getBooleanOrDefault 추가 - parseGrammarCheckFromJson, parseConversationResponse 안전하게 수정 Closes #394 --- .../factory/BedrockGrammarCheckFactory.java | 108 +++++++++++++----- 1 file changed, 79 insertions(+), 29 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/factory/BedrockGrammarCheckFactory.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/factory/BedrockGrammarCheckFactory.java index 84735539..d3d05212 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/factory/BedrockGrammarCheckFactory.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/factory/BedrockGrammarCheckFactory.java @@ -56,14 +56,12 @@ public GrammarCheckResponse checkGrammar(String sentence, GrammarLevel level) { String responseBody = response.body().asUtf8String(); JsonObject jsonResponse = gson.fromJson(responseBody, JsonObject.class); - - String content = jsonResponse.getAsJsonArray("content") - .get(0).getAsJsonObject() - .get("text").getAsString(); - + + String content = extractContentFromResponse(jsonResponse); + long processingTime = System.currentTimeMillis() - startTime; logger.info("Grammar check completed in {}ms", processingTime); - + return parseGrammarResponse(sentence, content); } catch (GrammarException e) { @@ -188,6 +186,46 @@ private Integer getIntOrNull(JsonObject obj, String key) { } return element.getAsInt(); } + + private String getStringOrDefault(JsonObject obj, String key, String defaultValue) { + JsonElement element = obj.get(key); + if (element == null || element.isJsonNull()) { + return defaultValue; + } + return element.getAsString(); + } + + private int getIntOrDefault(JsonObject obj, String key, int defaultValue) { + JsonElement element = obj.get(key); + if (element == null || element.isJsonNull()) { + return defaultValue; + } + return element.getAsInt(); + } + + private boolean getBooleanOrDefault(JsonObject obj, String key, boolean defaultValue) { + JsonElement element = obj.get(key); + if (element == null || element.isJsonNull()) { + return defaultValue; + } + return element.getAsBoolean(); + } + + private String extractContentFromResponse(JsonObject jsonResponse) { + JsonArray contentArray = jsonResponse.getAsJsonArray("content"); + if (contentArray == null || contentArray.isEmpty()) { + throw GrammarException.bedrockResponseParseError("content array is null or empty"); + } + JsonObject firstContent = contentArray.get(0).getAsJsonObject(); + if (firstContent == null) { + throw GrammarException.bedrockResponseParseError("first content object is null"); + } + JsonElement textElement = firstContent.get("text"); + if (textElement == null || textElement.isJsonNull()) { + throw GrammarException.bedrockResponseParseError("text element is null"); + } + return textElement.getAsString(); + } public ConversationResponse generateConversation(String sessionId, String message, GrammarLevel level, String conversationHistory) { logger.info("Generating conversation: sessionId={}, level={}", sessionId, level.name()); @@ -211,14 +249,12 @@ public ConversationResponse generateConversation(String sessionId, String messag String responseBody = response.body().asUtf8String(); JsonObject jsonResponse = gson.fromJson(responseBody, JsonObject.class); - - String content = jsonResponse.getAsJsonArray("content") - .get(0).getAsJsonObject() - .get("text").getAsString(); - + + String content = extractContentFromResponse(jsonResponse); + long processingTime = System.currentTimeMillis() - startTime; logger.info("Conversation generated in {}ms", processingTime); - + return parseConversationResponse(sessionId, message, content); } catch (GrammarException e) { @@ -307,20 +343,34 @@ private ConversationResponse parseConversationResponse(String sessionId, String try { String jsonContent = extractJson(aiResponse); JsonObject json = gson.fromJson(jsonContent, JsonObject.class); - + JsonObject grammarCheckObj = json.getAsJsonObject("grammarCheck"); - GrammarCheckResponse grammarCheck = parseGrammarCheckFromJson(originalMessage, grammarCheckObj); - - String conversationResponse = json.get("aiResponse").getAsString(); - String tip = json.get("conversationTip").getAsString(); - + GrammarCheckResponse grammarCheck; + if (grammarCheckObj != null) { + grammarCheck = parseGrammarCheckFromJson(originalMessage, grammarCheckObj); + } else { + grammarCheck = GrammarCheckResponse.builder() + .originalSentence(originalMessage) + .correctedSentence(originalMessage) + .score(100) + .isCorrect(true) + .errors(new ArrayList<>()) + .feedback("Perfect!") + .build(); + } + + String conversationResponse = getStringOrDefault(json, "aiResponse", ""); + String tip = getStringOrDefault(json, "conversationTip", ""); + return ConversationResponse.builder() .sessionId(sessionId) .grammarCheck(grammarCheck) .aiResponse(conversationResponse) .conversationTip(tip) .build(); - + + } catch (GrammarException e) { + throw e; } catch (Exception e) { logger.error("Failed to parse conversation response: length={}", aiResponse != null ? aiResponse.length() : 0, e); @@ -332,28 +382,28 @@ private ConversationResponse parseConversationResponse(String sessionId, String } private GrammarCheckResponse parseGrammarCheckFromJson(String originalSentence, JsonObject json) { - String correctedSentence = json.get("correctedSentence").getAsString(); - int score = json.get("score").getAsInt(); - boolean isCorrect = json.get("isCorrect").getAsBoolean(); - String feedback = json.get("feedback").getAsString(); - + String correctedSentence = getStringOrDefault(json, "correctedSentence", originalSentence); + int score = getIntOrDefault(json, "score", 100); + boolean isCorrect = getBooleanOrDefault(json, "isCorrect", true); + String feedback = getStringOrDefault(json, "feedback", ""); + List errors = new ArrayList<>(); JsonArray errorsArray = json.getAsJsonArray("errors"); if (errorsArray != null) { for (JsonElement element : errorsArray) { JsonObject errorObj = element.getAsJsonObject(); GrammarError error = GrammarError.builder() - .type(parseErrorType(errorObj.get("type").getAsString())) - .original(errorObj.get("original").getAsString()) - .corrected(errorObj.get("corrected").getAsString()) - .explanation(errorObj.get("explanation").getAsString()) + .type(parseErrorType(getStringOrDefault(errorObj, "type", "OTHER"))) + .original(getStringOrDefault(errorObj, "original", "")) + .corrected(getStringOrDefault(errorObj, "corrected", "")) + .explanation(getStringOrDefault(errorObj, "explanation", "")) .startIndex(getIntOrNull(errorObj, "startIndex")) .endIndex(getIntOrNull(errorObj, "endIndex")) .build(); errors.add(error); } } - + return GrammarCheckResponse.builder() .originalSentence(originalSentence) .correctedSentence(correctedSentence) From 59e9829bd43a3c909078d8900e0b7ce609d88d2e Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 14:29:25 +0900 Subject: [PATCH 316/528] =?UTF-8?q?refactor:=20Grammar=20=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=20=EC=A0=80=EC=9E=A5=20=EB=A9=94=EC=84=9C?= =?UTF-8?q?=EB=93=9C=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - saveUserMessage()와 saveAssistantMessage()를 saveMessage()로 통합 - role 파라미터로 메시지 타입 구분 - 중복 코드 47줄 → 25줄로 감소 Closes #395 --- .../service/GrammarConversationService.java | 40 +++++-------------- 1 file changed, 9 insertions(+), 31 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/service/GrammarConversationService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/service/GrammarConversationService.java index 0aebdc12..8c5615ae 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/service/GrammarConversationService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/service/GrammarConversationService.java @@ -67,10 +67,10 @@ public ConversationResponse chat(ConversationRequest request) { ); // 사용자 메시지 저장 - saveUserMessage(session, request.getMessage(), response.getGrammarCheck()); - + saveMessage("USER", session, request.getMessage(), response.getGrammarCheck()); + // AI 응답 메시지 저장 - saveAssistantMessage(session, response.getAiResponse()); + saveMessage("ASSISTANT", session, response.getAiResponse(), null); // 세션 업데이트 updateSession(session, request.getMessage()); @@ -144,11 +144,11 @@ private String buildConversationHistory(String sessionId) { } } - private void saveUserMessage(GrammarSession session, String content, GrammarCheckResponse grammarCheck) { + private void saveMessage(String role, GrammarSession session, String content, GrammarCheckResponse grammarCheck) { String now = Instant.now().toString(); String messageId = UUID.randomUUID().toString(); long ttl = Instant.now().plus(SESSION_TTL_DAYS, ChronoUnit.DAYS).getEpochSecond(); - + GrammarMessage message = GrammarMessage.builder() .pk(GrammarKey.sessionPk(session.getUserId())) .sk(GrammarKey.messageSk(now, messageId)) @@ -157,7 +157,7 @@ private void saveUserMessage(GrammarSession session, String content, GrammarChec .messageId(messageId) .sessionId(session.getSessionId()) .userId(session.getUserId()) - .role("USER") + .role(role) .content(content) .correctedContent(grammarCheck != null ? grammarCheck.getCorrectedSentence() : null) .errorsJson(grammarCheck != null ? gson.toJson(grammarCheck.getErrors()) : null) @@ -167,29 +167,7 @@ private void saveUserMessage(GrammarSession session, String content, GrammarChec .createdAt(now) .ttl(ttl) .build(); - - repository.saveMessage(message); - } - - private void saveAssistantMessage(GrammarSession session, String content) { - String now = Instant.now().toString(); - String messageId = UUID.randomUUID().toString(); - long ttl = Instant.now().plus(SESSION_TTL_DAYS, ChronoUnit.DAYS).getEpochSecond(); - - GrammarMessage message = GrammarMessage.builder() - .pk(GrammarKey.sessionPk(session.getUserId())) - .sk(GrammarKey.messageSk(now, messageId)) - .gsi1pk(GrammarKey.messageGsi1Pk(session.getSessionId())) - .gsi1sk(GrammarKey.messageGsi1Sk(now)) - .messageId(messageId) - .sessionId(session.getSessionId()) - .userId(session.getUserId()) - .role("ASSISTANT") - .content(content) - .createdAt(now) - .ttl(ttl) - .build(); - + repository.saveMessage(message); } @@ -264,9 +242,9 @@ public void onToken(String token) { @Override public void onComplete(ConversationResponse response) { // 사용자 메시지 저장 - saveUserMessage(session, message, response.getGrammarCheck()); + saveMessage("USER", session, message, response.getGrammarCheck()); // AI 응답 메시지 저장 - saveAssistantMessage(session, response.getAiResponse()); + saveMessage("ASSISTANT", session, response.getAiResponse(), null); // 세션 업데이트 updateSession(session, message); // 완료 콜백 호출 From 36a6e6f026681498f34aa926ca8595c02748a671 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 14:31:07 +0900 Subject: [PATCH 317/528] =?UTF-8?q?fix:=20BadgeRepository=20=ED=81=B4?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EC=96=B8=ED=8A=B8=20=EC=B4=88=EA=B8=B0?= =?UTF-8?q?=ED=99=94=20=ED=8C=A8=ED=84=B4=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 개별 DynamoDbEnhancedClient 생성 대신 AwsClients.dynamoDbEnhanced() 싱글톤 사용 - 다른 Repository들과 동일한 패턴 적용 - 불필요한 import 제거 Closes #396 --- .../serverless/domain/badge/repository/BadgeRepository.java | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/repository/BadgeRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/repository/BadgeRepository.java index 104123df..6c139927 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/repository/BadgeRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/repository/BadgeRepository.java @@ -5,7 +5,6 @@ import com.mzc.secondproject.serverless.domain.badge.model.UserBadge; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; import software.amazon.awssdk.enhanced.dynamodb.Key; import software.amazon.awssdk.enhanced.dynamodb.TableSchema; @@ -24,10 +23,7 @@ public class BadgeRepository { private final DynamoDbTable table; public BadgeRepository() { - DynamoDbEnhancedClient enhancedClient = DynamoDbEnhancedClient.builder() - .dynamoDbClient(AwsClients.dynamoDb()) - .build(); - this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(UserBadge.class)); + this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(UserBadge.class)); } public void save(UserBadge badge) { From a02dd3ecaf9b6a491893bba7417a8d3e767c9cab Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 14:47:45 +0900 Subject: [PATCH 318/528] =?UTF-8?q?feat:=20EnvConfig=20=EC=9C=A0=ED=8B=B8?= =?UTF-8?q?=EB=A6=AC=ED=8B=B0=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=20=EB=B3=80=EC=88=98=20=EA=B2=80=EC=A6=9D=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 환경 변수 미설정 시 명확한 에러 메시지를 제공하는 EnvConfig 유틸리티를 추가하고, 기존 System.getenv 호출을 EnvConfig.getRequired/getOrDefault로 대체함. - EnvConfig: getRequired, getOrDefault, getIntOrDefault, getLongOrDefault 메서드 제공 - Lambda Cold Start 시점에 환경 변수 누락을 조기 감지 - 기존 Config 클래스(WebSocketConfig, RoomTokenConfig) EnvConfig 사용으로 통일 Closes #403 --- .../serverless/common/config/EnvConfig.java | 94 +++++++++++++++++++ .../common/config/RoomTokenConfig.java | 20 +--- .../common/config/WebSocketConfig.java | 24 ++--- .../serverless/common/util/S3PresignUtil.java | 3 +- .../badge/repository/BadgeRepository.java | 3 +- .../chatting/handler/ChatVoiceHandler.java | 3 +- .../repository/ChatMessageRepository.java | 3 +- .../repository/ChatRoomRepository.java | 3 +- .../repository/ConnectionRepository.java | 3 +- .../repository/GameRoundRepository.java | 3 +- .../repository/RoomTokenRepository.java | 3 +- .../GrammarConnectionRepository.java | 3 +- .../repository/GrammarSessionRepository.java | 3 +- .../stats/handler/ScheduledStatsHandler.java | 3 +- .../stats/repository/UserStatsRepository.java | 3 +- .../vocabulary/handler/VoiceHandler.java | 3 +- .../repository/DailyStudyRepository.java | 3 +- .../repository/TestResultRepository.java | 3 +- .../repository/UserWordRepository.java | 3 +- .../repository/WordGroupRepository.java | 3 +- .../vocabulary/repository/WordRepository.java | 3 +- .../service/TestCommandService.java | 3 +- .../vocabulary/service/TestService.java | 3 +- 23 files changed, 144 insertions(+), 54 deletions(-) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/EnvConfig.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/EnvConfig.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/EnvConfig.java new file mode 100644 index 00000000..bd8f6405 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/EnvConfig.java @@ -0,0 +1,94 @@ +package com.mzc.secondproject.serverless.common.config; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * 환경 변수 설정 유틸리티 + * - 필수 환경 변수 누락 시 명확한 에러 메시지 제공 + * - Lambda 시작 시 설정 오류를 빠르게 감지 + */ +public final class EnvConfig { + + private static final Logger logger = LoggerFactory.getLogger(EnvConfig.class); + + private EnvConfig() { + // 유틸리티 클래스 - 인스턴스화 방지 + } + + /** + * 필수 환경 변수를 가져옵니다. + * 환경 변수가 설정되지 않았거나 빈 문자열인 경우 IllegalStateException을 발생시킵니다. + * + * @param name 환경 변수 이름 + * @return 환경 변수 값 + * @throws IllegalStateException 환경 변수가 설정되지 않은 경우 + */ + public static String getRequired(String name) { + String value = System.getenv(name); + if (value == null || value.trim().isEmpty()) { + String message = String.format("필수 환경 변수 '%s'가 설정되지 않았습니다. SAM template.yaml을 확인하세요.", name); + logger.error(message); + throw new IllegalStateException(message); + } + return value; + } + + /** + * 선택적 환경 변수를 가져옵니다. + * 환경 변수가 설정되지 않은 경우 기본값을 반환합니다. + * + * @param name 환경 변수 이름 + * @param defaultValue 기본값 + * @return 환경 변수 값 또는 기본값 + */ + public static String getOrDefault(String name, String defaultValue) { + String value = System.getenv(name); + if (value == null || value.trim().isEmpty()) { + logger.debug("환경 변수 '{}'가 설정되지 않아 기본값 '{}'을 사용합니다.", name, defaultValue); + return defaultValue; + } + return value; + } + + /** + * 선택적 환경 변수를 정수로 가져옵니다. + * 환경 변수가 설정되지 않거나 파싱에 실패한 경우 기본값을 반환합니다. + * + * @param name 환경 변수 이름 + * @param defaultValue 기본값 + * @return 환경 변수 값 또는 기본값 + */ + public static int getIntOrDefault(String name, int defaultValue) { + String value = System.getenv(name); + if (value == null || value.trim().isEmpty()) { + return defaultValue; + } + try { + return Integer.parseInt(value.trim()); + } catch (NumberFormatException e) { + logger.warn("환경 변수 '{}'의 값 '{}'을 정수로 변환할 수 없어 기본값 {}을 사용합니다.", name, value, defaultValue); + return defaultValue; + } + } + + /** + * 선택적 환경 변수를 long으로 가져옵니다. + * + * @param name 환경 변수 이름 + * @param defaultValue 기본값 + * @return 환경 변수 값 또는 기본값 + */ + public static long getLongOrDefault(String name, long defaultValue) { + String value = System.getenv(name); + if (value == null || value.trim().isEmpty()) { + return defaultValue; + } + try { + return Long.parseLong(value.trim()); + } catch (NumberFormatException e) { + logger.warn("환경 변수 '{}'의 값 '{}'을 long으로 변환할 수 없어 기본값 {}을 사용합니다.", name, value, defaultValue); + return defaultValue; + } + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/RoomTokenConfig.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/RoomTokenConfig.java index 240a02c1..fdbf4b37 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/RoomTokenConfig.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/RoomTokenConfig.java @@ -5,30 +5,18 @@ * Lambda 환경변수에서 값을 읽어오며, 없을 경우 기본값 사용 */ public final class RoomTokenConfig { - + // 환경변수 키 private static final String ENV_TOKEN_TTL_SECONDS = "ROOM_TOKEN_TTL_SECONDS"; // 기본값: 5분 private static final long DEFAULT_TOKEN_TTL_SECONDS = 300L; // 캐시된 값 (Cold Start 최적화) - private static final long TOKEN_TTL_SECONDS = parseTokenTtl(); - + private static final long TOKEN_TTL_SECONDS = EnvConfig.getLongOrDefault(ENV_TOKEN_TTL_SECONDS, DEFAULT_TOKEN_TTL_SECONDS); + private RoomTokenConfig() { // 인스턴스화 방지 } - - private static long parseTokenTtl() { - String value = System.getenv(ENV_TOKEN_TTL_SECONDS); - if (value != null) { - try { - return Long.parseLong(value); - } catch (NumberFormatException ignored) { - // 파싱 실패 시 기본값 사용 - } - } - return DEFAULT_TOKEN_TTL_SECONDS; - } - + /** * RoomToken TTL (초) */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/WebSocketConfig.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/WebSocketConfig.java index 1ceb6331..2660fd48 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/WebSocketConfig.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/WebSocketConfig.java @@ -5,39 +5,27 @@ * Lambda 환경변수에서 값을 읽어오며, 없을 경우 기본값 사용 */ public final class WebSocketConfig { - + // 환경변수 키 private static final String ENV_CONNECTION_TTL_SECONDS = "WEBSOCKET_CONNECTION_TTL_SECONDS"; private static final String ENV_WEBSOCKET_ENDPOINT = "WEBSOCKET_ENDPOINT"; // 기본값 private static final long DEFAULT_CONNECTION_TTL_SECONDS = 600L; // 10분 // 캐시된 값 (Cold Start 최적화) - private static final long CONNECTION_TTL_SECONDS = parseConnectionTtl(); - private static final String WEBSOCKET_ENDPOINT = System.getenv(ENV_WEBSOCKET_ENDPOINT); - + private static final long CONNECTION_TTL_SECONDS = EnvConfig.getLongOrDefault(ENV_CONNECTION_TTL_SECONDS, DEFAULT_CONNECTION_TTL_SECONDS); + private static final String WEBSOCKET_ENDPOINT = EnvConfig.getRequired(ENV_WEBSOCKET_ENDPOINT); + private WebSocketConfig() { // 인스턴스화 방지 } - - private static long parseConnectionTtl() { - String value = System.getenv(ENV_CONNECTION_TTL_SECONDS); - if (value != null) { - try { - return Long.parseLong(value); - } catch (NumberFormatException ignored) { - // 파싱 실패 시 기본값 사용 - } - } - return DEFAULT_CONNECTION_TTL_SECONDS; - } - + /** * WebSocket 연결 TTL (초) */ public static long connectionTtlSeconds() { return CONNECTION_TTL_SECONDS; } - + /** * WebSocket API Gateway 엔드포인트 URL * 메시지 브로드캐스트 시 사용 diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/S3PresignUtil.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/S3PresignUtil.java index aa4fbcab..0c85196e 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/S3PresignUtil.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/S3PresignUtil.java @@ -1,6 +1,7 @@ package com.mzc.secondproject.serverless.common.util; import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.config.EnvConfig; import software.amazon.awssdk.services.s3.model.GetObjectRequest; import software.amazon.awssdk.services.s3.presigner.S3Presigner; import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; @@ -16,7 +17,7 @@ public class S3PresignUtil { private static final S3Presigner presigner = AwsClients.s3Presigner(); - private static final String BUCKET_NAME = System.getenv("BUCKET_NAME"); + private static final String BUCKET_NAME = EnvConfig.getRequired("BUCKET_NAME"); private static final Duration DEFAULT_DURATION = Duration.ofHours(24); // 캐시 (키: S3 key, 값: presigned URL, 만료시간) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/repository/BadgeRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/repository/BadgeRepository.java index 6c139927..17ddeeae 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/repository/BadgeRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/repository/BadgeRepository.java @@ -1,6 +1,7 @@ package com.mzc.secondproject.serverless.domain.badge.repository; import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.config.EnvConfig; import com.mzc.secondproject.serverless.domain.badge.constants.BadgeKey; import com.mzc.secondproject.serverless.domain.badge.model.UserBadge; import org.slf4j.Logger; @@ -18,7 +19,7 @@ public class BadgeRepository { private static final Logger logger = LoggerFactory.getLogger(BadgeRepository.class); - private static final String TABLE_NAME = System.getenv("VOCAB_TABLE_NAME"); + private static final String TABLE_NAME = EnvConfig.getRequired("VOCAB_TABLE_NAME"); private final DynamoDbTable table; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatVoiceHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatVoiceHandler.java index 39dfd2a3..7ac5b102 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatVoiceHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatVoiceHandler.java @@ -4,6 +4,7 @@ import com.amazonaws.services.lambda.runtime.RequestHandler; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import com.mzc.secondproject.serverless.common.config.EnvConfig; import com.mzc.secondproject.serverless.common.exception.CommonErrorCode; import com.mzc.secondproject.serverless.common.service.PollyService; import com.mzc.secondproject.serverless.common.service.PollyService.VoiceSynthesisResult; @@ -22,7 +23,7 @@ public class ChatVoiceHandler implements RequestHandler { private static final Logger logger = LoggerFactory.getLogger(ChatVoiceHandler.class); - private static final String BUCKET_NAME = System.getenv("CHAT_BUCKET_NAME"); + private static final String BUCKET_NAME = EnvConfig.getRequired("CHAT_BUCKET_NAME"); private final PollyService pollyService; private final ChatMessageRepository messageRepository; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatMessageRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatMessageRepository.java index 9f179e66..e2a5ddbb 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatMessageRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatMessageRepository.java @@ -1,6 +1,7 @@ package com.mzc.secondproject.serverless.domain.chatting.repository; import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.config.EnvConfig; import com.mzc.secondproject.serverless.common.dto.PaginatedResult; import com.mzc.secondproject.serverless.common.util.CursorUtil; import com.mzc.secondproject.serverless.domain.chatting.model.ChatMessage; @@ -21,7 +22,7 @@ public class ChatMessageRepository { private static final Logger logger = LoggerFactory.getLogger(ChatMessageRepository.class); - private static final String TABLE_NAME = System.getenv("CHAT_TABLE_NAME"); + private static final String TABLE_NAME = EnvConfig.getRequired("CHAT_TABLE_NAME"); private final DynamoDbTable table; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatRoomRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatRoomRepository.java index 1546a091..59a00e85 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatRoomRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatRoomRepository.java @@ -1,6 +1,7 @@ package com.mzc.secondproject.serverless.domain.chatting.repository; import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.config.EnvConfig; import com.mzc.secondproject.serverless.common.dto.PaginatedResult; import com.mzc.secondproject.serverless.common.util.CursorUtil; import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; @@ -24,7 +25,7 @@ public class ChatRoomRepository { private static final Logger logger = LoggerFactory.getLogger(ChatRoomRepository.class); - private static final String TABLE_NAME = System.getenv("CHAT_TABLE_NAME"); + private static final String TABLE_NAME = EnvConfig.getRequired("CHAT_TABLE_NAME"); private final DynamoDbTable table; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ConnectionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ConnectionRepository.java index bcdc6a3a..bf59cdb8 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ConnectionRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ConnectionRepository.java @@ -1,6 +1,7 @@ package com.mzc.secondproject.serverless.domain.chatting.repository; import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.config.EnvConfig; import com.mzc.secondproject.serverless.domain.chatting.model.Connection; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -18,7 +19,7 @@ public class ConnectionRepository { private static final Logger logger = LoggerFactory.getLogger(ConnectionRepository.class); - private static final String TABLE_NAME = System.getenv("CHAT_TABLE_NAME"); + private static final String TABLE_NAME = EnvConfig.getRequired("CHAT_TABLE_NAME"); private final DynamoDbTable table; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/GameRoundRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/GameRoundRepository.java index 9194eb8d..ccb6556f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/GameRoundRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/GameRoundRepository.java @@ -1,6 +1,7 @@ package com.mzc.secondproject.serverless.domain.chatting.repository; import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.config.EnvConfig; import com.mzc.secondproject.serverless.domain.chatting.model.GameRound; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -22,7 +23,7 @@ public class GameRoundRepository { private static final Logger logger = LoggerFactory.getLogger(GameRoundRepository.class); - private static final String TABLE_NAME = System.getenv("CHAT_TABLE_NAME"); + private static final String TABLE_NAME = EnvConfig.getRequired("CHAT_TABLE_NAME"); private static final int BATCH_SIZE = 25; // DynamoDB BatchWriteItem 최대 25개 private final DynamoDbEnhancedClient enhancedClient; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/RoomTokenRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/RoomTokenRepository.java index 41ad0fdd..fa6aee2e 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/RoomTokenRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/RoomTokenRepository.java @@ -1,6 +1,7 @@ package com.mzc.secondproject.serverless.domain.chatting.repository; import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.config.EnvConfig; import com.mzc.secondproject.serverless.domain.chatting.model.RoomToken; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -13,7 +14,7 @@ public class RoomTokenRepository { private static final Logger logger = LoggerFactory.getLogger(RoomTokenRepository.class); - private static final String TABLE_NAME = System.getenv("CHAT_TABLE_NAME"); + private static final String TABLE_NAME = EnvConfig.getRequired("CHAT_TABLE_NAME"); private final DynamoDbTable table; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/repository/GrammarConnectionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/repository/GrammarConnectionRepository.java index 0d70b423..9ec4396c 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/repository/GrammarConnectionRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/repository/GrammarConnectionRepository.java @@ -1,6 +1,7 @@ package com.mzc.secondproject.serverless.domain.grammar.repository; import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.config.EnvConfig; import com.mzc.secondproject.serverless.domain.grammar.model.GrammarConnection; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -16,7 +17,7 @@ public class GrammarConnectionRepository { private static final Logger logger = LoggerFactory.getLogger(GrammarConnectionRepository.class); - private static final String TABLE_NAME = System.getenv("CHAT_TABLE_NAME"); + private static final String TABLE_NAME = EnvConfig.getRequired("CHAT_TABLE_NAME"); private final DynamoDbTable table; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/repository/GrammarSessionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/repository/GrammarSessionRepository.java index 34fb090c..8c0466f8 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/repository/GrammarSessionRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/repository/GrammarSessionRepository.java @@ -1,6 +1,7 @@ package com.mzc.secondproject.serverless.domain.grammar.repository; import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.config.EnvConfig; import com.mzc.secondproject.serverless.common.dto.PaginatedResult; import com.mzc.secondproject.serverless.common.util.CursorUtil; import com.mzc.secondproject.serverless.domain.grammar.constants.GrammarKey; @@ -23,7 +24,7 @@ public class GrammarSessionRepository { private static final Logger logger = LoggerFactory.getLogger(GrammarSessionRepository.class); - private static final String TABLE_NAME = System.getenv("CHAT_TABLE_NAME"); + private static final String TABLE_NAME = EnvConfig.getRequired("CHAT_TABLE_NAME"); private final DynamoDbTable sessionTable; private final DynamoDbTable messageTable; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/ScheduledStatsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/ScheduledStatsHandler.java index bd2bc1f8..550dd691 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/ScheduledStatsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/ScheduledStatsHandler.java @@ -3,6 +3,7 @@ import com.amazonaws.services.lambda.runtime.Context; import com.amazonaws.services.lambda.runtime.RequestHandler; import com.amazonaws.services.lambda.runtime.events.ScheduledEvent; +import com.mzc.secondproject.serverless.common.config.EnvConfig; import com.mzc.secondproject.serverless.domain.stats.repository.UserStatsRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -18,7 +19,7 @@ public class ScheduledStatsHandler implements RequestHandler { private static final Logger logger = LoggerFactory.getLogger(ScheduledStatsHandler.class); - private static final String TABLE_NAME = System.getenv("VOCAB_TABLE_NAME"); + private static final String TABLE_NAME = EnvConfig.getRequired("VOCAB_TABLE_NAME"); private final UserStatsRepository userStatsRepository; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java index d6cb8741..dc64cebd 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java @@ -1,6 +1,7 @@ package com.mzc.secondproject.serverless.domain.stats.repository; import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.config.EnvConfig; import com.mzc.secondproject.serverless.common.dto.PaginatedResult; import com.mzc.secondproject.serverless.common.util.CursorUtil; import com.mzc.secondproject.serverless.domain.stats.constants.StatsKey; @@ -28,7 +29,7 @@ public class UserStatsRepository { private static final Logger logger = LoggerFactory.getLogger(UserStatsRepository.class); - private static final String TABLE_NAME = System.getenv("VOCAB_TABLE_NAME"); + private static final String TABLE_NAME = EnvConfig.getRequired("VOCAB_TABLE_NAME"); private final DynamoDbTable table; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/VoiceHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/VoiceHandler.java index e5031139..83029c40 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/VoiceHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/VoiceHandler.java @@ -4,6 +4,7 @@ import com.amazonaws.services.lambda.runtime.RequestHandler; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import com.mzc.secondproject.serverless.common.config.EnvConfig; import com.mzc.secondproject.serverless.common.exception.CommonErrorCode; import com.mzc.secondproject.serverless.common.service.PollyService; import com.mzc.secondproject.serverless.common.util.ResponseGenerator; @@ -22,7 +23,7 @@ public class VoiceHandler implements RequestHandler { private static final Logger logger = LoggerFactory.getLogger(VoiceHandler.class); - private static final String BUCKET_NAME = System.getenv("VOCAB_BUCKET_NAME"); + private static final String BUCKET_NAME = EnvConfig.getRequired("VOCAB_BUCKET_NAME"); private final WordRepository wordRepository; private final PollyService pollyService; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/DailyStudyRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/DailyStudyRepository.java index 5b3e9e81..e73c5ca7 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/DailyStudyRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/DailyStudyRepository.java @@ -1,6 +1,7 @@ package com.mzc.secondproject.serverless.domain.vocabulary.repository; import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.config.EnvConfig; import com.mzc.secondproject.serverless.common.dto.PaginatedResult; import com.mzc.secondproject.serverless.common.util.CursorUtil; import com.mzc.secondproject.serverless.domain.vocabulary.model.DailyStudy; @@ -22,7 +23,7 @@ public class DailyStudyRepository { private static final Logger logger = LoggerFactory.getLogger(DailyStudyRepository.class); - private static final String TABLE_NAME = System.getenv("VOCAB_TABLE_NAME"); + private static final String TABLE_NAME = EnvConfig.getRequired("VOCAB_TABLE_NAME"); private final DynamoDbTable table; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/TestResultRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/TestResultRepository.java index 0eeba78e..790b7a79 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/TestResultRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/TestResultRepository.java @@ -1,6 +1,7 @@ package com.mzc.secondproject.serverless.domain.vocabulary.repository; import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.config.EnvConfig; import com.mzc.secondproject.serverless.common.dto.PaginatedResult; import com.mzc.secondproject.serverless.common.util.CursorUtil; import com.mzc.secondproject.serverless.domain.vocabulary.model.TestResult; @@ -21,7 +22,7 @@ public class TestResultRepository { private static final Logger logger = LoggerFactory.getLogger(TestResultRepository.class); - private static final String TABLE_NAME = System.getenv("VOCAB_TABLE_NAME"); + private static final String TABLE_NAME = EnvConfig.getRequired("VOCAB_TABLE_NAME"); private final DynamoDbTable table; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/UserWordRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/UserWordRepository.java index b221462f..8ad00235 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/UserWordRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/UserWordRepository.java @@ -1,6 +1,7 @@ package com.mzc.secondproject.serverless.domain.vocabulary.repository; import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.config.EnvConfig; import com.mzc.secondproject.serverless.common.dto.PaginatedResult; import com.mzc.secondproject.serverless.common.util.CursorUtil; import com.mzc.secondproject.serverless.domain.vocabulary.model.UserWord; @@ -18,7 +19,7 @@ public class UserWordRepository { private static final Logger logger = LoggerFactory.getLogger(UserWordRepository.class); - private static final String TABLE_NAME = System.getenv("VOCAB_TABLE_NAME"); + private static final String TABLE_NAME = EnvConfig.getRequired("VOCAB_TABLE_NAME"); private final DynamoDbTable table; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordGroupRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordGroupRepository.java index 92263ff2..ddab69b1 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordGroupRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordGroupRepository.java @@ -1,6 +1,7 @@ package com.mzc.secondproject.serverless.domain.vocabulary.repository; import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.config.EnvConfig; import com.mzc.secondproject.serverless.common.dto.PaginatedResult; import com.mzc.secondproject.serverless.common.util.CursorUtil; import com.mzc.secondproject.serverless.domain.vocabulary.model.WordGroup; @@ -20,7 +21,7 @@ public class WordGroupRepository { private static final Logger logger = LoggerFactory.getLogger(WordGroupRepository.class); - private static final String TABLE_NAME = System.getenv("VOCAB_TABLE_NAME"); + private static final String TABLE_NAME = EnvConfig.getRequired("VOCAB_TABLE_NAME"); private final DynamoDbTable table; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordRepository.java index d6ba6491..d8602c9e 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordRepository.java @@ -1,6 +1,7 @@ package com.mzc.secondproject.serverless.domain.vocabulary.repository; import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.config.EnvConfig; import com.mzc.secondproject.serverless.common.dto.PaginatedResult; import com.mzc.secondproject.serverless.common.util.CursorUtil; import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; @@ -18,7 +19,7 @@ public class WordRepository { private static final Logger logger = LoggerFactory.getLogger(WordRepository.class); - private static final String TABLE_NAME = System.getenv("VOCAB_TABLE_NAME"); + private static final String TABLE_NAME = EnvConfig.getRequired("VOCAB_TABLE_NAME"); private final DynamoDbEnhancedClient enhancedClient; private final DynamoDbTable table; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java index 41ce80ee..45370e0c 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java @@ -1,6 +1,7 @@ package com.mzc.secondproject.serverless.domain.vocabulary.service; import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.config.EnvConfig; import com.mzc.secondproject.serverless.common.dto.PaginatedResult; import com.mzc.secondproject.serverless.common.util.ResponseGenerator; import com.mzc.secondproject.serverless.domain.vocabulary.dto.request.SubmitTestRequest; @@ -26,7 +27,7 @@ public class TestCommandService { private static final Logger logger = LoggerFactory.getLogger(TestCommandService.class); - private static final String TEST_RESULT_TOPIC_ARN = System.getenv("TEST_RESULT_TOPIC_ARN"); + private static final String TEST_RESULT_TOPIC_ARN = EnvConfig.getRequired("TEST_RESULT_TOPIC_ARN"); private final TestResultRepository testResultRepository; private final DailyStudyRepository dailyStudyRepository; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestService.java index a8eb506c..ecde07d6 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestService.java @@ -1,6 +1,7 @@ package com.mzc.secondproject.serverless.domain.vocabulary.service; import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.config.EnvConfig; import com.mzc.secondproject.serverless.common.dto.PaginatedResult; import com.mzc.secondproject.serverless.common.util.ResponseGenerator; import com.mzc.secondproject.serverless.domain.vocabulary.model.DailyStudy; @@ -21,7 +22,7 @@ public class TestService { private static final Logger logger = LoggerFactory.getLogger(TestService.class); - private static final String TEST_RESULT_TOPIC_ARN = System.getenv("TEST_RESULT_TOPIC_ARN"); + private static final String TEST_RESULT_TOPIC_ARN = EnvConfig.getRequired("TEST_RESULT_TOPIC_ARN"); private final TestResultRepository testResultRepository; private final DailyStudyRepository dailyStudyRepository; From fc144bd6a5c336e06f611b5268a9c837a7578eb3 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 14:50:43 +0900 Subject: [PATCH 319/528] =?UTF-8?q?refactor:=20TestService=20submitTest=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=B1=85=EC=9E=84=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit submitTest 메서드를 단일 책임 원칙에 맞게 리팩토링: - gradeAnswers(): 답안 채점 및 결과 집계 - isAnswerCorrect(): 단일 답안 정답 여부 판단 - buildResultItem(): 결과 항목 생성 - saveTestResult(): 테스트 결과 저장 - GradingResult record: 채점 결과 캡슐화 TestService와 TestCommandService 모두 동일하게 적용 Closes #404 --- .../service/TestCommandService.java | 138 +++++++++++------- .../vocabulary/service/TestService.java | 126 ++++++++++------ 2 files changed, 169 insertions(+), 95 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java index 41ce80ee..ee289584 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java @@ -92,53 +92,87 @@ public StartTestResult startTest(String userId, String testType) { public SubmitTestResult submitTest(String userId, String testId, String testType, List answers, String startedAt) { - String now = Instant.now().toString(); - String today = LocalDate.now().toString(); - - int correctCount = 0; - int incorrectCount = 0; - List incorrectWordIds = new ArrayList<>(); - List> results = new ArrayList<>(); - + // 1. 답안 채점 + GradingResult gradingResult = gradeAnswers(answers); + + // 2. 테스트 결과 저장 + TestResult testResult = saveTestResult(userId, testId, testType, gradingResult, startedAt); + + // 3. 오답 단어 자동 북마크 + bookmarkIncorrectWords(userId, gradingResult.incorrectWordIds()); + + // 4. SNS 알림 발행 + publishTestResultToSns(userId, gradingResult.results()); + + logger.info("Test submitted: userId={}, testId={}, successRate={}%", + userId, testId, gradingResult.successRate()); + + return new SubmitTestResult( + testId, testType, gradingResult.totalQuestions(), + gradingResult.correctCount(), gradingResult.incorrectCount(), + gradingResult.successRate(), gradingResult.results() + ); + } + + private GradingResult gradeAnswers(List answers) { List wordIds = answers.stream() .map(SubmitTestRequest.TestAnswer::getWordId) .collect(Collectors.toList()); - List words = wordRepository.findByIds(wordIds); - - Map wordMap = words.stream() + + Map wordMap = wordRepository.findByIds(wordIds).stream() .collect(Collectors.toMap(Word::getWordId, w -> w)); - + + int correctCount = 0; + int incorrectCount = 0; + List incorrectWordIds = new ArrayList<>(); + List> results = new ArrayList<>(); + for (SubmitTestRequest.TestAnswer answer : answers) { String wordId = answer.getWordId(); String userAnswer = answer.getAnswer(); - Word word = wordMap.get(wordId); - if (word != null) { - // 빈 답변은 오답 처리 - boolean isCorrect = userAnswer != null - && !userAnswer.isBlank() - && word.getKorean().trim().equalsIgnoreCase(userAnswer.trim()); - - Map resultItem = new HashMap<>(); - resultItem.put("wordId", wordId); - resultItem.put("english", word.getEnglish()); - resultItem.put("correctAnswer", word.getKorean()); - resultItem.put("userAnswer", userAnswer != null ? userAnswer : ""); - resultItem.put("isCorrect", isCorrect); - results.add(resultItem); - - if (isCorrect) { - correctCount++; - } else { - incorrectCount++; - incorrectWordIds.add(wordId); - } + + if (word == null) continue; + + boolean isCorrect = isAnswerCorrect(userAnswer, word.getKorean()); + results.add(buildResultItem(word, userAnswer, isCorrect)); + + if (isCorrect) { + correctCount++; + } else { + incorrectCount++; + incorrectWordIds.add(wordId); } } - + int totalQuestions = answers.size(); double successRate = totalQuestions > 0 ? (correctCount * 100.0 / totalQuestions) : 0; - + + return new GradingResult(wordIds, correctCount, incorrectCount, incorrectWordIds, + totalQuestions, successRate, results); + } + + private boolean isAnswerCorrect(String userAnswer, String correctAnswer) { + return userAnswer != null + && !userAnswer.isBlank() + && correctAnswer.trim().equalsIgnoreCase(userAnswer.trim()); + } + + private Map buildResultItem(Word word, String userAnswer, boolean isCorrect) { + Map resultItem = new HashMap<>(); + resultItem.put("wordId", word.getWordId()); + resultItem.put("english", word.getEnglish()); + resultItem.put("correctAnswer", word.getKorean()); + resultItem.put("userAnswer", userAnswer != null ? userAnswer : ""); + resultItem.put("isCorrect", isCorrect); + return resultItem; + } + + private TestResult saveTestResult(String userId, String testId, String testType, + GradingResult gradingResult, String startedAt) { + String now = Instant.now().toString(); + String today = LocalDate.now().toString(); + TestResult testResult = TestResult.builder() .pk("TEST#" + userId) .sk("RESULT#" + now) @@ -147,27 +181,29 @@ public SubmitTestResult submitTest(String userId, String testId, String testType .testId(testId) .userId(userId) .testType(testType) - .totalQuestions(totalQuestions) - .correctAnswers(correctCount) - .incorrectAnswers(incorrectCount) - .successRate(successRate) - .testedWordIds(wordIds) - .incorrectWordIds(incorrectWordIds) + .totalQuestions(gradingResult.totalQuestions()) + .correctAnswers(gradingResult.correctCount()) + .incorrectAnswers(gradingResult.incorrectCount()) + .successRate(gradingResult.successRate()) + .testedWordIds(gradingResult.wordIds()) + .incorrectWordIds(gradingResult.incorrectWordIds()) .startedAt(startedAt) .completedAt(now) .build(); - + testResultRepository.save(testResult); - - // 오답 단어 자동 북마크 - bookmarkIncorrectWords(userId, incorrectWordIds); - - publishTestResultToSns(userId, results); - - logger.info("Test submitted: userId={}, testId={}, successRate={}%", userId, testId, successRate); - - return new SubmitTestResult(testId, testType, totalQuestions, correctCount, incorrectCount, successRate, results); + return testResult; } + + private record GradingResult( + List wordIds, + int correctCount, + int incorrectCount, + List incorrectWordIds, + int totalQuestions, + double successRate, + List> results + ) {} private void bookmarkIncorrectWords(String userId, List incorrectWordIds) { if (incorrectWordIds == null || incorrectWordIds.isEmpty()) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestService.java index a8eb506c..f0af3d51 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestService.java @@ -85,50 +85,84 @@ public StartTestResult startTest(String userId, String testType) { public SubmitTestResult submitTest(String userId, String testId, String testType, List> answers, String startedAt) { - String now = Instant.now().toString(); - String today = LocalDate.now().toString(); - - int correctCount = 0; - int incorrectCount = 0; - List incorrectWordIds = new ArrayList<>(); - List> results = new ArrayList<>(); - + // 1. 답안 채점 + GradingResult gradingResult = gradeAnswers(answers); + + // 2. 테스트 결과 저장 + saveTestResult(userId, testId, testType, gradingResult, startedAt); + + // 3. SNS 알림 발행 + publishTestResultToSns(userId, gradingResult.results()); + + logger.info("Test submitted: userId={}, testId={}, successRate={}%", + userId, testId, gradingResult.successRate()); + + return new SubmitTestResult( + testId, testType, gradingResult.totalQuestions(), + gradingResult.correctCount(), gradingResult.incorrectCount(), + gradingResult.successRate(), gradingResult.results() + ); + } + + private GradingResult gradeAnswers(List> answers) { List wordIds = answers.stream() .map(a -> (String) a.get("wordId")) .collect(Collectors.toList()); - List words = wordRepository.findByIds(wordIds); - - Map wordMap = words.stream() + + Map wordMap = wordRepository.findByIds(wordIds).stream() .collect(Collectors.toMap(Word::getWordId, w -> w)); - + + int correctCount = 0; + int incorrectCount = 0; + List incorrectWordIds = new ArrayList<>(); + List> results = new ArrayList<>(); + for (Map answer : answers) { String wordId = (String) answer.get("wordId"); String userAnswer = (String) answer.get("answer"); - Word word = wordMap.get(wordId); - if (word != null) { - boolean isCorrect = word.getKorean().trim().equalsIgnoreCase(userAnswer.trim()); - - Map resultItem = new HashMap<>(); - resultItem.put("wordId", wordId); - resultItem.put("english", word.getEnglish()); - resultItem.put("correctAnswer", word.getKorean()); - resultItem.put("userAnswer", userAnswer); - resultItem.put("isCorrect", isCorrect); - results.add(resultItem); - - if (isCorrect) { - correctCount++; - } else { - incorrectCount++; - incorrectWordIds.add(wordId); - } + + if (word == null) continue; + + boolean isCorrect = isAnswerCorrect(userAnswer, word.getKorean()); + results.add(buildResultItem(word, userAnswer, isCorrect)); + + if (isCorrect) { + correctCount++; + } else { + incorrectCount++; + incorrectWordIds.add(wordId); } } - + int totalQuestions = answers.size(); double successRate = totalQuestions > 0 ? (correctCount * 100.0 / totalQuestions) : 0; - + + return new GradingResult(wordIds, correctCount, incorrectCount, incorrectWordIds, + totalQuestions, successRate, results); + } + + private boolean isAnswerCorrect(String userAnswer, String correctAnswer) { + return userAnswer != null + && !userAnswer.isBlank() + && correctAnswer.trim().equalsIgnoreCase(userAnswer.trim()); + } + + private Map buildResultItem(Word word, String userAnswer, boolean isCorrect) { + Map resultItem = new HashMap<>(); + resultItem.put("wordId", word.getWordId()); + resultItem.put("english", word.getEnglish()); + resultItem.put("correctAnswer", word.getKorean()); + resultItem.put("userAnswer", userAnswer != null ? userAnswer : ""); + resultItem.put("isCorrect", isCorrect); + return resultItem; + } + + private void saveTestResult(String userId, String testId, String testType, + GradingResult gradingResult, String startedAt) { + String now = Instant.now().toString(); + String today = LocalDate.now().toString(); + TestResult testResult = TestResult.builder() .pk("TEST#" + userId) .sk("RESULT#" + now) @@ -137,23 +171,27 @@ public SubmitTestResult submitTest(String userId, String testId, String testType .testId(testId) .userId(userId) .testType(testType) - .totalQuestions(totalQuestions) - .correctAnswers(correctCount) - .incorrectAnswers(incorrectCount) - .successRate(successRate) - .incorrectWordIds(incorrectWordIds) + .totalQuestions(gradingResult.totalQuestions()) + .correctAnswers(gradingResult.correctCount()) + .incorrectAnswers(gradingResult.incorrectCount()) + .successRate(gradingResult.successRate()) + .incorrectWordIds(gradingResult.incorrectWordIds()) .startedAt(startedAt) .completedAt(now) .build(); - + testResultRepository.save(testResult); - - publishTestResultToSns(userId, results); - - logger.info("Test submitted: userId={}, testId={}, successRate={}%", userId, testId, successRate); - - return new SubmitTestResult(testId, testType, totalQuestions, correctCount, incorrectCount, successRate, results); } + + private record GradingResult( + List wordIds, + int correctCount, + int incorrectCount, + List incorrectWordIds, + int totalQuestions, + double successRate, + List> results + ) {} public PaginatedResult getTestResults(String userId, int limit, String cursor) { return testResultRepository.findByUserIdWithPagination(userId, limit, cursor); From fb827d31da4841ef5eef181c217f11f8eeb936c4 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 15:00:33 +0900 Subject: [PATCH 320/528] =?UTF-8?q?refactor:=20=ED=95=98=EB=93=9C=EC=BD=94?= =?UTF-8?q?=EB=94=A9=EB=90=9C=20=EC=84=A4=EC=A0=95=EA=B0=92=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=20=EB=B3=80=EC=88=98=EB=A1=9C=20=EC=99=B8=EB=B6=80?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 각 도메인별 Config 클래스를 생성하여 하드코딩된 값들을 환경 변수로 설정 가능하게 변경. 기본값이 있어 환경 변수 미설정 시에도 기존 동작 유지. ## 새로 추가된 Config 클래스 - GrammarConfig: SESSION_TTL_DAYS, MAX_HISTORY_MESSAGES, MAX_TOKENS 등 - GameConfig: TOTAL_ROUNDS, ROUND_TIME_LIMIT, QUICK_GUESS_THRESHOLD_MS - VocabularyConfig: NEW_WORDS_COUNT, REVIEW_WORDS_COUNT, 상태 전이 임계값 등 ## 지원하는 환경 변수 - GRAMMAR_SESSION_TTL_DAYS, GRAMMAR_MAX_HISTORY_MESSAGES, GRAMMAR_MAX_TOKENS - GAME_TOTAL_ROUNDS, GAME_ROUND_TIME_LIMIT, GAME_QUICK_GUESS_THRESHOLD_MS - VOCAB_NEW_WORDS_COUNT, VOCAB_REVIEW_WORDS_COUNT - VOCAB_TRANSITION_TO_REVIEWING, VOCAB_TRANSITION_TO_MASTERED Closes #406 --- .../serverless/common/config/EnvConfig.java | 94 +++++++++++++++++++ .../domain/chatting/config/GameConfig.java | 33 +++++++ .../domain/chatting/service/GameService.java | 15 ++- .../chatting/service/GameStatsService.java | 6 +- .../domain/grammar/config/GrammarConfig.java | 39 ++++++++ .../factory/BedrockGrammarCheckFactory.java | 6 +- .../service/GrammarConversationService.java | 12 +-- .../vocabulary/config/VocabularyConfig.java | 48 ++++++++++ .../service/DailyStudyCommandService.java | 8 +- .../vocabulary/service/DailyStudyService.java | 8 +- .../vocabulary/state/LearningState.java | 7 +- .../vocabulary/state/ReviewingState.java | 4 +- 12 files changed, 243 insertions(+), 37 deletions(-) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/EnvConfig.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/config/GameConfig.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/config/GrammarConfig.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/config/VocabularyConfig.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/EnvConfig.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/EnvConfig.java new file mode 100644 index 00000000..bd8f6405 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/EnvConfig.java @@ -0,0 +1,94 @@ +package com.mzc.secondproject.serverless.common.config; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * 환경 변수 설정 유틸리티 + * - 필수 환경 변수 누락 시 명확한 에러 메시지 제공 + * - Lambda 시작 시 설정 오류를 빠르게 감지 + */ +public final class EnvConfig { + + private static final Logger logger = LoggerFactory.getLogger(EnvConfig.class); + + private EnvConfig() { + // 유틸리티 클래스 - 인스턴스화 방지 + } + + /** + * 필수 환경 변수를 가져옵니다. + * 환경 변수가 설정되지 않았거나 빈 문자열인 경우 IllegalStateException을 발생시킵니다. + * + * @param name 환경 변수 이름 + * @return 환경 변수 값 + * @throws IllegalStateException 환경 변수가 설정되지 않은 경우 + */ + public static String getRequired(String name) { + String value = System.getenv(name); + if (value == null || value.trim().isEmpty()) { + String message = String.format("필수 환경 변수 '%s'가 설정되지 않았습니다. SAM template.yaml을 확인하세요.", name); + logger.error(message); + throw new IllegalStateException(message); + } + return value; + } + + /** + * 선택적 환경 변수를 가져옵니다. + * 환경 변수가 설정되지 않은 경우 기본값을 반환합니다. + * + * @param name 환경 변수 이름 + * @param defaultValue 기본값 + * @return 환경 변수 값 또는 기본값 + */ + public static String getOrDefault(String name, String defaultValue) { + String value = System.getenv(name); + if (value == null || value.trim().isEmpty()) { + logger.debug("환경 변수 '{}'가 설정되지 않아 기본값 '{}'을 사용합니다.", name, defaultValue); + return defaultValue; + } + return value; + } + + /** + * 선택적 환경 변수를 정수로 가져옵니다. + * 환경 변수가 설정되지 않거나 파싱에 실패한 경우 기본값을 반환합니다. + * + * @param name 환경 변수 이름 + * @param defaultValue 기본값 + * @return 환경 변수 값 또는 기본값 + */ + public static int getIntOrDefault(String name, int defaultValue) { + String value = System.getenv(name); + if (value == null || value.trim().isEmpty()) { + return defaultValue; + } + try { + return Integer.parseInt(value.trim()); + } catch (NumberFormatException e) { + logger.warn("환경 변수 '{}'의 값 '{}'을 정수로 변환할 수 없어 기본값 {}을 사용합니다.", name, value, defaultValue); + return defaultValue; + } + } + + /** + * 선택적 환경 변수를 long으로 가져옵니다. + * + * @param name 환경 변수 이름 + * @param defaultValue 기본값 + * @return 환경 변수 값 또는 기본값 + */ + public static long getLongOrDefault(String name, long defaultValue) { + String value = System.getenv(name); + if (value == null || value.trim().isEmpty()) { + return defaultValue; + } + try { + return Long.parseLong(value.trim()); + } catch (NumberFormatException e) { + logger.warn("환경 변수 '{}'의 값 '{}'을 long으로 변환할 수 없어 기본값 {}을 사용합니다.", name, value, defaultValue); + return defaultValue; + } + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/config/GameConfig.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/config/GameConfig.java new file mode 100644 index 00000000..998e4f0b --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/config/GameConfig.java @@ -0,0 +1,33 @@ +package com.mzc.secondproject.serverless.domain.chatting.config; + +import com.mzc.secondproject.serverless.common.config.EnvConfig; + +/** + * 게임 관련 설정값 + * 환경 변수로 오버라이드 가능 + */ +public final class GameConfig { + + private static final int DEFAULT_TOTAL_ROUNDS = 5; + private static final int DEFAULT_ROUND_TIME_LIMIT = 60; + private static final long DEFAULT_QUICK_GUESS_THRESHOLD_MS = 5000L; + + private static final int TOTAL_ROUNDS = EnvConfig.getIntOrDefault("GAME_TOTAL_ROUNDS", DEFAULT_TOTAL_ROUNDS); + private static final int ROUND_TIME_LIMIT = EnvConfig.getIntOrDefault("GAME_ROUND_TIME_LIMIT", DEFAULT_ROUND_TIME_LIMIT); + private static final long QUICK_GUESS_THRESHOLD_MS = EnvConfig.getLongOrDefault("GAME_QUICK_GUESS_THRESHOLD_MS", DEFAULT_QUICK_GUESS_THRESHOLD_MS); + + private GameConfig() { + } + + public static int totalRounds() { + return TOTAL_ROUNDS; + } + + public static int roundTimeLimit() { + return ROUND_TIME_LIMIT; + } + + public static long quickGuessThresholdMs() { + return QUICK_GUESS_THRESHOLD_MS; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java index 9a472dda..ff796133 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java @@ -1,6 +1,7 @@ package com.mzc.secondproject.serverless.domain.chatting.service; import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.domain.chatting.config.GameConfig; import com.mzc.secondproject.serverless.domain.chatting.dto.response.CommandResult; import com.mzc.secondproject.serverless.domain.chatting.enums.GameStatus; import com.mzc.secondproject.serverless.domain.chatting.enums.MessageType; @@ -25,8 +26,6 @@ public class GameService { private static final Logger logger = LoggerFactory.getLogger(GameService.class); - private static final int DEFAULT_TOTAL_ROUNDS = 5; - private static final int DEFAULT_ROUND_TIME_LIMIT = 60; // 초 private final ChatRoomRepository chatRoomRepository; private final ConnectionRepository connectionRepository; @@ -82,9 +81,9 @@ public GameStartResult startGame(String roomId, String userId) { // 제시어 추출 (난이도별) String level = room.getLevel() != null ? room.getLevel() : "beginner"; - List words = getRandomWords(level, DEFAULT_TOTAL_ROUNDS); + List words = getRandomWords(level, GameConfig.totalRounds()); - if (words.size() < DEFAULT_TOTAL_ROUNDS) { + if (words.size() < GameConfig.totalRounds()) { return GameStartResult.error("단어가 부족합니다. 관리자에게 문의하세요."); } @@ -92,11 +91,11 @@ public GameStartResult startGame(String roomId, String userId) { room.setGameStatus(GameStatus.PLAYING.name()); room.setGameStartedBy(userId); room.setCurrentRound(1); - room.setTotalRounds(DEFAULT_TOTAL_ROUNDS); + room.setTotalRounds(GameConfig.totalRounds()); room.setDrawerOrder(drawerOrder); room.setScores(new HashMap<>()); room.setStreaks(new HashMap<>()); - room.setRoundTimeLimit(DEFAULT_ROUND_TIME_LIMIT); + room.setRoundTimeLimit(GameConfig.roundTimeLimit()); // 첫 라운드 설정 String firstDrawer = drawerOrder.get(0); @@ -132,7 +131,7 @@ public GameStartResult startGame(String roomId, String userId) { gameRoundRepository.save(firstRound); - logger.info("Game started: roomId={}, starter={}, rounds={}", roomId, userId, DEFAULT_TOTAL_ROUNDS); + logger.info("Game started: roomId={}, starter={}, rounds={}", roomId, userId, GameConfig.totalRounds()); return GameStartResult.success(room, firstWord, drawerOrder); } @@ -486,7 +485,7 @@ private int calculateScore(ChatRoom room, long elapsedTimeMs, String userId, int // 시간 보너스 (빨리 맞출수록 높은 점수): (제한시간 - 경과시간) * 0.5 int elapsedSeconds = (int) (elapsedTimeMs / 1000); - int timeLimit = room.getRoundTimeLimit() != null ? room.getRoundTimeLimit() : DEFAULT_ROUND_TIME_LIMIT; + int timeLimit = room.getRoundTimeLimit() != null ? room.getRoundTimeLimit() : GameConfig.roundTimeLimit(); int timeBonus = Math.max(0, (int) ((timeLimit - elapsedSeconds) * 0.5)); // 연속 정답 보너스: 연속정답수 * 2 diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameStatsService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameStatsService.java index c71b0865..ae067951 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameStatsService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameStatsService.java @@ -2,6 +2,7 @@ import com.mzc.secondproject.serverless.domain.badge.model.UserBadge; import com.mzc.secondproject.serverless.domain.badge.service.BadgeService; +import com.mzc.secondproject.serverless.domain.chatting.config.GameConfig; import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; import com.mzc.secondproject.serverless.domain.chatting.model.GameRound; import com.mzc.secondproject.serverless.domain.chatting.repository.GameRoundRepository; @@ -18,7 +19,6 @@ public class GameStatsService { private static final Logger logger = LoggerFactory.getLogger(GameStatsService.class); - private static final long QUICK_GUESS_THRESHOLD_MS = 5000; // 5초 private final UserStatsRepository userStatsRepository; private final GameRoundRepository gameRoundRepository; @@ -83,7 +83,7 @@ private List updateUserGameStats(String userId, int score, boolean is // 빠른 정답 체크 (5초 이내) if (round.getGuessTimes() != null) { Long guessTime = round.getGuessTimes().get(userId); - if (guessTime != null && guessTime <= QUICK_GUESS_THRESHOLD_MS) { + if (guessTime != null && guessTime <= GameConfig.quickGuessThresholdMs()) { quickGuesses++; } } @@ -123,7 +123,7 @@ private List updateUserGameStats(String userId, int score, boolean is * 정답 시 즉시 통계 업데이트 (빠른 정답 뱃지용) */ public List updateOnCorrectAnswer(String userId, long elapsedTimeMs) { - if (elapsedTimeMs > QUICK_GUESS_THRESHOLD_MS) { + if (elapsedTimeMs > GameConfig.quickGuessThresholdMs()) { return List.of(); } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/config/GrammarConfig.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/config/GrammarConfig.java new file mode 100644 index 00000000..4e7a07cc --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/config/GrammarConfig.java @@ -0,0 +1,39 @@ +package com.mzc.secondproject.serverless.domain.grammar.config; + +import com.mzc.secondproject.serverless.common.config.EnvConfig; + +/** + * Grammar 도메인 설정값 + * 환경 변수로 오버라이드 가능 + */ +public final class GrammarConfig { + + private static final int DEFAULT_SESSION_TTL_DAYS = 30; + private static final int DEFAULT_MAX_HISTORY_MESSAGES = 10; + private static final int DEFAULT_LAST_MESSAGE_MAX_LENGTH = 100; + private static final int DEFAULT_MAX_TOKENS = 2048; + + private static final int SESSION_TTL_DAYS = EnvConfig.getIntOrDefault("GRAMMAR_SESSION_TTL_DAYS", DEFAULT_SESSION_TTL_DAYS); + private static final int MAX_HISTORY_MESSAGES = EnvConfig.getIntOrDefault("GRAMMAR_MAX_HISTORY_MESSAGES", DEFAULT_MAX_HISTORY_MESSAGES); + private static final int LAST_MESSAGE_MAX_LENGTH = EnvConfig.getIntOrDefault("GRAMMAR_LAST_MESSAGE_MAX_LENGTH", DEFAULT_LAST_MESSAGE_MAX_LENGTH); + private static final int MAX_TOKENS = EnvConfig.getIntOrDefault("GRAMMAR_MAX_TOKENS", DEFAULT_MAX_TOKENS); + + private GrammarConfig() { + } + + public static int sessionTtlDays() { + return SESSION_TTL_DAYS; + } + + public static int maxHistoryMessages() { + return MAX_HISTORY_MESSAGES; + } + + public static int lastMessageMaxLength() { + return LAST_MESSAGE_MAX_LENGTH; + } + + public static int maxTokens() { + return MAX_TOKENS; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/factory/BedrockGrammarCheckFactory.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/factory/BedrockGrammarCheckFactory.java index d3d05212..5dfc9475 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/factory/BedrockGrammarCheckFactory.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/factory/BedrockGrammarCheckFactory.java @@ -5,6 +5,7 @@ import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.domain.grammar.config.GrammarConfig; import com.mzc.secondproject.serverless.domain.grammar.dto.response.ConversationResponse; import com.mzc.secondproject.serverless.domain.grammar.dto.response.GrammarCheckResponse; import com.mzc.secondproject.serverless.domain.grammar.dto.response.GrammarError; @@ -31,7 +32,6 @@ public class BedrockGrammarCheckFactory implements GrammarCheckFactory { private static final Gson gson = new Gson(); private static final String MODEL_ID = "anthropic.claude-3-haiku-20240307-v1:0"; - private static final int MAX_TOKENS = 2048; @Override public GrammarCheckResponse checkGrammar(String sentence, GrammarLevel level) { @@ -131,7 +131,7 @@ private String buildUserPrompt(String sentence) { private JsonObject buildRequestBody(String userPrompt, String systemPrompt) { JsonObject requestBody = new JsonObject(); requestBody.addProperty("anthropic_version", "bedrock-2023-05-31"); - requestBody.addProperty("max_tokens", MAX_TOKENS); + requestBody.addProperty("max_tokens", GrammarConfig.maxTokens()); requestBody.addProperty("system", systemPrompt); JsonArray messages = new JsonArray(); @@ -570,7 +570,7 @@ private String buildStreamingConversationPrompt(GrammarLevel level) { private JsonObject buildStreamingRequestBody(String userPrompt, String systemPrompt) { JsonObject requestBody = new JsonObject(); requestBody.addProperty("anthropic_version", "bedrock-2023-05-31"); - requestBody.addProperty("max_tokens", MAX_TOKENS); + requestBody.addProperty("max_tokens", GrammarConfig.maxTokens()); requestBody.addProperty("system", systemPrompt); // Streaming을 위해 stop_sequences 추가하지 않음 diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/service/GrammarConversationService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/service/GrammarConversationService.java index b1dda7f2..195b7771 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/service/GrammarConversationService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/service/GrammarConversationService.java @@ -1,6 +1,7 @@ package com.mzc.secondproject.serverless.domain.grammar.service; import com.google.gson.Gson; +import com.mzc.secondproject.serverless.domain.grammar.config.GrammarConfig; import com.mzc.secondproject.serverless.domain.grammar.constants.GrammarKey; import com.mzc.secondproject.serverless.domain.grammar.dto.request.ConversationRequest; import com.mzc.secondproject.serverless.domain.grammar.dto.response.ConversationResponse; @@ -24,9 +25,6 @@ public class GrammarConversationService { private static final Logger logger = LoggerFactory.getLogger(GrammarConversationService.class); - private static final int SESSION_TTL_DAYS = 30; - private static final int MAX_HISTORY_MESSAGES = 10; - private static final int LAST_MESSAGE_MAX_LENGTH = 100; private final BedrockGrammarCheckFactory grammarFactory; private final GrammarSessionRepository repository; @@ -97,7 +95,7 @@ private GrammarSession getOrCreateSession(String sessionId, String userId, Gramm private GrammarSession createNewSession(String sessionId, String userId, GrammarLevel level) { String now = Instant.now().toString(); - long ttl = Instant.now().plus(SESSION_TTL_DAYS, ChronoUnit.DAYS).getEpochSecond(); + long ttl = Instant.now().plus(GrammarConfig.sessionTtlDays(), ChronoUnit.DAYS).getEpochSecond(); GrammarSession session = GrammarSession.builder() .pk(GrammarKey.sessionPk(userId)) @@ -121,7 +119,7 @@ private GrammarSession createNewSession(String sessionId, String userId, Grammar private String buildConversationHistory(String sessionId) { try { - List messages = repository.findRecentMessagesBySessionId(sessionId, MAX_HISTORY_MESSAGES); + List messages = repository.findRecentMessagesBySessionId(sessionId, GrammarConfig.maxHistoryMessages()); if (messages.isEmpty()) { return ""; @@ -147,7 +145,7 @@ private String buildConversationHistory(String sessionId) { private void saveMessage(String role, GrammarSession session, String content, GrammarCheckResponse grammarCheck) { String now = Instant.now().toString(); String messageId = UUID.randomUUID().toString(); - long ttl = Instant.now().plus(SESSION_TTL_DAYS, ChronoUnit.DAYS).getEpochSecond(); + long ttl = Instant.now().plus(GrammarConfig.sessionTtlDays(), ChronoUnit.DAYS).getEpochSecond(); GrammarMessage message = GrammarMessage.builder() .pk(GrammarKey.sessionPk(session.getUserId())) @@ -175,7 +173,7 @@ private void updateSession(GrammarSession session, String lastMessage) { String now = Instant.now().toString(); session.setGsi1sk(GrammarKey.updatedSk(now)); session.setMessageCount(session.getMessageCount() + 2); // user + assistant - session.setLastMessage(truncateMessage(lastMessage, LAST_MESSAGE_MAX_LENGTH)); + session.setLastMessage(truncateMessage(lastMessage, GrammarConfig.lastMessageMaxLength())); session.setUpdatedAt(now); repository.saveSession(session); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/config/VocabularyConfig.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/config/VocabularyConfig.java new file mode 100644 index 00000000..8567b5f9 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/config/VocabularyConfig.java @@ -0,0 +1,48 @@ +package com.mzc.secondproject.serverless.domain.vocabulary.config; + +import com.mzc.secondproject.serverless.common.config.EnvConfig; + +/** + * Vocabulary 도메인 설정값 + * 환경 변수로 오버라이드 가능 + */ +public final class VocabularyConfig { + + // 일일 학습 관련 + private static final int DEFAULT_NEW_WORDS_COUNT = 50; + private static final int DEFAULT_REVIEW_WORDS_COUNT = 5; + + // 단어 상태 전이 관련 + private static final int DEFAULT_TRANSITION_TO_REVIEWING_THRESHOLD = 2; + private static final int DEFAULT_TRANSITION_TO_MASTERED_THRESHOLD = 5; + private static final int DEFAULT_SECOND_INTERVAL_DAYS = 6; + + private static final int NEW_WORDS_COUNT = EnvConfig.getIntOrDefault("VOCAB_NEW_WORDS_COUNT", DEFAULT_NEW_WORDS_COUNT); + private static final int REVIEW_WORDS_COUNT = EnvConfig.getIntOrDefault("VOCAB_REVIEW_WORDS_COUNT", DEFAULT_REVIEW_WORDS_COUNT); + private static final int TRANSITION_TO_REVIEWING_THRESHOLD = EnvConfig.getIntOrDefault("VOCAB_TRANSITION_TO_REVIEWING", DEFAULT_TRANSITION_TO_REVIEWING_THRESHOLD); + private static final int TRANSITION_TO_MASTERED_THRESHOLD = EnvConfig.getIntOrDefault("VOCAB_TRANSITION_TO_MASTERED", DEFAULT_TRANSITION_TO_MASTERED_THRESHOLD); + private static final int SECOND_INTERVAL_DAYS = EnvConfig.getIntOrDefault("VOCAB_SECOND_INTERVAL_DAYS", DEFAULT_SECOND_INTERVAL_DAYS); + + private VocabularyConfig() { + } + + public static int newWordsCount() { + return NEW_WORDS_COUNT; + } + + public static int reviewWordsCount() { + return REVIEW_WORDS_COUNT; + } + + public static int transitionToReviewingThreshold() { + return TRANSITION_TO_REVIEWING_THRESHOLD; + } + + public static int transitionToMasteredThreshold() { + return TRANSITION_TO_MASTERED_THRESHOLD; + } + + public static int secondIntervalDays() { + return SECOND_INTERVAL_DAYS; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyCommandService.java index 5e0b1967..4713bdaf 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyCommandService.java @@ -2,6 +2,7 @@ import com.mzc.secondproject.serverless.common.dto.PaginatedResult; import com.mzc.secondproject.serverless.common.enums.StudyLevel; +import com.mzc.secondproject.serverless.domain.vocabulary.config.VocabularyConfig; import com.mzc.secondproject.serverless.domain.badge.service.BadgeService; import com.mzc.secondproject.serverless.domain.stats.model.UserStats; import com.mzc.secondproject.serverless.domain.stats.repository.UserStatsRepository; @@ -28,9 +29,6 @@ public class DailyStudyCommandService { private static final Logger logger = LoggerFactory.getLogger(DailyStudyCommandService.class); - private static final int NEW_WORDS_COUNT = 50; - private static final int REVIEW_WORDS_COUNT = 5; - private final DailyStudyRepository dailyStudyRepository; private final UserWordRepository userWordRepository; private final WordRepository wordRepository; @@ -116,12 +114,12 @@ public Map markWordLearned(String userId, String wordId) { private DailyStudy createDailyStudy(String userId, String date, String level) { String now = Instant.now().toString(); - PaginatedResult reviewPage = userWordRepository.findReviewDueWords(userId, date, REVIEW_WORDS_COUNT, null); + PaginatedResult reviewPage = userWordRepository.findReviewDueWords(userId, date, VocabularyConfig.reviewWordsCount(), null); List reviewWordIds = reviewPage.items().stream() .map(UserWord::getWordId) .collect(Collectors.toList()); - List newWordIds = getNewWordsForUser(userId, level, NEW_WORDS_COUNT); + List newWordIds = getNewWordsForUser(userId, level, VocabularyConfig.newWordsCount()); DailyStudy dailyStudy = DailyStudy.builder() .pk(VocabKey.dailyPk(userId)) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyService.java index c715d14a..2493a2ea 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyService.java @@ -1,6 +1,7 @@ package com.mzc.secondproject.serverless.domain.vocabulary.service; import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.domain.vocabulary.config.VocabularyConfig; import com.mzc.secondproject.serverless.domain.vocabulary.model.DailyStudy; import com.mzc.secondproject.serverless.domain.vocabulary.model.UserWord; import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; @@ -19,9 +20,6 @@ public class DailyStudyService { private static final Logger logger = LoggerFactory.getLogger(DailyStudyService.class); - private static final int NEW_WORDS_COUNT = 50; - private static final int REVIEW_WORDS_COUNT = 5; - private final DailyStudyRepository dailyStudyRepository; private final UserWordRepository userWordRepository; private final WordRepository wordRepository; @@ -87,12 +85,12 @@ public Map markWordLearned(String userId, String wordId) { private DailyStudy createDailyStudy(String userId, String date, String level) { String now = Instant.now().toString(); - PaginatedResult reviewPage = userWordRepository.findReviewDueWords(userId, date, REVIEW_WORDS_COUNT, null); + PaginatedResult reviewPage = userWordRepository.findReviewDueWords(userId, date, VocabularyConfig.reviewWordsCount(), null); List reviewWordIds = reviewPage.items().stream() .map(UserWord::getWordId) .collect(Collectors.toList()); - List newWordIds = getNewWordsForUser(userId, level, NEW_WORDS_COUNT); + List newWordIds = getNewWordsForUser(userId, level, VocabularyConfig.newWordsCount()); DailyStudy dailyStudy = DailyStudy.builder() .pk("DAILY#" + userId) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/state/LearningState.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/state/LearningState.java index 58de229c..32b4a7b7 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/state/LearningState.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/state/LearningState.java @@ -1,6 +1,7 @@ package com.mzc.secondproject.serverless.domain.vocabulary.state; import com.mzc.secondproject.serverless.common.config.StudyConfig; +import com.mzc.secondproject.serverless.domain.vocabulary.config.VocabularyConfig; import com.mzc.secondproject.serverless.domain.vocabulary.enums.WordStatus; /** @@ -10,8 +11,6 @@ public class LearningState implements WordState { private static final LearningState INSTANCE = new LearningState(); - private static final int TRANSITION_TO_REVIEWING_THRESHOLD = 2; - private static final int SECOND_INTERVAL_DAYS = 6; private LearningState() { } @@ -29,12 +28,12 @@ public WordState onCorrectAnswer(SpacedRepetitionContext context) { if (repetitions == 1) { context.updateInterval(StudyConfig.INITIAL_INTERVAL_DAYS); } else if (repetitions == 2) { - context.updateInterval(SECOND_INTERVAL_DAYS); + context.updateInterval(VocabularyConfig.secondIntervalDays()); } else { context.updateInterval(context.calculateNextInterval()); } - if (repetitions >= TRANSITION_TO_REVIEWING_THRESHOLD) { + if (repetitions >= VocabularyConfig.transitionToReviewingThreshold()) { return ReviewingState.getInstance(); } return this; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/state/ReviewingState.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/state/ReviewingState.java index 67ef0605..a846eb1a 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/state/ReviewingState.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/state/ReviewingState.java @@ -1,5 +1,6 @@ package com.mzc.secondproject.serverless.domain.vocabulary.state; +import com.mzc.secondproject.serverless.domain.vocabulary.config.VocabularyConfig; import com.mzc.secondproject.serverless.domain.vocabulary.enums.WordStatus; /** @@ -9,7 +10,6 @@ public class ReviewingState implements WordState { private static final ReviewingState INSTANCE = new ReviewingState(); - private static final int TRANSITION_TO_MASTERED_THRESHOLD = 5; private ReviewingState() { } @@ -24,7 +24,7 @@ public WordState onCorrectAnswer(SpacedRepetitionContext context) { context.incrementRepetitions(); context.updateInterval(context.calculateNextInterval()); - if (context.getRepetitions() >= TRANSITION_TO_MASTERED_THRESHOLD) { + if (context.getRepetitions() >= VocabularyConfig.transitionToMasteredThreshold()) { return MasteredState.getInstance(); } return this; From 173f06fd929dbe3af23dfcebd5a940d60c4233d8 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 15:18:58 +0900 Subject: [PATCH 321/528] =?UTF-8?q?test:=20StudyLevel=20enum=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/enums/StudyLevelSpec.groovy | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/common/enums/StudyLevelSpec.groovy diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/common/enums/StudyLevelSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/common/enums/StudyLevelSpec.groovy new file mode 100644 index 00000000..17751d7f --- /dev/null +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/common/enums/StudyLevelSpec.groovy @@ -0,0 +1,96 @@ +package com.mzc.secondproject.serverless.common.enums + +import spock.lang.Specification +import spock.lang.Unroll + +class StudyLevelSpec extends Specification { + + // ==================== isValid Tests ==================== + + @Unroll + def "isValid: '#value' -> #expected"() { + expect: "유효성 검사 결과가 예상과 일치" + StudyLevel.isValid(value) == expected + + where: + value | expected + "BEGINNER" | true + "INTERMEDIATE" | true + "ADVANCED" | true + "beginner" | true + "Beginner" | true + "INVALID" | false + "" | false + null | false + } + + // ==================== fromString Tests ==================== + + @Unroll + def "fromString: '#value' -> #expected"() { + when: "문자열로부터 StudyLevel 변환" + def result = StudyLevel.fromString(value) + + then: "올바른 StudyLevel 반환" + result == expected + + where: + value | expected + "BEGINNER" | StudyLevel.BEGINNER + "beginner" | StudyLevel.BEGINNER + "INTERMEDIATE" | StudyLevel.INTERMEDIATE + "intermediate" | StudyLevel.INTERMEDIATE + "ADVANCED" | StudyLevel.ADVANCED + "advanced" | StudyLevel.ADVANCED + } + + def "fromString: null 입력 시 IllegalArgumentException 발생"() { + when: "null로 변환 시도" + StudyLevel.fromString(null) + + then: "예외 발생" + thrown(IllegalArgumentException) + } + + def "fromString: 잘못된 값 입력 시 IllegalArgumentException 발생"() { + when: "잘못된 값으로 변환 시도" + StudyLevel.fromString("INVALID") + + then: "예외 발생" + thrown(IllegalArgumentException) + } + + // ==================== fromStringOrDefault Tests ==================== + + @Unroll + def "fromStringOrDefault: '#value' with default #defaultValue -> #expected"() { + expect: "기본값 처리 정상 동작" + StudyLevel.fromStringOrDefault(value, defaultValue) == expected + + where: + value | defaultValue | expected + "BEGINNER" | StudyLevel.ADVANCED | StudyLevel.BEGINNER + null | StudyLevel.INTERMEDIATE | StudyLevel.INTERMEDIATE + "INVALID" | StudyLevel.BEGINNER | StudyLevel.BEGINNER + "" | StudyLevel.ADVANCED | StudyLevel.ADVANCED + } + + // ==================== Getter Tests ==================== + + def "StudyLevel 속성 정상 반환"() { + expect: "각 레벨의 code와 displayName 확인" + StudyLevel.BEGINNER.getCode() == "beginner" + StudyLevel.BEGINNER.getDisplayName() == "초급" + + StudyLevel.INTERMEDIATE.getCode() == "intermediate" + StudyLevel.INTERMEDIATE.getDisplayName() == "중급" + + StudyLevel.ADVANCED.getCode() == "advanced" + StudyLevel.ADVANCED.getDisplayName() == "고급" + } + + def "모든 StudyLevel 값 존재 확인"() { + expect: "3개의 레벨 존재" + StudyLevel.values().length == 3 + } +} From 3c01c5fb1b6a9423aacee2494131bade71ab2a88 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 15:19:05 +0900 Subject: [PATCH 322/528] =?UTF-8?q?test:=20Difficulty=20enum=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/enums/DifficultySpec.groovy | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/common/enums/DifficultySpec.groovy diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/common/enums/DifficultySpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/common/enums/DifficultySpec.groovy new file mode 100644 index 00000000..cf9cda8f --- /dev/null +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/common/enums/DifficultySpec.groovy @@ -0,0 +1,100 @@ +package com.mzc.secondproject.serverless.common.enums + +import spock.lang.Specification +import spock.lang.Unroll + +class DifficultySpec extends Specification { + + // ==================== isValid Tests ==================== + + @Unroll + def "isValid: '#value' -> #expected"() { + expect: "유효성 검사 결과가 예상과 일치" + Difficulty.isValid(value) == expected + + where: + value | expected + "EASY" | true + "NORMAL" | true + "HARD" | true + "easy" | true + "normal" | true + "hard" | true + "Easy" | true + "Normal" | true + "Hard" | true + "INVALID" | false + "" | false + null | false + } + + // ==================== fromString Tests ==================== + + @Unroll + def "fromString: '#value' -> #expected"() { + when: "문자열로부터 Difficulty 변환" + def result = Difficulty.fromString(value) + + then: "올바른 Difficulty 반환" + result == expected + + where: + value | expected + "EASY" | Difficulty.EASY + "easy" | Difficulty.EASY + "NORMAL" | Difficulty.NORMAL + "normal" | Difficulty.NORMAL + "HARD" | Difficulty.HARD + "hard" | Difficulty.HARD + } + + def "fromString: null 입력 시 IllegalArgumentException 발생"() { + when: "null로 변환 시도" + Difficulty.fromString(null) + + then: "예외 발생" + thrown(IllegalArgumentException) + } + + def "fromString: 잘못된 값 입력 시 IllegalArgumentException 발생"() { + when: "잘못된 값으로 변환 시도" + Difficulty.fromString("VERY_HARD") + + then: "예외 발생" + thrown(IllegalArgumentException) + } + + // ==================== fromStringOrDefault Tests ==================== + + @Unroll + def "fromStringOrDefault: '#value' with default #defaultValue -> #expected"() { + expect: "기본값 처리 정상 동작" + Difficulty.fromStringOrDefault(value, defaultValue) == expected + + where: + value | defaultValue | expected + "EASY" | Difficulty.HARD | Difficulty.EASY + null | Difficulty.NORMAL | Difficulty.NORMAL + "INVALID" | Difficulty.EASY | Difficulty.EASY + "" | Difficulty.HARD | Difficulty.HARD + } + + // ==================== Getter Tests ==================== + + def "Difficulty 속성 정상 반환"() { + expect: "각 난이도의 code와 displayName 확인" + Difficulty.EASY.getCode() == "easy" + Difficulty.EASY.getDisplayName() == "쉬움" + + Difficulty.NORMAL.getCode() == "normal" + Difficulty.NORMAL.getDisplayName() == "보통" + + Difficulty.HARD.getCode() == "hard" + Difficulty.HARD.getDisplayName() == "어려움" + } + + def "모든 Difficulty 값 존재 확인"() { + expect: "3개의 난이도 존재" + Difficulty.values().length == 3 + } +} From 1585b0e107879f7ff45eca9097bed80bd8630714 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 15:19:06 +0900 Subject: [PATCH 323/528] =?UTF-8?q?test:=20StudyConfig=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/config/StudyConfigSpec.groovy | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/common/config/StudyConfigSpec.groovy diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/common/config/StudyConfigSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/common/config/StudyConfigSpec.groovy new file mode 100644 index 00000000..67b2f29c --- /dev/null +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/common/config/StudyConfigSpec.groovy @@ -0,0 +1,67 @@ +package com.mzc.secondproject.serverless.common.config + +import spock.lang.Specification + +class StudyConfigSpec extends Specification { + + // ==================== Spaced Repetition Config Tests ==================== + + def "Spaced Repetition 기본값 확인"() { + expect: + StudyConfig.INITIAL_INTERVAL_DAYS == 1 + StudyConfig.DEFAULT_EASE_FACTOR == 2.5d + StudyConfig.MIN_EASE_FACTOR == 1.3d + StudyConfig.INITIAL_REPETITIONS == 0 + } + + def "오답 관련 기본값 확인"() { + expect: + StudyConfig.MAX_WRONG_COUNT == 3 + } + + def "테스트 관련 기본값 확인"() { + expect: + StudyConfig.DEFAULT_WORD_COUNT == 20 + StudyConfig.DAILY_TEST_WORD_COUNT == 10 + } + + def "복습 간격 배열 확인"() { + expect: + StudyConfig.REVIEW_INTERVALS == [1, 3, 7, 14, 30] as int[] + StudyConfig.REVIEW_INTERVALS.length == 5 + } + + def "상태 기본값 확인"() { + expect: + StudyConfig.DEFAULT_WORD_STATUS == "NEW" + StudyConfig.DEFAULT_DIFFICULTY == "NORMAL" + } + + def "카운트 초기값 확인"() { + expect: + StudyConfig.INITIAL_CORRECT_COUNT == 0 + StudyConfig.INITIAL_INCORRECT_COUNT == 0 + } + + // ==================== Business Logic Tests ==================== + + def "MIN_EASE_FACTOR가 DEFAULT_EASE_FACTOR보다 작음"() { + expect: + StudyConfig.MIN_EASE_FACTOR < StudyConfig.DEFAULT_EASE_FACTOR + } + + def "REVIEW_INTERVALS가 오름차순 정렬"() { + given: + def intervals = StudyConfig.REVIEW_INTERVALS + + expect: "배열이 오름차순 정렬되어 있음" + (0.. + intervals[i] < intervals[i + 1] + } + } + + def "DAILY_TEST_WORD_COUNT가 DEFAULT_WORD_COUNT보다 작음"() { + expect: + StudyConfig.DAILY_TEST_WORD_COUNT <= StudyConfig.DEFAULT_WORD_COUNT + } +} From a398edc6aea388566f2b00fbe6adf86c861ca80c Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 15:19:06 +0900 Subject: [PATCH 324/528] =?UTF-8?q?test:=20EnvConfig=20=EB=8B=A8=EC=9C=84?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/config/EnvConfigSpec.groovy | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/common/config/EnvConfigSpec.groovy diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/common/config/EnvConfigSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/common/config/EnvConfigSpec.groovy new file mode 100644 index 00000000..96e615af --- /dev/null +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/common/config/EnvConfigSpec.groovy @@ -0,0 +1,133 @@ +package com.mzc.secondproject.serverless.common.config + +import spock.lang.Specification + +class EnvConfigSpec extends Specification { + + // ==================== getRequired Tests ==================== + + def "getRequired: 설정되지 않은 환경 변수에 대해 IllegalStateException 발생"() { + when: "존재하지 않는 환경 변수 요청" + EnvConfig.getRequired("NON_EXISTENT_ENV_VAR_FOR_TEST_12345") + + then: "IllegalStateException 발생" + def e = thrown(IllegalStateException) + e.message.contains("NON_EXISTENT_ENV_VAR_FOR_TEST_12345") + e.message.contains("설정되지 않았습니다") + } + + def "getRequired: 에러 메시지에 SAM template.yaml 언급 포함"() { + when: + EnvConfig.getRequired("TEST_MISSING_VAR") + + then: + def e = thrown(IllegalStateException) + e.message.contains("SAM template.yaml") + } + + // ==================== getOrDefault Tests ==================== + + def "getOrDefault: 설정되지 않은 환경 변수에 대해 기본값 반환"() { + given: + def defaultValue = "defaultTestValue" + + when: + def result = EnvConfig.getOrDefault("NON_EXISTENT_VAR_TEST", defaultValue) + + then: + result == defaultValue + } + + def "getOrDefault: PATH 환경 변수가 존재하면 값 반환 (시스템 환경 변수)"() { + when: "일반적으로 설정되어 있는 PATH 환경 변수 요청" + def result = EnvConfig.getOrDefault("PATH", "default") + + then: "PATH가 설정되어 있으면 해당 값 반환, 아니면 기본값" + result != null + !result.isEmpty() + } + + // ==================== getIntOrDefault Tests ==================== + + def "getIntOrDefault: 설정되지 않은 환경 변수에 대해 기본값 반환"() { + given: + def defaultValue = 42 + + when: + def result = EnvConfig.getIntOrDefault("NON_EXISTENT_INT_VAR", defaultValue) + + then: + result == defaultValue + } + + def "getIntOrDefault: 기본값 0 처리"() { + when: + def result = EnvConfig.getIntOrDefault("NON_EXISTENT_VAR", 0) + + then: + result == 0 + } + + def "getIntOrDefault: 음수 기본값 처리"() { + when: + def result = EnvConfig.getIntOrDefault("NON_EXISTENT_VAR", -10) + + then: + result == -10 + } + + // ==================== getLongOrDefault Tests ==================== + + def "getLongOrDefault: 설정되지 않은 환경 변수에 대해 기본값 반환"() { + given: + def defaultValue = 1000000000000L + + when: + def result = EnvConfig.getLongOrDefault("NON_EXISTENT_LONG_VAR", defaultValue) + + then: + result == defaultValue + } + + def "getLongOrDefault: 기본값 0L 처리"() { + when: + def result = EnvConfig.getLongOrDefault("NON_EXISTENT_VAR", 0L) + + then: + result == 0L + } + + def "getLongOrDefault: 음수 기본값 처리"() { + when: + def result = EnvConfig.getLongOrDefault("NON_EXISTENT_VAR", -5000L) + + then: + result == -5000L + } + + // ==================== Edge Cases ==================== + + def "getOrDefault: 빈 환경 변수 이름에 대해 기본값 반환"() { + when: + def result = EnvConfig.getOrDefault("", "default") + + then: + result == "default" + } + + def "getIntOrDefault: 최대 정수값 기본값 처리"() { + when: + def result = EnvConfig.getIntOrDefault("NON_EXISTENT", Integer.MAX_VALUE) + + then: + result == Integer.MAX_VALUE + } + + def "getLongOrDefault: 최대 long값 기본값 처리"() { + when: + def result = EnvConfig.getLongOrDefault("NON_EXISTENT", Long.MAX_VALUE) + + then: + result == Long.MAX_VALUE + } +} From 427948464b4d9f5cde2b6ff9d7f1aeebf51881c6 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 15:19:06 +0900 Subject: [PATCH 325/528] =?UTF-8?q?test:=20PaginatedResult=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/dto/PaginatedResultSpec.groovy | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/common/dto/PaginatedResultSpec.groovy diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/common/dto/PaginatedResultSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/common/dto/PaginatedResultSpec.groovy new file mode 100644 index 00000000..11978d27 --- /dev/null +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/common/dto/PaginatedResultSpec.groovy @@ -0,0 +1,70 @@ +package com.mzc.secondproject.serverless.common.dto + +import spock.lang.Specification + +class PaginatedResultSpec extends Specification { + + def "hasMore: nextCursor가 있으면 true"() { + given: + def result = new PaginatedResult(["item1", "item2"], "cursor123") + + expect: + result.hasMore() == true + } + + def "hasMore: nextCursor가 null이면 false"() { + given: + def result = new PaginatedResult(["item1", "item2"], null) + + expect: + result.hasMore() == false + } + + def "items: 아이템 목록 반환"() { + given: + def items = ["apple", "banana", "cherry"] + def result = new PaginatedResult(items, "cursor") + + expect: + result.items() == items + result.items().size() == 3 + } + + def "nextCursor: 커서 값 반환"() { + given: + def cursor = "abc123" + def result = new PaginatedResult([], cursor) + + expect: + result.nextCursor() == cursor + } + + def "빈 아이템 목록 처리"() { + given: + def result = new PaginatedResult([], null) + + expect: + result.items().isEmpty() + !result.hasMore() + } + + def "제네릭 타입으로 Integer 사용"() { + given: + def items = [1, 2, 3, 4, 5] + def result = new PaginatedResult(items, "next") + + expect: + result.items() == [1, 2, 3, 4, 5] + result.hasMore() + } + + def "제네릭 타입으로 커스텀 객체 사용"() { + given: + def items = [[name: "test1"], [name: "test2"]] + def result = new PaginatedResult(items, null) + + expect: + result.items().size() == 2 + !result.hasMore() + } +} From b9f739aa55a25e20f59acc558088dace167a8534 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 15:19:13 +0900 Subject: [PATCH 326/528] =?UTF-8?q?test:=20JsonUtil=20=EB=8B=A8=EC=9C=84?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/util/JsonUtilSpec.groovy | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/common/util/JsonUtilSpec.groovy diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/common/util/JsonUtilSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/common/util/JsonUtilSpec.groovy new file mode 100644 index 00000000..3b6768ff --- /dev/null +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/common/util/JsonUtilSpec.groovy @@ -0,0 +1,122 @@ +package com.mzc.secondproject.serverless.common.util + +import com.google.gson.JsonArray +import com.google.gson.JsonParser +import spock.lang.Specification +import spock.lang.Unroll + +class JsonUtilSpec extends Specification { + + // ==================== extractJson Tests ==================== + + def "extractJson: 정상적인 JSON 객체 추출"() { + given: + def response = 'Some text before {"key": "value"} and after' + + when: + def result = JsonUtil.extractJson(response) + + then: + result == '{"key": "value"}' + } + + def "extractJson: 중첩된 JSON 객체 추출"() { + given: + def response = 'Prefix {"outer": {"inner": "value"}} Suffix' + + when: + def result = JsonUtil.extractJson(response) + + then: + result == '{"outer": {"inner": "value"}}' + } + + def "extractJson: 순수 JSON 문자열"() { + given: + def response = '{"name": "test", "count": 10}' + + when: + def result = JsonUtil.extractJson(response) + + then: + result == '{"name": "test", "count": 10}' + } + + def "extractJson: null 입력"() { + when: + def result = JsonUtil.extractJson(null) + + then: + result == null + } + + def "extractJson: 빈 문자열 입력"() { + when: + def result = JsonUtil.extractJson("") + + then: + result == null + } + + def "extractJson: 공백만 있는 문자열 입력"() { + when: + def result = JsonUtil.extractJson(" ") + + then: + result == null + } + + def "extractJson: JSON이 없는 문자열"() { + given: + def response = "This is plain text without JSON" + + when: + def result = JsonUtil.extractJson(response) + + then: + result == response // 원본 반환 + } + + // ==================== toStringList Tests ==================== + + def "toStringList: 정상적인 JsonArray 변환"() { + given: + def jsonArray = JsonParser.parseString('["apple", "banana", "cherry"]').getAsJsonArray() + + when: + def result = JsonUtil.toStringList(jsonArray) + + then: + result == ["apple", "banana", "cherry"] + } + + def "toStringList: 빈 JsonArray 변환"() { + given: + def jsonArray = new JsonArray() + + when: + def result = JsonUtil.toStringList(jsonArray) + + then: + result == [] + } + + def "toStringList: null 입력"() { + when: + def result = JsonUtil.toStringList(null) + + then: + result == [] + } + + def "toStringList: 단일 요소 JsonArray"() { + given: + def jsonArray = JsonParser.parseString('["single"]').getAsJsonArray() + + when: + def result = JsonUtil.toStringList(jsonArray) + + then: + result == ["single"] + } +} From bdebc368aef72c95bdb6d6415cc6a570e4b8594e Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 15:19:14 +0900 Subject: [PATCH 327/528] =?UTF-8?q?test:=20CursorUtil=20=EB=8B=A8=EC=9C=84?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/util/CursorUtilSpec.groovy | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/common/util/CursorUtilSpec.groovy diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/common/util/CursorUtilSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/common/util/CursorUtilSpec.groovy new file mode 100644 index 00000000..fb1dffac --- /dev/null +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/common/util/CursorUtilSpec.groovy @@ -0,0 +1,98 @@ +package com.mzc.secondproject.serverless.common.util + +import software.amazon.awssdk.services.dynamodb.model.AttributeValue +import spock.lang.Specification + +class CursorUtilSpec extends Specification { + + // ==================== encode Tests ==================== + + def "encode: 정상적인 lastEvaluatedKey 인코딩"() { + given: "PK, SK가 있는 lastEvaluatedKey" + def lastEvaluatedKey = [ + "PK": AttributeValue.builder().s("USER#user123").build(), + "SK": AttributeValue.builder().s("WORD#word456").build() + ] + + when: "인코딩" + def cursor = CursorUtil.encode(lastEvaluatedKey) + + then: "Base64 인코딩된 커서 반환" + cursor != null + !cursor.isEmpty() + } + + def "encode: null 입력"() { + when: + def cursor = CursorUtil.encode(null) + + then: + cursor == null + } + + def "encode: 빈 Map 입력"() { + when: + def cursor = CursorUtil.encode([:]) + + then: + cursor == null + } + + // ==================== decode Tests ==================== + + def "encode-decode 왕복 테스트"() { + given: "원본 lastEvaluatedKey" + def original = [ + "PK": AttributeValue.builder().s("USER#testUser").build(), + "SK": AttributeValue.builder().s("DATE#2026-01-20").build() + ] + + when: "인코딩 후 디코딩" + def cursor = CursorUtil.encode(original) + def decoded = CursorUtil.decode(cursor) + + then: "원본과 동일한 키-값 복원" + decoded != null + decoded["PK"].s() == "USER#testUser" + decoded["SK"].s() == "DATE#2026-01-20" + } + + def "decode: null 입력"() { + when: + def result = CursorUtil.decode(null) + + then: + result == null + } + + def "decode: 빈 문자열 입력"() { + when: + def result = CursorUtil.decode("") + + then: + result == null + } + + def "decode: 잘못된 Base64 문자열"() { + when: + def result = CursorUtil.decode("not-valid-base64!!!") + + then: "예외 발생하지 않고 null 반환" + result == null + } + + def "encode: 단일 키-값 쌍"() { + given: + def lastEvaluatedKey = [ + "PK": AttributeValue.builder().s("SINGLE#key").build() + ] + + when: + def cursor = CursorUtil.encode(lastEvaluatedKey) + def decoded = CursorUtil.decode(cursor) + + then: + decoded != null + decoded["PK"].s() == "SINGLE#key" + } +} From 224c14104d0b4e71b4de6dd9fe320d978effca99 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 15:19:14 +0900 Subject: [PATCH 328/528] =?UTF-8?q?test:=20CommonErrorCode=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../exception/CommonErrorCodeSpec.groovy | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/common/exception/CommonErrorCodeSpec.groovy diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/common/exception/CommonErrorCodeSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/common/exception/CommonErrorCodeSpec.groovy new file mode 100644 index 00000000..142e12aa --- /dev/null +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/common/exception/CommonErrorCodeSpec.groovy @@ -0,0 +1,79 @@ +package com.mzc.secondproject.serverless.common.exception + +import spock.lang.Specification +import spock.lang.Unroll + +class CommonErrorCodeSpec extends Specification { + + @Unroll + def "에러 코드 '#errorCode': code=#expectedCode, statusCode=#expectedStatusCode"() { + expect: + errorCode.getCode() == expectedCode + errorCode.getStatusCode() == expectedStatusCode + errorCode.getMessage() != null + !errorCode.getMessage().isEmpty() + + where: + errorCode | expectedCode | expectedStatusCode + CommonErrorCode.UNAUTHORIZED | "AUTH_001" | 401 + CommonErrorCode.FORBIDDEN | "AUTH_002" | 403 + CommonErrorCode.INVALID_TOKEN | "AUTH_003" | 401 + CommonErrorCode.TOKEN_EXPIRED | "AUTH_004" | 401 + CommonErrorCode.INVALID_INPUT | "VALIDATION_001" | 400 + CommonErrorCode.REQUIRED_FIELD_MISSING | "VALIDATION_002" | 400 + CommonErrorCode.INVALID_FORMAT | "VALIDATION_003" | 400 + CommonErrorCode.VALUE_OUT_OF_RANGE | "VALIDATION_004" | 400 + CommonErrorCode.RESOURCE_NOT_FOUND | "RESOURCE_001" | 404 + CommonErrorCode.METHOD_NOT_ALLOWED | "RESOURCE_003" | 405 + CommonErrorCode.RESOURCE_ALREADY_EXISTS | "RESOURCE_002" | 409 + CommonErrorCode.INTERNAL_SERVER_ERROR | "SYSTEM_001" | 500 + CommonErrorCode.DATABASE_ERROR | "SYSTEM_002" | 500 + CommonErrorCode.EXTERNAL_API_ERROR | "SYSTEM_003" | 502 + CommonErrorCode.SERVICE_UNAVAILABLE | "SYSTEM_004" | 503 + } + + def "인증 관련 에러 코드들은 4xx"() { + expect: + CommonErrorCode.UNAUTHORIZED.getStatusCode() == 401 + CommonErrorCode.FORBIDDEN.getStatusCode() == 403 + CommonErrorCode.INVALID_TOKEN.getStatusCode() == 401 + CommonErrorCode.TOKEN_EXPIRED.getStatusCode() == 401 + } + + def "검증 관련 에러 코드들은 400"() { + expect: + CommonErrorCode.INVALID_INPUT.getStatusCode() == 400 + CommonErrorCode.REQUIRED_FIELD_MISSING.getStatusCode() == 400 + CommonErrorCode.INVALID_FORMAT.getStatusCode() == 400 + CommonErrorCode.VALUE_OUT_OF_RANGE.getStatusCode() == 400 + } + + def "시스템 에러 코드들은 5xx"() { + expect: + CommonErrorCode.INTERNAL_SERVER_ERROR.getStatusCode() == 500 + CommonErrorCode.DATABASE_ERROR.getStatusCode() == 500 + CommonErrorCode.EXTERNAL_API_ERROR.getStatusCode() == 502 + CommonErrorCode.SERVICE_UNAVAILABLE.getStatusCode() == 503 + } + + def "isClientError: 4xx 상태 코드"() { + expect: + CommonErrorCode.UNAUTHORIZED.isClientError() + CommonErrorCode.FORBIDDEN.isClientError() + CommonErrorCode.INVALID_INPUT.isClientError() + CommonErrorCode.RESOURCE_NOT_FOUND.isClientError() + } + + def "isServerError: 5xx 상태 코드"() { + expect: + CommonErrorCode.INTERNAL_SERVER_ERROR.isServerError() + CommonErrorCode.DATABASE_ERROR.isServerError() + CommonErrorCode.EXTERNAL_API_ERROR.isServerError() + CommonErrorCode.SERVICE_UNAVAILABLE.isServerError() + } + + def "모든 에러 코드 개수 확인"() { + expect: "15개의 에러 코드 존재" + CommonErrorCode.values().length == 15 + } +} From 5f9e1821b15e95720664ad77333a0f7294d35803 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 15:19:14 +0900 Subject: [PATCH 329/528] =?UTF-8?q?test:=20CommonException=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../exception/CommonExceptionSpec.groovy | 236 ++++++++++++++++++ 1 file changed, 236 insertions(+) create mode 100644 ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/common/exception/CommonExceptionSpec.groovy diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/common/exception/CommonExceptionSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/common/exception/CommonExceptionSpec.groovy new file mode 100644 index 00000000..261e73eb --- /dev/null +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/common/exception/CommonExceptionSpec.groovy @@ -0,0 +1,236 @@ +package com.mzc.secondproject.serverless.common.exception + +import spock.lang.Specification + +class CommonExceptionSpec extends Specification { + + // ==================== 인증/인가 예외 Tests ==================== + + def "unauthorized: 기본 메시지와 401 상태 코드"() { + when: + def exception = CommonException.unauthorized() + + then: + exception.getErrorCode() == CommonErrorCode.UNAUTHORIZED + exception.getStatusCode() == 401 + exception.isClientError() + } + + def "unauthorized: 커스텀 메시지"() { + given: + def message = "로그인이 필요한 서비스입니다" + + when: + def exception = CommonException.unauthorized(message) + + then: + exception.getMessage() == message + exception.getStatusCode() == 401 + } + + def "forbidden: 기본 메시지와 403 상태 코드"() { + when: + def exception = CommonException.forbidden() + + then: + exception.getErrorCode() == CommonErrorCode.FORBIDDEN + exception.getStatusCode() == 403 + } + + def "forbidden: 리소스 이름 포함"() { + when: + def exception = CommonException.forbidden("관리자 페이지") + + then: + exception.getMessage().contains("관리자 페이지") + exception.getStatusCode() == 403 + } + + def "invalidToken: 401 상태 코드"() { + when: + def exception = CommonException.invalidToken() + + then: + exception.getErrorCode() == CommonErrorCode.INVALID_TOKEN + exception.getStatusCode() == 401 + } + + def "tokenExpired: 401 상태 코드"() { + when: + def exception = CommonException.tokenExpired() + + then: + exception.getErrorCode() == CommonErrorCode.TOKEN_EXPIRED + exception.getStatusCode() == 401 + } + + // ==================== 검증 예외 Tests ==================== + + def "invalidInput: 기본 메시지와 400 상태 코드"() { + when: + def exception = CommonException.invalidInput() + + then: + exception.getErrorCode() == CommonErrorCode.INVALID_INPUT + exception.getStatusCode() == 400 + } + + def "invalidInput: 커스텀 메시지"() { + given: + def message = "이메일 형식이 올바르지 않습니다" + + when: + def exception = CommonException.invalidInput(message) + + then: + exception.getMessage() == message + } + + def "requiredFieldMissing: 필드명 포함"() { + when: + def exception = CommonException.requiredFieldMissing("email") + + then: + exception.getMessage().contains("email") + exception.getStatusCode() == 400 + } + + def "invalidFormat: 필드명 포함"() { + when: + def exception = CommonException.invalidFormat("phoneNumber") + + then: + exception.getMessage().contains("phoneNumber") + exception.getStatusCode() == 400 + } + + def "valueOutOfRange: 필드명과 범위 포함"() { + when: + def exception = CommonException.valueOutOfRange("age", 1, 120) + + then: + exception.getMessage().contains("age") + exception.getMessage().contains("1") + exception.getMessage().contains("120") + exception.getStatusCode() == 400 + } + + // ==================== 리소스 예외 Tests ==================== + + def "notFound: 리소스명 포함"() { + when: + def exception = CommonException.notFound("사용자") + + then: + exception.getMessage().contains("사용자") + exception.getStatusCode() == 404 + } + + def "notFound: 리소스명과 ID 포함"() { + when: + def exception = CommonException.notFound("사용자", "user123") + + then: + exception.getMessage().contains("사용자") + exception.getMessage().contains("user123") + exception.getStatusCode() == 404 + } + + def "alreadyExists: 리소스명 포함"() { + when: + def exception = CommonException.alreadyExists("이메일") + + then: + exception.getMessage().contains("이메일") + exception.getStatusCode() == 409 + } + + def "alreadyExists: 리소스명과 ID 포함"() { + when: + def exception = CommonException.alreadyExists("사용자", "user@test.com") + + then: + exception.getMessage().contains("사용자") + exception.getMessage().contains("user@test.com") + exception.getStatusCode() == 409 + } + + // ==================== 시스템 예외 Tests ==================== + + def "internalError: 기본 메시지와 500 상태 코드"() { + when: + def exception = CommonException.internalError() + + then: + exception.getErrorCode() == CommonErrorCode.INTERNAL_SERVER_ERROR + exception.getStatusCode() == 500 + exception.isServerError() + } + + def "internalError: Throwable cause 포함"() { + given: + def cause = new RuntimeException("Original error") + + when: + def exception = CommonException.internalError(cause) + + then: + exception.getCause() == cause + exception.getStatusCode() == 500 + } + + def "internalError: 커스텀 메시지"() { + given: + def message = "데이터 처리 중 오류" + + when: + def exception = CommonException.internalError(message) + + then: + exception.getMessage() == message + exception.getStatusCode() == 500 + } + + def "databaseError: cause 포함"() { + given: + def cause = new RuntimeException("DB connection failed") + + when: + def exception = CommonException.databaseError(cause) + + then: + exception.getCause() == cause + exception.getStatusCode() == 500 + } + + def "externalApiError: API 이름 포함"() { + when: + def exception = CommonException.externalApiError("OpenAI API") + + then: + exception.getMessage().contains("OpenAI API") + exception.getStatusCode() == 502 + } + + def "externalApiError: API 이름과 cause 포함"() { + given: + def cause = new RuntimeException("Connection timeout") + + when: + def exception = CommonException.externalApiError("Bedrock API", cause) + + then: + exception.getMessage().contains("Bedrock API") + exception.getCause() == cause + exception.getStatusCode() == 502 + } + + def "serviceUnavailable: 503 상태 코드"() { + when: + def exception = CommonException.serviceUnavailable() + + then: + exception.getErrorCode() == CommonErrorCode.SERVICE_UNAVAILABLE + exception.getStatusCode() == 503 + } +} From e3b509a92dd6aaf70196bda406e4b6d41830e513 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 15:19:21 +0900 Subject: [PATCH 330/528] =?UTF-8?q?test:=20BadgeType=20enum=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/badge/enums/BadgeTypeSpec.groovy | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/badge/enums/BadgeTypeSpec.groovy diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/badge/enums/BadgeTypeSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/badge/enums/BadgeTypeSpec.groovy new file mode 100644 index 00000000..26d44e2d --- /dev/null +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/badge/enums/BadgeTypeSpec.groovy @@ -0,0 +1,124 @@ +package com.mzc.secondproject.serverless.domain.badge.enums + +import spock.lang.Specification +import spock.lang.Unroll + +class BadgeTypeSpec extends Specification { + + // ==================== fromString Tests ==================== + + @Unroll + def "fromString: '#value' -> #expected"() { + expect: "문자열로부터 BadgeType 변환" + BadgeType.fromString(value) == expected + + where: + value | expected + "FIRST_STEP" | BadgeType.FIRST_STEP + "first_step" | BadgeType.FIRST_STEP + "First_Step" | BadgeType.FIRST_STEP + "STREAK_3" | BadgeType.STREAK_3 + "STREAK_7" | BadgeType.STREAK_7 + "STREAK_30" | BadgeType.STREAK_30 + "WORDS_100" | BadgeType.WORDS_100 + "WORDS_500" | BadgeType.WORDS_500 + "WORDS_1000" | BadgeType.WORDS_1000 + null | null + "INVALID" | null + "" | null + } + + // ==================== Category Tests ==================== + + @Unroll + def "Badge '#badge.name()' 카테고리 '#badge.getCategory()' 확인"() { + expect: "카테고리별 뱃지 분류 확인" + badge.getCategory() == expectedCategory + + where: + badge | expectedCategory + BadgeType.FIRST_STEP | "FIRST_STUDY" + BadgeType.STREAK_3 | "STREAK" + BadgeType.STREAK_7 | "STREAK" + BadgeType.STREAK_30 | "STREAK" + BadgeType.WORDS_100 | "WORDS_LEARNED" + BadgeType.WORDS_500 | "WORDS_LEARNED" + BadgeType.WORDS_1000 | "WORDS_LEARNED" + BadgeType.PERFECT_SCORE | "PERFECT_TEST" + BadgeType.TEST_10 | "TESTS_COMPLETED" + BadgeType.ACCURACY_90 | "ACCURACY" + BadgeType.GAME_FIRST_PLAY | "GAMES_PLAYED" + BadgeType.GAME_10_WINS | "GAMES_WON" + BadgeType.QUICK_GUESSER | "QUICK_GUESSES" + BadgeType.PERFECT_DRAWER | "PERFECT_DRAWS" + BadgeType.MASTER | "ALL_BADGES" + } + + // ==================== Threshold Tests ==================== + + @Unroll + def "Badge '#badge.name()' threshold #badge.getThreshold() 확인"() { + expect: "임계값 확인" + badge.getThreshold() == expectedThreshold + + where: + badge | expectedThreshold + BadgeType.FIRST_STEP | 1 + BadgeType.STREAK_3 | 3 + BadgeType.STREAK_7 | 7 + BadgeType.STREAK_30 | 30 + BadgeType.WORDS_100 | 100 + BadgeType.WORDS_500 | 500 + BadgeType.WORDS_1000 | 1000 + BadgeType.TEST_10 | 10 + BadgeType.ACCURACY_90 | 90 + BadgeType.GAME_10_WINS | 10 + } + + // ==================== Property Tests ==================== + + def "FIRST_STEP 뱃지 속성 확인"() { + given: + def badge = BadgeType.FIRST_STEP + + expect: + badge.getName() == "첫 걸음" + badge.getDescription() == "첫 학습을 완료했습니다" + badge.getImageFile() == "first_step.png" + badge.getImageUrl().contains("first_step.png") + } + + def "STREAK 뱃지 속성 확인"() { + expect: + BadgeType.STREAK_3.getName() == "3일 연속 학습" + BadgeType.STREAK_7.getName() == "일주일 연속 학습" + BadgeType.STREAK_30.getName() == "한 달 연속 학습" + } + + def "WORDS 뱃지 속성 확인"() { + expect: + BadgeType.WORDS_100.getName() == "단어 수집가" + BadgeType.WORDS_500.getName() == "단어 전문가" + BadgeType.WORDS_1000.getName() == "단어 마스터" + } + + def "게임 관련 뱃지 속성 확인"() { + expect: + BadgeType.GAME_FIRST_PLAY.getName() == "첫 게임" + BadgeType.GAME_10_WINS.getName() == "게임 10승" + BadgeType.QUICK_GUESSER.getName() == "번개 정답" + BadgeType.PERFECT_DRAWER.getName() == "완벽한 출제자" + } + + def "모든 BadgeType 개수 확인"() { + expect: "15개의 뱃지 타입 존재" + BadgeType.values().length == 15 + } + + def "모든 뱃지의 imageUrl이 S3 URL 형식"() { + expect: "모든 뱃지 이미지 URL이 S3 경로 포함" + BadgeType.values().every { badge -> + badge.getImageUrl().contains("s3.ap-northeast-2.amazonaws.com") + } + } +} From 1b781f687b65f3c769d3430bb2696c40fc1801f9 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 15:19:21 +0900 Subject: [PATCH 331/528] =?UTF-8?q?test:=20BadgeKey=20=EC=83=81=EC=88=98?= =?UTF-8?q?=20=EB=8B=A8=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../badge/constants/BadgeKeySpec.groovy | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/badge/constants/BadgeKeySpec.groovy diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/badge/constants/BadgeKeySpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/badge/constants/BadgeKeySpec.groovy new file mode 100644 index 00000000..910d3363 --- /dev/null +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/badge/constants/BadgeKeySpec.groovy @@ -0,0 +1,47 @@ +package com.mzc.secondproject.serverless.domain.badge.constants + +import spock.lang.Specification + +class BadgeKeySpec extends Specification { + + // ==================== Key Builder Tests ==================== + + def "userBadgePk: USER#userId#BADGE 형식"() { + expect: + BadgeKey.userBadgePk("user123") == "USER#user123#BADGE" + BadgeKey.userBadgePk("testUser") == "USER#testUser#BADGE" + } + + def "badgeSk: BADGE#badgeType 형식"() { + expect: + BadgeKey.badgeSk("FIRST_STEP") == "BADGE#FIRST_STEP" + BadgeKey.badgeSk("STREAK_7") == "BADGE#STREAK_7" + BadgeKey.badgeSk("WORDS_100") == "BADGE#WORDS_100" + } + + def "earnedSk: EARNED#earnedAt 형식"() { + expect: + BadgeKey.earnedSk("2026-01-20T10:30:00Z") == "EARNED#2026-01-20T10:30:00Z" + } + + // ==================== Constants Tests ==================== + + def "BADGE_ALL 상수값 확인"() { + expect: + BadgeKey.BADGE_ALL == "BADGE#ALL" + } + + // ==================== Consistency Tests ==================== + + def "동일한 입력에 대해 동일한 키 생성"() { + given: + def userId = "consistentUser" + + when: + def key1 = BadgeKey.userBadgePk(userId) + def key2 = BadgeKey.userBadgePk(userId) + + then: + key1 == key2 + } +} From 843aa1eb9fd661c78ec7b469b46075b56751574d Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 15:19:22 +0900 Subject: [PATCH 332/528] =?UTF-8?q?test:=20WordStatus=20enum=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vocabulary/enums/WordStatusSpec.groovy | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/vocabulary/enums/WordStatusSpec.groovy diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/vocabulary/enums/WordStatusSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/vocabulary/enums/WordStatusSpec.groovy new file mode 100644 index 00000000..cda95d0f --- /dev/null +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/vocabulary/enums/WordStatusSpec.groovy @@ -0,0 +1,109 @@ +package com.mzc.secondproject.serverless.domain.vocabulary.enums + +import spock.lang.Specification +import spock.lang.Unroll + +class WordStatusSpec extends Specification { + + // ==================== isValid Tests ==================== + + @Unroll + def "isValid: '#value' -> #expected"() { + expect: "유효성 검사 결과가 예상과 일치" + WordStatus.isValid(value) == expected + + where: + value | expected + "NEW" | true + "LEARNING" | true + "REVIEWING" | true + "MASTERED" | true + "UNKNOWN" | true + "new" | true + "learning" | true + "New" | true + "INVALID" | false + "" | false + null | false + } + + // ==================== fromString Tests ==================== + + @Unroll + def "fromString: '#value' -> #expected"() { + when: "문자열로부터 WordStatus 변환" + def result = WordStatus.fromString(value) + + then: "올바른 WordStatus 반환" + result == expected + + where: + value | expected + "NEW" | WordStatus.NEW + "new" | WordStatus.NEW + "LEARNING" | WordStatus.LEARNING + "learning" | WordStatus.LEARNING + "REVIEWING" | WordStatus.REVIEWING + "reviewing" | WordStatus.REVIEWING + "MASTERED" | WordStatus.MASTERED + "mastered" | WordStatus.MASTERED + "UNKNOWN" | WordStatus.UNKNOWN + "unknown" | WordStatus.UNKNOWN + } + + def "fromString: null 입력 시 IllegalArgumentException 발생"() { + when: "null로 변환 시도" + WordStatus.fromString(null) + + then: "예외 발생" + thrown(IllegalArgumentException) + } + + def "fromString: 잘못된 값 입력 시 IllegalArgumentException 발생"() { + when: "잘못된 값으로 변환 시도" + WordStatus.fromString("INVALID") + + then: "예외 발생" + thrown(IllegalArgumentException) + } + + // ==================== fromStringOrDefault Tests ==================== + + @Unroll + def "fromStringOrDefault: '#value' with default #defaultValue -> #expected"() { + expect: "기본값 처리 정상 동작" + WordStatus.fromStringOrDefault(value, defaultValue) == expected + + where: + value | defaultValue | expected + "NEW" | WordStatus.UNKNOWN | WordStatus.NEW + null | WordStatus.NEW | WordStatus.NEW + "INVALID" | WordStatus.LEARNING | WordStatus.LEARNING + "" | WordStatus.REVIEWING | WordStatus.REVIEWING + } + + // ==================== Getter Tests ==================== + + def "WordStatus 속성 정상 반환"() { + expect: "각 상태의 code와 displayName 확인" + WordStatus.NEW.getCode() == "new" + WordStatus.NEW.getDisplayName() == "새 단어" + + WordStatus.LEARNING.getCode() == "learning" + WordStatus.LEARNING.getDisplayName() == "학습 중" + + WordStatus.REVIEWING.getCode() == "reviewing" + WordStatus.REVIEWING.getDisplayName() == "복습 중" + + WordStatus.MASTERED.getCode() == "mastered" + WordStatus.MASTERED.getDisplayName() == "완료" + + WordStatus.UNKNOWN.getCode() == "unknown" + WordStatus.UNKNOWN.getDisplayName() == "모르겠음" + } + + def "모든 WordStatus 값 존재 확인"() { + expect: "5개의 상태 존재" + WordStatus.values().length == 5 + } +} From 6503d3d61a387860d47def4a75136e2b35a80c57 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 15:19:22 +0900 Subject: [PATCH 333/528] =?UTF-8?q?test:=20TestType=20enum=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vocabulary/enums/TestTypeSpec.groovy | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/vocabulary/enums/TestTypeSpec.groovy diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/vocabulary/enums/TestTypeSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/vocabulary/enums/TestTypeSpec.groovy new file mode 100644 index 00000000..f329c86c --- /dev/null +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/vocabulary/enums/TestTypeSpec.groovy @@ -0,0 +1,98 @@ +package com.mzc.secondproject.serverless.domain.vocabulary.enums + +import spock.lang.Specification +import spock.lang.Unroll + +class TestTypeSpec extends Specification { + + // ==================== isValid Tests ==================== + + @Unroll + def "isValid: '#value' -> #expected"() { + expect: "유효성 검사 결과가 예상과 일치" + TestType.isValid(value) == expected + + where: + value | expected + "DAILY" | true + "WEEKLY" | true + "CUSTOM" | true + "daily" | true + "weekly" | true + "custom" | true + "Daily" | true + "INVALID" | false + "" | false + null | false + } + + // ==================== fromString Tests ==================== + + @Unroll + def "fromString: '#value' -> #expected"() { + when: "문자열로부터 TestType 변환" + def result = TestType.fromString(value) + + then: "올바른 TestType 반환" + result == expected + + where: + value | expected + "DAILY" | TestType.DAILY + "daily" | TestType.DAILY + "WEEKLY" | TestType.WEEKLY + "weekly" | TestType.WEEKLY + "CUSTOM" | TestType.CUSTOM + "custom" | TestType.CUSTOM + } + + def "fromString: null 입력 시 IllegalArgumentException 발생"() { + when: "null로 변환 시도" + TestType.fromString(null) + + then: "예외 발생" + thrown(IllegalArgumentException) + } + + def "fromString: 잘못된 값 입력 시 IllegalArgumentException 발생"() { + when: "잘못된 값으로 변환 시도" + TestType.fromString("INVALID") + + then: "예외 발생" + thrown(IllegalArgumentException) + } + + // ==================== fromStringOrDefault Tests ==================== + + @Unroll + def "fromStringOrDefault: '#value' with default #defaultValue -> #expected"() { + expect: "기본값 처리 정상 동작" + TestType.fromStringOrDefault(value, defaultValue) == expected + + where: + value | defaultValue | expected + "DAILY" | TestType.WEEKLY | TestType.DAILY + null | TestType.DAILY | TestType.DAILY + "INVALID" | TestType.CUSTOM | TestType.CUSTOM + "" | TestType.WEEKLY | TestType.WEEKLY + } + + // ==================== Getter Tests ==================== + + def "TestType 속성 정상 반환"() { + expect: "각 테스트 타입의 code와 displayName 확인" + TestType.DAILY.getCode() == "daily" + TestType.DAILY.getDisplayName() == "일일 테스트" + + TestType.WEEKLY.getCode() == "weekly" + TestType.WEEKLY.getDisplayName() == "주간 테스트" + + TestType.CUSTOM.getCode() == "custom" + TestType.CUSTOM.getDisplayName() == "사용자 지정 테스트" + } + + def "모든 TestType 값 존재 확인"() { + expect: "3개의 테스트 타입 존재" + TestType.values().length == 3 + } +} From 8b7da72c75c862bd69697997ece8c96d7f3c7965 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 15:19:29 +0900 Subject: [PATCH 334/528] =?UTF-8?q?test:=20VocabularyConfig=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/VocabularyConfigSpec.groovy | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/vocabulary/config/VocabularyConfigSpec.groovy diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/vocabulary/config/VocabularyConfigSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/vocabulary/config/VocabularyConfigSpec.groovy new file mode 100644 index 00000000..a567ab77 --- /dev/null +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/vocabulary/config/VocabularyConfigSpec.groovy @@ -0,0 +1,47 @@ +package com.mzc.secondproject.serverless.domain.vocabulary.config + +import spock.lang.Specification + +class VocabularyConfigSpec extends Specification { + + def "newWordsCount 기본값 확인"() { + expect: "환경 변수 미설정 시 기본값 반환" + VocabularyConfig.newWordsCount() > 0 + } + + def "reviewWordsCount 기본값 확인"() { + expect: "환경 변수 미설정 시 기본값 반환" + VocabularyConfig.reviewWordsCount() > 0 + } + + def "transitionToReviewingThreshold 기본값 확인"() { + expect: "기본값은 2" + VocabularyConfig.transitionToReviewingThreshold() >= 1 + } + + def "transitionToMasteredThreshold 기본값 확인"() { + expect: "기본값은 5" + VocabularyConfig.transitionToMasteredThreshold() >= VocabularyConfig.transitionToReviewingThreshold() + } + + def "secondIntervalDays 기본값 확인"() { + expect: "기본값은 6" + VocabularyConfig.secondIntervalDays() > 0 + } + + // ==================== Business Logic Tests ==================== + + def "transitionToMasteredThreshold가 transitionToReviewingThreshold보다 큼"() { + expect: "마스터 전이 임계값이 복습 전이 임계값보다 커야 함" + VocabularyConfig.transitionToMasteredThreshold() > VocabularyConfig.transitionToReviewingThreshold() + } + + def "모든 설정값이 양수"() { + expect: + VocabularyConfig.newWordsCount() > 0 + VocabularyConfig.reviewWordsCount() > 0 + VocabularyConfig.transitionToReviewingThreshold() > 0 + VocabularyConfig.transitionToMasteredThreshold() > 0 + VocabularyConfig.secondIntervalDays() > 0 + } +} From e9a6c180d941d60aa134dba4577cd32a634658de Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 15:19:29 +0900 Subject: [PATCH 335/528] =?UTF-8?q?test:=20VocabKey=20=EC=83=81=EC=88=98?= =?UTF-8?q?=20=EB=8B=A8=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vocabulary/constants/VocabKeySpec.groovy | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/vocabulary/constants/VocabKeySpec.groovy diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/vocabulary/constants/VocabKeySpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/vocabulary/constants/VocabKeySpec.groovy new file mode 100644 index 00000000..0bb55dd5 --- /dev/null +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/vocabulary/constants/VocabKeySpec.groovy @@ -0,0 +1,97 @@ +package com.mzc.secondproject.serverless.domain.vocabulary.constants + +import spock.lang.Specification + +class VocabKeySpec extends Specification { + + // ==================== Word Key Tests ==================== + + def "wordPk: WORD# prefix 적용"() { + expect: + VocabKey.wordPk("word123") == "WORD#word123" + } + + def "wordSk: WORD# prefix 적용"() { + expect: + VocabKey.wordSk("word456") == "WORD#word456" + } + + // ==================== Daily Study Key Tests ==================== + + def "dailyPk: DAILY# prefix 적용"() { + expect: + VocabKey.dailyPk("user123") == "DAILY#user123" + } + + def "dateSk: DATE# prefix 적용"() { + expect: + VocabKey.dateSk("2026-01-20") == "DATE#2026-01-20" + } + + // ==================== Level/Category Key Tests ==================== + + def "levelPk: LEVEL# prefix 적용"() { + expect: + VocabKey.levelPk("BEGINNER") == "LEVEL#BEGINNER" + VocabKey.levelPk("INTERMEDIATE") == "LEVEL#INTERMEDIATE" + VocabKey.levelPk("ADVANCED") == "LEVEL#ADVANCED" + } + + def "categoryPk: CATEGORY# prefix 적용"() { + expect: + VocabKey.categoryPk("TOEIC") == "CATEGORY#TOEIC" + } + + def "statusSk: STATUS# prefix 적용"() { + expect: + VocabKey.statusSk("NEW") == "STATUS#NEW" + VocabKey.statusSk("LEARNING") == "STATUS#LEARNING" + } + + // ==================== User Key Tests ==================== + + def "userReviewPk: USER#userId#REVIEW 형식"() { + expect: + VocabKey.userReviewPk("user123") == "USER#user123#REVIEW" + } + + def "userStatusPk: USER#userId#STATUS 형식"() { + expect: + VocabKey.userStatusPk("user456") == "USER#user456#STATUS" + } + + def "userGroupPk: USER#userId#GROUP 형식"() { + expect: + VocabKey.userGroupPk("user789") == "USER#user789#GROUP" + } + + def "userBookmarkedPk: USER#userId#BOOKMARKED 형식"() { + expect: + VocabKey.userBookmarkedPk("user000") == "USER#user000#BOOKMARKED" + } + + // ==================== Test Key Tests ==================== + + def "testPk: TEST# prefix 적용"() { + expect: + VocabKey.testPk("test123") == "TEST#test123" + } + + // ==================== Constants Tests ==================== + + def "상수값 확인"() { + expect: + VocabKey.WORD == "WORD#" + VocabKey.DAILY == "DAILY#" + VocabKey.LEVEL == "LEVEL#" + VocabKey.CATEGORY == "CATEGORY#" + VocabKey.TEST == "TEST#" + VocabKey.DATE == "DATE#" + VocabKey.STATUS_PREFIX == "STATUS#" + VocabKey.SUFFIX_REVIEW == "#REVIEW" + VocabKey.SUFFIX_STATUS == "#STATUS" + VocabKey.SUFFIX_GROUP == "#GROUP" + VocabKey.SUFFIX_BOOKMARKED == "#BOOKMARKED" + VocabKey.DAILY_ALL == "DAILY#ALL" + } +} From b019975c51ebda952766efd284c73116a9c4d26e Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 15:19:30 +0900 Subject: [PATCH 336/528] =?UTF-8?q?test:=20VocabularyErrorCode=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../exception/VocabularyErrorCodeSpec.groovy | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/vocabulary/exception/VocabularyErrorCodeSpec.groovy diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/vocabulary/exception/VocabularyErrorCodeSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/vocabulary/exception/VocabularyErrorCodeSpec.groovy new file mode 100644 index 00000000..1b680739 --- /dev/null +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/vocabulary/exception/VocabularyErrorCodeSpec.groovy @@ -0,0 +1,59 @@ +package com.mzc.secondproject.serverless.domain.vocabulary.exception + +import spock.lang.Specification +import spock.lang.Unroll + +class VocabularyErrorCodeSpec extends Specification { + + def "모든 에러 코드의 도메인은 VOCABULARY"() { + expect: "모든 에러 코드의 도메인이 VOCABULARY" + VocabularyErrorCode.values().every { it.getDomain() == "VOCABULARY" } + } + + @Unroll + def "에러 코드 '#errorCode': code=#expectedCode, statusCode=#expectedStatusCode"() { + expect: + errorCode.getCode() == expectedCode + errorCode.getStatusCode() == expectedStatusCode + errorCode.getMessage() != null + !errorCode.getMessage().isEmpty() + + where: + errorCode | expectedCode | expectedStatusCode + VocabularyErrorCode.WORD_NOT_FOUND | "WORD_001" | 404 + VocabularyErrorCode.WORD_ALREADY_EXISTS | "WORD_002" | 409 + VocabularyErrorCode.INVALID_WORD_DATA | "WORD_003" | 400 + VocabularyErrorCode.USER_WORD_NOT_FOUND | "USER_WORD_001" | 404 + VocabularyErrorCode.INVALID_DIFFICULTY | "USER_WORD_002" | 400 + VocabularyErrorCode.INVALID_WORD_STATUS | "USER_WORD_003" | 400 + VocabularyErrorCode.DAILY_STUDY_NOT_FOUND | "STUDY_001" | 404 + VocabularyErrorCode.STUDY_LIMIT_EXCEEDED | "STUDY_002" | 400 + VocabularyErrorCode.INVALID_STUDY_LEVEL | "STUDY_003" | 400 + VocabularyErrorCode.INVALID_CATEGORY | "CATEGORY_001" | 400 + VocabularyErrorCode.INVALID_LEVEL | "LEVEL_001" | 400 + VocabularyErrorCode.GROUP_NOT_FOUND | "GROUP_001" | 404 + VocabularyErrorCode.GROUP_ALREADY_EXISTS | "GROUP_002" | 409 + VocabularyErrorCode.TEST_NOT_FOUND | "TEST_001" | 404 + VocabularyErrorCode.NO_WORDS_TO_TEST | "TEST_002" | 400 + } + + def "404 에러 코드들 확인"() { + expect: "404 상태 코드를 가진 에러들" + VocabularyErrorCode.WORD_NOT_FOUND.getStatusCode() == 404 + VocabularyErrorCode.USER_WORD_NOT_FOUND.getStatusCode() == 404 + VocabularyErrorCode.DAILY_STUDY_NOT_FOUND.getStatusCode() == 404 + VocabularyErrorCode.GROUP_NOT_FOUND.getStatusCode() == 404 + VocabularyErrorCode.TEST_NOT_FOUND.getStatusCode() == 404 + } + + def "409 에러 코드들 확인"() { + expect: "409 상태 코드를 가진 에러들 (Conflict)" + VocabularyErrorCode.WORD_ALREADY_EXISTS.getStatusCode() == 409 + VocabularyErrorCode.GROUP_ALREADY_EXISTS.getStatusCode() == 409 + } + + def "모든 에러 코드 개수 확인"() { + expect: "15개의 에러 코드 존재" + VocabularyErrorCode.values().length == 15 + } +} From 3bc79062b9ce78035bdec488bb40d0e85d1dce65 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 15:19:30 +0900 Subject: [PATCH 337/528] =?UTF-8?q?test:=20VocabularyException=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../exception/VocabularyExceptionSpec.groovy | 190 ++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/vocabulary/exception/VocabularyExceptionSpec.groovy diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/vocabulary/exception/VocabularyExceptionSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/vocabulary/exception/VocabularyExceptionSpec.groovy new file mode 100644 index 00000000..e8809547 --- /dev/null +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/vocabulary/exception/VocabularyExceptionSpec.groovy @@ -0,0 +1,190 @@ +package com.mzc.secondproject.serverless.domain.vocabulary.exception + +import spock.lang.Specification + +class VocabularyExceptionSpec extends Specification { + + // ==================== Word 관련 예외 Tests ==================== + + def "wordNotFound: 메시지에 wordId 포함"() { + given: + def wordId = "word123" + + when: + def exception = VocabularyException.wordNotFound(wordId) + + then: + exception.getMessage().contains(wordId) + exception.getErrorCode() == VocabularyErrorCode.WORD_NOT_FOUND + } + + def "wordAlreadyExists: 메시지에 영단어 포함"() { + given: + def english = "apple" + + when: + def exception = VocabularyException.wordAlreadyExists(english) + + then: + exception.getMessage().contains(english) + exception.getErrorCode() == VocabularyErrorCode.WORD_ALREADY_EXISTS + } + + def "invalidWordData: 사유 메시지 포함"() { + given: + def reason = "영단어는 필수입니다" + + when: + def exception = VocabularyException.invalidWordData(reason) + + then: + exception.getMessage() == reason + exception.getErrorCode() == VocabularyErrorCode.INVALID_WORD_DATA + } + + // ==================== UserWord 관련 예외 Tests ==================== + + def "userWordNotFound: userId와 wordId 포함"() { + given: + def userId = "user123" + def wordId = "word456" + + when: + def exception = VocabularyException.userWordNotFound(userId, wordId) + + then: + exception.getMessage().contains(userId) + exception.getMessage().contains(wordId) + exception.getErrorCode() == VocabularyErrorCode.USER_WORD_NOT_FOUND + } + + def "invalidDifficulty: 잘못된 난이도 값 포함"() { + given: + def difficulty = "VERY_HARD" + + when: + def exception = VocabularyException.invalidDifficulty(difficulty) + + then: + exception.getMessage().contains(difficulty) + exception.getMessage().contains("EASY") + exception.getMessage().contains("NORMAL") + exception.getMessage().contains("HARD") + exception.getErrorCode() == VocabularyErrorCode.INVALID_DIFFICULTY + } + + def "invalidWordStatus: 잘못된 상태 값 포함"() { + given: + def status = "INVALID_STATUS" + + when: + def exception = VocabularyException.invalidWordStatus(status) + + then: + exception.getMessage().contains(status) + exception.getErrorCode() == VocabularyErrorCode.INVALID_WORD_STATUS + } + + // ==================== Study 관련 예외 Tests ==================== + + def "dailyStudyNotFound: userId와 date 포함"() { + given: + def userId = "user123" + def date = "2026-01-20" + + when: + def exception = VocabularyException.dailyStudyNotFound(userId, date) + + then: + exception.getMessage().contains(userId) + exception.getMessage().contains(date) + exception.getErrorCode() == VocabularyErrorCode.DAILY_STUDY_NOT_FOUND + } + + def "studyLimitExceeded: 한도 값 포함"() { + given: + def limit = 50 + + when: + def exception = VocabularyException.studyLimitExceeded(limit) + + then: + exception.getMessage().contains("50") + exception.getErrorCode() == VocabularyErrorCode.STUDY_LIMIT_EXCEEDED + } + + def "invalidStudyLevel: 잘못된 레벨 값 포함"() { + given: + def level = "EXPERT" + + when: + def exception = VocabularyException.invalidStudyLevel(level) + + then: + exception.getMessage().contains(level) + exception.getErrorCode() == VocabularyErrorCode.INVALID_STUDY_LEVEL + } + + // ==================== Category/Level 관련 예외 Tests ==================== + + def "invalidCategory: 잘못된 카테고리 값 포함"() { + given: + def category = "INVALID_CATEGORY" + + when: + def exception = VocabularyException.invalidCategory(category) + + then: + exception.getMessage().contains(category) + exception.getErrorCode() == VocabularyErrorCode.INVALID_CATEGORY + } + + def "invalidLevel: 잘못된 레벨 값 포함"() { + given: + def level = "UNKNOWN_LEVEL" + + when: + def exception = VocabularyException.invalidLevel(level) + + then: + exception.getMessage().contains(level) + exception.getErrorCode() == VocabularyErrorCode.INVALID_LEVEL + } + + // ==================== WordGroup 관련 예외 Tests ==================== + + def "groupNotFound: groupId 포함"() { + given: + def groupId = "group123" + + when: + def exception = VocabularyException.groupNotFound(groupId) + + then: + exception.getMessage().contains(groupId) + exception.getErrorCode() == VocabularyErrorCode.GROUP_NOT_FOUND + } + + def "groupAlreadyExists: groupName 포함"() { + given: + def groupName = "My Vocabulary" + + when: + def exception = VocabularyException.groupAlreadyExists(groupName) + + then: + exception.getMessage().contains(groupName) + exception.getErrorCode() == VocabularyErrorCode.GROUP_ALREADY_EXISTS + } + + // ==================== Test 관련 예외 Tests ==================== + + def "noWordsToTest: 적절한 메시지 포함"() { + when: + def exception = VocabularyException.noWordsToTest() + + then: + exception.getMessage().contains("테스트할 단어가 없습니다") + exception.getErrorCode() == VocabularyErrorCode.NO_WORDS_TO_TEST + } +} From 26401c944c2179e3d9860852386e8f9074a52362 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 15:19:42 +0900 Subject: [PATCH 338/528] =?UTF-8?q?test:=20SpacedRepetitionContext=20?= =?UTF-8?q?=EB=8B=A8=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../state/SpacedRepetitionContextSpec.groovy | 227 ++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100644 ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/vocabulary/state/SpacedRepetitionContextSpec.groovy diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/vocabulary/state/SpacedRepetitionContextSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/vocabulary/state/SpacedRepetitionContextSpec.groovy new file mode 100644 index 00000000..7e1c0ab6 --- /dev/null +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/vocabulary/state/SpacedRepetitionContextSpec.groovy @@ -0,0 +1,227 @@ +package com.mzc.secondproject.serverless.domain.vocabulary.state + +import com.mzc.secondproject.serverless.common.config.StudyConfig +import spock.lang.Specification + +class SpacedRepetitionContextSpec extends Specification { + + // ==================== 생성자 Tests ==================== + + def "기본 생성자: 초기값 설정 확인"() { + when: + def context = new SpacedRepetitionContext() + + then: + context.getRepetitions() == StudyConfig.INITIAL_REPETITIONS + context.getInterval() == StudyConfig.INITIAL_INTERVAL_DAYS + context.getEaseFactor() == StudyConfig.DEFAULT_EASE_FACTOR + context.getCorrectCount() == StudyConfig.INITIAL_CORRECT_COUNT + context.getIncorrectCount() == StudyConfig.INITIAL_INCORRECT_COUNT + } + + def "매개변수 생성자: 지정값 설정 확인"() { + given: + int repetitions = 3 + int interval = 7 + double easeFactor = 2.0 + int correctCount = 5 + int incorrectCount = 2 + + when: + def context = new SpacedRepetitionContext(repetitions, interval, easeFactor, correctCount, incorrectCount) + + then: + context.getRepetitions() == 3 + context.getInterval() == 7 + context.getEaseFactor() == 2.0 + context.getCorrectCount() == 5 + context.getIncorrectCount() == 2 + } + + // ==================== Repetition Tests ==================== + + def "incrementRepetitions: 반복 횟수 증가"() { + given: + def context = new SpacedRepetitionContext() + def initial = context.getRepetitions() + + when: + context.incrementRepetitions() + + then: + context.getRepetitions() == initial + 1 + } + + def "resetRepetitions: 반복 횟수 초기화"() { + given: + def context = new SpacedRepetitionContext(5, 10, 2.5, 5, 0) + + when: + context.resetRepetitions() + + then: + context.getRepetitions() == StudyConfig.INITIAL_REPETITIONS + } + + // ==================== Count Tests ==================== + + def "incrementCorrectCount: 정답 카운트 증가"() { + given: + def context = new SpacedRepetitionContext() + + when: + context.incrementCorrectCount() + context.incrementCorrectCount() + + then: + context.getCorrectCount() == 2 + } + + def "incrementIncorrectCount: 오답 카운트 증가"() { + given: + def context = new SpacedRepetitionContext() + + when: + context.incrementIncorrectCount() + + then: + context.getIncorrectCount() == 1 + } + + // ==================== Interval Tests ==================== + + def "updateInterval: 간격 업데이트"() { + given: + def context = new SpacedRepetitionContext() + + when: + context.updateInterval(14) + + then: + context.getInterval() == 14 + } + + def "resetInterval: 간격 초기화"() { + given: + def context = new SpacedRepetitionContext(3, 30, 2.5, 3, 0) + + when: + context.resetInterval() + + then: + context.getInterval() == StudyConfig.INITIAL_INTERVAL_DAYS + } + + // ==================== EaseFactor Tests ==================== + + def "decreaseEaseFactor: easeFactor 감소"() { + given: + def context = new SpacedRepetitionContext() + def initial = context.getEaseFactor() + + when: + context.decreaseEaseFactor() + + then: + context.getEaseFactor() == initial - 0.2 + } + + def "decreaseEaseFactor: 최소값 보장"() { + given: "easeFactor가 최소값에 가까운 컨텍스트" + def context = new SpacedRepetitionContext(0, 1, 1.4, 0, 0) + + when: "easeFactor 감소" + context.decreaseEaseFactor() + + then: "최소값(1.3) 이하로 내려가지 않음" + context.getEaseFactor() >= StudyConfig.MIN_EASE_FACTOR + } + + def "decreaseEaseFactor: 연속 감소 시 최소값 유지"() { + given: + def context = new SpacedRepetitionContext() + + when: "여러 번 감소" + 10.times { context.decreaseEaseFactor() } + + then: "최소값 유지" + context.getEaseFactor() == StudyConfig.MIN_EASE_FACTOR + } + + // ==================== calculateNextInterval Tests ==================== + + def "calculateNextInterval: interval * easeFactor 계산"() { + given: + def context = new SpacedRepetitionContext(2, 6, 2.5, 2, 0) + + when: + def nextInterval = context.calculateNextInterval() + + then: "6 * 2.5 = 15" + nextInterval == 15 + } + + def "calculateNextInterval: 소수점 반올림"() { + given: + def context = new SpacedRepetitionContext(3, 7, 2.5, 3, 0) + + when: + def nextInterval = context.calculateNextInterval() + + then: "7 * 2.5 = 17.5 -> 18 (반올림)" + nextInterval == 18 + } + + def "calculateNextInterval: 초기 상태"() { + given: + def context = new SpacedRepetitionContext() + + when: + def nextInterval = context.calculateNextInterval() + + then: "1 * 2.5 = 2.5 -> 3 (반올림)" + nextInterval == 3 + } + + // ==================== 복합 시나리오 Tests ==================== + + def "학습 시나리오: 연속 정답 후 interval 증가"() { + given: + def context = new SpacedRepetitionContext() + + when: "3번 연속 정답" + context.incrementCorrectCount() + context.incrementRepetitions() + context.updateInterval(1) + + context.incrementCorrectCount() + context.incrementRepetitions() + context.updateInterval(6) + + context.incrementCorrectCount() + context.incrementRepetitions() + context.updateInterval(context.calculateNextInterval()) + + then: + context.getRepetitions() == 3 + context.getCorrectCount() == 3 + context.getInterval() == 15 // 6 * 2.5 + } + + def "학습 시나리오: 오답 후 리셋"() { + given: "진행 중인 학습 컨텍스트" + def context = new SpacedRepetitionContext(3, 14, 2.5, 3, 0) + + when: "오답 처리" + context.incrementIncorrectCount() + context.resetRepetitions() + context.resetInterval() + context.decreaseEaseFactor() + + then: + context.getRepetitions() == 0 + context.getInterval() == 1 + context.getIncorrectCount() == 1 + context.getEaseFactor() == 2.3 + } +} From 45ce8a12abb1b370ee65beac311f5d3478c24595 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 15:19:43 +0900 Subject: [PATCH 339/528] =?UTF-8?q?test:=20GrammarLevel=20enum=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../grammar/enums/GrammarLevelSpec.groovy | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/grammar/enums/GrammarLevelSpec.groovy diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/grammar/enums/GrammarLevelSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/grammar/enums/GrammarLevelSpec.groovy new file mode 100644 index 00000000..2b92eb9f --- /dev/null +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/grammar/enums/GrammarLevelSpec.groovy @@ -0,0 +1,99 @@ +package com.mzc.secondproject.serverless.domain.grammar.enums + +import spock.lang.Specification +import spock.lang.Unroll + +class GrammarLevelSpec extends Specification { + + // ==================== isValid Tests ==================== + + @Unroll + def "isValid: '#value' -> #expected"() { + expect: + GrammarLevel.isValid(value) == expected + + where: + value | expected + "BEGINNER" | true + "INTERMEDIATE" | true + "ADVANCED" | true + "beginner" | true + "intermediate" | true + "advanced" | true + "INVALID" | false + "" | false + null | false + } + + // ==================== fromString Tests ==================== + + @Unroll + def "fromString: '#value' -> #expected"() { + when: + def result = GrammarLevel.fromString(value) + + then: + result == expected + + where: + value | expected + "BEGINNER" | GrammarLevel.BEGINNER + "beginner" | GrammarLevel.BEGINNER + "INTERMEDIATE" | GrammarLevel.INTERMEDIATE + "intermediate" | GrammarLevel.INTERMEDIATE + "ADVANCED" | GrammarLevel.ADVANCED + "advanced" | GrammarLevel.ADVANCED + } + + def "fromString: null 입력 시 IllegalArgumentException 발생"() { + when: + GrammarLevel.fromString(null) + + then: + thrown(IllegalArgumentException) + } + + def "fromString: 잘못된 값 입력 시 IllegalArgumentException 발생"() { + when: + GrammarLevel.fromString("INVALID") + + then: + thrown(IllegalArgumentException) + } + + // ==================== fromStringOrDefault Tests ==================== + + @Unroll + def "fromStringOrDefault: '#value' with default #defaultValue -> #expected"() { + expect: + GrammarLevel.fromStringOrDefault(value, defaultValue) == expected + + where: + value | defaultValue | expected + "BEGINNER" | GrammarLevel.ADVANCED | GrammarLevel.BEGINNER + null | GrammarLevel.BEGINNER | GrammarLevel.BEGINNER + "INVALID" | GrammarLevel.INTERMEDIATE | GrammarLevel.INTERMEDIATE + } + + // ==================== Getter Tests ==================== + + def "GrammarLevel 속성 정상 반환"() { + expect: + GrammarLevel.BEGINNER.getCode() == "beginner" + GrammarLevel.BEGINNER.getDisplayName() == "초급" + GrammarLevel.BEGINNER.getDescription() == "한국어 번역과 쉬운 설명 포함" + + GrammarLevel.INTERMEDIATE.getCode() == "intermediate" + GrammarLevel.INTERMEDIATE.getDisplayName() == "중급" + GrammarLevel.INTERMEDIATE.getDescription() == "영어 위주 설명" + + GrammarLevel.ADVANCED.getCode() == "advanced" + GrammarLevel.ADVANCED.getDisplayName() == "고급" + GrammarLevel.ADVANCED.getDescription() == "상세한 문법 규칙 설명" + } + + def "모든 GrammarLevel 값 존재 확인"() { + expect: "3개의 레벨 존재" + GrammarLevel.values().length == 3 + } +} From f27ddbf90b534b7866c8b38835370c0543c266b2 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 15:19:43 +0900 Subject: [PATCH 340/528] =?UTF-8?q?test:=20GrammarConfig=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../grammar/config/GrammarConfigSpec.groovy | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/grammar/config/GrammarConfigSpec.groovy diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/grammar/config/GrammarConfigSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/grammar/config/GrammarConfigSpec.groovy new file mode 100644 index 00000000..2f7c673f --- /dev/null +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/grammar/config/GrammarConfigSpec.groovy @@ -0,0 +1,47 @@ +package com.mzc.secondproject.serverless.domain.grammar.config + +import spock.lang.Specification + +class GrammarConfigSpec extends Specification { + + def "sessionTtlDays 기본값 확인"() { + expect: "환경 변수 미설정 시 기본값 반환" + GrammarConfig.sessionTtlDays() > 0 + } + + def "maxHistoryMessages 기본값 확인"() { + expect: "환경 변수 미설정 시 기본값 반환" + GrammarConfig.maxHistoryMessages() > 0 + } + + def "lastMessageMaxLength 기본값 확인"() { + expect: "환경 변수 미설정 시 기본값 반환" + GrammarConfig.lastMessageMaxLength() > 0 + } + + def "maxTokens 기본값 확인"() { + expect: "환경 변수 미설정 시 기본값 반환" + GrammarConfig.maxTokens() > 0 + } + + // ==================== Business Logic Tests ==================== + + def "모든 설정값이 양수"() { + expect: + GrammarConfig.sessionTtlDays() > 0 + GrammarConfig.maxHistoryMessages() > 0 + GrammarConfig.lastMessageMaxLength() > 0 + GrammarConfig.maxTokens() > 0 + } + + def "maxTokens가 합리적인 범위"() { + expect: "토큰 수는 1000 이상이어야 함" + GrammarConfig.maxTokens() >= 1000 + } + + def "maxHistoryMessages가 합리적인 범위"() { + expect: "히스토리 메시지 수는 1~100 범위" + GrammarConfig.maxHistoryMessages() >= 1 + GrammarConfig.maxHistoryMessages() <= 100 + } +} From a70d534da7301fdadc40bccc6663ba3eca387cf9 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 15:19:43 +0900 Subject: [PATCH 341/528] =?UTF-8?q?test:=20GrammarErrorCode=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../exception/GrammarErrorCodeSpec.groovy | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/grammar/exception/GrammarErrorCodeSpec.groovy diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/grammar/exception/GrammarErrorCodeSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/grammar/exception/GrammarErrorCodeSpec.groovy new file mode 100644 index 00000000..0032b9a1 --- /dev/null +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/grammar/exception/GrammarErrorCodeSpec.groovy @@ -0,0 +1,53 @@ +package com.mzc.secondproject.serverless.domain.grammar.exception + +import spock.lang.Specification +import spock.lang.Unroll + +class GrammarErrorCodeSpec extends Specification { + + def "모든 에러 코드의 도메인은 GRAMMAR"() { + expect: + GrammarErrorCode.values().every { it.getDomain() == "GRAMMAR" } + } + + @Unroll + def "에러 코드 '#errorCode': code=#expectedCode, statusCode=#expectedStatusCode"() { + expect: + errorCode.getCode() == expectedCode + errorCode.getStatusCode() == expectedStatusCode + errorCode.getMessage() != null + !errorCode.getMessage().isEmpty() + + where: + errorCode | expectedCode | expectedStatusCode + GrammarErrorCode.INVALID_REQUEST | "GRAMMAR_000" | 400 + GrammarErrorCode.INVALID_SENTENCE | "GRAMMAR_001" | 400 + GrammarErrorCode.GRAMMAR_CHECK_FAILED | "GRAMMAR_002" | 500 + GrammarErrorCode.INVALID_LEVEL | "GRAMMAR_003" | 400 + GrammarErrorCode.BEDROCK_API_ERROR | "GRAMMAR_004" | 502 + GrammarErrorCode.BEDROCK_RESPONSE_PARSE_ERROR | "GRAMMAR_005" | 500 + GrammarErrorCode.SESSION_NOT_FOUND | "GRAMMAR_006" | 404 + GrammarErrorCode.SESSION_EXPIRED | "GRAMMAR_007" | 410 + } + + def "모든 에러 코드 개수 확인"() { + expect: "8개의 에러 코드 존재" + GrammarErrorCode.values().length == 8 + } + + def "클라이언트 에러 확인 (4xx)"() { + expect: + GrammarErrorCode.INVALID_REQUEST.isClientError() + GrammarErrorCode.INVALID_SENTENCE.isClientError() + GrammarErrorCode.INVALID_LEVEL.isClientError() + GrammarErrorCode.SESSION_NOT_FOUND.isClientError() + GrammarErrorCode.SESSION_EXPIRED.isClientError() + } + + def "서버 에러 확인 (5xx)"() { + expect: + GrammarErrorCode.GRAMMAR_CHECK_FAILED.isServerError() + GrammarErrorCode.BEDROCK_API_ERROR.isServerError() + GrammarErrorCode.BEDROCK_RESPONSE_PARSE_ERROR.isServerError() + } +} From 7a4be05f6ec3d52ebdd5db7dcebe3a1cb1f42f62 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 15:19:55 +0900 Subject: [PATCH 342/528] =?UTF-8?q?test:=20GrammarException=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../exception/GrammarExceptionSpec.groovy | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/grammar/exception/GrammarExceptionSpec.groovy diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/grammar/exception/GrammarExceptionSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/grammar/exception/GrammarExceptionSpec.groovy new file mode 100644 index 00000000..81dd18dd --- /dev/null +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/grammar/exception/GrammarExceptionSpec.groovy @@ -0,0 +1,116 @@ +package com.mzc.secondproject.serverless.domain.grammar.exception + +import spock.lang.Specification + +class GrammarExceptionSpec extends Specification { + + // ==================== 요청 검증 관련 예외 Tests ==================== + + def "invalidRequest: 필드와 사유 포함"() { + when: + def exception = GrammarException.invalidRequest("sentence", "문장이 비어있습니다") + + then: + exception.getMessage().contains("잘못된 요청") + exception.getErrorCode() == GrammarErrorCode.INVALID_REQUEST + exception.getStatusCode() == 400 + } + + // ==================== 문법 체크 관련 예외 Tests ==================== + + def "invalidSentence: 문장 포함"() { + given: + def sentence = "This is invalid." + + when: + def exception = GrammarException.invalidSentence(sentence) + + then: + exception.getMessage().contains("유효하지 않은 문장") + exception.getErrorCode() == GrammarErrorCode.INVALID_SENTENCE + } + + def "grammarCheckFailed: 사유 포함"() { + given: + def reason = "AI 서비스 연결 실패" + + when: + def exception = GrammarException.grammarCheckFailed(reason) + + then: + exception.getMessage().contains(reason) + exception.getErrorCode() == GrammarErrorCode.GRAMMAR_CHECK_FAILED + } + + // ==================== 레벨 관련 예외 Tests ==================== + + def "invalidLevel: 레벨과 허용값 포함"() { + given: + def level = "EXPERT" + + when: + def exception = GrammarException.invalidLevel(level) + + then: + exception.getMessage().contains(level) + exception.getMessage().contains("BEGINNER") + exception.getMessage().contains("INTERMEDIATE") + exception.getMessage().contains("ADVANCED") + exception.getErrorCode() == GrammarErrorCode.INVALID_LEVEL + } + + // ==================== Bedrock API 관련 예외 Tests ==================== + + def "bedrockApiError: cause 포함"() { + given: + def cause = new RuntimeException("Connection refused") + + when: + def exception = GrammarException.bedrockApiError(cause) + + then: + exception.getCause() == cause + exception.getErrorCode() == GrammarErrorCode.BEDROCK_API_ERROR + exception.getStatusCode() == 502 + } + + def "bedrockResponseParseError: 응답 포함"() { + given: + def response = "{ invalid json }" + + when: + def exception = GrammarException.bedrockResponseParseError(response) + + then: + exception.getMessage().contains("파싱") + exception.getErrorCode() == GrammarErrorCode.BEDROCK_RESPONSE_PARSE_ERROR + } + + // ==================== 세션 관련 예외 Tests ==================== + + def "sessionNotFound: sessionId 포함"() { + given: + def sessionId = "session123" + + when: + def exception = GrammarException.sessionNotFound(sessionId) + + then: + exception.getMessage().contains(sessionId) + exception.getErrorCode() == GrammarErrorCode.SESSION_NOT_FOUND + exception.getStatusCode() == 404 + } + + def "sessionExpired: sessionId 포함"() { + given: + def sessionId = "expiredSession456" + + when: + def exception = GrammarException.sessionExpired(sessionId) + + then: + exception.getMessage().contains(sessionId) + exception.getMessage().contains("만료") + exception.getErrorCode() == GrammarErrorCode.SESSION_EXPIRED + } +} From 90083254a51334af26736090e2fef8ad5e3ebc56 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 15:19:55 +0900 Subject: [PATCH 343/528] =?UTF-8?q?test:=20GameStatus=20enum=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chatting/enums/GameStatusSpec.groovy | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/enums/GameStatusSpec.groovy diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/enums/GameStatusSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/enums/GameStatusSpec.groovy new file mode 100644 index 00000000..23661c6b --- /dev/null +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/enums/GameStatusSpec.groovy @@ -0,0 +1,95 @@ +package com.mzc.secondproject.serverless.domain.chatting.enums + +import spock.lang.Specification +import spock.lang.Unroll + +class GameStatusSpec extends Specification { + + // ==================== isValid Tests ==================== + + @Unroll + def "isValid: '#value' -> #expected"() { + expect: + GameStatus.isValid(value) == expected + + where: + value | expected + "NONE" | true + "WAITING" | true + "PLAYING" | true + "ROUND_END" | true + "FINISHED" | true + "none" | true + "waiting" | true + "INVALID" | false + "" | false + null | false + } + + // ==================== fromString Tests ==================== + + @Unroll + def "fromString: '#value' -> #expected"() { + expect: + GameStatus.fromString(value) == expected + + where: + value | expected + "NONE" | GameStatus.NONE + "none" | GameStatus.NONE + "WAITING" | GameStatus.WAITING + "waiting" | GameStatus.WAITING + "PLAYING" | GameStatus.PLAYING + "ROUND_END" | GameStatus.ROUND_END + "FINISHED" | GameStatus.FINISHED + null | GameStatus.NONE + "INVALID" | GameStatus.NONE + } + + // ==================== isGameActive Tests ==================== + + def "isGameActive: PLAYING과 ROUND_END만 true"() { + expect: + GameStatus.NONE.isGameActive() == false + GameStatus.WAITING.isGameActive() == false + GameStatus.PLAYING.isGameActive() == true + GameStatus.ROUND_END.isGameActive() == true + GameStatus.FINISHED.isGameActive() == false + } + + // ==================== canStartGame Tests ==================== + + def "canStartGame: NONE과 FINISHED만 true"() { + expect: + GameStatus.NONE.canStartGame() == true + GameStatus.WAITING.canStartGame() == false + GameStatus.PLAYING.canStartGame() == false + GameStatus.ROUND_END.canStartGame() == false + GameStatus.FINISHED.canStartGame() == true + } + + // ==================== Getter Tests ==================== + + def "GameStatus 속성 정상 반환"() { + expect: + GameStatus.NONE.getCode() == "none" + GameStatus.NONE.getDisplayName() == "게임 없음" + + GameStatus.WAITING.getCode() == "waiting" + GameStatus.WAITING.getDisplayName() == "게임 대기 중" + + GameStatus.PLAYING.getCode() == "playing" + GameStatus.PLAYING.getDisplayName() == "게임 진행 중" + + GameStatus.ROUND_END.getCode() == "round_end" + GameStatus.ROUND_END.getDisplayName() == "라운드 종료" + + GameStatus.FINISHED.getCode() == "finished" + GameStatus.FINISHED.getDisplayName() == "게임 종료" + } + + def "모든 GameStatus 값 존재 확인"() { + expect: "5개의 상태 존재" + GameStatus.values().length == 5 + } +} From 3f22c4c6ba0b7ed83586840274801385a0098a9a Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 15:19:55 +0900 Subject: [PATCH 344/528] =?UTF-8?q?test:=20GameConfig=20=EB=8B=A8=EC=9C=84?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chatting/config/GameConfigSpec.groovy | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/config/GameConfigSpec.groovy diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/config/GameConfigSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/config/GameConfigSpec.groovy new file mode 100644 index 00000000..f297f4ab --- /dev/null +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/config/GameConfigSpec.groovy @@ -0,0 +1,47 @@ +package com.mzc.secondproject.serverless.domain.chatting.config + +import spock.lang.Specification + +class GameConfigSpec extends Specification { + + def "totalRounds 기본값 확인"() { + expect: "환경 변수 미설정 시 기본값 반환" + GameConfig.totalRounds() > 0 + } + + def "roundTimeLimit 기본값 확인"() { + expect: "환경 변수 미설정 시 기본값 반환" + GameConfig.roundTimeLimit() > 0 + } + + def "quickGuessThresholdMs 기본값 확인"() { + expect: "환경 변수 미설정 시 기본값 반환" + GameConfig.quickGuessThresholdMs() > 0 + } + + // ==================== Business Logic Tests ==================== + + def "모든 설정값이 양수"() { + expect: + GameConfig.totalRounds() > 0 + GameConfig.roundTimeLimit() > 0 + GameConfig.quickGuessThresholdMs() > 0 + } + + def "quickGuessThresholdMs가 roundTimeLimit보다 작음"() { + expect: "빠른 추측 임계값(ms)이 라운드 시간 제한(초)을 ms로 변환한 값보다 작아야 함" + GameConfig.quickGuessThresholdMs() < GameConfig.roundTimeLimit() * 1000L + } + + def "totalRounds가 합리적인 범위"() { + expect: "라운드 수는 1~20 범위" + GameConfig.totalRounds() >= 1 + GameConfig.totalRounds() <= 20 + } + + def "roundTimeLimit가 합리적인 범위"() { + expect: "라운드 시간 제한은 10~300초 범위" + GameConfig.roundTimeLimit() >= 10 + GameConfig.roundTimeLimit() <= 300 + } +} From 3f8dc261dcf147f21d6318f0e9b8af93a5467612 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 15:19:56 +0900 Subject: [PATCH 345/528] =?UTF-8?q?test:=20ChattingErrorCode=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../exception/ChattingErrorCodeSpec.groovy | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCodeSpec.groovy diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCodeSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCodeSpec.groovy new file mode 100644 index 00000000..66742acb --- /dev/null +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCodeSpec.groovy @@ -0,0 +1,75 @@ +package com.mzc.secondproject.serverless.domain.chatting.exception + +import spock.lang.Specification +import spock.lang.Unroll + +class ChattingErrorCodeSpec extends Specification { + + def "모든 에러 코드의 도메인은 CHATTING"() { + expect: + ChattingErrorCode.values().every { it.getDomain() == "CHATTING" } + } + + @Unroll + def "에러 코드 '#errorCode': code=#expectedCode, statusCode=#expectedStatusCode"() { + expect: + errorCode.getCode() == expectedCode + errorCode.getStatusCode() == expectedStatusCode + errorCode.getMessage() != null + !errorCode.getMessage().isEmpty() + + where: + errorCode | expectedCode | expectedStatusCode + ChattingErrorCode.ROOM_NOT_FOUND | "ROOM_001" | 404 + ChattingErrorCode.ROOM_ALREADY_EXISTS | "ROOM_002" | 409 + ChattingErrorCode.ROOM_FULL | "ROOM_003" | 400 + ChattingErrorCode.ROOM_CLOSED | "ROOM_004" | 400 + ChattingErrorCode.ROOM_INVALID_PASSWORD | "ROOM_005" | 401 + ChattingErrorCode.ROOM_NOT_OWNER | "ROOM_006" | 403 + ChattingErrorCode.MESSAGE_NOT_FOUND | "MSG_001" | 404 + ChattingErrorCode.MESSAGE_TOO_LONG | "MSG_002" | 400 + ChattingErrorCode.INVALID_MESSAGE_TYPE | "MSG_003" | 400 + ChattingErrorCode.NOT_ROOM_MEMBER | "MEMBER_001" | 403 + ChattingErrorCode.ALREADY_JOINED | "MEMBER_002" | 409 + ChattingErrorCode.INVALID_ROOM_TOKEN | "MEMBER_003" | 401 + ChattingErrorCode.INVALID_CHAT_LEVEL | "LEVEL_001" | 400 + ChattingErrorCode.CONNECTION_FAILED | "CONN_001" | 500 + ChattingErrorCode.CONNECTION_TIMEOUT | "CONN_002" | 408 + ChattingErrorCode.GAME_START_FAILED | "GAME_001" | 400 + ChattingErrorCode.GAME_STOP_FAILED | "GAME_002" | 400 + ChattingErrorCode.GAME_NOT_IN_PROGRESS | "GAME_003" | 400 + ChattingErrorCode.GAME_ALREADY_IN_PROGRESS| "GAME_004" | 409 + ChattingErrorCode.NOT_GAME_STARTER | "GAME_005" | 403 + } + + def "모든 에러 코드 개수 확인"() { + expect: "20개의 에러 코드 존재" + ChattingErrorCode.values().length == 20 + } + + def "채팅방 관련 에러 코드들 (ROOM_XXX)"() { + expect: + ChattingErrorCode.ROOM_NOT_FOUND.getCode().startsWith("ROOM_") + ChattingErrorCode.ROOM_ALREADY_EXISTS.getCode().startsWith("ROOM_") + ChattingErrorCode.ROOM_FULL.getCode().startsWith("ROOM_") + ChattingErrorCode.ROOM_CLOSED.getCode().startsWith("ROOM_") + ChattingErrorCode.ROOM_INVALID_PASSWORD.getCode().startsWith("ROOM_") + ChattingErrorCode.ROOM_NOT_OWNER.getCode().startsWith("ROOM_") + } + + def "메시지 관련 에러 코드들 (MSG_XXX)"() { + expect: + ChattingErrorCode.MESSAGE_NOT_FOUND.getCode().startsWith("MSG_") + ChattingErrorCode.MESSAGE_TOO_LONG.getCode().startsWith("MSG_") + ChattingErrorCode.INVALID_MESSAGE_TYPE.getCode().startsWith("MSG_") + } + + def "게임 관련 에러 코드들 (GAME_XXX)"() { + expect: + ChattingErrorCode.GAME_START_FAILED.getCode().startsWith("GAME_") + ChattingErrorCode.GAME_STOP_FAILED.getCode().startsWith("GAME_") + ChattingErrorCode.GAME_NOT_IN_PROGRESS.getCode().startsWith("GAME_") + ChattingErrorCode.GAME_ALREADY_IN_PROGRESS.getCode().startsWith("GAME_") + ChattingErrorCode.NOT_GAME_STARTER.getCode().startsWith("GAME_") + } +} From 88fd39010f7dd4feb93cc41421b929c0c506b806 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 15:20:07 +0900 Subject: [PATCH 346/528] =?UTF-8?q?test:=20ChattingException=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../exception/ChattingExceptionSpec.groovy | 195 ++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100644 ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingExceptionSpec.groovy diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingExceptionSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingExceptionSpec.groovy new file mode 100644 index 00000000..2ef43594 --- /dev/null +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingExceptionSpec.groovy @@ -0,0 +1,195 @@ +package com.mzc.secondproject.serverless.domain.chatting.exception + +import spock.lang.Specification + +class ChattingExceptionSpec extends Specification { + + // ==================== 채팅방(Room) 관련 예외 Tests ==================== + + def "roomNotFound: roomId 포함"() { + given: + def roomId = "room123" + + when: + def exception = ChattingException.roomNotFound(roomId) + + then: + exception.getMessage().contains(roomId) + exception.getErrorCode() == ChattingErrorCode.ROOM_NOT_FOUND + exception.getStatusCode() == 404 + } + + def "roomAlreadyExists: roomName 포함"() { + given: + def roomName = "영어 스터디방" + + when: + def exception = ChattingException.roomAlreadyExists(roomName) + + then: + exception.getMessage().contains(roomName) + exception.getErrorCode() == ChattingErrorCode.ROOM_ALREADY_EXISTS + exception.getStatusCode() == 409 + } + + def "roomFull: roomId와 maxCapacity 포함"() { + when: + def exception = ChattingException.roomFull("room123", 10) + + then: + exception.getMessage().contains("10") + exception.getErrorCode() == ChattingErrorCode.ROOM_FULL + } + + def "roomClosed: roomId 포함"() { + given: + def roomId = "closedRoom456" + + when: + def exception = ChattingException.roomClosed(roomId) + + then: + exception.getMessage().contains(roomId) + exception.getMessage().contains("종료") + exception.getErrorCode() == ChattingErrorCode.ROOM_CLOSED + } + + def "roomInvalidPassword: 적절한 메시지"() { + when: + def exception = ChattingException.roomInvalidPassword("room123") + + then: + exception.getMessage().contains("비밀번호") + exception.getErrorCode() == ChattingErrorCode.ROOM_INVALID_PASSWORD + } + + def "roomNotOwner: userId와 roomId 포함"() { + when: + def exception = ChattingException.roomNotOwner("user123", "room456") + + then: + exception.getMessage().contains("user123") + exception.getMessage().contains("room456") + exception.getMessage().contains("방장") + exception.getErrorCode() == ChattingErrorCode.ROOM_NOT_OWNER + } + + // ==================== 메시지(Message) 관련 예외 Tests ==================== + + def "messageNotFound: messageId 포함"() { + given: + def messageId = "msg123" + + when: + def exception = ChattingException.messageNotFound(messageId) + + then: + exception.getMessage().contains(messageId) + exception.getErrorCode() == ChattingErrorCode.MESSAGE_NOT_FOUND + } + + def "messageTooLong: length와 maxLength 포함"() { + when: + def exception = ChattingException.messageTooLong(1500, 1000) + + then: + exception.getMessage().contains("1500") + exception.getMessage().contains("1000") + exception.getErrorCode() == ChattingErrorCode.MESSAGE_TOO_LONG + } + + def "invalidMessageType: type 포함"() { + given: + def type = "INVALID_TYPE" + + when: + def exception = ChattingException.invalidMessageType(type) + + then: + exception.getMessage().contains(type) + exception.getErrorCode() == ChattingErrorCode.INVALID_MESSAGE_TYPE + } + + // ==================== 참여자(Member) 관련 예외 Tests ==================== + + def "notRoomMember: userId와 roomId 포함"() { + when: + def exception = ChattingException.notRoomMember("user123", "room456") + + then: + exception.getMessage().contains("user123") + exception.getMessage().contains("room456") + exception.getErrorCode() == ChattingErrorCode.NOT_ROOM_MEMBER + } + + def "alreadyJoined: userId와 roomId 포함"() { + when: + def exception = ChattingException.alreadyJoined("user123", "room456") + + then: + exception.getMessage().contains("user123") + exception.getMessage().contains("room456") + exception.getMessage().contains("이미 참여") + exception.getErrorCode() == ChattingErrorCode.ALREADY_JOINED + } + + def "invalidRoomToken: 기본 메시지"() { + when: + def exception = ChattingException.invalidRoomToken() + + then: + exception.getErrorCode() == ChattingErrorCode.INVALID_ROOM_TOKEN + } + + def "invalidRoomToken: 커스텀 메시지"() { + given: + def reason = "토큰이 만료되었습니다" + + when: + def exception = ChattingException.invalidRoomToken(reason) + + then: + exception.getMessage() == reason + } + + // ==================== 채팅 레벨 관련 예외 Tests ==================== + + def "invalidChatLevel: level 포함"() { + given: + def level = "EXPERT" + + when: + def exception = ChattingException.invalidChatLevel(level) + + then: + exception.getMessage().contains(level) + exception.getErrorCode() == ChattingErrorCode.INVALID_CHAT_LEVEL + } + + // ==================== 연결 관련 예외 Tests ==================== + + def "connectionFailed: cause 포함"() { + given: + def cause = new RuntimeException("Connection refused") + + when: + def exception = ChattingException.connectionFailed(cause) + + then: + exception.getCause() == cause + exception.getErrorCode() == ChattingErrorCode.CONNECTION_FAILED + } + + def "connectionTimeout: connectionId 포함"() { + given: + def connectionId = "conn123" + + when: + def exception = ChattingException.connectionTimeout(connectionId) + + then: + exception.getMessage().contains(connectionId) + exception.getMessage().contains("시간") + exception.getErrorCode() == ChattingErrorCode.CONNECTION_TIMEOUT + } +} From a6327d380d410e259ae771bc44381b3962e2b235 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 15:20:07 +0900 Subject: [PATCH 347/528] =?UTF-8?q?fix:=20OPIc=20FeedbackResponse.java=20?= =?UTF-8?q?=EB=AC=B8=EB=B2=95=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../serverless/domain/opic/dto/response/FeedbackResponse.java | 1 - 1 file changed, 1 deletion(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/FeedbackResponse.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/FeedbackResponse.java index 42e1df7a..1e612668 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/FeedbackResponse.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/FeedbackResponse.java @@ -11,4 +11,3 @@ public static FeedbackResponse perfect(String answer, String sampleAnswer) { return new FeedbackResponse(List.of(), answer, sampleAnswer); } } -가 \ No newline at end of file From a0075fa10eeda05917d299679eb8ba611c6f1421 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 15:25:19 +0900 Subject: [PATCH 348/528] =?UTF-8?q?style:=20AwsClients=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=ED=8F=AC=EB=A7=B7=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../serverless/common/config/AwsClients.java | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/AwsClients.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/AwsClients.java index c5678ecb..05ad609a 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/AwsClients.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/AwsClients.java @@ -19,11 +19,11 @@ * X-Ray TracingInterceptor 적용으로 다운스트림 서비스 추적 */ public final class AwsClients { - + private static final ClientOverrideConfiguration XRAY_CONFIG = ClientOverrideConfiguration.builder() .addExecutionInterceptor(new TracingInterceptor()) .build(); - + // DynamoDB private static final DynamoDbClient DYNAMO_DB_CLIENT = DynamoDbClient.builder() .overrideConfiguration(XRAY_CONFIG) @@ -55,7 +55,7 @@ public final class AwsClients { private static final ComprehendClient COMPREHEND_CLIENT = ComprehendClient.builder() .overrideConfiguration(XRAY_CONFIG) .build(); - + // SSM (Parameter Store) private static final SsmClient SSM_CLIENT = SsmClient.builder() .overrideConfiguration(XRAY_CONFIG) @@ -100,6 +100,8 @@ public static BedrockRuntimeAsyncClient bedrockAsync() { public static ComprehendClient comprehend() { return COMPREHEND_CLIENT; } - - public static SsmClient ssm() { return SSM_CLIENT; } + + public static SsmClient ssm() { + return SSM_CLIENT; + } } From 7fb673fcd308da1063cd15e78203aa5a5670797d Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 15:25:20 +0900 Subject: [PATCH 349/528] =?UTF-8?q?style:=20EnvConfig=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=20=ED=8F=AC=EB=A7=B7=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../serverless/common/config/EnvConfig.java | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/EnvConfig.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/EnvConfig.java index bd8f6405..c59f4930 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/EnvConfig.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/EnvConfig.java @@ -9,13 +9,13 @@ * - Lambda 시작 시 설정 오류를 빠르게 감지 */ public final class EnvConfig { - + private static final Logger logger = LoggerFactory.getLogger(EnvConfig.class); - + private EnvConfig() { // 유틸리티 클래스 - 인스턴스화 방지 } - + /** * 필수 환경 변수를 가져옵니다. * 환경 변수가 설정되지 않았거나 빈 문자열인 경우 IllegalStateException을 발생시킵니다. @@ -33,12 +33,12 @@ public static String getRequired(String name) { } return value; } - + /** * 선택적 환경 변수를 가져옵니다. * 환경 변수가 설정되지 않은 경우 기본값을 반환합니다. * - * @param name 환경 변수 이름 + * @param name 환경 변수 이름 * @param defaultValue 기본값 * @return 환경 변수 값 또는 기본값 */ @@ -50,12 +50,12 @@ public static String getOrDefault(String name, String defaultValue) { } return value; } - + /** * 선택적 환경 변수를 정수로 가져옵니다. * 환경 변수가 설정되지 않거나 파싱에 실패한 경우 기본값을 반환합니다. * - * @param name 환경 변수 이름 + * @param name 환경 변수 이름 * @param defaultValue 기본값 * @return 환경 변수 값 또는 기본값 */ @@ -71,11 +71,11 @@ public static int getIntOrDefault(String name, int defaultValue) { return defaultValue; } } - + /** * 선택적 환경 변수를 long으로 가져옵니다. * - * @param name 환경 변수 이름 + * @param name 환경 변수 이름 * @param defaultValue 기본값 * @return 환경 변수 값 또는 기본값 */ From 36a747a47c6faad6058cf75a6d5122d5c03204ed Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 15:25:20 +0900 Subject: [PATCH 350/528] =?UTF-8?q?style:=20RoomTokenConfig=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=ED=8F=AC=EB=A7=B7=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../serverless/common/config/RoomTokenConfig.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/RoomTokenConfig.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/RoomTokenConfig.java index fdbf4b37..63496b48 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/RoomTokenConfig.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/RoomTokenConfig.java @@ -5,18 +5,18 @@ * Lambda 환경변수에서 값을 읽어오며, 없을 경우 기본값 사용 */ public final class RoomTokenConfig { - + // 환경변수 키 private static final String ENV_TOKEN_TTL_SECONDS = "ROOM_TOKEN_TTL_SECONDS"; // 기본값: 5분 private static final long DEFAULT_TOKEN_TTL_SECONDS = 300L; // 캐시된 값 (Cold Start 최적화) private static final long TOKEN_TTL_SECONDS = EnvConfig.getLongOrDefault(ENV_TOKEN_TTL_SECONDS, DEFAULT_TOKEN_TTL_SECONDS); - + private RoomTokenConfig() { // 인스턴스화 방지 } - + /** * RoomToken TTL (초) */ From 9b7a7c2679b89d01a2a6cf0f6886179a49d65fbd Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 15:25:20 +0900 Subject: [PATCH 351/528] =?UTF-8?q?style:=20WebSocketConfig=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=ED=8F=AC=EB=A7=B7=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../serverless/common/config/WebSocketConfig.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/WebSocketConfig.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/WebSocketConfig.java index 2660fd48..9872654c 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/WebSocketConfig.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/WebSocketConfig.java @@ -5,7 +5,7 @@ * Lambda 환경변수에서 값을 읽어오며, 없을 경우 기본값 사용 */ public final class WebSocketConfig { - + // 환경변수 키 private static final String ENV_CONNECTION_TTL_SECONDS = "WEBSOCKET_CONNECTION_TTL_SECONDS"; private static final String ENV_WEBSOCKET_ENDPOINT = "WEBSOCKET_ENDPOINT"; @@ -14,18 +14,18 @@ public final class WebSocketConfig { // 캐시된 값 (Cold Start 최적화) private static final long CONNECTION_TTL_SECONDS = EnvConfig.getLongOrDefault(ENV_CONNECTION_TTL_SECONDS, DEFAULT_CONNECTION_TTL_SECONDS); private static final String WEBSOCKET_ENDPOINT = EnvConfig.getRequired(ENV_WEBSOCKET_ENDPOINT); - + private WebSocketConfig() { // 인스턴스화 방지 } - + /** * WebSocket 연결 TTL (초) */ public static long connectionTtlSeconds() { return CONNECTION_TTL_SECONDS; } - + /** * WebSocket API Gateway 엔드포인트 URL * 메시지 브로드캐스트 시 사용 From 49b4f823510b44668982cc7b279a11622262e00e Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 15:26:27 +0900 Subject: [PATCH 352/528] =?UTF-8?q?style:=20JsonUtil=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=20=ED=8F=AC=EB=A7=B7=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../serverless/common/util/JsonUtil.java | 53 +- docs/FRONTEND-DEVELOPMENT-GUIDE.md | 3904 ----------------- 2 files changed, 27 insertions(+), 3930 deletions(-) delete mode 100644 docs/FRONTEND-DEVELOPMENT-GUIDE.md diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/JsonUtil.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/JsonUtil.java index 4cf4b544..94685020 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/JsonUtil.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/JsonUtil.java @@ -10,30 +10,31 @@ * JSON 파싱 관련 공통 유틸리티 */ public class JsonUtil { - - private JsonUtil() {} - - // 응답에서 JSON 부분만 추출 - public static String extractJson(String response) { - if (response == null || response.isBlank()) { - return null; - } - int start = response.indexOf('{'); - int end = response.lastIndexOf('}'); - if (start != -1 && end != -1 && end > start) { - return response.substring(start, end + 1); - } - return response; - } - - // JsonArray → List 변환 - public static List toStringList(JsonArray array) { - List result = new ArrayList<>(); - if (array != null) { - for (JsonElement el : array) { - result.add(el.getAsString()); - } - } - return result; - } + + private JsonUtil() { + } + + // 응답에서 JSON 부분만 추출 + public static String extractJson(String response) { + if (response == null || response.isBlank()) { + return null; + } + int start = response.indexOf('{'); + int end = response.lastIndexOf('}'); + if (start != -1 && end != -1 && end > start) { + return response.substring(start, end + 1); + } + return response; + } + + // JsonArray → List 변환 + public static List toStringList(JsonArray array) { + List result = new ArrayList<>(); + if (array != null) { + for (JsonElement el : array) { + result.add(el.getAsString()); + } + } + return result; + } } diff --git a/docs/FRONTEND-DEVELOPMENT-GUIDE.md b/docs/FRONTEND-DEVELOPMENT-GUIDE.md deleted file mode 100644 index 297fd387..00000000 --- a/docs/FRONTEND-DEVELOPMENT-GUIDE.md +++ /dev/null @@ -1,3904 +0,0 @@ -# 프론트엔드 개발 가이드 - -영어 학습 플랫폼 백엔드 API 통합을 위한 프론트엔드 개발 가이드입니다. - -**목차** -1. [개요](#개요) -2. [인증](#인증) -3. [API 공통 규칙](#api-공통-규칙) -4. [TypeScript 인터페이스](#typescript-인터페이스) -5. [API 엔드포인트](#api-엔드포인트) -6. [에러 코드 및 처리](#에러-코드-및-처리) -7. [WebSocket 연동 가이드](#websocket-연동-가이드) - ---- - -## 개요 - -### API 기본 정보 - -영어 학습 플랫폼은 AWS Serverless 아키텍처를 기반으로 한 백엔드 API를 제공합니다. - -**주요 기능:** -- 단어 학습 및 관리 (Spaced Repetition 알고리즘) -- 단어 테스트 및 통계 -- 실시간 채팅 및 게임 -- AI 기반 문법 검사 및 대화 -- 뱃지 시스템 - -**API Base URL:** -``` -Production: https://{api-id}.execute-api.{region}.amazonaws.com/prod -Development: https://{api-id}.execute-api.{region}.amazonaws.com/dev -``` - -### 기술 스택 - -- **Backend**: Java 21, Spring Cloud Functions (AWS Lambda) -- **Database**: DynamoDB -- **Authentication**: AWS Cognito (JWT) -- **Real-time**: API Gateway WebSocket -- **AI**: AWS Bedrock (Claude) -- **Voice**: AWS Polly - ---- - -## 인증 - -### Cognito JWT 인증 흐름 - -#### 1. 회원가입 및 로그인 - -AWS Cognito User Pool을 사용합니다. - -```typescript -// 예제: Cognito 초기화 (AWS Amplify 또는 aws-amplify) -import { Auth } from 'aws-amplify'; - -// Amplify 설정 -Auth.configure({ - region: 'ap-northeast-2', - userPoolId: '{USER_POOL_ID}', - userPoolWebClientId: '{CLIENT_ID}' -}); - -// 회원가입 -async function signUp(email: string, password: string, name: string) { - try { - const response = await Auth.signUp({ - username: email, - password: password, - attributes: { - email: email, - name: name - } - }); - console.log('회원가입 성공:', response); - } catch (error) { - console.error('회원가입 실패:', error); - } -} - -// 로그인 -async function signIn(email: string, password: string) { - try { - const user = await Auth.signIn(email, password); - console.log('로그인 성공:', user); - return user; - } catch (error) { - console.error('로그인 실패:', error); - } -} -``` - -#### 2. 토큰 획득 및 관리 - -로그인 후 JWT 토큰을 획득하여 API 요청에 사용합니다. - -```typescript -// JWT 토큰 획득 -async function getAccessToken(): Promise { - try { - const session = await Auth.currentSession(); - const accessToken = session.getAccessToken().getJwtToken(); - return accessToken; - } catch (error) { - console.error('토큰 획득 실패:', error); - throw error; - } -} - -// 토큰 갱신 -async function refreshToken() { - try { - const session = await Auth.currentSession(); - // 토큰이 만료되었을 경우 자동으로 갱신됨 - const idToken = session.getIdToken().getJwtToken(); - return idToken; - } catch (error) { - console.error('토큰 갱신 실패:', error); - } -} -``` - -#### 3. API 요청에 토큰 포함 - -모든 인증이 필요한 API 요청에는 Authorization 헤더에 JWT 토큰을 포함합니다. - -```typescript -// Fetch API를 사용한 인증 요청 -async function authenticatedFetch( - url: string, - options: RequestInit = {} -): Promise { - const token = await getAccessToken(); - - return fetch(url, { - ...options, - headers: { - ...options.headers, - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json' - } - }); -} - -// 사용 예제 -async function getUserWords() { - try { - const response = await authenticatedFetch( - 'https://api-id.execute-api.region.amazonaws.com/dev/vocab/user-words' - ); - const data = await response.json(); - console.log('사용자 단어:', data); - } catch (error) { - console.error('요청 실패:', error); - } -} -``` - -#### 4. Axios를 사용한 예제 - -```typescript -import axios from 'axios'; - -// Axios 인스턴스 생성 -const apiClient = axios.create({ - baseURL: 'https://api-id.execute-api.region.amazonaws.com/dev' -}); - -// 인터셉터로 토큰 자동 추가 -apiClient.interceptors.request.use( - async (config) => { - const token = await getAccessToken(); - config.headers.Authorization = `Bearer ${token}`; - return config; - }, - (error) => { - return Promise.reject(error); - } -); - -// 응답 인터셉터: 토큰 만료 시 갱신 -apiClient.interceptors.response.use( - (response) => response, - async (error) => { - if (error.response?.status === 401) { - try { - await refreshToken(); - // 원래 요청 재시도 - return apiClient(error.config); - } catch (refreshError) { - // 로그인 페이지로 리다이렉트 - window.location.href = '/login'; - return Promise.reject(refreshError); - } - } - return Promise.reject(error); - } -); - -// 사용 예제 -async function fetchUserWords() { - try { - const response = await apiClient.get('/vocab/user-words'); - console.log('사용자 단어:', response.data); - } catch (error) { - console.error('요청 실패:', error); - } -} -``` - ---- - -## API 공통 규칙 - -### 표준 응답 형식 - -모든 API 응답은 다음과 같은 표준 형식을 따릅니다. - -```typescript -interface ApiResponse { - isSuccess: boolean; // 성공 여부 - message: string | null; // 응답 메시지 (선택사항) - data: T | null; // 응답 데이터 - error: string | null; // 에러 메시지 (실패 시) -} -``` - -**성공 응답 예제:** - -```json -{ - "isSuccess": true, - "message": "단어 조회 성공", - "data": { - "wordId": "word_001", - "english": "hello", - "korean": "안녕하세요", - "level": "BEGINNER" - }, - "error": null -} -``` - -**실패 응답 예제:** - -```json -{ - "isSuccess": false, - "message": null, - "data": null, - "error": "필수 필드가 누락되었습니다" -} -``` - -### 페이지네이션 - -목록 조회 API는 커서 기반의 페이지네이션을 지원합니다. - -```typescript -interface PaginatedResponse { - items: T[]; - nextCursor?: string; - hasMore: boolean; -} -``` - -**페이지네이션 요청 예제:** - -```typescript -// 첫 번째 페이지 -const firstPage = await apiClient.get('/vocab/words', { - params: { - limit: 20, - level: 'BEGINNER', - category: 'DAILY' - } -}); - -// 다음 페이지 (cursor 사용) -if (firstPage.data.data.hasMore) { - const nextPage = await apiClient.get('/vocab/words', { - params: { - limit: 20, - cursor: firstPage.data.data.nextCursor, - level: 'BEGINNER', - category: 'DAILY' - } - }); -} -``` - -### 공통 쿼리 파라미터 - -| 파라미터 | 타입 | 필수 | 설명 | -|---------|------|------|------| -| limit | number | N | 반환할 아이템 개수 (기본값: 20, 최대: 100) | -| cursor | string | N | 커서 기반 페이지네이션 커서 | -| sortBy | string | N | 정렬 기준 (기본값: 'createdAt') | -| sortOrder | string | N | 정렬 순서 ('ASC' 또는 'DESC', 기본값: 'DESC') | - ---- - -## TypeScript 인터페이스 - -### 공통 인터페이스 - -```typescript -// API 응답 래퍼 -interface ApiResponse { - isSuccess: boolean; - message: string | null; - data: T | null; - error: string | null; -} - -// 페이지네이션 응답 -interface PaginatedResponse { - items: T[]; - nextCursor?: string; - hasMore: boolean; -} - -// 사용자 정보 -interface User { - userId: string; - email: string; - name: string; - level: StudyLevel; - createdAt: string; - updatedAt: string; -} -``` - -### 단어 관련 인터페이스 - -```typescript -// 단어 레벨 enum -type WordLevel = 'BEGINNER' | 'INTERMEDIATE' | 'ADVANCED'; - -// 단어 카테고리 enum -type WordCategory = 'DAILY' | 'IDIOM' | 'PHRASAL_VERB' | 'BUSINESS' | 'ACADEMIC'; - -// 단어 상태 enum -type WordStatus = 'NEW' | 'LEARNING' | 'REVIEWING' | 'MASTERED'; - -// 사용자 지정 난이도 enum -type Difficulty = 'EASY' | 'NORMAL' | 'HARD'; - -// 단어 정보 -interface Word { - wordId: string; - english: string; - korean: string; - example?: string; - level: WordLevel; - category: WordCategory; - createdAt: string; -} - -// 단어 생성 요청 -interface CreateWordRequest { - english: string; - korean: string; - example?: string; - level?: WordLevel; - category?: WordCategory; -} - -// 단어 수정 요청 -interface UpdateWordRequest { - english?: string; - korean?: string; - example?: string; - level?: WordLevel; - category?: WordCategory; -} - -// 단어 일괄 생성 요청 -interface CreateWordsBatchRequest { - words: CreateWordRequest[]; -} - -// 단어 일괄 조회 요청 -interface BatchGetWordsRequest { - wordIds: string[]; -} - -// 사용자 단어 (학습 상태 포함) -interface UserWord { - wordId: string; - userId: string; - status: WordStatus; - interval: number; // 복습 간격 (일) - easeFactor: number; // 난이도 계수 - repetitions: number; // 연속 정답 횟수 - nextReviewAt: string; // 다음 복습 예정일 - lastReviewedAt?: string; // 마지막 복습일 - correctCount: number; // 정답 횟수 - incorrectCount: number; // 오답 횟수 - bookmarked: boolean; // 북마크 여부 - favorite: boolean; // 즐겨찾기 여부 - difficulty?: Difficulty; // 사용자 지정 난이도 - createdAt: string; - updatedAt: string; -} - -// 사용자 단어 업데이트 요청 -interface UpdateUserWordRequest { - isCorrect: boolean; // 정답 여부 -} - -// 사용자 단어 태그 업데이트 요청 -interface UpdateUserWordTagRequest { - bookmarked?: boolean; - favorite?: boolean; - difficulty?: Difficulty; -} - -// 사용자 단어 상태 업데이트 요청 -interface UpdateUserWordStatusRequest { - status: WordStatus; -} - -// 단어 그룹 -interface WordGroup { - groupId: string; - userId: string; - name: string; - description?: string; - wordIds: string[]; - createdAt: string; - updatedAt: string; -} - -// 단어 그룹 생성 요청 -interface CreateWordGroupRequest { - name: string; - description?: string; - wordIds?: string[]; -} - -// 단어 그룹 수정 요청 -interface UpdateWordGroupRequest { - name?: string; - description?: string; -} -``` - -### 테스트 관련 인터페이스 - -```typescript -// 테스트 타입 enum -type TestType = 'DAILY' | 'CUSTOM' | 'QUICK'; - -// 테스트 시작 요청 -interface StartTestRequest { - testType?: TestType; -} - -// 테스트 응답 -interface StartTestResponse { - testId: string; - words: Word[]; - startedAt: string; -} - -// 테스트 답안 -interface TestAnswer { - wordId: string; - answer?: string; // 빈 값 허용 (오답 처리) -} - -// 테스트 제출 요청 -interface SubmitTestRequest { - testId: string; - testType?: TestType; - answers: TestAnswer[]; - startedAt: string; -} - -// 테스트 결과 -interface TestResult { - testId: string; - userId: string; - testType: TestType; - totalQuestions: number; - correctAnswers: number; - correctRate: number; // 0 ~ 100 - spentTime: number; // 밀리초 - completedAt: string; -} - -// 테스트된 단어 -interface TestedWord { - wordId: string; - english: string; - korean: string; - isCorrect: boolean; - userAnswer?: string; -} - -// 통계 -interface Statistics { - totalWords: number; - masteredCount: number; - learningCount: number; - reviewingCount: number; - newCount: number; - correctRate: number; - totalTestsTaken: number; - averageSpentTime: number; -} - -// 일별 통계 -interface DailyStatistics { - date: string; - wordsLearned: number; - testsTaken: number; - correctRate: number; - spentTime: number; -} - -// 취약점 분석 -interface WeaknessAnalysis { - wordId: string; - english: string; - korean: string; - incorrectCount: number; - level: WordLevel; -} -``` - -### 채팅 관련 인터페이스 - -```typescript -// 채팅 레벨 enum -type ChatLevel = 'beginner' | 'intermediate' | 'advanced'; - -// 메시지 타입 enum -type MessageType = 'TEXT' | 'IMAGE' | 'VOICE' | 'GAME_MOVE'; - -// 채팅 방 -interface ChatRoom { - roomId: string; - name: string; - description?: string; - level: ChatLevel; - maxMembers: number; - currentMembers: number; - isPrivate: boolean; - createdBy: string; - createdAt: string; -} - -// 채팅 방 생성 요청 -interface CreateRoomRequest { - name: string; - description?: string; - level?: ChatLevel; - maxMembers?: number; - isPrivate?: boolean; - password?: string; -} - -// 방 참여 요청 -interface JoinRoomRequest { - roomId: string; - password?: string; -} - -// 방 참여 응답 -interface JoinRoomResponse { - roomId: string; - roomToken: string; - name: string; - level: ChatLevel; - members: { - userId: string; - name: string; - }[]; -} - -// 방 나가기 요청 -interface LeaveRoomRequest { - roomId: string; -} - -// 채팅 메시지 -interface ChatMessage { - messageId: string; - roomId: string; - userId: string; - userName: string; - content: string; - messageType: MessageType; - createdAt: string; -} - -// 메시지 전송 요청 -interface SendMessageRequest { - roomId: string; - content: string; - messageType?: MessageType; -} - -// 음성 합성 요청 (채팅) -interface VoiceSynthesisRequest { - text: string; - voiceId?: string; -} - -// 게임 상태 -interface GameStatusResponse { - roomId: string; - isActive: boolean; - currentPlayer?: string; - gameData?: { - wordId: string; - hint?: string; - }; -} - -// 점수판 -interface ScoreboardResponse { - roomId: string; - scores: { - userId: string; - userName: string; - score: number; - }[]; -} -``` - -### 문법 관련 인터페이스 - -```typescript -// 문법 검사 요청 -interface GrammarCheckRequest { - sentence: string; - level?: WordLevel; -} - -// 문법 검사 응답 -interface GrammarCheckResponse { - originalSentence: string; - correctedSentence: string; - score: number; // 0-100 점수 - isCorrect: boolean; - errors: GrammarError[]; - feedback: string; // 전체 피드백 메시지 -} - -// 문법 오류 -interface GrammarError { - type: GrammarErrorType; // 오류 타입 - original: string; // 원본 텍스트 - corrected: string; // 수정된 텍스트 - explanation: string; // 오류 설명 (레벨별 언어/상세도 다름) - startIndex?: number; // 오류 시작 위치 (optional) - endIndex?: number; // 오류 끝 위치 (optional) -} - -// 문법 오류 타입 -type GrammarErrorType = - | 'VERB_TENSE' - | 'SUBJECT_VERB_AGREEMENT' - | 'ARTICLE' - | 'PREPOSITION' - | 'WORD_ORDER' - | 'PLURAL_SINGULAR' - | 'PRONOUN' - | 'SPELLING' - | 'PUNCTUATION' - | 'WORD_CHOICE' - | 'SENTENCE_STRUCTURE' - | 'OTHER'; - -// AI 대화 요청 -interface ConversationRequest { - message: string; - sessionId?: string; - level?: GrammarLevel; // BEGINNER | INTERMEDIATE | ADVANCED -} - -// AI 대화 응답 -interface ConversationResponse { - sessionId: string; - grammarCheck: GrammarCheckResponse; // 문법 검사 결과 - aiResponse: string; // AI 대화 응답 - conversationTip: string; // 학습 팁 (선택적으로 표시) -} - -// 문법 레벨 -type GrammarLevel = 'BEGINNER' | 'INTERMEDIATE' | 'ADVANCED'; - -// 세션 -interface GrammarSession { - sessionId: string; - userId: string; - startedAt: string; - updatedAt: string; - messageCount: number; -} -``` - -### 뱃지 관련 인터페이스 - -```typescript -// 뱃지 -interface Badge { - badgeId: string; - name: string; - description: string; - imageUrl: string; - condition: string; - isEarned: boolean; - earnedAt?: string; -} - -// 사용자 뱃지 -interface UserBadge { - badgeId: string; - userId: string; - earnedAt: string; -} -``` - -### 통계 관련 인터페이스 - -```typescript -// 일간 통계 -interface DailyStats { - date: string; - wordsLearned: number; - testsTaken: number; - correctRate: number; - spentTime: number; -} - -// 주간 통계 -interface WeeklyStats { - startDate: string; - endDate: string; - totalWordsLearned: number; - totalTestsTaken: number; - averageCorrectRate: number; - totalSpentTime: number; - dailyStats: DailyStats[]; -} - -// 월간 통계 -interface MonthlyStats { - month: string; - totalWordsLearned: number; - totalTestsTaken: number; - averageCorrectRate: number; - totalSpentTime: number; -} - -// 전체 통계 -interface TotalStats { - totalWordsLearned: number; - masteredCount: number; - learningCount: number; - totalTestsTaken: number; - averageCorrectRate: number; - totalSpentTime: number; - streak: number; -} -``` - ---- - -## API 엔드포인트 - -### 1. 단어 관리 (Vocabulary) - -#### 1.1 단어 CRUD - -##### POST /vocab/words - 단어 추가 - -단일 단어를 추가합니다. 공개 API입니다. - -**인증:** 불필요 - -**요청:** - -```typescript -const createWordRequest: CreateWordRequest = { - english: "hello", - korean: "안녕하세요", - example: "Hello, my name is John.", - level: "BEGINNER", - category: "DAILY" -}; - -const response = await fetch( - 'https://api-id.execute-api.region.amazonaws.com/dev/vocab/words', - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(createWordRequest) - } -); - -const result: ApiResponse = await response.json(); -``` - -**응답:** - -```json -{ - "isSuccess": true, - "message": "단어가 등록되었습니다", - "data": { - "wordId": "word_001", - "english": "hello", - "korean": "안녕하세요", - "example": "Hello, my name is John.", - "level": "BEGINNER", - "category": "DAILY", - "createdAt": "2024-01-14T10:00:00Z" - }, - "error": null -} -``` - -**에러 케이스:** - -```json -{ - "isSuccess": false, - "message": null, - "data": null, - "error": "필수 필드가 누락되었습니다" -} -``` - ---- - -##### POST /vocab/words/batch - 단어 일괄 추가 - -최대 100개의 단어를 한번에 추가합니다. 공개 API입니다. - -**인증:** 불필요 - -**요청:** - -```typescript -const batchRequest: CreateWordsBatchRequest = { - words: [ - { - english: "hello", - korean: "안녕하세요", - level: "BEGINNER", - category: "DAILY" - }, - { - english: "goodbye", - korean: "안녕히 가세요", - level: "BEGINNER", - category: "DAILY" - } - ] -}; - -const response = await fetch( - 'https://api-id.execute-api.region.amazonaws.com/dev/vocab/words/batch', - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(batchRequest) - } -); - -const result: ApiResponse = await response.json(); -``` - -**응답:** - -```json -{ - "isSuccess": true, - "message": "2개의 단어가 등록되었습니다", - "data": [ - { - "wordId": "word_001", - "english": "hello", - "korean": "안녕하세요", - "level": "BEGINNER", - "category": "DAILY", - "createdAt": "2024-01-14T10:00:00Z" - }, - { - "wordId": "word_002", - "english": "goodbye", - "korean": "안녕히 가세요", - "level": "BEGINNER", - "category": "DAILY", - "createdAt": "2024-01-14T10:00:01Z" - } - ], - "error": null -} -``` - ---- - -##### POST /vocab/words/batch/get - 단어 일괄 조회 - -최대 100개의 단어를 일괄 조회합니다. 공개 API입니다. - -**인증:** 불필요 - -**요청:** - -```typescript -const batchGetRequest: BatchGetWordsRequest = { - wordIds: ["word_001", "word_002", "word_003"] -}; - -const response = await fetch( - 'https://api-id.execute-api.region.amazonaws.com/dev/vocab/words/batch/get', - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(batchGetRequest) - } -); - -const result: ApiResponse = await response.json(); -``` - -**응답:** - -```json -{ - "isSuccess": true, - "message": "3개의 단어를 조회했습니다", - "data": [ - { - "wordId": "word_001", - "english": "hello", - "korean": "안녕하세요", - "level": "BEGINNER", - "category": "DAILY", - "createdAt": "2024-01-14T10:00:00Z" - } - ], - "error": null -} -``` - ---- - -##### GET /vocab/words - 단어 목록 조회 - -페이지네이션과 필터를 지원합니다. 공개 API입니다. - -**인증:** 불필요 - -**쿼리 파라미터:** - -| 파라미터 | 타입 | 필수 | 설명 | -|---------|------|------|------| -| limit | number | N | 반환할 아이템 개수 (기본값: 20) | -| cursor | string | N | 페이지네이션 커서 | -| level | string | N | 단어 레벨 (BEGINNER, INTERMEDIATE, ADVANCED) | -| category | string | N | 단어 카테고리 (DAILY, IDIOM, etc.) | -| sortBy | string | N | 정렬 기준 (createdAt, english) | -| sortOrder | string | N | 정렬 순서 (ASC, DESC) | - -**요청:** - -```typescript -// Fetch API -const response = await fetch( - 'https://api-id.execute-api.region.amazonaws.com/dev/vocab/words?limit=20&level=BEGINNER&category=DAILY' -); - -const result: ApiResponse> = await response.json(); - -// Axios -const result = await apiClient.get('/vocab/words', { - params: { - limit: 20, - level: 'BEGINNER', - category: 'DAILY' - } -}); -``` - -**응답:** - -```json -{ - "isSuccess": true, - "message": "20개의 단어를 조회했습니다", - "data": { - "items": [ - { - "wordId": "word_001", - "english": "hello", - "korean": "안녕하세요", - "example": "Hello, my name is John.", - "level": "BEGINNER", - "category": "DAILY", - "createdAt": "2024-01-14T10:00:00Z" - } - ], - "nextCursor": "cursor_abc123", - "hasMore": true - }, - "error": null -} -``` - ---- - -##### GET /vocab/words/search - 단어 검색 - -단어를 검색합니다. 공개 API입니다. - -**인증:** 불필요 - -**쿼리 파라미터:** - -| 파라미터 | 타입 | 필수 | 설명 | -|---------|------|------|------| -| q | string | Y | 검색어 | -| limit | number | N | 반환할 아이템 개수 (기본값: 20) | - -**요청:** - -```typescript -const response = await fetch( - 'https://api-id.execute-api.region.amazonaws.com/dev/vocab/words/search?q=hello&limit=10' -); - -const result: ApiResponse = await response.json(); -``` - -**응답:** - -```json -{ - "isSuccess": true, - "message": "5개의 단어를 검색했습니다", - "data": [ - { - "wordId": "word_001", - "english": "hello", - "korean": "안녕하세요", - "level": "BEGINNER", - "category": "DAILY", - "createdAt": "2024-01-14T10:00:00Z" - } - ], - "error": null -} -``` - ---- - -##### GET /vocab/words/{wordId} - 단어 상세 조회 - -특정 단어의 상세 정보를 조회합니다. 공개 API입니다. - -**인증:** 불필요 - -**요청:** - -```typescript -const wordId = "word_001"; -const response = await authenticatedFetch( - `https://api-id.execute-api.region.amazonaws.com/dev/vocab/words/${wordId}` -); - -const result: ApiResponse = await response.json(); -``` - -**응답:** - -```json -{ - "isSuccess": true, - "message": "단어 조회 성공", - "data": { - "wordId": "word_001", - "english": "hello", - "korean": "안녕하세요", - "example": "Hello, my name is John.", - "level": "BEGINNER", - "category": "DAILY", - "createdAt": "2024-01-14T10:00:00Z" - }, - "error": null -} -``` - ---- - -##### PUT /vocab/words/{wordId} - 단어 수정 - -단어 정보를 수정합니다. 공개 API입니다. - -**인증:** 불필요 - -**요청:** - -```typescript -const wordId = "word_001"; -const updateRequest: UpdateWordRequest = { - korean: "안녕하세요 (인사)", - example: "Updated example sentence." -}; - -const response = await fetch( - `https://api-id.execute-api.region.amazonaws.com/dev/vocab/words/${wordId}`, - { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(updateRequest) - } -); - -const result: ApiResponse = await response.json(); -``` - -**응답:** - -```json -{ - "isSuccess": true, - "message": "단어가 수정되었습니다", - "data": { - "wordId": "word_001", - "english": "hello", - "korean": "안녕하세요 (인사)", - "example": "Updated example sentence.", - "level": "BEGINNER", - "category": "DAILY", - "createdAt": "2024-01-14T10:00:00Z" - }, - "error": null -} -``` - ---- - -##### DELETE /vocab/words/{wordId} - 단어 삭제 - -단어를 삭제합니다. 공개 API입니다. - -**인증:** 불필요 - -**요청:** - -```typescript -const wordId = "word_001"; -const response = await fetch( - `https://api-id.execute-api.region.amazonaws.com/dev/vocab/words/${wordId}`, - { method: 'DELETE' } -); - -const result: ApiResponse = await response.json(); -``` - -**응답:** - -```json -{ - "isSuccess": true, - "message": "단어가 삭제되었습니다", - "data": null, - "error": null -} -``` - ---- - -#### 1.2 사용자 단어 관리 (인증 필요) - -##### GET /vocab/user-words - 사용자 학습 진행도 - -사용자의 단어 학습 진행도를 조회합니다. - -**인증:** 필수 (JWT) - -**쿼리 파라미터:** - -| 파라미터 | 타입 | 필수 | 설명 | -|---------|------|------|------| -| status | string | N | 상태 필터 (NEW, LEARNING, REVIEWING, MASTERED) | -| limit | number | N | 반환할 아이템 개수 (기본값: 20) | -| cursor | string | N | 페이지네이션 커서 | - -**요청:** - -```typescript -const response = await authenticatedFetch( - 'https://api-id.execute-api.region.amazonaws.com/dev/vocab/user-words?limit=20&status=LEARNING' -); - -const result: ApiResponse> = await response.json(); -``` - -**응답:** - -```json -{ - "isSuccess": true, - "message": "사용자 단어 조회 성공", - "data": { - "items": [ - { - "wordId": "word_001", - "userId": "user_123", - "status": "LEARNING", - "interval": 3, - "easeFactor": 2.5, - "repetitions": 2, - "nextReviewAt": "2024-01-17T10:00:00Z", - "correctCount": 5, - "incorrectCount": 2, - "bookmarked": false, - "favorite": true, - "createdAt": "2024-01-10T10:00:00Z", - "updatedAt": "2024-01-14T10:00:00Z" - } - ], - "nextCursor": "cursor_xyz789", - "hasMore": true - }, - "error": null -} -``` - ---- - -##### GET /vocab/user-words/{wordId} - 특정 단어 학습 상태 - -특정 단어의 사용자 학습 상태를 조회합니다. - -**인증:** 필수 (JWT) - -**요청:** - -```typescript -const wordId = "word_001"; -const response = await authenticatedFetch( - `https://api-id.execute-api.region.amazonaws.com/dev/vocab/user-words/${wordId}` -); - -const result: ApiResponse = await response.json(); -``` - -**응답:** - -```json -{ - "isSuccess": true, - "message": "단어 학습 상태 조회 성공", - "data": { - "wordId": "word_001", - "userId": "user_123", - "status": "LEARNING", - "interval": 3, - "easeFactor": 2.5, - "repetitions": 2, - "nextReviewAt": "2024-01-17T10:00:00Z", - "lastReviewedAt": "2024-01-14T10:00:00Z", - "correctCount": 5, - "incorrectCount": 2, - "bookmarked": false, - "favorite": true, - "difficulty": "NORMAL", - "createdAt": "2024-01-10T10:00:00Z", - "updatedAt": "2024-01-14T10:00:00Z" - }, - "error": null -} -``` - ---- - -##### PUT /vocab/user-words/{wordId} - 정오답 업데이트 - -단어 학습 진행도를 업데이트합니다. 정답/오답을 기록합니다. - -**인증:** 필수 (JWT) - -**요청:** - -```typescript -const wordId = "word_001"; -const updateRequest: UpdateUserWordRequest = { - isCorrect: true -}; - -const response = await authenticatedFetch( - `https://api-id.execute-api.region.amazonaws.com/dev/vocab/user-words/${wordId}`, - { - method: 'PUT', - body: JSON.stringify(updateRequest) - } -); - -const result: ApiResponse = await response.json(); -``` - -**응답:** - -```json -{ - "isSuccess": true, - "message": "단어 학습 상태가 업데이트되었습니다", - "data": { - "wordId": "word_001", - "userId": "user_123", - "status": "LEARNING", - "interval": 6, - "easeFactor": 2.6, - "repetitions": 3, - "nextReviewAt": "2024-01-20T10:00:00Z", - "correctCount": 6, - "incorrectCount": 2, - "bookmarked": false, - "favorite": true, - "createdAt": "2024-01-10T10:00:00Z", - "updatedAt": "2024-01-14T10:30:00Z" - }, - "error": null -} -``` - ---- - -##### PUT /vocab/user-words/{wordId}/tag - 태그 업데이트 - -북마크, 즐겨찾기, 난이도 등의 태그를 업데이트합니다. - -**인증:** 필수 (JWT) - -**요청:** - -```typescript -const wordId = "word_001"; -const updateTagRequest: UpdateUserWordTagRequest = { - bookmarked: true, - favorite: true, - difficulty: "HARD" -}; - -const response = await authenticatedFetch( - `https://api-id.execute-api.region.amazonaws.com/dev/vocab/user-words/${wordId}/tag`, - { - method: 'PUT', - body: JSON.stringify(updateTagRequest) - } -); - -const result: ApiResponse = await response.json(); -``` - -**응답:** - -```json -{ - "isSuccess": true, - "message": "태그가 업데이트되었습니다", - "data": { - "wordId": "word_001", - "userId": "user_123", - "status": "LEARNING", - "interval": 6, - "easeFactor": 2.6, - "repetitions": 3, - "nextReviewAt": "2024-01-20T10:00:00Z", - "correctCount": 6, - "incorrectCount": 2, - "bookmarked": true, - "favorite": true, - "difficulty": "HARD", - "createdAt": "2024-01-10T10:00:00Z", - "updatedAt": "2024-01-14T10:30:00Z" - }, - "error": null -} -``` - ---- - -##### PUT /vocab/user-words/{wordId}/status - 상태 변경 - -단어 학습 상태를 변경합니다 (NEW, LEARNING, REVIEWING, MASTERED). - -**인증:** 필수 (JWT) - -**요청:** - -```typescript -const wordId = "word_001"; -const statusRequest: UpdateUserWordStatusRequest = { - status: "MASTERED" -}; - -const response = await authenticatedFetch( - `https://api-id.execute-api.region.amazonaws.com/dev/vocab/user-words/${wordId}/status`, - { - method: 'PUT', - body: JSON.stringify(statusRequest) - } -); - -const result: ApiResponse = await response.json(); -``` - -**응답:** - -```json -{ - "isSuccess": true, - "message": "단어 상태가 변경되었습니다", - "data": { - "wordId": "word_001", - "userId": "user_123", - "status": "MASTERED", - "interval": 30, - "easeFactor": 2.6, - "repetitions": 10, - "nextReviewAt": "2024-02-13T10:00:00Z", - "correctCount": 15, - "incorrectCount": 2, - "bookmarked": true, - "favorite": true, - "createdAt": "2024-01-10T10:00:00Z", - "updatedAt": "2024-01-14T10:30:00Z" - }, - "error": null -} -``` - ---- - -##### GET /vocab/wrong-answers - 오답 목록 - -사용자의 오답 목록을 조회합니다. - -**인증:** 필수 (JWT) - -**쿼리 파라미터:** - -| 파라미터 | 타입 | 필수 | 설명 | -|---------|------|------|------| -| limit | number | N | 반환할 아이템 개수 (기본값: 20) | -| cursor | string | N | 페이지네이션 커서 | - -**요청:** - -```typescript -const response = await authenticatedFetch( - 'https://api-id.execute-api.region.amazonaws.com/dev/vocab/wrong-answers?limit=20' -); - -const result: ApiResponse> = await response.json(); -``` - -**응답:** - -```json -{ - "isSuccess": true, - "message": "오답 목록 조회 성공", - "data": { - "items": [ - { - "wordId": "word_005", - "userId": "user_123", - "status": "LEARNING", - "correctCount": 2, - "incorrectCount": 8, - "interval": 1, - "easeFactor": 1.3, - "repetitions": 0, - "nextReviewAt": "2024-01-15T10:00:00Z", - "createdAt": "2024-01-08T10:00:00Z", - "updatedAt": "2024-01-14T15:00:00Z" - } - ], - "nextCursor": "cursor_wrong123", - "hasMore": false - }, - "error": null -} -``` - ---- - -#### 1.3 단어 그룹 관리 (인증 필요) - -##### POST /vocab/groups - 그룹 생성 - -새로운 단어 그룹을 생성합니다. - -**인증:** 필수 (JWT) - -**요청:** - -```typescript -const createGroupRequest: CreateWordGroupRequest = { - name: "일상 회화", - description: "일상에서 자주 사용하는 표현들", - wordIds: ["word_001", "word_002", "word_003"] -}; - -const response = await authenticatedFetch( - 'https://api-id.execute-api.region.amazonaws.com/dev/vocab/groups', - { - method: 'POST', - body: JSON.stringify(createGroupRequest) - } -); - -const result: ApiResponse = await response.json(); -``` - -**응답:** - -```json -{ - "isSuccess": true, - "message": "단어 그룹이 생성되었습니다", - "data": { - "groupId": "group_001", - "userId": "user_123", - "name": "일상 회화", - "description": "일상에서 자주 사용하는 표현들", - "wordIds": ["word_001", "word_002", "word_003"], - "createdAt": "2024-01-14T10:00:00Z", - "updatedAt": "2024-01-14T10:00:00Z" - }, - "error": null -} -``` - ---- - -##### GET /vocab/groups - 그룹 목록 - -사용자의 단어 그룹 목록을 조회합니다. - -**인증:** 필수 (JWT) - -**쿼리 파라미터:** - -| 파라미터 | 타입 | 필수 | 설명 | -|---------|------|------|------| -| limit | number | N | 반환할 아이템 개수 (기본값: 20) | -| cursor | string | N | 페이지네이션 커서 | - -**요청:** - -```typescript -const response = await authenticatedFetch( - 'https://api-id.execute-api.region.amazonaws.com/dev/vocab/groups?limit=10' -); - -const result: ApiResponse> = await response.json(); -``` - -**응답:** - -```json -{ - "isSuccess": true, - "message": "그룹 목록 조회 성공", - "data": { - "items": [ - { - "groupId": "group_001", - "userId": "user_123", - "name": "일상 회화", - "description": "일상에서 자주 사용하는 표현들", - "wordIds": ["word_001", "word_002", "word_003"], - "createdAt": "2024-01-14T10:00:00Z", - "updatedAt": "2024-01-14T10:00:00Z" - } - ], - "nextCursor": "cursor_group123", - "hasMore": false - }, - "error": null -} -``` - ---- - -##### GET /vocab/groups/{groupId} - 그룹 상세 조회 - -그룹과 포함된 모든 단어를 조회합니다. - -**인증:** 필수 (JWT) - -**요청:** - -```typescript -const groupId = "group_001"; -const response = await authenticatedFetch( - `https://api-id.execute-api.region.amazonaws.com/dev/vocab/groups/${groupId}` -); - -const result: ApiResponse = await response.json(); -``` - -**응답:** - -```json -{ - "isSuccess": true, - "message": "그룹 상세 조회 성공", - "data": { - "groupId": "group_001", - "userId": "user_123", - "name": "일상 회화", - "description": "일상에서 자주 사용하는 표현들", - "wordIds": ["word_001", "word_002", "word_003"], - "words": [ - { - "wordId": "word_001", - "english": "hello", - "korean": "안녕하세요", - "level": "BEGINNER", - "category": "DAILY", - "createdAt": "2024-01-14T10:00:00Z" - } - ], - "createdAt": "2024-01-14T10:00:00Z", - "updatedAt": "2024-01-14T10:00:00Z" - }, - "error": null -} -``` - ---- - -##### PUT /vocab/groups/{groupId} - 그룹 수정 - -단어 그룹의 이름과 설명을 수정합니다. - -**인증:** 필수 (JWT) - -**요청:** - -```typescript -const groupId = "group_001"; -const updateRequest: UpdateWordGroupRequest = { - name: "일상 회화 (수정됨)", - description: "일상에서 자주 사용하는 인사 표현들" -}; - -const response = await authenticatedFetch( - `https://api-id.execute-api.region.amazonaws.com/dev/vocab/groups/${groupId}`, - { - method: 'PUT', - body: JSON.stringify(updateRequest) - } -); - -const result: ApiResponse = await response.json(); -``` - -**응답:** - -```json -{ - "isSuccess": true, - "message": "그룹이 수정되었습니다", - "data": { - "groupId": "group_001", - "userId": "user_123", - "name": "일상 회화 (수정됨)", - "description": "일상에서 자주 사용하는 인사 표현들", - "wordIds": ["word_001", "word_002", "word_003"], - "createdAt": "2024-01-14T10:00:00Z", - "updatedAt": "2024-01-14T10:30:00Z" - }, - "error": null -} -``` - ---- - -##### DELETE /vocab/groups/{groupId} - 그룹 삭제 - -단어 그룹을 삭제합니다. 포함된 단어는 삭제되지 않습니다. - -**인증:** 필수 (JWT) - -**요청:** - -```typescript -const groupId = "group_001"; -const response = await authenticatedFetch( - `https://api-id.execute-api.region.amazonaws.com/dev/vocab/groups/${groupId}`, - { method: 'DELETE' } -); - -const result: ApiResponse = await response.json(); -``` - -**응답:** - -```json -{ - "isSuccess": true, - "message": "그룹이 삭제되었습니다", - "data": null, - "error": null -} -``` - ---- - -##### POST /vocab/groups/{groupId}/words/{wordId} - 그룹에 단어 추가 - -그룹에 단어를 추가합니다. - -**인증:** 필수 (JWT) - -**요청:** - -```typescript -const groupId = "group_001"; -const wordId = "word_004"; - -const response = await authenticatedFetch( - `https://api-id.execute-api.region.amazonaws.com/dev/vocab/groups/${groupId}/words/${wordId}`, - { method: 'POST' } -); - -const result: ApiResponse = await response.json(); -``` - -**응답:** - -```json -{ - "isSuccess": true, - "message": "단어가 그룹에 추가되었습니다", - "data": { - "groupId": "group_001", - "userId": "user_123", - "name": "일상 회화", - "wordIds": ["word_001", "word_002", "word_003", "word_004"], - "createdAt": "2024-01-14T10:00:00Z", - "updatedAt": "2024-01-14T10:30:00Z" - }, - "error": null -} -``` - ---- - -##### DELETE /vocab/groups/{groupId}/words/{wordId} - 그룹에서 단어 제거 - -그룹에서 단어를 제거합니다. - -**인증:** 필수 (JWT) - -**요청:** - -```typescript -const groupId = "group_001"; -const wordId = "word_004"; - -const response = await authenticatedFetch( - `https://api-id.execute-api.region.amazonaws.com/dev/vocab/groups/${groupId}/words/${wordId}`, - { method: 'DELETE' } -); - -const result: ApiResponse = await response.json(); -``` - -**응답:** - -```json -{ - "isSuccess": true, - "message": "단어가 그룹에서 제거되었습니다", - "data": { - "groupId": "group_001", - "userId": "user_123", - "name": "일상 회화", - "wordIds": ["word_001", "word_002", "word_003"], - "createdAt": "2024-01-14T10:00:00Z", - "updatedAt": "2024-01-14T10:35:00Z" - }, - "error": null -} -``` - ---- - -#### 1.4 일일 학습 (인증 필요) - -##### GET /vocab/daily - 오늘의 학습 단어 - -오늘의 학습 단어 10개를 자동으로 선정하여 반환합니다. - -**인증:** 필수 (JWT) - -**요청:** - -```typescript -const response = await authenticatedFetch( - 'https://api-id.execute-api.region.amazonaws.com/dev/vocab/daily' -); - -const result: ApiResponse = await response.json(); -``` - -**응답:** - -```json -{ - "isSuccess": true, - "message": "오늘의 학습 단어를 조회했습니다", - "data": { - "userId": "user_123", - "date": "2024-01-14", - "words": [ - { - "wordId": "word_001", - "english": "hello", - "korean": "안녕하세요", - "level": "BEGINNER", - "category": "DAILY", - "createdAt": "2024-01-14T10:00:00Z" - } - ], - "createdAt": "2024-01-14T00:00:00Z" - }, - "error": null -} -``` - ---- - -##### POST /vocab/daily/words/{wordId}/learned - 학습 완료 표시 - -오늘의 학습 단어 중 하나를 학습 완료로 표시합니다. - -**인증:** 필수 (JWT) - -**요청:** - -```typescript -const wordId = "word_001"; - -const response = await authenticatedFetch( - `https://api-id.execute-api.region.amazonaws.com/dev/vocab/daily/words/${wordId}/learned`, - { method: 'POST' } -); - -const result: ApiResponse = await response.json(); -``` - -**응답:** - -```json -{ - "isSuccess": true, - "message": "학습이 완료되었습니다", - "data": { - "wordId": "word_001", - "userId": "user_123", - "status": "LEARNING", - "interval": 1, - "easeFactor": 2.5, - "repetitions": 0, - "nextReviewAt": "2024-01-15T10:00:00Z", - "correctCount": 1, - "incorrectCount": 0, - "createdAt": "2024-01-14T10:00:00Z", - "updatedAt": "2024-01-14T10:30:00Z" - }, - "error": null -} -``` - ---- - -#### 1.5 음성 합성 - -##### POST /vocab/voice/synthesize - TTS (텍스트-음성 변환) - -텍스트를 음성으로 변환합니다. 공개 API입니다. - -**인증:** 불필요 - -**요청:** - -```typescript -interface SynthesizeVoiceRequest { - text: string; - voiceId?: string; // Polly voice ID (Joanna, Kendra, etc.) - outputFormat?: string; // mp3, ogg_vorbis, pcm -} - -const synthesizeRequest = { - text: "Hello, my name is John.", - voiceId: "Joanna", - outputFormat: "mp3" -}; - -const response = await fetch( - 'https://api-id.execute-api.region.amazonaws.com/dev/vocab/voice/synthesize', - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(synthesizeRequest) - } -); - -const result = await response.blob(); -// 결과는 음성 파일 (blob) -``` - -**응답:** - -음성 파일 (audio/mpeg, audio/ogg, audio/pcm) - ---- - -### 2. 테스트 (Vocabulary Tests) - -#### 2.1 테스트 관리 - -##### POST /vocab/test/start - 테스트 시작 - -새로운 테스트를 시작합니다. - -**인증:** 필수 (JWT) - -**요청:** - -```typescript -const startTestRequest: StartTestRequest = { - testType: "DAILY" // DAILY, CUSTOM, QUICK -}; - -const response = await authenticatedFetch( - 'https://api-id.execute-api.region.amazonaws.com/dev/vocab/test/start', - { - method: 'POST', - body: JSON.stringify(startTestRequest) - } -); - -const result: ApiResponse = await response.json(); -``` - -**응답:** - -```json -{ - "isSuccess": true, - "message": "테스트가 시작되었습니다", - "data": { - "testId": "test_abc123", - "words": [ - { - "wordId": "word_001", - "english": "hello", - "korean": "안녕하세요", - "level": "BEGINNER", - "category": "DAILY", - "createdAt": "2024-01-14T10:00:00Z" - } - ], - "startedAt": "2024-01-14T10:00:00Z" - }, - "error": null -} -``` - ---- - -##### POST /vocab/test/submit - 답안 제출 - -테스트 답안을 제출합니다. - -**인증:** 필수 (JWT) - -**요청:** - -```typescript -const submitRequest: SubmitTestRequest = { - testId: "test_abc123", - testType: "DAILY", - answers: [ - { wordId: "word_001", answer: "hello" }, - { wordId: "word_002", answer: "goodbye" }, - { wordId: "word_003", answer: "" } // 빈 답변 (오답) - ], - startedAt: "2024-01-14T10:00:00Z" -}; - -const response = await authenticatedFetch( - 'https://api-id.execute-api.region.amazonaws.com/dev/vocab/test/submit', - { - method: 'POST', - body: JSON.stringify(submitRequest) - } -); - -const result: ApiResponse = await response.json(); -``` - -**응답:** - -```json -{ - "isSuccess": true, - "message": "테스트 결과가 저장되었습니다", - "data": { - "testId": "test_abc123", - "userId": "user_123", - "testType": "DAILY", - "totalQuestions": 10, - "correctAnswers": 7, - "correctRate": 70.0, - "spentTime": 180000, - "completedAt": "2024-01-14T10:03:00Z" - }, - "error": null -} -``` - ---- - -##### GET /vocab/test/results - 테스트 결과 목록 - -과거 테스트 결과를 조회합니다. - -**인증:** 필수 (JWT) - -**쿼리 파라미터:** - -| 파라미터 | 타입 | 필수 | 설명 | -|---------|------|------|------| -| testType | string | N | 테스트 타입 필터 (DAILY, CUSTOM, QUICK) | -| limit | number | N | 반환할 아이템 개수 (기본값: 20) | -| cursor | string | N | 페이지네이션 커서 | - -**요청:** - -```typescript -const response = await authenticatedFetch( - 'https://api-id.execute-api.region.amazonaws.com/dev/vocab/test/results?limit=10' -); - -const result: ApiResponse> = await response.json(); -``` - -**응답:** - -```json -{ - "isSuccess": true, - "message": "테스트 결과 목록 조회 성공", - "data": { - "items": [ - { - "testId": "test_abc123", - "userId": "user_123", - "testType": "DAILY", - "totalQuestions": 10, - "correctAnswers": 7, - "correctRate": 70.0, - "spentTime": 180000, - "completedAt": "2024-01-14T10:03:00Z" - } - ], - "nextCursor": "cursor_test123", - "hasMore": false - }, - "error": null -} -``` - ---- - -##### GET /vocab/test/results/{testId} - 테스트 결과 상세 - -특정 테스트의 상세 결과를 조회합니다. - -**인증:** 필수 (JWT) - -**요청:** - -```typescript -const testId = "test_abc123"; - -const response = await authenticatedFetch( - `https://api-id.execute-api.region.amazonaws.com/dev/vocab/test/results/${testId}` -); - -const result: ApiResponse = - await response.json(); -``` - -**응답:** - -```json -{ - "isSuccess": true, - "message": "테스트 상세 조회 성공", - "data": { - "testId": "test_abc123", - "userId": "user_123", - "testType": "DAILY", - "totalQuestions": 10, - "correctAnswers": 7, - "correctRate": 70.0, - "spentTime": 180000, - "completedAt": "2024-01-14T10:03:00Z", - "testedWords": [ - { - "wordId": "word_001", - "english": "hello", - "korean": "안녕하세요", - "isCorrect": true, - "userAnswer": "hello" - }, - { - "wordId": "word_003", - "english": "goodbye", - "korean": "안녕히 가세요", - "isCorrect": false, - "userAnswer": "" - } - ] - }, - "error": null -} -``` - ---- - -##### GET /vocab/test/tested-words - 최근 테스트 단어 - -최근에 테스트한 단어들을 조회합니다. - -**인증:** 필수 (JWT) - -**쿼리 파라미터:** - -| 파라미터 | 타입 | 필수 | 설명 | -|---------|------|------|------| -| limit | number | N | 반환할 아이템 개수 (기본값: 20) | - -**요청:** - -```typescript -const response = await authenticatedFetch( - 'https://api-id.execute-api.region.amazonaws.com/dev/vocab/test/tested-words?limit=20' -); - -const result: ApiResponse = await response.json(); -``` - -**응답:** - -```json -{ - "isSuccess": true, - "message": "최근 테스트 단어 조회 성공", - "data": [ - { - "wordId": "word_001", - "english": "hello", - "korean": "안녕하세요", - "isCorrect": true, - "userAnswer": "hello" - }, - { - "wordId": "word_002", - "english": "goodbye", - "korean": "안녕히 가세요", - "isCorrect": false, - "userAnswer": "" - } - ], - "error": null -} -``` - ---- - -#### 2.2 통계 - -##### GET /vocab/stats - 전체 통계 - -사용자의 전체 학습 통계를 조회합니다. - -**인증:** 필수 (JWT) - -**요청:** - -```typescript -const response = await authenticatedFetch( - 'https://api-id.execute-api.region.amazonaws.com/dev/vocab/stats' -); - -const result: ApiResponse = await response.json(); -``` - -**응답:** - -```json -{ - "isSuccess": true, - "message": "통계 조회 성공", - "data": { - "totalWords": 150, - "masteredCount": 50, - "learningCount": 60, - "reviewingCount": 30, - "newCount": 10, - "correctRate": 82.5, - "totalTestsTaken": 25, - "averageSpentTime": 240000 - }, - "error": null -} -``` - ---- - -##### GET /vocab/stats/daily - 일별 통계 - -일별 학습 통계를 조회합니다. - -**인증:** 필수 (JWT) - -**쿼리 파라미터:** - -| 파라미터 | 타입 | 필수 | 설명 | -|---------|------|------|------| -| days | number | N | 조회할 일수 (기본값: 30) | - -**요청:** - -```typescript -const response = await authenticatedFetch( - 'https://api-id.execute-api.region.amazonaws.com/dev/vocab/stats/daily?days=30' -); - -const result: ApiResponse = await response.json(); -``` - -**응답:** - -```json -{ - "isSuccess": true, - "message": "일별 통계 조회 성공", - "data": [ - { - "date": "2024-01-14", - "wordsLearned": 5, - "testsTaken": 2, - "correctRate": 85.0, - "spentTime": 300000 - }, - { - "date": "2024-01-13", - "wordsLearned": 3, - "testsTaken": 1, - "correctRate": 80.0, - "spentTime": 180000 - } - ], - "error": null -} -``` - ---- - -##### GET /vocab/stats/weakness - 취약점 분석 - -오답이 많은 단어들을 조회합니다. - -**인証:** 필수 (JWT) - -**쿼리 파라미터:** - -| 파라미터 | 타입 | 필수 | 설명 | -|---------|------|------|------| -| limit | number | N | 반환할 아이템 개수 (기본값: 20) | - -**요청:** - -```typescript -const response = await authenticatedFetch( - 'https://api-id.execute-api.region.amazonaws.com/dev/vocab/stats/weakness?limit=20' -); - -const result: ApiResponse = await response.json(); -``` - -**응답:** - -```json -{ - "isSuccess": true, - "message": "취약점 분석 조회 성공", - "data": [ - { - "wordId": "word_005", - "english": "perspective", - "korean": "관점", - "incorrectCount": 8, - "level": "ADVANCED" - }, - { - "wordId": "word_012", - "english": "aggregate", - "korean": "집계하다", - "incorrectCount": 6, - "level": "INTERMEDIATE" - } - ], - "error": null -} -``` - ---- - -### 3. 채팅 (Chatting) - -#### 3.1 채팅 방 관리 - -##### POST /chat/rooms - 방 생성 - -새로운 채팅 방을 생성합니다. - -**인증:** 필수 (JWT) - -**요청:** - -```typescript -const createRoomRequest: CreateRoomRequest = { - name: "초급 회화 토론", - description: "초급 레벨 학습자들을 위한 회화 연습방", - level: "beginner", - maxMembers: 6, - isPrivate: false -}; - -const response = await authenticatedFetch( - 'https://api-id.execute-api.region.amazonaws.com/dev/chat/rooms', - { - method: 'POST', - body: JSON.stringify(createRoomRequest) - } -); - -const result: ApiResponse = await response.json(); -``` - -**응답:** - -```json -{ - "isSuccess": true, - "message": "채팅 방이 생성되었습니다", - "data": { - "roomId": "room_001", - "name": "초급 회화 토론", - "description": "초급 레벨 학습자들을 위한 회화 연습방", - "level": "beginner", - "maxMembers": 6, - "currentMembers": 1, - "isPrivate": false, - "createdBy": "user_123", - "createdAt": "2024-01-14T10:00:00Z" - }, - "error": null -} -``` - ---- - -##### GET /chat/rooms - 방 목록 - -채팅 방 목록을 조회합니다. - -**인증:** 불필요 (공개 목록), 인증 필수 (참여 여부 필터) - -**쿼리 파라미터:** - -| 파라미터 | 타입 | 필수 | 설명 | -|---------|------|------|------| -| level | string | N | 레벨 필터 (beginner, intermediate, advanced) | -| joined | boolean | N | 참여한 방만 (true) | -| limit | number | N | 반환할 아이템 개수 (기본값: 20) | -| cursor | string | N | 페이지네이션 커서 | - -**요청:** - -```typescript -const response = await authenticatedFetch( - 'https://api-id.execute-api.region.amazonaws.com/dev/chat/rooms?level=beginner&limit=10' -); - -const result: ApiResponse> = await response.json(); -``` - -**응답:** - -```json -{ - "isSuccess": true, - "message": "방 목록 조회 성공", - "data": { - "items": [ - { - "roomId": "room_001", - "name": "초급 회화 토론", - "description": "초급 레벨 학습자들을 위한 회화 연습방", - "level": "beginner", - "maxMembers": 6, - "currentMembers": 3, - "isPrivate": false, - "createdBy": "user_123", - "createdAt": "2024-01-14T10:00:00Z" - } - ], - "nextCursor": "cursor_room123", - "hasMore": false - }, - "error": null -} -``` - ---- - -##### GET /chat/rooms/{roomId} - 방 상세 조회 - -특정 채팅 방의 상세 정보를 조회합니다. - -**인증:** 불필요 - -**요청:** - -```typescript -const roomId = "room_001"; - -const response = await fetch( - `https://api-id.execute-api.region.amazonaws.com/dev/chat/rooms/${roomId}` -); - -const result: ApiResponse = await response.json(); -``` - -**응답:** - -```json -{ - "isSuccess": true, - "message": "방 상세 조회 성공", - "data": { - "roomId": "room_001", - "name": "초급 회화 토론", - "description": "초급 레벨 학습자들을 위한 회화 연습방", - "level": "beginner", - "maxMembers": 6, - "currentMembers": 3, - "isPrivate": false, - "createdBy": "user_123", - "createdAt": "2024-01-14T10:00:00Z" - }, - "error": null -} -``` - ---- - -##### POST /chat/rooms/{roomId}/join - 방 참여 - -채팅 방에 참여합니다. WebSocket 연결에 필요한 roomToken을 받습니다. - -**인증:** 필수 (JWT) - -**요청:** - -```typescript -const roomId = "room_001"; - -const joinRequest: JoinRoomRequest = { - roomId: roomId -}; - -const response = await authenticatedFetch( - `https://api-id.execute-api.region.amazonaws.com/dev/chat/rooms/${roomId}/join`, - { - method: 'POST', - body: JSON.stringify(joinRequest) - } -); - -const result: ApiResponse = await response.json(); -``` - -**응답:** - -```json -{ - "isSuccess": true, - "message": "방에 입장했습니다", - "data": { - "roomId": "room_001", - "roomToken": "token_xyz789", - "name": "초급 회화 토론", - "level": "beginner", - "members": [ - { - "userId": "user_123", - "name": "John" - }, - { - "userId": "user_456", - "name": "Jane" - } - ] - }, - "error": null -} -``` - ---- - -##### POST /chat/rooms/{roomId}/leave - 방 나가기 - -채팅 방을 나갑니다. - -**인증:** 필수 (JWT) - -**요청:** - -```typescript -const roomId = "room_001"; - -const leaveRequest: LeaveRoomRequest = { - roomId: roomId -}; - -const response = await authenticatedFetch( - `https://api-id.execute-api.region.amazonaws.com/dev/chat/rooms/${roomId}/leave`, - { - method: 'POST', - body: JSON.stringify(leaveRequest) - } -); - -const result: ApiResponse = await response.json(); -``` - -**응답:** - -```json -{ - "isSuccess": true, - "message": "방을 나갔습니다", - "data": null, - "error": null -} -``` - ---- - -##### DELETE /chat/rooms/{roomId} - 방 삭제 - -채팅 방을 삭제합니다. 방장만 가능합니다. - -**인증:** 필수 (JWT) - -**요청:** - -```typescript -const roomId = "room_001"; - -const response = await authenticatedFetch( - `https://api-id.execute-api.region.amazonaws.com/dev/chat/rooms/${roomId}`, - { method: 'DELETE' } -); - -const result: ApiResponse = await response.json(); -``` - -**응답:** - -```json -{ - "isSuccess": true, - "message": "방이 삭제되었습니다", - "data": null, - "error": null -} -``` - ---- - -#### 3.2 메시지 관리 - -##### POST /chat/rooms/{roomId}/messages - 메시지 전송 - -채팅 방에 메시지를 전송합니다. REST API로도 전송 가능하지만, 실시간 채팅은 WebSocket 사용을 권장합니다. - -**인증:** 필수 (JWT) - -**요청:** - -```typescript -const roomId = "room_001"; - -const sendMessageRequest: SendMessageRequest = { - roomId: roomId, - content: "Hello everyone!", - messageType: "TEXT" -}; - -const response = await authenticatedFetch( - `https://api-id.execute-api.region.amazonaws.com/dev/chat/rooms/${roomId}/messages`, - { - method: 'POST', - body: JSON.stringify(sendMessageRequest) - } -); - -const result: ApiResponse = await response.json(); -``` - -**응답:** - -```json -{ - "isSuccess": true, - "message": "메시지가 전송되었습니다", - "data": { - "messageId": "msg_001", - "roomId": "room_001", - "userId": "user_123", - "userName": "John", - "content": "Hello everyone!", - "messageType": "TEXT", - "createdAt": "2024-01-14T10:00:00Z" - }, - "error": null -} -``` - ---- - -##### GET /chat/rooms/{roomId}/messages - 메시지 목록 - -채팅 방의 메시지 목록을 조회합니다. - -**인증:** 필수 (JWT) - -**쿼리 파라미터:** - -| 파라미터 | 타입 | 필수 | 설명 | -|---------|------|------|------| -| limit | number | N | 반환할 아이템 개수 (기본값: 50) | -| cursor | string | N | 페이지네이션 커서 | - -**요청:** - -```typescript -const roomId = "room_001"; - -const response = await authenticatedFetch( - `https://api-id.execute-api.region.amazonaws.com/dev/chat/rooms/${roomId}/messages?limit=50` -); - -const result: ApiResponse> = await response.json(); -``` - -**응답:** - -```json -{ - "isSuccess": true, - "message": "메시지 목록 조회 성공", - "data": { - "items": [ - { - "messageId": "msg_001", - "roomId": "room_001", - "userId": "user_123", - "userName": "John", - "content": "Hello everyone!", - "messageType": "TEXT", - "createdAt": "2024-01-14T10:00:00Z" - } - ], - "nextCursor": "cursor_msg123", - "hasMore": false - }, - "error": null -} -``` - ---- - -##### GET /chat/rooms/{roomId}/messages/{messageId} - 메시지 상세 - -특정 메시지의 상세 정보를 조회합니다. - -**인증:** 필수 (JWT) - -**요청:** - -```typescript -const roomId = "room_001"; -const messageId = "msg_001"; - -const response = await authenticatedFetch( - `https://api-id.execute-api.region.amazonaws.com/dev/chat/rooms/${roomId}/messages/${messageId}` -); - -const result: ApiResponse = await response.json(); -``` - -**응답:** - -```json -{ - "isSuccess": true, - "message": "메시지 상세 조회 성공", - "data": { - "messageId": "msg_001", - "roomId": "room_001", - "userId": "user_123", - "userName": "John", - "content": "Hello everyone!", - "messageType": "TEXT", - "createdAt": "2024-01-14T10:00:00Z" - }, - "error": null -} -``` - ---- - -#### 3.3 음성 합성 (채팅) - -##### POST /chat/voice/synthesize - 음성 합성 - -텍스트를 음성으로 변환합니다. - -**인증:** 필수 (JWT) - -**요청:** - -```typescript -const synthesizeRequest: VoiceSynthesisRequest = { - text: "Hello everyone! How are you today?", - voiceId: "Joanna" -}; - -const response = await authenticatedFetch( - 'https://api-id.execute-api.region.amazonaws.com/dev/chat/voice/synthesize', - { - method: 'POST', - body: JSON.stringify(synthesizeRequest) - } -); - -const result = await response.blob(); -// 결과는 음성 파일 (blob) -``` - -**응답:** - -음성 파일 (audio/mpeg) - ---- - -#### 3.4 게임 (Catch-Mind) - -##### POST /chat/rooms/{roomId}/game/start - 게임 시작 - -채팅 방에서 Catch-Mind 게임을 시작합니다. - -**인증:** 필수 (JWT) - -**요청:** - -```typescript -const roomId = "room_001"; - -const response = await authenticatedFetch( - `https://api-id.execute-api.region.amazonaws.com/dev/chat/rooms/${roomId}/game/start`, - { method: 'POST' } -); - -const result: ApiResponse<{ gameId: string }> = await response.json(); -``` - -**응답:** - -```json -{ - "isSuccess": true, - "message": "게임이 시작되었습니다", - "data": { - "gameId": "game_001" - }, - "error": null -} -``` - ---- - -##### POST /chat/rooms/{roomId}/game/stop - 게임 종료 - -게임을 종료합니다. - -**인증:** 필수 (JWT) - -**요청:** - -```typescript -const roomId = "room_001"; - -const response = await authenticatedFetch( - `https://api-id.execute-api.region.amazonaws.com/dev/chat/rooms/${roomId}/game/stop`, - { method: 'POST' } -); - -const result: ApiResponse = await response.json(); -``` - -**응답:** - -```json -{ - "isSuccess": true, - "message": "게임이 종료되었습니다", - "data": null, - "error": null -} -``` - ---- - -##### GET /chat/rooms/{roomId}/game/status - 게임 상태 - -게임의 현재 상태를 조회합니다. - -**인증:** 필수 (JWT) - -**요청:** - -```typescript -const roomId = "room_001"; - -const response = await authenticatedFetch( - `https://api-id.execute-api.region.amazonaws.com/dev/chat/rooms/${roomId}/game/status` -); - -const result: ApiResponse = await response.json(); -``` - -**응답:** - -```json -{ - "isSuccess": true, - "message": "게임 상태 조회 성공", - "data": { - "roomId": "room_001", - "isActive": true, - "currentPlayer": "user_456", - "gameData": { - "wordId": "word_042", - "hint": "A greeting" - } - }, - "error": null -} -``` - ---- - -##### GET /chat/rooms/{roomId}/game/scores - 점수판 - -게임 점수판을 조회합니다. - -**인증:** 필수 (JWT) - -**요청:** - -```typescript -const roomId = "room_001"; - -const response = await authenticatedFetch( - `https://api-id.execute-api.region.amazonaws.com/dev/chat/rooms/${roomId}/game/scores` -); - -const result: ApiResponse = await response.json(); -``` - -**응답:** - -```json -{ - "isSuccess": true, - "message": "점수판 조회 성공", - "data": { - "roomId": "room_001", - "scores": [ - { - "userId": "user_123", - "userName": "John", - "score": 250 - }, - { - "userId": "user_456", - "userName": "Jane", - "score": 180 - } - ] - }, - "error": null -} -``` - ---- - -### 4. 문법 검사 및 AI 대화 (Grammar) - -#### 4.1 문법 검사 - -##### POST /grammar/check - 문법 검사 - -문장의 문법을 검사합니다. - -**인증:** 필수 (JWT) - -**요청:** - -```typescript -const grammarCheckRequest: GrammarCheckRequest = { - sentence: "She go to the school.", - level: "BEGINNER" -}; - -const response = await authenticatedFetch( - 'https://api-id.execute-api.region.amazonaws.com/dev/grammar/check', - { - method: 'POST', - body: JSON.stringify(grammarCheckRequest) - } -); - -const result: ApiResponse = await response.json(); -``` - -**응답:** - -```json -{ - "isSuccess": true, - "message": "문법 검사 완료", - "data": { - "originalSentence": "She go to the school.", - "correctedSentence": "She goes to school.", - "score": 70, - "isCorrect": false, - "errors": [ - { - "type": "SUBJECT_VERB_AGREEMENT", - "original": "go", - "corrected": "goes", - "explanation": "'She'는 3인칭 단수이므로 동사 'go'는 'goes'로 변경해야 합니다. (She goes, He goes)", - "startIndex": 4, - "endIndex": 6 - }, - { - "type": "ARTICLE", - "original": "the school", - "corrected": "school", - "explanation": "일반적인 장소(학교, 교회 등)를 나타낼 때는 관사 'the'를 생략합니다. (go to school, go to church)", - "startIndex": 10, - "endIndex": 20 - } - ], - "feedback": "주어-동사 일치와 관사 사용에 주의하세요. 3인칭 단수 주어에는 동사에 -s/-es를 붙여야 합니다." - }, - "error": null -} -``` - -**레벨별 explanation 예시:** - -| 레벨 | explanation 스타일 | -|------|-------------------| -| BEGINNER | `"'go'의 과거형은 'went'입니다. (go → went)"` (한국어 포함) | -| INTERMEDIATE | `"Use 'went' for past tense of 'go'. Irregular verbs don't follow the -ed rule."` | -| ADVANCED | `"The verb 'go' is irregular. Past simple: went, Past participle: gone."` | - ---- - -#### 4.2 AI 대화 - -##### POST /grammar/conversation - AI 대화 - -AI와의 회화형 학습을 진행합니다. - -**인증:** 필수 (JWT) - -**요청:** - -```typescript -const conversationRequest: ConversationRequest = { - message: "Hi, how are you today?", - level: "BEGINNER" -}; - -const response = await authenticatedFetch( - 'https://api-id.execute-api.region.amazonaws.com/dev/grammar/conversation', - { - method: 'POST', - body: JSON.stringify(conversationRequest) - } -); - -const result: ApiResponse = await response.json(); -``` - -**응답:** - -```json -{ - "isSuccess": true, - "message": "대화 완료", - "data": { - "sessionId": "550e8400-e29b-41d4-a716-446655440000", - "grammarCheck": { - "originalSentence": "I go to school yesterday", - "correctedSentence": "I went to school yesterday", - "score": 75, - "isCorrect": false, - "errors": [ - { - "type": "VERB_TENSE", - "original": "go", - "corrected": "went", - "explanation": "과거 시제에서는 'go'가 'went'로 변합니다. (go → went)", - "startIndex": 2, - "endIndex": 4 - } - ], - "feedback": "동사 시제에 주의하세요!" - }, - "aiResponse": "That sounds like a busy day! (바쁜 하루였겠네요!) What did you do at school?", - "conversationTip": "Try using past tense when talking about yesterday." - }, - "error": null -} -``` - -**레벨별 aiResponse 톤:** - -| 레벨 | 스타일 | -|------|--------| -| BEGINNER | 짧은 문장, 한국어 번역 포함. `"That sounds fun! (재미있었겠네요!)"` | -| INTERMEDIATE | 자연스러운 일상 영어. `"That sounds lovely! What did you do there?"` | -| ADVANCED | 고급 어휘, 관용어 사용. `"How delightful! What activities did you engage in?"` | - ---- - -##### GET /grammar/sessions - 세션 목록 - -과거 대화 세션을 조회합니다. - -**인증:** 필수 (JWT) - -**요청:** - -```typescript -const response = await authenticatedFetch( - 'https://api-id.execute-api.region.amazonaws.com/dev/grammar/sessions' -); - -const result: ApiResponse> = await response.json(); -``` - -**응답:** - -```json -{ - "isSuccess": true, - "message": "세션 목록 조회 성공", - "data": { - "items": [ - { - "sessionId": "session_001", - "userId": "user_123", - "startedAt": "2024-01-14T10:00:00Z", - "updatedAt": "2024-01-14T10:15:00Z", - "messageCount": 5 - } - ], - "nextCursor": "cursor_session123", - "hasMore": false - }, - "error": null -} -``` - ---- - -##### GET /grammar/sessions/{sessionId} - 세션 상세 - -특정 세션의 상세 정보를 조회합니다. - -**인증:** 필수 (JWT) - -**요청:** - -```typescript -const sessionId = "session_001"; - -const response = await authenticatedFetch( - `https://api-id.execute-api.region.amazonaws.com/dev/grammar/sessions/${sessionId}` -); - -const result: ApiResponse = await response.json(); -``` - -**응답:** - -```json -{ - "isSuccess": true, - "message": "세션 상세 조회 성공", - "data": { - "sessionId": "session_001", - "userId": "user_123", - "startedAt": "2024-01-14T10:00:00Z", - "updatedAt": "2024-01-14T10:15:00Z", - "messageCount": 5 - }, - "error": null -} -``` - ---- - -##### DELETE /grammar/sessions/{sessionId} - 세션 삭제 - -세션을 삭제합니다. - -**인증:** 필수 (JWT) - -**요청:** - -```typescript -const sessionId = "session_001"; - -const response = await authenticatedFetch( - `https://api-id.execute-api.region.amazonaws.com/dev/grammar/sessions/${sessionId}`, - { method: 'DELETE' } -); - -const result: ApiResponse = await response.json(); -``` - -**응답:** - -```json -{ - "isSuccess": true, - "message": "세션이 삭제되었습니다", - "data": null, - "error": null -} -``` - ---- - -### 5. 통계 (Statistics) - -#### 5.1 일/주/월간 통계 - -##### GET /stats/daily - 일간 통계 - -일간 학습 통계를 조회합니다. - -**인증:** 필수 (JWT) - -**요청:** - -```typescript -const response = await authenticatedFetch( - 'https://api-id.execute-api.region.amazonaws.com/dev/stats/daily' -); - -const result: ApiResponse = await response.json(); -``` - -**응답:** - -```json -{ - "isSuccess": true, - "message": "일간 통계 조회 성공", - "data": { - "date": "2024-01-14", - "wordsLearned": 5, - "testsTaken": 2, - "correctRate": 85.0, - "spentTime": 300000 - }, - "error": null -} -``` - ---- - -##### GET /stats/weekly - 주간 통계 - -주간 학습 통계를 조회합니다. - -**인증:** 필수 (JWT) - -**요청:** - -```typescript -const response = await authenticatedFetch( - 'https://api-id.execute-api.region.amazonaws.com/dev/stats/weekly' -); - -const result: ApiResponse = await response.json(); -``` - -**응답:** - -```json -{ - "isSuccess": true, - "message": "주간 통계 조회 성공", - "data": { - "startDate": "2024-01-08", - "endDate": "2024-01-14", - "totalWordsLearned": 35, - "totalTestsTaken": 12, - "averageCorrectRate": 82.5, - "totalSpentTime": 2100000, - "dailyStats": [ - { - "date": "2024-01-14", - "wordsLearned": 5, - "testsTaken": 2, - "correctRate": 85.0, - "spentTime": 300000 - } - ] - }, - "error": null -} -``` - ---- - -##### GET /stats/monthly - 월간 통계 - -월간 학습 통계를 조회합니다. - -**인증:** 필수 (JWT) - -**요청:** - -```typescript -const response = await authenticatedFetch( - 'https://api-id.execute-api.region.amazonaws.com/dev/stats/monthly' -); - -const result: ApiResponse = await response.json(); -``` - -**응답:** - -```json -{ - "isSuccess": true, - "message": "월간 통계 조회 성공", - "data": { - "month": "2024-01", - "totalWordsLearned": 150, - "totalTestsTaken": 50, - "averageCorrectRate": 81.5, - "totalSpentTime": 8700000 - }, - "error": null -} -``` - ---- - -##### GET /stats/total - 전체 통계 - -누적 학습 통계를 조회합니다. - -**인증:** 필수 (JWT) - -**요청:** - -```typescript -const response = await authenticatedFetch( - 'https://api-id.execute-api.region.amazonaws.com/dev/stats/total' -); - -const result: ApiResponse = await response.json(); -``` - -**응답:** - -```json -{ - "isSuccess": true, - "message": "전체 통계 조회 성공", - "data": { - "totalWordsLearned": 450, - "masteredCount": 150, - "learningCount": 200, - "totalTestsTaken": 120, - "averageCorrectRate": 80.5, - "totalSpentTime": 32400000, - "streak": 15 - }, - "error": null -} -``` - ---- - -##### GET /stats/history - 통계 히스토리 - -통계 변화 히스토리를 조회합니다. - -**인증:** 필수 (JWT) - -**쿼리 파라미터:** - -| 파라미터 | 타입 | 필수 | 설명 | -|---------|------|------|------| -| days | number | N | 조회할 일수 (기본값: 30) | - -**요청:** - -```typescript -const response = await authenticatedFetch( - 'https://api-id.execute-api.region.amazonaws.com/dev/stats/history?days=30' -); - -const result: ApiResponse = await response.json(); -``` - -**응답:** - -```json -{ - "isSuccess": true, - "message": "통계 히스토리 조회 성공", - "data": [ - { - "date": "2024-01-14", - "wordsLearned": 5, - "testsTaken": 2, - "correctRate": 85.0, - "spentTime": 300000 - }, - { - "date": "2024-01-13", - "wordsLearned": 3, - "testsTaken": 1, - "correctRate": 80.0, - "spentTime": 180000 - } - ], - "error": null -} -``` - ---- - -### 6. 뱃지 (Badges) - -#### 6.1 뱃지 조회 - -##### GET /badges - 전체 뱃지 - -모든 뱃지를 조회합니다 (획득 여부 포함). - -**인증:** 필수 (JWT) - -**요청:** - -```typescript -const response = await authenticatedFetch( - 'https://api-id.execute-api.region.amazonaws.com/dev/badges' -); - -const result: ApiResponse = await response.json(); -``` - -**응답:** - -```json -{ - "isSuccess": true, - "message": "뱃지 목록 조회 성공", - "data": [ - { - "badgeId": "badge_001", - "name": "첫 단어", - "description": "첫 번째 단어를 학습했습니다", - "imageUrl": "https://s3.amazonaws.com/badges/first_word.png", - "condition": "1개 단어 학습", - "isEarned": true, - "earnedAt": "2024-01-10T10:00:00Z" - }, - { - "badgeId": "badge_002", - "name": "100단어 마스터", - "description": "100개 단어를 마스터했습니다", - "imageUrl": "https://s3.amazonaws.com/badges/100_words.png", - "condition": "100개 단어 MASTERED 상태", - "isEarned": false - } - ], - "error": null -} -``` - ---- - -##### GET /badges/earned - 획득한 뱃지 - -사용자가 획득한 뱃지만 조회합니다. - -**인증:** 필수 (JWT) - -**요청:** - -```typescript -const response = await authenticatedFetch( - 'https://api-id.execute-api.region.amazonaws.com/dev/badges/earned' -); - -const result: ApiResponse = await response.json(); -``` - -**응답:** - -```json -{ - "isSuccess": true, - "message": "획득한 뱃지 조회 성공", - "data": [ - { - "badgeId": "badge_001", - "name": "첫 단어", - "description": "첫 번째 단어를 학습했습니다", - "imageUrl": "https://s3.amazonaws.com/badges/first_word.png", - "condition": "1개 단어 학습", - "isEarned": true, - "earnedAt": "2024-01-10T10:00:00Z" - } - ], - "error": null -} -``` - ---- - -## 에러 코드 및 처리 - -### 표준 에러 코드 - -| 코드 | HTTP 상태 | 메시지 | 설명 | -|------|---------|--------|------| -| AUTH_001 | 401 | 인증이 필요합니다 | JWT 토큰이 없거나 요청에 Authorization 헤더가 없음 | -| AUTH_002 | 403 | 접근 권한이 없습니다 | 사용자가 리소스에 접근할 권한이 없음 | -| AUTH_003 | 401 | 유효하지 않은 토큰입니다 | JWT 토큰이 유효하지 않음 (서명 오류 등) | -| AUTH_004 | 401 | 토큰이 만료되었습니다 | JWT 토큰의 유효 기간이 만료됨 | -| VALIDATION_001 | 400 | 잘못된 입력입니다 | 요청 데이터의 형식이 올바르지 않음 | -| VALIDATION_002 | 400 | 필수 필드가 누락되었습니다 | 필수 필드가 요청에 포함되지 않음 | -| VALIDATION_003 | 400 | 형식이 올바르지 않습니다 | 데이터 형식이 예상된 형식과 다름 | -| VALIDATION_004 | 400 | 값이 허용 범위를 벗어났습니다 | 값이 최소/최대 범위를 벗어남 | -| RESOURCE_001 | 404 | 리소스를 찾을 수 없습니다 | 요청한 리소스가 존재하지 않음 | -| RESOURCE_002 | 409 | 이미 존재하는 리소스입니다 | 중복 생성 시도 (예: 같은 이메일로 가입) | -| RESOURCE_003 | 405 | 허용되지 않는 메서드입니다 | HTTP 메서드가 지원되지 않음 | -| SYSTEM_001 | 500 | 내부 서버 오류가 발생했습니다 | 서버 내부 오류 | -| SYSTEM_002 | 500 | 데이터베이스 오류가 발생했습니다 | DynamoDB 쿼리 오류 | -| SYSTEM_003 | 502 | 외부 API 호출 오류가 발생했습니다 | AWS Bedrock 또는 Polly 호출 실패 | -| SYSTEM_004 | 503 | 서비스를 일시적으로 사용할 수 없습니다 | 서비스 일시적 불가능 (다시 시도하세요) | - -### 에러 처리 예제 - -```typescript -// 일반적인 에러 처리 -interface ErrorResponse { - isSuccess: false; - data: null; - message: null; - error: string; -} - -async function handleApiError(response: Response) { - const errorData: ErrorResponse = await response.json(); - - if (response.status === 401) { - // 인증 에러 - 로그인 페이지로 이동 - if (errorData.error.includes('토큰')) { - window.location.href = '/login'; - } - } else if (response.status === 403) { - // 권한 에러 - 사용자 알림 - alert('접근 권한이 없습니다'); - } else if (response.status === 404) { - // 리소스 없음 - console.error('리소스를 찾을 수 없습니다:', errorData.error); - } else if (response.status === 400) { - // 검증 에러 - console.error('입력 오류:', errorData.error); - } else if (response.status >= 500) { - // 서버 에러 - console.error('서버 에러:', errorData.error); - } -} - -// Axios 인터셉터로 통합 에러 처리 -apiClient.interceptors.response.use( - (response) => response, - (error) => { - if (error.response) { - const { status, data } = error.response; - - if (status === 401) { - // 재로그인 처리 - Auth.signOut(); - window.location.href = '/login'; - } else if (status === 403) { - // 권한 없음 처리 - throw new Error('접근 권한이 없습니다'); - } else if (status === 404) { - // 리소스 없음 처리 - throw new Error('리소스를 찾을 수 없습니다'); - } else if (status >= 500) { - // 서버 에러 처리 - throw new Error('서버 오류가 발생했습니다. 잠시 후 다시 시도하세요'); - } - - throw new Error(data.error || '요청 실패'); - } - - throw error; - } -); -``` - ---- - -## WebSocket 연동 가이드 - -### WebSocket 엔드포인트 - -``` -wss://{api-id}.execute-api.{region}.amazonaws.com/dev -``` - -### 연결 순서 - -1. REST API로 채팅 방에 참여 (`/chat/rooms/{roomId}/join`) -2. `roomToken` 획득 -3. WebSocket에 연결하고 `roomToken` 전송 -4. 메시지 수신/전송 - -### WebSocket 연동 예제 - -```typescript -import { Auth } from 'aws-amplify'; - -class ChatWebSocketManager { - private ws: WebSocket | null = null; - private roomId: string; - private roomToken: string; - private userId: string; - private reconnectAttempts = 0; - private maxReconnectAttempts = 5; - private reconnectDelay = 3000; - - constructor( - roomId: string, - roomToken: string, - userId: string - ) { - this.roomId = roomId; - this.roomToken = roomToken; - this.userId = userId; - } - - // WebSocket 연결 - connect(onMessageReceived: (message: ChatMessage) => void): Promise { - return new Promise((resolve, reject) => { - try { - const wsUrl = `wss://{api-id}.execute-api.{region}.amazonaws.com/dev`; - - this.ws = new WebSocket(wsUrl); - - this.ws.onopen = () => { - console.log('WebSocket 연결됨'); - this.reconnectAttempts = 0; - - // 인증 메시지 전송 - this.sendAuthMessage(); - resolve(); - }; - - this.ws.onmessage = (event) => { - try { - const message = JSON.parse(event.data); - console.log('메시지 수신:', message); - - if (message.action === 'sendMessage') { - onMessageReceived(message.data); - } - } catch (error) { - console.error('메시지 파싱 오류:', error); - } - }; - - this.ws.onerror = (error) => { - console.error('WebSocket 에러:', error); - reject(error); - }; - - this.ws.onclose = () => { - console.log('WebSocket 연결 종료'); - this.attemptReconnect(onMessageReceived); - }; - } catch (error) { - reject(error); - } - }); - } - - // 인증 메시지 전송 - private sendAuthMessage() { - const authMessage = { - action: 'connect', - roomId: this.roomId, - roomToken: this.roomToken, - userId: this.userId - }; - - this.send(authMessage); - } - - // 메시지 전송 - sendMessage(content: string, messageType: MessageType = 'TEXT') { - const message = { - action: 'sendMessage', - roomId: this.roomId, - userId: this.userId, - content: content, - messageType: messageType, - timestamp: new Date().toISOString() - }; - - this.send(message); - } - - // 내부 메서드: 메시지 전송 - private send(message: any) { - if (this.ws && this.ws.readyState === WebSocket.OPEN) { - this.ws.send(JSON.stringify(message)); - } else { - console.error('WebSocket이 연결되지 않았습니다'); - } - } - - // 재연결 시도 - private attemptReconnect(onMessageReceived: (message: ChatMessage) => void) { - if (this.reconnectAttempts < this.maxReconnectAttempts) { - this.reconnectAttempts++; - console.log( - `재연결 시도 ${this.reconnectAttempts}/${this.maxReconnectAttempts}` - ); - - setTimeout(() => { - this.connect(onMessageReceived).catch((error) => { - console.error('재연결 실패:', error); - }); - }, this.reconnectDelay); - } else { - console.error('최대 재연결 횟수 초과'); - } - } - - // 연결 종료 - disconnect() { - if (this.ws) { - this.ws.close(); - this.ws = null; - } - } - - // 연결 상태 확인 - isConnected(): boolean { - return this.ws !== null && this.ws.readyState === WebSocket.OPEN; - } -} - -// 사용 예제 -async function startChatting() { - try { - // 1. 방에 참여하여 roomToken 획득 - const joinResponse = await authenticatedFetch( - 'https://api-id.execute-api.region.amazonaws.com/dev/chat/rooms/room_001/join', - { method: 'POST', body: JSON.stringify({ roomId: 'room_001' }) } - ); - - const joinData: ApiResponse = await joinResponse.json(); - - if (!joinData.isSuccess) { - throw new Error(joinData.error); - } - - const { roomToken } = joinData.data; - - // 2. 사용자 정보 획득 - const user = await Auth.currentAuthenticatedUser(); - - // 3. WebSocket 매니저 생성 및 연결 - const chatManager = new ChatWebSocketManager( - 'room_001', - roomToken, - user.username - ); - - await chatManager.connect((message: ChatMessage) => { - console.log('새 메시지:', message); - // UI에 메시지 표시 - displayMessage(message); - }); - - // 4. 메시지 전송 - chatManager.sendMessage('Hello everyone!', 'TEXT'); - - // 5. 연결 종료 - // chatManager.disconnect(); - - } catch (error) { - console.error('채팅 시작 오류:', error); - } -} - -function displayMessage(message: ChatMessage) { - const messageElement = document.createElement('div'); - messageElement.className = 'message'; - messageElement.innerHTML = ` -

- ${message.userName} - ${new Date(message.createdAt).toLocaleTimeString()} -
-
${escapeHtml(message.content)}
- `; - - document.getElementById('messages-container')?.appendChild(messageElement); -} - -function escapeHtml(text: string): string { - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; -} -``` - -### WebSocket 이벤트 - -#### $connect - -클라이언트가 WebSocket에 처음 연결할 때 발생합니다. - -```json -{ - "action": "connect", - "roomId": "room_001", - "roomToken": "token_xyz789", - "userId": "user_123" -} -``` - -#### $disconnect - -클라이언트가 WebSocket 연결을 종료할 때 발생합니다. - -```json -{ - "action": "disconnect", - "roomId": "room_001", - "userId": "user_123" -} -``` - -#### sendMessage - -메시지를 전송할 때 발생합니다. - -```json -{ - "action": "sendMessage", - "roomId": "room_001", - "userId": "user_123", - "content": "Hello everyone!", - "messageType": "TEXT", - "timestamp": "2024-01-14T10:00:00Z" -} -``` - -서버로부터의 응답: - -```json -{ - "action": "sendMessage", - "data": { - "messageId": "msg_001", - "roomId": "room_001", - "userId": "user_123", - "userName": "John", - "content": "Hello everyone!", - "messageType": "TEXT", - "createdAt": "2024-01-14T10:00:00Z" - } -} -``` - ---- - -## 추가 리소스 - -### 관련 문서 - -- AWS Cognito 인증: https://docs.aws.amazon.com/cognito/ -- AWS API Gateway: https://docs.aws.amazon.com/apigateway/ -- AWS Lambda: https://docs.aws.amazon.com/lambda/ -- AWS Amplify: https://docs.amplify.aws/ - -### 유용한 라이브러리 - -- **HTTP 클라이언트**: axios, fetch API -- **인증**: AWS Amplify, AWS SDK -- **실시간**: WebSocket API -- **상태 관리**: Redux, Zustand, Jotai -- **UI**: React, Vue, Angular - -### 베스트 프랙티스 - -1. **토큰 관리** - - 토큰 만료 시 자동 갱신 구현 - - 로컬 스토리지 대신 httpOnly 쿠키 사용 권장 - - 토큰 검증은 항상 백엔드에서 수행 - -2. **에러 처리** - - 모든 API 호출에 try-catch 또는 .catch() 구현 - - 사용자 친화적인 에러 메시지 표시 - - 에러 로깅 시스템 구축 - -3. **성능** - - API 응답 캐싱 (적절한 TTL 설정) - - 페이지네이션으로 대량 데이터 처리 - - 이미지 최적화 및 CDN 사용 - -4. **보안** - - HTTPS만 사용 - - CORS 설정 확인 - - 민감한 데이터는 암호화 - - 주기적인 보안 업데이트 - ---- - -**최종 수정일:** 2024년 1월 14일 -**버전:** 1.0.0 \ No newline at end of file From 1f32ea09dbe0dd7ed1532d00a80bc552e705efb7 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 15:26:27 +0900 Subject: [PATCH 353/528] =?UTF-8?q?style:=20GameConfig=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=ED=8F=AC=EB=A7=B7=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/chatting/config/GameConfig.java | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/config/GameConfig.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/config/GameConfig.java index 998e4f0b..26e72064 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/config/GameConfig.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/config/GameConfig.java @@ -7,26 +7,26 @@ * 환경 변수로 오버라이드 가능 */ public final class GameConfig { - + private static final int DEFAULT_TOTAL_ROUNDS = 5; private static final int DEFAULT_ROUND_TIME_LIMIT = 60; private static final long DEFAULT_QUICK_GUESS_THRESHOLD_MS = 5000L; - + private static final int TOTAL_ROUNDS = EnvConfig.getIntOrDefault("GAME_TOTAL_ROUNDS", DEFAULT_TOTAL_ROUNDS); private static final int ROUND_TIME_LIMIT = EnvConfig.getIntOrDefault("GAME_ROUND_TIME_LIMIT", DEFAULT_ROUND_TIME_LIMIT); private static final long QUICK_GUESS_THRESHOLD_MS = EnvConfig.getLongOrDefault("GAME_QUICK_GUESS_THRESHOLD_MS", DEFAULT_QUICK_GUESS_THRESHOLD_MS); - + private GameConfig() { } - + public static int totalRounds() { return TOTAL_ROUNDS; } - + public static int roundTimeLimit() { return ROUND_TIME_LIMIT; } - + public static long quickGuessThresholdMs() { return QUICK_GUESS_THRESHOLD_MS; } From 86d0c1365ee9292e102d0bcecb7de1a3c9b08960 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 15:26:27 +0900 Subject: [PATCH 354/528] =?UTF-8?q?style:=20GrammarConfig=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=ED=8F=AC=EB=A7=B7=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/grammar/config/GrammarConfig.java | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/config/GrammarConfig.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/config/GrammarConfig.java index 4e7a07cc..f06cb4be 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/config/GrammarConfig.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/config/GrammarConfig.java @@ -7,32 +7,32 @@ * 환경 변수로 오버라이드 가능 */ public final class GrammarConfig { - + private static final int DEFAULT_SESSION_TTL_DAYS = 30; private static final int DEFAULT_MAX_HISTORY_MESSAGES = 10; private static final int DEFAULT_LAST_MESSAGE_MAX_LENGTH = 100; private static final int DEFAULT_MAX_TOKENS = 2048; - + private static final int SESSION_TTL_DAYS = EnvConfig.getIntOrDefault("GRAMMAR_SESSION_TTL_DAYS", DEFAULT_SESSION_TTL_DAYS); private static final int MAX_HISTORY_MESSAGES = EnvConfig.getIntOrDefault("GRAMMAR_MAX_HISTORY_MESSAGES", DEFAULT_MAX_HISTORY_MESSAGES); private static final int LAST_MESSAGE_MAX_LENGTH = EnvConfig.getIntOrDefault("GRAMMAR_LAST_MESSAGE_MAX_LENGTH", DEFAULT_LAST_MESSAGE_MAX_LENGTH); private static final int MAX_TOKENS = EnvConfig.getIntOrDefault("GRAMMAR_MAX_TOKENS", DEFAULT_MAX_TOKENS); - + private GrammarConfig() { } - + public static int sessionTtlDays() { return SESSION_TTL_DAYS; } - + public static int maxHistoryMessages() { return MAX_HISTORY_MESSAGES; } - + public static int lastMessageMaxLength() { return LAST_MESSAGE_MAX_LENGTH; } - + public static int maxTokens() { return MAX_TOKENS; } From 524062deed0604fbb86f7a1a49c0b1b37375240b Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 15:26:28 +0900 Subject: [PATCH 355/528] =?UTF-8?q?style:=20GrammarKey=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=ED=8F=AC=EB=A7=B7=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../serverless/domain/grammar/constants/GrammarKey.java | 1 + 1 file changed, 1 insertion(+) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/constants/GrammarKey.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/constants/GrammarKey.java index 1aa99716..df5d2ccb 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/constants/GrammarKey.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/constants/GrammarKey.java @@ -7,6 +7,7 @@ public final class GrammarKey { public static final String MSG_PREFIX = "MSG#"; public static final String ALL_SESSIONS = "GSESSION#ALL"; public static final String UPDATED_PREFIX = "UPDATED#"; + private GrammarKey() { } From f06d42f18ea5a629a9fdd766972160baed96fe02 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 15:26:28 +0900 Subject: [PATCH 356/528] =?UTF-8?q?style:=20GrammarErrorCode=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=ED=8F=AC=EB=A7=B7=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../serverless/domain/grammar/exception/GrammarErrorCode.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/exception/GrammarErrorCode.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/exception/GrammarErrorCode.java index cc1b9701..26f6347b 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/exception/GrammarErrorCode.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/exception/GrammarErrorCode.java @@ -6,7 +6,7 @@ public enum GrammarErrorCode implements DomainErrorCode { // 요청 검증 관련 에러 INVALID_REQUEST("GRAMMAR_000", "잘못된 요청입니다", 400), - + // 문법 체크 관련 에러 INVALID_SENTENCE("GRAMMAR_001", "유효하지 않은 문장입니다", 400), GRAMMAR_CHECK_FAILED("GRAMMAR_002", "문법 체크에 실패했습니다", 500), From ff3f3e44404b2fe3d87d277bd52d05de5fe7bcb9 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 15:26:28 +0900 Subject: [PATCH 357/528] =?UTF-8?q?style:=20GrammarException=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=ED=8F=AC=EB=A7=B7=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/grammar/exception/GrammarException.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/exception/GrammarException.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/exception/GrammarException.java index b1892588..9c5f16ba 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/exception/GrammarException.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/exception/GrammarException.java @@ -17,16 +17,16 @@ private GrammarException(GrammarErrorCode errorCode, Throwable cause) { } // === 요청 검증 관련 팩토리 메서드 === - + public static GrammarException invalidRequest(String field, String reason) { return (GrammarException) new GrammarException(GrammarErrorCode.INVALID_REQUEST, String.format("잘못된 요청입니다: %s", reason)) .addDetail("field", field) .addDetail("reason", reason); } - + // === 문법 체크 관련 팩토리 메서드 === - + public static GrammarException invalidSentence(String sentence) { return (GrammarException) new GrammarException(GrammarErrorCode.INVALID_SENTENCE, "유효하지 않은 문장입니다. 문장을 확인해주세요.") From e30e7ac4cc49565636e6b7e6d2d69624ea99bec7 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 15:26:41 +0900 Subject: [PATCH 358/528] =?UTF-8?q?style:=20BedrockGrammarCheckFactory=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=ED=8F=AC=EB=A7=B7=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../factory/BedrockGrammarCheckFactory.java | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/factory/BedrockGrammarCheckFactory.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/factory/BedrockGrammarCheckFactory.java index 5dfc9475..4cdb41a2 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/factory/BedrockGrammarCheckFactory.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/factory/BedrockGrammarCheckFactory.java @@ -56,12 +56,12 @@ public GrammarCheckResponse checkGrammar(String sentence, GrammarLevel level) { String responseBody = response.body().asUtf8String(); JsonObject jsonResponse = gson.fromJson(responseBody, JsonObject.class); - + String content = extractContentFromResponse(jsonResponse); - + long processingTime = System.currentTimeMillis() - startTime; logger.info("Grammar check completed in {}ms", processingTime); - + return parseGrammarResponse(sentence, content); } catch (GrammarException e) { @@ -186,7 +186,7 @@ private Integer getIntOrNull(JsonObject obj, String key) { } return element.getAsInt(); } - + private String getStringOrDefault(JsonObject obj, String key, String defaultValue) { JsonElement element = obj.get(key); if (element == null || element.isJsonNull()) { @@ -194,7 +194,7 @@ private String getStringOrDefault(JsonObject obj, String key, String defaultValu } return element.getAsString(); } - + private int getIntOrDefault(JsonObject obj, String key, int defaultValue) { JsonElement element = obj.get(key); if (element == null || element.isJsonNull()) { @@ -202,7 +202,7 @@ private int getIntOrDefault(JsonObject obj, String key, int defaultValue) { } return element.getAsInt(); } - + private boolean getBooleanOrDefault(JsonObject obj, String key, boolean defaultValue) { JsonElement element = obj.get(key); if (element == null || element.isJsonNull()) { @@ -210,7 +210,7 @@ private boolean getBooleanOrDefault(JsonObject obj, String key, boolean defaultV } return element.getAsBoolean(); } - + private String extractContentFromResponse(JsonObject jsonResponse) { JsonArray contentArray = jsonResponse.getAsJsonArray("content"); if (contentArray == null || contentArray.isEmpty()) { @@ -249,12 +249,12 @@ public ConversationResponse generateConversation(String sessionId, String messag String responseBody = response.body().asUtf8String(); JsonObject jsonResponse = gson.fromJson(responseBody, JsonObject.class); - + String content = extractContentFromResponse(jsonResponse); - + long processingTime = System.currentTimeMillis() - startTime; logger.info("Conversation generated in {}ms", processingTime); - + return parseConversationResponse(sessionId, message, content); } catch (GrammarException e) { @@ -343,7 +343,7 @@ private ConversationResponse parseConversationResponse(String sessionId, String try { String jsonContent = extractJson(aiResponse); JsonObject json = gson.fromJson(jsonContent, JsonObject.class); - + JsonObject grammarCheckObj = json.getAsJsonObject("grammarCheck"); GrammarCheckResponse grammarCheck; if (grammarCheckObj != null) { @@ -358,17 +358,17 @@ private ConversationResponse parseConversationResponse(String sessionId, String .feedback("Perfect!") .build(); } - + String conversationResponse = getStringOrDefault(json, "aiResponse", ""); String tip = getStringOrDefault(json, "conversationTip", ""); - + return ConversationResponse.builder() .sessionId(sessionId) .grammarCheck(grammarCheck) .aiResponse(conversationResponse) .conversationTip(tip) .build(); - + } catch (GrammarException e) { throw e; } catch (Exception e) { @@ -386,7 +386,7 @@ private GrammarCheckResponse parseGrammarCheckFromJson(String originalSentence, int score = getIntOrDefault(json, "score", 100); boolean isCorrect = getBooleanOrDefault(json, "isCorrect", true); String feedback = getStringOrDefault(json, "feedback", ""); - + List errors = new ArrayList<>(); JsonArray errorsArray = json.getAsJsonArray("errors"); if (errorsArray != null) { @@ -403,7 +403,7 @@ private GrammarCheckResponse parseGrammarCheckFromJson(String originalSentence, errors.add(error); } } - + return GrammarCheckResponse.builder() .originalSentence(originalSentence) .correctedSentence(correctedSentence) From b84bf6a16c0b2717ef96d39576850fb42f7f04f0 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 15:26:41 +0900 Subject: [PATCH 359/528] =?UTF-8?q?style:=20GrammarConversationService=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=ED=8F=AC=EB=A7=B7=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/grammar/service/GrammarConversationService.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/service/GrammarConversationService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/service/GrammarConversationService.java index 195b7771..3bed2c41 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/service/GrammarConversationService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/service/GrammarConversationService.java @@ -66,7 +66,7 @@ public ConversationResponse chat(ConversationRequest request) { // 사용자 메시지 저장 saveMessage("USER", session, request.getMessage(), response.getGrammarCheck()); - + // AI 응답 메시지 저장 saveMessage("ASSISTANT", session, response.getAiResponse(), null); @@ -146,7 +146,7 @@ private void saveMessage(String role, GrammarSession session, String content, Gr String now = Instant.now().toString(); String messageId = UUID.randomUUID().toString(); long ttl = Instant.now().plus(GrammarConfig.sessionTtlDays(), ChronoUnit.DAYS).getEpochSecond(); - + GrammarMessage message = GrammarMessage.builder() .pk(GrammarKey.sessionPk(session.getUserId())) .sk(GrammarKey.messageSk(now, messageId)) @@ -165,7 +165,7 @@ private void saveMessage(String role, GrammarSession session, String content, Gr .createdAt(now) .ttl(ttl) .build(); - + repository.saveMessage(message); } From 6a3d2061bf95b9a94194fc4072b86b39d0d6b7a4 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 15:26:41 +0900 Subject: [PATCH 360/528] =?UTF-8?q?style:=20FeedbackResponse=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=ED=8F=AC=EB=A7=B7=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../opic/dto/response/FeedbackResponse.java | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/FeedbackResponse.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/FeedbackResponse.java index 1e612668..69ac6b44 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/FeedbackResponse.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/FeedbackResponse.java @@ -1,13 +1,14 @@ package com.mzc.secondproject.serverless.domain.opic.dto.response; + import java.util.List; -public record FeedbackResponse ( - List errors, // 오류/개선점 목록 - String correctedAnswer, // 교정된 답변 - String sampleAnswer // 모범 답변 -){ - public static FeedbackResponse perfect(String answer, String sampleAnswer) { - return new FeedbackResponse(List.of(), answer, sampleAnswer); - } +public record FeedbackResponse( + List errors, // 오류/개선점 목록 + String correctedAnswer, // 교정된 답변 + String sampleAnswer // 모범 답변 +) { + public static FeedbackResponse perfect(String answer, String sampleAnswer) { + return new FeedbackResponse(List.of(), answer, sampleAnswer); + } } From 862d7928c776f86f1e1fe796ed70579f22badec1 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 15:26:42 +0900 Subject: [PATCH 361/528] =?UTF-8?q?style:=20SessionReportResponse=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=ED=8F=AC=EB=A7=B7=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../opic/dto/response/SessionReportResponse.java | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/SessionReportResponse.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/SessionReportResponse.java index d6351a64..d6d8e9bf 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/SessionReportResponse.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/SessionReportResponse.java @@ -6,10 +6,11 @@ * 세션 종합 리포트 응답 DTO */ public record SessionReportResponse( - String estimatedLevel, // 예상 레벨 (IM1, IM2 등) - int overallScore, // 종합 점수 (0-100) - List strengths, // 잘한 점 - List weaknesses, // 개선할 점 - String feedback, // 종합 피드백 - List recommendations // 학습 추천 -) {} \ No newline at end of file + String estimatedLevel, // 예상 레벨 (IM1, IM2 등) + int overallScore, // 종합 점수 (0-100) + List strengths, // 잘한 점 + List weaknesses, // 개선할 점 + String feedback, // 종합 피드백 + List recommendations // 학습 추천 +) { +} From 4154935415cec456d9f5340f904116008e68e80b Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 15:26:42 +0900 Subject: [PATCH 362/528] =?UTF-8?q?style:=20SpeakingError=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=ED=8F=AC=EB=A7=B7=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/opic/dto/response/SpeakingError.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/SpeakingError.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/SpeakingError.java index bc054e33..fd0d8637 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/SpeakingError.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/SpeakingError.java @@ -11,8 +11,8 @@ @NoArgsConstructor @AllArgsConstructor public class SpeakingError { - private SpeakingErrorType type; - private String original; - private String corrected; - private String explanation; + private SpeakingErrorType type; + private String original; + private String corrected; + private String explanation; } From 1bf6de17808c96fb653a063c5da6d8f65d15d3e4 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 15:26:42 +0900 Subject: [PATCH 363/528] =?UTF-8?q?style:=20SpeakingErrorType=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=ED=8F=AC=EB=A7=B7=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../serverless/domain/opic/enums/SpeakingErrorType.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/enums/SpeakingErrorType.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/enums/SpeakingErrorType.java index 283f6d00..e8e26af1 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/enums/SpeakingErrorType.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/enums/SpeakingErrorType.java @@ -1,7 +1,7 @@ package com.mzc.secondproject.serverless.domain.opic.enums; public enum SpeakingErrorType { - GRAMMAR, // 문법 오류 - EXPRESSION, // 표현 개선 - VOCABULARY // 어휘 개선 + GRAMMAR, // 문법 오류 + EXPRESSION, // 표현 개선 + VOCABULARY // 어휘 개선 } From ee0e8f82289df731ce948625673037cffc198f92 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 15:26:54 +0900 Subject: [PATCH 364/528] =?UTF-8?q?style:=20OPIcException=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=ED=8F=AC=EB=A7=B7=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/opic/exception/OPIcException.java | 140 +++++++++--------- 1 file changed, 70 insertions(+), 70 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/exception/OPIcException.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/exception/OPIcException.java index a02bdb2b..ecb0f27b 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/exception/OPIcException.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/exception/OPIcException.java @@ -3,74 +3,74 @@ /** * OPIc 도메인 공통 예외 */ -public class OPIcException extends RuntimeException{ - public OPIcException(String message) { - super(message); - } - - public OPIcException(String message, Throwable cause) { - super(message, cause); - } - - /** - * Transcribe 관련 예외 - */ - public static class TranscribeException extends OPIcException { - public TranscribeException(String message) { - super(message); - } - - public TranscribeException(String message, Throwable cause) { - super(message, cause); - } - } - - /** - * 세션 관련 예외 - */ - public static class SessionException extends OPIcException { - public SessionException(String message) { - super(message); - } - } - - /** - * 피드백 생성 예외 - */ - public static class FeedbackException extends OPIcException { - public FeedbackException(String message) { - super(message); - } - - public FeedbackException(String message, Throwable cause) { - super(message, cause); - } - } - - // 피드백 파싱 실패 - public static class FeedbackParseException extends OPIcException { - public FeedbackParseException(String response, Throwable cause) { - super("피드백 응답 파싱 실패: " + truncate(response), cause); - } - } - - // 세션 리포트 파싱 실패 - public static class ReportParseException extends OPIcException { - public ReportParseException(String response, Throwable cause) { - super("세션 리포트 파싱 실패: " + truncate(response), cause); - } - } - - // Bedrock API 호출 실패 - public static class BedrockApiException extends OPIcException { - public BedrockApiException(String message, Throwable cause) { - super("Bedrock API 호출 실패: " + message, cause); - } - } - - // 응답 truncate (로그용) - private static String truncate(String text) { - if (text == null) return "null"; - return text.length() > 200 ? text.substring(0, 200) + "..." : text; - } +public class OPIcException extends RuntimeException { + public OPIcException(String message) { + super(message); + } + + public OPIcException(String message, Throwable cause) { + super(message, cause); + } + + // 응답 truncate (로그용) + private static String truncate(String text) { + if (text == null) return "null"; + return text.length() > 200 ? text.substring(0, 200) + "..." : text; + } + + /** + * Transcribe 관련 예외 + */ + public static class TranscribeException extends OPIcException { + public TranscribeException(String message) { + super(message); + } + + public TranscribeException(String message, Throwable cause) { + super(message, cause); + } + } + + /** + * 세션 관련 예외 + */ + public static class SessionException extends OPIcException { + public SessionException(String message) { + super(message); + } + } + + /** + * 피드백 생성 예외 + */ + public static class FeedbackException extends OPIcException { + public FeedbackException(String message) { + super(message); + } + + public FeedbackException(String message, Throwable cause) { + super(message, cause); + } + } + + // 피드백 파싱 실패 + public static class FeedbackParseException extends OPIcException { + public FeedbackParseException(String response, Throwable cause) { + super("피드백 응답 파싱 실패: " + truncate(response), cause); + } + } + + // 세션 리포트 파싱 실패 + public static class ReportParseException extends OPIcException { + public ReportParseException(String response, Throwable cause) { + super("세션 리포트 파싱 실패: " + truncate(response), cause); + } + } + + // Bedrock API 호출 실패 + public static class BedrockApiException extends OPIcException { + public BedrockApiException(String message, Throwable cause) { + super("Bedrock API 호출 실패: " + message, cause); + } + } } From 438c0c05c512976d9002da8c38b64267698c83d2 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 15:26:54 +0900 Subject: [PATCH 365/528] =?UTF-8?q?style:=20OPIcAnswer=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=ED=8F=AC=EB=A7=B7=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/opic/model/OPIcAnswer.java | 395 +++++++++++------- 1 file changed, 255 insertions(+), 140 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/model/OPIcAnswer.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/model/OPIcAnswer.java index e6e5da95..b45ab680 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/model/OPIcAnswer.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/model/OPIcAnswer.java @@ -10,144 +10,259 @@ * OPIc 답변 + 피드백 */ public class OPIcAnswer { - - private String pk; // SESSION#sessionId - private String sk; // Q#01 (질문 순서) - - private String sessionId; - private String questionId; - private int questionIndex; // 질문 순서 (0, 1, 2) - - // 질문 정보 (비정규화) - private String questionText; - - // 사용자 답변 - private String audioS3Key; // 답변 음성 S3 키 - private String transcript; // 음성 → 텍스트 - private double transcriptConfidence; // 변환 신뢰도 - private int durationSeconds; // 답변 길이 (초) - - // AI 피드백 - private String score; // 예상 등급 (IM1, IM2, IM3, IH, AL) - private String grammarFeedback; // 문법 피드백 (JSON) - private String vocabularyFeedback; // 어휘 피드백 (JSON) - private String contentFeedback; // 내용 피드백 - private String pronunciationFeedback; // 발음 피드백 - private String strengths; // 잘한 점 (JSON array) - private String improvements; // 개선점 (JSON array) - - // 모범 답변 - private String sampleAnswer; // 모범 답변 텍스트 - private String sampleAudioS3Key; // 모범 답변 음성 S3 키 - - // 메타데이터 - private AnswerStatus status; - private int attemptCount; // 시도 횟수 - private Instant createdAt; - private Instant completedAt; - - @DynamoDbPartitionKey - @DynamoDbAttribute("PK") - public String getPk() { return pk; } - public void setPk(String pk) { this.pk = pk; } - - @DynamoDbSortKey - @DynamoDbAttribute("SK") - public String getSk() { return sk; } - public void setSk(String sk) { this.sk = sk; } - - @DynamoDbAttribute("sessionId") - public String getSessionId() { return sessionId; } - public void setSessionId(String id) { this.sessionId = id; } - - @DynamoDbAttribute("questionId") - public String getQuestionId() { return questionId; } - public void setQuestionId(String id) { this.questionId = id; } - - @DynamoDbAttribute("questionIndex") - public int getQuestionIndex() { return questionIndex; } - public void setQuestionIndex(int idx) { this.questionIndex = idx; } - - @DynamoDbAttribute("questionText") - public String getQuestionText() { return questionText; } - public void setQuestionText(String text) { this.questionText = text; } - - @DynamoDbAttribute("audioS3Key") - public String getAudioS3Key() { return audioS3Key; } - public void setAudioS3Key(String key) { this.audioS3Key = key; } - - @DynamoDbAttribute("transcript") - public String getTranscript() { return transcript; } - public void setTranscript(String transcript) { this.transcript = transcript; } - - @DynamoDbAttribute("transcriptConfidence") - public double getTranscriptConfidence() { return transcriptConfidence; } - public void setTranscriptConfidence(double conf) { this.transcriptConfidence = conf; } - - @DynamoDbAttribute("durationSeconds") - public int getDurationSeconds() { return durationSeconds; } - public void setDurationSeconds(int duration) { this.durationSeconds = duration; } - - @DynamoDbAttribute("score") - public String getScore() { return score; } - public void setScore(String score) { this.score = score; } - - @DynamoDbAttribute("grammarFeedback") - public String getGrammarFeedback() { return grammarFeedback; } - public void setGrammarFeedback(String feedback) { this.grammarFeedback = feedback; } - - @DynamoDbAttribute("vocabularyFeedback") - public String getVocabularyFeedback() { return vocabularyFeedback; } - public void setVocabularyFeedback(String feedback) { this.vocabularyFeedback = feedback; } - - @DynamoDbAttribute("contentFeedback") - public String getContentFeedback() { return contentFeedback; } - public void setContentFeedback(String feedback) { this.contentFeedback = feedback; } - - @DynamoDbAttribute("pronunciationFeedback") - public String getPronunciationFeedback() { return pronunciationFeedback; } - public void setPronunciationFeedback(String feedback) { this.pronunciationFeedback = feedback; } - - @DynamoDbAttribute("strengths") - public String getStrengths() { return strengths; } - public void setStrengths(String strengths) { this.strengths = strengths; } - - @DynamoDbAttribute("improvements") - public String getImprovements() { return improvements; } - public void setImprovements(String improvements) { this.improvements = improvements; } - - @DynamoDbAttribute("sampleAnswer") - public String getSampleAnswer() { return sampleAnswer; } - public void setSampleAnswer(String answer) { this.sampleAnswer = answer; } - - @DynamoDbAttribute("sampleAudioS3Key") - public String getSampleAudioS3Key() { return sampleAudioS3Key; } - public void setSampleAudioS3Key(String key) { this.sampleAudioS3Key = key; } - - @DynamoDbAttribute("status") - public AnswerStatus getStatus() { return status; } - public void setStatus(AnswerStatus status) { this.status = status; } - - @DynamoDbAttribute("attemptCount") - public int getAttemptCount() { return attemptCount; } - public void setAttemptCount(int count) { this.attemptCount = count; } - - @DynamoDbAttribute("createdAt") - public Instant getCreatedAt() { return createdAt; } - public void setCreatedAt(Instant time) { this.createdAt = time; } - - @DynamoDbAttribute("completedAt") - public Instant getCompletedAt() { return completedAt; } - public void setCompletedAt(Instant time) { this.completedAt = time; } - - /** - * 답변 상태 - */ - public enum AnswerStatus { - PENDING, // 음성 업로드 대기 - UPLOADED, // 음성 업로드 완료 - PROCESSING, // Transcribe + Bedrock 처리 중 - COMPLETED, // 피드백 완료 - FAILED // 처리 실패 - } + + private String pk; // SESSION#sessionId + private String sk; // Q#01 (질문 순서) + + private String sessionId; + private String questionId; + private int questionIndex; // 질문 순서 (0, 1, 2) + + // 질문 정보 (비정규화) + private String questionText; + + // 사용자 답변 + private String audioS3Key; // 답변 음성 S3 키 + private String transcript; // 음성 → 텍스트 + private double transcriptConfidence; // 변환 신뢰도 + private int durationSeconds; // 답변 길이 (초) + + // AI 피드백 + private String score; // 예상 등급 (IM1, IM2, IM3, IH, AL) + private String grammarFeedback; // 문법 피드백 (JSON) + private String vocabularyFeedback; // 어휘 피드백 (JSON) + private String contentFeedback; // 내용 피드백 + private String pronunciationFeedback; // 발음 피드백 + private String strengths; // 잘한 점 (JSON array) + private String improvements; // 개선점 (JSON array) + + // 모범 답변 + private String sampleAnswer; // 모범 답변 텍스트 + private String sampleAudioS3Key; // 모범 답변 음성 S3 키 + + // 메타데이터 + private AnswerStatus status; + private int attemptCount; // 시도 횟수 + private Instant createdAt; + private Instant completedAt; + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { + return pk; + } + + public void setPk(String pk) { + this.pk = pk; + } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { + return sk; + } + + public void setSk(String sk) { + this.sk = sk; + } + + @DynamoDbAttribute("sessionId") + public String getSessionId() { + return sessionId; + } + + public void setSessionId(String id) { + this.sessionId = id; + } + + @DynamoDbAttribute("questionId") + public String getQuestionId() { + return questionId; + } + + public void setQuestionId(String id) { + this.questionId = id; + } + + @DynamoDbAttribute("questionIndex") + public int getQuestionIndex() { + return questionIndex; + } + + public void setQuestionIndex(int idx) { + this.questionIndex = idx; + } + + @DynamoDbAttribute("questionText") + public String getQuestionText() { + return questionText; + } + + public void setQuestionText(String text) { + this.questionText = text; + } + + @DynamoDbAttribute("audioS3Key") + public String getAudioS3Key() { + return audioS3Key; + } + + public void setAudioS3Key(String key) { + this.audioS3Key = key; + } + + @DynamoDbAttribute("transcript") + public String getTranscript() { + return transcript; + } + + public void setTranscript(String transcript) { + this.transcript = transcript; + } + + @DynamoDbAttribute("transcriptConfidence") + public double getTranscriptConfidence() { + return transcriptConfidence; + } + + public void setTranscriptConfidence(double conf) { + this.transcriptConfidence = conf; + } + + @DynamoDbAttribute("durationSeconds") + public int getDurationSeconds() { + return durationSeconds; + } + + public void setDurationSeconds(int duration) { + this.durationSeconds = duration; + } + + @DynamoDbAttribute("score") + public String getScore() { + return score; + } + + public void setScore(String score) { + this.score = score; + } + + @DynamoDbAttribute("grammarFeedback") + public String getGrammarFeedback() { + return grammarFeedback; + } + + public void setGrammarFeedback(String feedback) { + this.grammarFeedback = feedback; + } + + @DynamoDbAttribute("vocabularyFeedback") + public String getVocabularyFeedback() { + return vocabularyFeedback; + } + + public void setVocabularyFeedback(String feedback) { + this.vocabularyFeedback = feedback; + } + + @DynamoDbAttribute("contentFeedback") + public String getContentFeedback() { + return contentFeedback; + } + + public void setContentFeedback(String feedback) { + this.contentFeedback = feedback; + } + + @DynamoDbAttribute("pronunciationFeedback") + public String getPronunciationFeedback() { + return pronunciationFeedback; + } + + public void setPronunciationFeedback(String feedback) { + this.pronunciationFeedback = feedback; + } + + @DynamoDbAttribute("strengths") + public String getStrengths() { + return strengths; + } + + public void setStrengths(String strengths) { + this.strengths = strengths; + } + + @DynamoDbAttribute("improvements") + public String getImprovements() { + return improvements; + } + + public void setImprovements(String improvements) { + this.improvements = improvements; + } + + @DynamoDbAttribute("sampleAnswer") + public String getSampleAnswer() { + return sampleAnswer; + } + + public void setSampleAnswer(String answer) { + this.sampleAnswer = answer; + } + + @DynamoDbAttribute("sampleAudioS3Key") + public String getSampleAudioS3Key() { + return sampleAudioS3Key; + } + + public void setSampleAudioS3Key(String key) { + this.sampleAudioS3Key = key; + } + + @DynamoDbAttribute("status") + public AnswerStatus getStatus() { + return status; + } + + public void setStatus(AnswerStatus status) { + this.status = status; + } + + @DynamoDbAttribute("attemptCount") + public int getAttemptCount() { + return attemptCount; + } + + public void setAttemptCount(int count) { + this.attemptCount = count; + } + + @DynamoDbAttribute("createdAt") + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant time) { + this.createdAt = time; + } + + @DynamoDbAttribute("completedAt") + public Instant getCompletedAt() { + return completedAt; + } + + public void setCompletedAt(Instant time) { + this.completedAt = time; + } + + /** + * 답변 상태 + */ + public enum AnswerStatus { + PENDING, // 음성 업로드 대기 + UPLOADED, // 음성 업로드 완료 + PROCESSING, // Transcribe + Bedrock 처리 중 + COMPLETED, // 피드백 완료 + FAILED // 처리 실패 + } } From 558a205fefbb1c309300fc44b6cab16afade8efa Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 15:26:55 +0900 Subject: [PATCH 366/528] =?UTF-8?q?style:=20OPIcQuestion=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=ED=8F=AC=EB=A7=B7=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/opic/model/OPIcQuestion.java | 222 ++++++++++++------ 1 file changed, 146 insertions(+), 76 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/model/OPIcQuestion.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/model/OPIcQuestion.java index c0ab96fd..6a2056b1 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/model/OPIcQuestion.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/model/OPIcQuestion.java @@ -7,80 +7,150 @@ */ @DynamoDbBean public class OPIcQuestion { - - private String pk; // QUESTION#questionId - private String sk; // METADATA - private String gsi1pk; // TOPIC#travel - private String gsi1sk; // LEVEL#IM2 - - private String questionId; - private String topic; // 대주제 - private String subTopic; // 소주제 - private String level; // 난이도 (IM1, IM2, IM3, IH, AL) - private String questionText; // 질문 텍스트 (영어) - private String questionTextKo; // 질문 텍스트 (한국어, 참고용) - private String audioS3Key; // 질문 음성 S3 키 (Polly 캐시) - private String tips; // 답변 팁 - private int orderInSet; // 콤보 세트 내 순서 (1, 2, 3) - private boolean isActive; // 활성화 여부 - - @DynamoDbPartitionKey - @DynamoDbAttribute("PK") - public String getPk() { return pk; } - public void setPk(String pk) { this.pk = pk; } - - @DynamoDbSortKey - @DynamoDbAttribute("SK") - public String getSk() { return sk; } - public void setSk(String sk) { this.sk = sk; } - - @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") - @DynamoDbAttribute("GSI1PK") - public String getGsi1pk() { return gsi1pk; } - public void setGsi1pk(String gsi1pk) { this.gsi1pk = gsi1pk; } - - @DynamoDbSecondarySortKey(indexNames = "GSI1") - @DynamoDbAttribute("GSI1SK") - public String getGsi1sk() { return gsi1sk; } - public void setGsi1sk(String gsi1sk) { this.gsi1sk = gsi1sk; } - - @DynamoDbAttribute("questionId") - public String getQuestionId() { return questionId; } - public void setQuestionId(String id) { this.questionId = id; } - - @DynamoDbAttribute("topic") - public String getTopic() { return topic; } - public void setTopic(String topic) { this.topic = topic; } - - @DynamoDbAttribute("subTopic") - public String getSubTopic() { return subTopic; } - public void setSubTopic(String subTopic) { this.subTopic = subTopic; } - - @DynamoDbAttribute("level") - public String getLevel() { return level; } - public void setLevel(String level) { this.level = level; } - - @DynamoDbAttribute("questionText") - public String getQuestionText() { return questionText; } - public void setQuestionText(String text) { this.questionText = text; } - - @DynamoDbAttribute("questionTextKo") - public String getQuestionTextKo() { return questionTextKo; } - public void setQuestionTextKo(String text) { this.questionTextKo = text; } - - @DynamoDbAttribute("audioS3Key") - public String getAudioS3Key() { return audioS3Key; } - public void setAudioS3Key(String key) { this.audioS3Key = key; } - - @DynamoDbAttribute("tips") - public String getTips() { return tips; } - public void setTips(String tips) { this.tips = tips; } - - @DynamoDbAttribute("orderInSet") - public int getOrderInSet() { return orderInSet; } - public void setOrderInSet(int order) { this.orderInSet = order; } - - @DynamoDbAttribute("isActive") - public boolean isActive() { return isActive; } - public void setActive(boolean active) { this.isActive = active; } + + private String pk; // QUESTION#questionId + private String sk; // METADATA + private String gsi1pk; // TOPIC#travel + private String gsi1sk; // LEVEL#IM2 + + private String questionId; + private String topic; // 대주제 + private String subTopic; // 소주제 + private String level; // 난이도 (IM1, IM2, IM3, IH, AL) + private String questionText; // 질문 텍스트 (영어) + private String questionTextKo; // 질문 텍스트 (한국어, 참고용) + private String audioS3Key; // 질문 음성 S3 키 (Polly 캐시) + private String tips; // 답변 팁 + private int orderInSet; // 콤보 세트 내 순서 (1, 2, 3) + private boolean isActive; // 활성화 여부 + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { + return pk; + } + + public void setPk(String pk) { + this.pk = pk; + } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { + return sk; + } + + public void setSk(String sk) { + this.sk = sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1PK") + public String getGsi1pk() { + return gsi1pk; + } + + public void setGsi1pk(String gsi1pk) { + this.gsi1pk = gsi1pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1SK") + public String getGsi1sk() { + return gsi1sk; + } + + public void setGsi1sk(String gsi1sk) { + this.gsi1sk = gsi1sk; + } + + @DynamoDbAttribute("questionId") + public String getQuestionId() { + return questionId; + } + + public void setQuestionId(String id) { + this.questionId = id; + } + + @DynamoDbAttribute("topic") + public String getTopic() { + return topic; + } + + public void setTopic(String topic) { + this.topic = topic; + } + + @DynamoDbAttribute("subTopic") + public String getSubTopic() { + return subTopic; + } + + public void setSubTopic(String subTopic) { + this.subTopic = subTopic; + } + + @DynamoDbAttribute("level") + public String getLevel() { + return level; + } + + public void setLevel(String level) { + this.level = level; + } + + @DynamoDbAttribute("questionText") + public String getQuestionText() { + return questionText; + } + + public void setQuestionText(String text) { + this.questionText = text; + } + + @DynamoDbAttribute("questionTextKo") + public String getQuestionTextKo() { + return questionTextKo; + } + + public void setQuestionTextKo(String text) { + this.questionTextKo = text; + } + + @DynamoDbAttribute("audioS3Key") + public String getAudioS3Key() { + return audioS3Key; + } + + public void setAudioS3Key(String key) { + this.audioS3Key = key; + } + + @DynamoDbAttribute("tips") + public String getTips() { + return tips; + } + + public void setTips(String tips) { + this.tips = tips; + } + + @DynamoDbAttribute("orderInSet") + public int getOrderInSet() { + return orderInSet; + } + + public void setOrderInSet(int order) { + this.orderInSet = order; + } + + @DynamoDbAttribute("isActive") + public boolean isActive() { + return isActive; + } + + public void setActive(boolean active) { + this.isActive = active; + } } From d44360c6e514d086aaebfccb8b7535675353ff8f Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 15:26:55 +0900 Subject: [PATCH 367/528] =?UTF-8?q?style:=20OPIcSession=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=ED=8F=AC=EB=A7=B7=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/opic/model/OPIcSession.java | 319 ++++++++++++------ 1 file changed, 207 insertions(+), 112 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/model/OPIcSession.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/model/OPIcSession.java index 24790ff7..b04182b1 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/model/OPIcSession.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/model/OPIcSession.java @@ -11,118 +11,213 @@ */ @DynamoDbBean public class OPIcSession { - - private String pk; // USER#userId - private String sk; // SESSION#date#sessionId - private String gsi1pk; // SESSION#sessionId - private String gsi1sk; // METADATA - - private String sessionId; - private String userId; - private String topic; // 대주제 (travel, hobby, work 등) - private String subTopic; // 소주제 - private String targetLevel; // 목표 레벨 (IM1, IM2, IM3, IH, AL) - private SessionStatus status; // IN_PROGRESS, COMPLETED, ABANDONED - private int currentQuestionIndex; // 현재 진행 중인 질문 (0, 1, 2) - private int totalQuestions; // 총 질문 수 (기본 3) - private List questionIds; // 질문 ID 목록 - private Instant createdAt; - private Instant updatedAt; - private Instant completedAt; - private int sequenceNumber; // 인과적 일관성용 - - // 종합 결과 (세션 완료 시) - private String overallScore; // 종합 예상 등급 - private String overallFeedback; // 종합 피드백 - - @DynamoDbPartitionKey - @DynamoDbAttribute("PK") - public String getPk() { return pk; } - public void setPk(String pk) { this.pk = pk; } - - @DynamoDbSortKey - @DynamoDbAttribute("SK") - public String getSk() { return sk; } - public void setSk(String sk) { this.sk = sk; } - - @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") - @DynamoDbAttribute("GSI1PK") - public String getGsi1pk() { return gsi1pk; } - public void setGsi1pk(String gsi1pk) { this.gsi1pk = gsi1pk; } - - @DynamoDbSecondarySortKey(indexNames = "GSI1") - @DynamoDbAttribute("GSI1SK") - public String getGsi1sk() { return gsi1sk; } - public void setGsi1sk(String gsi1sk) { this.gsi1sk = gsi1sk; } - - @DynamoDbAttribute("sessionId") - public String getSessionId() { return sessionId; } - public void setSessionId(String sessionId) { this.sessionId = sessionId; } - - @DynamoDbAttribute("userId") - public String getUserId() { return userId; } - public void setUserId(String userId) { this.userId = userId; } - - @DynamoDbAttribute("topic") - public String getTopic() { return topic; } - public void setTopic(String topic) { this.topic = topic; } - - @DynamoDbAttribute("subTopic") - public String getSubTopic() { return subTopic; } - public void setSubTopic(String subTopic) { this.subTopic = subTopic; } - - @DynamoDbAttribute("targetLevel") - public String getTargetLevel() { return targetLevel; } - public void setTargetLevel(String targetLevel) { this.targetLevel = targetLevel; } - - @DynamoDbAttribute("status") - public SessionStatus getStatus() { return status; } - public void setStatus(SessionStatus status) { this.status = status; } - - @DynamoDbAttribute("currentQuestionIndex") - public int getCurrentQuestionIndex() { return currentQuestionIndex; } - public void setCurrentQuestionIndex(int idx) { this.currentQuestionIndex = idx; } - - @DynamoDbAttribute("totalQuestions") - public int getTotalQuestions() { return totalQuestions; } - public void setTotalQuestions(int total) { this.totalQuestions = total; } - - @DynamoDbAttribute("questionIds") - public List getQuestionIds() { return questionIds; } - public void setQuestionIds(List ids) { this.questionIds = ids; } - - @DynamoDbAttribute("createdAt") - public Instant getCreatedAt() { return createdAt; } - public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; } - - @DynamoDbAttribute("updatedAt") - public Instant getUpdatedAt() { return updatedAt; } - public void setUpdatedAt(Instant updatedAt) { this.updatedAt = updatedAt; } - - @DynamoDbAttribute("completedAt") - public Instant getCompletedAt() { return completedAt; } - public void setCompletedAt(Instant completedAt) { this.completedAt = completedAt; } - - @DynamoDbAttribute("sequenceNumber") - public int getSequenceNumber() { return sequenceNumber; } - public void setSequenceNumber(int seq) { this.sequenceNumber = seq; } - - @DynamoDbAttribute("overallScore") - public String getOverallScore() { return overallScore; } - public void setOverallScore(String score) { this.overallScore = score; } - - @DynamoDbAttribute("overallFeedback") - public String getOverallFeedback() { return overallFeedback; } - public void setOverallFeedback(String feedback) { this.overallFeedback = feedback; } - - /** - * 세션 상태 - */ - public enum SessionStatus { - IN_PROGRESS, - COMPLETED, - ABANDONED - } + + private String pk; // USER#userId + private String sk; // SESSION#date#sessionId + private String gsi1pk; // SESSION#sessionId + private String gsi1sk; // METADATA + + private String sessionId; + private String userId; + private String topic; // 대주제 (travel, hobby, work 등) + private String subTopic; // 소주제 + private String targetLevel; // 목표 레벨 (IM1, IM2, IM3, IH, AL) + private SessionStatus status; // IN_PROGRESS, COMPLETED, ABANDONED + private int currentQuestionIndex; // 현재 진행 중인 질문 (0, 1, 2) + private int totalQuestions; // 총 질문 수 (기본 3) + private List questionIds; // 질문 ID 목록 + private Instant createdAt; + private Instant updatedAt; + private Instant completedAt; + private int sequenceNumber; // 인과적 일관성용 + + // 종합 결과 (세션 완료 시) + private String overallScore; // 종합 예상 등급 + private String overallFeedback; // 종합 피드백 + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { + return pk; + } + + public void setPk(String pk) { + this.pk = pk; + } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { + return sk; + } + + public void setSk(String sk) { + this.sk = sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1PK") + public String getGsi1pk() { + return gsi1pk; + } + + public void setGsi1pk(String gsi1pk) { + this.gsi1pk = gsi1pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1SK") + public String getGsi1sk() { + return gsi1sk; + } + + public void setGsi1sk(String gsi1sk) { + this.gsi1sk = gsi1sk; + } + + @DynamoDbAttribute("sessionId") + public String getSessionId() { + return sessionId; + } + + public void setSessionId(String sessionId) { + this.sessionId = sessionId; + } + + @DynamoDbAttribute("userId") + public String getUserId() { + return userId; + } + + public void setUserId(String userId) { + this.userId = userId; + } + + @DynamoDbAttribute("topic") + public String getTopic() { + return topic; + } + + public void setTopic(String topic) { + this.topic = topic; + } + + @DynamoDbAttribute("subTopic") + public String getSubTopic() { + return subTopic; + } + + public void setSubTopic(String subTopic) { + this.subTopic = subTopic; + } + + @DynamoDbAttribute("targetLevel") + public String getTargetLevel() { + return targetLevel; + } + + public void setTargetLevel(String targetLevel) { + this.targetLevel = targetLevel; + } + + @DynamoDbAttribute("status") + public SessionStatus getStatus() { + return status; + } + + public void setStatus(SessionStatus status) { + this.status = status; + } + + @DynamoDbAttribute("currentQuestionIndex") + public int getCurrentQuestionIndex() { + return currentQuestionIndex; + } + + public void setCurrentQuestionIndex(int idx) { + this.currentQuestionIndex = idx; + } + + @DynamoDbAttribute("totalQuestions") + public int getTotalQuestions() { + return totalQuestions; + } + + public void setTotalQuestions(int total) { + this.totalQuestions = total; + } + + @DynamoDbAttribute("questionIds") + public List getQuestionIds() { + return questionIds; + } + + public void setQuestionIds(List ids) { + this.questionIds = ids; + } + + @DynamoDbAttribute("createdAt") + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } + + @DynamoDbAttribute("updatedAt") + public Instant getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(Instant updatedAt) { + this.updatedAt = updatedAt; + } + + @DynamoDbAttribute("completedAt") + public Instant getCompletedAt() { + return completedAt; + } + + public void setCompletedAt(Instant completedAt) { + this.completedAt = completedAt; + } + + @DynamoDbAttribute("sequenceNumber") + public int getSequenceNumber() { + return sequenceNumber; + } + + public void setSequenceNumber(int seq) { + this.sequenceNumber = seq; + } + + @DynamoDbAttribute("overallScore") + public String getOverallScore() { + return overallScore; + } + + public void setOverallScore(String score) { + this.overallScore = score; + } + + @DynamoDbAttribute("overallFeedback") + public String getOverallFeedback() { + return overallFeedback; + } + + public void setOverallFeedback(String feedback) { + this.overallFeedback = feedback; + } + + /** + * 세션 상태 + */ + public enum SessionStatus { + IN_PROGRESS, + COMPLETED, + ABANDONED + } } From d67b463281dc51c4836b05cd426563529055083a Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 15:26:55 +0900 Subject: [PATCH 368/528] =?UTF-8?q?style:=20OPIcRepository=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=ED=8F=AC=EB=A7=B7=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../opic/repository/OPIcRepository.java | 466 +++++++++--------- 1 file changed, 233 insertions(+), 233 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/repository/OPIcRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/repository/OPIcRepository.java index 2b5fd803..7f74cd7d 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/repository/OPIcRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/repository/OPIcRepository.java @@ -20,237 +20,237 @@ import java.util.stream.Collectors; public class OPIcRepository { - - private static final Logger logger = LoggerFactory.getLogger(OPIcRepository.class); - private static final String TABLE_NAME = System.getenv("OPIC_TABLE_NAME"); - - private final DynamoDbEnhancedClient enhancedClient; - private final DynamoDbTable sessionTable; - private final DynamoDbTable questionTable; - private final DynamoDbTable answerTable; - - public OPIcRepository() { - this.enhancedClient = AwsClients.dynamoDbEnhanced(); - this.sessionTable = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(OPIcSession.class)); - this.questionTable = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(OPIcQuestion.class)); - this.answerTable = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(OPIcAnswer.class)); - } - - // ==================== Session ==================== - - /** - * 새 세션 생성 - */ - public OPIcSession createSession(String userId, String topic, String subTopic, - String targetLevel, List questionIds) { - String sessionId = UUID.randomUUID().toString(); - String today = LocalDate.now(ZoneId.of("Asia/Seoul")) - .format(DateTimeFormatter.ISO_LOCAL_DATE); - Instant now = Instant.now(); - - OPIcSession session = new OPIcSession(); - session.setPk("USER#" + userId); - session.setSk("SESSION#" + today + "#" + sessionId); - session.setGsi1pk("SESSION#" + sessionId); - session.setGsi1sk("METADATA"); - - session.setSessionId(sessionId); - session.setUserId(userId); - session.setTopic(topic); - session.setSubTopic(subTopic); - session.setTargetLevel(targetLevel); - session.setStatus(OPIcSession.SessionStatus.IN_PROGRESS); - session.setCurrentQuestionIndex(0); - session.setTotalQuestions(questionIds.size()); - session.setQuestionIds(questionIds); - session.setCreatedAt(now); - session.setUpdatedAt(now); - session.setSequenceNumber(0); - - sessionTable.putItem(session); - logger.info("Session created: {}", sessionId); - - return session; - } - - /** - * 세션 ID로 조회 (GSI1 사용) - */ - public Optional findSessionById(String sessionId) { - DynamoDbIndex gsi1 = sessionTable.index("GSI1"); - - QueryConditional queryConditional = QueryConditional.keyEqualTo( - Key.builder() - .partitionValue("SESSION#" + sessionId) - .sortValue("METADATA") - .build() - ); - - return gsi1.query(QueryEnhancedRequest.builder() - .queryConditional(queryConditional) - .build()) - .stream() - .flatMap(page -> page.items().stream()) - .findFirst(); - } - - /** - * 사용자의 세션 목록 조회 (최신순) - */ - public List findSessionsByUserId(String userId, int limit) { - QueryConditional queryConditional = QueryConditional.sortBeginsWith( - Key.builder() - .partitionValue("USER#" + userId) - .sortValue("SESSION#") - .build() - ); - - return sessionTable.query(QueryEnhancedRequest.builder() - .queryConditional(queryConditional) - .scanIndexForward(false) // 최신순 - .limit(limit) - .build()) - .stream() - .flatMap(page -> page.items().stream()) - .collect(Collectors.toList()); - } - - /** - * 세션 업데이트 - */ - public void updateSession(OPIcSession session) { - session.setUpdatedAt(Instant.now()); - session.setSequenceNumber(session.getSequenceNumber() + 1); - sessionTable.putItem(session); - logger.debug("Session updated: {}", session.getSessionId()); - } - - /** - * 세션 완료 처리 - */ - public void completeSession(OPIcSession session, String overallScore, String overallFeedback) { - session.setStatus(OPIcSession.SessionStatus.COMPLETED); - session.setOverallScore(overallScore); - session.setOverallFeedback(overallFeedback); - session.setCompletedAt(Instant.now()); - updateSession(session); - logger.info("Session completed: {}", session.getSessionId()); - } - - - // ==================== Question ==================== - - /** - * 질문 ID로 조회 - */ - public Optional findQuestionById(String questionId) { - Key key = Key.builder() - .partitionValue("QUESTION#" + questionId) - .sortValue("METADATA") - .build(); - - return Optional.ofNullable(questionTable.getItem(key)); - } - - /** - * 주제 + 레벨로 질문 조회 (GSI1) - */ - public List findQuestionsByTopicAndLevel(String topic, String level) { - DynamoDbIndex gsi1 = questionTable.index("GSI1"); - - QueryConditional queryConditional = QueryConditional.keyEqualTo( - Key.builder() - .partitionValue("TOPIC#" + topic) - .sortValue("LEVEL#" + level) - .build() - ); - - return gsi1.query(queryConditional) - .stream() - .flatMap(page -> page.items().stream()) - .filter(OPIcQuestion::isActive) - .collect(Collectors.toList()); - } - - /** - * 여러 질문 ID로 조회 - */ - public List findQuestionsByIds(List questionIds) { - return questionIds.stream() - .map(this::findQuestionById) - .filter(Optional::isPresent) - .map(Optional::get) - .collect(Collectors.toList()); - } - - /** - * 질문 저장 (마스터 데이터 등록용) - */ - public void saveQuestion(OPIcQuestion question) { - question.setPk("QUESTION#" + question.getQuestionId()); - question.setSk("METADATA"); - question.setGsi1pk("TOPIC#" + question.getTopic()); - question.setGsi1sk("LEVEL#" + question.getLevel()); - - questionTable.putItem(question); - logger.info("Question saved: {}", question.getQuestionId()); - } - - // ==================== Answer ==================== - - /** - * 답변 저장 - */ - public void saveAnswer(OPIcAnswer answer) { - answer.setPk("SESSION#" + answer.getSessionId()); - answer.setSk(String.format("Q#%02d", answer.getQuestionIndex())); - - if (answer.getCreatedAt() == null) { - answer.setCreatedAt(Instant.now()); - } - - answerTable.putItem(answer); - logger.debug("Answer saved: session={}, questionIndex={}", - answer.getSessionId(), answer.getQuestionIndex()); - } - - /** - * 세션의 특정 질문 답변 조회 - */ - public Optional findAnswer(String sessionId, int questionIndex) { - Key key = Key.builder() - .partitionValue("SESSION#" + sessionId) - .sortValue(String.format("Q#%02d", questionIndex)) - .build(); - - return Optional.ofNullable(answerTable.getItem(key)); - } - - /** - * 세션의 모든 답변 조회 - */ - public List findAnswersBySessionId(String sessionId) { - QueryConditional queryConditional = QueryConditional.sortBeginsWith( - Key.builder() - .partitionValue("SESSION#" + sessionId) - .sortValue("Q#") - .build() - ); - - return answerTable.query(queryConditional) - .stream() - .flatMap(page -> page.items().stream()) - .collect(Collectors.toList()); - } - - /** - * 답변 업데이트 (피드백 추가 등) - */ - public void updateAnswer(OPIcAnswer answer) { - answerTable.putItem(answer); - logger.debug("Answer updated: session={}, questionIndex={}", - answer.getSessionId(), answer.getQuestionIndex()); - } - - + + private static final Logger logger = LoggerFactory.getLogger(OPIcRepository.class); + private static final String TABLE_NAME = System.getenv("OPIC_TABLE_NAME"); + + private final DynamoDbEnhancedClient enhancedClient; + private final DynamoDbTable sessionTable; + private final DynamoDbTable questionTable; + private final DynamoDbTable answerTable; + + public OPIcRepository() { + this.enhancedClient = AwsClients.dynamoDbEnhanced(); + this.sessionTable = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(OPIcSession.class)); + this.questionTable = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(OPIcQuestion.class)); + this.answerTable = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(OPIcAnswer.class)); + } + + // ==================== Session ==================== + + /** + * 새 세션 생성 + */ + public OPIcSession createSession(String userId, String topic, String subTopic, + String targetLevel, List questionIds) { + String sessionId = UUID.randomUUID().toString(); + String today = LocalDate.now(ZoneId.of("Asia/Seoul")) + .format(DateTimeFormatter.ISO_LOCAL_DATE); + Instant now = Instant.now(); + + OPIcSession session = new OPIcSession(); + session.setPk("USER#" + userId); + session.setSk("SESSION#" + today + "#" + sessionId); + session.setGsi1pk("SESSION#" + sessionId); + session.setGsi1sk("METADATA"); + + session.setSessionId(sessionId); + session.setUserId(userId); + session.setTopic(topic); + session.setSubTopic(subTopic); + session.setTargetLevel(targetLevel); + session.setStatus(OPIcSession.SessionStatus.IN_PROGRESS); + session.setCurrentQuestionIndex(0); + session.setTotalQuestions(questionIds.size()); + session.setQuestionIds(questionIds); + session.setCreatedAt(now); + session.setUpdatedAt(now); + session.setSequenceNumber(0); + + sessionTable.putItem(session); + logger.info("Session created: {}", sessionId); + + return session; + } + + /** + * 세션 ID로 조회 (GSI1 사용) + */ + public Optional findSessionById(String sessionId) { + DynamoDbIndex gsi1 = sessionTable.index("GSI1"); + + QueryConditional queryConditional = QueryConditional.keyEqualTo( + Key.builder() + .partitionValue("SESSION#" + sessionId) + .sortValue("METADATA") + .build() + ); + + return gsi1.query(QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .build()) + .stream() + .flatMap(page -> page.items().stream()) + .findFirst(); + } + + /** + * 사용자의 세션 목록 조회 (최신순) + */ + public List findSessionsByUserId(String userId, int limit) { + QueryConditional queryConditional = QueryConditional.sortBeginsWith( + Key.builder() + .partitionValue("USER#" + userId) + .sortValue("SESSION#") + .build() + ); + + return sessionTable.query(QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .scanIndexForward(false) // 최신순 + .limit(limit) + .build()) + .stream() + .flatMap(page -> page.items().stream()) + .collect(Collectors.toList()); + } + + /** + * 세션 업데이트 + */ + public void updateSession(OPIcSession session) { + session.setUpdatedAt(Instant.now()); + session.setSequenceNumber(session.getSequenceNumber() + 1); + sessionTable.putItem(session); + logger.debug("Session updated: {}", session.getSessionId()); + } + + /** + * 세션 완료 처리 + */ + public void completeSession(OPIcSession session, String overallScore, String overallFeedback) { + session.setStatus(OPIcSession.SessionStatus.COMPLETED); + session.setOverallScore(overallScore); + session.setOverallFeedback(overallFeedback); + session.setCompletedAt(Instant.now()); + updateSession(session); + logger.info("Session completed: {}", session.getSessionId()); + } + + + // ==================== Question ==================== + + /** + * 질문 ID로 조회 + */ + public Optional findQuestionById(String questionId) { + Key key = Key.builder() + .partitionValue("QUESTION#" + questionId) + .sortValue("METADATA") + .build(); + + return Optional.ofNullable(questionTable.getItem(key)); + } + + /** + * 주제 + 레벨로 질문 조회 (GSI1) + */ + public List findQuestionsByTopicAndLevel(String topic, String level) { + DynamoDbIndex gsi1 = questionTable.index("GSI1"); + + QueryConditional queryConditional = QueryConditional.keyEqualTo( + Key.builder() + .partitionValue("TOPIC#" + topic) + .sortValue("LEVEL#" + level) + .build() + ); + + return gsi1.query(queryConditional) + .stream() + .flatMap(page -> page.items().stream()) + .filter(OPIcQuestion::isActive) + .collect(Collectors.toList()); + } + + /** + * 여러 질문 ID로 조회 + */ + public List findQuestionsByIds(List questionIds) { + return questionIds.stream() + .map(this::findQuestionById) + .filter(Optional::isPresent) + .map(Optional::get) + .collect(Collectors.toList()); + } + + /** + * 질문 저장 (마스터 데이터 등록용) + */ + public void saveQuestion(OPIcQuestion question) { + question.setPk("QUESTION#" + question.getQuestionId()); + question.setSk("METADATA"); + question.setGsi1pk("TOPIC#" + question.getTopic()); + question.setGsi1sk("LEVEL#" + question.getLevel()); + + questionTable.putItem(question); + logger.info("Question saved: {}", question.getQuestionId()); + } + + // ==================== Answer ==================== + + /** + * 답변 저장 + */ + public void saveAnswer(OPIcAnswer answer) { + answer.setPk("SESSION#" + answer.getSessionId()); + answer.setSk(String.format("Q#%02d", answer.getQuestionIndex())); + + if (answer.getCreatedAt() == null) { + answer.setCreatedAt(Instant.now()); + } + + answerTable.putItem(answer); + logger.debug("Answer saved: session={}, questionIndex={}", + answer.getSessionId(), answer.getQuestionIndex()); + } + + /** + * 세션의 특정 질문 답변 조회 + */ + public Optional findAnswer(String sessionId, int questionIndex) { + Key key = Key.builder() + .partitionValue("SESSION#" + sessionId) + .sortValue(String.format("Q#%02d", questionIndex)) + .build(); + + return Optional.ofNullable(answerTable.getItem(key)); + } + + /** + * 세션의 모든 답변 조회 + */ + public List findAnswersBySessionId(String sessionId) { + QueryConditional queryConditional = QueryConditional.sortBeginsWith( + Key.builder() + .partitionValue("SESSION#" + sessionId) + .sortValue("Q#") + .build() + ); + + return answerTable.query(queryConditional) + .stream() + .flatMap(page -> page.items().stream()) + .collect(Collectors.toList()); + } + + /** + * 답변 업데이트 (피드백 추가 등) + */ + public void updateAnswer(OPIcAnswer answer) { + answerTable.putItem(answer); + logger.debug("Answer updated: session={}, questionIndex={}", + answer.getSessionId(), answer.getQuestionIndex()); + } + + } From 7b0d9e2e3cefb98186f3d68f40e06f09917f7dc8 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 15:26:55 +0900 Subject: [PATCH 369/528] =?UTF-8?q?style:=20FeedbackService=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=ED=8F=AC=EB=A7=B7=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/opic/service/FeedbackService.java | 554 +++++++++--------- 1 file changed, 277 insertions(+), 277 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/service/FeedbackService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/service/FeedbackService.java index 9343ae51..0180e7a5 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/service/FeedbackService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/service/FeedbackService.java @@ -18,282 +18,282 @@ import java.util.List; /** -* OPIc 피드백 생성 서비스 -*/ + * OPIc 피드백 생성 서비스 + */ public class FeedbackService { - - private static final Logger logger = LoggerFactory.getLogger(FeedbackService.class); - private static final Gson gson = new GsonBuilder().setPrettyPrinting().create(); - private static final String MODEL_ID = "anthropic.claude-3-5-sonnet-20241022-v2:0"; - private static final int MAX_TOKENS = 2000; - - /** - * 사용자 답변에 대한 피드백 생성 - */ - public FeedbackResponse generateFeedback(String question, String userAnswer, String targetLevel) { - logger.info("피드백 생성, 대상 Level: {}", targetLevel); - - String prompt = buildFeedbackPrompt(question, userAnswer, targetLevel); - String response = invokeClaude(prompt); - String jsonResponse = JsonUtil.extractJson(response); - - return parseFeedbackResponse(jsonResponse); - } - - - /** - * 세션 종합 리포트 생성 - */ - public SessionReportResponse generateSessionReport(String sessionSummary, String targetLevel) { - logger.info("세션 리포트 생성, 대상 Level: {}", targetLevel); - - String prompt = buildSessionReportPrompt(sessionSummary, targetLevel); - String response = invokeClaude(prompt); - String jsonResponse = JsonUtil.extractJson(response); - - return parseSessionReportResponse(jsonResponse); - } - - - /** - * 개별 질문 피드백 프롬프트 - */ - private String buildFeedbackPrompt(String question, String userAnswer, String targetLevel) { - return String.format(""" - You are an expert OPIc speaking evaluator. - - ## Question - %s - - ## User's Answer - %s - - ## Target Level - %s - - ## Task - Analyze the answer and provide feedback in the following JSON format only: - - { - "errors": [ - { - "type": "GRAMMAR | EXPRESSION | VOCABULARY", - "original": "원본 표현", - "corrected": "교정된 표현", - "explanation": "설명 (한국어)" - } - ], - "correctedAnswer": "전체 교정된 답변 (영어)", - "sampleAnswer": "목표 레벨에 맞는 모범 답변 (영어, 4-6문장)" - } - - Error types: - - GRAMMAR: 문법 오류 (시제, 관사, 주어-동사 일치 등) - - EXPRESSION: 더 자연스러운 표현 제안 - - VOCABULARY: 더 적절하거나 풍부한 어휘 제안 - - Rules: - 1. errors 배열은 최대 5개까지만 포함 - 2. 오류가 없으면 errors는 빈 배열 [] - 3. explanation은 한국어로 간결하게 - 4. sampleAnswer는 목표 레벨에 맞는 자연스러운 답변 - - Respond with ONLY the JSON, no markdown code blocks. - """, question, userAnswer, targetLevel); - } - - /** - * 세션 종합 리포트 프롬프트 - */ - private String buildSessionReportPrompt(String sessionSummary, String targetLevel) { - return String.format(""" - You are an expert OPIc speaking coach creating a comprehensive session report. - - ## Session Summary (Questions and Answers) - %s - - ## Target Level - %s - - ## Task - Generate a detailed learning report in the following JSON format only: - - { - "estimatedLevel": "NL | NM | NH | IL | IM1 | IM2 | IM3 | IH | AL", - "overallScore": 0-100, - "strengths": ["잘한 점 1 (한국어)", "잘한 점 2", "잘한 점 3"], - "weaknesses": ["개선할 점 1 (한국어)", "개선할 점 2", "개선할 점 3"], - "feedback": "종합 피드백 (한국어, 3-4문장, 격려하는 톤)", - "recommendations": ["학습 추천 1 (한국어)", "학습 추천 2"] - } - - Evaluation criteria: - - Task completion: 질문에 적절히 답했는가 - - Fluency: 유창성, 자연스러움 - - Grammar: 문법 정확도 - - Vocabulary: 어휘 다양성 - - Content: 내용의 구체성 - - Be encouraging but honest. Provide specific, actionable feedback in Korean. - Respond with ONLY the JSON, no markdown code blocks. - """, sessionSummary, targetLevel); - } - - - /** - * Claude 호출 (일반 텍스트 응답) - */ - private String invokeClaude(String prompt) { - try { - JsonObject requestBody = buildRequestBody(prompt); - - InvokeModelRequest request = InvokeModelRequest.builder() - .modelId(MODEL_ID) - .contentType("application/json") - .body(SdkBytes.fromUtf8String(gson.toJson(requestBody))) - .build(); - - long startTime = System.currentTimeMillis(); - InvokeModelResponse response = AwsClients.bedrock().invokeModel(request); - long elapsed = System.currentTimeMillis() - startTime; - - logger.info("Bedrock 응답 수신: {}ms", elapsed); - - JsonObject responseJson = JsonParser.parseString( - response.body().asUtf8String() - ).getAsJsonObject(); - - return responseJson - .getAsJsonArray("content") - .get(0) - .getAsJsonObject() - .get("text") - .getAsString(); - - } catch (Exception e) { - logger.error("Bedrock 호출 실패", e); - throw new OPIcException.BedrockApiException(e.getMessage(), e); - } - } - - /** - * Bedrock 요청 Body 생성 - */ - private JsonObject buildRequestBody(String prompt) { - JsonObject requestBody = new JsonObject(); - requestBody.addProperty("anthropic_version", "bedrock-2023-05-31"); - requestBody.addProperty("max_tokens", MAX_TOKENS); - - JsonArray messages = new JsonArray(); - JsonObject userMessage = new JsonObject(); - userMessage.addProperty("role", "user"); - userMessage.addProperty("content", prompt); - messages.add(userMessage); - - requestBody.add("messages", messages); - return requestBody; - } - - // ==================== 응답 파싱 ==================== - - /** - * 피드백 응답 파싱 - * - * Claude 응답 JSON 구조: - * { - * "errors": [{ "type": "GRAMMAR", "original": "...", "corrected": "...", "explanation": "..." }], - * "correctedAnswer": "...", - * "sampleAnswer": "..." - * } - */ - private FeedbackResponse parseFeedbackResponse(String jsonResponse) { - try { - JsonObject json = JsonParser.parseString(jsonResponse).getAsJsonObject(); - - // errors 배열 파싱 - List errors = parseErrors(json.getAsJsonArray("errors")); - - // 응답 DTO 생성 - return new FeedbackResponse( - errors, - json.get("correctedAnswer").getAsString(), - json.get("sampleAnswer").getAsString() - ); - - } catch (Exception e) { - logger.error("피드백 파싱 실패: {}", jsonResponse, e); - throw new OPIcException.FeedbackParseException(jsonResponse, e); - } - } - - /** - * 세션 리포트 응답 파싱 - * - * Claude 응답 JSON 구조: - * { - * "estimatedLevel": "IM2", - * "overallScore": 72, - * "strengths": ["...", "..."], - * "weaknesses": ["...", "..."], - * "feedback": "...", - * "recommendations": ["...", "..."] - * } - */ - private SessionReportResponse parseSessionReportResponse(String jsonResponse) { - try { - JsonObject json = JsonParser.parseString(jsonResponse).getAsJsonObject(); - - return new SessionReportResponse( - json.get("estimatedLevel").getAsString(), - json.get("overallScore").getAsInt(), - JsonUtil.toStringList(json.getAsJsonArray("strengths")), - JsonUtil.toStringList(json.getAsJsonArray("weaknesses")), - json.get("feedback").getAsString(), - JsonUtil.toStringList(json.getAsJsonArray("recommendations")) - ); - - } catch (Exception e) { - logger.error("세션 리포트 파싱 실패: {}", jsonResponse, e); - throw new OPIcException.ReportParseException(jsonResponse, e); - } - } - - /** - * errors 배열 파싱 - */ - private List parseErrors(JsonArray errorsArray) { - List errors = new ArrayList<>(); - - if (errorsArray == null || errorsArray.isEmpty()) { - return errors; - } - - for (JsonElement el : errorsArray) { - JsonObject obj = el.getAsJsonObject(); - errors.add(SpeakingError.builder() - .type(parseErrorType(obj.get("type").getAsString())) - .original(obj.get("original").getAsString()) - .corrected(obj.get("corrected").getAsString()) - .explanation(obj.get("explanation").getAsString()) - .build()); - } - - return errors; - } - - - /** - * 오류 타입 문자열 -> Enum 변환 - */ - private SpeakingErrorType parseErrorType(String typeStr) { - try { - // "GRAMMAR | EXPRESSION | VOCABULARY" 형태 처리 - String cleaned = typeStr.replace(" ", "").split("\\|")[0].trim(); - return SpeakingErrorType.valueOf(cleaned.toUpperCase()); - } catch (Exception e) { - logger.warn("알 수 없는 오류 타입: {}, 기본값 GRAMMAR 사용", typeStr); - return SpeakingErrorType.GRAMMAR; - } - } - + + private static final Logger logger = LoggerFactory.getLogger(FeedbackService.class); + private static final Gson gson = new GsonBuilder().setPrettyPrinting().create(); + private static final String MODEL_ID = "anthropic.claude-3-5-sonnet-20241022-v2:0"; + private static final int MAX_TOKENS = 2000; + + /** + * 사용자 답변에 대한 피드백 생성 + */ + public FeedbackResponse generateFeedback(String question, String userAnswer, String targetLevel) { + logger.info("피드백 생성, 대상 Level: {}", targetLevel); + + String prompt = buildFeedbackPrompt(question, userAnswer, targetLevel); + String response = invokeClaude(prompt); + String jsonResponse = JsonUtil.extractJson(response); + + return parseFeedbackResponse(jsonResponse); + } + + + /** + * 세션 종합 리포트 생성 + */ + public SessionReportResponse generateSessionReport(String sessionSummary, String targetLevel) { + logger.info("세션 리포트 생성, 대상 Level: {}", targetLevel); + + String prompt = buildSessionReportPrompt(sessionSummary, targetLevel); + String response = invokeClaude(prompt); + String jsonResponse = JsonUtil.extractJson(response); + + return parseSessionReportResponse(jsonResponse); + } + + + /** + * 개별 질문 피드백 프롬프트 + */ + private String buildFeedbackPrompt(String question, String userAnswer, String targetLevel) { + return String.format(""" + You are an expert OPIc speaking evaluator. + + ## Question + %s + + ## User's Answer + %s + + ## Target Level + %s + + ## Task + Analyze the answer and provide feedback in the following JSON format only: + + { + "errors": [ + { + "type": "GRAMMAR | EXPRESSION | VOCABULARY", + "original": "원본 표현", + "corrected": "교정된 표현", + "explanation": "설명 (한국어)" + } + ], + "correctedAnswer": "전체 교정된 답변 (영어)", + "sampleAnswer": "목표 레벨에 맞는 모범 답변 (영어, 4-6문장)" + } + + Error types: + - GRAMMAR: 문법 오류 (시제, 관사, 주어-동사 일치 등) + - EXPRESSION: 더 자연스러운 표현 제안 + - VOCABULARY: 더 적절하거나 풍부한 어휘 제안 + + Rules: + 1. errors 배열은 최대 5개까지만 포함 + 2. 오류가 없으면 errors는 빈 배열 [] + 3. explanation은 한국어로 간결하게 + 4. sampleAnswer는 목표 레벨에 맞는 자연스러운 답변 + + Respond with ONLY the JSON, no markdown code blocks. + """, question, userAnswer, targetLevel); + } + + /** + * 세션 종합 리포트 프롬프트 + */ + private String buildSessionReportPrompt(String sessionSummary, String targetLevel) { + return String.format(""" + You are an expert OPIc speaking coach creating a comprehensive session report. + + ## Session Summary (Questions and Answers) + %s + + ## Target Level + %s + + ## Task + Generate a detailed learning report in the following JSON format only: + + { + "estimatedLevel": "NL | NM | NH | IL | IM1 | IM2 | IM3 | IH | AL", + "overallScore": 0-100, + "strengths": ["잘한 점 1 (한국어)", "잘한 점 2", "잘한 점 3"], + "weaknesses": ["개선할 점 1 (한국어)", "개선할 점 2", "개선할 점 3"], + "feedback": "종합 피드백 (한국어, 3-4문장, 격려하는 톤)", + "recommendations": ["학습 추천 1 (한국어)", "학습 추천 2"] + } + + Evaluation criteria: + - Task completion: 질문에 적절히 답했는가 + - Fluency: 유창성, 자연스러움 + - Grammar: 문법 정확도 + - Vocabulary: 어휘 다양성 + - Content: 내용의 구체성 + + Be encouraging but honest. Provide specific, actionable feedback in Korean. + Respond with ONLY the JSON, no markdown code blocks. + """, sessionSummary, targetLevel); + } + + + /** + * Claude 호출 (일반 텍스트 응답) + */ + private String invokeClaude(String prompt) { + try { + JsonObject requestBody = buildRequestBody(prompt); + + InvokeModelRequest request = InvokeModelRequest.builder() + .modelId(MODEL_ID) + .contentType("application/json") + .body(SdkBytes.fromUtf8String(gson.toJson(requestBody))) + .build(); + + long startTime = System.currentTimeMillis(); + InvokeModelResponse response = AwsClients.bedrock().invokeModel(request); + long elapsed = System.currentTimeMillis() - startTime; + + logger.info("Bedrock 응답 수신: {}ms", elapsed); + + JsonObject responseJson = JsonParser.parseString( + response.body().asUtf8String() + ).getAsJsonObject(); + + return responseJson + .getAsJsonArray("content") + .get(0) + .getAsJsonObject() + .get("text") + .getAsString(); + + } catch (Exception e) { + logger.error("Bedrock 호출 실패", e); + throw new OPIcException.BedrockApiException(e.getMessage(), e); + } + } + + /** + * Bedrock 요청 Body 생성 + */ + private JsonObject buildRequestBody(String prompt) { + JsonObject requestBody = new JsonObject(); + requestBody.addProperty("anthropic_version", "bedrock-2023-05-31"); + requestBody.addProperty("max_tokens", MAX_TOKENS); + + JsonArray messages = new JsonArray(); + JsonObject userMessage = new JsonObject(); + userMessage.addProperty("role", "user"); + userMessage.addProperty("content", prompt); + messages.add(userMessage); + + requestBody.add("messages", messages); + return requestBody; + } + + // ==================== 응답 파싱 ==================== + + /** + * 피드백 응답 파싱 + *

+ * Claude 응답 JSON 구조: + * { + * "errors": [{ "type": "GRAMMAR", "original": "...", "corrected": "...", "explanation": "..." }], + * "correctedAnswer": "...", + * "sampleAnswer": "..." + * } + */ + private FeedbackResponse parseFeedbackResponse(String jsonResponse) { + try { + JsonObject json = JsonParser.parseString(jsonResponse).getAsJsonObject(); + + // errors 배열 파싱 + List errors = parseErrors(json.getAsJsonArray("errors")); + + // 응답 DTO 생성 + return new FeedbackResponse( + errors, + json.get("correctedAnswer").getAsString(), + json.get("sampleAnswer").getAsString() + ); + + } catch (Exception e) { + logger.error("피드백 파싱 실패: {}", jsonResponse, e); + throw new OPIcException.FeedbackParseException(jsonResponse, e); + } + } + + /** + * 세션 리포트 응답 파싱 + *

+ * Claude 응답 JSON 구조: + * { + * "estimatedLevel": "IM2", + * "overallScore": 72, + * "strengths": ["...", "..."], + * "weaknesses": ["...", "..."], + * "feedback": "...", + * "recommendations": ["...", "..."] + * } + */ + private SessionReportResponse parseSessionReportResponse(String jsonResponse) { + try { + JsonObject json = JsonParser.parseString(jsonResponse).getAsJsonObject(); + + return new SessionReportResponse( + json.get("estimatedLevel").getAsString(), + json.get("overallScore").getAsInt(), + JsonUtil.toStringList(json.getAsJsonArray("strengths")), + JsonUtil.toStringList(json.getAsJsonArray("weaknesses")), + json.get("feedback").getAsString(), + JsonUtil.toStringList(json.getAsJsonArray("recommendations")) + ); + + } catch (Exception e) { + logger.error("세션 리포트 파싱 실패: {}", jsonResponse, e); + throw new OPIcException.ReportParseException(jsonResponse, e); + } + } + + /** + * errors 배열 파싱 + */ + private List parseErrors(JsonArray errorsArray) { + List errors = new ArrayList<>(); + + if (errorsArray == null || errorsArray.isEmpty()) { + return errors; + } + + for (JsonElement el : errorsArray) { + JsonObject obj = el.getAsJsonObject(); + errors.add(SpeakingError.builder() + .type(parseErrorType(obj.get("type").getAsString())) + .original(obj.get("original").getAsString()) + .corrected(obj.get("corrected").getAsString()) + .explanation(obj.get("explanation").getAsString()) + .build()); + } + + return errors; + } + + + /** + * 오류 타입 문자열 -> Enum 변환 + */ + private SpeakingErrorType parseErrorType(String typeStr) { + try { + // "GRAMMAR | EXPRESSION | VOCABULARY" 형태 처리 + String cleaned = typeStr.replace(" ", "").split("\\|")[0].trim(); + return SpeakingErrorType.valueOf(cleaned.toUpperCase()); + } catch (Exception e) { + logger.warn("알 수 없는 오류 타입: {}, 기본값 GRAMMAR 사용", typeStr); + return SpeakingErrorType.GRAMMAR; + } + } + } From d06ca165709d4168bff1e89678195acc6007cdd3 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 15:27:02 +0900 Subject: [PATCH 370/528] =?UTF-8?q?style:=20TranscribeProxyService=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=ED=8F=AC=EB=A7=B7=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../opic/service/TranscribeProxyService.java | 305 +++++++++--------- 1 file changed, 153 insertions(+), 152 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/service/TranscribeProxyService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/service/TranscribeProxyService.java index 2183e30e..cc0ceff6 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/service/TranscribeProxyService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/service/TranscribeProxyService.java @@ -20,155 +20,156 @@ * Cross-Account로 Transcribe 기능 사용 */ public class TranscribeProxyService { - - private static final Logger logger = LoggerFactory.getLogger(TranscribeProxyService.class); - private static final Gson gson = new Gson(); - - private static final SsmClient ssmClient = SsmClient.builder().build(); - - // API Key 캐싱 (Lambda 인스턴스 재사용 시 SSM 호출 최소화) - private static String cachedApiKey = null; - - private final String proxyUrl; - private final String apiKeyParamName; - private final HttpClient httpClient; - - public TranscribeProxyService() { - this.proxyUrl = System.getenv("TRANSCRIBE_PROXY_URL"); - this.apiKeyParamName = System.getenv("TRANSCRIBE_API_KEY"); - this.httpClient = HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(10)) - .build(); - - if (proxyUrl == null || apiKeyParamName == null) { - logger.warn("TRANSCRIBE_PROXY_URL or TRANSCRIBE_API_KEY is not set"); - } - } - - // 테스트용 생성자 - public TranscribeProxyService(String proxyUrl, String apiKey) { - this.proxyUrl = proxyUrl; - this.apiKeyParamName = apiKey; - this.httpClient = HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(10)) - .build(); - } - - /** - * API Key 조회 (Parameter Store에서 + 캐싱) - */ - private String getApiKey() { - if (cachedApiKey != null) { - return cachedApiKey; - } - - try { - logger.debug("Fetching API Key from Parameter Store: {}", apiKeyParamName); - - var response = ssmClient.getParameter( - GetParameterRequest.builder() - .name(apiKeyParamName) - .withDecryption(true) - .build() - ); - - cachedApiKey = response.parameter().value(); - logger.info("API Key loaded from Parameter Store"); - - return cachedApiKey; - } catch (Exception e) { - logger.error("Failed to get API Key from Parameter Store", e); - throw new OPIcException.TranscribeException("API Key 로드 실패", e); - } - } - - /** - * 음성 파일을 텍스트로 변환 - * - * @param audioBase64 Base64 인코딩된 음성 데이터 - * @param sessionId 세션 ID - * @return 변환된 텍스트 결과 - */ - public TranscribeResult transcribe(String audioBase64, String sessionId) { - return transcribe(audioBase64, sessionId, "en-US"); - } - - /** - * 음성 파일을 텍스트로 변환 (언어 지정) - * - * @param audioBase64 Base64 인코딩된 음성 데이터 - * @param sessionId 세션 ID - * @param languageCode 언어 코드 (en-US, ko-KR 등) - * @return 변환된 텍스트 결과 - */ - public TranscribeResult transcribe(String audioBase64, String sessionId, String languageCode) { - logger.info("Transcribe 요청 시작 - sessionId: {}, language: {}", sessionId, languageCode); - - try { - // API Key 조회 - String apiKey = getApiKey(); - - // 요청 바디 생성 - JsonObject requestBody = new JsonObject(); - requestBody.addProperty("audio_data", audioBase64); - requestBody.addProperty("session_id", sessionId); - requestBody.addProperty("language_code", languageCode); - - // HTTP 요청 생성 - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(proxyUrl)) - .header("Content-Type", "application/json") - .header("X-Api-Key", apiKey) - .timeout(Duration.ofSeconds(120)) // Transcribe 처리 시간 고려 - .POST(HttpRequest.BodyPublishers.ofString(gson.toJson(requestBody))) - .build(); - - long startTime = System.currentTimeMillis(); - - // API 호출 - HttpResponse response = httpClient.send( - request, - HttpResponse.BodyHandlers.ofString() - ); - - long elapsed = System.currentTimeMillis() - startTime; - logger.info("Transcribe Proxy 응답 - status: {}, 소요시간: {}ms", - response.statusCode(), elapsed); - - // 응답 처리 - if (response.statusCode() != 200) { - logger.error("Transcribe 실패 - status: {}, body: {}", - response.statusCode(), response.body()); - throw new OPIcException.TranscribeException("Transcribe 실패: " + response.statusCode()); - } - - // JSON 파싱 - JsonObject resultJson = JsonParser.parseString(response.body()).getAsJsonObject(); - - String transcript = resultJson.get("transcript").getAsString(); - String jobName = resultJson.get("job_name").getAsString(); - double confidence = resultJson.get("confidence").getAsDouble(); - - logger.info("Transcribe 완료 - jobName: {}, confidence: {}", jobName, confidence); - logger.debug("Transcript: {}", transcript); - - return new TranscribeResult(transcript, jobName, confidence); - - } catch (OPIcException.TranscribeException e) { - throw e; - } catch (Exception e) { - logger.error("Transcribe 호출 중 오류 발생", e); - throw new OPIcException.TranscribeException("음성 변환 실패: " + e.getMessage(), e); - } - } - - /** - * Transcribe 결과 레코드 - */ - public record TranscribeResult( - String transcript, - String jobName, - double confidence - ) {} - -} \ No newline at end of file + + private static final Logger logger = LoggerFactory.getLogger(TranscribeProxyService.class); + private static final Gson gson = new Gson(); + + private static final SsmClient ssmClient = SsmClient.builder().build(); + + // API Key 캐싱 (Lambda 인스턴스 재사용 시 SSM 호출 최소화) + private static String cachedApiKey = null; + + private final String proxyUrl; + private final String apiKeyParamName; + private final HttpClient httpClient; + + public TranscribeProxyService() { + this.proxyUrl = System.getenv("TRANSCRIBE_PROXY_URL"); + this.apiKeyParamName = System.getenv("TRANSCRIBE_API_KEY"); + this.httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .build(); + + if (proxyUrl == null || apiKeyParamName == null) { + logger.warn("TRANSCRIBE_PROXY_URL or TRANSCRIBE_API_KEY is not set"); + } + } + + // 테스트용 생성자 + public TranscribeProxyService(String proxyUrl, String apiKey) { + this.proxyUrl = proxyUrl; + this.apiKeyParamName = apiKey; + this.httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .build(); + } + + /** + * API Key 조회 (Parameter Store에서 + 캐싱) + */ + private String getApiKey() { + if (cachedApiKey != null) { + return cachedApiKey; + } + + try { + logger.debug("Fetching API Key from Parameter Store: {}", apiKeyParamName); + + var response = ssmClient.getParameter( + GetParameterRequest.builder() + .name(apiKeyParamName) + .withDecryption(true) + .build() + ); + + cachedApiKey = response.parameter().value(); + logger.info("API Key loaded from Parameter Store"); + + return cachedApiKey; + } catch (Exception e) { + logger.error("Failed to get API Key from Parameter Store", e); + throw new OPIcException.TranscribeException("API Key 로드 실패", e); + } + } + + /** + * 음성 파일을 텍스트로 변환 + * + * @param audioBase64 Base64 인코딩된 음성 데이터 + * @param sessionId 세션 ID + * @return 변환된 텍스트 결과 + */ + public TranscribeResult transcribe(String audioBase64, String sessionId) { + return transcribe(audioBase64, sessionId, "en-US"); + } + + /** + * 음성 파일을 텍스트로 변환 (언어 지정) + * + * @param audioBase64 Base64 인코딩된 음성 데이터 + * @param sessionId 세션 ID + * @param languageCode 언어 코드 (en-US, ko-KR 등) + * @return 변환된 텍스트 결과 + */ + public TranscribeResult transcribe(String audioBase64, String sessionId, String languageCode) { + logger.info("Transcribe 요청 시작 - sessionId: {}, language: {}", sessionId, languageCode); + + try { + // API Key 조회 + String apiKey = getApiKey(); + + // 요청 바디 생성 + JsonObject requestBody = new JsonObject(); + requestBody.addProperty("audio_data", audioBase64); + requestBody.addProperty("session_id", sessionId); + requestBody.addProperty("language_code", languageCode); + + // HTTP 요청 생성 + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(proxyUrl)) + .header("Content-Type", "application/json") + .header("X-Api-Key", apiKey) + .timeout(Duration.ofSeconds(120)) // Transcribe 처리 시간 고려 + .POST(HttpRequest.BodyPublishers.ofString(gson.toJson(requestBody))) + .build(); + + long startTime = System.currentTimeMillis(); + + // API 호출 + HttpResponse response = httpClient.send( + request, + HttpResponse.BodyHandlers.ofString() + ); + + long elapsed = System.currentTimeMillis() - startTime; + logger.info("Transcribe Proxy 응답 - status: {}, 소요시간: {}ms", + response.statusCode(), elapsed); + + // 응답 처리 + if (response.statusCode() != 200) { + logger.error("Transcribe 실패 - status: {}, body: {}", + response.statusCode(), response.body()); + throw new OPIcException.TranscribeException("Transcribe 실패: " + response.statusCode()); + } + + // JSON 파싱 + JsonObject resultJson = JsonParser.parseString(response.body()).getAsJsonObject(); + + String transcript = resultJson.get("transcript").getAsString(); + String jobName = resultJson.get("job_name").getAsString(); + double confidence = resultJson.get("confidence").getAsDouble(); + + logger.info("Transcribe 완료 - jobName: {}, confidence: {}", jobName, confidence); + logger.debug("Transcript: {}", transcript); + + return new TranscribeResult(transcript, jobName, confidence); + + } catch (OPIcException.TranscribeException e) { + throw e; + } catch (Exception e) { + logger.error("Transcribe 호출 중 오류 발생", e); + throw new OPIcException.TranscribeException("음성 변환 실패: " + e.getMessage(), e); + } + } + + /** + * Transcribe 결과 레코드 + */ + public record TranscribeResult( + String transcript, + String jobName, + double confidence + ) { + } + +} From 2ce8f1a8b7869ff9f4e680a3245dd2dad704f9b6 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 15:27:02 +0900 Subject: [PATCH 371/528] =?UTF-8?q?style:=20VocabularyConfig=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=ED=8F=AC=EB=A7=B7=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vocabulary/config/VocabularyConfig.java | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/config/VocabularyConfig.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/config/VocabularyConfig.java index 8567b5f9..836bfda0 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/config/VocabularyConfig.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/config/VocabularyConfig.java @@ -7,41 +7,41 @@ * 환경 변수로 오버라이드 가능 */ public final class VocabularyConfig { - + // 일일 학습 관련 private static final int DEFAULT_NEW_WORDS_COUNT = 50; private static final int DEFAULT_REVIEW_WORDS_COUNT = 5; - + // 단어 상태 전이 관련 private static final int DEFAULT_TRANSITION_TO_REVIEWING_THRESHOLD = 2; private static final int DEFAULT_TRANSITION_TO_MASTERED_THRESHOLD = 5; private static final int DEFAULT_SECOND_INTERVAL_DAYS = 6; - + private static final int NEW_WORDS_COUNT = EnvConfig.getIntOrDefault("VOCAB_NEW_WORDS_COUNT", DEFAULT_NEW_WORDS_COUNT); private static final int REVIEW_WORDS_COUNT = EnvConfig.getIntOrDefault("VOCAB_REVIEW_WORDS_COUNT", DEFAULT_REVIEW_WORDS_COUNT); private static final int TRANSITION_TO_REVIEWING_THRESHOLD = EnvConfig.getIntOrDefault("VOCAB_TRANSITION_TO_REVIEWING", DEFAULT_TRANSITION_TO_REVIEWING_THRESHOLD); private static final int TRANSITION_TO_MASTERED_THRESHOLD = EnvConfig.getIntOrDefault("VOCAB_TRANSITION_TO_MASTERED", DEFAULT_TRANSITION_TO_MASTERED_THRESHOLD); private static final int SECOND_INTERVAL_DAYS = EnvConfig.getIntOrDefault("VOCAB_SECOND_INTERVAL_DAYS", DEFAULT_SECOND_INTERVAL_DAYS); - + private VocabularyConfig() { } - + public static int newWordsCount() { return NEW_WORDS_COUNT; } - + public static int reviewWordsCount() { return REVIEW_WORDS_COUNT; } - + public static int transitionToReviewingThreshold() { return TRANSITION_TO_REVIEWING_THRESHOLD; } - + public static int transitionToMasteredThreshold() { return TRANSITION_TO_MASTERED_THRESHOLD; } - + public static int secondIntervalDays() { return SECOND_INTERVAL_DAYS; } From 002e70894c6a9864c955b731d4a43094d5736856 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 15:27:02 +0900 Subject: [PATCH 372/528] =?UTF-8?q?style:=20DailyStudyCommandService=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=ED=8F=AC=EB=A7=B7=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/vocabulary/service/DailyStudyCommandService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyCommandService.java index 4713bdaf..2a3114b2 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyCommandService.java @@ -2,10 +2,10 @@ import com.mzc.secondproject.serverless.common.dto.PaginatedResult; import com.mzc.secondproject.serverless.common.enums.StudyLevel; -import com.mzc.secondproject.serverless.domain.vocabulary.config.VocabularyConfig; import com.mzc.secondproject.serverless.domain.badge.service.BadgeService; import com.mzc.secondproject.serverless.domain.stats.model.UserStats; import com.mzc.secondproject.serverless.domain.stats.repository.UserStatsRepository; +import com.mzc.secondproject.serverless.domain.vocabulary.config.VocabularyConfig; import com.mzc.secondproject.serverless.domain.vocabulary.constants.VocabKey; import com.mzc.secondproject.serverless.domain.vocabulary.exception.VocabularyException; import com.mzc.secondproject.serverless.domain.vocabulary.model.DailyStudy; From 57a04081273bfb15373af3cb3dad80f8998c33d7 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 15:27:03 +0900 Subject: [PATCH 373/528] =?UTF-8?q?style:=20TestCommandService=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=ED=8F=AC=EB=A7=B7=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/TestCommandService.java | 61 ++++++++++--------- 1 file changed, 31 insertions(+), 30 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java index 62c3bde2..9b3ae1a1 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java @@ -95,49 +95,49 @@ public SubmitTestResult submitTest(String userId, String testId, String testType List answers, String startedAt) { // 1. 답안 채점 GradingResult gradingResult = gradeAnswers(answers); - + // 2. 테스트 결과 저장 TestResult testResult = saveTestResult(userId, testId, testType, gradingResult, startedAt); - + // 3. 오답 단어 자동 북마크 bookmarkIncorrectWords(userId, gradingResult.incorrectWordIds()); - + // 4. SNS 알림 발행 publishTestResultToSns(userId, gradingResult.results()); - + logger.info("Test submitted: userId={}, testId={}, successRate={}%", userId, testId, gradingResult.successRate()); - + return new SubmitTestResult( testId, testType, gradingResult.totalQuestions(), gradingResult.correctCount(), gradingResult.incorrectCount(), gradingResult.successRate(), gradingResult.results() ); } - + private GradingResult gradeAnswers(List answers) { List wordIds = answers.stream() .map(SubmitTestRequest.TestAnswer::getWordId) .collect(Collectors.toList()); - + Map wordMap = wordRepository.findByIds(wordIds).stream() .collect(Collectors.toMap(Word::getWordId, w -> w)); - + int correctCount = 0; int incorrectCount = 0; List incorrectWordIds = new ArrayList<>(); List> results = new ArrayList<>(); - + for (SubmitTestRequest.TestAnswer answer : answers) { String wordId = answer.getWordId(); String userAnswer = answer.getAnswer(); Word word = wordMap.get(wordId); - + if (word == null) continue; - + boolean isCorrect = isAnswerCorrect(userAnswer, word.getKorean()); results.add(buildResultItem(word, userAnswer, isCorrect)); - + if (isCorrect) { correctCount++; } else { @@ -145,20 +145,20 @@ private GradingResult gradeAnswers(List answers) { incorrectWordIds.add(wordId); } } - + int totalQuestions = answers.size(); double successRate = totalQuestions > 0 ? (correctCount * 100.0 / totalQuestions) : 0; - + return new GradingResult(wordIds, correctCount, incorrectCount, incorrectWordIds, totalQuestions, successRate, results); } - + private boolean isAnswerCorrect(String userAnswer, String correctAnswer) { return userAnswer != null && !userAnswer.isBlank() && correctAnswer.trim().equalsIgnoreCase(userAnswer.trim()); } - + private Map buildResultItem(Word word, String userAnswer, boolean isCorrect) { Map resultItem = new HashMap<>(); resultItem.put("wordId", word.getWordId()); @@ -168,12 +168,12 @@ private Map buildResultItem(Word word, String userAnswer, boolea resultItem.put("isCorrect", isCorrect); return resultItem; } - + private TestResult saveTestResult(String userId, String testId, String testType, - GradingResult gradingResult, String startedAt) { + GradingResult gradingResult, String startedAt) { String now = Instant.now().toString(); String today = LocalDate.now().toString(); - + TestResult testResult = TestResult.builder() .pk("TEST#" + userId) .sk("RESULT#" + now) @@ -191,20 +191,10 @@ private TestResult saveTestResult(String userId, String testId, String testType, .startedAt(startedAt) .completedAt(now) .build(); - + testResultRepository.save(testResult); return testResult; } - - private record GradingResult( - List wordIds, - int correctCount, - int incorrectCount, - List incorrectWordIds, - int totalQuestions, - double successRate, - List> results - ) {} private void bookmarkIncorrectWords(String userId, List incorrectWordIds) { if (incorrectWordIds == null || incorrectWordIds.isEmpty()) { @@ -291,6 +281,17 @@ private void publishTestResultToSns(String userId, List> res } } + private record GradingResult( + List wordIds, + int correctCount, + int incorrectCount, + List incorrectWordIds, + int totalQuestions, + double successRate, + List> results + ) { + } + public record StartTestResult(String testId, String testType, List> questions, int totalQuestions, String startedAt) { } From 8af9280b44802c3b14d061d08685d226fe5b7ec5 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 15:27:03 +0900 Subject: [PATCH 374/528] =?UTF-8?q?style:=20TestService=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=ED=8F=AC=EB=A7=B7=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vocabulary/service/TestService.java | 59 ++++++++++--------- 1 file changed, 30 insertions(+), 29 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestService.java index 9851b274..f365c4ba 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestService.java @@ -88,46 +88,46 @@ public SubmitTestResult submitTest(String userId, String testId, String testType List> answers, String startedAt) { // 1. 답안 채점 GradingResult gradingResult = gradeAnswers(answers); - + // 2. 테스트 결과 저장 saveTestResult(userId, testId, testType, gradingResult, startedAt); - + // 3. SNS 알림 발행 publishTestResultToSns(userId, gradingResult.results()); - + logger.info("Test submitted: userId={}, testId={}, successRate={}%", userId, testId, gradingResult.successRate()); - + return new SubmitTestResult( testId, testType, gradingResult.totalQuestions(), gradingResult.correctCount(), gradingResult.incorrectCount(), gradingResult.successRate(), gradingResult.results() ); } - + private GradingResult gradeAnswers(List> answers) { List wordIds = answers.stream() .map(a -> (String) a.get("wordId")) .collect(Collectors.toList()); - + Map wordMap = wordRepository.findByIds(wordIds).stream() .collect(Collectors.toMap(Word::getWordId, w -> w)); - + int correctCount = 0; int incorrectCount = 0; List incorrectWordIds = new ArrayList<>(); List> results = new ArrayList<>(); - + for (Map answer : answers) { String wordId = (String) answer.get("wordId"); String userAnswer = (String) answer.get("answer"); Word word = wordMap.get(wordId); - + if (word == null) continue; - + boolean isCorrect = isAnswerCorrect(userAnswer, word.getKorean()); results.add(buildResultItem(word, userAnswer, isCorrect)); - + if (isCorrect) { correctCount++; } else { @@ -135,20 +135,20 @@ private GradingResult gradeAnswers(List> answers) { incorrectWordIds.add(wordId); } } - + int totalQuestions = answers.size(); double successRate = totalQuestions > 0 ? (correctCount * 100.0 / totalQuestions) : 0; - + return new GradingResult(wordIds, correctCount, incorrectCount, incorrectWordIds, totalQuestions, successRate, results); } - + private boolean isAnswerCorrect(String userAnswer, String correctAnswer) { return userAnswer != null && !userAnswer.isBlank() && correctAnswer.trim().equalsIgnoreCase(userAnswer.trim()); } - + private Map buildResultItem(Word word, String userAnswer, boolean isCorrect) { Map resultItem = new HashMap<>(); resultItem.put("wordId", word.getWordId()); @@ -158,12 +158,12 @@ private Map buildResultItem(Word word, String userAnswer, boolea resultItem.put("isCorrect", isCorrect); return resultItem; } - + private void saveTestResult(String userId, String testId, String testType, - GradingResult gradingResult, String startedAt) { + GradingResult gradingResult, String startedAt) { String now = Instant.now().toString(); String today = LocalDate.now().toString(); - + TestResult testResult = TestResult.builder() .pk("TEST#" + userId) .sk("RESULT#" + now) @@ -180,19 +180,9 @@ private void saveTestResult(String userId, String testId, String testType, .startedAt(startedAt) .completedAt(now) .build(); - + testResultRepository.save(testResult); } - - private record GradingResult( - List wordIds, - int correctCount, - int incorrectCount, - List incorrectWordIds, - int totalQuestions, - double successRate, - List> results - ) {} public PaginatedResult getTestResults(String userId, int limit, String cursor) { return testResultRepository.findByUserIdWithPagination(userId, limit, cursor); @@ -265,6 +255,17 @@ private void publishTestResultToSns(String userId, List> res } } + private record GradingResult( + List wordIds, + int correctCount, + int incorrectCount, + List incorrectWordIds, + int totalQuestions, + double successRate, + List> results + ) { + } + public record StartTestResult(String testId, String testType, List> questions, int totalQuestions, String startedAt) { } From a425f7ad566b9ddcc5a29c3e09c0d0c24462bdf7 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 15:27:13 +0900 Subject: [PATCH 375/528] =?UTF-8?q?style:=20CommonErrorCodeSpec=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=ED=8F=AC=EB=A7=B7=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../exception/CommonErrorCodeSpec.groovy | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/common/exception/CommonErrorCodeSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/common/exception/CommonErrorCodeSpec.groovy index 142e12aa..ee8e14f5 100644 --- a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/common/exception/CommonErrorCodeSpec.groovy +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/common/exception/CommonErrorCodeSpec.groovy @@ -14,22 +14,22 @@ class CommonErrorCodeSpec extends Specification { !errorCode.getMessage().isEmpty() where: - errorCode | expectedCode | expectedStatusCode - CommonErrorCode.UNAUTHORIZED | "AUTH_001" | 401 - CommonErrorCode.FORBIDDEN | "AUTH_002" | 403 - CommonErrorCode.INVALID_TOKEN | "AUTH_003" | 401 - CommonErrorCode.TOKEN_EXPIRED | "AUTH_004" | 401 - CommonErrorCode.INVALID_INPUT | "VALIDATION_001" | 400 - CommonErrorCode.REQUIRED_FIELD_MISSING | "VALIDATION_002" | 400 - CommonErrorCode.INVALID_FORMAT | "VALIDATION_003" | 400 - CommonErrorCode.VALUE_OUT_OF_RANGE | "VALIDATION_004" | 400 - CommonErrorCode.RESOURCE_NOT_FOUND | "RESOURCE_001" | 404 - CommonErrorCode.METHOD_NOT_ALLOWED | "RESOURCE_003" | 405 - CommonErrorCode.RESOURCE_ALREADY_EXISTS | "RESOURCE_002" | 409 - CommonErrorCode.INTERNAL_SERVER_ERROR | "SYSTEM_001" | 500 - CommonErrorCode.DATABASE_ERROR | "SYSTEM_002" | 500 - CommonErrorCode.EXTERNAL_API_ERROR | "SYSTEM_003" | 502 - CommonErrorCode.SERVICE_UNAVAILABLE | "SYSTEM_004" | 503 + errorCode | expectedCode | expectedStatusCode + CommonErrorCode.UNAUTHORIZED | "AUTH_001" | 401 + CommonErrorCode.FORBIDDEN | "AUTH_002" | 403 + CommonErrorCode.INVALID_TOKEN | "AUTH_003" | 401 + CommonErrorCode.TOKEN_EXPIRED | "AUTH_004" | 401 + CommonErrorCode.INVALID_INPUT | "VALIDATION_001" | 400 + CommonErrorCode.REQUIRED_FIELD_MISSING | "VALIDATION_002" | 400 + CommonErrorCode.INVALID_FORMAT | "VALIDATION_003" | 400 + CommonErrorCode.VALUE_OUT_OF_RANGE | "VALIDATION_004" | 400 + CommonErrorCode.RESOURCE_NOT_FOUND | "RESOURCE_001" | 404 + CommonErrorCode.METHOD_NOT_ALLOWED | "RESOURCE_003" | 405 + CommonErrorCode.RESOURCE_ALREADY_EXISTS | "RESOURCE_002" | 409 + CommonErrorCode.INTERNAL_SERVER_ERROR | "SYSTEM_001" | 500 + CommonErrorCode.DATABASE_ERROR | "SYSTEM_002" | 500 + CommonErrorCode.EXTERNAL_API_ERROR | "SYSTEM_003" | 502 + CommonErrorCode.SERVICE_UNAVAILABLE | "SYSTEM_004" | 503 } def "인증 관련 에러 코드들은 4xx"() { From bf87d005b4aed995855e0140ea729a21f435b7de Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 15:27:13 +0900 Subject: [PATCH 376/528] =?UTF-8?q?style:=20JsonUtilSpec=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=ED=8F=AC=EB=A7=B7=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mzc/secondproject/serverless/common/util/JsonUtilSpec.groovy | 1 - 1 file changed, 1 deletion(-) diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/common/util/JsonUtilSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/common/util/JsonUtilSpec.groovy index 3b6768ff..e3343447 100644 --- a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/common/util/JsonUtilSpec.groovy +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/common/util/JsonUtilSpec.groovy @@ -3,7 +3,6 @@ package com.mzc.secondproject.serverless.common.util import com.google.gson.JsonArray import com.google.gson.JsonParser import spock.lang.Specification -import spock.lang.Unroll class JsonUtilSpec extends Specification { From c1dea55570f95c206ad998da21b7795eae585b90 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 15:27:13 +0900 Subject: [PATCH 377/528] =?UTF-8?q?style:=20BadgeTypeSpec=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=ED=8F=AC=EB=A7=B7=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/badge/enums/BadgeTypeSpec.groovy | 80 +++++++++---------- 1 file changed, 40 insertions(+), 40 deletions(-) diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/badge/enums/BadgeTypeSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/badge/enums/BadgeTypeSpec.groovy index 26d44e2d..19a64976 100644 --- a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/badge/enums/BadgeTypeSpec.groovy +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/badge/enums/BadgeTypeSpec.groovy @@ -13,19 +13,19 @@ class BadgeTypeSpec extends Specification { BadgeType.fromString(value) == expected where: - value | expected - "FIRST_STEP" | BadgeType.FIRST_STEP - "first_step" | BadgeType.FIRST_STEP - "First_Step" | BadgeType.FIRST_STEP - "STREAK_3" | BadgeType.STREAK_3 - "STREAK_7" | BadgeType.STREAK_7 - "STREAK_30" | BadgeType.STREAK_30 - "WORDS_100" | BadgeType.WORDS_100 - "WORDS_500" | BadgeType.WORDS_500 - "WORDS_1000" | BadgeType.WORDS_1000 - null | null - "INVALID" | null - "" | null + value | expected + "FIRST_STEP" | BadgeType.FIRST_STEP + "first_step" | BadgeType.FIRST_STEP + "First_Step" | BadgeType.FIRST_STEP + "STREAK_3" | BadgeType.STREAK_3 + "STREAK_7" | BadgeType.STREAK_7 + "STREAK_30" | BadgeType.STREAK_30 + "WORDS_100" | BadgeType.WORDS_100 + "WORDS_500" | BadgeType.WORDS_500 + "WORDS_1000" | BadgeType.WORDS_1000 + null | null + "INVALID" | null + "" | null } // ==================== Category Tests ==================== @@ -36,22 +36,22 @@ class BadgeTypeSpec extends Specification { badge.getCategory() == expectedCategory where: - badge | expectedCategory - BadgeType.FIRST_STEP | "FIRST_STUDY" - BadgeType.STREAK_3 | "STREAK" - BadgeType.STREAK_7 | "STREAK" - BadgeType.STREAK_30 | "STREAK" - BadgeType.WORDS_100 | "WORDS_LEARNED" - BadgeType.WORDS_500 | "WORDS_LEARNED" - BadgeType.WORDS_1000 | "WORDS_LEARNED" - BadgeType.PERFECT_SCORE | "PERFECT_TEST" - BadgeType.TEST_10 | "TESTS_COMPLETED" - BadgeType.ACCURACY_90 | "ACCURACY" - BadgeType.GAME_FIRST_PLAY | "GAMES_PLAYED" - BadgeType.GAME_10_WINS | "GAMES_WON" - BadgeType.QUICK_GUESSER | "QUICK_GUESSES" - BadgeType.PERFECT_DRAWER | "PERFECT_DRAWS" - BadgeType.MASTER | "ALL_BADGES" + badge | expectedCategory + BadgeType.FIRST_STEP | "FIRST_STUDY" + BadgeType.STREAK_3 | "STREAK" + BadgeType.STREAK_7 | "STREAK" + BadgeType.STREAK_30 | "STREAK" + BadgeType.WORDS_100 | "WORDS_LEARNED" + BadgeType.WORDS_500 | "WORDS_LEARNED" + BadgeType.WORDS_1000 | "WORDS_LEARNED" + BadgeType.PERFECT_SCORE | "PERFECT_TEST" + BadgeType.TEST_10 | "TESTS_COMPLETED" + BadgeType.ACCURACY_90 | "ACCURACY" + BadgeType.GAME_FIRST_PLAY | "GAMES_PLAYED" + BadgeType.GAME_10_WINS | "GAMES_WON" + BadgeType.QUICK_GUESSER | "QUICK_GUESSES" + BadgeType.PERFECT_DRAWER | "PERFECT_DRAWS" + BadgeType.MASTER | "ALL_BADGES" } // ==================== Threshold Tests ==================== @@ -62,17 +62,17 @@ class BadgeTypeSpec extends Specification { badge.getThreshold() == expectedThreshold where: - badge | expectedThreshold - BadgeType.FIRST_STEP | 1 - BadgeType.STREAK_3 | 3 - BadgeType.STREAK_7 | 7 - BadgeType.STREAK_30 | 30 - BadgeType.WORDS_100 | 100 - BadgeType.WORDS_500 | 500 - BadgeType.WORDS_1000 | 1000 - BadgeType.TEST_10 | 10 - BadgeType.ACCURACY_90 | 90 - BadgeType.GAME_10_WINS | 10 + badge | expectedThreshold + BadgeType.FIRST_STEP | 1 + BadgeType.STREAK_3 | 3 + BadgeType.STREAK_7 | 7 + BadgeType.STREAK_30 | 30 + BadgeType.WORDS_100 | 100 + BadgeType.WORDS_500 | 500 + BadgeType.WORDS_1000 | 1000 + BadgeType.TEST_10 | 10 + BadgeType.ACCURACY_90 | 90 + BadgeType.GAME_10_WINS | 10 } // ==================== Property Tests ==================== From 95a662ca9278b237b42a7f9986a9871e0178b455 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 15:27:13 +0900 Subject: [PATCH 378/528] =?UTF-8?q?style:=20ChattingErrorCodeSpec=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=ED=8F=AC=EB=A7=B7=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../exception/ChattingErrorCodeSpec.groovy | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCodeSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCodeSpec.groovy index 66742acb..d1348464 100644 --- a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCodeSpec.groovy +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCodeSpec.groovy @@ -19,27 +19,27 @@ class ChattingErrorCodeSpec extends Specification { !errorCode.getMessage().isEmpty() where: - errorCode | expectedCode | expectedStatusCode - ChattingErrorCode.ROOM_NOT_FOUND | "ROOM_001" | 404 - ChattingErrorCode.ROOM_ALREADY_EXISTS | "ROOM_002" | 409 - ChattingErrorCode.ROOM_FULL | "ROOM_003" | 400 - ChattingErrorCode.ROOM_CLOSED | "ROOM_004" | 400 - ChattingErrorCode.ROOM_INVALID_PASSWORD | "ROOM_005" | 401 - ChattingErrorCode.ROOM_NOT_OWNER | "ROOM_006" | 403 - ChattingErrorCode.MESSAGE_NOT_FOUND | "MSG_001" | 404 - ChattingErrorCode.MESSAGE_TOO_LONG | "MSG_002" | 400 - ChattingErrorCode.INVALID_MESSAGE_TYPE | "MSG_003" | 400 - ChattingErrorCode.NOT_ROOM_MEMBER | "MEMBER_001" | 403 - ChattingErrorCode.ALREADY_JOINED | "MEMBER_002" | 409 - ChattingErrorCode.INVALID_ROOM_TOKEN | "MEMBER_003" | 401 - ChattingErrorCode.INVALID_CHAT_LEVEL | "LEVEL_001" | 400 - ChattingErrorCode.CONNECTION_FAILED | "CONN_001" | 500 - ChattingErrorCode.CONNECTION_TIMEOUT | "CONN_002" | 408 - ChattingErrorCode.GAME_START_FAILED | "GAME_001" | 400 - ChattingErrorCode.GAME_STOP_FAILED | "GAME_002" | 400 - ChattingErrorCode.GAME_NOT_IN_PROGRESS | "GAME_003" | 400 - ChattingErrorCode.GAME_ALREADY_IN_PROGRESS| "GAME_004" | 409 - ChattingErrorCode.NOT_GAME_STARTER | "GAME_005" | 403 + errorCode | expectedCode | expectedStatusCode + ChattingErrorCode.ROOM_NOT_FOUND | "ROOM_001" | 404 + ChattingErrorCode.ROOM_ALREADY_EXISTS | "ROOM_002" | 409 + ChattingErrorCode.ROOM_FULL | "ROOM_003" | 400 + ChattingErrorCode.ROOM_CLOSED | "ROOM_004" | 400 + ChattingErrorCode.ROOM_INVALID_PASSWORD | "ROOM_005" | 401 + ChattingErrorCode.ROOM_NOT_OWNER | "ROOM_006" | 403 + ChattingErrorCode.MESSAGE_NOT_FOUND | "MSG_001" | 404 + ChattingErrorCode.MESSAGE_TOO_LONG | "MSG_002" | 400 + ChattingErrorCode.INVALID_MESSAGE_TYPE | "MSG_003" | 400 + ChattingErrorCode.NOT_ROOM_MEMBER | "MEMBER_001" | 403 + ChattingErrorCode.ALREADY_JOINED | "MEMBER_002" | 409 + ChattingErrorCode.INVALID_ROOM_TOKEN | "MEMBER_003" | 401 + ChattingErrorCode.INVALID_CHAT_LEVEL | "LEVEL_001" | 400 + ChattingErrorCode.CONNECTION_FAILED | "CONN_001" | 500 + ChattingErrorCode.CONNECTION_TIMEOUT | "CONN_002" | 408 + ChattingErrorCode.GAME_START_FAILED | "GAME_001" | 400 + ChattingErrorCode.GAME_STOP_FAILED | "GAME_002" | 400 + ChattingErrorCode.GAME_NOT_IN_PROGRESS | "GAME_003" | 400 + ChattingErrorCode.GAME_ALREADY_IN_PROGRESS | "GAME_004" | 409 + ChattingErrorCode.NOT_GAME_STARTER | "GAME_005" | 403 } def "모든 에러 코드 개수 확인"() { From 4f55e65e658b7f90fcba0b7b105a3a7dcd930c67 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 15:27:13 +0900 Subject: [PATCH 379/528] =?UTF-8?q?style:=20GrammarLevelSpec=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=ED=8F=AC=EB=A7=B7=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../serverless/domain/grammar/enums/GrammarLevelSpec.groovy | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/grammar/enums/GrammarLevelSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/grammar/enums/GrammarLevelSpec.groovy index 2b92eb9f..2b7203a5 100644 --- a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/grammar/enums/GrammarLevelSpec.groovy +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/grammar/enums/GrammarLevelSpec.groovy @@ -69,9 +69,9 @@ class GrammarLevelSpec extends Specification { GrammarLevel.fromStringOrDefault(value, defaultValue) == expected where: - value | defaultValue | expected - "BEGINNER" | GrammarLevel.ADVANCED | GrammarLevel.BEGINNER - null | GrammarLevel.BEGINNER | GrammarLevel.BEGINNER + value | defaultValue | expected + "BEGINNER" | GrammarLevel.ADVANCED | GrammarLevel.BEGINNER + null | GrammarLevel.BEGINNER | GrammarLevel.BEGINNER "INVALID" | GrammarLevel.INTERMEDIATE | GrammarLevel.INTERMEDIATE } From 5c6aff1c4fd1c4e5a5d1347b74b7e0526aef840b Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 15:27:13 +0900 Subject: [PATCH 380/528] =?UTF-8?q?style:=20GrammarErrorCodeSpec=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=ED=8F=AC=EB=A7=B7=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../exception/GrammarErrorCodeSpec.groovy | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/grammar/exception/GrammarErrorCodeSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/grammar/exception/GrammarErrorCodeSpec.groovy index 0032b9a1..3a786aea 100644 --- a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/grammar/exception/GrammarErrorCodeSpec.groovy +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/grammar/exception/GrammarErrorCodeSpec.groovy @@ -19,15 +19,15 @@ class GrammarErrorCodeSpec extends Specification { !errorCode.getMessage().isEmpty() where: - errorCode | expectedCode | expectedStatusCode - GrammarErrorCode.INVALID_REQUEST | "GRAMMAR_000" | 400 - GrammarErrorCode.INVALID_SENTENCE | "GRAMMAR_001" | 400 - GrammarErrorCode.GRAMMAR_CHECK_FAILED | "GRAMMAR_002" | 500 - GrammarErrorCode.INVALID_LEVEL | "GRAMMAR_003" | 400 - GrammarErrorCode.BEDROCK_API_ERROR | "GRAMMAR_004" | 502 - GrammarErrorCode.BEDROCK_RESPONSE_PARSE_ERROR | "GRAMMAR_005" | 500 - GrammarErrorCode.SESSION_NOT_FOUND | "GRAMMAR_006" | 404 - GrammarErrorCode.SESSION_EXPIRED | "GRAMMAR_007" | 410 + errorCode | expectedCode | expectedStatusCode + GrammarErrorCode.INVALID_REQUEST | "GRAMMAR_000" | 400 + GrammarErrorCode.INVALID_SENTENCE | "GRAMMAR_001" | 400 + GrammarErrorCode.GRAMMAR_CHECK_FAILED | "GRAMMAR_002" | 500 + GrammarErrorCode.INVALID_LEVEL | "GRAMMAR_003" | 400 + GrammarErrorCode.BEDROCK_API_ERROR | "GRAMMAR_004" | 502 + GrammarErrorCode.BEDROCK_RESPONSE_PARSE_ERROR | "GRAMMAR_005" | 500 + GrammarErrorCode.SESSION_NOT_FOUND | "GRAMMAR_006" | 404 + GrammarErrorCode.SESSION_EXPIRED | "GRAMMAR_007" | 410 } def "모든 에러 코드 개수 확인"() { From c116134435986c1d8ad8e9add13d1b61db81dd7b Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 15:27:13 +0900 Subject: [PATCH 381/528] =?UTF-8?q?style:=20VocabularyErrorCodeSpec=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=ED=8F=AC=EB=A7=B7=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../exception/VocabularyErrorCodeSpec.groovy | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/vocabulary/exception/VocabularyErrorCodeSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/vocabulary/exception/VocabularyErrorCodeSpec.groovy index 1b680739..9c6384be 100644 --- a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/vocabulary/exception/VocabularyErrorCodeSpec.groovy +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/vocabulary/exception/VocabularyErrorCodeSpec.groovy @@ -19,22 +19,22 @@ class VocabularyErrorCodeSpec extends Specification { !errorCode.getMessage().isEmpty() where: - errorCode | expectedCode | expectedStatusCode - VocabularyErrorCode.WORD_NOT_FOUND | "WORD_001" | 404 - VocabularyErrorCode.WORD_ALREADY_EXISTS | "WORD_002" | 409 - VocabularyErrorCode.INVALID_WORD_DATA | "WORD_003" | 400 - VocabularyErrorCode.USER_WORD_NOT_FOUND | "USER_WORD_001" | 404 - VocabularyErrorCode.INVALID_DIFFICULTY | "USER_WORD_002" | 400 - VocabularyErrorCode.INVALID_WORD_STATUS | "USER_WORD_003" | 400 - VocabularyErrorCode.DAILY_STUDY_NOT_FOUND | "STUDY_001" | 404 - VocabularyErrorCode.STUDY_LIMIT_EXCEEDED | "STUDY_002" | 400 - VocabularyErrorCode.INVALID_STUDY_LEVEL | "STUDY_003" | 400 - VocabularyErrorCode.INVALID_CATEGORY | "CATEGORY_001" | 400 - VocabularyErrorCode.INVALID_LEVEL | "LEVEL_001" | 400 - VocabularyErrorCode.GROUP_NOT_FOUND | "GROUP_001" | 404 - VocabularyErrorCode.GROUP_ALREADY_EXISTS | "GROUP_002" | 409 - VocabularyErrorCode.TEST_NOT_FOUND | "TEST_001" | 404 - VocabularyErrorCode.NO_WORDS_TO_TEST | "TEST_002" | 400 + errorCode | expectedCode | expectedStatusCode + VocabularyErrorCode.WORD_NOT_FOUND | "WORD_001" | 404 + VocabularyErrorCode.WORD_ALREADY_EXISTS | "WORD_002" | 409 + VocabularyErrorCode.INVALID_WORD_DATA | "WORD_003" | 400 + VocabularyErrorCode.USER_WORD_NOT_FOUND | "USER_WORD_001" | 404 + VocabularyErrorCode.INVALID_DIFFICULTY | "USER_WORD_002" | 400 + VocabularyErrorCode.INVALID_WORD_STATUS | "USER_WORD_003" | 400 + VocabularyErrorCode.DAILY_STUDY_NOT_FOUND | "STUDY_001" | 404 + VocabularyErrorCode.STUDY_LIMIT_EXCEEDED | "STUDY_002" | 400 + VocabularyErrorCode.INVALID_STUDY_LEVEL | "STUDY_003" | 400 + VocabularyErrorCode.INVALID_CATEGORY | "CATEGORY_001" | 400 + VocabularyErrorCode.INVALID_LEVEL | "LEVEL_001" | 400 + VocabularyErrorCode.GROUP_NOT_FOUND | "GROUP_001" | 404 + VocabularyErrorCode.GROUP_ALREADY_EXISTS | "GROUP_002" | 409 + VocabularyErrorCode.TEST_NOT_FOUND | "TEST_001" | 404 + VocabularyErrorCode.NO_WORDS_TO_TEST | "TEST_002" | 400 } def "404 에러 코드들 확인"() { From ad155d316929556bfd8642ec24f56dca6c1ef1b4 Mon Sep 17 00:00:00 2001 From: hyein Heo <128613248+hye-inA@users.noreply.github.com> Date: Tue, 20 Jan 2026 16:18:13 +0900 Subject: [PATCH 382/528] =?UTF-8?q?feature=20:=20OPIc=20=EC=84=B8=EC=85=98?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20+=20=EB=8B=B5=EB=B3=80=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=ED=8C=8C=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20=20(#413)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat : OPIc 질문 & 세션 관련 dto 생성 * refactor : @DynamoDbBean 주석 추가 * feat : 주제 + 소주제 + 레벨로 오픽 질문 조회 추가 * feat : OPIc 세션, 질문, 답변 종합 handler 구현 * feat : 오픽 주제, 소주제별 seed 데이터 추가 * refactor : Transcribe API KEY 환경변수명 수정 * refactor : Bedrock에 사용하는 클로드 모델 변경 * refactor : S3 Key 대신 Proxy에서 요청하는 Base64 값으로 변환 --- .../dto/request/CreateSessionRequest.java | 7 + .../opic/dto/request/SubmitAnswerRequest.java | 5 + .../dto/response/AnswerFeedbackResponse.java | 10 + .../dto/response/CreateSessionResponse.java | 7 + .../opic/dto/response/QuestionResponse.java | 9 + .../opic/handler/OPIcSessionHandler.java | 506 ++++++++++++++++++ .../domain/opic/model/OPIcAnswer.java | 2 + .../opic/repository/OPIcRepository.java | 11 + .../domain/opic/service/FeedbackService.java | 2 +- ServerlessFunction/template.yaml | 2 +- opic/seed-data/question-homes.json | 108 ++++ 11 files changed, 667 insertions(+), 2 deletions(-) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/request/CreateSessionRequest.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/request/SubmitAnswerRequest.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/AnswerFeedbackResponse.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/CreateSessionResponse.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/QuestionResponse.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/handler/OPIcSessionHandler.java create mode 100644 opic/seed-data/question-homes.json diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/request/CreateSessionRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/request/CreateSessionRequest.java new file mode 100644 index 00000000..6dc7dc3f --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/request/CreateSessionRequest.java @@ -0,0 +1,7 @@ +package com.mzc.secondproject.serverless.domain.opic.dto.request; + +public record CreateSessionRequest( + String topic, + String subTopic, + String targetLevel +) {} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/request/SubmitAnswerRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/request/SubmitAnswerRequest.java new file mode 100644 index 00000000..c425f42f --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/request/SubmitAnswerRequest.java @@ -0,0 +1,5 @@ +package com.mzc.secondproject.serverless.domain.opic.dto.request; + +public record SubmitAnswerRequest ( + String audioS3Key +) {} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/AnswerFeedbackResponse.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/AnswerFeedbackResponse.java new file mode 100644 index 00000000..fcc6bf3b --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/AnswerFeedbackResponse.java @@ -0,0 +1,10 @@ +package com.mzc.secondproject.serverless.domain.opic.dto.response; + +public record AnswerFeedbackResponse( + String answerId, + String transcript, + FeedbackResponse feedback, + boolean hasNextQuestion, + Integer nextQustionNumber, + int totalQuestions +) {} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/CreateSessionResponse.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/CreateSessionResponse.java new file mode 100644 index 00000000..17d1cf92 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/CreateSessionResponse.java @@ -0,0 +1,7 @@ +package com.mzc.secondproject.serverless.domain.opic.dto.response; + +public record CreateSessionResponse ( + String sessionId, + QuestionResponse firstQuestion, + int totalQuestions +) {} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/QuestionResponse.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/QuestionResponse.java new file mode 100644 index 00000000..0ecf6230 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/QuestionResponse.java @@ -0,0 +1,9 @@ +package com.mzc.secondproject.serverless.domain.opic.dto.response; + +public record QuestionResponse ( + String questionId, + String questionText, + String audioUrl, + int questionNumber, + int totalQuestions +) {} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/handler/OPIcSessionHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/handler/OPIcSessionHandler.java new file mode 100644 index 00000000..80b34f31 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/handler/OPIcSessionHandler.java @@ -0,0 +1,506 @@ +package com.mzc.secondproject.serverless.domain.opic.handler; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import com.google.gson.*; +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.service.PollyService; +import com.mzc.secondproject.serverless.common.util.JwtUtil; +import com.mzc.secondproject.serverless.common.util.ResponseGenerator; +import com.mzc.secondproject.serverless.domain.opic.dto.request.CreateSessionRequest; +import com.mzc.secondproject.serverless.domain.opic.dto.request.SubmitAnswerRequest; +import com.mzc.secondproject.serverless.domain.opic.dto.response.FeedbackResponse; +import com.mzc.secondproject.serverless.domain.opic.model.OPIcAnswer; +import com.mzc.secondproject.serverless.domain.opic.model.OPIcQuestion; +import com.mzc.secondproject.serverless.domain.opic.model.OPIcSession; +import com.mzc.secondproject.serverless.domain.opic.repository.OPIcRepository; +import com.mzc.secondproject.serverless.domain.opic.service.FeedbackService; +import com.mzc.secondproject.serverless.domain.opic.service.TranscribeProxyService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; + +import java.lang.reflect.Type; +import java.time.Duration; +import java.time.Instant; +import java.util.*; +import java.util.stream.Collectors; + +/** + * OPIc 세션 통합 Handler + * - 세션 생성/조회 + * - 질문 조회 (Polly 음성 URL 포함) + * - 답변 제출 (Transcribe + Bedrock 피드백) + * - 세션 완료 (종합 리포트) + */ +public class OPIcSessionHandler implements RequestHandler { + + private static final Logger logger = LoggerFactory.getLogger(OPIcSessionHandler.class); + private static final Gson gson = new GsonBuilder() + .setDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'") + .registerTypeAdapter(Instant.class, new InstantTypeAdapter()) + .create(); + + private static final String OPIC_BUCKET = System.getenv("OPIC_BUCKET_NAME"); + + private final OPIcRepository repository; + private final PollyService pollyService; + private final TranscribeProxyService transcribeService; + private final FeedbackService feedbackService; + + public OPIcSessionHandler() { + this.repository = new OPIcRepository(); + this.pollyService = new PollyService(OPIC_BUCKET, "opic/voice/questions/"); + this.transcribeService = new TranscribeProxyService(); + this.feedbackService = new FeedbackService(); + } + + @Override + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent event, Context context) { + String httpMethod = event.getHttpMethod(); + String path = event.getPath(); + + try { + + String userId = extractUserId(event); + + + // POST /opic/sessions - 세션 생성 + if ("POST".equals(httpMethod) && path.equals("/opic/sessions")) { + return createSession(event, userId); + } + + // GET /opic/sessions - 세션 목록 조회 + if ("GET".equals(httpMethod) && path.equals("/opic/sessions")) { + return getSessions(userId); + } + + // GET /opic/sessions/{sessionId} - 세션 상세 조회 + if ("GET".equals(httpMethod) && path.matches("/opic/sessions/[^/]+") + && !path.contains("/questions") && !path.contains("/upload-url")) { + return getSession(event, userId); + } + + // GET /opic/sessions/{sessionId}/questions/next - 다음 질문 조회 + if ("GET".equals(httpMethod) && path.contains("/questions/next")) { + return getNextQuestion(event, userId); + } + + // GET /opic/sessions/{sessionId}/upload-url - Presigned URL 발급 + if ("GET".equals(httpMethod) && path.contains("/upload-url")) { + return getUploadUrl(event, userId); + } + + // POST /opic/sessions/{sessionId}/answers - 답변 제출 + if ("POST".equals(httpMethod) && path.contains("/answers")) { + return submitAnswer(event, userId); + } + + // POST /opic/sessions/{sessionId}/complete - 세션 완료 + if ("POST".equals(httpMethod) && path.contains("/complete")) { + return completeSession(event, userId); + } + + return ResponseGenerator.badRequest("지원하지 않는 요청입니다: " + httpMethod + " " + path); + + } catch (Exception e) { + logger.error("OPIc Handler 에러", e); + return ResponseGenerator.serverError(e.getMessage()); + } + } + + + /** + * POST /opic/sessions + * 세션 생성 + 첫 질문 반환 + */ + private APIGatewayProxyResponseEvent createSession(APIGatewayProxyRequestEvent event, String userId) { + CreateSessionRequest request = gson.fromJson(event.getBody(), CreateSessionRequest.class); + + logger.info("세션 생성 요청: userId={}, topic={}, level={}", + userId, request.topic(), request.targetLevel()); + + // 주제 + 소주제 + 레벨로 질문 세트 조회 + List questions = repository.findQuestionsByTopicSubTopicAndLevel( + request.topic(), + request.subTopic(), + request.targetLevel() + ); + + if (questions.isEmpty()) { + return ResponseGenerator.notFound("해당 주제/레벨의 질문이 없습니다."); + } + + // 최대 3개 질문 선택 (랜덤 셔플) + Collections.shuffle(questions); + List questionIds = questions.stream() + .limit(3) + .map(OPIcQuestion::getQuestionId) + .collect(Collectors.toList()); + + // 세션 생성 + OPIcSession session = repository.createSession( + userId, + request.topic(), + request.subTopic(), + request.targetLevel(), + questionIds + ); + + // 첫 질문 Polly 음성 URL 생성 (#368 PollyService 연동) + OPIcQuestion firstQuestion = questions.get(0); + String audioUrl = generateQuestionAudioUrl(firstQuestion); + + // Response + Map response = new LinkedHashMap<>(); + response.put("sessionId", session.getSessionId()); + response.put("totalQuestions", session.getTotalQuestions()); + response.put("firstQuestion", Map.of( + "questionId", firstQuestion.getQuestionId(), + "questionText", firstQuestion.getQuestionText(), + "audioUrl", audioUrl, + "questionNumber", 1, + "totalQuestions", session.getTotalQuestions() + )); + + logger.info("세션 생성 완료: sessionId={}", session.getSessionId()); + return ResponseGenerator.created("세션이 생성되었습니다.", response); + } + + /** + * GET /opic/sessions + * 사용자의 세션 목록 조회 + */ + private APIGatewayProxyResponseEvent getSessions(String userId) { + List sessions = repository.findSessionsByUserId(userId, 20); + + Map responseBody = new LinkedHashMap<>(); + responseBody.put("isSuccess", true); + responseBody.put("data", sessions); + + return new APIGatewayProxyResponseEvent() + .withStatusCode(200) + .withHeaders(Map.of("Content-Type", "application/json")) + .withBody(gson.toJson(responseBody)); + } + + /** + * GET /opic/sessions/{sessionId} + * 세션 상세 조회 + */ + private APIGatewayProxyResponseEvent getSession(APIGatewayProxyRequestEvent event, String userId) { + String sessionId = event.getPathParameters().get("sessionId"); + + OPIcSession session = repository.findSessionById(sessionId).orElse(null); + + if (session == null) { + return ResponseGenerator.notFound("세션을 찾을 수 없습니다."); + } + + if (!session.getUserId().equals(userId)) { + return ResponseGenerator.forbidden("접근 권한이 없습니다."); + } + + // 세션에 포함된 답변들도 조회 + List answers = repository.findAnswersBySessionId(sessionId); + + Map response = new LinkedHashMap<>(); + response.put("session", session); + response.put("answers", answers); + + return ResponseGenerator.ok(response); + } + + /** + * GET /opic/sessions/{sessionId}/questions/next + * 다음 질문 조회 (Polly 음성 URL 포함) + */ + private APIGatewayProxyResponseEvent getNextQuestion(APIGatewayProxyRequestEvent event, String userId) { + String sessionId = event.getPathParameters().get("sessionId"); + + OPIcSession session = repository.findSessionById(sessionId).orElse(null); + + if (session == null) { + return ResponseGenerator.notFound("세션을 찾을 수 없습니다."); + } + + if (!session.getUserId().equals(userId)) { + return ResponseGenerator.forbidden("접근 권한이 없습니다."); + } + + // 모든 질문 완료 확인 + int currentIndex = session.getCurrentQuestionIndex(); + if (currentIndex >= session.getTotalQuestions()) { + return ResponseGenerator.ok(Map.of( + "completed", true, + "message", "모든 질문이 완료되었습니다. 세션을 완료해주세요.", + "sessionId", sessionId + )); + } + + // 다음 질문 조회 + String questionId = session.getQuestionIds().get(currentIndex); + OPIcQuestion question = repository.findQuestionById(questionId) + .orElseThrow(() -> new RuntimeException("질문을 찾을 수 없습니다: " + questionId)); + + // Polly 음성 URL + String audioUrl = generateQuestionAudioUrl(question); + + Map response = new LinkedHashMap<>(); + response.put("questionId", question.getQuestionId()); + response.put("questionText", question.getQuestionText()); + response.put("audioUrl", audioUrl); + response.put("questionNumber", currentIndex + 1); + response.put("totalQuestions", session.getTotalQuestions()); + response.put("completed", false); + + return ResponseGenerator.ok(response); + } + + /** + * GET /opic/sessions/{sessionId}/upload-url + * S3 Presigned URL 발급 (음성 업로드용) + */ + private APIGatewayProxyResponseEvent getUploadUrl(APIGatewayProxyRequestEvent event, String userId) { + String sessionId = event.getPathParameters().get("sessionId"); + + // 세션 검증 + OPIcSession session = repository.findSessionById(sessionId).orElse(null); + if (session == null || !session.getUserId().equals(userId)) { + return ResponseGenerator.forbidden("접근 권한이 없습니다."); + } + + // S3 키 생성 + String s3Key = String.format("opic/answers/%s/%s/%s.webm", + userId, + sessionId, + UUID.randomUUID().toString() + ); + + // Presigned URL 생성 (5분 유효) + PutObjectRequest putRequest = PutObjectRequest.builder() + .bucket(OPIC_BUCKET) + .key(s3Key) + .contentType("audio/webm") + .build(); + + String presignedUrl = AwsClients.s3Presigner() + .presignPutObject(PutObjectPresignRequest.builder() + .putObjectRequest(putRequest) + .signatureDuration(Duration.ofMinutes(5)) + .build()) + .url() + .toString(); + + return ResponseGenerator.ok(Map.of( + "uploadUrl", presignedUrl, + "s3Key", s3Key, + "expiresIn", 300 + )); + } + + /** + * POST /opic/sessions/{sessionId}/answers + * 답변 제출 → STT → AI 피드백 + */ + private APIGatewayProxyResponseEvent submitAnswer(APIGatewayProxyRequestEvent event, String userId) { + String sessionId = event.getPathParameters().get("sessionId"); + SubmitAnswerRequest request = gson.fromJson(event.getBody(), SubmitAnswerRequest.class); + + logger.info("답변 제출: sessionId={}, s3Key={}", sessionId, request.audioS3Key()); + + // 세션 검증 + OPIcSession session = repository.findSessionById(sessionId).orElse(null); + if (session == null) { + return ResponseGenerator.notFound("세션을 찾을 수 없습니다."); + } + if (!session.getUserId().equals(userId)) { + return ResponseGenerator.forbidden("접근 권한이 없습니다."); + } + + // 현재 질문 조회 + int currentIndex = session.getCurrentQuestionIndex(); + if (currentIndex >= session.getTotalQuestions()) { + return ResponseGenerator.badRequest("이미 모든 질문에 답변했습니다."); + } + + String questionId = session.getQuestionIds().get(currentIndex); + OPIcQuestion question = repository.findQuestionById(questionId) + .orElseThrow(() -> new RuntimeException("질문을 찾을 수 없습니다.")); + + // Transcribe Proxy 호출 (음성 → 텍스트) + logger.info("S3에서 오디오 파일 로드: {}", request.audioS3Key()); + + byte[] audioBytes = AwsClients.s3().getObjectAsBytes( + software.amazon.awssdk.services.s3.model.GetObjectRequest.builder() + .bucket(OPIC_BUCKET) + .key(request.audioS3Key()) + .build() + ).asByteArray(); + + String audioBase64 = java.util.Base64.getEncoder().encodeToString(audioBytes); + logger.info("오디오 파일 Base64 변환 완료: {} bytes → {} chars", + audioBytes.length, audioBase64.length()); + + // 4. Transcribe Proxy 호출 (Base64 데이터 전송) + TranscribeProxyService.TranscribeResult transcribeResult = + transcribeService.transcribe(audioBase64, sessionId); + + String transcript = transcribeResult.transcript(); + logger.info("STT 변환 완료: transcript 길이={}", transcript.length()); + + // Bedrock 피드백 생성 + FeedbackResponse feedback = feedbackService.generateFeedback( + question.getQuestionText(), + transcript, + session.getTargetLevel() + ); + + // Answer 저장 - 개별 필드로 분리 저장 + OPIcAnswer answer = new OPIcAnswer(); + answer.setSessionId(sessionId); + answer.setQuestionId(questionId); + answer.setQuestionIndex(currentIndex); + answer.setQuestionText(question.getQuestionText()); // 비정규화 + answer.setAudioS3Key(request.audioS3Key()); + answer.setTranscript(transcript); + answer.setTranscriptConfidence(transcribeResult.confidence()); + + // 피드백 개별 필드 저장 + answer.setGrammarFeedback(gson.toJson(feedback.errors())); // errors → grammarFeedback + answer.setContentFeedback(feedback.correctedAnswer()); // correctedAnswer → contentFeedback + answer.setSampleAnswer(feedback.sampleAnswer()); // 모범 답변 + answer.setStatus(OPIcAnswer.AnswerStatus.COMPLETED); + answer.setAttemptCount(1); + answer.setCreatedAt(Instant.now()); + answer.setCompletedAt(Instant.now()); + + repository.saveAnswer(answer); + + // 세션 진행 상태 업데이트 + session.setCurrentQuestionIndex(currentIndex + 1); + repository.updateSession(session); + + // Response + boolean hasNext = (currentIndex + 1) < session.getTotalQuestions(); + + Map response = new LinkedHashMap<>(); + response.put("transcript", transcript); + response.put("feedback", feedback); + response.put("hasNextQuestion", hasNext); + response.put("currentQuestion", currentIndex + 1); + response.put("totalQuestions", session.getTotalQuestions()); + + if (hasNext) { + response.put("nextQuestionNumber", currentIndex + 2); + } + + logger.info("답변 처리 완료: sessionId={}, questionIndex={}", sessionId, currentIndex); + return ResponseGenerator.ok("피드백이 생성되었습니다.", response); + } + + /** + * POST /opic/sessions/{sessionId}/complete + * 세션 완료 + 종합 리포트 생성 + */ + private APIGatewayProxyResponseEvent completeSession(APIGatewayProxyRequestEvent event, String userId) { + String sessionId = event.getPathParameters().get("sessionId"); + + OPIcSession session = repository.findSessionById(sessionId).orElse(null); + if (session == null) { + return ResponseGenerator.notFound("세션을 찾을 수 없습니다."); + } + if (!session.getUserId().equals(userId)) { + return ResponseGenerator.forbidden("접근 권한이 없습니다."); + } + + // 모든 질문 답변 완료 확인 + List answers = repository.findAnswersBySessionId(sessionId); + if (answers.size() < session.getTotalQuestions()) { + return ResponseGenerator.badRequest( + String.format("아직 %d개의 질문에 답변하지 않았습니다.", + session.getTotalQuestions() - answers.size()) + ); + } + + // 세션 요약 생성 (피드백용) + StringBuilder summaryBuilder = new StringBuilder(); + for (int i = 0; i < answers.size(); i++) { + OPIcAnswer answer = answers.get(i); + OPIcQuestion question = repository.findQuestionById(answer.getQuestionId()).orElse(null); + + summaryBuilder.append(String.format("### Question %d\n", i + 1)); + if (question != null) { + summaryBuilder.append("Q: ").append(question.getQuestionText()).append("\n"); + } + summaryBuilder.append("A: ").append(answer.getTranscript()).append("\n\n"); + } + + // 종합 리포트 생성 (Bedrock) + var sessionReport = feedbackService.generateSessionReport( + summaryBuilder.toString(), + session.getTargetLevel() + ); + + // 세션 완료 처리 + repository.completeSession( + session, + sessionReport.estimatedLevel(), + gson.toJson(sessionReport) + ); + + logger.info("세션 완료: sessionId={}, estimatedLevel={}", + sessionId, sessionReport.estimatedLevel()); + + return ResponseGenerator.ok("세션이 완료되었습니다.", sessionReport); + } + + // ==================== 유틸리티 ==================== + + /** + * 질문 음성 URL 생성 (Polly + S3 캐싱) + */ + private String generateQuestionAudioUrl(OPIcQuestion question) { + try { + PollyService.VoiceSynthesisResult result = pollyService.synthesizeSpeech( + question.getQuestionId(), + question.getQuestionText(), + "FEMALE" + ); + return result.getAudioUrl(); + } catch (Exception e) { + logger.warn("Polly 음성 생성 실패, 텍스트만 반환: {}", e.getMessage()); + return null; + } + } + + /** + * JWT 토큰에서 userId 추출 + */ + private String extractUserId(APIGatewayProxyRequestEvent event) { + String authHeader = event.getHeaders().get("Authorization"); + + if (authHeader == null || authHeader.isEmpty()) { + authHeader = event.getHeaders().get("authorization"); + } + + return JwtUtil.extractUserId(authHeader) + .orElseThrow(() -> new RuntimeException("인증 정보를 찾을 수 없습니다.")); + } + + private static class InstantTypeAdapter implements JsonSerializer, JsonDeserializer { + @Override + public JsonElement serialize(Instant src, Type typeOfSrc, JsonSerializationContext context) { + return new JsonPrimitive(src.toString()); + } + + @Override + public Instant deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) + throws JsonParseException { + return Instant.parse(json.getAsString()); + } + } +} \ No newline at end of file diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/model/OPIcAnswer.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/model/OPIcAnswer.java index e6e5da95..7052627f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/model/OPIcAnswer.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/model/OPIcAnswer.java @@ -1,6 +1,7 @@ package com.mzc.secondproject.serverless.domain.opic.model; import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbAttribute; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey; import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSortKey; @@ -9,6 +10,7 @@ /** * OPIc 답변 + 피드백 */ +@DynamoDbBean public class OPIcAnswer { private String pk; // SESSION#sessionId diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/repository/OPIcRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/repository/OPIcRepository.java index 2b5fd803..92ac2541 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/repository/OPIcRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/repository/OPIcRepository.java @@ -172,6 +172,17 @@ public List findQuestionsByTopicAndLevel(String topic, String leve .collect(Collectors.toList()); } + /** + * 주제 + 소주제 + 레벨로 질문 조회 (subTopic 필터 추가) + */ + public List findQuestionsByTopicSubTopicAndLevel( + String topic, String subTopic, String level) { + + return findQuestionsByTopicAndLevel(topic, level).stream() + .filter(q -> subTopic == null || subTopic.equals(q.getSubTopic())) + .collect(Collectors.toList()); + } + /** * 여러 질문 ID로 조회 */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/service/FeedbackService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/service/FeedbackService.java index 9343ae51..fc42849d 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/service/FeedbackService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/service/FeedbackService.java @@ -24,7 +24,7 @@ public class FeedbackService { private static final Logger logger = LoggerFactory.getLogger(FeedbackService.class); private static final Gson gson = new GsonBuilder().setPrettyPrinting().create(); - private static final String MODEL_ID = "anthropic.claude-3-5-sonnet-20241022-v2:0"; + private static final String MODEL_ID = "anthropic.claude-3-haiku-20240307-v1:0"; private static final int MAX_TOKENS = 2000; /** diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 570dd871..35cde30e 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -1282,7 +1282,7 @@ Resources: ApplyOn: PublishedVersions Environment: Variables: - TRANSCRIBE_API_KEY_PARAM: "/opic/transcribe-proxy-api-key" + TRANSCRIBE_API_KEY: "/opic/transcribe-proxy-api-key" Policies: - DynamoDBCrudPolicy: TableName: !Ref OPIcTable diff --git a/opic/seed-data/question-homes.json b/opic/seed-data/question-homes.json new file mode 100644 index 00000000..ddd67703 --- /dev/null +++ b/opic/seed-data/question-homes.json @@ -0,0 +1,108 @@ +{ + "questions": [ + { + "questionId": "desc-homes-001", + "topic": "DESCRIPTION", + "subTopic": "HOMES", + "questionText": "I would like to know about where you live. Talk about the different rooms at your place. Tell me about your favorite room in your home. What does it look like?", + "difficulty": "IM2", + "questionNumber": 44 + }, + { + "questionId": "desc-homes-002", + "topic": "DESCRIPTION", + "subTopic": "HOMES", + "questionText": "I would like to know where you live. Can you describe your home to me? What does it look like? How many rooms does it have? Give me a description with lots of details.", + "difficulty": "IM2", + "questionNumber": 45 + }, + { + "questionId": "desc-homes-003", + "topic": "DESCRIPTION", + "subTopic": "HOMES", + "questionText": "Now, let's talk about your bedroom. What's inside? What kind of furniture do you have in your room?", + "difficulty": "IM1", + "questionNumber": 46 + }, + { + "questionId": "habit-homes-001", + "topic": "HABIT", + "subTopic": "HOMES", + "questionText": "What kinds of home improvement projects do you enjoy doing and why?", + "difficulty": "IM2", + "questionNumber": 139 + }, + { + "questionId": "habit-homes-002", + "topic": "HABIT", + "subTopic": "HOMES", + "questionText": "Tell me about your normal routine at home. What do you do on weekdays and on weekends?", + "difficulty": "IM1", + "questionNumber": 140 + }, + { + "questionId": "habit-homes-003", + "topic": "HABIT", + "subTopic": "HOMES", + "questionText": "What is your normal routine at home? What things do you usually do on weekdays and on weekends?", + "difficulty": "IM1", + "questionNumber": 141 + }, + { + "questionId": "habit-homes-004", + "topic": "HABIT", + "subTopic": "HOMES", + "questionText": "What is your responsibility at home? What is your role? Tell me in detail.", + "difficulty": "IM2", + "questionNumber": 142 + }, + { + "questionId": "habit-homes-005", + "topic": "HABIT", + "subTopic": "HOMES", + "questionText": "What kinds of things do you do to keep your house clean and comfortable? What kinds of housework do you do at home?", + "difficulty": "IM1", + "questionNumber": 143 + }, + { + "questionId": "habit-homes-006", + "topic": "HABIT", + "subTopic": "HOMES", + "questionText": "What kind of house work do you usually do at home? Do you share your chores with your family?", + "difficulty": "IM1", + "questionNumber": 144 + }, + { + "questionId": "habit-homes-007", + "topic": "HABIT", + "subTopic": "HOMES", + "questionText": "Tell me about all the steps you take for a home improvement project. What steps do you usually take? How do you complete the project?", + "difficulty": "IH", + "questionNumber": 145 + }, + { + "questionId": "past-homes-001", + "topic": "PAST_EXPERIENCE", + "subTopic": "HOMES", + "questionText": "Tell me about a memorable experience you had at home. What happened and why was it memorable?", + "difficulty": "IM2", + "questionNumber": 0 + }, + { + "questionId": "past-homes-002", + "topic": "PAST_EXPERIENCE", + "subTopic": "HOMES", + "questionText": "Have you ever had any problems at home? Maybe something broke or there was an issue with your neighbors. Tell me about that experience and how you solved it.", + "difficulty": "IH", + "questionNumber": 0 + }, + { + "questionId": "past-homes-003", + "topic": "PAST_EXPERIENCE", + "subTopic": "HOMES", + "questionText": "Tell me about a time when you had to do a major cleaning or organizing at home. What did you do and how did it turn out?", + "difficulty": "IM2", + "questionNumber": 0 + } + ] +} From b30ea0aa5201b6b6442fccae724ace4e98e9ac43 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 17:14:56 +0900 Subject: [PATCH 383/528] =?UTF-8?q?feat:=20GAME=5FSTART,=20ROUND=5FEND=20?= =?UTF-8?q?=EB=A9=94=EC=8B=9C=EC=A7=80=EC=97=90=20serverTime=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - broadcastGameStart(): serverTime, roundDuration 필드 추가 - broadcastRoundEnd(): serverTime, roundStartTime, roundDuration 필드 추가 - GameService.endRound(): data에 roundStartTime, roundDuration 포함 - 타이머 동기화 버그 수정을 위한 서버 시간 제공 --- .../websocket/WebSocketMessageHandler.java | 148 ++++- .../domain/chatting/service/GameService.java | 5 +- docs/CATCHMIND_ARCHITECTURE_SOLUTION.md | 518 ++++++++++++++++++ 3 files changed, 658 insertions(+), 13 deletions(-) create mode 100644 docs/CATCHMIND_ARCHITECTURE_SOLUTION.md diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java index 0386a136..1f6bde08 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java @@ -309,10 +309,26 @@ private void handleAllCorrect(String roomId) { * 명령어 처리 결과를 브로드캐스트 */ private Map handleCommandResult(CommandResult result, String roomId, String userId) { + List connections = connectionRepository.findByRoomId(roomId); + + // GAME_START는 특별 처리 (출제자에게만 제시어 전송 + serverTime 포함) + if (result.messageType() == MessageType.GAME_START && result.data() instanceof GameService.GameStartResult gameResult) { + broadcastGameStart(connections, result, gameResult, roomId); + return WebSocketEventUtil.ok("Command executed"); + } + + // ROUND_END는 특별 처리 (다음 출제자에게만 제시어 전송 + serverTime 포함) + if (result.messageType() == MessageType.ROUND_END && result.data() instanceof Map) { + @SuppressWarnings("unchecked") + Map data = (Map) result.data(); + broadcastRoundEnd(connections, result, data, roomId); + return WebSocketEventUtil.ok("Command executed"); + } + + // 일반 시스템 메시지 String messageId = UUID.randomUUID().toString(); String now = Instant.now().toString(); - - // 시스템 메시지 생성 + ChatMessage systemMessage = ChatMessage.builder() .pk("ROOM#" + roomId) .sk("MSG#" + now + "#" + messageId) @@ -327,21 +343,129 @@ private Map handleCommandResult(CommandResult result, String roo .messageType(result.messageType().getCode()) .createdAt(now) .build(); - - // 명령어 결과는 저장하지 않고 브로드캐스트만 수행 - List connections = connectionRepository.findByRoomId(roomId); + String broadcastPayload = gson.toJson(systemMessage); List failedConnections = broadcaster.broadcast(connections, broadcastPayload); - - // 실패한 연결 정리 - for (String failedConnectionId : failedConnections) { - connectionRepository.delete(failedConnectionId); - logger.info("Deleted stale connection: {}", failedConnectionId); - } - + cleanupFailedConnections(failedConnections); + logger.info("Command result broadcasted: type={}, roomId={}", result.messageType(), roomId); return WebSocketEventUtil.ok("Command executed"); } + + /** + * GAME_START 메시지 브로드캐스트 - 출제자에게만 제시어 포함, serverTime 추가 + */ + private void broadcastGameStart(List connections, CommandResult result, + GameService.GameStartResult gameResult, String roomId) { + String messageId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + long serverTime = System.currentTimeMillis(); + + String currentDrawerId = gameResult.room().getCurrentDrawerId(); + + for (Connection conn : connections) { + Map message = new HashMap<>(); + message.put("messageId", messageId); + message.put("roomId", roomId); + message.put("userId", "SYSTEM"); + message.put("content", result.message()); + message.put("messageType", result.messageType().getCode()); + message.put("createdAt", now); + + // 게임 상태 정보 + message.put("gameStatus", gameResult.room().getGameStatus()); + message.put("currentRound", gameResult.room().getCurrentRound()); + message.put("totalRounds", gameResult.room().getTotalRounds()); + message.put("currentDrawerId", currentDrawerId); + message.put("drawerOrder", gameResult.drawerOrder()); + + // 타이머 동기화용 필드 (핵심!) + message.put("roundStartTime", gameResult.room().getRoundStartTime()); + message.put("serverTime", serverTime); + message.put("roundDuration", gameResult.room().getRoundTimeLimit()); + + // 출제자에게만 제시어 전송 + if (conn.getUserId().equals(currentDrawerId) && gameResult.firstWord() != null) { + Map wordInfo = new HashMap<>(); + wordInfo.put("wordId", gameResult.firstWord().getWordId()); + wordInfo.put("word", gameResult.firstWord().getEnglish()); + message.put("currentWord", wordInfo); + } + + String payload = gson.toJson(message); + try { + broadcaster.sendToConnection(conn.getConnectionId(), payload); + } catch (Exception e) { + logger.warn("Failed to send GAME_START to connection: {}", conn.getConnectionId()); + connectionRepository.delete(conn.getConnectionId()); + } + } + + logger.info("GAME_START broadcasted: roomId={}, serverTime={}", roomId, serverTime); + } + + /** + * ROUND_END 메시지 브로드캐스트 - 다음 출제자에게만 제시어 포함, serverTime 추가 + */ + private void broadcastRoundEnd(List connections, CommandResult result, + Map data, String roomId) { + String messageId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + long serverTime = System.currentTimeMillis(); + + String nextDrawer = (String) data.get("nextDrawer"); + Object nextWordObj = data.get("nextWord"); + + for (Connection conn : connections) { + Map message = new HashMap<>(); + message.put("messageId", messageId); + message.put("roomId", roomId); + message.put("userId", "SYSTEM"); + message.put("content", result.message()); + message.put("messageType", result.messageType().getCode()); + message.put("createdAt", now); + + // 기본 데이터 복사 (nextWord 제외) + Map messageData = new HashMap<>(); + messageData.put("answer", data.get("answer")); + messageData.put("nextRound", data.get("nextRound")); + messageData.put("nextDrawer", nextDrawer); + messageData.put("ranking", data.get("ranking")); + messageData.put("currentRound", data.get("currentRound")); + messageData.put("totalRounds", data.get("totalRounds")); + + // 타이머 동기화용 필드 (핵심!) + messageData.put("serverTime", serverTime); + if (data.get("roundStartTime") != null) { + messageData.put("roundStartTime", data.get("roundStartTime")); + } + if (data.get("roundDuration") != null) { + messageData.put("roundDuration", data.get("roundDuration")); + } + + // 다음 출제자에게만 제시어 전송 + if (conn.getUserId().equals(nextDrawer) && nextWordObj != null) { + if (nextWordObj instanceof com.mzc.secondproject.serverless.domain.vocabulary.model.Word nextWord) { + Map wordInfo = new HashMap<>(); + wordInfo.put("wordId", nextWord.getWordId()); + wordInfo.put("word", nextWord.getEnglish()); + messageData.put("nextWord", wordInfo); + } + } + + message.put("data", messageData); + + String payload = gson.toJson(message); + try { + broadcaster.sendToConnection(conn.getConnectionId(), payload); + } catch (Exception e) { + logger.warn("Failed to send ROUND_END to connection: {}", conn.getConnectionId()); + connectionRepository.delete(conn.getConnectionId()); + } + } + + logger.info("ROUND_END broadcasted: roomId={}, serverTime={}", roomId, serverTime); + } /** * 메시지 페이로드 DTO diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java index ff796133..3ac401a5 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java @@ -383,7 +383,10 @@ public CommandResult endRound(ChatRoom room, String reason) { data.put("ranking", ranking); data.put("currentRound", currentRound); data.put("totalRounds", room.getTotalRounds()); - + // 타이머 동기화용 필드 추가 + data.put("roundStartTime", room.getRoundStartTime()); + data.put("roundDuration", room.getRoundTimeLimit() != null ? room.getRoundTimeLimit() : GameConfig.roundTimeLimit()); + return CommandResult.success(MessageType.ROUND_END, message, data); } diff --git a/docs/CATCHMIND_ARCHITECTURE_SOLUTION.md b/docs/CATCHMIND_ARCHITECTURE_SOLUTION.md new file mode 100644 index 00000000..6c4ab164 --- /dev/null +++ b/docs/CATCHMIND_ARCHITECTURE_SOLUTION.md @@ -0,0 +1,518 @@ +# 채팅방 / 캐치마인드 게임 분리 - 종합 솔루션 + +## 1. 현재 문제점 분석 + +### 1.1 백엔드 현황 + +``` +ChatRoom.java (현재 - 혼합 모델) +├── 채팅 필드 +│ ├── roomId, name, description +│ ├── memberIds, currentMembers +│ └── lastMessageAt +│ +└── 게임 필드 (여기에 섞여있음) + ├── gameStatus, gameStartedBy + ├── currentRound, totalRounds + ├── currentDrawerId, currentWord + ├── roundStartTime, roundTimeLimit ← serverTime 없음! + ├── scores, streaks + └── correctGuessers +``` + +**문제점:** +1. `roundStartTime`만 전송, `serverTime` 누락 → 클라이언트 타이머 동기화 불가 +2. 게임 세션이 채팅방에 종속 → 게임 상태 독립 관리 불가 +3. 재접속 시 게임 상태 복구 어려움 +4. 게임 종료 후 상태 정리 복잡 + +### 1.2 WebSocket 메시지 현황 + +```java +// WebSocketMessageHandler.java - 현재 구조 +handleRequest() { + switch (messageType) { + case "DRAWING", "DRAWING_CLEAR" -> handleDrawingMessage() // 게임 + default -> handleRegularMessage() { + // 1. 슬래시 명령어 처리 (/start, /stop, /score...) + // 2. 게임 중 정답 체크 + // 3. 일반 채팅 메시지 + } + } +} +``` + +**문제점:** +- 채팅/게임 구분 없이 모든 메시지가 동일 핸들러에서 처리 +- 메시지에 `domain` 필드 없음 + +--- + +## 2. 최적 솔루션 + +### 2.1 아키텍처 개요 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ WebSocket (단일 엔드포인트 유지) │ +│ │ +│ ┌──────────────────────┐ ┌────────────────────────────────┐ │ +│ │ domain: "chat" │ │ domain: "game" │ │ +│ │ │ │ │ │ +│ │ • TEXT │ │ • GAME_START / GAME_END │ │ +│ │ • USER_JOIN │ │ • ROUND_START / ROUND_END │ │ +│ │ • USER_LEAVE │ │ • DRAWING / DRAWING_CLEAR │ │ +│ │ • SYSTEM │ │ • GUESS / CORRECT_ANSWER │ │ +│ │ │ │ • SCORE_UPDATE / HINT │ │ +│ └──────────────────────┘ └────────────────────────────────┘ │ +│ │ +│ GameSession (별도 모델) │ +│ ├── gameSessionId │ +│ ├── roomId (연결용) │ +│ ├── status, currentRound │ +│ ├── roundStartTime + serverTime ← 핵심! │ +│ └── scores, players │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 2.2 핵심 변경사항 + +| 구분 | 현재 | 변경 후 | +|------|------|---------| +| 모델 | `ChatRoom`에 게임 필드 포함 | `ChatRoom` + `GameSession` 분리 | +| 타이머 | `roundStartTime`만 전송 | `roundStartTime` + `serverTime` | +| 메시지 | `messageType`만 존재 | `domain` + `messageType` | +| API | 채팅방 API만 존재 | 게임 세션 API 추가 | + +--- + +## 3. 백엔드 변경사항 + +### 3.1 Phase 1: 타이머 버그 수정 (즉시) + +**변경 파일:** `WebSocketMessageHandler.java` + +```java +// GAME_START 메시지에 serverTime 추가 +private void broadcastGameStart(...) { + Map message = new HashMap<>(); + // ... 기존 코드 ... + + message.put("roundStartTime", gameResult.room().getRoundStartTime()); + message.put("serverTime", System.currentTimeMillis()); // 추가! + message.put("roundDuration", gameResult.room().getRoundTimeLimit()); // 명확한 이름 + + // ... +} + +// ROUND_END → ROUND_START 메시지에도 동일하게 추가 +private void broadcastRoundEnd(...) { + // ... + messageData.put("roundStartTime", room.getRoundStartTime()); + messageData.put("serverTime", System.currentTimeMillis()); // 추가! + messageData.put("roundDuration", room.getRoundTimeLimit()); + // ... +} +``` + +**예상 작업량:** 30분 + +### 3.2 Phase 2: 메시지 구조 개선 (1일) + +**변경 파일:** `WebSocketMessageHandler.java`, 모든 브로드캐스트 메서드 + +```java +// 모든 메시지에 domain 필드 추가 +private Map createMessage(String domain, String messageType, Object data) { + Map message = new HashMap<>(); + message.put("domain", domain); // "chat" 또는 "game" + message.put("messageType", messageType); + message.put("data", data); + message.put("timestamp", System.currentTimeMillis()); + return message; +} + +// 채팅 메시지 +createMessage("chat", "TEXT", chatData); +createMessage("chat", "USER_JOIN", joinData); + +// 게임 메시지 +createMessage("game", "GAME_START", gameStartData); +createMessage("game", "ROUND_START", roundStartData); +createMessage("game", "DRAWING", drawingData); +``` + +### 3.3 Phase 3: 게임 세션 분리 (1주) + +#### 3.3.1 새 모델: GameSession.java + +```java +@DynamoDbBean +public class GameSession { + private String pk; // GAME#{gameSessionId} + private String sk; // METADATA + private String gsi1pk; // ROOM#{roomId} + private String gsi1sk; // GAME#{createdAt} + + // 게임 식별 + private String gameSessionId; + private String roomId; // 연결된 채팅방 + private String gameType; // "catchmind" + + // 게임 상태 + private String status; // WAITING, PLAYING, FINISHED + private String startedBy; + private Long startedAt; + private Long endedAt; + + // 라운드 정보 + private Integer currentRound; + private Integer totalRounds; + private String currentDrawerId; + private String currentWordId; + private String currentWord; + private Long roundStartTime; + private Integer roundDuration; + + // 점수 + private Map scores; + private Map streaks; + private List players; + private List drawerOrder; + + // 자동 종료 + private Long gameEndScheduledAt; + private String scheduleRuleArn; + + // TTL + private Long ttl; +} +``` + +#### 3.3.2 ChatRoom에서 게임 필드 제거 + +```java +@DynamoDbBean +public class ChatRoom { + // 채팅 필드만 유지 + private String roomId; + private String name; + private String description; + private String level; + private Integer currentMembers; + private Integer maxMembers; + private Boolean isPrivate; + private String password; + private String createdBy; + private String createdAt; + private String lastMessageAt; + private List memberIds; + + // 게임 연결 (참조만) + private String activeGameSessionId; // 현재 진행중인 게임 세션 ID + + // 게임 필드 모두 제거! + // - gameStatus, gameStartedBy, currentRound... 전부 GameSession으로 이동 +} +``` + +#### 3.3.3 게임 세션 API + +``` +# 게임 세션 생성 +POST /api/chat/rooms/{roomId}/games +Request: +{ + "gameType": "catchmind", + "settings": { + "totalRounds": 5, + "roundDuration": 60 + } +} + +Response: +{ + "gameSessionId": "game-abc123", + "roomId": "room-xyz", + "status": "WAITING", + "createdAt": "2024-01-20T10:00:00Z" +} + +# 게임 상태 조회 (재접속 시 필수!) +GET /api/games/{gameSessionId} + +Response: +{ + "gameSessionId": "game-abc123", + "roomId": "room-xyz", + "status": "PLAYING", + "currentRound": 2, + "totalRounds": 5, + "currentDrawerId": "user123", + "roundStartTime": 1705744800000, + "serverTime": 1705744830000, // 핵심! + "roundDuration": 60, + "scores": { + "user1": 150, + "user2": 120 + }, + "players": ["user1", "user2", "user3"] +} + +# 게임 시작 (기존 /start 명령어 대체) +POST /api/games/{gameSessionId}/start + +# 게임 종료 +POST /api/games/{gameSessionId}/stop +``` + +--- + +## 4. 프론트엔드 변경사항 + +### 4.1 Phase 1: 타이머 버그 수정 (즉시) + +```javascript +// useTimer.js - 독립적인 타이머 훅 +export function useTimer(roundStartTime, roundDuration, serverTime) { + const [remainingTime, setRemainingTime] = useState(roundDuration); + + useEffect(() => { + if (!roundStartTime || !roundDuration) return; + + // 서버-클라이언트 시간 차이 보정 + const timeOffset = serverTime ? (Date.now() - serverTime) : 0; + + const interval = setInterval(() => { + const adjustedNow = Date.now() - timeOffset; + const elapsed = Math.floor((adjustedNow - roundStartTime) / 1000); + const remaining = Math.max(0, roundDuration - elapsed); + setRemainingTime(remaining); + + if (remaining <= 0) { + clearInterval(interval); + } + }, 100); + + return () => clearInterval(interval); + }, [roundStartTime, roundDuration, serverTime]); + + return remainingTime; +} +``` + +### 4.2 Phase 2: 메시지 핸들러 분리 + +```javascript +// WebSocket 메시지 핸들러 +onMessage(event) { + const message = JSON.parse(event.data); + + switch (message.domain) { + case 'chat': + this.handleChatMessage(message); + break; + case 'game': + this.handleGameMessage(message); + break; + } +} + +handleChatMessage(message) { + switch (message.messageType) { + case 'TEXT': // 채팅 메시지 + case 'USER_JOIN': + case 'USER_LEAVE': + case 'SYSTEM': + } +} + +handleGameMessage(message) { + switch (message.messageType) { + case 'GAME_START': + case 'ROUND_START': + case 'DRAWING': + case 'CORRECT_ANSWER': + case 'SCORE_UPDATE': + } +} +``` + +### 4.3 Phase 3: 훅 분리 + +``` +src/domains/ +├── chat/ +│ ├── hooks/ +│ │ └── useChatWebSocket.js # 채팅만 처리 +│ └── components/ +│ ├── ChatMessages.jsx +│ └── ChatInput.jsx +│ +├── catchmind/ +│ ├── hooks/ +│ │ ├── useGameWebSocket.js # 게임만 처리 +│ │ ├── useGameState.js +│ │ └── useTimer.js +│ └── components/ +│ ├── DrawingCanvas.jsx +│ ├── ScoreBoard.jsx +│ └── Timer.jsx +│ +└── freetalk/ + └── pages/ + └── FreeTalkPage.jsx # chat + catchmind 조합 +``` + +--- + +## 5. 메시지 스펙 (최종) + +### 5.1 공통 메시지 구조 + +```json +{ + "domain": "chat" | "game", + "messageType": "...", + "data": { ... }, + "timestamp": 1705744800000 +} +``` + +### 5.2 채팅 메시지 + +| Type | 방향 | data 필드 | +|------|------|-----------| +| `TEXT` | 양방향 | `messageId`, `userId`, `content`, `createdAt` | +| `USER_JOIN` | S→C | `userId`, `memberCount` | +| `USER_LEAVE` | S→C | `userId`, `memberCount` | +| `SYSTEM` | S→C | `content` | + +### 5.3 게임 메시지 + +| Type | 방향 | data 필드 | +|------|------|-----------| +| `GAME_START` | S→C | `gameSessionId`, `totalRounds`, `currentDrawerId`, `roundStartTime`, `serverTime`, `roundDuration`, `players` | +| `GAME_END` | S→C | `gameSessionId`, `reason`, `finalScores`, `winner` | +| `ROUND_START` | S→C | `currentRound`, `currentDrawerId`, `roundStartTime`, `serverTime`, `roundDuration`, `currentWord`(출제자만) | +| `ROUND_END` | S→C | `currentRound`, `answer`, `scores` | +| `DRAWING` | 양방향 | `drawingData` | +| `DRAWING_CLEAR` | 양방향 | - | +| `GUESS` | C→S | `content` | +| `CORRECT_ANSWER` | S→C | `userId`, `score`, `elapsedTime` | +| `SCORE_UPDATE` | S→C | `scores`, `currentRound`, `totalRounds` | +| `HINT` | S→C | `hint` | + +### 5.4 ROUND_START 상세 (핵심!) + +```json +{ + "domain": "game", + "messageType": "ROUND_START", + "data": { + "gameSessionId": "game-abc123", + "currentRound": 2, + "totalRounds": 5, + "currentDrawerId": "user123", + "roundStartTime": 1705744800000, + "serverTime": 1705744800500, + "roundDuration": 60, + "currentWord": { + "wordId": "word-1", + "word": "apple" + } + }, + "timestamp": 1705744800500 +} +``` + +**중요:** `currentWord`는 출제자에게만 전송! + +--- + +## 6. 구현 일정 + +``` +Week 1: 긴급 버그 수정 +├── [BE] serverTime 필드 추가 (0.5일) +├── [FE] useTimer 훅 수정 (0.5일) +├── [BE] 메시지에 domain 필드 추가 (1일) +└── [FE] 메시지 핸들러 domain 분기 (0.5일) + +Week 2: 게임 세션 분리 (BE) +├── [BE] GameSession 모델 생성 +├── [BE] GameSessionRepository 구현 +├── [BE] GameService 리팩토링 +└── [BE] 게임 세션 API 구현 + +Week 3: 프론트엔드 리팩토링 +├── [FE] useChatWebSocket 분리 +├── [FE] useGameWebSocket 신규 +├── [FE] 컴포넌트 분리 +└── [FE/BE] 통합 테스트 + +Week 4: 안정화 및 추가 기능 +├── [BE] 게임 자동 종료 (7분) - Issue #417 +├── [BE] 재접속 시 게임 상태 복구 +└── [FE/BE] E2E 테스트 +``` + +--- + +## 7. 기대 효과 + +| 항목 | 현재 | 개선 후 | +|------|------|---------| +| 타이머 정확도 | 클라이언트 시계 의존 | 서버 시간 기준 동기화 | +| 재접속 | 게임 상태 유실 | 완전 복구 가능 | +| 테스트 | 채팅/게임 분리 불가 | 독립 테스트 가능 | +| 확장성 | 새 게임 추가 어려움 | gameType으로 확장 용이 | +| 유지보수 | 책임 혼재 | 명확한 책임 분리 | + +--- + +## 8. 즉시 적용 (백엔드 변경 전 프론트엔드 임시 조치) + +```javascript +// 백엔드 변경 전까지 프론트엔드에서 적용 가능한 임시 코드 + +onRoundStart: (data) => { + const roundData = data.data || data; + const now = Date.now(); + + // serverTime이 없으면 클라이언트 시간 사용 (임시) + const serverTime = roundData.serverTime || now; + let roundStartTime = roundData.roundStartTime || now; + + // roundStartTime이 미래 시간이면 현재로 보정 + if (roundStartTime > now + 1000) { + console.warn('Invalid roundStartTime, using current time'); + roundStartTime = now; + } + + setGameState((prev) => ({ + ...prev, + currentRound: roundData.currentRound, + currentDrawerId: roundData.currentDrawerId, + roundStartTime: roundStartTime, + serverTime: serverTime, + roundDuration: roundData.roundDuration || roundData.roundTimeLimit || 60, + })); +} +``` + +--- + +## 9. 결론 + +**우선순위:** + +1. **즉시 (이번 주)**: `serverTime` 추가 + `domain` 필드 추가 +2. **단기 (2주)**: GameSession 모델 분리 + API 구현 +3. **중기 (3-4주)**: FE/BE 완전 분리 + 자동 종료 + 재접속 복구 + +**핵심 원칙:** +- 단일 WebSocket 엔드포인트 유지 (비용/복잡도) +- `domain` 필드로 채팅/게임 구분 +- `serverTime`으로 정확한 타이머 동기화 +- GameSession 독립 모델로 상태 관리 명확화 From 6474f76f0be2fcc7edd2d5a55019fd54b3cc508b Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 17:16:34 +0900 Subject: [PATCH 384/528] =?UTF-8?q?feat:=20WebSocketMessageHelper=20?= =?UTF-8?q?=EC=9C=A0=ED=8B=B8=EB=A6=AC=ED=8B=B0=20=ED=81=B4=EB=9E=98?= =?UTF-8?q?=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - domain 필드 포함 메시지 생성 헬퍼 - DOMAIN_CHAT, DOMAIN_GAME 상수 정의 - buildChatMessage(), buildGameMessage() 메서드 --- .../common/util/WebSocketMessageHelper.java | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketMessageHelper.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketMessageHelper.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketMessageHelper.java new file mode 100644 index 00000000..56a7f2b4 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketMessageHelper.java @@ -0,0 +1,109 @@ +package com.mzc.secondproject.serverless.common.util; + +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +/** + * WebSocket 메시지 생성 헬퍼 + * 모든 메시지에 domain 필드를 포함하여 채팅/게임 구분 지원 + */ +public final class WebSocketMessageHelper { + + public static final String DOMAIN_CHAT = "chat"; + public static final String DOMAIN_GAME = "game"; + + private WebSocketMessageHelper() { + } + + /** + * 기본 메시지 생성 + * + * @param domain 도메인 ("chat" 또는 "game") + * @param messageType 메시지 타입 + * @param data 메시지 데이터 + * @return 메시지 Map + */ + public static Map createMessage(String domain, String messageType, Object data) { + Map message = new HashMap<>(); + message.put("domain", domain); + message.put("messageType", messageType); + message.put("data", data); + message.put("timestamp", System.currentTimeMillis()); + return message; + } + + /** + * 채팅 메시지 생성 + */ + public static Map createChatMessage(String messageType, Object data) { + return createMessage(DOMAIN_CHAT, messageType, data); + } + + /** + * 게임 메시지 생성 + */ + public static Map createGameMessage(String messageType, Object data) { + return createMessage(DOMAIN_GAME, messageType, data); + } + + /** + * 채팅 메시지 빌더 (상세 필드 포함) + */ + public static Map buildChatMessage( + String roomId, + String userId, + String content, + String messageType + ) { + String messageId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + + Map message = new HashMap<>(); + message.put("domain", DOMAIN_CHAT); + message.put("messageType", messageType); + message.put("messageId", messageId); + message.put("roomId", roomId); + message.put("userId", userId); + message.put("content", content); + message.put("createdAt", now); + message.put("timestamp", System.currentTimeMillis()); + return message; + } + + /** + * 게임 메시지 빌더 (상세 필드 포함) + */ + public static Map buildGameMessage( + String roomId, + String messageType, + Map gameData + ) { + String messageId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + long serverTime = System.currentTimeMillis(); + + Map message = new HashMap<>(); + message.put("domain", DOMAIN_GAME); + message.put("messageType", messageType); + message.put("messageId", messageId); + message.put("roomId", roomId); + message.put("userId", "SYSTEM"); + message.put("createdAt", now); + message.put("timestamp", serverTime); + message.put("serverTime", serverTime); + + if (gameData != null) { + message.put("data", gameData); + } + return message; + } + + /** + * 시스템 메시지 생성 (채팅 도메인) + */ + public static Map buildSystemMessage(String roomId, String content, String messageType) { + return buildChatMessage(roomId, "SYSTEM", content, messageType); + } +} From 1107a59e73911722289a74fcfeadbdd525f6756d Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 17:21:42 +0900 Subject: [PATCH 385/528] =?UTF-8?q?feat:=20=EB=AA=A8=EB=93=A0=20WebSocket?= =?UTF-8?q?=20=EB=A9=94=EC=8B=9C=EC=A7=80=EC=97=90=20domain=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ScoreUpdateMessage에 domain 필드 추가 - broadcastGameStart에 domain:"game" 추가 - broadcastRoundEnd에 domain:"game" 추가 - broadcastCorrectAnswerMessage에 domain:"game" 추가 - handleCommandResult 시스템 메시지에 domain:"game" 추가 - handleRegularMessage 채팅 메시지에 domain:"chat" 추가 Closes #426, #427 --- .../dto/response/ScoreUpdateMessage.java | 2 + .../websocket/WebSocketMessageHandler.java | 91 +++++++++++-------- 2 files changed, 53 insertions(+), 40 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/ScoreUpdateMessage.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/ScoreUpdateMessage.java index 6f9ad110..37edba8f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/ScoreUpdateMessage.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/ScoreUpdateMessage.java @@ -16,6 +16,7 @@ @NoArgsConstructor @AllArgsConstructor public class ScoreUpdateMessage { + private String domain; private String messageType; private String roomId; private String scorerId; @@ -31,6 +32,7 @@ public static ScoreUpdateMessage from(String roomId, String scorerId, int scoreG List ranking = buildRanking(scores); return ScoreUpdateMessage.builder() + .domain("game") .messageType("SCORE_UPDATE") .roomId(roomId) .scorerId(scorerId) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java index 1f6bde08..d4b803d8 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java @@ -6,6 +6,7 @@ import com.google.gson.GsonBuilder; import com.mzc.secondproject.serverless.common.util.WebSocketBroadcaster; import com.mzc.secondproject.serverless.common.util.WebSocketEventUtil; +import com.mzc.secondproject.serverless.common.util.WebSocketMessageHelper; import com.mzc.secondproject.serverless.domain.chatting.dto.response.CommandResult; import com.mzc.secondproject.serverless.domain.chatting.dto.response.ScoreUpdateMessage; import com.mzc.secondproject.serverless.domain.chatting.enums.MessageType; @@ -93,11 +94,13 @@ private Map handleDrawingMessage(String connectionId, MessagePay // 그림 데이터 메시지 생성 (저장 안 함) Map drawingMessage = new HashMap<>(); + drawingMessage.put("domain", WebSocketMessageHelper.DOMAIN_GAME); drawingMessage.put("messageType", messageType); drawingMessage.put("roomId", payload.roomId); drawingMessage.put("userId", payload.userId); drawingMessage.put("content", payload.content); drawingMessage.put("createdAt", Instant.now().toString()); + drawingMessage.put("timestamp", System.currentTimeMillis()); // 본인 제외 브로드캐스트 List connections = connectionRepository.findByRoomId(payload.roomId); @@ -147,7 +150,7 @@ private Map handleRegularMessage(String connectionId, MessagePay // 일반 메시지 저장 및 브로드캐스트 String messageId = UUID.randomUUID().toString(); String now = Instant.now().toString(); - + ChatMessage message = ChatMessage.builder() .pk("ROOM#" + payload.roomId) .sk("MSG#" + now + "#" + messageId) @@ -162,23 +165,33 @@ private Map handleRegularMessage(String connectionId, MessagePay .messageType(messageType) .createdAt(now) .build(); - + ChatMessage savedMessage = chatMessageService.saveMessage(message); chatRoomRepository.updateLastMessageAt(payload.roomId, now); - + logger.info("Message saved: messageId={}, roomId={}", messageId, payload.roomId); - - // 브로드캐스트 + + // 브로드캐스트 (domain 필드 포함을 위해 Map으로 변환) + Map broadcastMessage = new HashMap<>(); + broadcastMessage.put("domain", WebSocketMessageHelper.DOMAIN_CHAT); + broadcastMessage.put("messageId", savedMessage.getMessageId()); + broadcastMessage.put("roomId", savedMessage.getRoomId()); + broadcastMessage.put("userId", savedMessage.getUserId()); + broadcastMessage.put("content", savedMessage.getContent()); + broadcastMessage.put("messageType", savedMessage.getMessageType()); + broadcastMessage.put("createdAt", savedMessage.getCreatedAt()); + broadcastMessage.put("timestamp", System.currentTimeMillis()); + List connections = connectionRepository.findByRoomId(payload.roomId); - String broadcastPayload = gson.toJson(savedMessage); + String broadcastPayload = gson.toJson(broadcastMessage); List failedConnections = broadcaster.broadcast(connections, broadcastPayload); - + // 실패한 연결 정리 for (String failedConnectionId : failedConnections) { connectionRepository.delete(failedConnectionId); logger.info("Deleted stale connection: {}", failedConnectionId); } - + return WebSocketEventUtil.ok("Message sent"); } @@ -191,12 +204,14 @@ private Map broadcastGuessMessage(MessagePayload payload) { // 추측 메시지 생성 (저장하지 않음) Map guessMessage = new HashMap<>(); + guessMessage.put("domain", WebSocketMessageHelper.DOMAIN_GAME); guessMessage.put("messageId", messageId); guessMessage.put("roomId", payload.roomId); guessMessage.put("userId", payload.userId); guessMessage.put("content", payload.content); guessMessage.put("messageType", "GUESS"); guessMessage.put("createdAt", now); + guessMessage.put("timestamp", System.currentTimeMillis()); List connections = connectionRepository.findByRoomId(payload.roomId); String broadcastPayload = gson.toJson(guessMessage); @@ -238,24 +253,20 @@ private Map handleCorrectAnswer(MessagePayload payload, GameServ private void broadcastCorrectAnswerMessage(MessagePayload payload, GameService.AnswerCheckResult result, List connections) { String messageId = UUID.randomUUID().toString(); String now = Instant.now().toString(); - + String message = String.format("🎉 %s님이 정답을 맞췄습니다! (+%d점)", payload.userId, result.score()); - - ChatMessage correctMessage = ChatMessage.builder() - .pk("ROOM#" + payload.roomId) - .sk("MSG#" + now + "#" + messageId) - .gsi1pk("SYSTEM") - .gsi1sk("MSG#" + now) - .gsi2pk("MSG#" + messageId) - .gsi2sk("ROOM#" + payload.roomId) - .messageId(messageId) - .roomId(payload.roomId) - .userId("SYSTEM") - .content(message) - .messageType(MessageType.CORRECT_ANSWER.getCode()) - .createdAt(now) - .build(); - + + // domain 필드 포함을 위해 Map으로 생성 + Map correctMessage = new HashMap<>(); + correctMessage.put("domain", WebSocketMessageHelper.DOMAIN_GAME); + correctMessage.put("messageId", messageId); + correctMessage.put("roomId", payload.roomId); + correctMessage.put("userId", "SYSTEM"); + correctMessage.put("content", message); + correctMessage.put("messageType", MessageType.CORRECT_ANSWER.getCode()); + correctMessage.put("createdAt", now); + correctMessage.put("timestamp", System.currentTimeMillis()); + String broadcastPayload = gson.toJson(correctMessage); List failedConnections = broadcaster.broadcast(connections, broadcastPayload); cleanupFailedConnections(failedConnections); @@ -325,24 +336,20 @@ private Map handleCommandResult(CommandResult result, String roo return WebSocketEventUtil.ok("Command executed"); } - // 일반 시스템 메시지 + // 일반 시스템 메시지 (게임 관련 명령어 결과) String messageId = UUID.randomUUID().toString(); String now = Instant.now().toString(); - ChatMessage systemMessage = ChatMessage.builder() - .pk("ROOM#" + roomId) - .sk("MSG#" + now + "#" + messageId) - .gsi1pk("SYSTEM") - .gsi1sk("MSG#" + now) - .gsi2pk("MSG#" + messageId) - .gsi2sk("ROOM#" + roomId) - .messageId(messageId) - .roomId(roomId) - .userId("SYSTEM") - .content(result.message()) - .messageType(result.messageType().getCode()) - .createdAt(now) - .build(); + // domain 필드 포함을 위해 Map으로 생성 + Map systemMessage = new HashMap<>(); + systemMessage.put("domain", WebSocketMessageHelper.DOMAIN_GAME); + systemMessage.put("messageId", messageId); + systemMessage.put("roomId", roomId); + systemMessage.put("userId", "SYSTEM"); + systemMessage.put("content", result.message()); + systemMessage.put("messageType", result.messageType().getCode()); + systemMessage.put("createdAt", now); + systemMessage.put("timestamp", System.currentTimeMillis()); String broadcastPayload = gson.toJson(systemMessage); List failedConnections = broadcaster.broadcast(connections, broadcastPayload); @@ -365,12 +372,14 @@ private void broadcastGameStart(List connections, CommandResult resu for (Connection conn : connections) { Map message = new HashMap<>(); + message.put("domain", WebSocketMessageHelper.DOMAIN_GAME); message.put("messageId", messageId); message.put("roomId", roomId); message.put("userId", "SYSTEM"); message.put("content", result.message()); message.put("messageType", result.messageType().getCode()); message.put("createdAt", now); + message.put("timestamp", serverTime); // 게임 상태 정보 message.put("gameStatus", gameResult.room().getGameStatus()); @@ -418,12 +427,14 @@ private void broadcastRoundEnd(List connections, CommandResult resul for (Connection conn : connections) { Map message = new HashMap<>(); + message.put("domain", WebSocketMessageHelper.DOMAIN_GAME); message.put("messageId", messageId); message.put("roomId", roomId); message.put("userId", "SYSTEM"); message.put("content", result.message()); message.put("messageType", result.messageType().getCode()); message.put("createdAt", now); + message.put("timestamp", serverTime); // 기본 데이터 복사 (nextWord 제외) Map messageData = new HashMap<>(); From 9c611153dd2d895047182725c4e3e5aaea3e82c4 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 17:25:29 +0900 Subject: [PATCH 386/528] =?UTF-8?q?feat:=20GameSession=20=EB=AA=A8?= =?UTF-8?q?=EB=8D=B8=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DynamoDB Enhanced Client 어노테이션 적용 - GSI1: roomId로 활성 게임 세션 조회 가능 - 게임 상태 관리용 헬퍼 메서드 포함 Closes #428 --- .../domain/chatting/model/GameSession.java | 189 ++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSession.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSession.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSession.java new file mode 100644 index 00000000..7f4ee1aa --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSession.java @@ -0,0 +1,189 @@ +package com.mzc.secondproject.serverless.domain.chatting.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.*; + +import java.util.List; +import java.util.Map; + +/** + * 게임 세션 모델 + * ChatRoom에서 분리된 게임 상태 관리용 독립 모델 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DynamoDbBean +public class GameSession { + + private String pk; // GAME#{gameSessionId} + private String sk; // METADATA + private String gsi1pk; // ROOM#{roomId} + private String gsi1sk; // GAME#{createdAt} + + private String gameSessionId; + private String roomId; + private String gameType; // "catchmind" + + // 게임 상태 + private String status; // NONE, WAITING, PLAYING, ROUND_END, FINISHED + private String startedBy; + private Long startedAt; + private Long endedAt; + + // 라운드 정보 + private Integer currentRound; + private Integer totalRounds; + private String currentDrawerId; + private String currentWordId; + private String currentWord; + private Long roundStartTime; + private Integer roundDuration; + + // 점수 및 플레이어 + private Map scores; + private Map streaks; + private List players; + private List drawerOrder; + + // 라운드 내 상태 + private Boolean hintUsed; + private List correctGuessers; + + // 스케줄링 (게임 자동 종료용) + private Long gameEndScheduledAt; + private String scheduleRuleArn; + + // TTL (게임 종료 후 일정 시간 뒤 삭제) + private Long ttl; + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { + return pk; + } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { + return sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1PK") + public String getGsi1pk() { + return gsi1pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1SK") + public String getGsi1sk() { + return gsi1sk; + } + + /** + * 게임이 활성 상태인지 확인 + */ + public boolean isActive() { + return "PLAYING".equals(status) || "ROUND_END".equals(status); + } + + /** + * 게임 시작 가능 여부 확인 + */ + public boolean canStart() { + return status == null || "NONE".equals(status) || "FINISHED".equals(status); + } + + /** + * 출제자 여부 확인 + */ + public boolean isDrawer(String userId) { + return userId != null && userId.equals(currentDrawerId); + } + + /** + * 이미 정답을 맞춘 사용자인지 확인 + */ + public boolean hasAlreadyGuessedCorrect(String userId) { + return correctGuessers != null && correctGuessers.contains(userId); + } + + /** + * 정답자 추가 + */ + public void addCorrectGuesser(String userId) { + if (correctGuessers == null) { + correctGuessers = new java.util.ArrayList<>(); + } + if (!correctGuessers.contains(userId)) { + correctGuessers.add(userId); + } + } + + /** + * 점수 추가 + */ + public void addScore(String userId, int points) { + if (scores == null) { + scores = new java.util.HashMap<>(); + } + scores.merge(userId, points, Integer::sum); + } + + /** + * 연속 정답 수 증가 + */ + public int incrementStreak(String userId) { + if (streaks == null) { + streaks = new java.util.HashMap<>(); + } + int newStreak = streaks.getOrDefault(userId, 0) + 1; + streaks.put(userId, newStreak); + return newStreak; + } + + /** + * 연속 정답 수 리셋 + */ + public void resetStreak(String userId) { + if (streaks != null) { + streaks.put(userId, 0); + } + } + + /** + * 다음 출제자 ID 반환 + */ + public String getNextDrawerId() { + if (drawerOrder == null || drawerOrder.isEmpty()) { + return null; + } + if (currentDrawerId == null) { + return drawerOrder.get(0); + } + int currentIndex = drawerOrder.indexOf(currentDrawerId); + if (currentIndex == -1 || currentIndex >= drawerOrder.size() - 1) { + return drawerOrder.get(0); + } + return drawerOrder.get(currentIndex + 1); + } + + /** + * 전원이 정답을 맞췄는지 확인 + */ + public boolean allPlayersGuessedCorrect() { + if (players == null || correctGuessers == null) { + return false; + } + // 출제자 제외한 인원이 모두 정답 + long guessersCount = players.stream() + .filter(p -> !p.equals(currentDrawerId)) + .count(); + return correctGuessers.size() >= guessersCount; + } +} From bf25a6e3ee3347c47f7cde74c18a30d260cbb726 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 17:26:59 +0900 Subject: [PATCH 387/528] =?UTF-8?q?feat:=20GameSessionRepository=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기본 CRUD (save, findById, delete) - roomId로 활성/전체 게임 세션 조회 - 상태, 라운드, 점수 업데이트 메서드 - 정답자 추가, 힌트 사용, 게임 종료 처리 Closes #429 --- .../repository/GameSessionRepository.java | 286 ++++++++++++++++++ 1 file changed, 286 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/GameSessionRepository.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/GameSessionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/GameSessionRepository.java new file mode 100644 index 00000000..61623038 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/GameSessionRepository.java @@ -0,0 +1,286 @@ +package com.mzc.secondproject.serverless.domain.chatting.repository; + +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.config.EnvConfig; +import com.mzc.secondproject.serverless.domain.chatting.model.GameSession; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbIndex; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; +import software.amazon.awssdk.enhanced.dynamodb.Key; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; +import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * GameSession Repository + * 게임 세션 CRUD 및 조회 기능 제공 + */ +public class GameSessionRepository { + + private static final Logger logger = LoggerFactory.getLogger(GameSessionRepository.class); + private static final String TABLE_NAME = EnvConfig.getRequired("CHAT_TABLE_NAME"); + + private final DynamoDbTable table; + + public GameSessionRepository() { + this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(GameSession.class)); + } + + /** + * 게임 세션 저장 + */ + public GameSession save(GameSession session) { + logger.info("Saving game session: {}", session.getGameSessionId()); + table.putItem(session); + return session; + } + + /** + * ID로 게임 세션 조회 + */ + public Optional findById(String gameSessionId) { + Key key = Key.builder() + .partitionValue("GAME#" + gameSessionId) + .sortValue("METADATA") + .build(); + + GameSession session = table.getItem(key); + return Optional.ofNullable(session); + } + + /** + * 게임 세션 삭제 + */ + public void delete(String gameSessionId) { + Key key = Key.builder() + .partitionValue("GAME#" + gameSessionId) + .sortValue("METADATA") + .build(); + + table.deleteItem(key); + logger.info("Deleted game session: {}", gameSessionId); + } + + /** + * roomId로 활성 게임 세션 조회 (PLAYING 또는 ROUND_END 상태) + */ + public Optional findActiveByRoomId(String roomId) { + List sessions = findByRoomId(roomId); + + return sessions.stream() + .filter(GameSession::isActive) + .findFirst(); + } + + /** + * roomId로 모든 게임 세션 조회 (최신순) + */ + public List findByRoomId(String roomId) { + QueryConditional queryConditional = QueryConditional + .keyEqualTo(Key.builder() + .partitionValue("ROOM#" + roomId) + .build()); + + QueryEnhancedRequest request = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .scanIndexForward(false) // 최신순 + .build(); + + DynamoDbIndex gsi1 = table.index("GSI1"); + + return gsi1.query(request).stream() + .flatMap(page -> page.items().stream()) + .toList(); + } + + /** + * 게임 상태 업데이트 + */ + public void updateStatus(String gameSessionId, String status) { + Map key = buildKey(gameSessionId); + + Map expressionValues = new HashMap<>(); + expressionValues.put(":status", AttributeValue.builder().s(status).build()); + + UpdateItemRequest updateRequest = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(key) + .updateExpression("SET #status = :status") + .expressionAttributeNames(Map.of("#status", "status")) + .expressionAttributeValues(expressionValues) + .build(); + + AwsClients.dynamoDb().updateItem(updateRequest); + logger.info("Updated game session status: {} -> {}", gameSessionId, status); + } + + /** + * 라운드 정보 업데이트 + */ + public void updateRoundInfo(String gameSessionId, int currentRound, String drawerId, + String wordId, String word, long roundStartTime, int roundDuration) { + Map key = buildKey(gameSessionId); + + Map expressionValues = new HashMap<>(); + expressionValues.put(":round", AttributeValue.builder().n(String.valueOf(currentRound)).build()); + expressionValues.put(":drawer", AttributeValue.builder().s(drawerId).build()); + expressionValues.put(":wordId", AttributeValue.builder().s(wordId).build()); + expressionValues.put(":word", AttributeValue.builder().s(word).build()); + expressionValues.put(":startTime", AttributeValue.builder().n(String.valueOf(roundStartTime)).build()); + expressionValues.put(":duration", AttributeValue.builder().n(String.valueOf(roundDuration)).build()); + expressionValues.put(":hintUsed", AttributeValue.builder().bool(false).build()); + expressionValues.put(":emptyList", AttributeValue.builder().l(List.of()).build()); + + String updateExpression = "SET currentRound = :round, " + + "currentDrawerId = :drawer, " + + "currentWordId = :wordId, " + + "currentWord = :word, " + + "roundStartTime = :startTime, " + + "roundDuration = :duration, " + + "hintUsed = :hintUsed, " + + "correctGuessers = :emptyList"; + + UpdateItemRequest updateRequest = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(key) + .updateExpression(updateExpression) + .expressionAttributeValues(expressionValues) + .build(); + + AwsClients.dynamoDb().updateItem(updateRequest); + logger.info("Updated round info: gameSession={}, round={}, drawer={}", gameSessionId, currentRound, drawerId); + } + + /** + * 점수 업데이트 + */ + public void updateScores(String gameSessionId, Map scores) { + Map key = buildKey(gameSessionId); + + Map scoresMap = new HashMap<>(); + scores.forEach((userId, score) -> + scoresMap.put(userId, AttributeValue.builder().n(String.valueOf(score)).build())); + + Map expressionValues = new HashMap<>(); + expressionValues.put(":scores", AttributeValue.builder().m(scoresMap).build()); + + UpdateItemRequest updateRequest = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(key) + .updateExpression("SET scores = :scores") + .expressionAttributeValues(expressionValues) + .build(); + + AwsClients.dynamoDb().updateItem(updateRequest); + logger.info("Updated scores for game session: {}", gameSessionId); + } + + /** + * 정답자 추가 + */ + public void addCorrectGuesser(String gameSessionId, String userId) { + Map key = buildKey(gameSessionId); + + Map expressionValues = new HashMap<>(); + expressionValues.put(":userId", AttributeValue.builder().l( + AttributeValue.builder().s(userId).build() + ).build()); + expressionValues.put(":emptyList", AttributeValue.builder().l(List.of()).build()); + + UpdateItemRequest updateRequest = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(key) + .updateExpression("SET correctGuessers = list_append(if_not_exists(correctGuessers, :emptyList), :userId)") + .expressionAttributeValues(expressionValues) + .build(); + + AwsClients.dynamoDb().updateItem(updateRequest); + logger.info("Added correct guesser: gameSession={}, userId={}", gameSessionId, userId); + } + + /** + * 연속 정답(streak) 업데이트 + */ + public void updateStreak(String gameSessionId, String userId, int streak) { + Map key = buildKey(gameSessionId); + + Map expressionValues = new HashMap<>(); + expressionValues.put(":streak", AttributeValue.builder().n(String.valueOf(streak)).build()); + + Map expressionNames = new HashMap<>(); + expressionNames.put("#streaks", "streaks"); + expressionNames.put("#userId", userId); + + UpdateItemRequest updateRequest = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(key) + .updateExpression("SET #streaks.#userId = :streak") + .expressionAttributeNames(expressionNames) + .expressionAttributeValues(expressionValues) + .build(); + + AwsClients.dynamoDb().updateItem(updateRequest); + logger.info("Updated streak: gameSession={}, userId={}, streak={}", gameSessionId, userId, streak); + } + + /** + * 힌트 사용 처리 + */ + public void markHintUsed(String gameSessionId) { + Map key = buildKey(gameSessionId); + + Map expressionValues = new HashMap<>(); + expressionValues.put(":hintUsed", AttributeValue.builder().bool(true).build()); + + UpdateItemRequest updateRequest = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(key) + .updateExpression("SET hintUsed = :hintUsed") + .expressionAttributeValues(expressionValues) + .build(); + + AwsClients.dynamoDb().updateItem(updateRequest); + logger.info("Marked hint used for game session: {}", gameSessionId); + } + + /** + * 게임 종료 처리 + */ + public void finishGame(String gameSessionId, long endedAt, long ttl) { + Map key = buildKey(gameSessionId); + + Map expressionValues = new HashMap<>(); + expressionValues.put(":status", AttributeValue.builder().s("FINISHED").build()); + expressionValues.put(":endedAt", AttributeValue.builder().n(String.valueOf(endedAt)).build()); + expressionValues.put(":ttl", AttributeValue.builder().n(String.valueOf(ttl)).build()); + + UpdateItemRequest updateRequest = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(key) + .updateExpression("SET #status = :status, endedAt = :endedAt, #ttl = :ttl") + .expressionAttributeNames(Map.of("#status", "status", "#ttl", "ttl")) + .expressionAttributeValues(expressionValues) + .build(); + + AwsClients.dynamoDb().updateItem(updateRequest); + logger.info("Finished game session: {}", gameSessionId); + } + + /** + * DynamoDB 키 빌더 헬퍼 + */ + private Map buildKey(String gameSessionId) { + Map key = new HashMap<>(); + key.put("PK", AttributeValue.builder().s("GAME#" + gameSessionId).build()); + key.put("SK", AttributeValue.builder().s("METADATA").build()); + return key; + } +} From 13f254e9c0ef67890a15b54725dcab0805dfcd6a Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 17:28:07 +0900 Subject: [PATCH 388/528] =?UTF-8?q?refactor:=20ChatRoom=EC=97=90=EC=84=9C?= =?UTF-8?q?=20=EA=B2=8C=EC=9E=84=20=ED=95=84=EB=93=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 게임 관련 필드 제거 (gameStatus, currentRound 등) - activeGameSessionId 필드 추가 - 게임 상태는 GameSession으로 분리됨 Note: 의존 코드 수정은 #431에서 진행 Closes #430 --- .../domain/chatting/model/ChatRoom.java | 20 +++---------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/ChatRoom.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/ChatRoom.java index 5fe45aaf..0c076da4 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/ChatRoom.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/ChatRoom.java @@ -7,7 +7,6 @@ import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.*; import java.util.List; -import java.util.Map; @Data @Builder @@ -34,22 +33,9 @@ public class ChatRoom { private String lastMessageAt; private List memberIds; // 참여 멤버 목록 private Long ttl; - - // 게임 관련 필드 - private String gameStatus; // NONE, WAITING, PLAYING, ROUND_END, FINISHED - private String gameStartedBy; // 게임 시작한 사용자 ID - private Integer currentRound; // 현재 라운드 (1부터 시작) - private Integer totalRounds; // 총 라운드 수 - private String currentDrawerId; // 현재 출제자 userId - private String currentWordId; // 현재 제시어 wordId - private String currentWord; // 현재 제시어 (korean) - private Long roundStartTime; // 라운드 시작 시간 (Unix timestamp) - private Integer roundTimeLimit; // 라운드 제한 시간 (초) - private List drawerOrder; // 출제 순서 (userId 목록) - private Map scores; // 사용자별 점수 - private Map streaks; // 사용자별 연속 정답 수 - private Boolean hintUsed; // 현재 라운드 힌트 사용 여부 - private List correctGuessers; // 현재 라운드 정답자 목록 + + // 게임 세션 참조 (게임 상태는 GameSession으로 분리됨) + private String activeGameSessionId; // 현재 진행중인 게임 세션 ID (nullable) @DynamoDbPartitionKey @DynamoDbAttribute("PK") From 5195ca601d9a634f957b7abcebc68630e7e7f47b Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 17:35:45 +0900 Subject: [PATCH 389/528] =?UTF-8?q?refactor:=20GameSession=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=EC=9C=BC=EB=A1=9C=20=EC=A0=84=EC=B2=B4=20=EA=B2=8C?= =?UTF-8?q?=EC=9E=84=20=EB=A1=9C=EC=A7=81=20=EB=A6=AC=ED=8C=A9=ED=86=A0?= =?UTF-8?q?=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GameService: ChatRoom 대신 GameSession 사용 - GameStatsService: GameSession 매개변수로 변경 - CommandService: GameSession 기반 점수 조회 - GameHandler: GameSession 기반 REST API - WebSocketMessageHandler: GameSession 기반 브로드캐스트 - GameStatusResponse, ScoreboardResponse: GameSession 매개변수 Closes #431 --- .../dto/response/GameStatusResponse.java | 26 +- .../dto/response/ScoreboardResponse.java | 22 +- .../domain/chatting/handler/GameHandler.java | 120 +++-- .../websocket/WebSocketMessageHandler.java | 39 +- .../chatting/service/CommandService.java | 49 +- .../domain/chatting/service/GameService.java | 489 ++++++++++-------- .../chatting/service/GameStatsService.java | 16 +- 7 files changed, 407 insertions(+), 354 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/GameStatusResponse.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/GameStatusResponse.java index 5676a93a..9a66e2d1 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/GameStatusResponse.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/GameStatusResponse.java @@ -1,6 +1,6 @@ package com.mzc.secondproject.serverless.domain.chatting.dto.response; -import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; +import com.mzc.secondproject.serverless.domain.chatting.model.GameSession; import java.util.List; import java.util.Map; @@ -14,24 +14,24 @@ public record GameStatusResponse( Integer totalRounds, String currentDrawerId, Long roundStartTime, - Integer roundTimeLimit, + Integer roundDuration, List drawerOrder, Map scores, Boolean hintUsed, List correctGuessers ) { - public static GameStatusResponse from(ChatRoom room, List drawerOrder) { + public static GameStatusResponse from(GameSession session) { return new GameStatusResponse( - room.getGameStatus(), - room.getCurrentRound(), - room.getTotalRounds(), - room.getCurrentDrawerId(), - room.getRoundStartTime(), - room.getRoundTimeLimit(), - drawerOrder != null ? drawerOrder : room.getDrawerOrder(), - room.getScores(), - room.getHintUsed(), - room.getCorrectGuessers() + session.getStatus(), + session.getCurrentRound(), + session.getTotalRounds(), + session.getCurrentDrawerId(), + session.getRoundStartTime(), + session.getRoundDuration(), + session.getDrawerOrder(), + session.getScores(), + session.getHintUsed(), + session.getCorrectGuessers() ); } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/ScoreboardResponse.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/ScoreboardResponse.java index 6cf2bdce..eb27a347 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/ScoreboardResponse.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/ScoreboardResponse.java @@ -1,6 +1,6 @@ package com.mzc.secondproject.serverless.domain.chatting.dto.response; -import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; +import com.mzc.secondproject.serverless.domain.chatting.model.GameSession; import java.util.List; import java.util.Map; @@ -15,33 +15,33 @@ public record ScoreboardResponse( Integer currentRound, Integer totalRounds ) { - public static ScoreboardResponse from(ChatRoom room) { - Map scores = room.getScores(); + public static ScoreboardResponse from(GameSession session) { + Map scores = session.getScores(); List ranking = buildRanking(scores); - + return new ScoreboardResponse( scores, ranking, - room.getGameStatus(), - room.getCurrentRound(), - room.getTotalRounds() + session.getStatus(), + session.getCurrentRound(), + session.getTotalRounds() ); } - + private static List buildRanking(Map scores) { if (scores == null || scores.isEmpty()) { return List.of(); } - + List> sorted = scores.entrySet().stream() .sorted(Map.Entry.comparingByValue().reversed()) .toList(); - + return java.util.stream.IntStream.range(0, sorted.size()) .mapToObj(i -> new RankEntry(i + 1, sorted.get(i).getKey(), sorted.get(i).getValue())) .toList(); } - + public record RankEntry( int rank, String userId, diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameHandler.java index 56410132..b95a369e 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameHandler.java @@ -8,15 +8,16 @@ import com.mzc.secondproject.serverless.common.router.Route; import com.mzc.secondproject.serverless.common.util.ResponseGenerator; import com.mzc.secondproject.serverless.common.util.WebSocketBroadcaster; +import com.mzc.secondproject.serverless.common.util.WebSocketMessageHelper; import com.mzc.secondproject.serverless.domain.chatting.dto.response.CommandResult; import com.mzc.secondproject.serverless.domain.chatting.dto.response.GameStatusResponse; import com.mzc.secondproject.serverless.domain.chatting.dto.response.ScoreboardResponse; import com.mzc.secondproject.serverless.domain.chatting.enums.MessageType; import com.mzc.secondproject.serverless.domain.chatting.exception.ChattingErrorCode; -import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; import com.mzc.secondproject.serverless.domain.chatting.model.Connection; -import com.mzc.secondproject.serverless.domain.chatting.repository.ChatRoomRepository; +import com.mzc.secondproject.serverless.domain.chatting.model.GameSession; import com.mzc.secondproject.serverless.domain.chatting.repository.ConnectionRepository; +import com.mzc.secondproject.serverless.domain.chatting.repository.GameSessionRepository; import com.mzc.secondproject.serverless.domain.chatting.service.GameService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -28,23 +29,23 @@ * 게임 REST API 핸들러 */ public class GameHandler implements RequestHandler { - + private static final Logger logger = LoggerFactory.getLogger(GameHandler.class); - + private final GameService gameService; - private final ChatRoomRepository chatRoomRepository; + private final GameSessionRepository gameSessionRepository; private final ConnectionRepository connectionRepository; private final WebSocketBroadcaster broadcaster; private final HandlerRouter router; - + public GameHandler() { this.gameService = new GameService(); - this.chatRoomRepository = new ChatRoomRepository(); + this.gameSessionRepository = new GameSessionRepository(); this.connectionRepository = new ConnectionRepository(); this.broadcaster = new WebSocketBroadcaster(); this.router = initRouter(); } - + private HandlerRouter initRouter() { return new HandlerRouter().addRoutes( Route.postAuth("/rooms/{roomId}/game/start", this::startGame), @@ -53,140 +54,151 @@ private HandlerRouter initRouter() { Route.getAuth("/rooms/{roomId}/game/scores", this::getScores) ); } - + @Override public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { logger.info("Received request: {} {}", request.getHttpMethod(), request.getPath()); return router.route(request); } - + /** * POST /rooms/{roomId}/game/start - 게임 시작 */ private APIGatewayProxyResponseEvent startGame(APIGatewayProxyRequestEvent request, String userId) { String roomId = request.getPathParameters().get("roomId"); - + GameService.GameStartResult result = gameService.startGame(roomId, userId); - + if (!result.success()) { return ResponseGenerator.fail(ChattingErrorCode.GAME_START_FAILED, result.error()); } - + // WebSocket으로 게임 시작 알림 브로드캐스트 broadcastGameStart(roomId, result); - - GameStatusResponse response = GameStatusResponse.from(result.room(), result.drawerOrder()); + + GameStatusResponse response = GameStatusResponse.from(result.session()); return ResponseGenerator.ok("Game started", response); } - + /** * POST /rooms/{roomId}/game/stop - 게임 중단 */ private APIGatewayProxyResponseEvent stopGame(APIGatewayProxyRequestEvent request, String userId) { String roomId = request.getPathParameters().get("roomId"); - + CommandResult result = gameService.stopGame(roomId, userId); - + if (!result.success()) { return ResponseGenerator.fail(ChattingErrorCode.GAME_STOP_FAILED, result.message()); } - + // WebSocket으로 게임 종료 알림 브로드캐스트 broadcastSystemMessage(roomId, result.message(), MessageType.GAME_END); - + return ResponseGenerator.ok("Game stopped", Map.of("message", result.message())); } - + /** * GET /rooms/{roomId}/game/status - 게임 상태 조회 */ private APIGatewayProxyResponseEvent getGameStatus(APIGatewayProxyRequestEvent request, String userId) { String roomId = request.getPathParameters().get("roomId"); - - Optional optRoom = chatRoomRepository.findById(roomId); - if (optRoom.isEmpty()) { - return ResponseGenerator.fail(ChattingErrorCode.ROOM_NOT_FOUND); + + Optional optSession = gameSessionRepository.findActiveByRoomId(roomId); + if (optSession.isEmpty()) { + // 게임이 없는 경우 빈 상태 반환 + return ResponseGenerator.ok("No active game", Map.of("gameStatus", "NONE")); } - - ChatRoom room = optRoom.get(); - GameStatusResponse response = GameStatusResponse.from(room, room.getDrawerOrder()); - + + GameSession session = optSession.get(); + GameStatusResponse response = GameStatusResponse.from(session); + return ResponseGenerator.ok("Game status retrieved", response); } - + /** * GET /rooms/{roomId}/game/scores - 점수 조회 */ private APIGatewayProxyResponseEvent getScores(APIGatewayProxyRequestEvent request, String userId) { String roomId = request.getPathParameters().get("roomId"); - - Optional optRoom = chatRoomRepository.findById(roomId); - if (optRoom.isEmpty()) { - return ResponseGenerator.fail(ChattingErrorCode.ROOM_NOT_FOUND); + + Optional optSession = gameSessionRepository.findActiveByRoomId(roomId); + if (optSession.isEmpty()) { + return ResponseGenerator.ok("No active game", Map.of("scores", Map.of())); } - - ChatRoom room = optRoom.get(); - ScoreboardResponse response = ScoreboardResponse.from(room); - + + GameSession session = optSession.get(); + ScoreboardResponse response = ScoreboardResponse.from(session); + return ResponseGenerator.ok("Scores retrieved", response); } - + /** * 게임 시작 브로드캐스트 */ private void broadcastGameStart(String roomId, GameService.GameStartResult result) { String messageId = UUID.randomUUID().toString(); String now = Instant.now().toString(); - + long serverTime = System.currentTimeMillis(); + + GameSession session = result.session(); + String message = String.format(""" 🎮 게임 시작! 총 %d 라운드 - + 라운드 1 시작! 출제자: %s """, - result.room().getTotalRounds(), - result.room().getCurrentDrawerId()); - + session.getTotalRounds(), + session.getCurrentDrawerId()); + Map gameStartMessage = new HashMap<>(); + gameStartMessage.put("domain", WebSocketMessageHelper.DOMAIN_GAME); gameStartMessage.put("messageId", messageId); gameStartMessage.put("roomId", roomId); gameStartMessage.put("userId", "SYSTEM"); gameStartMessage.put("content", message); gameStartMessage.put("messageType", MessageType.GAME_START.getCode()); gameStartMessage.put("createdAt", now); - gameStartMessage.put("gameStatus", result.room().getGameStatus()); - gameStartMessage.put("currentRound", result.room().getCurrentRound()); - gameStartMessage.put("totalRounds", result.room().getTotalRounds()); - gameStartMessage.put("currentDrawerId", result.room().getCurrentDrawerId()); + gameStartMessage.put("timestamp", serverTime); + gameStartMessage.put("gameStatus", session.getStatus()); + gameStartMessage.put("currentRound", session.getCurrentRound()); + gameStartMessage.put("totalRounds", session.getTotalRounds()); + gameStartMessage.put("currentDrawerId", session.getCurrentDrawerId()); gameStartMessage.put("drawerOrder", result.drawerOrder()); - + gameStartMessage.put("roundStartTime", session.getRoundStartTime()); + gameStartMessage.put("serverTime", serverTime); + gameStartMessage.put("roundDuration", session.getRoundDuration()); + List connections = connectionRepository.findByRoomId(roomId); String broadcastPayload = ResponseGenerator.gson().toJson(gameStartMessage); broadcaster.broadcast(connections, broadcastPayload); - + logger.info("Game start broadcasted: roomId={}", roomId); } - + /** * 시스템 메시지 브로드캐스트 */ private void broadcastSystemMessage(String roomId, String message, MessageType messageType) { String messageId = UUID.randomUUID().toString(); String now = Instant.now().toString(); - + Map systemMessage = new HashMap<>(); + systemMessage.put("domain", WebSocketMessageHelper.DOMAIN_GAME); systemMessage.put("messageId", messageId); systemMessage.put("roomId", roomId); systemMessage.put("userId", "SYSTEM"); systemMessage.put("content", message); systemMessage.put("messageType", messageType.getCode()); systemMessage.put("createdAt", now); - + systemMessage.put("timestamp", System.currentTimeMillis()); + List connections = connectionRepository.findByRoomId(roomId); String broadcastPayload = ResponseGenerator.gson().toJson(systemMessage); broadcaster.broadcast(connections, broadcastPayload); - + logger.info("System message broadcasted: roomId={}, type={}", roomId, messageType); } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java index d4b803d8..c511c6ba 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java @@ -12,8 +12,10 @@ import com.mzc.secondproject.serverless.domain.chatting.enums.MessageType; import com.mzc.secondproject.serverless.domain.chatting.model.ChatMessage; import com.mzc.secondproject.serverless.domain.chatting.model.Connection; +import com.mzc.secondproject.serverless.domain.chatting.model.GameSession; import com.mzc.secondproject.serverless.domain.chatting.repository.ChatRoomRepository; import com.mzc.secondproject.serverless.domain.chatting.repository.ConnectionRepository; +import com.mzc.secondproject.serverless.domain.chatting.repository.GameSessionRepository; import com.mzc.secondproject.serverless.domain.chatting.service.ChatMessageService; import com.mzc.secondproject.serverless.domain.chatting.service.CommandService; import com.mzc.secondproject.serverless.domain.chatting.service.GameService; @@ -38,14 +40,16 @@ public class WebSocketMessageHandler implements RequestHandler broadcastGuessMessage(MessagePayload payload) { */ private Map handleCorrectAnswer(MessagePayload payload, GameService.AnswerCheckResult result) { List connections = connectionRepository.findByRoomId(payload.roomId); - + // 1. 정답 알림 메시지 브로드캐스트 broadcastCorrectAnswerMessage(payload, result, connections); - + // 2. 점수 업데이트 메시지 브로드캐스트 (실시간 리더보드) - chatRoomRepository.findById(payload.roomId).ifPresent(room -> { + gameSessionRepository.findActiveByRoomId(payload.roomId).ifPresent(session -> { broadcastScoreUpdate(payload.roomId, payload.userId, result.score(), - result.scores(), room.getCurrentRound(), room.getTotalRounds(), connections); + result.scores(), session.getCurrentRound(), session.getTotalRounds(), connections); }); - + logger.info("Correct answer: roomId={}, userId={}, score={}", payload.roomId, payload.userId, result.score()); - + // 전원 정답 시 라운드 종료 처리 if (result.allCorrect()) { handleAllCorrect(payload.roomId); } - + return WebSocketEventUtil.ok("Correct answer"); } @@ -310,10 +314,10 @@ private void cleanupFailedConnections(List failedConnections) { * 전원 정답 시 라운드 종료 */ private void handleAllCorrect(String roomId) { - chatRoomRepository.findById(roomId).ifPresent(room -> { - CommandResult endResult = gameService.endRound(room, "ALL_CORRECT"); + CommandResult endResult = gameService.endRound(roomId, "ALL_CORRECT"); + if (endResult != null && !endResult.message().contains("진행 중인 게임이 없습니다")) { handleCommandResult(endResult, roomId, "SYSTEM"); - }); + } } /** @@ -368,7 +372,8 @@ private void broadcastGameStart(List connections, CommandResult resu String now = Instant.now().toString(); long serverTime = System.currentTimeMillis(); - String currentDrawerId = gameResult.room().getCurrentDrawerId(); + GameSession session = gameResult.session(); + String currentDrawerId = session.getCurrentDrawerId(); for (Connection conn : connections) { Map message = new HashMap<>(); @@ -382,16 +387,16 @@ private void broadcastGameStart(List connections, CommandResult resu message.put("timestamp", serverTime); // 게임 상태 정보 - message.put("gameStatus", gameResult.room().getGameStatus()); - message.put("currentRound", gameResult.room().getCurrentRound()); - message.put("totalRounds", gameResult.room().getTotalRounds()); + message.put("gameStatus", session.getStatus()); + message.put("currentRound", session.getCurrentRound()); + message.put("totalRounds", session.getTotalRounds()); message.put("currentDrawerId", currentDrawerId); message.put("drawerOrder", gameResult.drawerOrder()); // 타이머 동기화용 필드 (핵심!) - message.put("roundStartTime", gameResult.room().getRoundStartTime()); + message.put("roundStartTime", session.getRoundStartTime()); message.put("serverTime", serverTime); - message.put("roundDuration", gameResult.room().getRoundTimeLimit()); + message.put("roundDuration", session.getRoundDuration()); // 출제자에게만 제시어 전송 if (conn.getUserId().equals(currentDrawerId) && gameResult.firstWord() != null) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/CommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/CommandService.java index 9fbda0b7..8f43d7af 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/CommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/CommandService.java @@ -2,10 +2,10 @@ import com.mzc.secondproject.serverless.domain.chatting.dto.response.CommandResult; import com.mzc.secondproject.serverless.domain.chatting.enums.MessageType; -import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; import com.mzc.secondproject.serverless.domain.chatting.model.Connection; -import com.mzc.secondproject.serverless.domain.chatting.repository.ChatRoomRepository; +import com.mzc.secondproject.serverless.domain.chatting.model.GameSession; import com.mzc.secondproject.serverless.domain.chatting.repository.ConnectionRepository; +import com.mzc.secondproject.serverless.domain.chatting.repository.GameSessionRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -18,14 +18,14 @@ public class CommandService { private static final Logger logger = LoggerFactory.getLogger(CommandService.class); - + private final ConnectionRepository connectionRepository; - private final ChatRoomRepository chatRoomRepository; + private final GameSessionRepository gameSessionRepository; private final GameService gameService; - + public CommandService() { this.connectionRepository = new ConnectionRepository(); - this.chatRoomRepository = new ChatRoomRepository(); + this.gameSessionRepository = new GameSessionRepository(); this.gameService = new GameService(); } @@ -78,21 +78,21 @@ private CommandResult handleMemberCommand(String roomId) { */ private CommandResult handleStartCommand(String roomId, String userId) { GameService.GameStartResult result = gameService.startGame(roomId, userId); - + if (!result.success()) { return CommandResult.error(result.error()); } - + String message = String.format(""" 🎮 게임 시작! 총 %d 라운드 - + 라운드 1 시작! 출제자: %s """, - result.room().getTotalRounds(), - result.room().getCurrentDrawerId()); - + result.session().getTotalRounds(), + result.session().getCurrentDrawerId()); + return CommandResult.success(MessageType.GAME_START, message, result); } @@ -107,28 +107,23 @@ private CommandResult handleStopCommand(String roomId, String userId) { * /score - 현재 점수 조회 */ private CommandResult handleScoreCommand(String roomId) { - Optional optRoom = chatRoomRepository.findById(roomId); - if (optRoom.isEmpty()) { - return CommandResult.error("채팅방을 찾을 수 없습니다."); - } - - ChatRoom room = optRoom.get(); - - if (room.getGameStatus() == null || "NONE".equals(room.getGameStatus())) { + Optional optSession = gameSessionRepository.findActiveByRoomId(roomId); + if (optSession.isEmpty()) { return CommandResult.error("진행 중인 게임이 없습니다."); } - - // TODO: 점수 포맷팅 (Story #225에서 구현) - if (room.getScores() == null || room.getScores().isEmpty()) { + + GameSession session = optSession.get(); + + if (session.getScores() == null || session.getScores().isEmpty()) { return CommandResult.success(MessageType.SCORE_UPDATE, "아직 점수가 없습니다."); } - + StringBuilder sb = new StringBuilder("📊 현재 점수:\n"); - room.getScores().entrySet().stream() + session.getScores().entrySet().stream() .sorted((a, b) -> b.getValue().compareTo(a.getValue())) .forEach(entry -> sb.append(String.format(" %s: %d점\n", entry.getKey(), entry.getValue()))); - - return CommandResult.success(MessageType.SCORE_UPDATE, sb.toString(), room.getScores()); + + return CommandResult.success(MessageType.SCORE_UPDATE, sb.toString(), session.getScores()); } /** diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java index 3ac401a5..ee8d92b9 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java @@ -8,9 +8,11 @@ import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; import com.mzc.secondproject.serverless.domain.chatting.model.Connection; import com.mzc.secondproject.serverless.domain.chatting.model.GameRound; +import com.mzc.secondproject.serverless.domain.chatting.model.GameSession; import com.mzc.secondproject.serverless.domain.chatting.repository.ChatRoomRepository; import com.mzc.secondproject.serverless.domain.chatting.repository.ConnectionRepository; import com.mzc.secondproject.serverless.domain.chatting.repository.GameRoundRepository; +import com.mzc.secondproject.serverless.domain.chatting.repository.GameSessionRepository; import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; import com.mzc.secondproject.serverless.domain.vocabulary.repository.WordRepository; import org.slf4j.Logger; @@ -22,93 +24,115 @@ /** * 캐치마인드 게임 로직 서비스 + * GameSession 모델을 사용하여 게임 상태 관리 */ public class GameService { - + private static final Logger logger = LoggerFactory.getLogger(GameService.class); - + private final ChatRoomRepository chatRoomRepository; private final ConnectionRepository connectionRepository; private final GameRoundRepository gameRoundRepository; + private final GameSessionRepository gameSessionRepository; private final WordRepository wordRepository; private final GameStatsService gameStatsService; - + /** * 기본 생성자 (Lambda에서 사용) */ public GameService() { this(new ChatRoomRepository(), new ConnectionRepository(), - new GameRoundRepository(), new WordRepository(), new GameStatsService()); + new GameRoundRepository(), new GameSessionRepository(), + new WordRepository(), new GameStatsService()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ public GameService(ChatRoomRepository chatRoomRepository, ConnectionRepository connectionRepository, - GameRoundRepository gameRoundRepository, WordRepository wordRepository, - GameStatsService gameStatsService) { + GameRoundRepository gameRoundRepository, GameSessionRepository gameSessionRepository, + WordRepository wordRepository, GameStatsService gameStatsService) { this.chatRoomRepository = chatRoomRepository; this.connectionRepository = connectionRepository; this.gameRoundRepository = gameRoundRepository; + this.gameSessionRepository = gameSessionRepository; this.wordRepository = wordRepository; this.gameStatsService = gameStatsService; } - + /** * 게임 시작 */ public GameStartResult startGame(String roomId, String userId) { ChatRoom room = chatRoomRepository.findById(roomId) .orElseThrow(() -> new IllegalArgumentException("채팅방을 찾을 수 없습니다.")); - - // 이미 게임 중인지 확인 - GameStatus currentStatus = GameStatus.fromString(room.getGameStatus()); - if (!currentStatus.canStartGame()) { + + // 이미 활성 게임 세션이 있는지 확인 + Optional existingSession = gameSessionRepository.findActiveByRoomId(roomId); + if (existingSession.isPresent()) { return GameStartResult.error("이미 게임이 진행 중입니다."); } - + // 접속자 확인 List connections = connectionRepository.findByRoomId(roomId); if (connections.size() < 2) { return GameStartResult.error("최소 2명 이상 접속해야 게임을 시작할 수 있습니다."); } - + // 출제 순서 생성 (랜덤 셔플) List drawerOrder = connections.stream() .map(Connection::getUserId) .collect(Collectors.toList()); Collections.shuffle(drawerOrder); - + // 제시어 추출 (난이도별) String level = room.getLevel() != null ? room.getLevel() : "beginner"; List words = getRandomWords(level, GameConfig.totalRounds()); - + if (words.size() < GameConfig.totalRounds()) { return GameStartResult.error("단어가 부족합니다. 관리자에게 문의하세요."); } - - // 게임 상태 업데이트 - room.setGameStatus(GameStatus.PLAYING.name()); - room.setGameStartedBy(userId); - room.setCurrentRound(1); - room.setTotalRounds(GameConfig.totalRounds()); - room.setDrawerOrder(drawerOrder); - room.setScores(new HashMap<>()); - room.setStreaks(new HashMap<>()); - room.setRoundTimeLimit(GameConfig.roundTimeLimit()); - - // 첫 라운드 설정 + + // 게임 세션 생성 + String gameSessionId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + long currentTime = System.currentTimeMillis(); + String firstDrawer = drawerOrder.get(0); Word firstWord = words.get(0); - room.setCurrentDrawerId(firstDrawer); - room.setCurrentWordId(firstWord.getWordId()); - room.setCurrentWord(firstWord.getKorean()); - room.setRoundStartTime(System.currentTimeMillis()); - room.setHintUsed(false); - room.setCorrectGuessers(new ArrayList<>()); - + + GameSession session = GameSession.builder() + .pk("GAME#" + gameSessionId) + .sk("METADATA") + .gsi1pk("ROOM#" + roomId) + .gsi1sk("GAME#" + now) + .gameSessionId(gameSessionId) + .roomId(roomId) + .gameType("catchmind") + .status(GameStatus.PLAYING.name()) + .startedBy(userId) + .startedAt(currentTime) + .currentRound(1) + .totalRounds(GameConfig.totalRounds()) + .currentDrawerId(firstDrawer) + .currentWordId(firstWord.getWordId()) + .currentWord(firstWord.getKorean()) + .roundStartTime(currentTime) + .roundDuration(GameConfig.roundTimeLimit()) + .scores(new HashMap<>()) + .streaks(new HashMap<>()) + .players(new ArrayList<>(drawerOrder)) + .drawerOrder(drawerOrder) + .hintUsed(false) + .correctGuessers(new ArrayList<>()) + .build(); + + gameSessionRepository.save(session); + + // ChatRoom에 활성 게임 세션 ID 연결 + room.setActiveGameSessionId(gameSessionId); chatRoomRepository.save(room); - + // 첫 라운드 기록 생성 (7일 후 자동 삭제) long ttlSeconds = Instant.now().plusSeconds(7 * 24 * 60 * 60).getEpochSecond(); GameRound firstRound = GameRound.builder() @@ -120,184 +144,180 @@ public GameStartResult startGame(String roomId, String userId) { .wordId(firstWord.getWordId()) .word(firstWord.getKorean()) .wordEnglish(firstWord.getEnglish()) - .startTime(System.currentTimeMillis()) + .startTime(currentTime) .hintUsed(false) .correctGuessers(new ArrayList<>()) .guessTimes(new HashMap<>()) .roundScores(new HashMap<>()) - .createdAt(Instant.now().toString()) + .createdAt(now) .ttl(ttlSeconds) .build(); - + gameRoundRepository.save(firstRound); - - logger.info("Game started: roomId={}, starter={}, rounds={}", roomId, userId, GameConfig.totalRounds()); - - return GameStartResult.success(room, firstWord, drawerOrder); + + logger.info("Game started: roomId={}, sessionId={}, starter={}, rounds={}", + roomId, gameSessionId, userId, GameConfig.totalRounds()); + + return GameStartResult.success(session, firstWord, drawerOrder); } - + /** * 게임 종료 */ public CommandResult stopGame(String roomId, String userId) { ChatRoom room = chatRoomRepository.findById(roomId) .orElseThrow(() -> new IllegalArgumentException("채팅방을 찾을 수 없습니다.")); - - GameStatus currentStatus = GameStatus.fromString(room.getGameStatus()); - if (!currentStatus.isGameActive()) { + + GameSession session = gameSessionRepository.findActiveByRoomId(roomId) + .orElse(null); + + if (session == null || !session.isActive()) { return CommandResult.error("진행 중인 게임이 없습니다."); } - + // 권한 확인 boolean isOwner = userId.equals(room.getCreatedBy()); - boolean isGameStarter = userId.equals(room.getGameStartedBy()); - + boolean isGameStarter = userId.equals(session.getStartedBy()); + if (!isOwner && !isGameStarter) { return CommandResult.error("게임을 중단할 권한이 없습니다."); } - + // 게임 종료 처리 - return finishGame(room, "STOPPED"); + return finishGame(session, room, "STOPPED"); } - + /** * 정답 체크 */ public AnswerCheckResult checkAnswer(String roomId, String userId, String answer) { - ChatRoom room = chatRoomRepository.findById(roomId) - .orElseThrow(() -> new IllegalArgumentException("채팅방을 찾을 수 없습니다.")); - + GameSession session = gameSessionRepository.findActiveByRoomId(roomId) + .orElse(null); + // 게임 진행 중인지 확인 - if (!GameStatus.PLAYING.name().equals(room.getGameStatus())) { + if (session == null || !GameStatus.PLAYING.name().equals(session.getStatus())) { return AnswerCheckResult.gameNotPlaying(); } - + // 출제자는 정답 체크 제외 - if (userId.equals(room.getCurrentDrawerId())) { + if (session.isDrawer(userId)) { return AnswerCheckResult.drawerCannotGuess(); } - + // 이미 맞춘 사람인지 확인 - if (room.getCorrectGuessers() != null && room.getCorrectGuessers().contains(userId)) { + if (session.hasAlreadyGuessedCorrect(userId)) { return AnswerCheckResult.alreadyGuessedCorrect(); } - + // 정답 체크 - String currentWord = room.getCurrentWord(); + String currentWord = session.getCurrentWord(); if (!isCorrectAnswer(answer, currentWord)) { return AnswerCheckResult.wrongAnswer(); } - + // 정답 처리 - long elapsedTime = System.currentTimeMillis() - room.getRoundStartTime(); - + long elapsedTime = System.currentTimeMillis() - session.getRoundStartTime(); + // 연속 정답 업데이트 (점수 계산 전에) - if (room.getStreaks() == null) { - room.setStreaks(new HashMap<>()); - } - int currentStreak = room.getStreaks().getOrDefault(userId, 0) + 1; - room.getStreaks().put(userId, currentStreak); - - int score = calculateScore(room, elapsedTime, userId, currentStreak); - + int currentStreak = session.incrementStreak(userId); + + int score = calculateScore(session, elapsedTime, userId, currentStreak); + // 정답자 목록에 추가 - if (room.getCorrectGuessers() == null) { - room.setCorrectGuessers(new ArrayList<>()); - } - room.getCorrectGuessers().add(userId); - + session.addCorrectGuesser(userId); + // 점수 업데이트 - if (room.getScores() == null) { - room.setScores(new HashMap<>()); - } - room.getScores().merge(userId, score, Integer::sum); - + session.addScore(userId, score); + // 출제자 점수도 추가 - room.getScores().merge(room.getCurrentDrawerId(), 5, Integer::sum); - - chatRoomRepository.save(room); - + session.addScore(session.getCurrentDrawerId(), 5); + + gameSessionRepository.save(session); + // 라운드 기록 업데이트 - updateRoundRecord(roomId, room.getCurrentRound(), userId, elapsedTime, score); - + updateRoundRecord(roomId, session.getCurrentRound(), userId, elapsedTime, score); + // 전원 정답 체크 List connections = connectionRepository.findByRoomId(roomId); int nonDrawerCount = (int) connections.stream() - .filter(c -> !c.getUserId().equals(room.getCurrentDrawerId())) + .filter(c -> !c.getUserId().equals(session.getCurrentDrawerId())) .count(); - - boolean allCorrect = room.getCorrectGuessers().size() >= nonDrawerCount; - + + boolean allCorrect = session.getCorrectGuessers().size() >= nonDrawerCount; + logger.info("Answer correct: roomId={}, userId={}, score={}, allCorrect={}", roomId, userId, score, allCorrect); - - return AnswerCheckResult.correctAnswer(score, elapsedTime, allCorrect, room.getScores()); + + return AnswerCheckResult.correctAnswer(score, elapsedTime, allCorrect, session.getScores()); } - + /** * 라운드 스킵 */ public CommandResult skipRound(String roomId, String userId) { ChatRoom room = chatRoomRepository.findById(roomId) .orElseThrow(() -> new IllegalArgumentException("채팅방을 찾을 수 없습니다.")); - - if (!GameStatus.PLAYING.name().equals(room.getGameStatus())) { + + GameSession session = gameSessionRepository.findActiveByRoomId(roomId) + .orElse(null); + + if (session == null || !GameStatus.PLAYING.name().equals(session.getStatus())) { return CommandResult.error("게임이 진행 중이 아닙니다."); } - - if (!userId.equals(room.getCurrentDrawerId())) { + + if (!session.isDrawer(userId)) { return CommandResult.error("출제자만 라운드를 스킵할 수 있습니다."); } - - return endRound(room, "SKIP"); + + return endRound(session, room, "SKIP"); } - + /** * 힌트 제공 */ public CommandResult provideHint(String roomId, String userId) { - ChatRoom room = chatRoomRepository.findById(roomId) - .orElseThrow(() -> new IllegalArgumentException("채팅방을 찾을 수 없습니다.")); - - if (!GameStatus.PLAYING.name().equals(room.getGameStatus())) { + GameSession session = gameSessionRepository.findActiveByRoomId(roomId) + .orElse(null); + + if (session == null || !GameStatus.PLAYING.name().equals(session.getStatus())) { return CommandResult.error("게임이 진행 중이 아닙니다."); } - - if (!userId.equals(room.getCurrentDrawerId())) { + + if (!session.isDrawer(userId)) { return CommandResult.error("출제자만 힌트를 제공할 수 있습니다."); } - - if (Boolean.TRUE.equals(room.getHintUsed())) { + + if (Boolean.TRUE.equals(session.getHintUsed())) { return CommandResult.error("이번 라운드에서 이미 힌트를 사용했습니다."); } - - String currentWord = room.getCurrentWord(); + + String currentWord = session.getCurrentWord(); String hint = currentWord.charAt(0) + "○".repeat(currentWord.length() - 1); - - room.setHintUsed(true); - chatRoomRepository.save(room); - + + session.setHintUsed(true); + gameSessionRepository.save(session); + // 라운드 기록 업데이트 - gameRoundRepository.findByRoomIdAndRound(roomId, room.getCurrentRound()) + gameRoundRepository.findByRoomIdAndRound(roomId, session.getCurrentRound()) .ifPresent(round -> { round.setHintUsed(true); gameRoundRepository.save(round); }); - + return CommandResult.success(MessageType.HINT, "💡 힌트: " + hint); } - + /** - * 라운드 종료 처리 + * 라운드 종료 처리 (GameSession 버전) */ - public CommandResult endRound(ChatRoom room, String reason) { - String roomId = room.getRoomId(); - Integer currentRound = room.getCurrentRound(); - String answer = room.getCurrentWord(); - + public CommandResult endRound(GameSession session, ChatRoom room, String reason) { + String roomId = session.getRoomId(); + Integer currentRound = session.getCurrentRound(); + String answer = session.getCurrentWord(); + // 정답 못 맞춘 사용자 연속 정답 초기화 - resetStreaksForNonGuessers(room); - + resetStreaksForNonGuessers(session); + // 라운드 기록 종료 gameRoundRepository.findByRoomIdAndRound(roomId, currentRound) .ifPresent(round -> { @@ -305,46 +325,48 @@ public CommandResult endRound(ChatRoom room, String reason) { round.setEndReason(reason); gameRoundRepository.save(round); }); - + // 다음 라운드로 진행 - if (currentRound >= room.getTotalRounds()) { - return finishGame(room, "COMPLETED"); + if (currentRound >= session.getTotalRounds()) { + return finishGame(session, room, "COMPLETED"); } - + // 현재 접속 중인 사용자 목록 조회 List connections = connectionRepository.findByRoomId(roomId); Set connectedUserIds = connections.stream() .map(Connection::getUserId) .collect(Collectors.toSet()); - + // 접속자가 2명 미만이면 게임 종료 if (connectedUserIds.size() < 2) { - return finishGame(room, "NOT_ENOUGH_PLAYERS"); + return finishGame(session, room, "NOT_ENOUGH_PLAYERS"); } - + // 다음 라운드 준비 - 접속 중인 사용자 중에서만 출제자 선택 int nextRound = currentRound + 1; - String nextDrawer = selectNextDrawer(room.getDrawerOrder(), connectedUserIds, nextRound); - + String nextDrawer = selectNextDrawer(session.getDrawerOrder(), connectedUserIds, nextRound); + // 다음 단어 추출 String level = room.getLevel() != null ? room.getLevel() : "beginner"; List words = getRandomWords(level, 1); if (words.isEmpty()) { - return finishGame(room, "NO_WORDS"); + return finishGame(session, room, "NO_WORDS"); } Word nextWord = words.get(0); - - // 상태 업데이트 - room.setCurrentRound(nextRound); - room.setCurrentDrawerId(nextDrawer); - room.setCurrentWordId(nextWord.getWordId()); - room.setCurrentWord(nextWord.getKorean()); - room.setRoundStartTime(System.currentTimeMillis()); - room.setHintUsed(false); - room.setCorrectGuessers(new ArrayList<>()); - - chatRoomRepository.save(room); - + + long currentTime = System.currentTimeMillis(); + + // 세션 상태 업데이트 + session.setCurrentRound(nextRound); + session.setCurrentDrawerId(nextDrawer); + session.setCurrentWordId(nextWord.getWordId()); + session.setCurrentWord(nextWord.getKorean()); + session.setRoundStartTime(currentTime); + session.setHintUsed(false); + session.setCorrectGuessers(new ArrayList<>()); + + gameSessionRepository.save(session); + // 다음 라운드 기록 생성 (7일 후 자동 삭제) long nextTtlSeconds = Instant.now().plusSeconds(7 * 24 * 60 * 60).getEpochSecond(); GameRound nextRoundRecord = GameRound.builder() @@ -356,7 +378,7 @@ public CommandResult endRound(ChatRoom room, String reason) { .wordId(nextWord.getWordId()) .word(nextWord.getKorean()) .wordEnglish(nextWord.getEnglish()) - .startTime(System.currentTimeMillis()) + .startTime(currentTime) .hintUsed(false) .correctGuessers(new ArrayList<>()) .guessTimes(new HashMap<>()) @@ -364,17 +386,17 @@ public CommandResult endRound(ChatRoom room, String reason) { .createdAt(Instant.now().toString()) .ttl(nextTtlSeconds) .build(); - + gameRoundRepository.save(nextRoundRecord); - + String message = String.format("라운드 %d 종료! 정답: %s\n\n라운드 %d 시작! 출제자: %s", currentRound, answer, nextRound, nextDrawer); - + logger.info("Round ended: roomId={}, round={}, reason={}", roomId, currentRound, reason); - + // ranking 생성 - List> ranking = buildRankingList(room.getScores()); - + List> ranking = buildRankingList(session.getScores()); + Map data = new HashMap<>(); data.put("answer", answer); data.put("nextRound", nextRound); @@ -382,36 +404,60 @@ public CommandResult endRound(ChatRoom room, String reason) { data.put("nextWord", nextWord); data.put("ranking", ranking); data.put("currentRound", currentRound); - data.put("totalRounds", room.getTotalRounds()); + data.put("totalRounds", session.getTotalRounds()); // 타이머 동기화용 필드 추가 - data.put("roundStartTime", room.getRoundStartTime()); - data.put("roundDuration", room.getRoundTimeLimit() != null ? room.getRoundTimeLimit() : GameConfig.roundTimeLimit()); + data.put("roundStartTime", session.getRoundStartTime()); + data.put("roundDuration", session.getRoundDuration() != null ? session.getRoundDuration() : GameConfig.roundTimeLimit()); return CommandResult.success(MessageType.ROUND_END, message, data); } - + + /** + * roomId로 활성 세션을 찾아 라운드 종료 (외부 호출용) + */ + public CommandResult endRound(String roomId, String reason) { + ChatRoom room = chatRoomRepository.findById(roomId) + .orElseThrow(() -> new IllegalArgumentException("채팅방을 찾을 수 없습니다.")); + + GameSession session = gameSessionRepository.findActiveByRoomId(roomId) + .orElse(null); + + if (session == null) { + return CommandResult.error("진행 중인 게임이 없습니다."); + } + + return endRound(session, room, reason); + } + /** * 게임 완전 종료 */ - private CommandResult finishGame(ChatRoom room, String reason) { - room.setGameStatus(GameStatus.FINISHED.name()); + private CommandResult finishGame(GameSession session, ChatRoom room, String reason) { + long currentTime = System.currentTimeMillis(); + long ttlSeconds = Instant.now().plusSeconds(30 * 24 * 60 * 60).getEpochSecond(); // 30일 보관 + + // 게임 세션 종료 처리 + gameSessionRepository.finishGame(session.getGameSessionId(), currentTime, ttlSeconds); + + // ChatRoom에서 활성 게임 세션 참조 제거 + room.setActiveGameSessionId(null); chatRoomRepository.save(room); - + // 게임 통계 업데이트 및 뱃지 체크 try { - var newBadges = gameStatsService.updateGameStats(room); + var newBadges = gameStatsService.updateGameStats(session); logger.info("Game stats updated: roomId={}, newBadges={}", room.getRoomId(), newBadges.size()); } catch (Exception e) { logger.error("Failed to update game stats: roomId={}, error={}", room.getRoomId(), e.getMessage()); } - + // 최종 점수 정렬 StringBuilder sb = new StringBuilder("🎮 게임 종료!\n\n📊 최종 순위:\n"); - if (room.getScores() != null && !room.getScores().isEmpty()) { - List> sorted = room.getScores().entrySet().stream() + if (session.getScores() != null && !session.getScores().isEmpty()) { + List> sorted = session.getScores().entrySet().stream() .sorted((a, b) -> b.getValue().compareTo(a.getValue())) .toList(); - + int rank = 1; for (Map.Entry entry : sorted) { String medal = switch (rank) { @@ -426,19 +472,20 @@ private CommandResult finishGame(ChatRoom room, String reason) { } else { sb.append(" 점수 없음"); } - - logger.info("Game finished: roomId={}, reason={}", room.getRoomId(), reason); - - return CommandResult.success(MessageType.GAME_END, sb.toString(), room.getScores()); + + logger.info("Game finished: roomId={}, sessionId={}, reason={}", + room.getRoomId(), session.getGameSessionId(), reason); + + return CommandResult.success(MessageType.GAME_END, sb.toString(), session.getScores()); } - + /** * 접속 중인 사용자 중에서 다음 출제자 선택 */ private String selectNextDrawer(List drawerOrder, Set connectedUserIds, int roundNumber) { // 원래 순서에서 시작 인덱스 계산 int startIndex = (roundNumber - 1) % drawerOrder.size(); - + // 접속 중인 사용자를 찾을 때까지 순회 for (int i = 0; i < drawerOrder.size(); i++) { int index = (startIndex + i) % drawerOrder.size(); @@ -447,11 +494,11 @@ private String selectNextDrawer(List drawerOrder, Set connectedU return candidate; } } - + // 원래 순서에 있는 사람이 모두 나갔으면, 접속 중인 아무나 선택 return connectedUserIds.iterator().next(); } - + /** * 랜덤 단어 추출 */ @@ -461,45 +508,39 @@ private List getRandomWords(String level, int count) { Collections.shuffle(words); return words.stream().limit(count).collect(Collectors.toList()); } - + /** * 정답 체크 로직 */ private boolean isCorrectAnswer(String input, String answer) { if (input == null || answer == null) return false; - + String normalizedInput = input.trim().toLowerCase().replace(" ", ""); String normalizedAnswer = answer.trim().toLowerCase().replace(" ", ""); - + return normalizedInput.equals(normalizedAnswer); } - + /** * 점수 계산 - * - * @param room 채팅방 - * @param elapsedTimeMs 경과 시간 (밀리초) - * @param userId 사용자 ID - * @param streak 연속 정답 수 - * @return 계산된 점수 */ - private int calculateScore(ChatRoom room, long elapsedTimeMs, String userId, int streak) { + private int calculateScore(GameSession session, long elapsedTimeMs, String userId, int streak) { int baseScore = 10; - - // 시간 보너스 (빨리 맞출수록 높은 점수): (제한시간 - 경과시간) * 0.5 + + // 시간 보너스 (빨리 맞출수록 높은 점수) int elapsedSeconds = (int) (elapsedTimeMs / 1000); - int timeLimit = room.getRoundTimeLimit() != null ? room.getRoundTimeLimit() : GameConfig.roundTimeLimit(); + int timeLimit = session.getRoundDuration() != null ? session.getRoundDuration() : GameConfig.roundTimeLimit(); int timeBonus = Math.max(0, (int) ((timeLimit - elapsedSeconds) * 0.5)); - - // 연속 정답 보너스: 연속정답수 * 2 + + // 연속 정답 보너스 int streakBonus = streak * 2; - + logger.info("Score calculation: base={}, timeBonus={}, streakBonus={}, total={}", baseScore, timeBonus, streakBonus, baseScore + timeBonus + streakBonus); - + return baseScore + timeBonus + streakBonus; } - + /** * 라운드 기록 업데이트 */ @@ -510,41 +551,41 @@ private void updateRoundRecord(String roomId, Integer roundNumber, String userId round.setCorrectGuessers(new ArrayList<>()); } round.getCorrectGuessers().add(userId); - + if (round.getGuessTimes() == null) { round.setGuessTimes(new HashMap<>()); } round.getGuessTimes().put(userId, elapsedTime); - + if (round.getRoundScores() == null) { round.setRoundScores(new HashMap<>()); } round.getRoundScores().put(userId, score); - + gameRoundRepository.save(round); }); } - + /** * 정답 못 맞춘 사용자 연속 정답 초기화 */ - private void resetStreaksForNonGuessers(ChatRoom room) { - if (room.getStreaks() == null || room.getStreaks().isEmpty()) { + private void resetStreaksForNonGuessers(GameSession session) { + if (session.getStreaks() == null || session.getStreaks().isEmpty()) { return; } - - List correctGuessers = room.getCorrectGuessers() != null - ? room.getCorrectGuessers() + + List correctGuessers = session.getCorrectGuessers() != null + ? session.getCorrectGuessers() : List.of(); - + // 정답 못 맞춘 사용자의 연속 정답 초기화 - room.getStreaks().keySet().stream() + session.getStreaks().keySet().stream() .filter(userId -> !correctGuessers.contains(userId)) - .forEach(userId -> room.getStreaks().put(userId, 0)); - + .forEach(userId -> session.getStreaks().put(userId, 0)); + logger.info("Reset streaks for non-guessers: correctGuessers={}", correctGuessers); } - + /** * 점수 맵을 순위 리스트로 변환 */ @@ -552,11 +593,11 @@ private List> buildRankingList(Map scores) if (scores == null || scores.isEmpty()) { return List.of(); } - + List> sorted = scores.entrySet().stream() .sorted(Map.Entry.comparingByValue().reversed()) .toList(); - + List> ranking = new ArrayList<>(); for (int i = 0; i < sorted.size(); i++) { Map entry = new HashMap<>(); @@ -567,25 +608,25 @@ private List> buildRankingList(Map scores) } return ranking; } - + // ========== Result DTOs ========== - + public record GameStartResult( boolean success, String error, - ChatRoom room, + GameSession session, Word firstWord, List drawerOrder ) { - public static GameStartResult success(ChatRoom room, Word word, List order) { - return new GameStartResult(true, null, room, word, order); + public static GameStartResult success(GameSession session, Word word, List order) { + return new GameStartResult(true, null, session, word, order); } - + public static GameStartResult error(String message) { return new GameStartResult(false, message, null, null, null); } } - + public record AnswerCheckResult( boolean correct, boolean drawer, @@ -599,19 +640,19 @@ public record AnswerCheckResult( public static AnswerCheckResult correctAnswer(int score, long elapsed, boolean allCorrect, Map scores) { return new AnswerCheckResult(true, false, false, false, allCorrect, score, elapsed, scores); } - + public static AnswerCheckResult wrongAnswer() { return new AnswerCheckResult(false, false, false, false, false, 0, 0, null); } - + public static AnswerCheckResult drawerCannotGuess() { return new AnswerCheckResult(false, true, false, false, false, 0, 0, null); } - + public static AnswerCheckResult alreadyGuessedCorrect() { return new AnswerCheckResult(false, false, true, false, false, 0, 0, null); } - + public static AnswerCheckResult gameNotPlaying() { return new AnswerCheckResult(false, false, false, true, false, 0, 0, null); } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameStatsService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameStatsService.java index ae067951..2c3d61e9 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameStatsService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameStatsService.java @@ -3,7 +3,7 @@ import com.mzc.secondproject.serverless.domain.badge.model.UserBadge; import com.mzc.secondproject.serverless.domain.badge.service.BadgeService; import com.mzc.secondproject.serverless.domain.chatting.config.GameConfig; -import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; +import com.mzc.secondproject.serverless.domain.chatting.model.GameSession; import com.mzc.secondproject.serverless.domain.chatting.model.GameRound; import com.mzc.secondproject.serverless.domain.chatting.repository.GameRoundRepository; import com.mzc.secondproject.serverless.domain.stats.model.UserStats; @@ -33,18 +33,18 @@ public GameStatsService() { /** * 게임 종료 시 모든 참가자 통계 업데이트 */ - public Map> updateGameStats(ChatRoom room) { + public Map> updateGameStats(GameSession session) { Map> newBadges = new HashMap<>(); - String roomId = room.getRoomId(); - + String roomId = session.getRoomId(); + // 모든 라운드 조회 List rounds = gameRoundRepository.findByRoomId(roomId); - + // 참가자별 통계 수집 - Map scores = room.getScores() != null ? room.getScores() : Map.of(); + Map scores = session.getScores() != null ? session.getScores() : Map.of(); Set participants = new HashSet<>(scores.keySet()); - if (room.getDrawerOrder() != null) { - participants.addAll(room.getDrawerOrder()); + if (session.getPlayers() != null) { + participants.addAll(session.getPlayers()); } // 1등 찾기 From 2fc718ce1da36de065bd4de0ca8d76a0688849d8 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 17:39:53 +0900 Subject: [PATCH 390/528] =?UTF-8?q?feat:=20GameSessionHandler=20Lambda=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B2=8C=EC=9E=84=20=EC=84=B8=EC=85=98=20API=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /rooms/{roomId}/games - 게임 세션 생성 - GET /games/{gameSessionId} - 게임 상태 조회 (재접속용) - POST /games/{gameSessionId}/start - 게임 시작 - POST /games/{gameSessionId}/stop - 게임 종료 모든 응답에 serverTime 포함 (타이머 동기화) 출제자에게만 currentWord 포함 Closes #432, #433 --- .../chatting/exception/ChattingErrorCode.java | 1 + .../chatting/handler/GameSessionHandler.java | 266 ++++++++++++++++++ 2 files changed, 267 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameSessionHandler.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCode.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCode.java index 9b0e5509..5a091aff 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCode.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCode.java @@ -40,6 +40,7 @@ public enum ChattingErrorCode implements DomainErrorCode { GAME_NOT_IN_PROGRESS("GAME_003", "진행 중인 게임이 없습니다", 400), GAME_ALREADY_IN_PROGRESS("GAME_004", "이미 게임이 진행 중입니다", 409), NOT_GAME_STARTER("GAME_005", "게임 시작자만 중단할 수 있습니다", 403), + GAME_NOT_FOUND("GAME_006", "게임 세션을 찾을 수 없습니다", 404), ; private static final String DOMAIN = "CHATTING"; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameSessionHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameSessionHandler.java new file mode 100644 index 00000000..9267e543 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameSessionHandler.java @@ -0,0 +1,266 @@ +package com.mzc.secondproject.serverless.domain.chatting.handler; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import com.mzc.secondproject.serverless.common.router.HandlerRouter; +import com.mzc.secondproject.serverless.common.router.Route; +import com.mzc.secondproject.serverless.common.util.ResponseGenerator; +import com.mzc.secondproject.serverless.common.util.WebSocketBroadcaster; +import com.mzc.secondproject.serverless.common.util.WebSocketMessageHelper; +import com.mzc.secondproject.serverless.domain.chatting.dto.response.CommandResult; +import com.mzc.secondproject.serverless.domain.chatting.enums.MessageType; +import com.mzc.secondproject.serverless.domain.chatting.exception.ChattingErrorCode; +import com.mzc.secondproject.serverless.domain.chatting.model.Connection; +import com.mzc.secondproject.serverless.domain.chatting.model.GameSession; +import com.mzc.secondproject.serverless.domain.chatting.repository.ConnectionRepository; +import com.mzc.secondproject.serverless.domain.chatting.repository.GameSessionRepository; +import com.mzc.secondproject.serverless.domain.chatting.service.GameService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.util.*; + +/** + * 게임 세션 REST API 핸들러 + * 게임 세션 조회 및 재접속 지원 + */ +public class GameSessionHandler implements RequestHandler { + + private static final Logger logger = LoggerFactory.getLogger(GameSessionHandler.class); + + private final GameService gameService; + private final GameSessionRepository gameSessionRepository; + private final ConnectionRepository connectionRepository; + private final WebSocketBroadcaster broadcaster; + private final HandlerRouter router; + + public GameSessionHandler() { + this.gameService = new GameService(); + this.gameSessionRepository = new GameSessionRepository(); + this.connectionRepository = new ConnectionRepository(); + this.broadcaster = new WebSocketBroadcaster(); + this.router = initRouter(); + } + + private HandlerRouter initRouter() { + return new HandlerRouter().addRoutes( + // 게임 세션 생성 (roomId 기반) + Route.postAuth("/rooms/{roomId}/games", this::createGameSession), + // 게임 세션 조회 (재접속용) + Route.getAuth("/games/{gameSessionId}", this::getGameSession), + // 게임 시작 + Route.postAuth("/games/{gameSessionId}/start", this::startGame), + // 게임 종료 + Route.postAuth("/games/{gameSessionId}/stop", this::stopGame) + ); + } + + @Override + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { + logger.info("GameSession API request: {} {}", request.getHttpMethod(), request.getPath()); + return router.route(request); + } + + /** + * POST /rooms/{roomId}/games - 게임 세션 생성 (게임 시작) + */ + private APIGatewayProxyResponseEvent createGameSession(APIGatewayProxyRequestEvent request, String userId) { + String roomId = request.getPathParameters().get("roomId"); + + GameService.GameStartResult result = gameService.startGame(roomId, userId); + + if (!result.success()) { + return ResponseGenerator.fail(ChattingErrorCode.GAME_START_FAILED, result.error()); + } + + // WebSocket으로 게임 시작 알림 브로드캐스트 + broadcastGameStart(roomId, result); + + // 응답 생성 (serverTime 포함) + Map response = buildGameSessionResponse(result.session(), userId); + + return ResponseGenerator.ok("Game session created", response); + } + + /** + * GET /games/{gameSessionId} - 게임 세션 조회 (재접속용) + */ + private APIGatewayProxyResponseEvent getGameSession(APIGatewayProxyRequestEvent request, String userId) { + String gameSessionId = request.getPathParameters().get("gameSessionId"); + + Optional optSession = gameSessionRepository.findById(gameSessionId); + if (optSession.isEmpty()) { + return ResponseGenerator.fail(ChattingErrorCode.GAME_NOT_FOUND); + } + + GameSession session = optSession.get(); + + // 응답 생성 (serverTime 포함, 출제자에게만 currentWord 포함) + Map response = buildGameSessionResponse(session, userId); + + return ResponseGenerator.ok("Game session retrieved", response); + } + + /** + * POST /games/{gameSessionId}/start - 게임 시작 (세션 ID로) + */ + private APIGatewayProxyResponseEvent startGame(APIGatewayProxyRequestEvent request, String userId) { + String gameSessionId = request.getPathParameters().get("gameSessionId"); + + Optional optSession = gameSessionRepository.findById(gameSessionId); + if (optSession.isEmpty()) { + return ResponseGenerator.fail(ChattingErrorCode.GAME_NOT_FOUND); + } + + GameSession session = optSession.get(); + + // 이미 시작된 게임인지 확인 + if (session.isActive()) { + return ResponseGenerator.fail(ChattingErrorCode.GAME_START_FAILED, "게임이 이미 진행 중입니다."); + } + + // roomId로 게임 시작 위임 + GameService.GameStartResult result = gameService.startGame(session.getRoomId(), userId); + + if (!result.success()) { + return ResponseGenerator.fail(ChattingErrorCode.GAME_START_FAILED, result.error()); + } + + broadcastGameStart(session.getRoomId(), result); + + Map response = buildGameSessionResponse(result.session(), userId); + return ResponseGenerator.ok("Game started", response); + } + + /** + * POST /games/{gameSessionId}/stop - 게임 종료 + */ + private APIGatewayProxyResponseEvent stopGame(APIGatewayProxyRequestEvent request, String userId) { + String gameSessionId = request.getPathParameters().get("gameSessionId"); + + Optional optSession = gameSessionRepository.findById(gameSessionId); + if (optSession.isEmpty()) { + return ResponseGenerator.fail(ChattingErrorCode.GAME_NOT_FOUND); + } + + GameSession session = optSession.get(); + CommandResult result = gameService.stopGame(session.getRoomId(), userId); + + if (!result.success()) { + return ResponseGenerator.fail(ChattingErrorCode.GAME_STOP_FAILED, result.message()); + } + + // WebSocket으로 게임 종료 알림 브로드캐스트 + broadcastSystemMessage(session.getRoomId(), result.message(), MessageType.GAME_END); + + return ResponseGenerator.ok("Game stopped", Map.of( + "message", result.message(), + "serverTime", System.currentTimeMillis() + )); + } + + /** + * 게임 세션 응답 빌드 (serverTime 포함) + */ + private Map buildGameSessionResponse(GameSession session, String userId) { + long serverTime = System.currentTimeMillis(); + + Map response = new LinkedHashMap<>(); + response.put("gameSessionId", session.getGameSessionId()); + response.put("roomId", session.getRoomId()); + response.put("gameType", session.getGameType()); + response.put("status", session.getStatus()); + response.put("currentRound", session.getCurrentRound()); + response.put("totalRounds", session.getTotalRounds()); + response.put("currentDrawerId", session.getCurrentDrawerId()); + response.put("roundStartTime", session.getRoundStartTime()); + response.put("serverTime", serverTime); // 핵심! 타이머 동기화용 + response.put("roundDuration", session.getRoundDuration()); + response.put("scores", session.getScores() != null ? session.getScores() : Map.of()); + response.put("players", session.getPlayers() != null ? session.getPlayers() : List.of()); + response.put("drawerOrder", session.getDrawerOrder()); + response.put("hintUsed", session.getHintUsed()); + + // 출제자에게만 현재 단어 포함 + if (userId != null && userId.equals(session.getCurrentDrawerId())) { + Map currentWord = new HashMap<>(); + currentWord.put("wordId", session.getCurrentWordId()); + currentWord.put("word", session.getCurrentWord()); + response.put("currentWord", currentWord); + } + + return response; + } + + /** + * 게임 시작 브로드캐스트 + */ + private void broadcastGameStart(String roomId, GameService.GameStartResult result) { + String messageId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + long serverTime = System.currentTimeMillis(); + + GameSession session = result.session(); + + String message = String.format(""" + 🎮 게임 시작! + 총 %d 라운드 + + 라운드 1 시작! + 출제자: %s + """, + session.getTotalRounds(), + session.getCurrentDrawerId()); + + Map gameStartMessage = new HashMap<>(); + gameStartMessage.put("domain", WebSocketMessageHelper.DOMAIN_GAME); + gameStartMessage.put("messageId", messageId); + gameStartMessage.put("roomId", roomId); + gameStartMessage.put("userId", "SYSTEM"); + gameStartMessage.put("content", message); + gameStartMessage.put("messageType", MessageType.GAME_START.getCode()); + gameStartMessage.put("createdAt", now); + gameStartMessage.put("timestamp", serverTime); + gameStartMessage.put("gameStatus", session.getStatus()); + gameStartMessage.put("currentRound", session.getCurrentRound()); + gameStartMessage.put("totalRounds", session.getTotalRounds()); + gameStartMessage.put("currentDrawerId", session.getCurrentDrawerId()); + gameStartMessage.put("drawerOrder", result.drawerOrder()); + gameStartMessage.put("roundStartTime", session.getRoundStartTime()); + gameStartMessage.put("serverTime", serverTime); + gameStartMessage.put("roundDuration", session.getRoundDuration()); + + List connections = connectionRepository.findByRoomId(roomId); + String broadcastPayload = ResponseGenerator.gson().toJson(gameStartMessage); + broadcaster.broadcast(connections, broadcastPayload); + + logger.info("Game start broadcasted: roomId={}, sessionId={}", roomId, session.getGameSessionId()); + } + + /** + * 시스템 메시지 브로드캐스트 + */ + private void broadcastSystemMessage(String roomId, String message, MessageType messageType) { + String messageId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + + Map systemMessage = new HashMap<>(); + systemMessage.put("domain", WebSocketMessageHelper.DOMAIN_GAME); + systemMessage.put("messageId", messageId); + systemMessage.put("roomId", roomId); + systemMessage.put("userId", "SYSTEM"); + systemMessage.put("content", message); + systemMessage.put("messageType", messageType.getCode()); + systemMessage.put("createdAt", now); + systemMessage.put("timestamp", System.currentTimeMillis()); + + List connections = connectionRepository.findByRoomId(roomId); + String broadcastPayload = ResponseGenerator.gson().toJson(systemMessage); + broadcaster.broadcast(connections, broadcastPayload); + + logger.info("System message broadcasted: roomId={}, type={}", roomId, messageType); + } +} From ed29c556c998622feda89fe149206d2228b00d56 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 17:51:10 +0900 Subject: [PATCH 391/528] =?UTF-8?q?feat:=20=EA=B2=8C=EC=9E=84=20=EC=8B=9C?= =?UTF-8?q?=EC=9E=91=207=EB=B6=84=20=ED=9B=84=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EC=A2=85=EB=A3=8C=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GameConfig에 gameTimeLimit() 메서드 추가 (기본값: 420초) - GameSchedulerClient 유틸리티 클래스 생성 (EventBridge Scheduler 연동) - GameService에 스케줄 생성/취소 로직 추가 - GameAutoCloseHandler Lambda 함수 생성 - template.yaml에 Lambda, IAM Role, Schedule Group 추가 Closes #417 --- ServerlessFunction/build.gradle | 1 + .../domain/chatting/config/GameConfig.java | 10 ++ .../handler/GameAutoCloseHandler.java | 96 ++++++++++++ .../chatting/service/GameSchedulerClient.java | 139 ++++++++++++++++++ .../domain/chatting/service/GameService.java | 48 +++++- ServerlessFunction/template.yaml | 84 +++++++++++ 6 files changed, 376 insertions(+), 2 deletions(-) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameAutoCloseHandler.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameSchedulerClient.java diff --git a/ServerlessFunction/build.gradle b/ServerlessFunction/build.gradle index b6c28326..cc5e6a12 100644 --- a/ServerlessFunction/build.gradle +++ b/ServerlessFunction/build.gradle @@ -34,6 +34,7 @@ dependencies { implementation 'software.amazon.awssdk:apigatewaymanagementapi' implementation 'software.amazon.awssdk:url-connection-client' implementation 'software.amazon.awssdk:ssm' + implementation 'software.amazon.awssdk:scheduler' // AWS X-Ray SDK (다운스트림 서비스 추적용) implementation 'com.amazonaws:aws-xray-recorder-sdk-core:2.15.0' diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/config/GameConfig.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/config/GameConfig.java index 998e4f0b..443d45d1 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/config/GameConfig.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/config/GameConfig.java @@ -11,10 +11,12 @@ public final class GameConfig { private static final int DEFAULT_TOTAL_ROUNDS = 5; private static final int DEFAULT_ROUND_TIME_LIMIT = 60; private static final long DEFAULT_QUICK_GUESS_THRESHOLD_MS = 5000L; + private static final int DEFAULT_GAME_TIME_LIMIT = 420; // 7분 (420초) private static final int TOTAL_ROUNDS = EnvConfig.getIntOrDefault("GAME_TOTAL_ROUNDS", DEFAULT_TOTAL_ROUNDS); private static final int ROUND_TIME_LIMIT = EnvConfig.getIntOrDefault("GAME_ROUND_TIME_LIMIT", DEFAULT_ROUND_TIME_LIMIT); private static final long QUICK_GUESS_THRESHOLD_MS = EnvConfig.getLongOrDefault("GAME_QUICK_GUESS_THRESHOLD_MS", DEFAULT_QUICK_GUESS_THRESHOLD_MS); + private static final int GAME_TIME_LIMIT = EnvConfig.getIntOrDefault("GAME_TIME_LIMIT_SECONDS", DEFAULT_GAME_TIME_LIMIT); private GameConfig() { } @@ -30,4 +32,12 @@ public static int roundTimeLimit() { public static long quickGuessThresholdMs() { return QUICK_GUESS_THRESHOLD_MS; } + + /** + * 게임 전체 시간 제한 (초) + * 기본값: 420초 (7분) + */ + public static int gameTimeLimit() { + return GAME_TIME_LIMIT; + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameAutoCloseHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameAutoCloseHandler.java new file mode 100644 index 00000000..e1030b59 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameAutoCloseHandler.java @@ -0,0 +1,96 @@ +package com.mzc.secondproject.serverless.domain.chatting.handler; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.mzc.secondproject.serverless.common.util.ResponseGenerator; +import com.mzc.secondproject.serverless.common.util.WebSocketBroadcaster; +import com.mzc.secondproject.serverless.common.util.WebSocketMessageHelper; +import com.mzc.secondproject.serverless.domain.chatting.dto.response.CommandResult; +import com.mzc.secondproject.serverless.domain.chatting.enums.MessageType; +import com.mzc.secondproject.serverless.domain.chatting.model.Connection; +import com.mzc.secondproject.serverless.domain.chatting.repository.ConnectionRepository; +import com.mzc.secondproject.serverless.domain.chatting.service.GameService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * 게임 자동 종료 Lambda 핸들러 + * EventBridge Scheduler에 의해 게임 시작 7분 후 호출됨 + */ +public class GameAutoCloseHandler implements RequestHandler, String> { + + private static final Logger logger = LoggerFactory.getLogger(GameAutoCloseHandler.class); + + private final GameService gameService; + private final ConnectionRepository connectionRepository; + private final WebSocketBroadcaster broadcaster; + + public GameAutoCloseHandler() { + this.gameService = new GameService(); + this.connectionRepository = new ConnectionRepository(); + this.broadcaster = new WebSocketBroadcaster(); + } + + @Override + public String handleRequest(Map event, Context context) { + String gameSessionId = event.get("gameSessionId"); + String roomId = event.get("roomId"); + + logger.info("Game auto-close triggered: gameSessionId={}, roomId={}", gameSessionId, roomId); + + if (gameSessionId == null || roomId == null) { + logger.error("Missing required parameters: gameSessionId={}, roomId={}", gameSessionId, roomId); + return "FAILED: Missing parameters"; + } + + try { + // 게임 종료 처리 + CommandResult result = gameService.finishGameByTimeout(gameSessionId); + + if (result.success()) { + // WebSocket으로 게임 종료 알림 브로드캐스트 + broadcastGameEnd(roomId, result.message()); + logger.info("Game auto-closed successfully: gameSessionId={}", gameSessionId); + return "SUCCESS: Game auto-closed"; + } else { + logger.info("Game auto-close skipped: gameSessionId={}, reason={}", gameSessionId, result.message()); + return "SKIPPED: " + result.message(); + } + + } catch (Exception e) { + logger.error("Game auto-close failed: gameSessionId={}, error={}", gameSessionId, e.getMessage(), e); + return "FAILED: " + e.getMessage(); + } + } + + /** + * 게임 종료 메시지 브로드캐스트 + */ + private void broadcastGameEnd(String roomId, String message) { + String messageId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + + Map gameEndMessage = new HashMap<>(); + gameEndMessage.put("domain", WebSocketMessageHelper.DOMAIN_GAME); + gameEndMessage.put("messageId", messageId); + gameEndMessage.put("roomId", roomId); + gameEndMessage.put("userId", "SYSTEM"); + gameEndMessage.put("content", "⏰ 시간 초과! " + message); + gameEndMessage.put("messageType", MessageType.GAME_END.getCode()); + gameEndMessage.put("createdAt", now); + gameEndMessage.put("timestamp", System.currentTimeMillis()); + gameEndMessage.put("reason", "TIME_EXPIRED"); + + List connections = connectionRepository.findByRoomId(roomId); + String broadcastPayload = ResponseGenerator.gson().toJson(gameEndMessage); + broadcaster.broadcast(connections, broadcastPayload); + + logger.info("Game end broadcasted: roomId={}, connections={}", roomId, connections.size()); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameSchedulerClient.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameSchedulerClient.java new file mode 100644 index 00000000..1ececf65 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameSchedulerClient.java @@ -0,0 +1,139 @@ +package com.mzc.secondproject.serverless.domain.chatting.service; + +import com.mzc.secondproject.serverless.common.config.EnvConfig; +import com.mzc.secondproject.serverless.domain.chatting.config.GameConfig; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.services.scheduler.SchedulerClient; +import software.amazon.awssdk.services.scheduler.model.*; + +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; + +/** + * EventBridge Scheduler를 사용한 게임 자동 종료 스케줄링 + */ +public class GameSchedulerClient { + + private static final Logger logger = LoggerFactory.getLogger(GameSchedulerClient.class); + + private static final String SCHEDULE_GROUP = "game-auto-close"; + private static final String SCHEDULE_NAME_PREFIX = "game-close-"; + + private final SchedulerClient schedulerClient; + private final String targetLambdaArn; + private final String roleArn; + + public GameSchedulerClient() { + this.schedulerClient = SchedulerClient.create(); + this.targetLambdaArn = EnvConfig.getOrDefault("GAME_AUTO_CLOSE_LAMBDA_ARN", null); + this.roleArn = EnvConfig.getOrDefault("SCHEDULER_ROLE_ARN", null); + } + + /** + * 게임 자동 종료 스케줄 생성 + * + * @param gameSessionId 게임 세션 ID + * @param roomId 방 ID + * @return 스케줄 ARN (실패 시 null) + */ + public ScheduleResult createGameEndSchedule(String gameSessionId, String roomId) { + if (targetLambdaArn == null || roleArn == null) { + logger.warn("Scheduler not configured: GAME_AUTO_CLOSE_LAMBDA_ARN or SCHEDULER_ROLE_ARN not set"); + return new ScheduleResult(null, 0L); + } + + try { + // 7분 후 시간 계산 + long scheduledAtMs = System.currentTimeMillis() + (GameConfig.gameTimeLimit() * 1000L); + Instant scheduledAt = Instant.ofEpochMilli(scheduledAtMs); + + // at() 표현식: at(yyyy-mm-ddThh:mm:ss) + String atExpression = "at(" + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss") + .withZone(ZoneOffset.UTC) + .format(scheduledAt) + ")"; + + String scheduleName = SCHEDULE_NAME_PREFIX + gameSessionId; + + // Lambda 호출 시 전달할 페이로드 + String payload = String.format("{\"gameSessionId\":\"%s\",\"roomId\":\"%s\"}", gameSessionId, roomId); + + CreateScheduleRequest request = CreateScheduleRequest.builder() + .name(scheduleName) + .groupName(SCHEDULE_GROUP) + .scheduleExpression(atExpression) + .scheduleExpressionTimezone("UTC") + .flexibleTimeWindow(FlexibleTimeWindow.builder() + .mode(FlexibleTimeWindowMode.OFF) + .build()) + .target(Target.builder() + .arn(targetLambdaArn) + .roleArn(roleArn) + .input(payload) + .build()) + .actionAfterCompletion(ActionAfterCompletion.DELETE) // 실행 후 자동 삭제 + .build(); + + CreateScheduleResponse response = schedulerClient.createSchedule(request); + + logger.info("Game end schedule created: gameSessionId={}, scheduledAt={}, arn={}", + gameSessionId, scheduledAt, response.scheduleArn()); + + return new ScheduleResult(response.scheduleArn(), scheduledAtMs); + + } catch (ConflictException e) { + logger.warn("Schedule already exists: gameSessionId={}", gameSessionId); + return new ScheduleResult(null, 0L); + + } catch (Exception e) { + logger.error("Failed to create game end schedule: gameSessionId={}, error={}", + gameSessionId, e.getMessage()); + return new ScheduleResult(null, 0L); + } + } + + /** + * 게임 자동 종료 스케줄 취소 + * + * @param gameSessionId 게임 세션 ID + * @return 취소 성공 여부 + */ + public boolean cancelGameEndSchedule(String gameSessionId) { + if (targetLambdaArn == null) { + return true; // 스케줄러 미설정 시 무시 + } + + try { + String scheduleName = SCHEDULE_NAME_PREFIX + gameSessionId; + + DeleteScheduleRequest request = DeleteScheduleRequest.builder() + .name(scheduleName) + .groupName(SCHEDULE_GROUP) + .build(); + + schedulerClient.deleteSchedule(request); + + logger.info("Game end schedule cancelled: gameSessionId={}", gameSessionId); + return true; + + } catch (ResourceNotFoundException e) { + logger.debug("Schedule not found (may have already executed): gameSessionId={}", gameSessionId); + return true; // 이미 삭제되었거나 없는 경우 + + } catch (Exception e) { + logger.error("Failed to cancel game end schedule: gameSessionId={}, error={}", + gameSessionId, e.getMessage()); + return false; + } + } + + /** + * 스케줄 생성 결과 + */ + public record ScheduleResult(String scheduleArn, long scheduledAtMs) { + public boolean success() { + return scheduleArn != null; + } + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java index ee8d92b9..031892e3 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java @@ -36,6 +36,7 @@ public class GameService { private final GameSessionRepository gameSessionRepository; private final WordRepository wordRepository; private final GameStatsService gameStatsService; + private final GameSchedulerClient gameSchedulerClient; /** * 기본 생성자 (Lambda에서 사용) @@ -43,7 +44,7 @@ public class GameService { public GameService() { this(new ChatRoomRepository(), new ConnectionRepository(), new GameRoundRepository(), new GameSessionRepository(), - new WordRepository(), new GameStatsService()); + new WordRepository(), new GameStatsService(), new GameSchedulerClient()); } /** @@ -51,13 +52,15 @@ public GameService() { */ public GameService(ChatRoomRepository chatRoomRepository, ConnectionRepository connectionRepository, GameRoundRepository gameRoundRepository, GameSessionRepository gameSessionRepository, - WordRepository wordRepository, GameStatsService gameStatsService) { + WordRepository wordRepository, GameStatsService gameStatsService, + GameSchedulerClient gameSchedulerClient) { this.chatRoomRepository = chatRoomRepository; this.connectionRepository = connectionRepository; this.gameRoundRepository = gameRoundRepository; this.gameSessionRepository = gameSessionRepository; this.wordRepository = wordRepository; this.gameStatsService = gameStatsService; + this.gameSchedulerClient = gameSchedulerClient; } /** @@ -129,6 +132,14 @@ public GameStartResult startGame(String roomId, String userId) { gameSessionRepository.save(session); + // 게임 자동 종료 스케줄 생성 (7분 후) + GameSchedulerClient.ScheduleResult scheduleResult = gameSchedulerClient.createGameEndSchedule(gameSessionId, roomId); + if (scheduleResult.success()) { + session.setScheduleRuleArn(scheduleResult.scheduleArn()); + session.setGameEndScheduledAt(scheduleResult.scheduledAtMs()); + gameSessionRepository.save(session); + } + // ChatRoom에 활성 게임 세션 ID 연결 room.setActiveGameSessionId(gameSessionId); chatRoomRepository.save(room); @@ -436,6 +447,11 @@ private CommandResult finishGame(GameSession session, ChatRoom room, String reas long currentTime = System.currentTimeMillis(); long ttlSeconds = Instant.now().plusSeconds(30 * 24 * 60 * 60).getEpochSecond(); // 30일 보관 + // 자동 종료 스케줄 취소 (TIME_EXPIRED가 아닌 경우에만) + if (!"TIME_EXPIRED".equals(reason)) { + gameSchedulerClient.cancelGameEndSchedule(session.getGameSessionId()); + } + // 게임 세션 종료 처리 gameSessionRepository.finishGame(session.getGameSessionId(), currentTime, ttlSeconds); @@ -479,6 +495,34 @@ private CommandResult finishGame(GameSession session, ChatRoom room, String reas return CommandResult.success(MessageType.GAME_END, sb.toString(), session.getScores()); } + /** + * 시간 만료로 인한 게임 자동 종료 (GameAutoCloseHandler에서 호출) + */ + public CommandResult finishGameByTimeout(String gameSessionId) { + GameSession session = gameSessionRepository.findById(gameSessionId).orElse(null); + if (session == null) { + logger.warn("Game session not found for auto-close: {}", gameSessionId); + return CommandResult.error("게임 세션을 찾을 수 없습니다."); + } + + // 이미 종료된 게임이면 무시 + if (!session.isActive()) { + logger.info("Game already finished, skipping auto-close: {}", gameSessionId); + return CommandResult.error("이미 종료된 게임입니다."); + } + + ChatRoom room = chatRoomRepository.findById(session.getRoomId()).orElse(null); + if (room == null) { + logger.warn("Room not found for auto-close: {}", session.getRoomId()); + return CommandResult.error("채팅방을 찾을 수 없습니다."); + } + + logger.info("Auto-closing game due to time expiration: sessionId={}, roomId={}", + gameSessionId, session.getRoomId()); + + return finishGame(session, room, "TIME_EXPIRED"); + } + /** * 접속 중인 사용자 중에서 다음 출제자 선택 */ diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 35cde30e..0202714b 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -277,14 +277,31 @@ Resources: Environment: Variables: WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" + GAME_AUTO_CLOSE_LAMBDA_ARN: !GetAtt GameAutoCloseFunction.Arn + SCHEDULER_ROLE_ARN: !GetAtt GameSchedulerRole.Arn Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable + - DynamoDBCrudPolicy: + TableName: !Ref VocabTable - Statement: - Effect: Allow Action: - execute-api:ManageConnections Resource: !Sub arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${WebSocketApi}/* + # EventBridge Scheduler 권한 + - Statement: + - Effect: Allow + Action: + - scheduler:CreateSchedule + - scheduler:DeleteSchedule + - scheduler:GetSchedule + Resource: !Sub "arn:aws:scheduler:${AWS::Region}:${AWS::AccountId}:schedule/game-auto-close/*" + - Statement: + - Effect: Allow + Action: + - iam:PassRole + Resource: !GetAtt GameSchedulerRole.Arn WebSocketMessagePermission: Type: AWS::Lambda::Permission @@ -410,6 +427,8 @@ Resources: Environment: Variables: WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" + GAME_AUTO_CLOSE_LAMBDA_ARN: !GetAtt GameAutoCloseFunction.Arn + SCHEDULER_ROLE_ARN: !GetAtt GameSchedulerRole.Arn Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -418,6 +437,19 @@ Resources: Action: - execute-api:ManageConnections Resource: !Sub "arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${WebSocketApi}/*" + # EventBridge Scheduler 권한 + - Statement: + - Effect: Allow + Action: + - scheduler:CreateSchedule + - scheduler:DeleteSchedule + - scheduler:GetSchedule + Resource: !Sub "arn:aws:scheduler:${AWS::Region}:${AWS::AccountId}:schedule/game-auto-close/*" + - Statement: + - Effect: Allow + Action: + - iam:PassRole + Resource: !GetAtt GameSchedulerRole.Arn Events: StartGame: Type: Api @@ -452,6 +484,58 @@ Resources: Auth: Authorizer: CognitoAuthorizer + # 게임 자동 종료 Lambda (EventBridge Scheduler에 의해 호출) + GameAutoCloseFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: group2-englishstudy-game-auto-close + CodeUri: . + Handler: com.mzc.secondproject.serverless.domain.chatting.handler.GameAutoCloseHandler::handleRequest + Description: Auto-close game after 7 minutes + Timeout: 30 + MemorySize: 512 + SnapStart: + ApplyOn: PublishedVersions + Environment: + Variables: + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref ChatTable + - Statement: + - Effect: Allow + Action: + - execute-api:ManageConnections + Resource: !Sub "arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${WebSocketApi}/*" + + # EventBridge Scheduler가 Lambda를 호출할 수 있는 IAM Role + GameSchedulerRole: + Type: AWS::IAM::Role + Properties: + RoleName: !Sub "${AWS::StackName}-game-scheduler-role" + AssumeRolePolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Principal: + Service: scheduler.amazonaws.com + Action: sts:AssumeRole + Policies: + - PolicyName: InvokeGameAutoCloseLambda + PolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Action: + - lambda:InvokeFunction + Resource: !GetAtt GameAutoCloseFunction.Arn + + # EventBridge Schedule Group + GameScheduleGroup: + Type: AWS::Scheduler::ScheduleGroup + Properties: + Name: game-auto-close + ChatMessageFunction: Type: AWS::Serverless::Function Properties: From 4f49838e776b89904c41e436db3ec05c721d7dd0 Mon Sep 17 00:00:00 2001 From: hyein Heo <128613248+hye-inA@users.noreply.github.com> Date: Tue, 20 Jan 2026 18:01:04 +0900 Subject: [PATCH 392/528] =?UTF-8?q?fix=20:=20=EB=A9=94=EB=AA=A8=EB=A6=AC?= =?UTF-8?q?=20=EC=A6=9D=EA=B0=80=20=EB=B0=8F=20Lambda=20=EC=9D=91=EB=8B=B5?= =?UTF-8?q?=20=EC=A0=9C=ED=95=9C=20=EC=8B=9C=EA=B0=84=20Cognito=20?= =?UTF-8?q?=ED=8A=B8=EB=A6=AC=EA=B1=B0=20=EC=A0=9C=ED=95=9C=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=EA=B3=BC=20=EB=8F=99=EC=9D=BC=ED=95=98=EA=B2=8C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20(#439)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ServerlessFunction/template.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 0202714b..b6b087e8 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -110,6 +110,8 @@ Resources: CodeUri: . Handler: com.mzc.secondproject.serverless.domain.user.handler.PostConfirmationHandler::handleRequest Description: Handle user registration - save to DynamoDB + MemorySize: 1024 + Timeout: 5 SnapStart: ApplyOn: PublishedVersions Policies: From 05e86273c3545b3befbe443430ad4029cecaaf15 Mon Sep 17 00:00:00 2001 From: DDING JOO Date: Tue, 20 Jan 2026 21:26:38 +0900 Subject: [PATCH 393/528] =?UTF-8?q?feat:=20=EC=BA=90=EC=B9=98=EB=A7=88?= =?UTF-8?q?=EC=9D=B8=EB=93=9C=20=EA=B2=8C=EC=9E=84=20=EB=B0=A9=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20(#455)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Room 타입 분리 (CHAT/GAME) - RoomType, RoomStatus enum 추가 - ChatRoom 모델에 type, gameType, gameSettings, status, hostId 필드 추가 - GameSettings 모델 추가 - 방 생성/조회 API 수정 - CreateRoomRequest에 type, gameType, gameSettings 필드 추가 - 방 목록 조회 시 type, gameType, status 필터 지원 - 게임 시작 조건 검증 - GAME 타입 방에서만 게임 시작 가능 (GAME_007 에러) - 게임 재시작 API 구현 (#452) - POST /rooms/{roomId}/game/restart 엔드포인트 추가 - 방장만 재시작 가능 (GAME_009 에러) - 게임 진행 중 재시작 불가 (GAME_008 에러) - 방장 변경 로직 구현 (#453) - 방장 퇴장 시 다음 멤버에게 자동 이전 - 모든 멤버 퇴장 시 방 자동 삭제 - WebSocket 메시지 타입 추가 (#454) - ROOM_STATUS_CHANGE, HOST_CHANGE 메시지 타입 추가 - WebSocketMessageHelper에 빌더 메서드 추가 - 테스트 추가 - RoomType, RoomStatus enum 테스트 - GameSettings 모델 테스트 - ChattingErrorCode 테스트 업데이트 Related: #440, #441, #442, #443, #444, #445, #446 Closes: #447, #448, #449, #450, #451, #452, #453, #454 --- .../common/util/WebSocketMessageHelper.java | 57 +++++++++++++++++++ .../dto/request/CreateRoomRequest.java | 10 +++- .../domain/chatting/enums/MessageType.java | 6 +- .../domain/chatting/enums/RoomStatus.java | 39 +++++++++++++ .../domain/chatting/enums/RoomType.java | 38 +++++++++++++ .../chatting/exception/ChattingErrorCode.java | 3 + .../chatting/handler/ChatRoomHandler.java | 28 +++++---- .../domain/chatting/handler/GameHandler.java | 20 +++++++ .../domain/chatting/model/ChatRoom.java | 8 ++- .../domain/chatting/model/GameSettings.java | 21 +++++++ .../service/ChatRoomCommandService.java | 50 +++++++++++----- .../service/ChatRoomQueryService.java | 22 ++++++- .../domain/chatting/service/GameService.java | 39 +++++++++++++ .../exception/ChattingErrorCodeSpec.groovy | 12 +++- .../domain/chatting/enums/RoomStatusTest.java | 44 ++++++++++++++ .../domain/chatting/enums/RoomTypeTest.java | 38 +++++++++++++ .../chatting/model/GameSettingsTest.java | 54 ++++++++++++++++++ 17 files changed, 455 insertions(+), 34 deletions(-) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomStatus.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomType.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSettings.java create mode 100644 ServerlessFunction/src/test/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomStatusTest.java create mode 100644 ServerlessFunction/src/test/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomTypeTest.java create mode 100644 ServerlessFunction/src/test/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSettingsTest.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketMessageHelper.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketMessageHelper.java index 56a7f2b4..7cbf5602 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketMessageHelper.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketMessageHelper.java @@ -13,6 +13,7 @@ public final class WebSocketMessageHelper { public static final String DOMAIN_CHAT = "chat"; public static final String DOMAIN_GAME = "game"; + public static final String DOMAIN_ROOM = "room"; private WebSocketMessageHelper() { } @@ -106,4 +107,60 @@ public static Map buildGameMessage( public static Map buildSystemMessage(String roomId, String content, String messageType) { return buildChatMessage(roomId, "SYSTEM", content, messageType); } + + /** + * 방 상태 변경 메시지 생성 + * + * @param roomId 방 ID + * @param status 현재 상태 + * @param previousStatus 이전 상태 + * @return 방 상태 변경 메시지 + */ + public static Map buildRoomStatusChangeMessage( + String roomId, + String status, + String previousStatus + ) { + String messageId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + + Map message = new HashMap<>(); + message.put("domain", DOMAIN_ROOM); + message.put("messageType", "room_status_change"); + message.put("messageId", messageId); + message.put("roomId", roomId); + message.put("status", status); + message.put("previousStatus", previousStatus); + message.put("createdAt", now); + message.put("timestamp", System.currentTimeMillis()); + return message; + } + + /** + * 방장 변경 메시지 생성 + * + * @param roomId 방 ID + * @param newHostId 새 방장 ID + * @param newHostNickname 새 방장 닉네임 + * @return 방장 변경 메시지 + */ + public static Map buildHostChangeMessage( + String roomId, + String newHostId, + String newHostNickname + ) { + String messageId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + + Map message = new HashMap<>(); + message.put("domain", DOMAIN_ROOM); + message.put("messageType", "host_change"); + message.put("messageId", messageId); + message.put("roomId", roomId); + message.put("newHostId", newHostId); + message.put("newHostNickname", newHostNickname); + message.put("createdAt", now); + message.put("timestamp", System.currentTimeMillis()); + return message; + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/CreateRoomRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/CreateRoomRequest.java index 255d6fc1..58316901 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/CreateRoomRequest.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/CreateRoomRequest.java @@ -1,5 +1,6 @@ package com.mzc.secondproject.serverless.domain.chatting.dto.request; +import com.mzc.secondproject.serverless.domain.chatting.model.GameSettings; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; @@ -32,6 +33,13 @@ public class CreateRoomRequest { @Builder.Default private Boolean isPrivate = false; - + private String password; + + @Builder.Default + private String type = "CHAT"; // CHAT or GAME + + private String gameType; // CATCHMIND (nullable) + + private GameSettings gameSettings; // 게임 설정 (nullable) } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/MessageType.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/MessageType.java index 28627177..a7433637 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/MessageType.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/MessageType.java @@ -18,7 +18,11 @@ public enum MessageType { CORRECT_ANSWER("correct_answer", "정답"), SCORE_UPDATE("score_update", "점수 업데이트"), SYSTEM_COMMAND("system_command", "시스템 명령"), - HINT("hint", "힌트"); + HINT("hint", "힌트"), + + // 방 관련 메시지 타입 + ROOM_STATUS_CHANGE("room_status_change", "방 상태 변경"), + HOST_CHANGE("host_change", "방장 변경"); private final String code; private final String displayName; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomStatus.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomStatus.java new file mode 100644 index 00000000..6cfbd65b --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomStatus.java @@ -0,0 +1,39 @@ +package com.mzc.secondproject.serverless.domain.chatting.enums; + +import java.util.Arrays; + +public enum RoomStatus { + WAITING("waiting", "대기 중"), + PLAYING("playing", "게임 중"), + FINISHED("finished", "종료됨"); + + private final String code; + private final String displayName; + + RoomStatus(String code, String displayName) { + this.code = code; + this.displayName = displayName; + } + + public static boolean isValid(String value) { + if (value == null) return false; + return Arrays.stream(values()) + .anyMatch(status -> status.name().equalsIgnoreCase(value) || status.code.equalsIgnoreCase(value)); + } + + public static RoomStatus fromString(String value) { + if (value == null) return WAITING; + return Arrays.stream(values()) + .filter(status -> status.name().equalsIgnoreCase(value) || status.code.equalsIgnoreCase(value)) + .findFirst() + .orElse(WAITING); + } + + public String getCode() { + return code; + } + + public String getDisplayName() { + return displayName; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomType.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomType.java new file mode 100644 index 00000000..8848b449 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomType.java @@ -0,0 +1,38 @@ +package com.mzc.secondproject.serverless.domain.chatting.enums; + +import java.util.Arrays; + +public enum RoomType { + CHAT("chat", "채팅방"), + GAME("game", "게임방"); + + private final String code; + private final String displayName; + + RoomType(String code, String displayName) { + this.code = code; + this.displayName = displayName; + } + + public static boolean isValid(String value) { + if (value == null) return false; + return Arrays.stream(values()) + .anyMatch(type -> type.name().equalsIgnoreCase(value) || type.code.equalsIgnoreCase(value)); + } + + public static RoomType fromString(String value) { + if (value == null) return CHAT; + return Arrays.stream(values()) + .filter(type -> type.name().equalsIgnoreCase(value) || type.code.equalsIgnoreCase(value)) + .findFirst() + .orElse(CHAT); + } + + public String getCode() { + return code; + } + + public String getDisplayName() { + return displayName; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCode.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCode.java index 5a091aff..ad599b53 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCode.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCode.java @@ -41,6 +41,9 @@ public enum ChattingErrorCode implements DomainErrorCode { GAME_ALREADY_IN_PROGRESS("GAME_004", "이미 게임이 진행 중입니다", 409), NOT_GAME_STARTER("GAME_005", "게임 시작자만 중단할 수 있습니다", 403), GAME_NOT_FOUND("GAME_006", "게임 세션을 찾을 수 없습니다", 404), + GAME_NOT_ALLOWED_IN_CHAT_ROOM("GAME_007", "게임은 게임 방에서만 시작할 수 있습니다", 400), + GAME_RESTART_NOT_ALLOWED("GAME_008", "게임 진행 중에는 재시작할 수 없습니다", 400), + GAME_START_NOT_HOST("GAME_009", "방장만 게임을 시작할 수 있습니다", 403), ; private static final String DOMAIN = "CHATTING"; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java index 0edcf0a9..13f6447c 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java @@ -57,46 +57,50 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent re private APIGatewayProxyResponseEvent createRoom(APIGatewayProxyRequestEvent request, String userId) { CreateRoomRequest req = ResponseGenerator.gson().fromJson(request.getBody(), CreateRoomRequest.class); - + return BeanValidator.validateAndExecute(req, dto -> { String level = dto.getLevel() != null ? dto.getLevel() : "beginner"; Integer maxMembers = dto.getMaxMembers() != null ? dto.getMaxMembers() : 6; Boolean isPrivate = dto.getIsPrivate() != null ? dto.getIsPrivate() : false; - + ChatRoom room = commandService.createRoom( - dto.getName(), dto.getDescription(), level, maxMembers, isPrivate, dto.getPassword(), userId); + dto.getName(), dto.getDescription(), level, maxMembers, isPrivate, dto.getPassword(), userId, + dto.getType(), dto.getGameType(), dto.getGameSettings()); room.setPassword(null); - + return ResponseGenerator.created("Room created", room); }); } private APIGatewayProxyResponseEvent getRooms(APIGatewayProxyRequestEvent request, String userId) { Map queryParams = request.getQueryStringParameters(); - + String level = queryParams != null ? queryParams.get("level") : null; String joined = queryParams != null ? queryParams.get("joined") : null; String cursor = queryParams != null ? queryParams.get("cursor") : null; - + String type = queryParams != null ? queryParams.get("type") : null; + String gameType = queryParams != null ? queryParams.get("gameType") : null; + String status = queryParams != null ? queryParams.get("status") : null; + int limit = 10; if (queryParams != null && queryParams.get("limit") != null) { limit = Math.min(Integer.parseInt(queryParams.get("limit")), 20); } - - PaginatedResult roomPage = queryService.getRooms(level, limit, cursor); + + PaginatedResult roomPage = queryService.getRooms(level, limit, cursor, type, gameType, status); List rooms = roomPage.items(); - + if ("true".equals(joined)) { rooms = queryService.filterByJoinedUser(rooms, userId); } - + rooms.forEach(room -> room.setPassword(null)); - + Map result = new HashMap<>(); result.put("rooms", rooms); result.put("nextCursor", roomPage.nextCursor()); result.put("hasMore", roomPage.hasMore()); - + return ResponseGenerator.ok("Rooms retrieved", result); } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameHandler.java index b95a369e..f72bd0dc 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameHandler.java @@ -50,6 +50,7 @@ private HandlerRouter initRouter() { return new HandlerRouter().addRoutes( Route.postAuth("/rooms/{roomId}/game/start", this::startGame), Route.postAuth("/rooms/{roomId}/game/stop", this::stopGame), + Route.postAuth("/rooms/{roomId}/game/restart", this::restartGame), Route.getAuth("/rooms/{roomId}/game/status", this::getGameStatus), Route.getAuth("/rooms/{roomId}/game/scores", this::getScores) ); @@ -98,6 +99,25 @@ private APIGatewayProxyResponseEvent stopGame(APIGatewayProxyRequestEvent reques return ResponseGenerator.ok("Game stopped", Map.of("message", result.message())); } + /** + * POST /rooms/{roomId}/game/restart - 게임 재시작 + */ + private APIGatewayProxyResponseEvent restartGame(APIGatewayProxyRequestEvent request, String userId) { + String roomId = request.getPathParameters().get("roomId"); + + GameService.GameStartResult result = gameService.restartGame(roomId, userId); + + if (!result.success()) { + return ResponseGenerator.fail(ChattingErrorCode.GAME_START_FAILED, result.error()); + } + + // WebSocket으로 게임 시작 알림 브로드캐스트 + broadcastGameStart(roomId, result); + + GameStatusResponse response = GameStatusResponse.from(result.session()); + return ResponseGenerator.ok("Game restarted", response); + } + /** * GET /rooms/{roomId}/game/status - 게임 상태 조회 */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/ChatRoom.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/ChatRoom.java index 0c076da4..44c38d39 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/ChatRoom.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/ChatRoom.java @@ -36,7 +36,13 @@ public class ChatRoom { // 게임 세션 참조 (게임 상태는 GameSession으로 분리됨) private String activeGameSessionId; // 현재 진행중인 게임 세션 ID (nullable) - + + private String type; // CHAT, GAME (기본값: CHAT) + private String gameType; // CATCHMIND (nullable, GAME 타입일 때만) + private GameSettings gameSettings; // 게임 설정 (nullable) + private String status; // WAITING, PLAYING, FINISHED (기본값: WAITING) + private String hostId; // 방장 userId (createdBy와 별도 관리) + @DynamoDbPartitionKey @DynamoDbAttribute("PK") public String getPk() { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSettings.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSettings.java new file mode 100644 index 00000000..d7d9b9cf --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSettings.java @@ -0,0 +1,21 @@ +package com.mzc.secondproject.serverless.domain.chatting.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class GameSettings { + @Builder.Default + private Integer maxRounds = 5; + + @Builder.Default + private Integer roundTimeLimit = 60; + + @Builder.Default + private Boolean autoDeleteOnEnd = false; +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomCommandService.java index b278c8b9..bce523c7 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomCommandService.java @@ -3,6 +3,7 @@ import com.mzc.secondproject.serverless.domain.chatting.dto.response.JoinRoomResponse; import com.mzc.secondproject.serverless.domain.chatting.exception.ChattingException; import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; +import com.mzc.secondproject.serverless.domain.chatting.model.GameSettings; import com.mzc.secondproject.serverless.domain.chatting.model.RoomToken; import com.mzc.secondproject.serverless.domain.chatting.repository.ChatRoomRepository; import org.mindrot.jbcrypt.BCrypt; @@ -31,10 +32,11 @@ public ChatRoomCommandService() { } public ChatRoom createRoom(String name, String description, String level, Integer maxMembers, - Boolean isPrivate, String password, String createdBy) { + Boolean isPrivate, String password, String createdBy, + String type, String gameType, GameSettings gameSettings) { String roomId = UUID.randomUUID().toString(); String now = Instant.now().toString(); - + ChatRoom room = ChatRoom.builder() .pk("ROOM#" + roomId) .sk("METADATA") @@ -52,11 +54,16 @@ public ChatRoom createRoom(String name, String description, String level, Intege .createdAt(now) .lastMessageAt(now) .memberIds(new ArrayList<>(List.of(createdBy))) + .type(type != null ? type : "CHAT") + .gameType(gameType) + .gameSettings(gameSettings) + .status("WAITING") + .hostId(createdBy) .build(); - + roomRepository.save(room); logger.info("Created room: {}", roomId); - + return room; } @@ -102,24 +109,39 @@ public LeaveResult leaveRoom(String roomId, String userId) { if (optRoom.isEmpty()) { throw ChattingException.roomNotFound(roomId); } - + ChatRoom room = optRoom.get(); - + if (room.getMemberIds() != null) { room.getMemberIds().remove(userId); room.setCurrentMembers(Math.max(0, room.getCurrentMembers() - 1)); } - - if (userId.equals(room.getCreatedBy()) || room.getCurrentMembers() <= 0) { + + // 모든 참가자가 나갔으면 방 삭제 + if (room.getCurrentMembers() <= 0 || + (room.getMemberIds() != null && room.getMemberIds().isEmpty())) { roomRepository.delete(roomId); - logger.info("Room {} deleted (owner left or empty)", roomId); - return new LeaveResult(true, null); + logger.info("Room {} deleted (empty)", roomId); + return new LeaveResult(true, null, null); } - + + // 방장이 나갔으면 다음 멤버에게 방장 이전 + String oldHostId = room.getHostId() != null ? room.getHostId() : room.getCreatedBy(); + String newHostId = null; + + if (userId.equals(oldHostId)) { + // 첫 번째 남은 멤버가 새 방장 + if (room.getMemberIds() != null && !room.getMemberIds().isEmpty()) { + newHostId = room.getMemberIds().get(0); + room.setHostId(newHostId); + logger.info("Host transferred from {} to {} in room {}", oldHostId, newHostId, roomId); + } + } + roomRepository.save(room); logger.info("User {} left room {}", userId, roomId); - - return new LeaveResult(false, room); + + return new LeaveResult(false, room, newHostId); } public void deleteRoom(String roomId, String userId) { @@ -137,6 +159,6 @@ public void deleteRoom(String roomId, String userId) { logger.info("Deleted room: {} by owner: {}", roomId, userId); } - public record LeaveResult(boolean deleted, ChatRoom room) { + public record LeaveResult(boolean deleted, ChatRoom room, String newHostId) { } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomQueryService.java index d99657db..5a5ed0df 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomQueryService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomQueryService.java @@ -26,11 +26,27 @@ public Optional getRoom(String roomId) { return roomRepository.findById(roomId); } - public PaginatedResult getRooms(String level, int limit, String cursor) { + public PaginatedResult getRooms(String level, int limit, String cursor, String type, String gameType, String status) { + PaginatedResult roomPage; if (level != null && !level.isEmpty()) { - return roomRepository.findByLevelWithPagination(level, limit, cursor); + roomPage = roomRepository.findByLevelWithPagination(level, limit, cursor); + } else { + roomPage = roomRepository.findAllWithPagination(limit, cursor); } - return roomRepository.findAllWithPagination(limit, cursor); + + List rooms = roomPage.items(); + + if (type != null) { + rooms = rooms.stream().filter(r -> type.equalsIgnoreCase(r.getType())).toList(); + } + if (gameType != null) { + rooms = rooms.stream().filter(r -> gameType.equalsIgnoreCase(r.getGameType())).toList(); + } + if (status != null) { + rooms = rooms.stream().filter(r -> status.equalsIgnoreCase(r.getStatus())).toList(); + } + + return new PaginatedResult<>(rooms, roomPage.nextCursor()); } public List filterByJoinedUser(List rooms, String userId) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java index 031892e3..accce2e4 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java @@ -63,6 +63,39 @@ public GameService(ChatRoomRepository chatRoomRepository, ConnectionRepository c this.gameSchedulerClient = gameSchedulerClient; } + /** + * 게임 재시작 + */ + public GameStartResult restartGame(String roomId, String userId) { + ChatRoom room = chatRoomRepository.findById(roomId) + .orElseThrow(() -> new IllegalArgumentException("채팅방을 찾을 수 없습니다.")); + + // 방장 권한 확인 + if (!userId.equals(room.getHostId()) && !userId.equals(room.getCreatedBy())) { + return GameStartResult.error("방장만 게임을 시작할 수 있습니다."); + } + + // 방 타입 검증 + if (room.getType() == null || !"GAME".equalsIgnoreCase(room.getType())) { + return GameStartResult.error("게임은 게임 방에서만 시작할 수 있습니다."); + } + + // FINISHED 상태인지 확인 (이미 게임이 끝났어야 재시작 가능) + Optional existingSession = gameSessionRepository.findActiveByRoomId(roomId); + if (existingSession.isPresent()) { + return GameStartResult.error("게임 진행 중에는 재시작할 수 없습니다."); + } + + // 접속자 확인 + List connections = connectionRepository.findByRoomId(roomId); + if (connections.size() < 2) { + return GameStartResult.error("최소 2명 이상 접속해야 게임을 시작할 수 있습니다."); + } + + // 기존 startGame 로직 재사용 - 내부적으로 startGame 호출 + return startGame(roomId, userId); + } + /** * 게임 시작 */ @@ -70,6 +103,12 @@ public GameStartResult startGame(String roomId, String userId) { ChatRoom room = chatRoomRepository.findById(roomId) .orElseThrow(() -> new IllegalArgumentException("채팅방을 찾을 수 없습니다.")); + // 방 타입 검증 - GAME 타입만 게임 시작 가능 + String roomType = room.getType(); + if (roomType == null || !"GAME".equalsIgnoreCase(roomType)) { + return GameStartResult.error("게임은 게임 방에서만 시작할 수 있습니다."); + } + // 이미 활성 게임 세션이 있는지 확인 Optional existingSession = gameSessionRepository.findActiveByRoomId(roomId); if (existingSession.isPresent()) { diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCodeSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCodeSpec.groovy index 66742acb..df2855f6 100644 --- a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCodeSpec.groovy +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCodeSpec.groovy @@ -40,11 +40,15 @@ class ChattingErrorCodeSpec extends Specification { ChattingErrorCode.GAME_NOT_IN_PROGRESS | "GAME_003" | 400 ChattingErrorCode.GAME_ALREADY_IN_PROGRESS| "GAME_004" | 409 ChattingErrorCode.NOT_GAME_STARTER | "GAME_005" | 403 + ChattingErrorCode.GAME_NOT_FOUND | "GAME_006" | 404 + ChattingErrorCode.GAME_NOT_ALLOWED_IN_CHAT_ROOM | "GAME_007" | 400 + ChattingErrorCode.GAME_RESTART_NOT_ALLOWED | "GAME_008" | 400 + ChattingErrorCode.GAME_START_NOT_HOST | "GAME_009" | 403 } def "모든 에러 코드 개수 확인"() { - expect: "20개의 에러 코드 존재" - ChattingErrorCode.values().length == 20 + expect: "24개의 에러 코드 존재" + ChattingErrorCode.values().length == 24 } def "채팅방 관련 에러 코드들 (ROOM_XXX)"() { @@ -71,5 +75,9 @@ class ChattingErrorCodeSpec extends Specification { ChattingErrorCode.GAME_NOT_IN_PROGRESS.getCode().startsWith("GAME_") ChattingErrorCode.GAME_ALREADY_IN_PROGRESS.getCode().startsWith("GAME_") ChattingErrorCode.NOT_GAME_STARTER.getCode().startsWith("GAME_") + ChattingErrorCode.GAME_NOT_FOUND.getCode().startsWith("GAME_") + ChattingErrorCode.GAME_NOT_ALLOWED_IN_CHAT_ROOM.getCode().startsWith("GAME_") + ChattingErrorCode.GAME_RESTART_NOT_ALLOWED.getCode().startsWith("GAME_") + ChattingErrorCode.GAME_START_NOT_HOST.getCode().startsWith("GAME_") } } diff --git a/ServerlessFunction/src/test/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomStatusTest.java b/ServerlessFunction/src/test/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomStatusTest.java new file mode 100644 index 00000000..03c59df5 --- /dev/null +++ b/ServerlessFunction/src/test/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomStatusTest.java @@ -0,0 +1,44 @@ +package com.mzc.secondproject.serverless.domain.chatting.enums; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class RoomStatusTest { + @Test + void testFromString() { + assertEquals(RoomStatus.WAITING, RoomStatus.fromString("waiting")); + assertEquals(RoomStatus.WAITING, RoomStatus.fromString("WAITING")); + assertEquals(RoomStatus.PLAYING, RoomStatus.fromString("playing")); + assertEquals(RoomStatus.PLAYING, RoomStatus.fromString("PLAYING")); + assertEquals(RoomStatus.FINISHED, RoomStatus.fromString("finished")); + assertEquals(RoomStatus.FINISHED, RoomStatus.fromString("FINISHED")); + assertEquals(RoomStatus.WAITING, RoomStatus.fromString(null)); + assertEquals(RoomStatus.WAITING, RoomStatus.fromString("invalid")); + } + + @Test + void testIsValid() { + assertTrue(RoomStatus.isValid("WAITING")); + assertTrue(RoomStatus.isValid("waiting")); + assertTrue(RoomStatus.isValid("PLAYING")); + assertTrue(RoomStatus.isValid("playing")); + assertTrue(RoomStatus.isValid("FINISHED")); + assertTrue(RoomStatus.isValid("finished")); + assertFalse(RoomStatus.isValid(null)); + assertFalse(RoomStatus.isValid("invalid")); + } + + @Test + void testGetCode() { + assertEquals("waiting", RoomStatus.WAITING.getCode()); + assertEquals("playing", RoomStatus.PLAYING.getCode()); + assertEquals("finished", RoomStatus.FINISHED.getCode()); + } + + @Test + void testGetDisplayName() { + assertEquals("대기 중", RoomStatus.WAITING.getDisplayName()); + assertEquals("게임 중", RoomStatus.PLAYING.getDisplayName()); + assertEquals("종료됨", RoomStatus.FINISHED.getDisplayName()); + } +} diff --git a/ServerlessFunction/src/test/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomTypeTest.java b/ServerlessFunction/src/test/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomTypeTest.java new file mode 100644 index 00000000..7d8d13db --- /dev/null +++ b/ServerlessFunction/src/test/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomTypeTest.java @@ -0,0 +1,38 @@ +package com.mzc.secondproject.serverless.domain.chatting.enums; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class RoomTypeTest { + @Test + void testFromString() { + assertEquals(RoomType.CHAT, RoomType.fromString("chat")); + assertEquals(RoomType.CHAT, RoomType.fromString("CHAT")); + assertEquals(RoomType.GAME, RoomType.fromString("game")); + assertEquals(RoomType.GAME, RoomType.fromString("GAME")); + assertEquals(RoomType.CHAT, RoomType.fromString(null)); + assertEquals(RoomType.CHAT, RoomType.fromString("invalid")); + } + + @Test + void testIsValid() { + assertTrue(RoomType.isValid("CHAT")); + assertTrue(RoomType.isValid("chat")); + assertTrue(RoomType.isValid("GAME")); + assertTrue(RoomType.isValid("game")); + assertFalse(RoomType.isValid(null)); + assertFalse(RoomType.isValid("invalid")); + } + + @Test + void testGetCode() { + assertEquals("chat", RoomType.CHAT.getCode()); + assertEquals("game", RoomType.GAME.getCode()); + } + + @Test + void testGetDisplayName() { + assertEquals("채팅방", RoomType.CHAT.getDisplayName()); + assertEquals("게임방", RoomType.GAME.getDisplayName()); + } +} diff --git a/ServerlessFunction/src/test/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSettingsTest.java b/ServerlessFunction/src/test/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSettingsTest.java new file mode 100644 index 00000000..e762e335 --- /dev/null +++ b/ServerlessFunction/src/test/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSettingsTest.java @@ -0,0 +1,54 @@ +package com.mzc.secondproject.serverless.domain.chatting.model; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class GameSettingsTest { + @Test + void testDefaultValues() { + GameSettings settings = GameSettings.builder().build(); + assertEquals(5, settings.getMaxRounds()); + assertEquals(60, settings.getRoundTimeLimit()); + assertFalse(settings.getAutoDeleteOnEnd()); + } + + @Test + void testCustomValues() { + GameSettings settings = GameSettings.builder() + .maxRounds(10) + .roundTimeLimit(90) + .autoDeleteOnEnd(true) + .build(); + assertEquals(10, settings.getMaxRounds()); + assertEquals(90, settings.getRoundTimeLimit()); + assertTrue(settings.getAutoDeleteOnEnd()); + } + + @Test + void testNoArgsConstructor() { + GameSettings settings = new GameSettings(); + assertEquals(5, settings.getMaxRounds()); + assertEquals(60, settings.getRoundTimeLimit()); + assertFalse(settings.getAutoDeleteOnEnd()); + } + + @Test + void testAllArgsConstructor() { + GameSettings settings = new GameSettings(10, 90, true); + assertEquals(10, settings.getMaxRounds()); + assertEquals(90, settings.getRoundTimeLimit()); + assertTrue(settings.getAutoDeleteOnEnd()); + } + + @Test + void testSettersAndGetters() { + GameSettings settings = new GameSettings(); + settings.setMaxRounds(8); + settings.setRoundTimeLimit(120); + settings.setAutoDeleteOnEnd(true); + + assertEquals(8, settings.getMaxRounds()); + assertEquals(120, settings.getRoundTimeLimit()); + assertTrue(settings.getAutoDeleteOnEnd()); + } +} From 0f29fb98961a86b355fa6e51df3eeed53cfc3b1e Mon Sep 17 00:00:00 2001 From: DDING JOO Date: Tue, 20 Jan 2026 21:32:05 +0900 Subject: [PATCH 394/528] =?UTF-8?q?feat:=20=EC=B0=B8=EA=B0=80=EC=9E=90=20?= =?UTF-8?q?=EB=8B=89=EB=84=A4=EC=9E=84=20=EB=B0=8F=20=EB=B0=A9=EC=9E=A5=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20WebSocket=20=EC=95=8C=EB=A6=BC=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#456)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RoomParticipant DTO 추가 (userId, nickname, isHost) - ChatRoomQueryService에 닉네임 조회 메서드 추가 - getParticipantsWithNicknames(): 참가자 목록 + 닉네임 - getHostNickname(): 방장 닉네임 조회 - ChatRoomHandler.getRoom() 응답에 participants, hostNickname 추가 - ChatRoomCommandService.leaveRoom()에서 방장 변경 시 WebSocket 브로드캐스트 Related: #440 --- .../dto/response/RoomParticipant.java | 16 +++++++ .../chatting/handler/ChatRoomHandler.java | 18 +++++-- .../service/ChatRoomCommandService.java | 38 ++++++++++++++- .../service/ChatRoomQueryService.java | 47 ++++++++++++++++++- 4 files changed, 111 insertions(+), 8 deletions(-) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/RoomParticipant.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/RoomParticipant.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/RoomParticipant.java new file mode 100644 index 00000000..146fa3dc --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/RoomParticipant.java @@ -0,0 +1,16 @@ +package com.mzc.secondproject.serverless.domain.chatting.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class RoomParticipant { + private String userId; + private String nickname; + private Boolean isHost; +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java index 13f6447c..6a5769d5 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java @@ -12,6 +12,7 @@ import com.mzc.secondproject.serverless.domain.chatting.dto.request.CreateRoomRequest; import com.mzc.secondproject.serverless.domain.chatting.dto.request.JoinRoomRequest; import com.mzc.secondproject.serverless.domain.chatting.dto.response.JoinRoomResponse; +import com.mzc.secondproject.serverless.domain.chatting.dto.response.RoomParticipant; import com.mzc.secondproject.serverless.domain.chatting.exception.ChattingErrorCode; import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; import com.mzc.secondproject.serverless.domain.chatting.service.ChatRoomCommandService; @@ -106,16 +107,25 @@ private APIGatewayProxyResponseEvent getRooms(APIGatewayProxyRequestEvent reques private APIGatewayProxyResponseEvent getRoom(APIGatewayProxyRequestEvent request, String userId) { String roomId = request.getPathParameters().get("roomId"); - + Optional optRoom = queryService.getRoom(roomId); if (optRoom.isEmpty()) { return ResponseGenerator.fail(ChattingErrorCode.ROOM_NOT_FOUND); } - + ChatRoom room = optRoom.get(); room.setPassword(null); - - return ResponseGenerator.ok("Room retrieved", room); + + // 참가자 정보와 방장 닉네임 추가 + List participants = queryService.getParticipantsWithNicknames(room); + String hostNickname = queryService.getHostNickname(room); + + Map result = new HashMap<>(); + result.put("room", room); + result.put("participants", participants); + result.put("hostNickname", hostNickname); + + return ResponseGenerator.ok("Room retrieved", result); } private APIGatewayProxyResponseEvent joinRoom(APIGatewayProxyRequestEvent request, String userId) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomCommandService.java index bce523c7..d1abff8d 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomCommandService.java @@ -1,11 +1,18 @@ package com.mzc.secondproject.serverless.domain.chatting.service; +import com.mzc.secondproject.serverless.common.util.ResponseGenerator; +import com.mzc.secondproject.serverless.common.util.WebSocketBroadcaster; +import com.mzc.secondproject.serverless.common.util.WebSocketMessageHelper; import com.mzc.secondproject.serverless.domain.chatting.dto.response.JoinRoomResponse; import com.mzc.secondproject.serverless.domain.chatting.exception.ChattingException; import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; +import com.mzc.secondproject.serverless.domain.chatting.model.Connection; import com.mzc.secondproject.serverless.domain.chatting.model.GameSettings; import com.mzc.secondproject.serverless.domain.chatting.model.RoomToken; import com.mzc.secondproject.serverless.domain.chatting.repository.ChatRoomRepository; +import com.mzc.secondproject.serverless.domain.chatting.repository.ConnectionRepository; +import com.mzc.secondproject.serverless.domain.user.model.User; +import com.mzc.secondproject.serverless.domain.user.repository.UserRepository; import org.mindrot.jbcrypt.BCrypt; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -13,6 +20,7 @@ import java.time.Instant; import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.UUID; @@ -22,13 +30,19 @@ public class ChatRoomCommandService { private static final Logger logger = LoggerFactory.getLogger(ChatRoomCommandService.class); - + private final ChatRoomRepository roomRepository; private final RoomTokenService roomTokenService; - + private final ConnectionRepository connectionRepository; + private final WebSocketBroadcaster broadcaster; + private final UserRepository userRepository; + public ChatRoomCommandService() { this.roomRepository = new ChatRoomRepository(); this.roomTokenService = new RoomTokenService(); + this.connectionRepository = new ConnectionRepository(); + this.broadcaster = new WebSocketBroadcaster(); + this.userRepository = new UserRepository(); } public ChatRoom createRoom(String name, String description, String level, Integer maxMembers, @@ -141,6 +155,26 @@ public LeaveResult leaveRoom(String roomId, String userId) { roomRepository.save(room); logger.info("User {} left room {}", userId, roomId); + // 방장이 나갔으면 다음 멤버에게 방장 이전 후 WebSocket 알림 + if (userId.equals(oldHostId) && newHostId != null) { + // 새 방장 닉네임 조회 + String newHostNickname = userRepository.findByCognitoSub(newHostId) + .map(User::getNickname) + .orElse(newHostId); + + // WebSocket 알림 브로드캐스트 + try { + List connections = connectionRepository.findByRoomId(roomId); + Map message = WebSocketMessageHelper.buildHostChangeMessage( + roomId, newHostId, newHostNickname); + String json = ResponseGenerator.gson().toJson(message); + broadcaster.broadcast(connections, json); + logger.info("Broadcasted host change: roomId={}, newHostId={}", roomId, newHostId); + } catch (Exception e) { + logger.error("Failed to broadcast host change: {}", e.getMessage()); + } + } + return new LeaveResult(false, room, newHostId); } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomQueryService.java index 5a5ed0df..06888d42 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomQueryService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomQueryService.java @@ -1,8 +1,11 @@ package com.mzc.secondproject.serverless.domain.chatting.service; import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.domain.chatting.dto.response.RoomParticipant; import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; import com.mzc.secondproject.serverless.domain.chatting.repository.ChatRoomRepository; +import com.mzc.secondproject.serverless.domain.user.model.User; +import com.mzc.secondproject.serverless.domain.user.repository.UserRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -15,11 +18,13 @@ public class ChatRoomQueryService { private static final Logger logger = LoggerFactory.getLogger(ChatRoomQueryService.class); - + private final ChatRoomRepository roomRepository; - + private final UserRepository userRepository; + public ChatRoomQueryService() { this.roomRepository = new ChatRoomRepository(); + this.userRepository = new UserRepository(); } public Optional getRoom(String roomId) { @@ -54,4 +59,42 @@ public List filterByJoinedUser(List rooms, String userId) { .filter(room -> room.getMemberIds() != null && room.getMemberIds().contains(userId)) .toList(); } + + /** + * 참가자 목록을 닉네임과 함께 조회 + * + * @param room ChatRoom 객체 + * @return 참가자 목록 (userId, nickname, isHost 포함) + */ + public List getParticipantsWithNicknames(ChatRoom room) { + if (room.getMemberIds() == null) return List.of(); + + String hostId = room.getHostId() != null ? room.getHostId() : room.getCreatedBy(); + + return room.getMemberIds().stream() + .map(userId -> { + String nickname = userRepository.findByCognitoSub(userId) + .map(User::getNickname) + .orElse(userId); // fallback to userId if not found + return RoomParticipant.builder() + .userId(userId) + .nickname(nickname) + .isHost(userId.equals(hostId)) + .build(); + }) + .toList(); + } + + /** + * 방장 닉네임 조회 + * + * @param room ChatRoom 객체 + * @return 방장 닉네임 (없으면 userId 반환) + */ + public String getHostNickname(ChatRoom room) { + String hostId = room.getHostId() != null ? room.getHostId() : room.getCreatedBy(); + return userRepository.findByCognitoSub(hostId) + .map(User::getNickname) + .orElse(hostId); + } } From 469cefd65065298ed4dd21367e356505abcc4151 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Wed, 21 Jan 2026 09:12:51 +0900 Subject: [PATCH 395/528] =?UTF-8?q?fix:=20ChatRoomFunction=EC=97=90=20User?= =?UTF-8?q?Table=20DynamoDB=20=EA=B6=8C=ED=95=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 참가자/방장 닉네임 조회를 위한 UserTable 읽기 권한 추가 - DynamoDBReadPolicy로 UserTable 접근 허용 Fixes #457 --- ServerlessFunction/template.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index b6b087e8..f345866d 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -367,6 +367,8 @@ Resources: Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable + - DynamoDBReadPolicy: + TableName: !Ref UserTable Events: CreateRoom: Type: Api From 229be3a6f5608014e4f4f4ba4af2847aeac98e54 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Wed, 21 Jan 2026 09:21:07 +0900 Subject: [PATCH 396/528] =?UTF-8?q?fix:=20GameSettings=EC=97=90=20@DynamoD?= =?UTF-8?q?bBean=20=EC=96=B4=EB=85=B8=ED=85=8C=EC=9D=B4=EC=85=98=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DynamoDB Enhanced Client가 중첩 객체를 직렬화/역직렬화할 수 있도록 수정 - ChatRoom 저장/조회 시 GameSettings 변환 오류 해결 Fixes #457 --- .../docs/CATCHMIND_FRONTEND_GUIDE.md | 722 ++++++++++++++++++ .../chatting/dto/response/RoomListItem.java | 71 ++ .../chatting/handler/ChatRoomHandler.java | 18 +- .../domain/chatting/model/GameSettings.java | 2 + 4 files changed, 809 insertions(+), 4 deletions(-) create mode 100644 ServerlessFunction/docs/CATCHMIND_FRONTEND_GUIDE.md create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/RoomListItem.java diff --git a/ServerlessFunction/docs/CATCHMIND_FRONTEND_GUIDE.md b/ServerlessFunction/docs/CATCHMIND_FRONTEND_GUIDE.md new file mode 100644 index 00000000..cfc4b168 --- /dev/null +++ b/ServerlessFunction/docs/CATCHMIND_FRONTEND_GUIDE.md @@ -0,0 +1,722 @@ +# Catchmind 게임 프론트엔드 연동 가이드 + +## 목차 +1. [개요](#개요) +2. [아키텍처](#아키텍처) +3. [WebSocket 연결](#websocket-연결) +4. [메시지 구조](#메시지-구조) +5. [게임 흐름](#게임-흐름) +6. [REST API](#rest-api) +7. [타이머 동기화](#타이머-동기화) +8. [게임 자동 종료](#게임-자동-종료) +9. [재접속 처리](#재접속-처리) +10. [에러 처리](#에러-처리) + +--- + +## 개요 + +Catchmind는 실시간 그림 맞추기 게임입니다. WebSocket을 통한 실시간 통신과 REST API를 통한 게임 세션 관리를 지원합니다. + +### 주요 특징 +- **실시간 통신**: WebSocket 기반 양방향 통신 +- **도메인 분리**: `chat` / `game` 도메인으로 메시지 라우팅 +- **타이머 동기화**: `serverTime` 필드를 통한 클라이언트-서버 시간 동기화 +- **자동 종료**: 게임 시작 7분 후 자동 종료 +- **재접속 지원**: 게임 세션 API를 통한 상태 복원 + +--- + +## 아키텍처 + +``` +┌─────────────┐ WebSocket ┌──────────────────┐ +│ Frontend │◄──────────────────►│ API Gateway WS │ +│ (React) │ └────────┬─────────┘ +│ │ │ +│ │ REST API ┌───────▼─────────┐ +│ │◄───────────────────►│ API Gateway │ +└─────────────┘ │ REST │ + └────────┬────────┘ + │ + ┌─────────────┼─────────────┐ + │ │ │ + ┌─────▼────┐ ┌─────▼────┐ ┌─────▼────┐ + │ WS Msg │ │ Game │ │ Game │ + │ Handler │ │ Handler │ │ Session │ + └──────────┘ └──────────┘ │ Handler │ + └──────────┘ +``` + +--- + +## WebSocket 연결 + +### 연결 URL +``` +wss://{api-id}.execute-api.{region}.amazonaws.com/dev?roomToken={token} +``` + +### 연결 절차 +1. REST API로 방 토큰 발급 (`POST /chat/rooms/{roomId}/join`) +2. 토큰으로 WebSocket 연결 +3. 연결 성공 시 자동으로 방에 입장 + +### 연결 예시 (TypeScript) +```typescript +const connectWebSocket = (roomToken: string): WebSocket => { + const ws = new WebSocket( + `wss://xxx.execute-api.ap-northeast-2.amazonaws.com/dev?roomToken=${roomToken}` + ); + + ws.onopen = () => console.log('WebSocket connected'); + ws.onmessage = (event) => handleMessage(JSON.parse(event.data)); + ws.onerror = (error) => console.error('WebSocket error:', error); + ws.onclose = () => console.log('WebSocket closed'); + + return ws; +}; +``` + +--- + +## 메시지 구조 + +### 공통 메시지 포맷 + +모든 WebSocket 메시지는 다음 필드를 포함합니다: + +```typescript +interface BaseMessage { + domain: 'chat' | 'game'; // 도메인 구분 + messageType: string; // 메시지 타입 + messageId: string; // 고유 메시지 ID + roomId: string; // 방 ID + userId: string; // 발신자 ID (시스템: "SYSTEM") + content?: string; // 메시지 내용 + createdAt: string; // ISO 8601 형식 시간 + timestamp: number; // Unix timestamp (ms) +} +``` + +### 도메인 구분 + +| 도메인 | 설명 | 메시지 타입 | +|--------|------|-------------| +| `chat` | 채팅 메시지 | text, image, voice, ai_response | +| `game` | 게임 메시지 | game_start, game_end, round_start, round_end, drawing, correct_answer, score_update, hint | + +### 메시지 라우팅 예시 +```typescript +const handleMessage = (message: BaseMessage) => { + if (message.domain === 'chat') { + handleChatMessage(message); + } else if (message.domain === 'game') { + handleGameMessage(message); + } +}; +``` + +--- + +## 게임 흐름 + +### 게임 상태 (GameStatus) +```typescript +type GameStatus = 'NONE' | 'WAITING' | 'PLAYING' | 'ROUND_END' | 'FINISHED'; +``` + +### 전체 흐름 +``` +[대기] ─── /game 시작 ───► [게임 시작] ─► [라운드 1] ─► [라운드 종료] + │ │ + │ ◄───────────────────┘ + │ (반복) + ▼ + [게임 종료] + │ + ┌────┴────┐ + │ │ + 수동 종료 자동 종료 + (7분 경과) +``` + +### 1. 게임 시작 (game_start) + +**수신 메시지:** +```json +{ + "domain": "game", + "messageType": "game_start", + "messageId": "uuid", + "roomId": "room-123", + "userId": "SYSTEM", + "content": "🎮 게임 시작!\n총 5 라운드\n\n라운드 1 시작!\n출제자: user-1", + "createdAt": "2024-01-20T10:00:00Z", + "timestamp": 1705746000000, + "serverTime": 1705746000000, + "gameStatus": "PLAYING", + "currentRound": 1, + "totalRounds": 5, + "currentDrawerId": "user-1", + "drawerOrder": ["user-1", "user-2", "user-3"], + "roundStartTime": 1705746000000, + "roundDuration": 60 +} +``` + +**프론트엔드 처리:** +```typescript +const handleGameStart = (message: GameStartMessage) => { + setGameStatus('PLAYING'); + setCurrentRound(message.currentRound); + setTotalRounds(message.totalRounds); + setCurrentDrawer(message.currentDrawerId); + setDrawerOrder(message.drawerOrder); + + // 타이머 동기화 + startTimer(message.roundStartTime, message.roundDuration, message.serverTime); + + // 현재 사용자가 출제자인지 확인 + setIsDrawer(message.currentDrawerId === currentUserId); +}; +``` + +### 2. 그림 데이터 전송/수신 (drawing) + +**전송 (출제자만):** +```typescript +const sendDrawing = (drawingData: DrawingData) => { + ws.send(JSON.stringify({ + action: 'sendMessage', + messageType: 'drawing', + content: JSON.stringify(drawingData) + })); +}; +``` + +**수신 메시지:** +```json +{ + "domain": "game", + "messageType": "drawing", + "messageId": "uuid", + "roomId": "room-123", + "userId": "user-1", + "content": "{\"type\":\"path\",\"points\":[...],\"color\":\"#000\",\"width\":3}", + "timestamp": 1705746010000 +} +``` + +### 3. 정답 체크 + +**채팅 메시지로 자동 체크됩니다:** +```typescript +const sendAnswer = (answer: string) => { + ws.send(JSON.stringify({ + action: 'sendMessage', + messageType: 'text', + content: answer + })); +}; +``` + +### 4. 정답 알림 (correct_answer) + +**수신 메시지:** +```json +{ + "domain": "game", + "messageType": "correct_answer", + "roomId": "room-123", + "userId": "user-2", + "content": "🎉 user-2님이 정답을 맞혔습니다! (+35점)", + "timestamp": 1705746030000, + "serverTime": 1705746030000, + "score": 35, + "elapsedTime": 30000, + "allCorrect": false, + "scores": { + "user-1": 5, + "user-2": 35 + } +} +``` + +### 5. 점수 업데이트 (score_update) + +**수신 메시지:** +```json +{ + "domain": "game", + "messageType": "score_update", + "roomId": "room-123", + "timestamp": 1705746030000, + "scores": { + "user-1": 15, + "user-2": 35, + "user-3": 20 + }, + "lastScorer": "user-2", + "lastScore": 35 +} +``` + +### 6. 라운드 종료 (round_end) + +**수신 메시지:** +```json +{ + "domain": "game", + "messageType": "round_end", + "roomId": "room-123", + "content": "라운드 1 종료! 정답: 사과\n\n라운드 2 시작! 출제자: user-2", + "timestamp": 1705746060000, + "serverTime": 1705746060000, + "data": { + "answer": "사과", + "currentRound": 1, + "totalRounds": 5, + "nextRound": 2, + "nextDrawer": "user-2", + "nextWord": { + "wordId": "word-123", + "korean": "바나나" + }, + "roundStartTime": 1705746060000, + "roundDuration": 60, + "ranking": [ + { "rank": 1, "userId": "user-2", "score": 35 }, + { "rank": 2, "userId": "user-3", "score": 20 }, + { "rank": 3, "userId": "user-1", "score": 15 } + ] + } +} +``` + +**프론트엔드 처리:** +```typescript +const handleRoundEnd = (message: RoundEndMessage) => { + const { data } = message; + + // 정답 표시 + showAnswer(data.answer); + + // 순위 표시 + showRanking(data.ranking); + + // 다음 라운드 준비 + if (data.nextRound) { + setCurrentRound(data.nextRound); + setCurrentDrawer(data.nextDrawer); + setIsDrawer(data.nextDrawer === currentUserId); + + // 출제자에게만 단어 표시 + if (data.nextDrawer === currentUserId && data.nextWord) { + setCurrentWord(data.nextWord.korean); + } + + // 타이머 재시작 + startTimer(data.roundStartTime, data.roundDuration, message.serverTime); + + // 캔버스 초기화 + clearCanvas(); + } +}; +``` + +### 7. 게임 종료 (game_end) + +**수신 메시지:** +```json +{ + "domain": "game", + "messageType": "game_end", + "roomId": "room-123", + "content": "🎮 게임 종료!\n\n📊 최종 순위:\n 🥇 user-2: 120점\n 🥈 user-3: 95점\n 🥉 user-1: 80점", + "timestamp": 1705746300000, + "reason": "COMPLETED" +} +``` + +**종료 사유 (reason):** +| 값 | 설명 | +|----|------| +| `COMPLETED` | 모든 라운드 완료 | +| `STOPPED` | 수동 종료 | +| `TIME_EXPIRED` | 7분 시간 초과 | +| `NOT_ENOUGH_PLAYERS` | 인원 부족 | + +--- + +## REST API + +### 게임 시작 +```http +POST /chat/rooms/{roomId}/game/start +Authorization: Bearer {accessToken} +``` + +**Response:** +```json +{ + "success": true, + "message": "Game started", + "data": { + "gameSessionId": "session-123", + "roomId": "room-123", + "status": "PLAYING", + "currentRound": 1, + "totalRounds": 5, + "currentDrawerId": "user-1", + "roundStartTime": 1705746000000, + "serverTime": 1705746000000, + "roundDuration": 60, + "drawerOrder": ["user-1", "user-2", "user-3"], + "currentWord": { + "wordId": "word-1", + "word": "사과" + } + } +} +``` +> **Note:** `currentWord`는 출제자에게만 포함됩니다. + +### 게임 종료 +```http +POST /chat/rooms/{roomId}/game/stop +Authorization: Bearer {accessToken} +``` + +### 게임 상태 조회 +```http +GET /chat/rooms/{roomId}/game/status +Authorization: Bearer {accessToken} +``` + +### 게임 세션 조회 (재접속용) +```http +GET /games/{gameSessionId} +Authorization: Bearer {accessToken} +``` + +**Response:** +```json +{ + "success": true, + "message": "Game session retrieved", + "data": { + "gameSessionId": "session-123", + "roomId": "room-123", + "gameType": "catchmind", + "status": "PLAYING", + "currentRound": 3, + "totalRounds": 5, + "currentDrawerId": "user-2", + "roundStartTime": 1705746180000, + "serverTime": 1705746200000, + "roundDuration": 60, + "scores": { + "user-1": 45, + "user-2": 60, + "user-3": 30 + }, + "players": ["user-1", "user-2", "user-3"], + "drawerOrder": ["user-1", "user-2", "user-3"], + "hintUsed": false, + "currentWord": { + "wordId": "word-5", + "word": "바나나" + } + } +} +``` +> **Note:** `currentWord`는 출제자에게만 포함됩니다. + +--- + +## 타이머 동기화 + +### 문제 +클라이언트와 서버 시간 차이로 인한 타이머 불일치 + +### 해결책 +`serverTime` 필드를 사용하여 서버 시간 기준 타이머 계산 + +### 구현 예시 +```typescript +interface TimerSync { + roundStartTime: number; // 라운드 시작 시간 (서버 기준) + roundDuration: number; // 라운드 지속 시간 (초) + serverTime: number; // 메시지 발송 시점의 서버 시간 +} + +const startTimer = ( + roundStartTime: number, + roundDuration: number, + serverTime: number +) => { + // 서버에서 이미 경과한 시간 계산 + const elapsedOnServer = serverTime - roundStartTime; + + // 남은 시간 계산 (밀리초) + const remainingTime = (roundDuration * 1000) - elapsedOnServer; + + // 음수 방지 + const safeRemainingTime = Math.max(0, remainingTime); + + setRemainingTime(safeRemainingTime); + + // 타이머 시작 + const interval = setInterval(() => { + setRemainingTime((prev) => { + if (prev <= 1000) { + clearInterval(interval); + return 0; + } + return prev - 1000; + }); + }, 1000); + + return () => clearInterval(interval); +}; +``` + +### React Hook 예시 +```typescript +const useGameTimer = (timerSync: TimerSync | null) => { + const [remainingSeconds, setRemainingSeconds] = useState(0); + + useEffect(() => { + if (!timerSync) return; + + const { roundStartTime, roundDuration, serverTime } = timerSync; + const elapsed = (serverTime - roundStartTime) / 1000; + const remaining = Math.max(0, roundDuration - elapsed); + + setRemainingSeconds(Math.ceil(remaining)); + + const interval = setInterval(() => { + setRemainingSeconds((prev) => Math.max(0, prev - 1)); + }, 1000); + + return () => clearInterval(interval); + }, [timerSync]); + + return remainingSeconds; +}; +``` + +--- + +## 게임 자동 종료 + +### 개요 +게임 시작 후 7분(420초)이 경과하면 자동으로 종료됩니다. + +### 자동 종료 메시지 +```json +{ + "domain": "game", + "messageType": "game_end", + "roomId": "room-123", + "userId": "SYSTEM", + "content": "⏰ 시간 초과! 🎮 게임 종료!\n\n📊 최종 순위:\n 🥇 user-2: 120점\n 🥈 user-1: 95점", + "timestamp": 1705746420000, + "reason": "TIME_EXPIRED" +} +``` + +### 프론트엔드 처리 +```typescript +const handleGameEnd = (message: GameEndMessage) => { + setGameStatus('FINISHED'); + + // 종료 사유에 따른 UI 처리 + if (message.reason === 'TIME_EXPIRED') { + showNotification('시간 초과로 게임이 종료되었습니다.'); + } else if (message.reason === 'STOPPED') { + showNotification('게임이 수동으로 종료되었습니다.'); + } + + // 최종 결과 표시 + showFinalResults(message.content); + + // 캔버스 초기화 + clearCanvas(); +}; +``` + +--- + +## 재접속 처리 + +### 시나리오 +사용자가 게임 중 연결이 끊어졌다가 다시 접속하는 경우 + +### 처리 절차 +1. WebSocket 재연결 +2. 게임 세션 API로 현재 상태 조회 +3. UI 상태 복원 +4. 타이머 동기화 + +### 구현 예시 +```typescript +const handleReconnect = async (roomId: string, gameSessionId: string) => { + // 1. WebSocket 재연결 + const roomToken = await getRoomToken(roomId); + connectWebSocket(roomToken); + + // 2. 게임 세션 조회 + const session = await fetchGameSession(gameSessionId); + + if (session.status === 'PLAYING') { + // 3. UI 상태 복원 + setGameStatus('PLAYING'); + setCurrentRound(session.currentRound); + setScores(session.scores); + setCurrentDrawer(session.currentDrawerId); + setIsDrawer(session.currentDrawerId === currentUserId); + + // 출제자인 경우 단어 설정 + if (session.currentWord) { + setCurrentWord(session.currentWord.word); + } + + // 4. 타이머 동기화 + startTimer( + session.roundStartTime, + session.roundDuration, + session.serverTime + ); + } else if (session.status === 'FINISHED') { + setGameStatus('FINISHED'); + } +}; +``` + +--- + +## 에러 처리 + +### WebSocket 에러 코드 +| 코드 | 설명 | 처리 방법 | +|------|------|-----------| +| 1000 | 정상 종료 | - | +| 1001 | 서버 종료 | 재연결 시도 | +| 1006 | 비정상 종료 | 재연결 시도 | +| 4001 | 인증 실패 | 토큰 재발급 후 재연결 | +| 4003 | 권한 없음 | 에러 표시 | + +### REST API 에러 코드 +| 코드 | 설명 | +|------|------| +| `GAME_001` | 게임 시작 실패 | +| `GAME_002` | 게임 중단 실패 | +| `GAME_003` | 진행 중인 게임 없음 | +| `GAME_004` | 이미 게임 진행 중 | +| `GAME_005` | 권한 없음 (게임 시작자만 중단 가능) | +| `GAME_006` | 게임 세션을 찾을 수 없음 | + +### 에러 처리 예시 +```typescript +const handleError = (error: ApiError) => { + switch (error.code) { + case 'GAME_001': + showNotification('게임을 시작할 수 없습니다. 최소 2명이 필요합니다.'); + break; + case 'GAME_004': + showNotification('이미 게임이 진행 중입니다.'); + break; + case 'GAME_006': + // 게임 세션 만료 - 목록으로 이동 + navigateToRoomList(); + break; + default: + showNotification('오류가 발생했습니다.'); + } +}; +``` + +--- + +## 전체 상태 관리 예시 (React) + +```typescript +interface GameState { + status: GameStatus; + currentRound: number; + totalRounds: number; + currentDrawerId: string | null; + currentWord: string | null; + scores: Record; + isDrawer: boolean; + remainingTime: number; + drawerOrder: string[]; +} + +const initialGameState: GameState = { + status: 'NONE', + currentRound: 0, + totalRounds: 0, + currentDrawerId: null, + currentWord: null, + scores: {}, + isDrawer: false, + remainingTime: 0, + drawerOrder: [], +}; + +const gameReducer = (state: GameState, action: GameAction): GameState => { + switch (action.type) { + case 'GAME_START': + return { + ...state, + status: 'PLAYING', + currentRound: action.payload.currentRound, + totalRounds: action.payload.totalRounds, + currentDrawerId: action.payload.currentDrawerId, + drawerOrder: action.payload.drawerOrder, + isDrawer: action.payload.currentDrawerId === action.payload.currentUserId, + scores: {}, + }; + + case 'ROUND_END': + return { + ...state, + currentRound: action.payload.nextRound, + currentDrawerId: action.payload.nextDrawer, + currentWord: action.payload.isDrawer ? action.payload.nextWord : null, + isDrawer: action.payload.isDrawer, + }; + + case 'SCORE_UPDATE': + return { + ...state, + scores: action.payload.scores, + }; + + case 'GAME_END': + return { + ...initialGameState, + status: 'FINISHED', + scores: state.scores, + }; + + case 'RESET': + return initialGameState; + + default: + return state; + } +}; +``` + +--- + +## 버전 이력 + +| 버전 | 날짜 | 변경 내용 | +|------|------|-----------| +| 1.0.0 | 2024-01-20 | 초기 문서 작성 | +| 1.1.0 | 2024-01-20 | 게임 자동 종료 (7분) 기능 추가 | diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/RoomListItem.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/RoomListItem.java new file mode 100644 index 00000000..63c8c9e2 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/RoomListItem.java @@ -0,0 +1,71 @@ +package com.mzc.secondproject.serverless.domain.chatting.dto.response; + +import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; +import com.mzc.secondproject.serverless.domain.chatting.model.GameSettings; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 방 목록 조회 시 사용되는 응답 DTO + * ChatRoom + hostNickname 포함 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class RoomListItem { + private String roomId; + private String name; + private String description; + private String level; + private Integer currentMembers; + private Integer maxMembers; + private Boolean isPrivate; + private String createdBy; + private String createdAt; + private String lastMessageAt; + private String type; + private String gameType; + private GameSettings gameSettings; + private String status; + private String hostId; + private String hostNickname; + private List participants; + + /** + * ChatRoom과 hostNickname으로 RoomListItem 생성 + */ + public static RoomListItem from(ChatRoom room, String hostNickname) { + return RoomListItem.builder() + .roomId(room.getRoomId()) + .name(room.getName()) + .description(room.getDescription()) + .level(room.getLevel()) + .currentMembers(room.getCurrentMembers()) + .maxMembers(room.getMaxMembers()) + .isPrivate(room.getIsPrivate()) + .createdBy(room.getCreatedBy()) + .createdAt(room.getCreatedAt()) + .lastMessageAt(room.getLastMessageAt()) + .type(room.getType()) + .gameType(room.getGameType()) + .gameSettings(room.getGameSettings()) + .status(room.getStatus()) + .hostId(room.getHostId()) + .hostNickname(hostNickname) + .build(); + } + + /** + * ChatRoom, hostNickname, participants로 RoomListItem 생성 + */ + public static RoomListItem from(ChatRoom room, String hostNickname, List participants) { + RoomListItem item = from(room, hostNickname); + item.setParticipants(participants); + return item; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java index 6a5769d5..62b9a323 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatRoomHandler.java @@ -12,6 +12,7 @@ import com.mzc.secondproject.serverless.domain.chatting.dto.request.CreateRoomRequest; import com.mzc.secondproject.serverless.domain.chatting.dto.request.JoinRoomRequest; import com.mzc.secondproject.serverless.domain.chatting.dto.response.JoinRoomResponse; +import com.mzc.secondproject.serverless.domain.chatting.dto.response.RoomListItem; import com.mzc.secondproject.serverless.domain.chatting.dto.response.RoomParticipant; import com.mzc.secondproject.serverless.domain.chatting.exception.ChattingErrorCode; import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; @@ -67,9 +68,12 @@ private APIGatewayProxyResponseEvent createRoom(APIGatewayProxyRequestEvent requ ChatRoom room = commandService.createRoom( dto.getName(), dto.getDescription(), level, maxMembers, isPrivate, dto.getPassword(), userId, dto.getType(), dto.getGameType(), dto.getGameSettings()); - room.setPassword(null); - return ResponseGenerator.created("Room created", room); + // hostNickname 포함하여 응답 + String hostNickname = queryService.getHostNickname(room); + RoomListItem roomItem = RoomListItem.from(room, hostNickname); + + return ResponseGenerator.created("Room created", roomItem); }); } @@ -95,10 +99,16 @@ private APIGatewayProxyResponseEvent getRooms(APIGatewayProxyRequestEvent reques rooms = queryService.filterByJoinedUser(rooms, userId); } - rooms.forEach(room -> room.setPassword(null)); + // hostNickname 포함하여 RoomListItem으로 변환 + List roomItems = rooms.stream() + .map(room -> { + String hostNickname = queryService.getHostNickname(room); + return RoomListItem.from(room, hostNickname); + }) + .toList(); Map result = new HashMap<>(); - result.put("rooms", rooms); + result.put("rooms", roomItems); result.put("nextCursor", roomPage.nextCursor()); result.put("hasMore", roomPage.hasMore()); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSettings.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSettings.java index d7d9b9cf..22e0f226 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSettings.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSettings.java @@ -4,11 +4,13 @@ import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; @Data @Builder @NoArgsConstructor @AllArgsConstructor +@DynamoDbBean public class GameSettings { @Builder.Default private Integer maxRounds = 5; From 0748cf554ff51de393fa9f1a2837e68c8edaf0a2 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Wed, 21 Jan 2026 09:26:59 +0900 Subject: [PATCH 397/528] =?UTF-8?q?fix:=20ChatRoomFunction=EC=97=90=20WEBS?= =?UTF-8?q?OCKET=5FENDPOINT=20=ED=99=98=EA=B2=BD=EB=B3=80=EC=88=98=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B6=8C=ED=95=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - leaveRoom에서 WebSocketBroadcaster 사용을 위한 환경변수 추가 - execute-api:ManageConnections 권한 추가 --- ServerlessFunction/template.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index f345866d..a96c3e27 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -364,11 +364,19 @@ Resources: Description: Handle chat room CRUD operations SnapStart: ApplyOn: PublishedVersions + Environment: + Variables: + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable - DynamoDBReadPolicy: TableName: !Ref UserTable + - Statement: + - Effect: Allow + Action: + - execute-api:ManageConnections + Resource: !Sub arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${WebSocketApi}/* Events: CreateRoom: Type: Api From 3f8c1147a12faa227543f58f2a30a9811e06ea78 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Wed, 21 Jan 2026 09:32:26 +0900 Subject: [PATCH 398/528] =?UTF-8?q?fix:=20WebSocket=20Lambda=20=ED=95=A8?= =?UTF-8?q?=EC=88=98=EB=93=A4=EC=97=90=20WEBSOCKET=5FENDPOINT=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=EB=B3=80=EC=88=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WebSocketConnectFunction에 WEBSOCKET_ENDPOINT 추가 - WebSocketDisconnectFunction에 WEBSOCKET_ENDPOINT 추가 - execute-api:ManageConnections 권한 추가 --- ServerlessFunction/template.yaml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index a96c3e27..7164ffe0 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -234,9 +234,15 @@ Resources: Environment: Variables: WEBSOCKET_CONNECTION_TTL_SECONDS: "600" + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable + - Statement: + - Effect: Allow + Action: + - execute-api:ManageConnections + Resource: !Sub arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${WebSocketApi}/* WebSocketConnectPermission: Type: AWS::Lambda::Permission @@ -255,9 +261,17 @@ Resources: Description: Handle WebSocket $disconnect SnapStart: ApplyOn: PublishedVersions + Environment: + Variables: + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable + - Statement: + - Effect: Allow + Action: + - execute-api:ManageConnections + Resource: !Sub arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${WebSocketApi}/* WebSocketDisconnectPermission: Type: AWS::Lambda::Permission From fbe58a3aa431416aa3e268610ab4982f639e4e3f Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Wed, 21 Jan 2026 09:51:08 +0900 Subject: [PATCH 399/528] =?UTF-8?q?fix:=20Grammar=20WebSocket=20Lambda=20?= =?UTF-8?q?=ED=99=98=EA=B2=BD=20=EB=B3=80=EC=88=98=20=EB=B0=8F=20=EA=B6=8C?= =?UTF-8?q?=ED=95=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GrammarStreamingConnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - GrammarStreamingDisconnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - 두 함수에 execute-api:ManageConnections 권한 추가 --- ServerlessFunction/template.yaml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 7164ffe0..ce4478b1 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -1285,9 +1285,17 @@ Resources: CodeUri: . Handler: com.mzc.secondproject.serverless.domain.grammar.handler.websocket.GrammarStreamingConnectHandler::handleRequest Description: Handle Grammar WebSocket $connect with JWT auth + Environment: + Variables: + WEBSOCKET_ENDPOINT: !Sub "https://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable + - Statement: + - Effect: Allow + Action: + - execute-api:ManageConnections + Resource: !Sub arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${GrammarWebSocketApi}/* GrammarStreamingConnectPermission: Type: AWS::Lambda::Permission @@ -1304,9 +1312,17 @@ Resources: CodeUri: . Handler: com.mzc.secondproject.serverless.domain.grammar.handler.websocket.GrammarStreamingDisconnectHandler::handleRequest Description: Handle Grammar WebSocket $disconnect + Environment: + Variables: + WEBSOCKET_ENDPOINT: !Sub "https://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable + - Statement: + - Effect: Allow + Action: + - execute-api:ManageConnections + Resource: !Sub arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${GrammarWebSocketApi}/* GrammarStreamingDisconnectPermission: Type: AWS::Lambda::Permission From e95315222b0ef782ebf46d868aa9f867cfe2ea40 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Wed, 21 Jan 2026 11:13:31 +0900 Subject: [PATCH 400/528] =?UTF-8?q?feat:=20GSI1SK=20=ED=99=95=EC=9E=A5?= =?UTF-8?q?=EC=84=B1=20=EC=9E=88=EB=8A=94=20=EC=9E=AC=EC=84=A4=EA=B3=84=20?= =?UTF-8?q?=EB=B0=8F=20DB=20=EB=A0=88=EB=B2=A8=20=ED=95=84=ED=84=B0?= =?UTF-8?q?=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 변경 사항 ### GSI1SK 포맷 변경 - 기존: {level}#{createdAt} - 신규: {type}#{gameType}#{status}#{level}#{createdAt} ### 지원 쿼리 패턴 - 전체 방: GSI1PK = "ROOMS" - 게임방만: begins_with(GSI1SK, "GAME#") - 캐치마인드만: begins_with(GSI1SK, "GAME#CATCHMIND#") - 대기중 캐치마인드: begins_with(GSI1SK, "GAME#CATCHMIND#WAITING#") ### 파일 수정 - ChatRoomCommandService.java: 방 생성 시 새 GSI1SK 포맷 적용 - ChatRoomRepository.java: findByFilters() 메서드 추가, updateStatus() 메서드 추가 - ChatRoomQueryService.java: 메모리 필터링 제거, DB 레벨 필터링으로 변경 - GameService.java: 게임 시작/종료 시 방 상태 업데이트 (GSI1SK 포함) ### 마이그레이션 - scripts/migrate-gsi1sk.sh: 기존 데이터 마이그레이션 스크립트 - 37개 기존 방 마이그레이션 완료 --- ServerlessFunction/scripts/migrate-gsi1sk.sh | 81 ++++++++++++++++ .../repository/ChatRoomRepository.java | 95 ++++++++++++++++--- .../service/ChatRoomCommandService.java | 8 +- .../service/ChatRoomQueryService.java | 34 +++---- .../domain/chatting/service/GameService.java | 8 +- 5 files changed, 189 insertions(+), 37 deletions(-) create mode 100755 ServerlessFunction/scripts/migrate-gsi1sk.sh diff --git a/ServerlessFunction/scripts/migrate-gsi1sk.sh b/ServerlessFunction/scripts/migrate-gsi1sk.sh new file mode 100755 index 00000000..8d3648f5 --- /dev/null +++ b/ServerlessFunction/scripts/migrate-gsi1sk.sh @@ -0,0 +1,81 @@ +#!/bin/bash +# GSI1SK 마이그레이션 스크립트 +# 기존: {level}#{createdAt} +# 신규: {type}#{gameType}#{status}#{level}#{createdAt} + +set -e + +AWS_PROFILE="${AWS_PROFILE:-mzc}" +AWS_REGION="ap-northeast-2" +TABLE_NAME="group2-englishstudy-chat" + +echo "=== GSI1SK Migration Script ===" +echo "Profile: $AWS_PROFILE" +echo "Region: $AWS_REGION" +echo "Table: $TABLE_NAME" +echo "" + +# GSI1에서 ROOMS로 시작하는 모든 방 조회 +echo "Fetching rooms from GSI1..." + +ROOMS=$(AWS_PROFILE=$AWS_PROFILE aws dynamodb query \ + --table-name $TABLE_NAME \ + --region $AWS_REGION \ + --index-name GSI1 \ + --key-condition-expression "GSI1PK = :pk" \ + --expression-attribute-values '{":pk": {"S": "ROOMS"}}' \ + --output json 2>/dev/null) + +COUNT=$(echo "$ROOMS" | jq '.Items | length') +echo "Found $COUNT rooms to migrate" +echo "" + +if [ "$COUNT" -eq "0" ]; then + echo "No rooms to migrate. Exiting." + exit 0 +fi + +# 각 방에 대해 GSI1SK 업데이트 +echo "$ROOMS" | jq -c '.Items[]' | while read -r ROOM; do + PK=$(echo "$ROOM" | jq -r '.PK.S') + SK=$(echo "$ROOM" | jq -r '.SK.S') + OLD_GSI1SK=$(echo "$ROOM" | jq -r '.GSI1SK.S') + ROOM_TYPE=$(echo "$ROOM" | jq -r '.type.S // "CHAT"') + GAME_TYPE=$(echo "$ROOM" | jq -r '.gameType.S // "-"') + STATUS=$(echo "$ROOM" | jq -r '.status.S // "WAITING"') + LEVEL=$(echo "$ROOM" | jq -r '.level.S // "beginner"') + CREATED_AT=$(echo "$ROOM" | jq -r '.createdAt.S') + + # gameType이 null이면 "-"로 설정 + if [ "$GAME_TYPE" == "null" ]; then + GAME_TYPE="-" + fi + + # 이미 새 포맷인지 확인 (5개 부분으로 나뉘는지) + PARTS=$(echo "$OLD_GSI1SK" | tr '#' '\n' | wc -l) + if [ "$PARTS" -ge 5 ]; then + echo "SKIP: $PK (already migrated: $OLD_GSI1SK)" + continue + fi + + # 새 GSI1SK 생성 + NEW_GSI1SK="${ROOM_TYPE}#${GAME_TYPE}#${STATUS}#${LEVEL}#${CREATED_AT}" + + echo "Migrating: $PK" + echo " Old GSI1SK: $OLD_GSI1SK" + echo " New GSI1SK: $NEW_GSI1SK" + + # DynamoDB 업데이트 + AWS_PROFILE=$AWS_PROFILE aws dynamodb update-item \ + --table-name $TABLE_NAME \ + --region $AWS_REGION \ + --key "{\"PK\": {\"S\": \"$PK\"}, \"SK\": {\"S\": \"$SK\"}}" \ + --update-expression "SET GSI1SK = :gsi1sk" \ + --expression-attribute-values "{\":gsi1sk\": {\"S\": \"$NEW_GSI1SK\"}}" \ + 2>/dev/null + + echo " Done!" + echo "" +done + +echo "=== Migration Complete ===" diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatRoomRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatRoomRepository.java index 59a00e85..3cadfc31 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatRoomRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatRoomRepository.java @@ -84,35 +84,80 @@ public PaginatedResult findAllWithPagination(int limit, String cursor) } /** - * 레벨별 채팅방 조회 - 최신순, 페이지네이션 지원 + * 필터 조건으로 채팅방 조회 - 최신순, 페이지네이션 지원 + * GSI1SK 포맷: {type}#{gameType}#{status}#{level}#{createdAt} + * + * @param type 방 타입 (CHAT, GAME) - nullable + * @param gameType 게임 타입 (CATCHMIND 등) - nullable + * @param status 방 상태 (WAITING, PLAYING, FINISHED) - nullable + * @param level 레벨 (beginner, intermediate, advanced) - nullable + * @param limit 조회 개수 + * @param cursor 페이지네이션 커서 + * @return 필터링된 채팅방 목록 */ - public PaginatedResult findByLevelWithPagination(String level, int limit, String cursor) { - QueryConditional queryConditional = QueryConditional - .sortBeginsWith(Key.builder() - .partitionValue("ROOMS") - .sortValue(level + "#") - .build()); - + public PaginatedResult findByFilters(String type, String gameType, String status, String level, int limit, String cursor) { + // GSI1SK prefix 생성: {type}#{gameType}#{status}#{level}# + StringBuilder prefixBuilder = new StringBuilder(); + + if (type != null && !type.isEmpty()) { + prefixBuilder.append(type).append("#"); + if (gameType != null && !gameType.isEmpty()) { + prefixBuilder.append(gameType).append("#"); + if (status != null && !status.isEmpty()) { + prefixBuilder.append(status).append("#"); + if (level != null && !level.isEmpty()) { + prefixBuilder.append(level).append("#"); + } + } + } + } + + String prefix = prefixBuilder.toString(); + + QueryConditional queryConditional; + if (prefix.isEmpty()) { + // 필터 없음 - 전체 조회 + queryConditional = QueryConditional.keyEqualTo(Key.builder().partitionValue("ROOMS").build()); + } else { + // prefix로 필터링 + queryConditional = QueryConditional.sortBeginsWith( + Key.builder() + .partitionValue("ROOMS") + .sortValue(prefix) + .build() + ); + } + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() .queryConditional(queryConditional) .scanIndexForward(false) // 최신순 .limit(limit); - + if (cursor != null && !cursor.isEmpty()) { Map exclusiveStartKey = CursorUtil.decode(cursor); if (exclusiveStartKey != null) { requestBuilder.exclusiveStartKey(exclusiveStartKey); } } - + DynamoDbIndex gsi1 = table.index("GSI1"); Page page = gsi1.query(requestBuilder.build()).iterator().next(); List rooms = page.items(); - + String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); - + + logger.info("Query with prefix '{}': found {} rooms", prefix, rooms.size()); return new PaginatedResult<>(rooms, nextCursor); } + + /** + * 레벨별 채팅방 조회 - 최신순, 페이지네이션 지원 + * @deprecated findByFilters 사용 권장 + */ + @Deprecated + public PaginatedResult findByLevelWithPagination(String level, int limit, String cursor) { + return findByFilters(null, null, null, null, limit, cursor); + } public void delete(String roomId) { Key key = Key.builder() @@ -124,6 +169,32 @@ public void delete(String roomId) { logger.info("Deleted room: {}", roomId); } + /** + * 방 상태 변경 시 GSI1SK도 함께 업데이트 + * GSI1SK 포맷: {type}#{gameType}#{status}#{level}#{createdAt} + */ + public void updateStatus(ChatRoom room, String newStatus) { + String oldGsi1sk = room.getGsi1sk(); + String[] parts = oldGsi1sk.split("#", 5); // type, gameType, oldStatus, level, createdAt + + if (parts.length < 5) { + logger.warn("Invalid GSI1SK format: {}", oldGsi1sk); + // 폴백: 새 포맷으로 생성 + String type = room.getType() != null ? room.getType() : "CHAT"; + String gameType = room.getGameType() != null ? room.getGameType() : "-"; + String level = room.getLevel() != null ? room.getLevel() : "beginner"; + String createdAt = room.getCreatedAt(); + room.setGsi1sk(String.format("%s#%s#%s#%s#%s", type, gameType, newStatus, level, createdAt)); + } else { + // 기존 포맷에서 status만 교체 + room.setGsi1sk(String.format("%s#%s#%s#%s#%s", parts[0], parts[1], newStatus, parts[3], parts[4])); + } + + room.setStatus(newStatus); + table.putItem(room); + logger.info("Updated room {} status to {} (GSI1SK: {})", room.getRoomId(), newStatus, room.getGsi1sk()); + } + /** * 채팅방 lastMessageAt 업데이트 (N+1 방지 - UpdateExpression 사용) */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomCommandService.java index d1abff8d..cb84f3cc 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomCommandService.java @@ -51,11 +51,17 @@ public ChatRoom createRoom(String name, String description, String level, Intege String roomId = UUID.randomUUID().toString(); String now = Instant.now().toString(); + // GSI1SK 포맷: {type}#{gameType}#{status}#{level}#{createdAt} + String roomType = type != null ? type : "CHAT"; + String roomGameType = gameType != null ? gameType : "-"; + String roomStatus = "WAITING"; + String gsi1sk = String.format("%s#%s#%s#%s#%s", roomType, roomGameType, roomStatus, level, now); + ChatRoom room = ChatRoom.builder() .pk("ROOM#" + roomId) .sk("METADATA") .gsi1pk("ROOMS") - .gsi1sk(level + "#" + now) + .gsi1sk(gsi1sk) .roomId(roomId) .name(name) .description(description) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomQueryService.java index 06888d42..f75ef802 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomQueryService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomQueryService.java @@ -31,27 +31,21 @@ public Optional getRoom(String roomId) { return roomRepository.findById(roomId); } + /** + * 필터 조건으로 방 목록 조회 (DB 레벨 필터링) + * GSI1SK 포맷: {type}#{gameType}#{status}#{level}#{createdAt} + * + * @param level 레벨 필터 (beginner, intermediate, advanced) + * @param limit 조회 개수 + * @param cursor 페이지네이션 커서 + * @param type 방 타입 (CHAT, GAME) + * @param gameType 게임 타입 (CATCHMIND 등) + * @param status 방 상태 (WAITING, PLAYING, FINISHED) + * @return 필터링된 방 목록 + */ public PaginatedResult getRooms(String level, int limit, String cursor, String type, String gameType, String status) { - PaginatedResult roomPage; - if (level != null && !level.isEmpty()) { - roomPage = roomRepository.findByLevelWithPagination(level, limit, cursor); - } else { - roomPage = roomRepository.findAllWithPagination(limit, cursor); - } - - List rooms = roomPage.items(); - - if (type != null) { - rooms = rooms.stream().filter(r -> type.equalsIgnoreCase(r.getType())).toList(); - } - if (gameType != null) { - rooms = rooms.stream().filter(r -> gameType.equalsIgnoreCase(r.getGameType())).toList(); - } - if (status != null) { - rooms = rooms.stream().filter(r -> status.equalsIgnoreCase(r.getStatus())).toList(); - } - - return new PaginatedResult<>(rooms, roomPage.nextCursor()); + // DB 레벨에서 필터링 (메모리 필터링 제거) + return roomRepository.findByFilters(type, gameType, status, level, limit, cursor); } public List filterByJoinedUser(List rooms, String userId) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java index accce2e4..133e49ff 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java @@ -179,9 +179,9 @@ public GameStartResult startGame(String roomId, String userId) { gameSessionRepository.save(session); } - // ChatRoom에 활성 게임 세션 ID 연결 + // ChatRoom에 활성 게임 세션 ID 연결 및 상태 업데이트 (GSI1SK 포함) room.setActiveGameSessionId(gameSessionId); - chatRoomRepository.save(room); + chatRoomRepository.updateStatus(room, "PLAYING"); // 첫 라운드 기록 생성 (7일 후 자동 삭제) long ttlSeconds = Instant.now().plusSeconds(7 * 24 * 60 * 60).getEpochSecond(); @@ -494,9 +494,9 @@ private CommandResult finishGame(GameSession session, ChatRoom room, String reas // 게임 세션 종료 처리 gameSessionRepository.finishGame(session.getGameSessionId(), currentTime, ttlSeconds); - // ChatRoom에서 활성 게임 세션 참조 제거 + // ChatRoom에서 활성 게임 세션 참조 제거 및 상태 업데이트 (GSI1SK 포함) room.setActiveGameSessionId(null); - chatRoomRepository.save(room); + chatRoomRepository.updateStatus(room, "WAITING"); // 게임 통계 업데이트 및 뱃지 체크 try { From c34d1837a95b9f7c733aa9a935ddca59535eb59f Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Wed, 21 Jan 2026 11:25:04 +0900 Subject: [PATCH 401/528] fix: increase stats query limit to 100 days - Adjust maximum allowable limit for recent stats query from 30 to 100 days to support extended data range responses. --- ServerlessFunction/scripts/migrate-gsi1sk.sh | 81 -------------------- 1 file changed, 81 deletions(-) delete mode 100755 ServerlessFunction/scripts/migrate-gsi1sk.sh diff --git a/ServerlessFunction/scripts/migrate-gsi1sk.sh b/ServerlessFunction/scripts/migrate-gsi1sk.sh deleted file mode 100755 index 8d3648f5..00000000 --- a/ServerlessFunction/scripts/migrate-gsi1sk.sh +++ /dev/null @@ -1,81 +0,0 @@ -#!/bin/bash -# GSI1SK 마이그레이션 스크립트 -# 기존: {level}#{createdAt} -# 신규: {type}#{gameType}#{status}#{level}#{createdAt} - -set -e - -AWS_PROFILE="${AWS_PROFILE:-mzc}" -AWS_REGION="ap-northeast-2" -TABLE_NAME="group2-englishstudy-chat" - -echo "=== GSI1SK Migration Script ===" -echo "Profile: $AWS_PROFILE" -echo "Region: $AWS_REGION" -echo "Table: $TABLE_NAME" -echo "" - -# GSI1에서 ROOMS로 시작하는 모든 방 조회 -echo "Fetching rooms from GSI1..." - -ROOMS=$(AWS_PROFILE=$AWS_PROFILE aws dynamodb query \ - --table-name $TABLE_NAME \ - --region $AWS_REGION \ - --index-name GSI1 \ - --key-condition-expression "GSI1PK = :pk" \ - --expression-attribute-values '{":pk": {"S": "ROOMS"}}' \ - --output json 2>/dev/null) - -COUNT=$(echo "$ROOMS" | jq '.Items | length') -echo "Found $COUNT rooms to migrate" -echo "" - -if [ "$COUNT" -eq "0" ]; then - echo "No rooms to migrate. Exiting." - exit 0 -fi - -# 각 방에 대해 GSI1SK 업데이트 -echo "$ROOMS" | jq -c '.Items[]' | while read -r ROOM; do - PK=$(echo "$ROOM" | jq -r '.PK.S') - SK=$(echo "$ROOM" | jq -r '.SK.S') - OLD_GSI1SK=$(echo "$ROOM" | jq -r '.GSI1SK.S') - ROOM_TYPE=$(echo "$ROOM" | jq -r '.type.S // "CHAT"') - GAME_TYPE=$(echo "$ROOM" | jq -r '.gameType.S // "-"') - STATUS=$(echo "$ROOM" | jq -r '.status.S // "WAITING"') - LEVEL=$(echo "$ROOM" | jq -r '.level.S // "beginner"') - CREATED_AT=$(echo "$ROOM" | jq -r '.createdAt.S') - - # gameType이 null이면 "-"로 설정 - if [ "$GAME_TYPE" == "null" ]; then - GAME_TYPE="-" - fi - - # 이미 새 포맷인지 확인 (5개 부분으로 나뉘는지) - PARTS=$(echo "$OLD_GSI1SK" | tr '#' '\n' | wc -l) - if [ "$PARTS" -ge 5 ]; then - echo "SKIP: $PK (already migrated: $OLD_GSI1SK)" - continue - fi - - # 새 GSI1SK 생성 - NEW_GSI1SK="${ROOM_TYPE}#${GAME_TYPE}#${STATUS}#${LEVEL}#${CREATED_AT}" - - echo "Migrating: $PK" - echo " Old GSI1SK: $OLD_GSI1SK" - echo " New GSI1SK: $NEW_GSI1SK" - - # DynamoDB 업데이트 - AWS_PROFILE=$AWS_PROFILE aws dynamodb update-item \ - --table-name $TABLE_NAME \ - --region $AWS_REGION \ - --key "{\"PK\": {\"S\": \"$PK\"}, \"SK\": {\"S\": \"$SK\"}}" \ - --update-expression "SET GSI1SK = :gsi1sk" \ - --expression-attribute-values "{\":gsi1sk\": {\"S\": \"$NEW_GSI1SK\"}}" \ - 2>/dev/null - - echo " Done!" - echo "" -done - -echo "=== Migration Complete ===" From 7db99c82ea5de2e7c9e230c857b38093d74faa66 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Wed, 21 Jan 2026 12:37:18 +0900 Subject: [PATCH 402/528] feat: improve game round and connection management logic - Added handling for game round timeout (`ROUND_TIMEOUT`) in WebSocketMessageHandler. - Enhanced game start broadcast to include `currentWord` for the drawer only. - Updated ConnectionRepository to remove duplicate user connections in the same room. - Added `currentWordEnglish` to GameSession for better answer verification. - Normalized ChatRoom levels to match database storage format. - Updated template.yaml to include DynamoDBReadPolicy for VocabTable. --- .../domain/chatting/handler/GameHandler.java | 67 ++++++++++++++++--- .../chatting/handler/GameSessionHandler.java | 25 +++++-- .../websocket/WebSocketConnectHandler.java | 7 +- .../websocket/WebSocketMessageHandler.java | 37 ++++++++++ .../domain/chatting/model/GameSession.java | 3 +- .../repository/ConnectionRepository.java | 39 ++++++++--- .../domain/chatting/service/GameService.java | 40 ++++++++--- ServerlessFunction/template.yaml | 2 + 8 files changed, 187 insertions(+), 33 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameHandler.java index f72bd0dc..4acbd169 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameHandler.java @@ -74,10 +74,11 @@ private APIGatewayProxyResponseEvent startGame(APIGatewayProxyRequestEvent reque return ResponseGenerator.fail(ChattingErrorCode.GAME_START_FAILED, result.error()); } - // WebSocket으로 게임 시작 알림 브로드캐스트 + // WebSocket으로 게임 시작 알림 브로드캐스트 (출제자에게 currentWord 포함) broadcastGameStart(roomId, result); - GameStatusResponse response = GameStatusResponse.from(result.session()); + // REST 응답에도 출제자에게 currentWord 포함 + Map response = buildGameStatusResponse(result.session(), userId); return ResponseGenerator.ok("Game started", response); } @@ -111,10 +112,11 @@ private APIGatewayProxyResponseEvent restartGame(APIGatewayProxyRequestEvent req return ResponseGenerator.fail(ChattingErrorCode.GAME_START_FAILED, result.error()); } - // WebSocket으로 게임 시작 알림 브로드캐스트 + // WebSocket으로 게임 시작 알림 브로드캐스트 (출제자에게 currentWord 포함) broadcastGameStart(roomId, result); - GameStatusResponse response = GameStatusResponse.from(result.session()); + // REST 응답에도 출제자에게 currentWord 포함 + Map response = buildGameStatusResponse(result.session(), userId); return ResponseGenerator.ok("Game restarted", response); } @@ -131,11 +133,41 @@ private APIGatewayProxyResponseEvent getGameStatus(APIGatewayProxyRequestEvent r } GameSession session = optSession.get(); - GameStatusResponse response = GameStatusResponse.from(session); + + // 출제자에게만 currentWord 포함 + Map response = buildGameStatusResponse(session, userId); return ResponseGenerator.ok("Game status retrieved", response); } + /** + * 게임 상태 응답 빌드 (출제자에게만 currentWord 포함) + */ + private Map buildGameStatusResponse(GameSession session, String userId) { + Map response = new LinkedHashMap<>(); + response.put("gameStatus", session.getStatus()); + response.put("currentRound", session.getCurrentRound()); + response.put("totalRounds", session.getTotalRounds()); + response.put("currentDrawerId", session.getCurrentDrawerId()); + response.put("roundStartTime", session.getRoundStartTime()); + response.put("serverTime", System.currentTimeMillis()); + response.put("roundDuration", session.getRoundDuration()); + response.put("drawerOrder", session.getDrawerOrder()); + response.put("scores", session.getScores() != null ? session.getScores() : Map.of()); + response.put("hintUsed", session.getHintUsed()); + response.put("correctGuessers", session.getCorrectGuessers()); + + // 출제자에게만 현재 단어 포함 + if (userId != null && userId.equals(session.getCurrentDrawerId())) { + Map currentWord = new HashMap<>(); + currentWord.put("wordId", session.getCurrentWordId()); + currentWord.put("word", session.getCurrentWord()); + response.put("currentWord", currentWord); + } + + return response; + } + /** * GET /rooms/{roomId}/game/scores - 점수 조회 */ @@ -155,6 +187,7 @@ private APIGatewayProxyResponseEvent getScores(APIGatewayProxyRequestEvent reque /** * 게임 시작 브로드캐스트 + * 모든 사용자에게 게임 시작 메시지 전송, 출제자에게는 currentWord 포함 */ private void broadcastGameStart(String roomId, GameService.GameStartResult result) { String messageId = UUID.randomUUID().toString(); @@ -162,6 +195,7 @@ private void broadcastGameStart(String roomId, GameService.GameStartResult resul long serverTime = System.currentTimeMillis(); GameSession session = result.session(); + String drawerId = session.getCurrentDrawerId(); String message = String.format(""" 🎮 게임 시작! @@ -171,8 +205,9 @@ private void broadcastGameStart(String roomId, GameService.GameStartResult resul 출제자: %s """, session.getTotalRounds(), - session.getCurrentDrawerId()); + drawerId); + // 기본 게임 시작 메시지 (모든 사용자용) Map gameStartMessage = new HashMap<>(); gameStartMessage.put("domain", WebSocketMessageHelper.DOMAIN_GAME); gameStartMessage.put("messageId", messageId); @@ -185,17 +220,31 @@ private void broadcastGameStart(String roomId, GameService.GameStartResult resul gameStartMessage.put("gameStatus", session.getStatus()); gameStartMessage.put("currentRound", session.getCurrentRound()); gameStartMessage.put("totalRounds", session.getTotalRounds()); - gameStartMessage.put("currentDrawerId", session.getCurrentDrawerId()); + gameStartMessage.put("currentDrawerId", drawerId); gameStartMessage.put("drawerOrder", result.drawerOrder()); gameStartMessage.put("roundStartTime", session.getRoundStartTime()); gameStartMessage.put("serverTime", serverTime); gameStartMessage.put("roundDuration", session.getRoundDuration()); List connections = connectionRepository.findByRoomId(roomId); + + // 출제자용 메시지 (currentWord 포함) + Map drawerMessage = new HashMap<>(gameStartMessage); + Map currentWord = new HashMap<>(); + currentWord.put("wordId", session.getCurrentWordId()); + currentWord.put("word", session.getCurrentWord()); + drawerMessage.put("currentWord", currentWord); + String broadcastPayload = ResponseGenerator.gson().toJson(gameStartMessage); - broadcaster.broadcast(connections, broadcastPayload); + String drawerPayload = ResponseGenerator.gson().toJson(drawerMessage); + + // 출제자와 일반 사용자에게 다른 메시지 전송 + for (Connection conn : connections) { + String payload = conn.getUserId().equals(drawerId) ? drawerPayload : broadcastPayload; + broadcaster.sendToConnection(conn.getConnectionId(), payload); + } - logger.info("Game start broadcasted: roomId={}", roomId); + logger.info("Game start broadcasted: roomId={}, drawerId={}", roomId, drawerId); } /** diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameSessionHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameSessionHandler.java index 9267e543..acb7b990 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameSessionHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameSessionHandler.java @@ -197,6 +197,7 @@ private Map buildGameSessionResponse(GameSession session, String /** * 게임 시작 브로드캐스트 + * 모든 사용자에게 게임 시작 메시지 전송, 출제자에게는 currentWord 포함 */ private void broadcastGameStart(String roomId, GameService.GameStartResult result) { String messageId = UUID.randomUUID().toString(); @@ -204,6 +205,7 @@ private void broadcastGameStart(String roomId, GameService.GameStartResult resul long serverTime = System.currentTimeMillis(); GameSession session = result.session(); + String drawerId = session.getCurrentDrawerId(); String message = String.format(""" 🎮 게임 시작! @@ -213,8 +215,9 @@ private void broadcastGameStart(String roomId, GameService.GameStartResult resul 출제자: %s """, session.getTotalRounds(), - session.getCurrentDrawerId()); + drawerId); + // 기본 게임 시작 메시지 (모든 사용자용) Map gameStartMessage = new HashMap<>(); gameStartMessage.put("domain", WebSocketMessageHelper.DOMAIN_GAME); gameStartMessage.put("messageId", messageId); @@ -227,17 +230,31 @@ private void broadcastGameStart(String roomId, GameService.GameStartResult resul gameStartMessage.put("gameStatus", session.getStatus()); gameStartMessage.put("currentRound", session.getCurrentRound()); gameStartMessage.put("totalRounds", session.getTotalRounds()); - gameStartMessage.put("currentDrawerId", session.getCurrentDrawerId()); + gameStartMessage.put("currentDrawerId", drawerId); gameStartMessage.put("drawerOrder", result.drawerOrder()); gameStartMessage.put("roundStartTime", session.getRoundStartTime()); gameStartMessage.put("serverTime", serverTime); gameStartMessage.put("roundDuration", session.getRoundDuration()); List connections = connectionRepository.findByRoomId(roomId); + + // 출제자용 메시지 (currentWord 포함) + Map drawerMessage = new HashMap<>(gameStartMessage); + Map currentWord = new HashMap<>(); + currentWord.put("wordId", session.getCurrentWordId()); + currentWord.put("word", session.getCurrentWord()); + drawerMessage.put("currentWord", currentWord); + String broadcastPayload = ResponseGenerator.gson().toJson(gameStartMessage); - broadcaster.broadcast(connections, broadcastPayload); + String drawerPayload = ResponseGenerator.gson().toJson(drawerMessage); + + // 출제자와 일반 사용자에게 다른 메시지 전송 + for (Connection conn : connections) { + String payload = conn.getUserId().equals(drawerId) ? drawerPayload : broadcastPayload; + broadcaster.sendToConnection(conn.getConnectionId(), payload); + } - logger.info("Game start broadcasted: roomId={}, sessionId={}", roomId, session.getGameSessionId()); + logger.info("Game start broadcasted: roomId={}, sessionId={}, drawerId={}", roomId, session.getGameSessionId(), drawerId); } /** diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketConnectHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketConnectHandler.java index 88cac6de..0588185e 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketConnectHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketConnectHandler.java @@ -56,10 +56,13 @@ public Map handleRequest(Map event, Context cont RoomToken token = optToken.get(); String userId = token.getUserId(); String roomId = token.getRoomId(); - + + // 같은 방에서 기존 연결 삭제 (새로고침 시 중복 연결 방지) + connectionRepository.deleteUserConnectionsInRoom(userId, roomId); + String now = Instant.now().toString(); long ttl = Instant.now().plusSeconds(WebSocketConfig.connectionTtlSeconds()).getEpochSecond(); - + Connection connection = Connection.builder() .pk("CONN#" + connectionId) .sk("METADATA") diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java index c511c6ba..fadd7296 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java @@ -78,6 +78,7 @@ public Map handleRequest(Map event, Context cont // 메시지 타입별 처리 return switch (messageType.toUpperCase()) { case "DRAWING", "DRAWING_CLEAR" -> handleDrawingMessage(connectionId, payload, messageType); + case "ROUND_TIMEOUT" -> handleRoundTimeout(payload); default -> handleRegularMessage(connectionId, payload, messageType); }; @@ -319,6 +320,42 @@ private void handleAllCorrect(String roomId) { handleCommandResult(endResult, roomId, "SYSTEM"); } } + + /** + * 라운드 타임아웃 처리 (프론트엔드에서 타이머 만료 시 호출) + * - 실제 라운드 시간이 만료되었는지 서버에서 검증 + * - 검증 통과 시 라운드 종료 및 ROUND_END 브로드캐스트 + */ + private Map handleRoundTimeout(MessagePayload payload) { + String roomId = payload.roomId; + logger.info("Round timeout request: roomId={}, userId={}", roomId, payload.userId); + + // 활성 게임 세션 조회 + GameSession session = gameSessionRepository.findActiveByRoomId(roomId).orElse(null); + if (session == null) { + logger.warn("No active game session for round timeout: roomId={}", roomId); + return WebSocketEventUtil.ok("No active game"); + } + + // 라운드 시간이 실제로 만료되었는지 검증 (5초 여유) + long elapsedMs = System.currentTimeMillis() - session.getRoundStartTime(); + int roundDurationMs = (session.getRoundDuration() != null ? session.getRoundDuration() : 60) * 1000; + + if (elapsedMs < roundDurationMs - 5000) { + logger.warn("Round timeout rejected - time not expired: elapsedMs={}, roundDurationMs={}", + elapsedMs, roundDurationMs); + return WebSocketEventUtil.ok("Round time not expired yet"); + } + + // 라운드 종료 처리 + CommandResult endResult = gameService.endRound(roomId, "TIMEOUT"); + if (endResult != null && endResult.success()) { + handleCommandResult(endResult, roomId, "SYSTEM"); + logger.info("Round ended due to timeout: roomId={}", roomId); + } + + return WebSocketEventUtil.ok("Round timeout processed"); + } /** * 명령어 처리 결과를 브로드캐스트 diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSession.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSession.java index 7f4ee1aa..403d7805 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSession.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSession.java @@ -40,7 +40,8 @@ public class GameSession { private Integer totalRounds; private String currentDrawerId; private String currentWordId; - private String currentWord; + private String currentWord; // 한국어 뜻 + private String currentWordEnglish; // 영어 단어 (정답 체크용) private Long roundStartTime; private Integer roundDuration; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ConnectionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ConnectionRepository.java index bf59cdb8..d079d3d7 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ConnectionRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ConnectionRepository.java @@ -56,20 +56,23 @@ public Optional findByConnectionId(String connectionId) { /** * 채팅방의 모든 연결 조회 (브로드캐스트용) - * GSI1: ROOM#{roomId}로 조회 + * GSI1: ROOM#{roomId}로 조회, GSI1SK가 CONN#으로 시작하는 항목만 반환 + * (GSI1에 GameSession도 포함되어 있으므로 CONN# prefix로 필터링) */ public List findByRoomId(String roomId) { + // GSI1SK가 CONN#으로 시작하는 항목만 조회 QueryConditional queryConditional = QueryConditional - .keyEqualTo(Key.builder() + .sortBeginsWith(Key.builder() .partitionValue("ROOM#" + roomId) + .sortValue("CONN#") .build()); - + QueryEnhancedRequest request = QueryEnhancedRequest.builder() .queryConditional(queryConditional) .build(); - + DynamoDbIndex gsi1 = table.index("GSI1"); - + return gsi1.query(request).stream() .flatMap(page -> page.items().stream()) .collect(Collectors.toList()); @@ -84,15 +87,35 @@ public List findByUserId(String userId) { .keyEqualTo(Key.builder() .partitionValue("USER#" + userId) .build()); - + QueryEnhancedRequest request = QueryEnhancedRequest.builder() .queryConditional(queryConditional) .build(); - + DynamoDbIndex gsi2 = table.index("GSI2"); - + return gsi2.query(request).stream() .flatMap(page -> page.items().stream()) .collect(Collectors.toList()); } + + /** + * 같은 방에서 사용자의 기존 연결 삭제 (중복 연결 방지) + * 새로고침 등으로 인한 중복 연결을 정리 + */ + public void deleteUserConnectionsInRoom(String userId, String roomId) { + List userConnections = findByUserId(userId); + + int deletedCount = 0; + for (Connection conn : userConnections) { + if (roomId.equals(conn.getRoomId())) { + delete(conn.getConnectionId()); + deletedCount++; + } + } + + if (deletedCount > 0) { + logger.info("Deleted {} existing connections for user {} in room {}", deletedCount, userId, roomId); + } + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java index 133e49ff..3ca83cff 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java @@ -159,6 +159,7 @@ public GameStartResult startGame(String roomId, String userId) { .currentDrawerId(firstDrawer) .currentWordId(firstWord.getWordId()) .currentWord(firstWord.getKorean()) + .currentWordEnglish(firstWord.getEnglish()) .roundStartTime(currentTime) .roundDuration(GameConfig.roundTimeLimit()) .scores(new HashMap<>()) @@ -259,9 +260,10 @@ public AnswerCheckResult checkAnswer(String roomId, String userId, String answer return AnswerCheckResult.alreadyGuessedCorrect(); } - // 정답 체크 - String currentWord = session.getCurrentWord(); - if (!isCorrectAnswer(answer, currentWord)) { + // 정답 체크 (한국어 또는 영어 둘 다 허용) + String koreanWord = session.getCurrentWord(); + String englishWord = session.getCurrentWordEnglish(); + if (!isCorrectAnswer(answer, koreanWord, englishWord)) { return AnswerCheckResult.wrongAnswer(); } @@ -411,6 +413,7 @@ public CommandResult endRound(GameSession session, ChatRoom room, String reason) session.setCurrentDrawerId(nextDrawer); session.setCurrentWordId(nextWord.getWordId()); session.setCurrentWord(nextWord.getKorean()); + session.setCurrentWordEnglish(nextWord.getEnglish()); session.setRoundStartTime(currentTime); session.setHintUsed(false); session.setCorrectGuessers(new ArrayList<>()); @@ -584,24 +587,43 @@ private String selectNextDrawer(List drawerOrder, Set connectedU /** * 랜덤 단어 추출 + * VocabTable은 LEVEL#BEGINNER 형식(대문자)으로 저장되어 있으므로 + * ChatRoom의 level(소문자)을 대문자로 변환 */ private List getRandomWords(String level, int count) { - PaginatedResult result = wordRepository.findByLevelWithPagination(level, 50, null); + // ChatRoom.level은 소문자(beginner), VocabTable GSI1PK는 대문자(BEGINNER) + String normalizedLevel = level != null ? level.toUpperCase() : "BEGINNER"; + PaginatedResult result = wordRepository.findByLevelWithPagination(normalizedLevel, 50, null); List words = new ArrayList<>(result.items()); Collections.shuffle(words); return words.stream().limit(count).collect(Collectors.toList()); } /** - * 정답 체크 로직 + * 정답 체크 로직 (한국어 또는 영어 둘 다 허용) */ - private boolean isCorrectAnswer(String input, String answer) { - if (input == null || answer == null) return false; + private boolean isCorrectAnswer(String input, String koreanAnswer, String englishAnswer) { + if (input == null) return false; String normalizedInput = input.trim().toLowerCase().replace(" ", ""); - String normalizedAnswer = answer.trim().toLowerCase().replace(" ", ""); - return normalizedInput.equals(normalizedAnswer); + // 한국어 정답 체크 + if (koreanAnswer != null) { + String normalizedKorean = koreanAnswer.trim().toLowerCase().replace(" ", ""); + if (normalizedInput.equals(normalizedKorean)) { + return true; + } + } + + // 영어 정답 체크 + if (englishAnswer != null) { + String normalizedEnglish = englishAnswer.trim().toLowerCase().replace(" ", ""); + if (normalizedInput.equals(normalizedEnglish)) { + return true; + } + } + + return false; } /** diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index ce4478b1..09663db9 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -458,6 +458,8 @@ Resources: Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable + - DynamoDBReadPolicy: + TableName: !Ref VocabTable - Statement: - Effect: Allow Action: From 7cdb9b3c493e5a2db826be6ea5287c5e6ee6376a Mon Sep 17 00:00:00 2001 From: DDING JOO Date: Wed, 21 Jan 2026 15:16:56 +0900 Subject: [PATCH 403/528] =?UTF-8?q?refactor:=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC=20=EB=B0=8F=20=EB=AF=B8=EC=82=AC=EC=9A=A9=20?= =?UTF-8?q?=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=A0=9C=EA=B1=B0=20(#459)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: remove unused WebSocketResponseUtil * refactor: add AutoCloseable to WebSocketBroadcaster * refactor: remove unused TestService * refactor: remove unused WordService * refactor: remove unused DailyStudyService * refactor: remove unused UserWordService * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * fix: resolve N+1 query in StatsService --- .../serverless/common/dto/ErrorInfo.java | 16 +- .../common/exception/CommonErrorCode.java | 2 +- .../common/exception/CommonException.java | 4 +- .../common/exception/DomainErrorCode.java | 4 +- .../common/exception/ErrorCode.java | 8 +- .../common/exception/ServerlessException.java | 4 +- .../common/router/AuthenticatedHandler.java | 2 +- .../common/router/HandlerRouter.java | 10 +- .../serverless/common/router/Route.java | 2 +- .../common/util/WebSocketBroadcaster.java | 15 +- .../common/util/WebSocketResponseUtil.java | 40 --- .../common/validation/BeanValidator.java | 17 +- .../chatting/exception/ChattingErrorCode.java | 2 +- .../chatting/exception/ChattingException.java | 4 +- .../GrammarStreamingConnectHandler.java | 2 +- .../websocket/GrammarStreamingHandler.java | 80 +++-- .../stats/handler/ScheduledStatsHandler.java | 92 ++++-- .../domain/stats/model/UserStats.java | 2 +- .../user/handler/PostConfirmationHandler.java | 4 +- .../exception/VocabularyErrorCode.java | 2 +- .../exception/VocabularyException.java | 4 +- .../vocabulary/service/DailyStudyService.java | 166 ----------- .../vocabulary/service/StatsService.java | 41 ++- .../vocabulary/service/TestService.java | 276 ------------------ .../vocabulary/service/UserWordService.java | 223 -------------- .../vocabulary/service/WordService.java | 123 -------- 26 files changed, 200 insertions(+), 945 deletions(-) delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketResponseUtil.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyService.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestService.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordService.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordService.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/ErrorInfo.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/ErrorInfo.java index 41feeb2b..329fc230 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/ErrorInfo.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/ErrorInfo.java @@ -8,17 +8,17 @@ /** * RFC 7807 스타일 에러 정보 - *

+ * * Problem Details for HTTP APIs (RFC 7807) 표준을 참고한 에러 응답 형식입니다. - *

+ * * 응답 예시: * { - * "code": "VOCABULARY.WORD_001", - * "message": "단어를 찾을 수 없습니다", - * "status": 404, - * "details": { - * "wordId": "abc-123" - * } + * "code": "VOCABULARY.WORD_001", + * "message": "단어를 찾을 수 없습니다", + * "status": 404, + * "details": { + * "wordId": "abc-123" + * } * } * * @param code 에러 코드 (예: AUTH_001, VOCABULARY.WORD_001) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/CommonErrorCode.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/CommonErrorCode.java index 126790d9..d1276375 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/CommonErrorCode.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/CommonErrorCode.java @@ -2,7 +2,7 @@ /** * 공통/시스템 에러 코드 - *

+ * * 도메인에 종속되지 않는 공통 에러 코드를 정의합니다. * - 인증/인가 에러 (AUTH_XXX) * - 검증 에러 (VALIDATION_XXX) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/CommonException.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/CommonException.java index a6f67ed0..497ff8eb 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/CommonException.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/CommonException.java @@ -2,10 +2,10 @@ /** * 공통/시스템 예외 클래스 - *

+ * * 도메인에 종속되지 않는 공통 예외를 처리합니다. * 정적 팩토리 메서드를 통해 가독성 높은 예외 생성을 지원합니다. - *

+ * * 사용 예시: * throw CommonException.unauthorized(); * throw CommonException.notFound("사용자"); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/DomainErrorCode.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/DomainErrorCode.java index 83ddb2ef..43a454e0 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/DomainErrorCode.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/DomainErrorCode.java @@ -2,10 +2,10 @@ /** * 도메인별 에러 코드 인터페이스 - *

+ * * 각 도메인(Vocabulary, Chatting 등)의 비즈니스 로직 관련 에러 코드가 구현하는 인터페이스입니다. * ErrorCode를 확장하여 도메인 식별 기능을 추가합니다. - *

+ * * 구현체: * - VocabularyErrorCode - 단어 학습 도메인 * - ChattingErrorCode - 채팅 도메인 diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/ErrorCode.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/ErrorCode.java index 35261fa0..5eabbd0a 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/ErrorCode.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/ErrorCode.java @@ -2,16 +2,16 @@ /** * 에러 코드 표준 인터페이스 (Sealed Interface) - *

+ * * 모든 에러 코드 enum이 구현해야 하는 표준 계약을 정의합니다. * Sealed interface를 사용하여 허용된 구현체만 존재하도록 제한합니다. - *

+ * * 계층 구조: * ErrorCode (sealed) * ├── CommonErrorCode (시스템/공통 에러) * └── DomainErrorCode (non-sealed) - 도메인별 에러 - * ├── VocabularyErrorCode - * └── ChattingErrorCode + * ├── VocabularyErrorCode + * └── ChattingErrorCode */ public sealed interface ErrorCode permits CommonErrorCode, DomainErrorCode { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/ServerlessException.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/ServerlessException.java index f95ea8ab..c9f578c0 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/ServerlessException.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/ServerlessException.java @@ -6,10 +6,10 @@ /** * 서버리스 애플리케이션 기본 예외 클래스 - *

+ * * 모든 비즈니스 예외의 추상 기반 클래스입니다. * ErrorCode를 통해 표준화된 에러 정보를 제공합니다. - *

+ * * 사용 예시: * - CommonException: 공통/시스템 예외 * - VocabularyException: 단어 학습 도메인 예외 diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/AuthenticatedHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/AuthenticatedHandler.java index b49dd275..51071fd5 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/AuthenticatedHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/AuthenticatedHandler.java @@ -5,7 +5,7 @@ /** * Cognito 인증이 필요한 요청 핸들러 - *

+ * * userId가 자동으로 추출되어 전달됩니다. */ @FunctionalInterface diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/HandlerRouter.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/HandlerRouter.java index 92d6dc1c..a7178643 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/HandlerRouter.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/HandlerRouter.java @@ -18,16 +18,14 @@ /** * Lambda Handler를 위한 HTTP 라우터 - *

+ * * 선언적 라우팅 + 자동 Path/Query 파라미터 검증 제공 - *

+ * * 사용 예시: - *

* new HandlerRouter().addRoutes( - * Route.get("/rooms/{roomId}", this::getRoom), // roomId 자동 검증 - * Route.delete("/rooms/{roomId}", this::deleteRoom).requireQueryParams("userId") // roomId + userId 검증 + * Route.get("/rooms/{roomId}", this::getRoom), + * Route.delete("/rooms/{roomId}", this::deleteRoom).requireQueryParams("userId") * ); - * */ public class HandlerRouter { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/Route.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/Route.java index 44a46f6f..a9d9f746 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/Route.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/Route.java @@ -13,7 +13,7 @@ /** * HTTP 라우트 정의 - *

+ * * Path 패턴에서 자동으로 필수 파라미터를 추출합니다. * 예: "/rooms/{roomId}/messages/{messageId}" → ["roomId", "messageId"] * diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketBroadcaster.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketBroadcaster.java index f438c239..516687a0 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketBroadcaster.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketBroadcaster.java @@ -17,7 +17,7 @@ /** * WebSocket 연결들에게 메시지를 브로드캐스트하는 유틸리티 */ -public class WebSocketBroadcaster { +public class WebSocketBroadcaster implements AutoCloseable { private static final Logger logger = LoggerFactory.getLogger(WebSocketBroadcaster.class); @@ -79,7 +79,18 @@ public List broadcast(List connections, String message) { logger.info("Broadcast completed: total={}, failed={}", connections.size(), failedConnections.size()); - + return failedConnections; } + + @Override + public void close() { + try { + if (apiClient != null) { + apiClient.close(); + } + } catch (Exception e) { + logger.warn("Failed to close ApiGatewayManagementApiClient: {}", e.getMessage()); + } + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketResponseUtil.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketResponseUtil.java deleted file mode 100644 index 6c14e542..00000000 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketResponseUtil.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.mzc.secondproject.serverless.common.util; - -import java.util.Map; - -/** - * WebSocket API Gateway 응답 생성 유틸리티 - */ -public final class WebSocketResponseUtil { - - private WebSocketResponseUtil() { - } - - public static Map ok(String message) { - return Map.of("statusCode", 200, "body", message); - } - - public static Map created(String message) { - return Map.of("statusCode", 201, "body", message); - } - - public static Map badRequest(String message) { - return Map.of("statusCode", 400, "body", message); - } - - public static Map unauthorized(String message) { - return Map.of("statusCode", 401, "body", message); - } - - public static Map forbidden(String message) { - return Map.of("statusCode", 403, "body", message); - } - - public static Map serverError(String message) { - return Map.of("statusCode", 500, "body", message); - } - - public static Map response(int statusCode, String message) { - return Map.of("statusCode", statusCode, "body", message); - } -} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/validation/BeanValidator.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/validation/BeanValidator.java index 27c204d4..9f98d4a1 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/validation/BeanValidator.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/validation/BeanValidator.java @@ -15,20 +15,17 @@ /** * Jakarta Bean Validation 기반 검증 유틸리티 - *

+ * * DTO에 선언된 @NotNull, @NotEmpty 등의 어노테이션을 검증합니다. - *

+ * * 사용 예시: - *

* CreateRoomRequest req = ResponseGenerator.gson().fromJson(body, CreateRoomRequest.class); - *

* return BeanValidator.validate(req) - * .map(error -> ResponseGenerator.fail(CommonErrorCode.REQUIRED_FIELD_MISSING, error)) - * .orElseGet(() -> { - * // 비즈니스 로직 - * return ResponseGenerator.ok("Success", result); - * }); - * + * .map(error -> ResponseGenerator.fail(CommonErrorCode.REQUIRED_FIELD_MISSING, error)) + * .orElseGet(() -> { + * // 비즈니스 로직 + * return ResponseGenerator.ok("Success", result); + * }); */ public final class BeanValidator { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCode.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCode.java index ad599b53..be3394c0 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCode.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCode.java @@ -4,7 +4,7 @@ /** * 채팅 도메인 에러 코드 - *

+ * * 채팅방(Room), 메시지(Message), 참여자(Participant) 관련 에러 코드를 정의합니다. */ public enum ChattingErrorCode implements DomainErrorCode { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingException.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingException.java index 16b51450..a2da178a 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingException.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingException.java @@ -4,9 +4,9 @@ /** * 채팅 도메인 예외 클래스 - *

+ * * 정적 팩토리 메서드를 통해 가독성 높은 예외 생성을 지원합니다. - *

+ * * 사용 예시: * throw ChattingException.roomNotFound(roomId); * throw ChattingException.notRoomMember(userId, roomId); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/websocket/GrammarStreamingConnectHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/websocket/GrammarStreamingConnectHandler.java index 5a6e8202..4e77dd27 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/websocket/GrammarStreamingConnectHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/websocket/GrammarStreamingConnectHandler.java @@ -16,7 +16,7 @@ /** * Grammar Streaming WebSocket $connect 핸들러 * JWT 토큰 검증 후 연결 정보를 DynamoDB에 저장 - *

+ * * 연결 방법: * wss://{api-id}.execute-api.{region}.amazonaws.com/{stage}?token={jwt} */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/websocket/GrammarStreamingHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/websocket/GrammarStreamingHandler.java index 41f1a4d4..c1d6f89e 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/websocket/GrammarStreamingHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/websocket/GrammarStreamingHandler.java @@ -28,7 +28,7 @@ /** * Grammar Streaming WebSocket 핸들러 * Bedrock 스트리밍 응답을 실시간으로 클라이언트에 전송 - *

+ * * 인증: $connect에서 JWT 검증 후 저장된 연결 정보에서 userId 조회 */ public class GrammarStreamingHandler implements RequestHandler, Map> { @@ -85,35 +85,52 @@ public Map handleRequest(Map event, Context cont private void processStreamingConversation(String connectionId, String endpoint, String userId, StreamingRequest request) { ApiGatewayManagementApiClient apiClient = createApiClient(endpoint); - - // 서비스에 스트리밍 처리 위임 (userId는 JWT 인증에서 가져온 값 사용) - conversationService.chatStreaming( - request.sessionId(), - request.message(), - userId, - request.level(), - // 세션 생성 콜백 - sessionId -> sendEvent(apiClient, connectionId, new StreamingEvent.StartEvent(sessionId)), - // 스트리밍 콜백 - new StreamingCallback() { - @Override - public void onToken(String token) { - sendEvent(apiClient, connectionId, new StreamingEvent.TokenEvent(token)); - } - - @Override - public void onComplete(ConversationResponse response) { - sendEvent(apiClient, connectionId, StreamingEvent.CompleteEvent.from(response)); - logger.info("Streaming completed for session: {}", response.getSessionId()); - } - - @Override - public void onError(Throwable error) { - logger.error("Streaming error: {}", error.getMessage(), error); - sendEvent(apiClient, connectionId, new StreamingEvent.ErrorEvent(error.getMessage())); + + try { + // 서비스에 스트리밍 처리 위임 (userId는 JWT 인증에서 가져온 값 사용) + conversationService.chatStreaming( + request.sessionId(), + request.message(), + userId, + request.level(), + // 세션 생성 콜백 + sessionId -> sendEvent(apiClient, connectionId, new StreamingEvent.StartEvent(sessionId)), + // 스트리밍 콜백 + new StreamingCallback() { + @Override + public void onToken(String token) { + sendEvent(apiClient, connectionId, new StreamingEvent.TokenEvent(token)); + } + + @Override + public void onComplete(ConversationResponse response) { + sendEvent(apiClient, connectionId, StreamingEvent.CompleteEvent.from(response)); + logger.info("Streaming completed for session: {}", response.getSessionId()); + closeApiClient(apiClient); + } + + @Override + public void onError(Throwable error) { + logger.error("Streaming error: {}", error.getMessage(), error); + sendEvent(apiClient, connectionId, new StreamingEvent.ErrorEvent(error.getMessage())); + closeApiClient(apiClient); + } } - } - ); + ); + } catch (Exception e) { + closeApiClient(apiClient); + throw e; + } + } + + private void closeApiClient(ApiGatewayManagementApiClient apiClient) { + try { + if (apiClient != null) { + apiClient.close(); + } + } catch (Exception e) { + logger.warn("Failed to close ApiGatewayManagementApiClient: {}", e.getMessage()); + } } private void sendEvent(ApiGatewayManagementApiClient apiClient, String connectionId, StreamingEvent event) { @@ -162,8 +179,9 @@ private boolean sendToConnection(ApiGatewayManagementApiClient apiClient, String } private Map sendError(String connectionId, String endpoint, String message) { - ApiGatewayManagementApiClient apiClient = createApiClient(endpoint); - sendEvent(apiClient, connectionId, new StreamingEvent.ErrorEvent(message)); + try (ApiGatewayManagementApiClient apiClient = createApiClient(endpoint)) { + sendEvent(apiClient, connectionId, new StreamingEvent.ErrorEvent(message)); + } return WebSocketEventUtil.badRequest(message); } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/ScheduledStatsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/ScheduledStatsHandler.java index 550dd691..82909fd0 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/ScheduledStatsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/ScheduledStatsHandler.java @@ -9,17 +9,25 @@ import org.slf4j.LoggerFactory; import java.time.LocalDate; +import java.util.List; +import java.util.Map; + +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; +import software.amazon.awssdk.services.dynamodb.model.ScanRequest; +import software.amazon.awssdk.services.dynamodb.model.ScanResponse; +import com.mzc.secondproject.serverless.common.config.AwsClients; /** * EventBridge Scheduler Handler * 매일 자정에 실행되어 Streak 리셋만 수행 - *

+ * * 단어 학습 통계는 Write-through 방식으로 markWordLearned에서 직접 업데이트 */ public class ScheduledStatsHandler implements RequestHandler { - + private static final Logger logger = LoggerFactory.getLogger(ScheduledStatsHandler.class); private static final String TABLE_NAME = EnvConfig.getRequired("VOCAB_TABLE_NAME"); + private static final int BATCH_SIZE = 25; private final UserStatsRepository userStatsRepository; @@ -45,32 +53,70 @@ public String handleRequest(ScheduledEvent event, Context context) { /** * Streak 체크 및 리셋 - * GSI를 사용하여 Query로 처리 (Scan 대신) - *

- * 어제 학습하지 않은 사용자 중 streak이 있는 사용자만 리셋 + * TOTAL 통계 레코드 중 lastStudyDate가 어제가 아니고 currentStreak > 0인 사용자의 streak을 리셋 */ private int checkAndResetStreaks(String yesterday) { logger.info("Checking streaks for date: {}", yesterday); - - // GSI1을 사용하여 TOTAL 통계 레코드만 조회 - // GSI1PK = "STATS#TOTAL" 으로 설계하면 Query 가능 - // 현재는 GSI가 없으므로 개별 사용자별로 처리하는 방식 사용 - - // 실제로는 lastStudyDate가 어제가 아닌 사용자를 찾아야 함 - // 하지만 현재 구조상 효율적인 방법은: - // 1. 활성 사용자 목록 관리 (별도 테이블/인덱스) - // 2. 또는 클라이언트에서 streak 조회 시 계산 - - // 현재는 간단하게 구현: DailyStudy가 없는 사용자의 streak을 리셋 - // 이는 학습을 한 번이라도 한 사용자 대상 - + int resetCount = 0; - - // Note: 실제 운영에서는 활성 사용자 목록을 별도로 관리하거나 - // GSI를 lastStudyDate로 만들어 Query 하는 것이 효율적 - // 현재는 비용 최적화를 위해 이 로직은 클라이언트에서 처리하도록 변경 가능 - + Map lastEvaluatedKey = null; + + do { + // SK = "STATS#TOTAL"인 레코드만 스캔 (currentStreak > 0 필터) + ScanRequest.Builder scanBuilder = ScanRequest.builder() + .tableName(TABLE_NAME) + .filterExpression("SK = :sk AND currentStreak > :zero AND (attribute_not_exists(lastStudyDate) OR lastStudyDate <> :yesterday)") + .expressionAttributeValues(Map.of( + ":sk", AttributeValue.builder().s("STATS#TOTAL").build(), + ":zero", AttributeValue.builder().n("0").build(), + ":yesterday", AttributeValue.builder().s(yesterday).build() + )) + .limit(BATCH_SIZE); + + if (lastEvaluatedKey != null) { + scanBuilder.exclusiveStartKey(lastEvaluatedKey); + } + + ScanResponse response = AwsClients.dynamoDb().scan(scanBuilder.build()); + List> items = response.items(); + + for (Map item : items) { + String pk = item.get("PK").s(); + // PK 형식: "USERSTATS#{userId}" 에서 userId 추출 + if (pk != null && pk.startsWith("USERSTATS#")) { + String userId = pk.substring("USERSTATS#".length()); + try { + resetUserStreak(userId); + resetCount++; + logger.debug("Reset streak for user: {}", userId); + } catch (Exception e) { + logger.warn("Failed to reset streak for user {}: {}", userId, e.getMessage()); + } + } + } + + lastEvaluatedKey = response.lastEvaluatedKey(); + } while (lastEvaluatedKey != null && !lastEvaluatedKey.isEmpty()); + logger.info("Streak reset completed: {} users processed", resetCount); return resetCount; } + + /** + * 사용자의 currentStreak을 0으로 리셋 (longestStreak은 유지) + */ + private void resetUserStreak(String userId) { + userStatsRepository.updateStreak(userId, 0, + getCurrentLongestStreak(userId), + LocalDate.now().minusDays(1).toString()); + } + + /** + * 사용자의 현재 longestStreak 조회 + */ + private int getCurrentLongestStreak(String userId) { + return userStatsRepository.findTotalStats(userId) + .map(stats -> stats.getLongestStreak() != null ? stats.getLongestStreak() : 0) + .orElse(0); + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/model/UserStats.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/model/UserStats.java index cc25634c..fe64840a 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/model/UserStats.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/model/UserStats.java @@ -13,7 +13,7 @@ * 사용자 학습 통계 * PK: USER#{userId}#STATS * SK: DAILY#{date} / WEEKLY#{year}-W{week} / MONTHLY#{year}-{month} / TOTAL - *

+ * * Write-time Aggregation 패턴: * - 이벤트 발생 시 Atomic Counter로 증분 업데이트 * - 조회 시 Scan 없이 O(1) GetItem diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PostConfirmationHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PostConfirmationHandler.java index edf0ba5e..31f528f7 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PostConfirmationHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PostConfirmationHandler.java @@ -13,8 +13,8 @@ /** * Cognito Post Confirmation 트리거 핸들러 - *

- * - 사용자 이메일 인증을 완료한 직후 DB에 데이터 생성 + * + * 사용자 이메일 인증을 완료한 직후 DB에 데이터 생성 */ public class PostConfirmationHandler implements RequestHandler { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/exception/VocabularyErrorCode.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/exception/VocabularyErrorCode.java index dd576d63..3c04d79d 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/exception/VocabularyErrorCode.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/exception/VocabularyErrorCode.java @@ -4,7 +4,7 @@ /** * 단어 학습 도메인 에러 코드 - *

+ * * 단어(Word), 사용자 단어(UserWord), 일일 학습(DailyStudy) 관련 에러 코드를 정의합니다. */ public enum VocabularyErrorCode implements DomainErrorCode { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/exception/VocabularyException.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/exception/VocabularyException.java index 7ab6adea..3deec811 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/exception/VocabularyException.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/exception/VocabularyException.java @@ -4,9 +4,9 @@ /** * 단어 학습 도메인 예외 클래스 - *

+ * * 정적 팩토리 메서드를 통해 가독성 높은 예외 생성을 지원합니다. - *

+ * * 사용 예시: * throw VocabularyException.wordNotFound(wordId); * throw VocabularyException.invalidDifficulty("INVALID"); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyService.java deleted file mode 100644 index 2493a2ea..00000000 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyService.java +++ /dev/null @@ -1,166 +0,0 @@ -package com.mzc.secondproject.serverless.domain.vocabulary.service; - -import com.mzc.secondproject.serverless.common.dto.PaginatedResult; -import com.mzc.secondproject.serverless.domain.vocabulary.config.VocabularyConfig; -import com.mzc.secondproject.serverless.domain.vocabulary.model.DailyStudy; -import com.mzc.secondproject.serverless.domain.vocabulary.model.UserWord; -import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; -import com.mzc.secondproject.serverless.domain.vocabulary.repository.DailyStudyRepository; -import com.mzc.secondproject.serverless.domain.vocabulary.repository.UserWordRepository; -import com.mzc.secondproject.serverless.domain.vocabulary.repository.WordRepository; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.time.Instant; -import java.time.LocalDate; -import java.util.*; -import java.util.stream.Collectors; - -public class DailyStudyService { - - private static final Logger logger = LoggerFactory.getLogger(DailyStudyService.class); - - private final DailyStudyRepository dailyStudyRepository; - private final UserWordRepository userWordRepository; - private final WordRepository wordRepository; - - public DailyStudyService() { - this.dailyStudyRepository = new DailyStudyRepository(); - this.userWordRepository = new UserWordRepository(); - this.wordRepository = new WordRepository(); - } - - public DailyStudyResult getDailyWords(String userId, String level) { - String today = LocalDate.now().toString(); - - Optional optDailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today); - - DailyStudy dailyStudy; - if (optDailyStudy.isPresent()) { - dailyStudy = optDailyStudy.get(); - } else { - if (level == null || level.isEmpty()) { - throw new IllegalArgumentException("level is required for first daily study (BEGINNER, INTERMEDIATE, ADVANCED)"); - } - if (!level.equals("BEGINNER") && !level.equals("INTERMEDIATE") && !level.equals("ADVANCED")) { - throw new IllegalArgumentException("Invalid level. Must be BEGINNER, INTERMEDIATE, or ADVANCED"); - } - dailyStudy = createDailyStudy(userId, today, level); - } - - List newWords = getWordDetails(dailyStudy.getNewWordIds()); - List reviewWords = getWordDetails(dailyStudy.getReviewWordIds()); - Map progress = calculateProgress(dailyStudy); - - return new DailyStudyResult(dailyStudy, newWords, reviewWords, progress); - } - - public Map markWordLearned(String userId, String wordId) { - String today = LocalDate.now().toString(); - - Optional optDailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today); - if (optDailyStudy.isEmpty()) { - throw new IllegalStateException("Daily study not found"); - } - - DailyStudy dailyStudy = optDailyStudy.get(); - - if (dailyStudy.getLearnedWordIds() != null && dailyStudy.getLearnedWordIds().contains(wordId)) { - return calculateProgress(dailyStudy); - } - - dailyStudyRepository.addLearnedWord(userId, today, wordId); - - DailyStudy updatedDailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today).orElse(dailyStudy); - - if (updatedDailyStudy.getLearnedCount() >= updatedDailyStudy.getTotalWords()) { - updatedDailyStudy.setIsCompleted(true); - dailyStudyRepository.save(updatedDailyStudy); - } - - logger.info("Marked word as learned: userId={}, wordId={}", userId, wordId); - return calculateProgress(updatedDailyStudy); - } - - private DailyStudy createDailyStudy(String userId, String date, String level) { - String now = Instant.now().toString(); - - PaginatedResult reviewPage = userWordRepository.findReviewDueWords(userId, date, VocabularyConfig.reviewWordsCount(), null); - List reviewWordIds = reviewPage.items().stream() - .map(UserWord::getWordId) - .collect(Collectors.toList()); - - List newWordIds = getNewWordsForUser(userId, level, VocabularyConfig.newWordsCount()); - - DailyStudy dailyStudy = DailyStudy.builder() - .pk("DAILY#" + userId) - .sk("DATE#" + date) - .gsi1pk("DAILY#ALL") - .gsi1sk("DATE#" + date) - .userId(userId) - .date(date) - .newWordIds(newWordIds) - .reviewWordIds(reviewWordIds) - .learnedWordIds(new ArrayList<>()) - .totalWords(newWordIds.size() + reviewWordIds.size()) - .learnedCount(0) - .isCompleted(false) - .createdAt(now) - .updatedAt(now) - .build(); - - dailyStudyRepository.save(dailyStudy); - logger.info("Created daily study for user: {}, date: {}", userId, date); - - return dailyStudy; - } - - private List getNewWordsForUser(String userId, String level, int count) { - PaginatedResult userWordPage = userWordRepository.findByUserIdWithPagination(userId, 1000, null); - List learnedWordIds = userWordPage.items().stream() - .map(UserWord::getWordId) - .collect(Collectors.toList()); - - List newWordIds = new ArrayList<>(); - String lastEvaluatedKey = null; - - do { - PaginatedResult wordPage = wordRepository.findByLevelWithPagination(level, count * 2, lastEvaluatedKey); - for (Word word : wordPage.items()) { - if (!learnedWordIds.contains(word.getWordId()) && !newWordIds.contains(word.getWordId())) { - newWordIds.add(word.getWordId()); - if (newWordIds.size() >= count) break; - } - } - lastEvaluatedKey = wordPage.nextCursor(); - } while (newWordIds.size() < count && lastEvaluatedKey != null); - - logger.info("Selected {} new words for user {} at level {}", newWordIds.size(), userId, level); - return newWordIds; - } - - private List getWordDetails(List wordIds) { - if (wordIds == null || wordIds.isEmpty()) { - return new ArrayList<>(); - } - return wordRepository.findByIds(wordIds); - } - - private Map calculateProgress(DailyStudy dailyStudy) { - Map progress = new HashMap<>(); - int total = dailyStudy.getTotalWords(); - int learned = dailyStudy.getLearnedCount(); - - progress.put("total", total); - progress.put("learned", learned); - progress.put("remaining", total - learned); - progress.put("percentage", total > 0 ? (learned * 100.0 / total) : 0); - progress.put("isCompleted", dailyStudy.getIsCompleted()); - - return progress; - } - - public record DailyStudyResult(DailyStudy dailyStudy, List newWords, List reviewWords, - Map progress) { - } -} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/StatsService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/StatsService.java index c3664156..3e49865d 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/StatsService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/StatsService.java @@ -12,7 +12,9 @@ import org.slf4j.LoggerFactory; import java.util.*; +import java.util.function.Function; import java.util.stream.Collectors; +import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; public class StatsService { @@ -111,7 +113,7 @@ public Map getWeaknessAnalysis(String userId) { allUserWords.addAll(page.items()); cursor = page.nextCursor(); } while (cursor != null); - + if (allUserWords.isEmpty()) { Map emptyResult = new HashMap<>(); emptyResult.put("weakestWords", List.of()); @@ -120,7 +122,16 @@ public Map getWeaknessAnalysis(String userId) { emptyResult.put("suggestions", List.of()); return emptyResult; } - + + // 배치 조회로 N+1 문제 해결: 모든 wordId를 수집하여 한 번에 조회 + List wordIds = allUserWords.stream() + .map(UserWord::getWordId) + .distinct() + .collect(Collectors.toList()); + List words = wordRepository.findByIds(wordIds); + Map wordMap = words.stream() + .collect(Collectors.toMap(Word::getWordId, Function.identity(), (a, b) -> a)); + List> weakestWords = allUserWords.stream() .filter(uw -> uw.getIncorrectCount() != null && uw.getIncorrectCount() > 0) .sorted(Comparator.comparingInt(UserWord::getIncorrectCount).reversed()) @@ -131,34 +142,36 @@ public Map getWeaknessAnalysis(String userId) { wordInfo.put("incorrectCount", uw.getIncorrectCount()); wordInfo.put("correctCount", uw.getCorrectCount()); wordInfo.put("status", uw.getStatus()); - - wordRepository.findById(uw.getWordId()).ifPresent(word -> { + + Word word = wordMap.get(uw.getWordId()); + if (word != null) { wordInfo.put("english", word.getEnglish()); wordInfo.put("korean", word.getKorean()); wordInfo.put("level", word.getLevel()); wordInfo.put("category", word.getCategory()); - }); - + } + int total = (uw.getCorrectCount() != null ? uw.getCorrectCount() : 0) + (uw.getIncorrectCount() != null ? uw.getIncorrectCount() : 0); wordInfo.put("accuracy", total > 0 ? (uw.getCorrectCount() != null ? uw.getCorrectCount() * 100.0 / total : 0) : 0); - + return wordInfo; }) .collect(Collectors.toList()); - + Map> categoryAnalysis = new HashMap<>(); Map> levelAnalysis = new HashMap<>(); - + for (UserWord uw : allUserWords) { - wordRepository.findById(uw.getWordId()).ifPresent(word -> { + Word word = wordMap.get(uw.getWordId()); + if (word != null) { String category = word.getCategory(); String level = word.getLevel(); - + int correct = uw.getCorrectCount() != null ? uw.getCorrectCount() : 0; int incorrect = uw.getIncorrectCount() != null ? uw.getIncorrectCount() : 0; - + categoryAnalysis.computeIfAbsent(category, k -> { Map stats = new HashMap<>(); stats.put("totalCorrect", 0); @@ -170,7 +183,7 @@ public Map getWeaknessAnalysis(String userId) { catStats.put("totalCorrect", (Integer) catStats.get("totalCorrect") + correct); catStats.put("totalIncorrect", (Integer) catStats.get("totalIncorrect") + incorrect); catStats.put("wordCount", (Integer) catStats.get("wordCount") + 1); - + levelAnalysis.computeIfAbsent(level, k -> { Map stats = new HashMap<>(); stats.put("totalCorrect", 0); @@ -182,7 +195,7 @@ public Map getWeaknessAnalysis(String userId) { lvlStats.put("totalCorrect", (Integer) lvlStats.get("totalCorrect") + correct); lvlStats.put("totalIncorrect", (Integer) lvlStats.get("totalIncorrect") + incorrect); lvlStats.put("wordCount", (Integer) lvlStats.get("wordCount") + 1); - }); + } } categoryAnalysis.values().forEach(stats -> { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestService.java deleted file mode 100644 index 9851b274..00000000 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestService.java +++ /dev/null @@ -1,276 +0,0 @@ -package com.mzc.secondproject.serverless.domain.vocabulary.service; - -import com.mzc.secondproject.serverless.common.config.AwsClients; -import com.mzc.secondproject.serverless.common.config.EnvConfig; -import com.mzc.secondproject.serverless.common.dto.PaginatedResult; -import com.mzc.secondproject.serverless.common.util.ResponseGenerator; -import com.mzc.secondproject.serverless.domain.vocabulary.model.DailyStudy; -import com.mzc.secondproject.serverless.domain.vocabulary.model.TestResult; -import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; -import com.mzc.secondproject.serverless.domain.vocabulary.repository.DailyStudyRepository; -import com.mzc.secondproject.serverless.domain.vocabulary.repository.TestResultRepository; -import com.mzc.secondproject.serverless.domain.vocabulary.repository.WordRepository; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import software.amazon.awssdk.services.sns.model.PublishRequest; - -import java.time.Instant; -import java.time.LocalDate; -import java.util.*; -import java.util.stream.Collectors; - -public class TestService { - - private static final Logger logger = LoggerFactory.getLogger(TestService.class); - private static final String TEST_RESULT_TOPIC_ARN = EnvConfig.getRequired("TEST_RESULT_TOPIC_ARN"); - - private final TestResultRepository testResultRepository; - private final DailyStudyRepository dailyStudyRepository; - private final WordRepository wordRepository; - - public TestService() { - this.testResultRepository = new TestResultRepository(); - this.dailyStudyRepository = new DailyStudyRepository(); - this.wordRepository = new WordRepository(); - } - - public StartTestResult startTest(String userId, String testType) { - String today = LocalDate.now().toString(); - - Optional optDailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today); - if (optDailyStudy.isEmpty()) { - throw new IllegalStateException("No daily study found for today"); - } - - DailyStudy dailyStudy = optDailyStudy.get(); - List allWordIds = new ArrayList<>(); - if (dailyStudy.getNewWordIds() != null) allWordIds.addAll(dailyStudy.getNewWordIds()); - if (dailyStudy.getReviewWordIds() != null) allWordIds.addAll(dailyStudy.getReviewWordIds()); - - if (allWordIds.isEmpty()) { - throw new IllegalStateException("No words to test"); - } - - List words = wordRepository.findByIds(allWordIds); - - Map> wordsByLevel = words.stream() - .collect(Collectors.groupingBy(Word::getLevel)); - - Map> distractorsByLevel = new HashMap<>(); - for (String level : wordsByLevel.keySet()) { - List distractors = getDistractorsForLevel(level, allWordIds); - distractorsByLevel.put(level, distractors); - } - - Random random = new Random(); - List> questions = new ArrayList<>(); - for (Word word : words) { - Map question = new HashMap<>(); - question.put("wordId", word.getWordId()); - question.put("english", word.getEnglish()); - question.put("example", word.getExample()); - - List options = generateOptions(word, wordsByLevel, distractorsByLevel, random); - question.put("options", options); - - questions.add(question); - } - - String testId = UUID.randomUUID().toString(); - String now = Instant.now().toString(); - - logger.info("Started test: userId={}, testId={}, questions={}", userId, testId, questions.size()); - - return new StartTestResult(testId, testType, questions, questions.size(), now); - } - - public SubmitTestResult submitTest(String userId, String testId, String testType, - List> answers, String startedAt) { - // 1. 답안 채점 - GradingResult gradingResult = gradeAnswers(answers); - - // 2. 테스트 결과 저장 - saveTestResult(userId, testId, testType, gradingResult, startedAt); - - // 3. SNS 알림 발행 - publishTestResultToSns(userId, gradingResult.results()); - - logger.info("Test submitted: userId={}, testId={}, successRate={}%", - userId, testId, gradingResult.successRate()); - - return new SubmitTestResult( - testId, testType, gradingResult.totalQuestions(), - gradingResult.correctCount(), gradingResult.incorrectCount(), - gradingResult.successRate(), gradingResult.results() - ); - } - - private GradingResult gradeAnswers(List> answers) { - List wordIds = answers.stream() - .map(a -> (String) a.get("wordId")) - .collect(Collectors.toList()); - - Map wordMap = wordRepository.findByIds(wordIds).stream() - .collect(Collectors.toMap(Word::getWordId, w -> w)); - - int correctCount = 0; - int incorrectCount = 0; - List incorrectWordIds = new ArrayList<>(); - List> results = new ArrayList<>(); - - for (Map answer : answers) { - String wordId = (String) answer.get("wordId"); - String userAnswer = (String) answer.get("answer"); - Word word = wordMap.get(wordId); - - if (word == null) continue; - - boolean isCorrect = isAnswerCorrect(userAnswer, word.getKorean()); - results.add(buildResultItem(word, userAnswer, isCorrect)); - - if (isCorrect) { - correctCount++; - } else { - incorrectCount++; - incorrectWordIds.add(wordId); - } - } - - int totalQuestions = answers.size(); - double successRate = totalQuestions > 0 ? (correctCount * 100.0 / totalQuestions) : 0; - - return new GradingResult(wordIds, correctCount, incorrectCount, incorrectWordIds, - totalQuestions, successRate, results); - } - - private boolean isAnswerCorrect(String userAnswer, String correctAnswer) { - return userAnswer != null - && !userAnswer.isBlank() - && correctAnswer.trim().equalsIgnoreCase(userAnswer.trim()); - } - - private Map buildResultItem(Word word, String userAnswer, boolean isCorrect) { - Map resultItem = new HashMap<>(); - resultItem.put("wordId", word.getWordId()); - resultItem.put("english", word.getEnglish()); - resultItem.put("correctAnswer", word.getKorean()); - resultItem.put("userAnswer", userAnswer != null ? userAnswer : ""); - resultItem.put("isCorrect", isCorrect); - return resultItem; - } - - private void saveTestResult(String userId, String testId, String testType, - GradingResult gradingResult, String startedAt) { - String now = Instant.now().toString(); - String today = LocalDate.now().toString(); - - TestResult testResult = TestResult.builder() - .pk("TEST#" + userId) - .sk("RESULT#" + now) - .gsi1pk("TEST#ALL") - .gsi1sk("DATE#" + today) - .testId(testId) - .userId(userId) - .testType(testType) - .totalQuestions(gradingResult.totalQuestions()) - .correctAnswers(gradingResult.correctCount()) - .incorrectAnswers(gradingResult.incorrectCount()) - .successRate(gradingResult.successRate()) - .incorrectWordIds(gradingResult.incorrectWordIds()) - .startedAt(startedAt) - .completedAt(now) - .build(); - - testResultRepository.save(testResult); - } - - private record GradingResult( - List wordIds, - int correctCount, - int incorrectCount, - List incorrectWordIds, - int totalQuestions, - double successRate, - List> results - ) {} - - public PaginatedResult getTestResults(String userId, int limit, String cursor) { - return testResultRepository.findByUserIdWithPagination(userId, limit, cursor); - } - - private List getDistractorsForLevel(String level, List excludeWordIds) { - PaginatedResult wordPage = wordRepository.findByLevelWithPagination(level, 50, null); - return wordPage.items().stream() - .filter(w -> !excludeWordIds.contains(w.getWordId())) - .map(Word::getKorean) - .collect(Collectors.toList()); - } - - private List generateOptions(Word correctWord, Map> wordsByLevel, - Map> distractorsByLevel, Random random) { - List options = new ArrayList<>(); - String correctAnswer = correctWord.getKorean(); - options.add(correctAnswer); - - String level = correctWord.getLevel(); - - List sameLevelOptions = wordsByLevel.getOrDefault(level, new ArrayList<>()).stream() - .filter(w -> !w.getWordId().equals(correctWord.getWordId())) - .map(Word::getKorean) - .collect(Collectors.toList()); - - List additionalDistractors = distractorsByLevel.getOrDefault(level, new ArrayList<>()); - - List allDistractors = new ArrayList<>(); - allDistractors.addAll(sameLevelOptions); - allDistractors.addAll(additionalDistractors); - - allDistractors = allDistractors.stream() - .filter(d -> !d.equals(correctAnswer)) - .distinct() - .collect(Collectors.toList()); - - Collections.shuffle(allDistractors, random); - int distractorCount = Math.min(3, allDistractors.size()); - for (int i = 0; i < distractorCount; i++) { - options.add(allDistractors.get(i)); - } - - Collections.shuffle(options, random); - return options; - } - - private void publishTestResultToSns(String userId, List> results) { - if (TEST_RESULT_TOPIC_ARN == null || TEST_RESULT_TOPIC_ARN.isEmpty()) { - logger.warn("TEST_RESULT_TOPIC_ARN is not configured, skipping SNS publish"); - return; - } - - try { - Map message = new HashMap<>(); - message.put("userId", userId); - message.put("results", results); - - String messageJson = ResponseGenerator.gson().toJson(message); - - PublishRequest publishRequest = PublishRequest.builder() - .topicArn(TEST_RESULT_TOPIC_ARN) - .message(messageJson) - .build(); - - AwsClients.sns().publish(publishRequest); - logger.info("Published test result to SNS for user: {}", userId); - } catch (Exception e) { - logger.error("Failed to publish test result to SNS for user: {}", userId, e); - } - } - - public record StartTestResult(String testId, String testType, List> questions, - int totalQuestions, String startedAt) { - } - - public record SubmitTestResult(String testId, String testType, int totalQuestions, - int correctCount, int incorrectCount, double successRate, - List> results) { - } -} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordService.java deleted file mode 100644 index c0bf7c3f..00000000 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordService.java +++ /dev/null @@ -1,223 +0,0 @@ -package com.mzc.secondproject.serverless.domain.vocabulary.service; - -import com.mzc.secondproject.serverless.common.dto.PaginatedResult; -import com.mzc.secondproject.serverless.domain.vocabulary.model.UserWord; -import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; -import com.mzc.secondproject.serverless.domain.vocabulary.repository.UserWordRepository; -import com.mzc.secondproject.serverless.domain.vocabulary.repository.WordRepository; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.time.Instant; -import java.time.LocalDate; -import java.util.*; -import java.util.stream.Collectors; - -public class UserWordService { - - private static final Logger logger = LoggerFactory.getLogger(UserWordService.class); - - private final UserWordRepository userWordRepository; - private final WordRepository wordRepository; - - public UserWordService() { - this.userWordRepository = new UserWordRepository(); - this.wordRepository = new WordRepository(); - } - - public UserWordsResult getUserWords(String userId, String status, String bookmarked, - String incorrectOnly, int limit, String cursor) { - PaginatedResult userWordPage; - - if ("true".equalsIgnoreCase(bookmarked)) { - userWordPage = userWordRepository.findBookmarkedWords(userId, limit, cursor); - } else if ("true".equalsIgnoreCase(incorrectOnly)) { - userWordPage = userWordRepository.findIncorrectWords(userId, limit, cursor); - } else if (status != null && !status.isEmpty()) { - userWordPage = userWordRepository.findByUserIdAndStatus(userId, status, limit, cursor); - } else { - userWordPage = userWordRepository.findByUserIdWithPagination(userId, limit, cursor); - } - - List> enrichedUserWords = enrichWithWordInfo(userWordPage.items()); - - return new UserWordsResult(enrichedUserWords, userWordPage.nextCursor(), userWordPage.hasMore()); - } - - public Optional getUserWord(String userId, String wordId) { - return userWordRepository.findByUserIdAndWordId(userId, wordId); - } - - public UserWord updateUserWord(String userId, String wordId, boolean isCorrect) { - Optional optUserWord = userWordRepository.findByUserIdAndWordId(userId, wordId); - UserWord userWord; - String now = Instant.now().toString(); - - if (optUserWord.isEmpty()) { - userWord = UserWord.builder() - .pk("USER#" + userId) - .sk("WORD#" + wordId) - .gsi1pk("USER#" + userId + "#REVIEW") - .gsi2pk("USER#" + userId + "#STATUS") - .userId(userId) - .wordId(wordId) - .status("NEW") - .interval(1) - .easeFactor(2.5) - .repetitions(0) - .correctCount(0) - .incorrectCount(0) - .createdAt(now) - .build(); - } else { - userWord = optUserWord.get(); - } - - applySpacedRepetition(userWord, isCorrect); - userWord.setUpdatedAt(now); - userWord.setLastReviewedAt(now); - - userWord.setGsi1sk("DATE#" + userWord.getNextReviewAt()); - userWord.setGsi2sk("STATUS#" + userWord.getStatus()); - - userWordRepository.save(userWord); - - logger.info("Updated user word: userId={}, wordId={}, isCorrect={}", userId, wordId, isCorrect); - return userWord; - } - - public UserWord updateUserWordTag(String userId, String wordId, Boolean bookmarked, - Boolean favorite, String difficulty) { - Optional optUserWord = userWordRepository.findByUserIdAndWordId(userId, wordId); - UserWord userWord; - String now = Instant.now().toString(); - - if (optUserWord.isEmpty()) { - userWord = UserWord.builder() - .pk("USER#" + userId) - .sk("WORD#" + wordId) - .gsi1pk("USER#" + userId + "#REVIEW") - .gsi2pk("USER#" + userId + "#STATUS") - .gsi2sk("STATUS#NEW") - .userId(userId) - .wordId(wordId) - .status("NEW") - .interval(1) - .easeFactor(2.5) - .repetitions(0) - .correctCount(0) - .incorrectCount(0) - .bookmarked(false) - .favorite(false) - .createdAt(now) - .build(); - } else { - userWord = optUserWord.get(); - } - - if (bookmarked != null) { - userWord.setBookmarked(bookmarked); - } - if (favorite != null) { - userWord.setFavorite(favorite); - } - if (difficulty != null) { - if (!difficulty.equals("EASY") && !difficulty.equals("NORMAL") && !difficulty.equals("HARD")) { - throw new IllegalArgumentException("difficulty must be EASY, NORMAL, or HARD"); - } - userWord.setDifficulty(difficulty); - } - - userWord.setUpdatedAt(now); - userWordRepository.save(userWord); - - logger.info("Updated user word tag: userId={}, wordId={}", userId, wordId); - return userWord; - } - - private void applySpacedRepetition(UserWord userWord, boolean isCorrect) { - if (isCorrect) { - userWord.setCorrectCount(userWord.getCorrectCount() + 1); - userWord.setRepetitions(userWord.getRepetitions() + 1); - - if (userWord.getRepetitions() == 1) { - userWord.setInterval(1); - } else if (userWord.getRepetitions() == 2) { - userWord.setInterval(6); - } else { - int newInterval = (int) Math.round(userWord.getInterval() * userWord.getEaseFactor()); - userWord.setInterval(newInterval); - } - - if (userWord.getRepetitions() >= 5) { - userWord.setStatus("MASTERED"); - } else if (userWord.getRepetitions() >= 2) { - userWord.setStatus("REVIEWING"); - } else { - userWord.setStatus("LEARNING"); - } - } else { - userWord.setIncorrectCount(userWord.getIncorrectCount() + 1); - userWord.setRepetitions(0); - userWord.setInterval(1); - userWord.setStatus("LEARNING"); - - double newEaseFactor = userWord.getEaseFactor() - 0.2; - userWord.setEaseFactor(Math.max(1.3, newEaseFactor)); - } - - LocalDate nextReview = LocalDate.now().plusDays(userWord.getInterval()); - userWord.setNextReviewAt(nextReview.toString()); - } - - private List> enrichWithWordInfo(List userWords) { - if (userWords == null || userWords.isEmpty()) { - return new ArrayList<>(); - } - - List wordIds = userWords.stream() - .map(UserWord::getWordId) - .collect(Collectors.toList()); - - List words = wordRepository.findByIds(wordIds); - - Map wordMap = words.stream() - .collect(Collectors.toMap(Word::getWordId, w -> w, (w1, w2) -> w1)); - - List> enrichedList = new ArrayList<>(); - for (UserWord userWord : userWords) { - Map enriched = new HashMap<>(); - - enriched.put("wordId", userWord.getWordId()); - enriched.put("userId", userWord.getUserId()); - enriched.put("status", userWord.getStatus()); - enriched.put("correctCount", userWord.getCorrectCount()); - enriched.put("incorrectCount", userWord.getIncorrectCount()); - enriched.put("bookmarked", userWord.getBookmarked()); - enriched.put("favorite", userWord.getFavorite()); - enriched.put("difficulty", userWord.getDifficulty()); - enriched.put("nextReviewAt", userWord.getNextReviewAt()); - enriched.put("lastReviewedAt", userWord.getLastReviewedAt()); - enriched.put("repetitions", userWord.getRepetitions()); - enriched.put("interval", userWord.getInterval()); - - Word word = wordMap.get(userWord.getWordId()); - if (word != null) { - enriched.put("english", word.getEnglish()); - enriched.put("korean", word.getKorean()); - enriched.put("level", word.getLevel()); - enriched.put("category", word.getCategory()); - enriched.put("example", word.getExample()); - enriched.put("maleVoiceKey", word.getMaleVoiceKey()); - enriched.put("femaleVoiceKey", word.getFemaleVoiceKey()); - } - - enrichedList.add(enriched); - } - - return enrichedList; - } - - public record UserWordsResult(List> userWords, String nextCursor, boolean hasMore) { - } -} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordService.java deleted file mode 100644 index bfea0b71..00000000 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordService.java +++ /dev/null @@ -1,123 +0,0 @@ -package com.mzc.secondproject.serverless.domain.vocabulary.service; - -import com.mzc.secondproject.serverless.common.dto.PaginatedResult; -import com.mzc.secondproject.serverless.domain.vocabulary.factory.WordFactory; -import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; -import com.mzc.secondproject.serverless.domain.vocabulary.repository.WordRepository; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.List; -import java.util.Map; -import java.util.Optional; - -public class WordService { - - private static final Logger logger = LoggerFactory.getLogger(WordService.class); - - private final WordRepository wordRepository; - private final WordFactory wordFactory; - - /** - * 기본 생성자 (Lambda에서 사용) - */ - public WordService() { - this(new WordRepository(), new WordFactory()); - } - - /** - * 의존성 주입 생성자 (테스트 용이성) - */ - public WordService(WordRepository wordRepository, WordFactory wordFactory) { - this.wordRepository = wordRepository; - this.wordFactory = wordFactory; - } - - public Word createWord(String english, String korean, String example, String level, String category) { - Word word = wordFactory.create(english, korean, example, level, category); - wordRepository.save(word); - logger.info("Created word: {}", word.getWordId()); - return word; - } - - public Optional getWord(String wordId) { - return wordRepository.findById(wordId); - } - - public PaginatedResult getWords(String level, String category, int limit, String cursor) { - if (level != null && !level.isEmpty()) { - return wordRepository.findByLevelWithPagination(level, limit, cursor); - } else if (category != null && !category.isEmpty()) { - return wordRepository.findByCategoryWithPagination(category, limit, cursor); - } - return wordRepository.findByLevelWithPagination("BEGINNER", limit, cursor); - } - - public Word updateWord(String wordId, Map updates) { - Optional optWord = wordRepository.findById(wordId); - if (optWord.isEmpty()) { - throw new IllegalArgumentException("Word not found"); - } - - Word word = optWord.get(); - wordFactory.updateFields( - word, - (String) updates.get("english"), - (String) updates.get("korean"), - (String) updates.get("example"), - (String) updates.get("level"), - (String) updates.get("category") - ); - - wordRepository.save(word); - logger.info("Updated word: {}", wordId); - return word; - } - - public void deleteWord(String wordId) { - Optional optWord = wordRepository.findById(wordId); - if (optWord.isEmpty()) { - throw new IllegalArgumentException("Word not found"); - } - - wordRepository.delete(wordId); - logger.info("Deleted word: {}", wordId); - } - - public BatchResult createWordsBatch(List> wordsList) { - int successCount = 0; - int failCount = 0; - - for (Map wordData : wordsList) { - try { - String english = (String) wordData.get("english"); - String korean = (String) wordData.get("korean"); - String example = (String) wordData.get("example"); - String level = (String) wordData.get("level"); - String category = (String) wordData.get("category"); - - if (english == null || korean == null) { - failCount++; - continue; - } - - Word word = wordFactory.create(english, korean, example, level, category); - wordRepository.save(word); - successCount++; - } catch (Exception e) { - logger.error("Failed to create word", e); - failCount++; - } - } - - logger.info("Batch created {} words, failed {}", successCount, failCount); - return new BatchResult(successCount, failCount, wordsList.size()); - } - - public PaginatedResult searchWords(String query, int limit, String cursor) { - return wordRepository.searchByKeyword(query, limit, cursor); - } - - public record BatchResult(int successCount, int failCount, int totalRequested) { - } -} From 90c0135c814e074789cc51c2c77b8c4499d9532f Mon Sep 17 00:00:00 2001 From: DDING JOO Date: Wed, 21 Jan 2026 15:42:53 +0900 Subject: [PATCH 404/528] =?UTF-8?q?refactor(all):=20DI=20=ED=8C=A8?= =?UTF-8?q?=ED=84=B4=20=EB=B0=8F=20=EC=A0=84=EB=9E=B5=20=ED=8C=A8=ED=84=B4?= =?UTF-8?q?=20=EC=A0=81=EC=9A=A9=20(#461)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Repository, Service, Handler에 DI 생성자 추가 (테스트 용이성) - BadgeService에 Strategy 패턴 적용 (뱃지 조건 검증 로직 분리) --- .../domain/badge/handler/BadgeHandler.java | 12 +++- .../badge/repository/BadgeRepository.java | 15 +++- .../domain/badge/service/BadgeService.java | 68 +++++++------------ .../badge/strategy/AccuracyStrategy.java | 34 ++++++++++ .../strategy/BadgeConditionStrategy.java | 25 +++++++ .../BadgeConditionStrategyFactory.java | 40 +++++++++++ .../badge/strategy/FirstStudyStrategy.java | 25 +++++++ .../badge/strategy/GamesPlayedStrategy.java | 25 +++++++ .../badge/strategy/GamesWonStrategy.java | 25 +++++++ .../domain/badge/strategy/NoOpStrategy.java | 32 +++++++++ .../badge/strategy/PerfectDrawsStrategy.java | 25 +++++++ .../badge/strategy/QuickGuessesStrategy.java | 25 +++++++ .../domain/badge/strategy/StreakStrategy.java | 25 +++++++ .../strategy/TestsCompletedStrategy.java | 25 +++++++ .../badge/strategy/WordsLearnedStrategy.java | 32 +++++++++ .../chatting/handler/ChatMessageHandler.java | 14 +++- .../chatting/handler/ChatRoomHandler.java | 14 +++- .../chatting/handler/ChatVoiceHandler.java | 14 +++- .../handler/GameAutoCloseHandler.java | 17 ++++- .../domain/chatting/handler/GameHandler.java | 19 ++++-- .../chatting/handler/GameSessionHandler.java | 19 ++++-- .../repository/ChatMessageRepository.java | 15 +++- .../repository/ChatRoomRepository.java | 15 +++- .../repository/ConnectionRepository.java | 15 +++- .../repository/GameRoundRepository.java | 12 +++- .../repository/GameSessionRepository.java | 13 +++- .../repository/RoomTokenRepository.java | 15 +++- .../chatting/service/ChatMessageService.java | 14 +++- .../service/ChatRoomCommandService.java | 25 +++++-- .../service/ChatRoomQueryService.java | 14 +++- .../chatting/service/CommandService.java | 18 ++++- .../chatting/service/GameSchedulerClient.java | 18 ++++- .../chatting/service/GameStatsService.java | 20 ++++-- .../chatting/service/RoomTokenService.java | 14 +++- .../grammar/handler/GrammarHandler.java | 17 ++++- .../GrammarConnectionRepository.java | 18 +++-- .../repository/GrammarSessionRepository.java | 17 ++++- .../stats/handler/ScheduledStatsHandler.java | 12 +++- .../stats/handler/StatsStreamHandler.java | 14 +++- .../stats/handler/UserStatsHandler.java | 14 +++- .../stats/repository/UserStatsRepository.java | 15 +++- .../domain/stats/service/StatsService.java | 14 +++- .../vocabulary/handler/DailyStudyHandler.java | 14 +++- .../vocabulary/handler/StatisticsHandler.java | 12 +++- .../vocabulary/handler/StatsHandler.java | 12 +++- .../vocabulary/handler/TestHandler.java | 14 +++- .../vocabulary/handler/UserWordHandler.java | 14 +++- .../vocabulary/handler/VoiceHandler.java | 14 +++- .../vocabulary/handler/WordGroupHandler.java | 14 +++- .../vocabulary/handler/WordHandler.java | 14 +++- .../repository/DailyStudyRepository.java | 15 +++- .../repository/TestResultRepository.java | 15 +++- .../repository/UserWordRepository.java | 14 +++- .../repository/WordGroupRepository.java | 15 +++- .../vocabulary/repository/WordRepository.java | 12 +++- .../service/DailyStudyCommandService.java | 27 ++++++-- .../service/DailyStudyQueryService.java | 16 ++++- .../vocabulary/service/StatisticsService.java | 14 +++- .../vocabulary/service/StatsService.java | 24 +++++-- .../service/TestCommandService.java | 24 +++++-- .../vocabulary/service/TestQueryService.java | 16 ++++- .../service/UserWordCommandService.java | 14 +++- .../service/UserWordQueryService.java | 16 ++++- .../service/WordCommandService.java | 14 +++- .../service/WordGroupCommandService.java | 14 +++- .../service/WordGroupQueryService.java | 16 ++++- .../vocabulary/service/WordQueryService.java | 14 +++- 67 files changed, 1070 insertions(+), 177 deletions(-) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/AccuracyStrategy.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/BadgeConditionStrategy.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/BadgeConditionStrategyFactory.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/FirstStudyStrategy.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/GamesPlayedStrategy.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/GamesWonStrategy.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NoOpStrategy.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/PerfectDrawsStrategy.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/QuickGuessesStrategy.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/StreakStrategy.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/TestsCompletedStrategy.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/WordsLearnedStrategy.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/handler/BadgeHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/handler/BadgeHandler.java index a60eb7bc..22159d80 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/handler/BadgeHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/handler/BadgeHandler.java @@ -23,8 +23,18 @@ public class BadgeHandler implements RequestHandler table; - + + /** + * 기본 생성자 (Lambda에서 사용) + */ public BadgeRepository() { - this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(UserBadge.class)); + this(AwsClients.dynamoDbEnhanced()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public BadgeRepository(DynamoDbEnhancedClient enhancedClient) { + this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(UserBadge.class)); } public void save(UserBadge badge) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/service/BadgeService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/service/BadgeService.java index ad69394c..62e887b3 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/service/BadgeService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/service/BadgeService.java @@ -10,6 +10,9 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.mzc.secondproject.serverless.domain.badge.strategy.BadgeConditionStrategy; +import com.mzc.secondproject.serverless.domain.badge.strategy.BadgeConditionStrategyFactory; + import java.time.Instant; import java.util.ArrayList; import java.util.List; @@ -22,10 +25,20 @@ public class BadgeService { private final BadgeRepository badgeRepository; private final UserStatsRepository userStatsRepository; - + + /** + * 기본 생성자 (Lambda에서 사용) + */ public BadgeService() { - this.badgeRepository = new BadgeRepository(); - this.userStatsRepository = new UserStatsRepository(); + this(new BadgeRepository(), new UserStatsRepository()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public BadgeService(BadgeRepository badgeRepository, UserStatsRepository userStatsRepository) { + this.badgeRepository = badgeRepository; + this.userStatsRepository = userStatsRepository; } /** @@ -132,51 +145,16 @@ private UserBadge createBadge(String userId, BadgeType type, String now) { private boolean checkBadgeCondition(BadgeType type, UserStats stats) { if (stats == null) return false; - - return switch (type.getCategory()) { - case "FIRST_STUDY" -> stats.getTestsCompleted() != null && stats.getTestsCompleted() >= 1; - case "STREAK" -> stats.getCurrentStreak() != null && stats.getCurrentStreak() >= type.getThreshold(); - case "WORDS_LEARNED" -> { - int total = (stats.getNewWordsLearned() != null ? stats.getNewWordsLearned() : 0) - + (stats.getWordsReviewed() != null ? stats.getWordsReviewed() : 0); - yield total >= type.getThreshold(); - } - case "PERFECT_TEST" -> false; // 별도 로직 필요 (테스트 결과에서 체크) - case "TESTS_COMPLETED" -> - stats.getTestsCompleted() != null && stats.getTestsCompleted() >= type.getThreshold(); - case "ACCURACY" -> { - if (stats.getQuestionsAnswered() == null || stats.getQuestionsAnswered() == 0) yield false; - double accuracy = (stats.getCorrectAnswers() * 100.0) / stats.getQuestionsAnswered(); - yield accuracy >= type.getThreshold(); - } - case "GAMES_PLAYED" -> stats.getGamesPlayed() != null && stats.getGamesPlayed() >= type.getThreshold(); - case "GAMES_WON" -> stats.getGamesWon() != null && stats.getGamesWon() >= type.getThreshold(); - case "QUICK_GUESSES" -> stats.getQuickGuesses() != null && stats.getQuickGuesses() >= type.getThreshold(); - case "PERFECT_DRAWS" -> stats.getPerfectDraws() != null && stats.getPerfectDraws() >= type.getThreshold(); - case "ALL_BADGES" -> false; // 별도 로직 필요 - default -> false; - }; + + BadgeConditionStrategy strategy = BadgeConditionStrategyFactory.getStrategy(type.getCategory()); + return strategy.checkCondition(type, stats); } - + private int calculateProgress(BadgeType type, UserStats stats) { if (stats == null) return 0; - - return switch (type.getCategory()) { - case "FIRST_STUDY" -> stats.getTestsCompleted() != null && stats.getTestsCompleted() >= 1 ? 1 : 0; - case "STREAK" -> stats.getCurrentStreak() != null ? stats.getCurrentStreak() : 0; - case "WORDS_LEARNED" -> (stats.getNewWordsLearned() != null ? stats.getNewWordsLearned() : 0) - + (stats.getWordsReviewed() != null ? stats.getWordsReviewed() : 0); - case "TESTS_COMPLETED" -> stats.getTestsCompleted() != null ? stats.getTestsCompleted() : 0; - case "ACCURACY" -> { - if (stats.getQuestionsAnswered() == null || stats.getQuestionsAnswered() == 0) yield 0; - yield (int) ((stats.getCorrectAnswers() * 100.0) / stats.getQuestionsAnswered()); - } - case "GAMES_PLAYED" -> stats.getGamesPlayed() != null ? stats.getGamesPlayed() : 0; - case "GAMES_WON" -> stats.getGamesWon() != null ? stats.getGamesWon() : 0; - case "QUICK_GUESSES" -> stats.getQuickGuesses() != null ? stats.getQuickGuesses() : 0; - case "PERFECT_DRAWS" -> stats.getPerfectDraws() != null ? stats.getPerfectDraws() : 0; - default -> 0; - }; + + BadgeConditionStrategy strategy = BadgeConditionStrategyFactory.getStrategy(type.getCategory()); + return strategy.calculateProgress(type, stats); } public record BadgeInfo( diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/AccuracyStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/AccuracyStrategy.java new file mode 100644 index 00000000..d6e5c58f --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/AccuracyStrategy.java @@ -0,0 +1,34 @@ +package com.mzc.secondproject.serverless.domain.badge.strategy; + +import com.mzc.secondproject.serverless.domain.badge.enums.BadgeType; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; + +/** + * 정확도 뱃지 조건 전략 + */ +public class AccuracyStrategy implements BadgeConditionStrategy { + + @Override + public boolean checkCondition(BadgeType type, UserStats stats) { + double accuracy = calculateAccuracy(stats); + return accuracy >= type.getThreshold(); + } + + @Override + public int calculateProgress(BadgeType type, UserStats stats) { + return (int) calculateAccuracy(stats); + } + + @Override + public String getCategory() { + return "ACCURACY"; + } + + private double calculateAccuracy(UserStats stats) { + if (stats.getQuestionsAnswered() == null || stats.getQuestionsAnswered() == 0) { + return 0.0; + } + int correct = stats.getCorrectAnswers() != null ? stats.getCorrectAnswers() : 0; + return (correct * 100.0) / stats.getQuestionsAnswered(); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/BadgeConditionStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/BadgeConditionStrategy.java new file mode 100644 index 00000000..10243bcd --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/BadgeConditionStrategy.java @@ -0,0 +1,25 @@ +package com.mzc.secondproject.serverless.domain.badge.strategy; + +import com.mzc.secondproject.serverless.domain.badge.enums.BadgeType; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; + +/** + * 뱃지 조건 확인 전략 인터페이스 + */ +public interface BadgeConditionStrategy { + + /** + * 뱃지 획득 조건 확인 + */ + boolean checkCondition(BadgeType type, UserStats stats); + + /** + * 현재 진행도 계산 + */ + int calculateProgress(BadgeType type, UserStats stats); + + /** + * 지원하는 카테고리 + */ + String getCategory(); +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/BadgeConditionStrategyFactory.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/BadgeConditionStrategyFactory.java new file mode 100644 index 00000000..c855ff19 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/BadgeConditionStrategyFactory.java @@ -0,0 +1,40 @@ +package com.mzc.secondproject.serverless.domain.badge.strategy; + +import java.util.HashMap; +import java.util.Map; + +/** + * 뱃지 조건 전략 팩토리 + * 카테고리별 전략 인스턴스를 관리하고 제공 + */ +public class BadgeConditionStrategyFactory { + + private static final Map STRATEGIES = new HashMap<>(); + private static final BadgeConditionStrategy DEFAULT_STRATEGY = new NoOpStrategy("DEFAULT"); + + static { + register(new FirstStudyStrategy()); + register(new StreakStrategy()); + register(new WordsLearnedStrategy()); + register(new TestsCompletedStrategy()); + register(new AccuracyStrategy()); + register(new GamesPlayedStrategy()); + register(new GamesWonStrategy()); + register(new QuickGuessesStrategy()); + register(new PerfectDrawsStrategy()); + // 별도 로직이 필요한 카테고리 + register(new NoOpStrategy("PERFECT_TEST")); + register(new NoOpStrategy("ALL_BADGES")); + } + + private static void register(BadgeConditionStrategy strategy) { + STRATEGIES.put(strategy.getCategory(), strategy); + } + + /** + * 카테고리에 해당하는 전략 반환 + */ + public static BadgeConditionStrategy getStrategy(String category) { + return STRATEGIES.getOrDefault(category, DEFAULT_STRATEGY); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/FirstStudyStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/FirstStudyStrategy.java new file mode 100644 index 00000000..ab23769f --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/FirstStudyStrategy.java @@ -0,0 +1,25 @@ +package com.mzc.secondproject.serverless.domain.badge.strategy; + +import com.mzc.secondproject.serverless.domain.badge.enums.BadgeType; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; + +/** + * 첫 학습 뱃지 조건 전략 + */ +public class FirstStudyStrategy implements BadgeConditionStrategy { + + @Override + public boolean checkCondition(BadgeType type, UserStats stats) { + return stats.getTestsCompleted() != null && stats.getTestsCompleted() >= 1; + } + + @Override + public int calculateProgress(BadgeType type, UserStats stats) { + return (stats.getTestsCompleted() != null && stats.getTestsCompleted() >= 1) ? 1 : 0; + } + + @Override + public String getCategory() { + return "FIRST_STUDY"; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/GamesPlayedStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/GamesPlayedStrategy.java new file mode 100644 index 00000000..c8e939f6 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/GamesPlayedStrategy.java @@ -0,0 +1,25 @@ +package com.mzc.secondproject.serverless.domain.badge.strategy; + +import com.mzc.secondproject.serverless.domain.badge.enums.BadgeType; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; + +/** + * 게임 플레이 횟수 뱃지 조건 전략 + */ +public class GamesPlayedStrategy implements BadgeConditionStrategy { + + @Override + public boolean checkCondition(BadgeType type, UserStats stats) { + return stats.getGamesPlayed() != null && stats.getGamesPlayed() >= type.getThreshold(); + } + + @Override + public int calculateProgress(BadgeType type, UserStats stats) { + return stats.getGamesPlayed() != null ? stats.getGamesPlayed() : 0; + } + + @Override + public String getCategory() { + return "GAMES_PLAYED"; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/GamesWonStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/GamesWonStrategy.java new file mode 100644 index 00000000..884ed90a --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/GamesWonStrategy.java @@ -0,0 +1,25 @@ +package com.mzc.secondproject.serverless.domain.badge.strategy; + +import com.mzc.secondproject.serverless.domain.badge.enums.BadgeType; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; + +/** + * 게임 승리 횟수 뱃지 조건 전략 + */ +public class GamesWonStrategy implements BadgeConditionStrategy { + + @Override + public boolean checkCondition(BadgeType type, UserStats stats) { + return stats.getGamesWon() != null && stats.getGamesWon() >= type.getThreshold(); + } + + @Override + public int calculateProgress(BadgeType type, UserStats stats) { + return stats.getGamesWon() != null ? stats.getGamesWon() : 0; + } + + @Override + public String getCategory() { + return "GAMES_WON"; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NoOpStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NoOpStrategy.java new file mode 100644 index 00000000..1f5f4c8b --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NoOpStrategy.java @@ -0,0 +1,32 @@ +package com.mzc.secondproject.serverless.domain.badge.strategy; + +import com.mzc.secondproject.serverless.domain.badge.enums.BadgeType; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; + +/** + * 별도 로직이 필요한 뱃지용 No-Op 전략 + * PERFECT_TEST, ALL_BADGES 등은 별도 로직에서 처리 + */ +public class NoOpStrategy implements BadgeConditionStrategy { + + private final String category; + + public NoOpStrategy(String category) { + this.category = category; + } + + @Override + public boolean checkCondition(BadgeType type, UserStats stats) { + return false; + } + + @Override + public int calculateProgress(BadgeType type, UserStats stats) { + return 0; + } + + @Override + public String getCategory() { + return category; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/PerfectDrawsStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/PerfectDrawsStrategy.java new file mode 100644 index 00000000..ac588a4d --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/PerfectDrawsStrategy.java @@ -0,0 +1,25 @@ +package com.mzc.secondproject.serverless.domain.badge.strategy; + +import com.mzc.secondproject.serverless.domain.badge.enums.BadgeType; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; + +/** + * 완벽한 출제 뱃지 조건 전략 + */ +public class PerfectDrawsStrategy implements BadgeConditionStrategy { + + @Override + public boolean checkCondition(BadgeType type, UserStats stats) { + return stats.getPerfectDraws() != null && stats.getPerfectDraws() >= type.getThreshold(); + } + + @Override + public int calculateProgress(BadgeType type, UserStats stats) { + return stats.getPerfectDraws() != null ? stats.getPerfectDraws() : 0; + } + + @Override + public String getCategory() { + return "PERFECT_DRAWS"; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/QuickGuessesStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/QuickGuessesStrategy.java new file mode 100644 index 00000000..610dc048 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/QuickGuessesStrategy.java @@ -0,0 +1,25 @@ +package com.mzc.secondproject.serverless.domain.badge.strategy; + +import com.mzc.secondproject.serverless.domain.badge.enums.BadgeType; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; + +/** + * 빠른 정답 뱃지 조건 전략 + */ +public class QuickGuessesStrategy implements BadgeConditionStrategy { + + @Override + public boolean checkCondition(BadgeType type, UserStats stats) { + return stats.getQuickGuesses() != null && stats.getQuickGuesses() >= type.getThreshold(); + } + + @Override + public int calculateProgress(BadgeType type, UserStats stats) { + return stats.getQuickGuesses() != null ? stats.getQuickGuesses() : 0; + } + + @Override + public String getCategory() { + return "QUICK_GUESSES"; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/StreakStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/StreakStrategy.java new file mode 100644 index 00000000..18f826cb --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/StreakStrategy.java @@ -0,0 +1,25 @@ +package com.mzc.secondproject.serverless.domain.badge.strategy; + +import com.mzc.secondproject.serverless.domain.badge.enums.BadgeType; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; + +/** + * 연속 학습 뱃지 조건 전략 + */ +public class StreakStrategy implements BadgeConditionStrategy { + + @Override + public boolean checkCondition(BadgeType type, UserStats stats) { + return stats.getCurrentStreak() != null && stats.getCurrentStreak() >= type.getThreshold(); + } + + @Override + public int calculateProgress(BadgeType type, UserStats stats) { + return stats.getCurrentStreak() != null ? stats.getCurrentStreak() : 0; + } + + @Override + public String getCategory() { + return "STREAK"; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/TestsCompletedStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/TestsCompletedStrategy.java new file mode 100644 index 00000000..ec791adc --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/TestsCompletedStrategy.java @@ -0,0 +1,25 @@ +package com.mzc.secondproject.serverless.domain.badge.strategy; + +import com.mzc.secondproject.serverless.domain.badge.enums.BadgeType; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; + +/** + * 테스트 완료 횟수 뱃지 조건 전략 + */ +public class TestsCompletedStrategy implements BadgeConditionStrategy { + + @Override + public boolean checkCondition(BadgeType type, UserStats stats) { + return stats.getTestsCompleted() != null && stats.getTestsCompleted() >= type.getThreshold(); + } + + @Override + public int calculateProgress(BadgeType type, UserStats stats) { + return stats.getTestsCompleted() != null ? stats.getTestsCompleted() : 0; + } + + @Override + public String getCategory() { + return "TESTS_COMPLETED"; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/WordsLearnedStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/WordsLearnedStrategy.java new file mode 100644 index 00000000..5f7cbe02 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/WordsLearnedStrategy.java @@ -0,0 +1,32 @@ +package com.mzc.secondproject.serverless.domain.badge.strategy; + +import com.mzc.secondproject.serverless.domain.badge.enums.BadgeType; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; + +/** + * 단어 학습량 뱃지 조건 전략 + */ +public class WordsLearnedStrategy implements BadgeConditionStrategy { + + @Override + public boolean checkCondition(BadgeType type, UserStats stats) { + int total = getTotalWordsLearned(stats); + return total >= type.getThreshold(); + } + + @Override + public int calculateProgress(BadgeType type, UserStats stats) { + return getTotalWordsLearned(stats); + } + + @Override + public String getCategory() { + return "WORDS_LEARNED"; + } + + private int getTotalWordsLearned(UserStats stats) { + int newWords = stats.getNewWordsLearned() != null ? stats.getNewWordsLearned() : 0; + int reviewedWords = stats.getWordsReviewed() != null ? stats.getWordsReviewed() : 0; + return newWords + reviewedWords; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatMessageHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatMessageHandler.java index 9d712f5f..b8a36d77 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatMessageHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatMessageHandler.java @@ -31,9 +31,19 @@ public class ChatMessageHandler implements RequestHandler, private final ConnectionRepository connectionRepository; private final WebSocketBroadcaster broadcaster; + /** + * 기본 생성자 (Lambda에서 사용) + */ public GameAutoCloseHandler() { - this.gameService = new GameService(); - this.connectionRepository = new ConnectionRepository(); - this.broadcaster = new WebSocketBroadcaster(); + this(new GameService(), new ConnectionRepository(), new WebSocketBroadcaster()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public GameAutoCloseHandler(GameService gameService, ConnectionRepository connectionRepository, + WebSocketBroadcaster broadcaster) { + this.gameService = gameService; + this.connectionRepository = connectionRepository; + this.broadcaster = broadcaster; } @Override diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameHandler.java index 4acbd169..55a6c503 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameHandler.java @@ -38,11 +38,22 @@ public class GameHandler implements RequestHandler table; - + + /** + * 기본 생성자 (Lambda에서 사용) + */ public ChatMessageRepository() { - this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(ChatMessage.class)); + this(AwsClients.dynamoDbEnhanced()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public ChatMessageRepository(DynamoDbEnhancedClient enhancedClient) { + this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(ChatMessage.class)); } public ChatMessage save(ChatMessage message) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatRoomRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatRoomRepository.java index 3cadfc31..e351a703 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatRoomRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatRoomRepository.java @@ -7,6 +7,7 @@ import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; import software.amazon.awssdk.enhanced.dynamodb.DynamoDbIndex; import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; import software.amazon.awssdk.enhanced.dynamodb.Key; @@ -28,9 +29,19 @@ public class ChatRoomRepository { private static final String TABLE_NAME = EnvConfig.getRequired("CHAT_TABLE_NAME"); private final DynamoDbTable table; - + + /** + * 기본 생성자 (Lambda에서 사용) + */ public ChatRoomRepository() { - this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(ChatRoom.class)); + this(AwsClients.dynamoDbEnhanced()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public ChatRoomRepository(DynamoDbEnhancedClient enhancedClient) { + this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(ChatRoom.class)); } public ChatRoom save(ChatRoom room) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ConnectionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ConnectionRepository.java index d079d3d7..eba332e8 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ConnectionRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ConnectionRepository.java @@ -5,6 +5,7 @@ import com.mzc.secondproject.serverless.domain.chatting.model.Connection; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; import software.amazon.awssdk.enhanced.dynamodb.DynamoDbIndex; import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; import software.amazon.awssdk.enhanced.dynamodb.Key; @@ -22,9 +23,19 @@ public class ConnectionRepository { private static final String TABLE_NAME = EnvConfig.getRequired("CHAT_TABLE_NAME"); private final DynamoDbTable table; - + + /** + * 기본 생성자 (Lambda에서 사용) + */ public ConnectionRepository() { - this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(Connection.class)); + this(AwsClients.dynamoDbEnhanced()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public ConnectionRepository(DynamoDbEnhancedClient enhancedClient) { + this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(Connection.class)); } public Connection save(Connection connection) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/GameRoundRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/GameRoundRepository.java index ccb6556f..047df2d0 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/GameRoundRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/GameRoundRepository.java @@ -29,8 +29,18 @@ public class GameRoundRepository { private final DynamoDbEnhancedClient enhancedClient; private final DynamoDbTable table; + /** + * 기본 생성자 (Lambda에서 사용) + */ public GameRoundRepository() { - this.enhancedClient = AwsClients.dynamoDbEnhanced(); + this(AwsClients.dynamoDbEnhanced()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public GameRoundRepository(DynamoDbEnhancedClient enhancedClient) { + this.enhancedClient = enhancedClient; this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(GameRound.class)); } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/GameSessionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/GameSessionRepository.java index 61623038..bf2493b5 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/GameSessionRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/GameSessionRepository.java @@ -5,6 +5,7 @@ import com.mzc.secondproject.serverless.domain.chatting.model.GameSession; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; import software.amazon.awssdk.enhanced.dynamodb.DynamoDbIndex; import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; import software.amazon.awssdk.enhanced.dynamodb.Key; @@ -30,8 +31,18 @@ public class GameSessionRepository { private final DynamoDbTable table; + /** + * 기본 생성자 (Lambda에서 사용) + */ public GameSessionRepository() { - this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(GameSession.class)); + this(AwsClients.dynamoDbEnhanced()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public GameSessionRepository(DynamoDbEnhancedClient enhancedClient) { + this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(GameSession.class)); } /** diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/RoomTokenRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/RoomTokenRepository.java index fa6aee2e..f4b39b96 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/RoomTokenRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/RoomTokenRepository.java @@ -5,6 +5,7 @@ import com.mzc.secondproject.serverless.domain.chatting.model.RoomToken; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; import software.amazon.awssdk.enhanced.dynamodb.Key; import software.amazon.awssdk.enhanced.dynamodb.TableSchema; @@ -17,9 +18,19 @@ public class RoomTokenRepository { private static final String TABLE_NAME = EnvConfig.getRequired("CHAT_TABLE_NAME"); private final DynamoDbTable table; - + + /** + * 기본 생성자 (Lambda에서 사용) + */ public RoomTokenRepository() { - this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(RoomToken.class)); + this(AwsClients.dynamoDbEnhanced()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public RoomTokenRepository(DynamoDbEnhancedClient enhancedClient) { + this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(RoomToken.class)); } public RoomToken save(RoomToken roomToken) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatMessageService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatMessageService.java index 1bc1c594..e0b44317 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatMessageService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatMessageService.java @@ -13,9 +13,19 @@ public class ChatMessageService { private static final Logger logger = LoggerFactory.getLogger(ChatMessageService.class); private final ChatMessageRepository repository; - + + /** + * 기본 생성자 (Lambda에서 사용) + */ public ChatMessageService() { - this.repository = new ChatMessageRepository(); + this(new ChatMessageRepository()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public ChatMessageService(ChatMessageRepository repository) { + this.repository = repository; } public ChatMessage saveMessage(ChatMessage message) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomCommandService.java index cb84f3cc..90a4d594 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomCommandService.java @@ -37,12 +37,27 @@ public class ChatRoomCommandService { private final WebSocketBroadcaster broadcaster; private final UserRepository userRepository; + /** + * 기본 생성자 (Lambda에서 사용) + */ public ChatRoomCommandService() { - this.roomRepository = new ChatRoomRepository(); - this.roomTokenService = new RoomTokenService(); - this.connectionRepository = new ConnectionRepository(); - this.broadcaster = new WebSocketBroadcaster(); - this.userRepository = new UserRepository(); + this(new ChatRoomRepository(), new RoomTokenService(), new ConnectionRepository(), + new WebSocketBroadcaster(), new UserRepository()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public ChatRoomCommandService(ChatRoomRepository roomRepository, + RoomTokenService roomTokenService, + ConnectionRepository connectionRepository, + WebSocketBroadcaster broadcaster, + UserRepository userRepository) { + this.roomRepository = roomRepository; + this.roomTokenService = roomTokenService; + this.connectionRepository = connectionRepository; + this.broadcaster = broadcaster; + this.userRepository = userRepository; } public ChatRoom createRoom(String name, String description, String level, Integer maxMembers, diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomQueryService.java index f75ef802..68af7c1f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomQueryService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomQueryService.java @@ -22,9 +22,19 @@ public class ChatRoomQueryService { private final ChatRoomRepository roomRepository; private final UserRepository userRepository; + /** + * 기본 생성자 (Lambda에서 사용) + */ public ChatRoomQueryService() { - this.roomRepository = new ChatRoomRepository(); - this.userRepository = new UserRepository(); + this(new ChatRoomRepository(), new UserRepository()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public ChatRoomQueryService(ChatRoomRepository roomRepository, UserRepository userRepository) { + this.roomRepository = roomRepository; + this.userRepository = userRepository; } public Optional getRoom(String roomId) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/CommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/CommandService.java index 8f43d7af..d4cf0148 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/CommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/CommandService.java @@ -23,10 +23,22 @@ public class CommandService { private final GameSessionRepository gameSessionRepository; private final GameService gameService; + /** + * 기본 생성자 (Lambda에서 사용) + */ public CommandService() { - this.connectionRepository = new ConnectionRepository(); - this.gameSessionRepository = new GameSessionRepository(); - this.gameService = new GameService(); + this(new ConnectionRepository(), new GameSessionRepository(), new GameService()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public CommandService(ConnectionRepository connectionRepository, + GameSessionRepository gameSessionRepository, + GameService gameService) { + this.connectionRepository = connectionRepository; + this.gameSessionRepository = gameSessionRepository; + this.gameService = gameService; } /** diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameSchedulerClient.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameSchedulerClient.java index 1ececf65..ab2d9f53 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameSchedulerClient.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameSchedulerClient.java @@ -25,10 +25,22 @@ public class GameSchedulerClient { private final String targetLambdaArn; private final String roleArn; + /** + * 기본 생성자 (Lambda에서 사용) + */ public GameSchedulerClient() { - this.schedulerClient = SchedulerClient.create(); - this.targetLambdaArn = EnvConfig.getOrDefault("GAME_AUTO_CLOSE_LAMBDA_ARN", null); - this.roleArn = EnvConfig.getOrDefault("SCHEDULER_ROLE_ARN", null); + this(SchedulerClient.create(), + EnvConfig.getOrDefault("GAME_AUTO_CLOSE_LAMBDA_ARN", null), + EnvConfig.getOrDefault("SCHEDULER_ROLE_ARN", null)); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public GameSchedulerClient(SchedulerClient schedulerClient, String targetLambdaArn, String roleArn) { + this.schedulerClient = schedulerClient; + this.targetLambdaArn = targetLambdaArn; + this.roleArn = roleArn; } /** diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameStatsService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameStatsService.java index 2c3d61e9..4ff2e235 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameStatsService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameStatsService.java @@ -23,11 +23,23 @@ public class GameStatsService { private final UserStatsRepository userStatsRepository; private final GameRoundRepository gameRoundRepository; private final BadgeService badgeService; - + + /** + * 기본 생성자 (Lambda에서 사용) + */ public GameStatsService() { - this.userStatsRepository = new UserStatsRepository(); - this.gameRoundRepository = new GameRoundRepository(); - this.badgeService = new BadgeService(); + this(new UserStatsRepository(), new GameRoundRepository(), new BadgeService()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public GameStatsService(UserStatsRepository userStatsRepository, + GameRoundRepository gameRoundRepository, + BadgeService badgeService) { + this.userStatsRepository = userStatsRepository; + this.gameRoundRepository = gameRoundRepository; + this.badgeService = badgeService; } /** diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/RoomTokenService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/RoomTokenService.java index ee470cfe..4ee6231a 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/RoomTokenService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/RoomTokenService.java @@ -19,9 +19,19 @@ public class RoomTokenService { private static final Logger logger = LoggerFactory.getLogger(RoomTokenService.class); private final RoomTokenRepository tokenRepository; - + + /** + * 기본 생성자 (Lambda에서 사용) + */ public RoomTokenService() { - this.tokenRepository = new RoomTokenRepository(); + this(new RoomTokenRepository()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public RoomTokenService(RoomTokenRepository tokenRepository) { + this.tokenRepository = tokenRepository; } /** diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/GrammarHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/GrammarHandler.java index be726d5b..9ae05c3b 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/GrammarHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/GrammarHandler.java @@ -30,10 +30,21 @@ public class GrammarHandler implements RequestHandler table; - + + /** + * 기본 생성자 (Lambda에서 사용) + */ public GrammarConnectionRepository() { - this.table = AwsClients.dynamoDbEnhanced().table( - TABLE_NAME, - TableSchema.fromBean(GrammarConnection.class) - ); + this(AwsClients.dynamoDbEnhanced()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public GrammarConnectionRepository(DynamoDbEnhancedClient enhancedClient) { + this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(GrammarConnection.class)); } /** diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/repository/GrammarSessionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/repository/GrammarSessionRepository.java index 8c0466f8..4c52c93c 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/repository/GrammarSessionRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/repository/GrammarSessionRepository.java @@ -9,6 +9,7 @@ import com.mzc.secondproject.serverless.domain.grammar.model.GrammarSession; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; import software.amazon.awssdk.enhanced.dynamodb.Key; import software.amazon.awssdk.enhanced.dynamodb.TableSchema; @@ -28,10 +29,20 @@ public class GrammarSessionRepository { private final DynamoDbTable sessionTable; private final DynamoDbTable messageTable; - + + /** + * 기본 생성자 (Lambda에서 사용) + */ public GrammarSessionRepository() { - this.sessionTable = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(GrammarSession.class)); - this.messageTable = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(GrammarMessage.class)); + this(AwsClients.dynamoDbEnhanced()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public GrammarSessionRepository(DynamoDbEnhancedClient enhancedClient) { + this.sessionTable = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(GrammarSession.class)); + this.messageTable = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(GrammarMessage.class)); } // ============ Session CRUD ============ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/ScheduledStatsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/ScheduledStatsHandler.java index 82909fd0..f8890e51 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/ScheduledStatsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/ScheduledStatsHandler.java @@ -31,8 +31,18 @@ public class ScheduledStatsHandler implements RequestHandler { private final UserStatsRepository userStatsRepository; private final BadgeService badgeService; + /** + * 기본 생성자 (Lambda에서 사용) + */ public StatsStreamHandler() { - this.userStatsRepository = new UserStatsRepository(); - this.badgeService = new BadgeService(); + this(new UserStatsRepository(), new BadgeService()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public StatsStreamHandler(UserStatsRepository userStatsRepository, BadgeService badgeService) { + this.userStatsRepository = userStatsRepository; + this.badgeService = badgeService; } @Override diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java index 47c07cd6..c006c5fb 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java @@ -31,9 +31,19 @@ public class UserStatsHandler implements RequestHandler table; - + + /** + * 기본 생성자 (Lambda에서 사용) + */ public UserStatsRepository() { - this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(UserStats.class)); + this(AwsClients.dynamoDbEnhanced()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public UserStatsRepository(DynamoDbEnhancedClient enhancedClient) { + this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(UserStats.class)); } /** diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/service/StatsService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/service/StatsService.java index faba5591..510754a5 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/service/StatsService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/service/StatsService.java @@ -17,9 +17,19 @@ public class StatsService { private static final Logger logger = LoggerFactory.getLogger(StatsService.class); private final UserStatsRepository userStatsRepository; - + + /** + * 기본 생성자 (Lambda에서 사용) + */ public StatsService() { - this.userStatsRepository = new UserStatsRepository(); + this(new UserStatsRepository()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public StatsService(UserStatsRepository userStatsRepository) { + this.userStatsRepository = userStatsRepository; } /** diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/DailyStudyHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/DailyStudyHandler.java index 44ffa02d..54488ac6 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/DailyStudyHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/DailyStudyHandler.java @@ -24,9 +24,19 @@ public class DailyStudyHandler implements RequestHandler { private final StatisticsService statisticsService; + /** + * 기본 생성자 (Lambda에서 사용) + */ public StatisticsHandler() { - this.statisticsService = new StatisticsService(); + this(new StatisticsService()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public StatisticsHandler(StatisticsService statisticsService) { + this.statisticsService = statisticsService; } @Override diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatsHandler.java index 190734ac..deefe73f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatsHandler.java @@ -21,8 +21,18 @@ public class StatsHandler implements RequestHandler table; - + + /** + * 기본 생성자 (Lambda에서 사용) + */ public DailyStudyRepository() { - this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(DailyStudy.class)); + this(AwsClients.dynamoDbEnhanced()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public DailyStudyRepository(DynamoDbEnhancedClient enhancedClient) { + this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(DailyStudy.class)); } public DailyStudy save(DailyStudy dailyStudy) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/TestResultRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/TestResultRepository.java index 790b7a79..7b0ea935 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/TestResultRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/TestResultRepository.java @@ -7,6 +7,7 @@ import com.mzc.secondproject.serverless.domain.vocabulary.model.TestResult; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; import software.amazon.awssdk.enhanced.dynamodb.Expression; import software.amazon.awssdk.enhanced.dynamodb.Key; @@ -25,9 +26,19 @@ public class TestResultRepository { private static final String TABLE_NAME = EnvConfig.getRequired("VOCAB_TABLE_NAME"); private final DynamoDbTable table; - + + /** + * 기본 생성자 (Lambda에서 사용) + */ public TestResultRepository() { - this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(TestResult.class)); + this(AwsClients.dynamoDbEnhanced()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public TestResultRepository(DynamoDbEnhancedClient enhancedClient) { + this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(TestResult.class)); } public TestResult save(TestResult testResult) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/UserWordRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/UserWordRepository.java index 8ad00235..015932c9 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/UserWordRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/UserWordRepository.java @@ -22,9 +22,19 @@ public class UserWordRepository { private static final String TABLE_NAME = EnvConfig.getRequired("VOCAB_TABLE_NAME"); private final DynamoDbTable table; - + + /** + * 기본 생성자 (Lambda에서 사용) + */ public UserWordRepository() { - this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(UserWord.class)); + this(AwsClients.dynamoDbEnhanced()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public UserWordRepository(DynamoDbEnhancedClient enhancedClient) { + this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(UserWord.class)); } public UserWord save(UserWord userWord) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordGroupRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordGroupRepository.java index ddab69b1..f740c667 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordGroupRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordGroupRepository.java @@ -7,6 +7,7 @@ import com.mzc.secondproject.serverless.domain.vocabulary.model.WordGroup; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; import software.amazon.awssdk.enhanced.dynamodb.Key; import software.amazon.awssdk.enhanced.dynamodb.TableSchema; @@ -24,9 +25,19 @@ public class WordGroupRepository { private static final String TABLE_NAME = EnvConfig.getRequired("VOCAB_TABLE_NAME"); private final DynamoDbTable table; - + + /** + * 기본 생성자 (Lambda에서 사용) + */ public WordGroupRepository() { - this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(WordGroup.class)); + this(AwsClients.dynamoDbEnhanced()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public WordGroupRepository(DynamoDbEnhancedClient enhancedClient) { + this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(WordGroup.class)); } public WordGroup save(WordGroup wordGroup) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordRepository.java index d8602c9e..b35fa686 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordRepository.java @@ -24,8 +24,18 @@ public class WordRepository { private final DynamoDbEnhancedClient enhancedClient; private final DynamoDbTable table; + /** + * 기본 생성자 (Lambda에서 사용) + */ public WordRepository() { - this.enhancedClient = AwsClients.dynamoDbEnhanced(); + this(AwsClients.dynamoDbEnhanced()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public WordRepository(DynamoDbEnhancedClient enhancedClient) { + this.enhancedClient = enhancedClient; this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(Word.class)); } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyCommandService.java index 4713bdaf..29cae332 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyCommandService.java @@ -34,13 +34,28 @@ public class DailyStudyCommandService { private final WordRepository wordRepository; private final UserStatsRepository userStatsRepository; private final BadgeService badgeService; - + + /** + * 기본 생성자 (Lambda에서 사용) + */ public DailyStudyCommandService() { - this.dailyStudyRepository = new DailyStudyRepository(); - this.userWordRepository = new UserWordRepository(); - this.wordRepository = new WordRepository(); - this.userStatsRepository = new UserStatsRepository(); - this.badgeService = new BadgeService(); + this(new DailyStudyRepository(), new UserWordRepository(), new WordRepository(), + new UserStatsRepository(), new BadgeService()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public DailyStudyCommandService(DailyStudyRepository dailyStudyRepository, + UserWordRepository userWordRepository, + WordRepository wordRepository, + UserStatsRepository userStatsRepository, + BadgeService badgeService) { + this.dailyStudyRepository = dailyStudyRepository; + this.userWordRepository = userWordRepository; + this.wordRepository = wordRepository; + this.userStatsRepository = userStatsRepository; + this.badgeService = badgeService; } public DailyStudyResult getDailyWords(String userId, String level) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyQueryService.java index 67c3265d..1a2af754 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyQueryService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyQueryService.java @@ -19,10 +19,20 @@ public class DailyStudyQueryService { private final DailyStudyRepository dailyStudyRepository; private final WordRepository wordRepository; - + + /** + * 기본 생성자 (Lambda에서 사용) + */ public DailyStudyQueryService() { - this.dailyStudyRepository = new DailyStudyRepository(); - this.wordRepository = new WordRepository(); + this(new DailyStudyRepository(), new WordRepository()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public DailyStudyQueryService(DailyStudyRepository dailyStudyRepository, WordRepository wordRepository) { + this.dailyStudyRepository = dailyStudyRepository; + this.wordRepository = wordRepository; } public Optional getDailyStudy(String userId, String date) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/StatisticsService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/StatisticsService.java index f1d1e75c..670f8e0d 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/StatisticsService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/StatisticsService.java @@ -16,9 +16,19 @@ public class StatisticsService { private static final Logger logger = LoggerFactory.getLogger(StatisticsService.class); private final UserWordRepository userWordRepository; - + + /** + * 기본 생성자 (Lambda에서 사용) + */ public StatisticsService() { - this.userWordRepository = new UserWordRepository(); + this(new UserWordRepository()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public StatisticsService(UserWordRepository userWordRepository) { + this.userWordRepository = userWordRepository; } /** diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/StatsService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/StatsService.java index 3e49865d..496014f2 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/StatsService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/StatsService.java @@ -24,12 +24,26 @@ public class StatsService { private final DailyStudyRepository dailyStudyRepository; private final TestResultRepository testResultRepository; private final WordRepository wordRepository; - + + /** + * 기본 생성자 (Lambda에서 사용) + */ public StatsService() { - this.userWordRepository = new UserWordRepository(); - this.dailyStudyRepository = new DailyStudyRepository(); - this.testResultRepository = new TestResultRepository(); - this.wordRepository = new WordRepository(); + this(new UserWordRepository(), new DailyStudyRepository(), + new TestResultRepository(), new WordRepository()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public StatsService(UserWordRepository userWordRepository, + DailyStudyRepository dailyStudyRepository, + TestResultRepository testResultRepository, + WordRepository wordRepository) { + this.userWordRepository = userWordRepository; + this.dailyStudyRepository = dailyStudyRepository; + this.testResultRepository = testResultRepository; + this.wordRepository = wordRepository; } public Map getOverallStats(String userId) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java index 62c3bde2..17125cf9 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java @@ -33,12 +33,26 @@ public class TestCommandService { private final DailyStudyRepository dailyStudyRepository; private final WordRepository wordRepository; private final UserWordCommandService userWordCommandService; - + + /** + * 기본 생성자 (Lambda에서 사용) + */ public TestCommandService() { - this.testResultRepository = new TestResultRepository(); - this.dailyStudyRepository = new DailyStudyRepository(); - this.wordRepository = new WordRepository(); - this.userWordCommandService = new UserWordCommandService(); + this(new TestResultRepository(), new DailyStudyRepository(), + new WordRepository(), new UserWordCommandService()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public TestCommandService(TestResultRepository testResultRepository, + DailyStudyRepository dailyStudyRepository, + WordRepository wordRepository, + UserWordCommandService userWordCommandService) { + this.testResultRepository = testResultRepository; + this.dailyStudyRepository = dailyStudyRepository; + this.wordRepository = wordRepository; + this.userWordCommandService = userWordCommandService; } public StartTestResult startTest(String userId, String testType) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestQueryService.java index 47dab64b..7b422f3c 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestQueryService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestQueryService.java @@ -19,10 +19,20 @@ public class TestQueryService { private final TestResultRepository testResultRepository; private final WordRepository wordRepository; - + + /** + * 기본 생성자 (Lambda에서 사용) + */ public TestQueryService() { - this.testResultRepository = new TestResultRepository(); - this.wordRepository = new WordRepository(); + this(new TestResultRepository(), new WordRepository()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public TestQueryService(TestResultRepository testResultRepository, WordRepository wordRepository) { + this.testResultRepository = testResultRepository; + this.wordRepository = wordRepository; } public PaginatedResult getTestResults(String userId, int limit, String cursor) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordCommandService.java index d356c125..18540ac2 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordCommandService.java @@ -25,9 +25,19 @@ public class UserWordCommandService { private static final Logger logger = LoggerFactory.getLogger(UserWordCommandService.class); private final UserWordRepository userWordRepository; - + + /** + * 기본 생성자 (Lambda에서 사용) + */ public UserWordCommandService() { - this.userWordRepository = new UserWordRepository(); + this(new UserWordRepository()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public UserWordCommandService(UserWordRepository userWordRepository) { + this.userWordRepository = userWordRepository; } public UserWord updateUserWord(String userId, String wordId, boolean isCorrect) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordQueryService.java index 1d5fe08c..2942c609 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordQueryService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordQueryService.java @@ -20,10 +20,20 @@ public class UserWordQueryService { private final UserWordRepository userWordRepository; private final WordRepository wordRepository; - + + /** + * 기본 생성자 (Lambda에서 사용) + */ public UserWordQueryService() { - this.userWordRepository = new UserWordRepository(); - this.wordRepository = new WordRepository(); + this(new UserWordRepository(), new WordRepository()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public UserWordQueryService(UserWordRepository userWordRepository, WordRepository wordRepository) { + this.userWordRepository = userWordRepository; + this.wordRepository = wordRepository; } public UserWordsResult getUserWords(String userId, String status, String bookmarked, diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordCommandService.java index 5807055a..f8365c69 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordCommandService.java @@ -19,9 +19,19 @@ public class WordCommandService { private static final Logger logger = LoggerFactory.getLogger(WordCommandService.class); private final WordRepository wordRepository; - + + /** + * 기본 생성자 (Lambda에서 사용) + */ public WordCommandService() { - this.wordRepository = new WordRepository(); + this(new WordRepository()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public WordCommandService(WordRepository wordRepository) { + this.wordRepository = wordRepository; } public Word createWord(String english, String korean, String example, String level, String category) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordGroupCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordGroupCommandService.java index 018405b5..796f9d1e 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordGroupCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordGroupCommandService.java @@ -20,9 +20,19 @@ public class WordGroupCommandService { private static final Logger logger = LoggerFactory.getLogger(WordGroupCommandService.class); private final WordGroupRepository wordGroupRepository; - + + /** + * 기본 생성자 (Lambda에서 사용) + */ public WordGroupCommandService() { - this.wordGroupRepository = new WordGroupRepository(); + this(new WordGroupRepository()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public WordGroupCommandService(WordGroupRepository wordGroupRepository) { + this.wordGroupRepository = wordGroupRepository; } public WordGroup createGroup(String userId, String groupName, String description) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordGroupQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordGroupQueryService.java index 0cc86c3c..6ce2d668 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordGroupQueryService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordGroupQueryService.java @@ -20,10 +20,20 @@ public class WordGroupQueryService { private final WordGroupRepository wordGroupRepository; private final WordRepository wordRepository; - + + /** + * 기본 생성자 (Lambda에서 사용) + */ public WordGroupQueryService() { - this.wordGroupRepository = new WordGroupRepository(); - this.wordRepository = new WordRepository(); + this(new WordGroupRepository(), new WordRepository()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public WordGroupQueryService(WordGroupRepository wordGroupRepository, WordRepository wordRepository) { + this.wordGroupRepository = wordGroupRepository; + this.wordRepository = wordRepository; } public PaginatedResult getGroups(String userId, int limit, String cursor) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordQueryService.java index a77cac3c..7257122c 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordQueryService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordQueryService.java @@ -17,9 +17,19 @@ public class WordQueryService { private static final Logger logger = LoggerFactory.getLogger(WordQueryService.class); private final WordRepository wordRepository; - + + /** + * 기본 생성자 (Lambda에서 사용) + */ public WordQueryService() { - this.wordRepository = new WordRepository(); + this(new WordRepository()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public WordQueryService(WordRepository wordRepository) { + this.wordRepository = wordRepository; } public Optional getWord(String wordId) { From ab28efd12dd8fdfc61351e1ab21f661a3698aeaa Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Wed, 21 Jan 2026 15:45:47 +0900 Subject: [PATCH 405/528] refactor: relocate and restructure seed data files - Moved `question-homes.json` and `words.json` from subdirectories to `seed` folder. - Updated file paths for better organization and clarity. --- .gitignore | 4 ++-- {opic/seed-data => seed/opic}/question-homes.json | 0 {vocabulary/seed-data => seed/vocabulary}/words.json | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename {opic/seed-data => seed/opic}/question-homes.json (100%) rename {vocabulary/seed-data => seed/vocabulary}/words.json (100%) diff --git a/.gitignore b/.gitignore index f1597d4c..bcf19f01 100644 --- a/.gitignore +++ b/.gitignore @@ -40,5 +40,5 @@ samconfig.toml *.test.ts coverage/ -# Seed Data (already uploaded to DynamoDB) -vocabulary/seed-data/ +# Seed Data +# seed/ diff --git a/opic/seed-data/question-homes.json b/seed/opic/question-homes.json similarity index 100% rename from opic/seed-data/question-homes.json rename to seed/opic/question-homes.json diff --git a/vocabulary/seed-data/words.json b/seed/vocabulary/words.json similarity index 100% rename from vocabulary/seed-data/words.json rename to seed/vocabulary/words.json From 3a951014dadbf8c028279b07a6eda6df2ff35240 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Wed, 21 Jan 2026 16:34:55 +0900 Subject: [PATCH 406/528] =?UTF-8?q?chore:=20seed=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=ED=8F=B4=EB=8D=94=20=EA=B5=AC=EC=A1=B0=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- seed/README.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 seed/README.md diff --git a/seed/README.md b/seed/README.md new file mode 100644 index 00000000..6cbd04e7 --- /dev/null +++ b/seed/README.md @@ -0,0 +1,25 @@ +# Seed Data + +DynamoDB 초기 데이터 시드 파일 + +## 구조 + +``` +seed/ +├── opic/ +│ └── question-homes.json # OPIc 질문 데이터 +└── vocabulary/ + └── words.json # 단어 학습 데이터 +``` + +## 사용법 + +AWS CLI를 사용하여 DynamoDB에 데이터 업로드: + +```bash +# Vocabulary words +aws dynamodb batch-write-item --request-items file://seed/vocabulary/words.json + +# OPIc questions +aws dynamodb batch-write-item --request-items file://seed/opic/question-homes.json +``` From e87cff6952ffbe03b337a33b8d73e218d512b8d1 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 09:33:50 +0900 Subject: [PATCH 407/528] feat: add CI/CD pipeline configuration for CodePipeline --- ServerlessFunction/buildspec.yml | 57 ++++++ cicd/pipeline.yaml | 310 +++++++++++++++++++++++++++++++ 2 files changed, 367 insertions(+) create mode 100644 ServerlessFunction/buildspec.yml create mode 100644 cicd/pipeline.yaml diff --git a/ServerlessFunction/buildspec.yml b/ServerlessFunction/buildspec.yml new file mode 100644 index 00000000..355289d1 --- /dev/null +++ b/ServerlessFunction/buildspec.yml @@ -0,0 +1,57 @@ +version: 0.2 + +env: + variables: + JAVA_HOME: /usr/lib/jvm/java-21-amazon-corretto + SAM_CLI_TELEMETRY: 0 + +phases: + install: + runtime-versions: + java: corretto21 + commands: + - echo "Installing SAM CLI..." + - pip3 install aws-sam-cli + - sam --version + + pre_build: + commands: + - echo "Running tests..." + - chmod +x gradlew + - ./gradlew clean test + - echo "Tests completed" + + build: + commands: + - echo "Building SAM application..." + - sam build + - echo "Packaging SAM application..." + - sam package \ + --s3-bucket ${ARTIFACT_BUCKET} \ + --s3-prefix sam-packages \ + --output-template-file packaged-template.yaml + + post_build: + commands: + - echo "Build completed on $(date)" + +artifacts: + files: + - packaged-template.yaml + - samconfig.toml + base-directory: . + +cache: + paths: + - '/root/.gradle/caches/**/*' + - '/root/.gradle/wrapper/**/*' + +reports: + junit-reports: + files: + - 'build/test-results/test/*.xml' + file-format: JUNITXML + jacoco-reports: + files: + - 'build/reports/jacoco/test/jacocoTestReport.xml' + file-format: JACOCOXML diff --git a/cicd/pipeline.yaml b/cicd/pipeline.yaml new file mode 100644 index 00000000..7bca4946 --- /dev/null +++ b/cicd/pipeline.yaml @@ -0,0 +1,310 @@ +AWSTemplateFormatVersion: '2010-09-09' +Description: CI/CD Pipeline for Group2 English Study Backend + +Parameters: + GitHubConnectionArn: + Type: String + Default: "arn:aws:codeconnections:ap-northeast-2:682405977339:connection/6cdbf218-483a-4e49-9c74-dd6fe84dbd2b" + Description: ARN of the GitHub Connection + + GitHubRepo: + Type: String + Default: "Language-Study-Prooject/BE_Repository" + Description: GitHub repository (owner/repo) + + GitHubBranch: + Type: String + Default: "prod" + Description: Branch to trigger pipeline + + NotificationEmail: + Type: String + Description: Email address for pipeline notifications + +Resources: + ############################################# + # S3 Bucket for Pipeline Artifacts + ############################################# + ArtifactBucket: + Type: AWS::S3::Bucket + Properties: + BucketName: group2-englishstudy-pipeline-artifacts + VersioningConfiguration: + Status: Enabled + BucketEncryption: + ServerSideEncryptionConfiguration: + - ServerSideEncryptionByDefault: + SSEAlgorithm: AES256 + PublicAccessBlockConfiguration: + BlockPublicAcls: true + BlockPublicPolicy: true + IgnorePublicAcls: true + RestrictPublicBuckets: true + + ############################################# + # SNS Topic for Notifications + ############################################# + NotificationTopic: + Type: AWS::SNS::Topic + Properties: + TopicName: cicd-pipeline-notifications + DisplayName: CI/CD Pipeline Notifications + + EmailSubscription: + Type: AWS::SNS::Subscription + Properties: + TopicArn: !Ref NotificationTopic + Protocol: email + Endpoint: !Ref NotificationEmail + + ############################################# + # IAM Roles + ############################################# + + # CodePipeline Service Role + PipelineRole: + Type: AWS::IAM::Role + Properties: + RoleName: group2-codepipeline-role + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: codepipeline.amazonaws.com + Action: sts:AssumeRole + Policies: + - PolicyName: PipelinePolicy + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - codestar-connections:UseConnection + - codeconnections:UseConnection + Resource: !Ref GitHubConnectionArn + - Effect: Allow + Action: + - s3:GetObject + - s3:GetObjectVersion + - s3:PutObject + - s3:GetBucketVersioning + Resource: + - !GetAtt ArtifactBucket.Arn + - !Sub "${ArtifactBucket.Arn}/*" + - Effect: Allow + Action: + - codebuild:BatchGetBuilds + - codebuild:StartBuild + Resource: !GetAtt CodeBuildProject.Arn + - Effect: Allow + Action: + - cloudformation:CreateStack + - cloudformation:DeleteStack + - cloudformation:DescribeStacks + - cloudformation:UpdateStack + - cloudformation:CreateChangeSet + - cloudformation:DeleteChangeSet + - cloudformation:DescribeChangeSet + - cloudformation:ExecuteChangeSet + - cloudformation:SetStackPolicy + - cloudformation:ValidateTemplate + Resource: "*" + - Effect: Allow + Action: + - iam:PassRole + Resource: !GetAtt CloudFormationRole.Arn + - Effect: Allow + Action: + - sns:Publish + Resource: !Ref NotificationTopic + + # CodeBuild Service Role + CodeBuildRole: + Type: AWS::IAM::Role + Properties: + RoleName: group2-codebuild-role + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: codebuild.amazonaws.com + Action: sts:AssumeRole + Policies: + - PolicyName: CodeBuildPolicy + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - logs:CreateLogGroup + - logs:CreateLogStream + - logs:PutLogEvents + Resource: + - !Sub "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/codebuild/*" + - Effect: Allow + Action: + - s3:GetObject + - s3:GetObjectVersion + - s3:PutObject + - s3:GetBucketAcl + - s3:GetBucketLocation + Resource: + - !GetAtt ArtifactBucket.Arn + - !Sub "${ArtifactBucket.Arn}/*" + - "arn:aws:s3:::group2-englishstudy" + - "arn:aws:s3:::group2-englishstudy/*" + - Effect: Allow + Action: + - codebuild:CreateReportGroup + - codebuild:CreateReport + - codebuild:UpdateReport + - codebuild:BatchPutTestCases + - codebuild:BatchPutCodeCoverages + Resource: !Sub "arn:aws:codebuild:${AWS::Region}:${AWS::AccountId}:report-group/*" + + # CloudFormation Execution Role + CloudFormationRole: + Type: AWS::IAM::Role + Properties: + RoleName: group2-cloudformation-role + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: cloudformation.amazonaws.com + Action: sts:AssumeRole + ManagedPolicyArns: + - arn:aws:iam::aws:policy/AdministratorAccess + + ############################################# + # CodeBuild Project + ############################################# + CodeBuildProject: + Type: AWS::CodeBuild::Project + Properties: + Name: group2-englishstudy-build + Description: Build project for Group2 English Study Backend + ServiceRole: !GetAtt CodeBuildRole.Arn + Artifacts: + Type: CODEPIPELINE + Environment: + Type: LINUX_CONTAINER + ComputeType: BUILD_GENERAL1_MEDIUM + Image: aws/codebuild/amazonlinux2-x86_64-standard:5.0 + EnvironmentVariables: + - Name: ARTIFACT_BUCKET + Value: !Ref ArtifactBucket + Source: + Type: CODEPIPELINE + BuildSpec: ServerlessFunction/buildspec.yml + TimeoutInMinutes: 30 + Cache: + Type: S3 + Location: !Sub "${ArtifactBucket}/cache" + LogsConfig: + CloudWatchLogs: + Status: ENABLED + GroupName: !Sub "/aws/codebuild/group2-englishstudy-build" + + ############################################# + # CodePipeline + ############################################# + Pipeline: + Type: AWS::CodePipeline::Pipeline + Properties: + Name: group2-englishstudy-pipeline + RoleArn: !GetAtt PipelineRole.Arn + ArtifactStore: + Type: S3 + Location: !Ref ArtifactBucket + Stages: + # Source Stage + - Name: Source + Actions: + - Name: GitHub + ActionTypeId: + Category: Source + Owner: AWS + Provider: CodeStarSourceConnection + Version: '1' + Configuration: + ConnectionArn: !Ref GitHubConnectionArn + FullRepositoryId: !Ref GitHubRepo + BranchName: !Ref GitHubBranch + OutputArtifactFormat: CODE_ZIP + DetectChanges: true + OutputArtifacts: + - Name: SourceArtifact + RunOrder: 1 + + # Build Stage + - Name: Build + Actions: + - Name: Build + ActionTypeId: + Category: Build + Owner: AWS + Provider: CodeBuild + Version: '1' + Configuration: + ProjectName: !Ref CodeBuildProject + InputArtifacts: + - Name: SourceArtifact + OutputArtifacts: + - Name: BuildArtifact + RunOrder: 1 + + # Deploy Stage + - Name: Deploy + Actions: + - Name: Deploy + ActionTypeId: + Category: Deploy + Owner: AWS + Provider: CloudFormation + Version: '1' + Configuration: + ActionMode: CREATE_UPDATE + StackName: group2-englishstudy-prod + TemplatePath: BuildArtifact::packaged-template.yaml + Capabilities: CAPABILITY_IAM,CAPABILITY_AUTO_EXPAND + RoleArn: !GetAtt CloudFormationRole.Arn + InputArtifacts: + - Name: BuildArtifact + RunOrder: 1 + + ############################################# + # Pipeline Notification Rule + ############################################# + PipelineNotificationRule: + Type: AWS::CodeStarNotifications::NotificationRule + Properties: + Name: group2-pipeline-notifications + DetailType: FULL + Resource: !Sub "arn:aws:codepipeline:${AWS::Region}:${AWS::AccountId}:${Pipeline}" + EventTypeIds: + - codepipeline-pipeline-pipeline-execution-started + - codepipeline-pipeline-pipeline-execution-succeeded + - codepipeline-pipeline-pipeline-execution-failed + Targets: + - TargetType: SNS + TargetAddress: !Ref NotificationTopic + +############################################# +# Outputs +############################################# +Outputs: + PipelineUrl: + Description: URL to the CodePipeline console + Value: !Sub "https://${AWS::Region}.console.aws.amazon.com/codesuite/codepipeline/pipelines/${Pipeline}/view" + + ArtifactBucketName: + Description: S3 bucket for pipeline artifacts + Value: !Ref ArtifactBucket + + NotificationTopicArn: + Description: SNS topic for notifications + Value: !Ref NotificationTopic From 4e3785d4f83706fbda476d89e681d0eb32e45ded Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 09:48:17 +0900 Subject: [PATCH 408/528] fix: add SNS topic policy and DependsOn for notification rule --- cicd/pipeline.yaml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/cicd/pipeline.yaml b/cicd/pipeline.yaml index 7bca4946..19b19448 100644 --- a/cicd/pipeline.yaml +++ b/cicd/pipeline.yaml @@ -276,11 +276,32 @@ Resources: - Name: BuildArtifact RunOrder: 1 + ############################################# + # SNS Topic Policy for CodeStar Notifications + ############################################# + NotificationTopicPolicy: + Type: AWS::SNS::TopicPolicy + Properties: + Topics: + - !Ref NotificationTopic + PolicyDocument: + Version: '2012-10-17' + Statement: + - Sid: AllowCodeStarNotifications + Effect: Allow + Principal: + Service: codestar-notifications.amazonaws.com + Action: sns:Publish + Resource: !Ref NotificationTopic + ############################################# # Pipeline Notification Rule ############################################# PipelineNotificationRule: Type: AWS::CodeStarNotifications::NotificationRule + DependsOn: + - Pipeline + - NotificationTopicPolicy Properties: Name: group2-pipeline-notifications DetailType: FULL From 126f89a9e334475415b3c90d746305c8aedc09de Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 09:51:35 +0900 Subject: [PATCH 409/528] fix: correct paths in buildspec.yml for CodeBuild --- ServerlessFunction/buildspec.yml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/ServerlessFunction/buildspec.yml b/ServerlessFunction/buildspec.yml index 355289d1..fdff4e4f 100644 --- a/ServerlessFunction/buildspec.yml +++ b/ServerlessFunction/buildspec.yml @@ -17,6 +17,7 @@ phases: pre_build: commands: - echo "Running tests..." + - cd ServerlessFunction - chmod +x gradlew - ./gradlew clean test - echo "Tests completed" @@ -24,6 +25,7 @@ phases: build: commands: - echo "Building SAM application..." + - cd $CODEBUILD_SRC_DIR/ServerlessFunction - sam build - echo "Packaging SAM application..." - sam package \ @@ -38,8 +40,7 @@ phases: artifacts: files: - packaged-template.yaml - - samconfig.toml - base-directory: . + base-directory: ServerlessFunction cache: paths: @@ -49,9 +50,9 @@ cache: reports: junit-reports: files: - - 'build/test-results/test/*.xml' + - 'ServerlessFunction/build/test-results/test/*.xml' file-format: JUNITXML jacoco-reports: files: - - 'build/reports/jacoco/test/jacocoTestReport.xml' + - 'ServerlessFunction/build/reports/jacoco/test/jacocoTestReport.xml' file-format: JACOCOXML From 8fa4eb64dae997a535170bbf40ae656c5ad71746 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 09:53:43 +0900 Subject: [PATCH 410/528] fix: remove hardcoded JAVA_HOME, use runtime default --- ServerlessFunction/buildspec.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/ServerlessFunction/buildspec.yml b/ServerlessFunction/buildspec.yml index fdff4e4f..79b8ecb7 100644 --- a/ServerlessFunction/buildspec.yml +++ b/ServerlessFunction/buildspec.yml @@ -2,7 +2,6 @@ version: 0.2 env: variables: - JAVA_HOME: /usr/lib/jvm/java-21-amazon-corretto SAM_CLI_TELEMETRY: 0 phases: From 00dfc65334263791b57fd0dce9dc2163790f202a Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 09:55:36 +0900 Subject: [PATCH 411/528] fix: add gradle wrapper for CI/CD build --- .gitignore | 3 ++- .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 45633 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 +++++++ 3 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 ServerlessFunction/gradle/wrapper/gradle-wrapper.jar create mode 100644 ServerlessFunction/gradle/wrapper/gradle-wrapper.properties diff --git a/.gitignore b/.gitignore index f1597d4c..389f80e0 100644 --- a/.gitignore +++ b/.gitignore @@ -12,7 +12,8 @@ out/ # Gradle .gradle/ -gradle/ +# gradle wrapper는 커밋 필요 +!gradle/wrapper/ # Maven *.jar diff --git a/ServerlessFunction/gradle/wrapper/gradle-wrapper.jar b/ServerlessFunction/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..f8e1ee3125fe0768e9a76ee977ac089eb657005e GIT binary patch literal 45633 zcma&NV|1n6wyqu9PQ|uu+csuwn-$x(T~Woh?Nr6KUD3(A)@l1Yd+oj6Z_U=8`RAE` z#vE6_`?!1WLs1443=Ieh3JM4ai0JG2|2{}S&_HrxszP*9^5P7#QX*pVDq?D?;6T8C z{bWO1$9at%!*8ax*TT&F99vwf1Ls+3lklsb|bC`H`~Q z_w}*E9P=Wq;PYlGYhZ^lt#N97bt5aZ#mQcOr~h^B;R>f-b0gf{y(;VA{noAt`RZzU z7vQWD{%|q!urW2j0Z&%ChtL(^9m` zgaU%|B;V#N_?%iPvu0PVkX=1m9=*SEGt-Lp#&Jh%rz6EJXlV^O5B5YfM5j{PCeElx z8sipzw8d=wVhFK+@mgrWyA)Sv3BJq=+q+cL@=wuH$2;LjY z^{&+X4*HFA0{QvlM_V4PTQjIdd;d|2YuN;s|bi!@<)r-G%TuOCHz$O(_-K z)5in&6uNN<0UfwY=K>d;cL{{WK2FR|NihJMN0Q4X+(1lE)$kY?T$7UWleIU`i zQG#X-&&m-8x^(;n@o}$@vPMYRoq~|FqC~CU3MnoiifD{(CwAGd%X#kFHq#4~%_a!{ zeX{XXDT#(DvX7NtAs7S}2ZuiZ>gtd;tCR7E)3{J^`~#Vd**9qz%~JRFAiZf{zt|Dr zvQw!)n7fNUn_gH`o9?8W8t_%x6~=y*`r46bjj(t{YU*qfqd}J}*mkgUfsXTI>Uxl6 z)Fj>#RMy{`wINIR;{_-!xGLgVaTfNJ2-)%YUfO&X5z&3^E#4?k-_|Yv$`fpgYkvnA%E{CiV zP|-zAf8+1@R`sT{rSE#)-nuU7Pwr-z>0_+CLQT|3vc-R22ExKT4ym@Gj77j$aTVns zp4Kri#Ml?t7*n(;>nkxKdhOU9Qbwz%*#i9_%K<`m4T{3aPbQ?J(Mo`6E5cDdbAk%X z+4bN%E#a(&ZXe{G#V!2Nt+^L$msKVHP z|APpBhq7knz(O2yY)$$VyI_Xg4UIC*$!i7qQG~KEZnO@Q1i89@4ZKW*3^Wh?o?zSkfPxdhnTxlO!3tAqe_ zuEqHVcAk3uQIFTpP~C{d$?>7yt3G3Fo>syXTus>o0tJdFpQWC27hDiwC%O09i|xCq z@H6l|+maB;%CYQIChyhu;PVYz9e&5a@EEQs3$DS6dLIS+;N@I0)V}%B`jdYv;JDck zd|xxp(I?aedivE7*19hesoa-@Xm$^EHbbVmh$2^W-&aTejsyc$i+}A#n2W*&0Qt`5 zJS!2A|LVV;L!(*x2N)GjJC;b1RB_f(#D&g_-};a*|BTRvfdIX}Gau<;uCylMNC;UG zzL((>6KQBQ01wr%7u9qI2HLEDY!>XisIKb#6=F?pAz)!_JX}w|>1V>X^QkMdFi@Jr z`1N*V4xUl{qvECHoF?#lXuO#Dg2#gh|AU$Wc=nuIbmVPBEGd(R#&Z`TP9*o%?%#ob zWN%ByU+55yBNfjMjkJnBjT!cVDi}+PR3N&H(f8$d^Pu;A_WV*{)c2Q{IiE7&LPsd4 z!rvkUf{sco_WNSIdW+btM#O+4n`JiceH6%`7pDV zRqJ@lj=Dt(e-Gkz$b!c2>b)H$lf(fuAPdIsLSe(dZ4E~9+Ge!{3j~>nS%r)eQZ;Iq ztWGpp=2Ptc!LK_TQ8cgJXUlU5mRu|7F2{eu*;a>_5S<;bus=t*IXcfzJRPv4xIs;s zt2<&}OM>KxkTxa=dFMfNr42=DL~I}6+_{`HT_YJBiWkpVZND1Diad~Yr*Fuq{zljr z*_+jXk=qVBdwlQkYuIrB4GG*#voba$?h*u0uRNL+87-?AjzG2X_R9mzQ7BJEawutObr|ey~%in>6k%A`K*`pb-|DF5m})!`b=~osoiW2)IFh?_y9y<3Cix_ znvC=bjBX1J820!%%9FaB@v?hAsd05e@w$^ZAvtUp*=Bi+Owkl?rLa6F#yl{s+?563 zmn2 zV95%gySAJ$L!Vvk4kx!n@mo`3Mfi`2lXUkBmd%)u)7C?Pa;oK~zUQ#p0u{a|&0;zNO#9a4`v^3df90X#~l_k$q7n&L5 z?TszF842~g+}tgUP}UG?ObLCE1(Js_$e>XS7m%o7j@@VdxePtg)w{i5an+xK95r?s zDeEhgMO-2$H?@0{p-!4NJ)}zP+3LzZB?FVap)ObHV6wp}Lrxvz$cjBND1T6ln$EfJ zZRPeR2lP}K0p8x`ahxB??Ud;i7$Y5X!5}qBFS+Zp=P^#)08nQi_HuJcN$0=x;2s53 zwoH}He9BlKT4GdWfWt)@o@$4zN$B@5gVIN~aHtwIhh{O$uHiMgYl=&Vd$w#B2 zRv+xK3>4E{!)+LXA2#*K6H~HpovXAQeXV(^Pd%G_>ro0(4_@`{2Ag(+8{9pqJ>Co$ zRRV(oX;nD+Jel_2^BlNO=cQP8q*G#~R3PTERUxvug_C4T3qwb9MQE|^{5(H*nt`fn z^%*p-RwkAhT6(r>E@5w8FaB)Q<{#`H9fTdc6QBuSr9D-x!Tb9f?wI=M{^$cB5@1;0 z+yLHh?3^c-Qte@JI<SW`$bs5Vv9!yWjJD%oY z8Cdc$a(LLy@tB2)+rUCt&0$&+;&?f~W6+3Xk3g zy9L�|d9Zj^A1Dgv5yzCONAB>8LM`TRL&7v_NKg(bEl#y&Z$py}mu<4DrT@8HHjE zqD@4|aM>vt!Yvc2;9Y#V;KJ8M>vPjiS2ycq52qkxInUK*QqA3$&OJ`jZBo zpzw&PT%w0$D94KD%}VN9c)eCueh1^)utGt2OQ+DP(BXszodfc1kFPWl~BQ5Psy*d`UIf zc}zQ8TVw35jdCSc78)MljC-g3$GX2$<0<3MEQXS&i<(ZFClz9WlL}}?%u>S2hhEk_ zyzfm&@Q%YVB-vw3KH|lU#c_)0aeG^;aDG&!bwfOz_9)6gLe;et;h(?*0d-RV0V)1l zzliq#`b9Y*c`0!*6;*mU@&EFSbW>9>L5xUX+unp%@tCW#kLfz)%3vwN{1<-R*g+B_C^W8)>?n%G z<#+`!wU$L&dn)Pz(9DGGI%RlmM2RpeDy9)31OZV$c2T>-Jl&4$6nul&e7){1u-{nP zE$uZs%gyanu+yBcAb+jTYGy(^<;&EzeLeqveN12Lvv)FQFn0o&*qAaH+gLJ)*xT9y z>`Y`W?M#K7%w26w?Oen>j7=R}EbZ;+jcowV&i}P|IfW^C5GJHt5D;Q~)|=gW3iQ;N zQGl4SQFtz=&~BGon6hO@mRnjpmM79ye^LY_L2no{f_M?j80pr`o3BrI7ice#8#Zt4 zO45G97Hpef+AUEU%jN-dLmPYHY(|t#D)9|IeB^i1X|eEq+ymld_Uj$l^zVAPRilx- z^II$sL4G~{^7?sik2BK7;ZV-VIVhrKjUxBIsf^N&K`)5;PjVg-DTm1Xtw4-tGtElU zJgVTCk4^N4#-kPuX=7p~GMf5Jj5A#>)GX)FIcOqY4lf}Vv2gjrOTuFusB@ERW-&fb zTp=E0E?gXkwzn)AMMY*QCftp%MOL-cbsG{02$0~b?-JD{-nwj58 zBHO1YL~yn~RpnZ6*;XA|MSJeBfX-D?afH*E!2uGjT%k!jtx~OG_jJ`Ln}lMQb7W41 zmTIRd%o$pu;%2}}@2J$x%fg{DZEa-Wxdu6mRP~Ea0zD2+g;Dl*to|%sO-5mUrZ`~C zjJ zUe^**YRgBvlxl<(r0LjxjSQKiTx+E<7$@9VO=RYgL9ldTyKzfqR;Y&gu^ub!fVX7u z3H@;8j#tVgga~EMuXv_#Q8<*uK@R{mGzn92eDYkF1sbxh5!P|M-D)T~Ae*SO`@u$Q z7=5s)HM)w~s2j5{I67cqSn6BLLhCMcn0=OTVE?T7bAmY!T+xZ_N3op~wZ3Oxlm6(a5qB({6KghlvBd9HJ#V6YY_zxbj-zI`%FN|C*Q`DiV z#>?Kk7VbuoE*I9tJaa+}=i7tJnMRn`P+(08 za*0VeuAz!eI7giYTsd26P|d^E2p1f#oF*t{#klPhgaShQ1*J7?#CTD@iDRQIV+Z$@ z>qE^3tR3~MVu=%U%*W(1(waaFG_1i5WE}mvAax;iwZKv^g1g}qXY7lAd;!QQa#5e= z1_8KLHje1@?^|6Wb(A{HQ_krJJP1GgE*|?H0Q$5yPBQJlGi;&Lt<3Qc+W4c}Ih~@* zj8lYvme}hwf@Js%Oj=4BxXm15E}7zS0(dW`7X0|$damJ|gJ6~&qKL>gB_eC7%1&Uh zLtOkf7N0b;B`Qj^9)Bfh-( z0or96!;EwEMnxwp!CphwxxJ+DDdP4y3F0i`zZp-sQ5wxGIHIsZCCQz5>QRetx8gq{ zA33BxQ}8Lpe!_o?^u2s3b!a-$DF$OoL=|9aNa7La{$zI#JTu_tYG{m2ly$k?>Yc); zTA9ckzd+ibu>SE6Rc=Yd&?GA9S5oaQgT~ER-|EwANJIAY74|6 z($#j^GP}EJqi%)^jURCj&i;Zl^-M9{=WE69<*p-cmBIz-400wEewWVEd^21}_@A#^ z2DQMldk_N)6bhFZeo8dDTWD@-IVunEY*nYRON_FYII-1Q@@hzzFe(lTvqm}InfjQ2 zN>>_rUG0Lhaz`s;GRPklV?0 z;~t4S8M)ZBW-ED?#UNbCrsWb=??P># zVc}MW_f80ygG_o~SW+Q6oeIUdFqV2Fzys*7+vxr^ZDeXcZZc;{kqK;(kR-DKL zByDdPnUQgnX^>x?1Tz~^wZ%Flu}ma$Xmgtc7pSmBIH%&H*Tnm=L-{GzCv^UBIrTH5 zaoPO|&G@SB{-N8Xq<+RVaM_{lHo@X-q}`zjeayVZ9)5&u*Y>1!$(wh9Qoe>yWbPgw zt#=gnjCaT_+$}w^*=pgiHD8N$hzqEuY5iVL_!Diw#>NP7mEd?1I@Io+?=$?7cU=yK zdDKk_(h_dB9A?NX+&=%k8g+?-f&`vhAR}&#zP+iG%;s}kq1~c{ac1@tfK4jP65Z&O zXj8Ew>l7c|PMp!cT|&;o+(3+)-|SK&0EVU-0-c&guW?6F$S`=hcKi zpx{Z)UJcyihmN;^E?*;fxjE3kLN4|&X?H&$md+Ege&9en#nUe=m>ep3VW#C?0V=aS zLhL6v)|%$G5AO4x?Jxy8e+?*)YR~<|-qrKO7k7`jlxpl6l5H&!C4sePiVjAT#)b#h zEwhfkpFN9eY%EAqg-h&%N>E0#%`InXY?sHyptcct{roG42Mli5l)sWt66D_nG2ed@ z#4>jF?sor7ME^`pDlPyQ(|?KL9Q88;+$C&3h*UV*B+*g$L<{yT9NG>;C^ZmPbVe(a z09K^qVO2agL`Hy{ISUJ{khPKh@5-)UG|S8Sg%xbJMF)wawbgll3bxk#^WRqmdY7qv zr_bqa3{`}CCbREypKd!>oIh^IUj4yl1I55=^}2mZAAW6z}Kpt3_o1b4__sQ;b zv)1=xHO?gE-1FL}Y$0YdD-N!US;VSH>UXnyKoAS??;T%tya@-u zfFo)@YA&Q#Q^?Mtam19`(PS*DL{PHjEZa(~LV7DNt5yoo1(;KT)?C7%^Mg;F!C)q= z6$>`--hQX4r?!aPEXn;L*bykF1r8JVDZ)x4aykACQy(5~POL;InZPU&s5aZm-w1L< z`crCS5=x>k_88n(*?zn=^w*;0+8>ui2i>t*Kr!4?aA1`yj*GXi#>$h8@#P{S)%8+N zCBeL6%!Ob1YJs5+a*yh{vZ8jH>5qpZhz_>(ph}ozKy9d#>gba1x3}`-s_zi+SqIeR z0NCd7B_Z|Fl+(r$W~l@xbeAPl5{uJ{`chq}Q;y8oUN0sUr4g@1XLZQ31z9h(fE_y( z_iQ(KB39LWd;qwPIzkvNNkL(P(6{Iu{)!#HvBlsbm`g2qy&cTsOsAbwMYOEw8!+75D!>V{9SZ?IP@pR9sFG{T#R*6ez2&BmP8*m^6+H2_ z>%9pg(+R^)*(S21iHjLmdt$fmq6y!B9L!%+;wL5WHc^MZRNjpL9EqbBMaMns2F(@h zN0BEqZ3EWGLjvY&I!8@-WV-o@>biD;nx;D}8DPapQF5ivpHVim8$G%3JrHtvN~U&) zb1;=o*lGfPq#=9Moe$H_UhQPBjzHuYw;&e!iD^U2veY8)!QX_E(X@3hAlPBIc}HoD z*NH1vvCi5xy@NS41F1Q3=Jkfu&G{Syin^RWwWX|JqUIX_`}l;_UIsj&(AFQ)ST*5$ z{G&KmdZcO;jGIoI^+9dsg{#=v5eRuPO41<*Ym!>=zHAXH#=LdeROU-nzj_@T4xr4M zJI+d{Pp_{r=IPWj&?%wfdyo`DG1~|=ef?>=DR@|vTuc)w{LHqNKVz9`Dc{iCOH;@H5T{ zc<$O&s%k_AhP^gCUT=uzrzlEHI3q`Z3em0*qOrPHpfl1v=8Xkp{!f9d2p!4 zL40+eJB4@5IT=JTTawIA=Z%3AFvv=l1A~JX>r6YUMV7GGLTSaIn-PUw| z;9L`a<)`D@Qs(@P(TlafW&-87mcZuwFxo~bpa01_M9;$>;4QYkMQlFPgmWv!eU8Ut zrV2<(`u-@1BTMc$oA*fX;OvklC1T$vQlZWS@&Wl}d!72MiXjOXxmiL8oq;sP{)oBe zS#i5knjf`OfBl}6l;BSHeY31w8c~8G>$sJ9?^^!)Z*Z*Xg zbTbkcbBpgFui(*n32hX~sC7gz{L?nlnOjJBd@ zUC4gd`o&YB4}!T9JGTe9tqo0M!JnEw4KH7WbrmTRsw^Nf z^>RxG?2A33VG3>E?iN|`G6jgr`wCzKo(#+zlOIzp-^E0W0%^a>zO)&f(Gc93WgnJ2p-%H-xhe{MqmO z8Iacz=Qvx$ML>Lhz$O;3wB(UI{yTk1LJHf+KDL2JPQ6#m%^bo>+kTj4-zQ~*YhcqS z2mOX!N!Q$d+KA^P0`EEA^%>c12X(QI-Z}-;2Rr-0CdCUOZ=7QqaxjZPvR%{pzd21HtcUSU>u1nw?)ZCy+ zAaYQGz59lqhNXR4GYONpUwBU+V&<{z+xA}`Q$fajmR86j$@`MeH}@zz*ZFeBV9Ot< ze8BLzuIIDxM&8=dS!1-hxiAB-x-cVmtpN}JcP^`LE#2r9ti-k8>Jnk{?@Gw>-WhL=v+H!*tv*mcNvtwo)-XpMnV#X>U1F z?HM?tn^zY$6#|(|S~|P!BPp6mur58i)tY=Z-9(pM&QIHq+I5?=itn>u1FkXiehCRC zW_3|MNOU)$-zrjKnU~{^@i9V^OvOJMp@(|iNnQ%|iojG2_Snnt`1Cqx2t)`vW&w2l zwb#`XLNY@FsnC-~O&9|#Lpvw7n!$wL9azSk)$O}?ygN@FEY({2%bTl)@F2wevCv`; zZb{`)uMENiwE|mti*q5U4;4puX{VWFJ#QIaa*%IHKyrU*HtjW_=@!3SlL~pqLRs?L zoqi&}JLsaP)yEH!=_)zmV-^xy!*MCtc{n|d%O zRM>N>eMG*Qi_XAxg@82*#zPe+!!f#;xBxS#6T-$ziegN-`dLm z=tTN|xpfCPng06|X^6_1JgN}dM<_;WsuL9lu#zLVt!0{%%D9*$nT2E>5@F(>Fxi%Y zpLHE%4LZSJ1=_qm0;^Wi%x56}k3h2Atro;!Ey}#g&*BpbNXXS}v>|nn=Mi0O(5?=1V7y1^1Bdt5h3}oL@VsG>NAH z1;5?|Sth=0*>dbXSQ%MQKB?eN$LRu?yBy@qQVaUl*f#p+sLy$Jd>*q;(l>brvNUbIF0OCf zk%Q;Zg!#0w0_#l)!t?3iz~`X8A>Yd3!P&A4Ov6&EdZmOixeTd4J`*Wutura(}4w@KV>i#rf(0PYL&v^89QiXBP6sj=N;q8kVxS}hA! z|3QaiYz!w+xQ%9&Zg${JgQ*Ip_bg2rmmG`JkX^}&5gbZF!Z(gDD1s5{QwarPK(li- zW9y-CiQ`5Ug1ceN1w7lCxl=2}7c*8_XH8W7y0AICn19qZ`w}z0iCJ$tJ}NjzQCH90 zc!UzpKvk%3;`XfFi2;F*q2eMQQ5fzO{!`KU1T^J?Z64|2Z}b1b6h80_H%~J)J)kbM0hsj+FV6%@_~$FjK9OG7lY}YA zRzyYxxy18z<+mCBiX?3Q{h{TrNRkHsyF|eGpLo0fKUQ|19Z0BamMNE9sW z?vq)r`Qge{9wN|ezzW=@ojpVQRwp##Q91F|B5c`a0A{HaIcW>AnqQ*0WT$wj^5sWOC1S;Xw7%)n(=%^in zw#N*+9bpt?0)PY$(vnU9SGSwRS&S!rpd`8xbF<1JmD&6fwyzyUqk){#Q9FxL*Z9%#rF$} zf8SsEkE+i91VY8d>Fap#FBacbS{#V&r0|8bQa;)D($^v2R1GdsQ8YUk(_L2;=DEyN%X*3 z;O@fS(pPLRGatI93mApLsX|H9$VL2)o(?EYqlgZMP{8oDYS8)3G#TWE<(LmZ6X{YA zRdvPLLBTatiUG$g@WK9cZzw%s6TT1Chmw#wQF&&opN6^(D`(5p0~ zNG~fjdyRsZv9Y?UCK(&#Q2XLH5G{{$9Y4vgMDutsefKVVPoS__MiT%qQ#_)3UUe=2fK)*36yXbQUp#E98ah(v`E$c3kAce_8a60#pa7rq6ZRtzSx6=I^-~A|D%>Riv{Y`F9n3CUPL>d`MZdRmBzCum2K%}z@Z(b7#K!-$Hb<+R@Rl9J6<~ z4Wo8!!y~j(!4nYsDtxPIaWKp+I*yY(ib`5Pg356Wa7cmM9sG6alwr7WB4IcAS~H3@ zWmYt|TByC?wY7yODHTyXvay9$7#S?gDlC?aS147Ed7zW!&#q$^E^_1sgB7GKfhhYu zOqe*Rojm~)8(;b!gsRgQZ$vl5mN>^LDgWicjGIcK9x4frI?ZR4Z%l1J=Q$0lSd5a9 z@(o?OxC72<>Gun*Y@Z8sq@od{7GGsf8lnBW^kl6sX|j~UA2$>@^~wtceTt^AtqMIx zO6!N}OC#Bh^qdQV+B=9hrwTj>7HvH1hfOQ{^#nf%e+l)*Kgv$|!kL5od^ka#S)BNT z{F(miX_6#U3+3k;KxPyYXE0*0CfL8;hDj!QHM@)sekF9uyBU$DRZkka4ie^-J2N8w z3PK+HEv7kMnJU1Y+>rheEpHdQ3_aTQkM3`0`tC->mpV=VtvU((Cq$^(S^p=+$P|@} zueLA}Us^NTI83TNI-15}vrC7j6s_S`f6T(BH{6Jj{Lt;`C+)d}vwPGx62x7WXOX19 z2mv1;f^p6cG|M`vfxMhHmZxkkmWHRNyu2PDTEpC(iJhH^af+tl7~h?Y(?qNDa`|Ogv{=+T@7?v344o zvge%8Jw?LRgWr7IFf%{-h>9}xlP}Y#GpP_3XM7FeGT?iN;BN-qzy=B# z=r$79U4rd6o4Zdt=$|I3nYy;WwCb^`%oikowOPGRUJ3IzChrX91DUDng5_KvhiEZwXl^y z+E!`Z6>}ijz5kq$nNM8JA|5gf_(J-);?SAn^N-(q2r6w31sQh6vLYp^ z<>+GyGLUe_6eTzX7soWpw{dDbP-*CsyKVw@I|u`kVX&6_h5m!A5&3#=UbYHYJ5GK& zLcq@0`%1;8KjwLiup&i&u&rmt*LqALkIqxh-)Exk&(V)gh9@Fn+WU=6-UG^X2~*Q-hnQ$;;+<&lRZ>g0I`~yuv!#84 zy>27(l&zrfDI!2PgzQyV*R(YFd`C`YwR_oNY+;|79t{NNMN1@fp?EaNjuM2DKuG%W z5749Br2aU6K|b=g4(IR39R8_!|B`uQ)bun^C9wR4!8isr$;w$VOtYk+1L9#CiJ#F) z)L}>^6>;X~0q&CO>>ZBo0}|Ex9$p*Hor@Ej9&75b&AGqzpGpM^dx}b~E^pPKau2i5 zr#tT^S+01mMm}z480>-WjU#q`6-gw4BJMWmW?+VXBZ#JPzPW5QQm@RM#+zbQMpr>M zX$huprL(A?yhv8Y81K}pTD|Gxs#z=K(Wfh+?#!I$js5u8+}vykZh~NcoLO?ofpg0! zlV4E9BAY_$pN~e-!VETD&@v%7J~_jdtS}<_U<4aRqEBa&LDpc?V;n72lTM?pIVG+> z*5cxz_iD@3vIL5f9HdHov{o()HQ@6<+c}hfC?LkpBEZ4xzMME^~AdB8?2F=#6ff!F740l&v7FN!n_ zoc1%OfX(q}cg4LDk-1%|iZ^=`x5Vs{oJYhXufP;BgVd*&@a04pSek6OS@*UH`*dAp z7wY#70IO^kSqLhoh9!qIj)8t4W6*`Kxy!j%Bi%(HKRtASZ2%vA0#2fZ=fHe0zDg8^ zucp;9(vmuO;Zq9tlNH)GIiPufZlt?}>i|y|haP!l#dn)rvm8raz5L?wKj9wTG znpl>V@};D!M{P!IE>evm)RAn|n=z-3M9m5J+-gkZHZ{L1Syyw|vHpP%hB!tMT+rv8 zIQ=keS*PTV%R7142=?#WHFnEJsTMGeG*h)nCH)GpaTT@|DGBJ6t>3A)XO)=jKPO<# zhkrgZtDV6oMy?rW$|*NdJYo#5?e|Nj>OAvCXHg~!MC4R;Q!W5xcMwX#+vXhI+{ywS zGP-+ZNr-yZmpm-A`e|Li#ehuWB{{ul8gB&6c98(k59I%mMN9MzK}i2s>Ejv_zVmcMsnobQLkp z)jmsJo2dwCR~lcUZs@-?3D6iNa z2k@iM#mvemMo^D1bu5HYpRfz(3k*pW)~jt8UrU&;(FDI5ZLE7&|ApGRFLZa{yynWx zEOzd$N20h|=+;~w$%yg>je{MZ!E4p4x05dc#<3^#{Fa5G4ZQDWh~%MPeu*hO-6}2*)t-`@rBMoz&gn0^@c)N>z|Ikj8|7Uvdf5@ng296rq2LiM#7KrWq{Jc7;oJ@djxbC1s6^OE>R6cuCItGJ? z6AA=5i=$b;RoVo7+GqbqKzFk>QKMOf?`_`!!S!6;PSCI~IkcQ?YGxRh_v86Q%go2) zG=snIC&_n9G^|`+KOc$@QwNE$b7wxBY*;g=K1oJnw8+ZR)ye`1Sn<@P&HZm0wDJV* z=rozX4l;bJROR*PEfHHSmFVY3M#_fw=4b_={0@MP<5k4RCa-ZShp|CIGvW^9$f|BM#Z`=3&=+=p zp%*DC-rEH3N;$A(Z>k_9rDGGj2&WPH|}=Pe3(g}v3=+`$+A=C5PLB3UEGUMk92-erU%0^)5FkU z^Yx#?Gjyt*$W>Os^Fjk-r-eu`{0ZJbhlsOsR;hD=`<~eP6ScQ)%8fEGvJ15u9+M0c|LM4@D(tTx!T(sRv zWg?;1n7&)-y0oXR+eBs9O;54ZKg=9eJ4gryudL84MAMsKwGo$85q6&cz+vi)9Y zvg#u>v&pQQ1NfOhD#L@}NNZe+l_~BQ+(xC1j-+({Cg3_jrZ(YpI{3=0F1GZsf+3&f z#+sRf=v7DVwTcYw;SiNxi5As}hE-Tpt)-2+lBmcAO)8cP55d0MXS*A3yI5A!Hq&IN zzb+)*y8d8WTE~Vm3(pgOzy%VI_e4lBx&hJEVBu!!P|g}j(^!S=rNaJ>H=Ef;;{iS$$0k-N(`n#J_K40VJP^8*3YR2S`* zED;iCzkrz@mP_(>i6ol5pMh!mnhrxM-NYm0gxPF<%(&Az*pqoRTpgaeC!~-qYKZHJ z2!g(qL_+hom-fp$7r=1#mU~Dz?(UFkV|g;&XovHh~^6 z1eq4BcKE%*aMm-a?zrj+p;2t>oJxxMgsmJ^Cm%SwDO?odL%v6fXU869KBEMoC0&x>qebmE%y+W z51;V2xca9B=wtmln74g7LcEgJe1z7o>kwc1W=K1X7WAcW%73eGwExo&{SSTnXR+pA zRL)j$LV7?Djn8{-8CVk94n|P>RAw}F9uvp$bpNz<>Yw3PgWVJo?zFYH9jzq zU|S+$C6I?B?Jm>V{P67c9aRvK283bnM(uikbL=``ew5E)AfV$SR4b8&4mPDkKT&M3 zok(sTB}>Gz%RzD{hz|7(AFjB$@#3&PZFF5_Ay&V3?c&mT8O;9(vSgWdwcy?@L-|`( z@@P4$nXBmVE&Xy(PFGHEl*K;31`*ilik77?w@N11G7IW!eL@1cz~XpM^02Z?CRv1R z5&x6kevgJ5Bh74Q8p(-u#_-3`246@>kY~V4!XlYgz|zMe18m7Vs`0+D!LQwTPzh?a zp?X169uBrRvG3p%4U@q_(*^M`uaNY!T6uoKk@>x(29EcJW_eY@I|Un z*d;^-XTsE{Vjde=Pp3`In(n!ohHxqB%V`0vSVMsYsbjN6}N6NC+Ea`Hhv~yo@ z|Ab%QndSEzidwOqoXCaF-%oZ?SFWn`*`1pjc1OIk2G8qSJ$QdrMzd~dev;uoh z>SneEICV>k}mz6&xMqp=Bs_0AW81D{_hqJXl6ZWPRNm@cC#+pF&w z{{TT0=$yGcqkPQL>NN%!#+tn}4H>ct#L#Jsg_I35#t}p)nNQh>j6(dfd6ng#+}x3^ zEH`G#vyM=;7q#SBQzTc%%Dz~faHJK+H;4xaAXn)7;)d(n*@Bv5cUDNTnM#byv)DTG zaD+~o&c-Z<$c;HIOc!sERIR>*&bsB8V_ldq?_>fT!y4X-UMddUmfumowO!^#*pW$- z_&)moxY0q!ypaJva)>Bc&tDs?D=Rta*Wc^n@uBO%dd+mnsCi0aBZ3W%?tz844FkZD zzhl+RuCVk=9Q#k;8EpXtSmR;sZUa5(o>dt+PBe96@6G}h`2)tAx(WKR4TqXy(YHIT z@feU+no42!!>y5*3Iv$!rn-B_%sKf6f4Y{2UpRgGg*dxU)B@IRQ`b{ncLrg9@Q)n$ zOZ7q3%zL99j1{56$!W(Wu{#m|@(6BBb-*zV23M!PmH7nzOD@~);0aK^iixd%>#BwR zyIlVF*t4-Ww*IPTGko3RuyJ*^bo-h}wJ{YkHa2y3mIK%U%>PFunkx0#EeIm{u93PX z4L24jUh+37=~WR47l=ug2cn_}7CLR(kWaIpH8ojFsD}GN3G}v6fI-IMK2sXnpgS5O zHt<|^d9q}_znrbP0~zxoJ-hh6o81y+N;i@6M8%S@#UT)#aKPYdm-xlbL@v*`|^%VS(M$ zMQqxcVVEKe5s~61T77N=9x7ndQ=dzWp^+#cX}v`1bbnH@&{k?%I%zUPTDB(DCWY6( zR`%eblFFkL&C{Q}T6PTF0@lW0JViFzz4s5Qt?P?wep8G8+z3QFAJ{Q8 z9J41|iAs{Um!2i{R7&sV=ESh*k(9`2MM2U#EXF4!WGl(6lI!mg_V%pRenG>dEhJug z^oLZ?bErlIPc@Jo&#@jy@~D<3Xo%x$)(5Si@~}ORyawQ{z^mzNSa$nwLYTh6E%!w_ zUe?c`JJ&RqFh1h18}LE47$L1AwR#xAny*v9NWjK$&6(=e0)H_v^+ZIJ{iVg^e_K-I z|L;t=x>(vU{1+G+P5=i7QzubN=dWIe(bqeBJ2fX85qrBYh5pj*f05=8WxcP7do(_h zkfEQ1Fhf^}%V~vr>ed9*Z2aL&OaYSRhJQFWHtirwJFFkfJdT$gZo;aq70{}E#rx((U`7NMIb~uf>{Y@Fy@-kmo{)ei*VjvpSH7AU zQG&3Eol$C{Upe`034cH43cD*~Fgt?^0R|)r(uoq3ZjaJqfj@tiI~`dQnxfcQIY8o| zx?Ye>NWZK8L1(kkb1S9^8Z8O_(anGZY+b+@QY;|DoLc>{O|aq(@x2=s^G<9MAhc~H z+C1ib(J*&#`+Lg;GpaQ^sWw~f&#%lNQ~GO}O<5{cJ@iXSW4#};tQz2#pIfu71!rQ( z4kCuX$!&s;)cMU9hv?R)rQE?_vV6Kg?&KyIEObikO?6Nay}u#c#`ywL(|Y-0_4B_| zZFZ?lHfgURDmYjMmoR8@i&Z@2Gxs;4uH)`pIv#lZ&^!198Fa^Jm;?}TWtz8sulPrL zKbu$b{{4m1$lv0`@ZWKA|0h5U!uIwqUkm{p7gFZ|dl@!5af*zlF% zpT-i|4JMt%M|0c1qZ$s8LIRgm6_V5}6l6_$cFS# z83cqh6K^W(X|r?V{bTQp14v|DQg;&;fZMu?5QbEN|DizzdZSB~$ZB%UAww;P??AT_-JFKAde%=4c z*WK^Iy5_Y`*IZ+cF`jvkCv~Urz3`nP{hF!UT7Z&e;MlB~LBDvL^hy{%; z7t5+&Ik;KwQ5H^i!;(ly8mfp@O>kH67-aW0cAAT~U)M1u`B>fG=Q2uC8k}6}DEV=% z<0n@WaN%dDBTe*&LIe^r-!r&t`a?#mEwYQuwZ69QU3&}7##(|SIP*4@y+}%v^Gb3# zrJ~68hi~77ya4=W-%{<(XErMm>&kvG`{7*$QxRf(jrz|KGXJN3Hs*8BfBx&9|5sZ1 zpFJ1(B%-bD42(%cOiT@2teyYoUBS`L%<(g;$b6nECbs|ADH5$LYxj?i3+2^#L@d{%E(US^chG<>aL7o>Fg~ zW@9wW@Mb&X;BoMz+kUPUcrDQOImm;-%|nxkXJ8xRz|MlPz5zcJHP<+yvqjB4hJAPE zRv>l{lLznW~SOGRU~u77UcOZyR#kuJrIH_){hzx!6NMX z>(OKAFh@s2V;jk|$k5-Q_ufVe;(KCrD}*^oBx{IZq^AB|7z*bH+g_-tkT~8S$bzdU zhbMY*g?Qb;-m|0`&Jm}A8SEI0twaTfXhIc=no}$>)n5^cc)v!C^YmpxLt=|kf%!%f zp5L$?mnzMt!o(fg7V`O^BLyjG=rNa}=$hiZzYo~0IVX$bp^H-hQn!;9JiFAF<3~nt zVhpABVoLWDQ}2vEEF3-?zzUA(yoYw&$YeHB#WGCXkK+YrG=+t0N~!OmTN;fK*k>^! zJW_v+4Q4n2GP7vgBmK;xHg^7zFqyTTfq|0+1^H2lXhn6PpG#TB*``?1STTC#wcaj3 zG~Q9!XHZ#1oPZo zB6h(BVIW5K+S@JG_HctDLHWb;wobZ0h(3xr6(uUspOSK0WoSHeF$ZLw@)cpoIP|kL zu`GnW>gD$rMt}J0qa9kJzn0s`@JNy1Crkb&;ve|()+_%!x%us>1_Xz|BS>9oQeD3O zy#CHX#(q^~`=@_p$XV6N&RG*~oEH$z96b8S16(6wqH)$vPs=ia!(xPVX5o&5OIYQ%E(-QAR1}CnLTIy zgu1MCqL{_wE)gkj0BAezF|AzPJs=8}H2bHAT-Q@Vuff?0GL=)t3hn{$Le?|+{-2N~`HWe24?!1a^UpC~3nK$(yZ_Gp(EzP~a{qe>xK@fN zEETlwEV_%9d1aWU0&?U>p3%4%>t5Pa@kMrL4&S@ zmSn!Dllj>DIO{6w+0^gt{RO_4fDC)f+Iq4?_cU@t8(B^je`$)eOOJh1Xs)5%u3hf; zjw$47aUJ9%1n1pGWTuBfjeBumDI)#nkldRmBPRW|;l|oDBL@cq1A~Zq`dXwO)hZkI zZ=P7a{Azp06yl(!tREU`!JsmXRps!?Z~zar>ix0-1C+}&t)%ist94(Ty$M}ZKn1sDaiZpcoW{q&ns8aWPf$bRkbMdSgG+=2BSRQ6GG_f%Lu#_F z&DxHu+nKZ!GuDhb>_o^vZn&^Sl8KWHRDV;z#6r*1Vp@QUndqwscd3kK;>7H!_nvYH zUl|agIWw_LPRj95F=+Ex$J05p??T9_#uqc|q>SXS&=+;eTYdcOOCJDhz7peuvzKoZhTAj&^RulU`#c?SktERgU|C$~O)>Q^$T8ippom{6Ze0_44rQB@UpR~wB? zPsL@8C)uCKxH7xrDor zeNvVfLLATsB!DD{STl{Fn3}6{tRWwG8*@a2OTysNQz2!b6Q2)r*|tZwIovIK9Ik#- z0k=RUmu97T$+6Lz%WQYdmL*MNII&MI^0WWWGKTTi&~H&*Ay7&^6Bpm!0yoVNlSvkB z;!l3U21sJyqc`dt)82)oXA5p>P_irU*EyG72iH%fEpUkm1K$?1^#-^$$Sb=c8_? zOWxxguW7$&-qzSI=Z{}sRGAqzy3J-%QYz2Cffj6SOU|{CshhHx z6?5L$V_QIUbI)HZ9pwP9S15 zXc%$`dxETq+S3_jrfmi$k=)YO5iUeuQ&uX}rCFvz&ubO?u)tv|^-G_`h$pb+8vn@f z7@eQe#Kx|8^37a4d0GulYIUAW|@I5|NIh%=OqHU{(>(UhKvJ}i_X*>!Geb+Rs0MWf66Lf z-cQ(4QOENSbTX$6w_9w4{5eR?14#?)Jqf2UCk5US4bnz8!e>vFduH6(cZZ=5*_!M# zUTZ_b<4v@}dSQOcH@wt-s;3JhkVDct$6k9!ETdi-tplkaxl^qF=p}Q8KMVm+ zeIa2q?RYr}nM0d_W2YWv%JKyCrGSePj8GrRN)<$Nsq8l$X=>`W;?>0eME3|8t&d$~ zH`XG45lBh>-te_f0Mh0??)=Ee0~zESx=sZPv<#!sAVv$0qTn@CmCUNJU<#=`GC)&P z9zuV~9*3_n2*ZQBUh)2xIi;0yo)9XXJxM-VB*6xpyz{Rx2ZCvFnF$2aPcYFG( zyXkO(B30?mt;5GW&{m^w3?!P`#_o;Y%P2z^A`|4%Bt2@3G?C2dcSPNy1#HMXZ>{+L z3BE#xvqR@Ub}uKfzGC=RO|W%dJpUK#m8p&Dk|6Ub8S+dN3qxf9dJ_|WFdM9CSNQv~ zjaFxIX`xx-($#Fq+EI76uB@kK=B4FS0k=9(c8UQnr(nLQxa2qWbuJyD7%`zuqH|eF zNrpM@SIBy@lKb%*$uLeRJQ->ko3yaG~8&}9|f z*KE`oMHQ(HdHlb&)jIzj5~&z8r}w?IM1KSdR=|GFYzDwbn8-uUfu+^h?80e*-9h%Nr;@)Q-TI#dN1V zQPT2;!Wk)DP`kiY<{o7*{on%It(j0&qSv=fNfg3qeNjT@CW{WT<)1Eig!g9lAGx6& zk9_Zrp2I+w_f!LRFsgxKA}gO=xSPSY``kn=c~orU4+0|^K762LWuk_~oK{!-4N8p8 zUDVu0ZhvoD0fN8!3RD~9Bz5GNEn%0~#+E-Js}NTBX;JXE@29MdGln$Aoa3Nzd@%Z= z^zuGY4xk?r(ax7i4RfxA?IPe27s87(e-2Z_KJ(~YI!7bhMQvfN4QX{!68nj@lz^-& z1Zwf=V5ir;j*30AT$nKSfB;K9(inDFwbI^%ohwEDOglz}2l}0!#LsdS3IW43= zBR#E@135bu#VExrtj?)RH^PM(K4B`d=Z6^kix`8$C1&q)w1<&?bAS?70}9fZwZU7R z5RYFo?2Q>e3RW2dl&3E^!&twE<~Lk+apY?#4PM5GWJb2xuWyZs6aAH-9gqg${<1?M zoK&n+$ZyGIi=hakHqRu{^8T4h@$xl?9OM46t;~1_mPs9}jV58E-sp!_CPH4<^A|Q5 zedUHmiyxTc2zgdxU?4PyQ{ON@r+Ucn1kjWSOsh6WzLV~Bv&vWLaj#Xz4VSDs*F#@M>#e^ixNCQ-J|iC=LcB*M4WUb>?v6C z14^8h9Ktd1>XhO$kb-rRL}SFTH)kSu+Dwds$oed7qL)Jbd zhQys4$Uw~yj03)6Kq+K-BsEDftLgjDZk@qLjAyrb5UMeuO^>D43g%0GoKJ~TO0o!D z9E$WfxEDFTT?~sT?|!7aYY*mpt`}i;WTgY|Cb4{Cscrmzb(?UE+nz1wC3#QSjbg>N zleu?7MGaQ&FtejK#?07Uq$vIZX5FqR*a=(zUm`Fq$VUl){GQ{2MA)_j4H$U8FZ`=A z&GU_an)?g%ULunbBq4EUT7uT=vI6~uapKC|H6uz1#Rqt$G(!hE7|c8_#JH%wp9+F? zX`ZigNe9GzC(|Nr8GlmwPre3*Nfu+ zF=SHtv_g@vvoVpev$Jxs|F7CH`X5#HAI=ke(>G6DQQ=h^U8>*J=t5Z3Fi>eH9}1|6 znwv3k>D=kufcp= zAyK#v05qERJxS_ts79QVns}M?sIf(hCO0Q9hKe49a@PzvqzZXTAde6a)iZLw|8V-) ziK`-s)d(oQSejO?eJki$UtP0ped)5T1b)uVFQJq*`7w8liL4TX*#K`hdS!pY9aLD+ zLt=c$c_wt^$Wp~N^!_nT(HiDVibxyq2oM^dw-jC~+3m-#=n!`h^8JYkDTP2fqcVC& zA`VWy*eJC$Eo7qIe@KK;HyTYo0c{Po-_yp=>J(1h#)aH5nV8WGT(oSP)LPgusH%N$?o%U%2I@Ftso10xd z)Tx(jT_vrmTQJDx0QI%9BRI1i!wMNy(LzFXM_wucgJGRBUefc413a9+)}~*UzvNI{KL# z_t4U&srNV|0+ZqwL(<}<%8QtjUD8kSB&p$v^y}vuEC2wyW{aXp2{LTi$EBEHjVnS# z+4=G$GUllsjw&hTbh6z%D2j=cG>gkNVlh|24QUfD*-x9OMzTO93n*pE(U7Vz7BaL% z@(c!GbEjK~fH}sqbB1JNI!~b+AYb5le<-qxDA9&r2o)|epl9@5Ya7}yVkcM)yW6KY7QOX_0-N=)+M!A$NpG? z6BvZ8Tb}Pw(i9f7S00=KbWmNvJGL(-MsAz3@aR~PM$Z>t)%AiCZu?A|?P*~UdhhFT`;Nb)MxIg*0QlkYVX+46( zSd%WoWR@kYToK7)(J=#qUD-ss;4M&27w#03y6$gk6X<-VL8AJM@NFTx#Z!n)F5T357%njjKyjro(yW8ceP{!%;*Y>DN`&_18p(z2Hg$%K zohbgJcp%+ux%q6F?(sc_mYJ<$;DxgkTEi?yjT6Du@+n(KsKtFHcO%7O z=AsfLSTdE2>7a@0^`;)?Fg|s2XOPV&fo<%Q)Izaw4s&RvrX0^+aPNq|yE?oSa7 zsnNs!+vGcTM4yM|$9so*2Nv;ngDD}b0MjH6i4e|l^O`lzCRj)-qa6f%|afJpmf(S1J2k7Nt^!;Q}0 z4ejPF?^M~Sv+@LYn&IFUk2;1h?kb8lfrT`oMm=JBm{fo5N|HY~yQQ`T*e2?!tF%*t zf+ncx15$NdF82GXrpP5rJ7!PVE3>u`ME$9Hw5RlP zUh+s#pg{9kEOsAhvu2pry#@dvbB3Lti+9VkLxPZSl;fNr9}wv1cTahUw_Py7%Xp;C zaz__|kz*ydKiYbsqK{?cXhqR(!1KMoV-+!mz>3S8S`Va4kD#(aKyqecGXB^nF*>mS z1gG>fKZc?R~Tye>%x+43D8=e zf0eKr-)>VEu7^I{%T}BT-WaGXO3+x<2w2jwnXePdc2#BdofU6wbE)ZWHsyj=_NT3o z)kySji#CTEnx8*-n=88Ld+TuNy;x$+vDpZ)=XwCr_Gx-+N=;=LCE7CqKX9 zQ-0{jIr zktqqWCgBa3PYK*qQqd=BO70DfM#|JvuW*0%zmTE{mBI$55J=Y2b2UoZ)Yk z3M%rrX7!nwk#@CXTr5=J__(3cI-8~*MC+>R);Z)0Zkj2kpsifdJeH)2uhA|9^B;S$ z4lT3;_fF@g%#qFotZ#|r-IB*zSo;fokxbsmMrfNfJEU&&TF%|!+YuN=#8jFS4^f*m zazCA-2krJ-;Tkufh!-urx#z*imYo|n6+NDGT#*EH355(vRfrGnr*x z5PWMD7>3IwEh=lO^V>O>iLP~S!GjrvI5lx<7oOg(d;6uEFqo5>IwptBQz;`>zx`n$ zjZQ#Hb)qJdQy#ML&qcfmb$KT+f_1#uYNo7HHDY}7xAw8qbl;9LWO-cndfI=5$%jBw zb}K3U%88Fg^|&0Vc~99bKl|$3JzdawRZ|`7%1S<8B7>9*rWAT0U<@mHDfnL1`~1U| zDw7m@<@}C|zqeHM(OK@di6~sKHiJvk^I0^S<LBe^_xZsUOzVkYSE)Bxn*NekQYbyTn5SRt!n{EseOo-$u)vjM(PV%6cIG3Kv$>dd}HUyXi;_Lv>}OyUj38dPe8+1Pr?{LXnIBCoTnocD60@vhsz+GG5lJB9ncgP8T6@LwuzZ)J zKETBS~AvzGE!{u^+Rd-|Gn!rc@UUnioP0{@_j_>tg8YI#?y zL-H$=&xXkCJ2Qe7&exbI!z`OyPxBp|4_ zZrrc;OAb%T4Ze%7E}FBB`8t$QN0sA3vpwU>?7QAmE%-ethXdCtby$Qm3v$lNxB2a7 ze6F5eEWV`={#W(G)Va}7?$D65WF|f0nmfZT;?=LE6Yz{{W3CV2h^Ma+LXdZ(HMVKZ z!YXJ*34lo!FA>)jSo@*!Hs_)IwmTo6pBr3c^j2u_amZ~g;&Z2jZIw!}v@w8DtZz7|A%rFksD4^HYB!xFAqX;u0HxPeG!3Z(z z4}+^N5-nckKf2YSR5R_}PD+2?Wq#BOiON74#{`u=4f59WKdy_77EYq~_|X6cNtno{ zZ?WLwbV57Z6uI|uY_;vzv~~`eiiOl($Au7C*X<&MY5v0b`KEu-GW}{2UNfmmrP!^Y zAOczy!}TIJsom=}kxH)9W`&Rp&rR6T7y&~5nXbut;wcs@M?aa^9j{ZDtx=1?P8TV{ zee2kKf%CE$mogyKKT=xQQ#)OCl9bjc)}{p2X$}aG`^B0w0yi-rI!d4e-u9uR$kJK3 zhqBG9Wx<-3DFw5olJ6neF@hB;8o(r(GB_;p1i>}cjN`JNEZg-dlxtLL=8~gfLrBy_ z1~bGh{I>_xqh(}?%bCf1U6~K@+N*i}bTi+pUAW)oM0`D*PeJq=S(-|Plxe9OqxBRg zM((r)xkSH@j!8@+=cA4US0fDL&O?W~x=Mlu>7zvHO2sy7D5_7ulP+YMecP~}F0b*K z3oO2j{o&WHd<&UWcyA(&6hvBJv}qUZ!@R<(mwKB^;y3zeE1>LzbDWSkRD1|5MZPx( zxd=&MsQi1eE@@6W+4N`cF?yh!3R5JlAV--&RONWQ#?SbrQ95<@ag>C{jQmGXpQX{) z1dbFg1_`qLxuDZnX#PKfCW*Jl3F&^7@gO&{>Nb8um$VBcF1!AL=N6`A%BFj=`QaPI z+m^`n+{o)KLif;Gt|7aQ(XXRP@x)jJt}s{&S`I3}jPTY>$@W0BD3Oif^ehs~!H7T1FUSWxLS&W;0q6+azjbWn?3!q$ z9qbmdr4H4Y)p^NOACJ^L>u}NS8T0_5hW)G z%Hv}dAqM}d@t;|hf8>+NHHPi*xePsRlqr46njzhiXXZti7i5+GTKcrlxA->OJ9*Pna`02EIA5~(SMV`T@H6F2VtwwP1$tYujbC1^VE$Yd&I`WSwB^1( zT7NP3|85z#R%&wktjwY_i*n_$RRZPM^ota{LPV%*>=>sAv%fn*cnkCIX{^SJRmwZv z!?f@T&D%Lz@*!mNYTGp{J|7)~PR*ib`;l^E)rQw@)Qn0ECnB8W1S_SbLZWdqcmo?V zX5g0_3qhn4TrN27^x#Qdq*4*G1L|)I^b8GuP_8O{p|M`uvZO6McXa>OSQRW|kQTNPZ#Zyj~SZ<`6B)Y+}jxpn+YT>MhZ!Rxyd@rU>N zP>MkDBLX|<)SJaO?Ge=!D>i+Wq&PgneO?ZXUq4IQuTq z+V{ZGkuw77o~o$!b>4ov`6CKJ)$cf=S6%1ZQyYU!kz_qiuNxY2*Bh;K9J6o_YV6xQ znW|>x+#Mymu&wF9P|3wP*(ZjwE+ou|{eFqMv}d_iEyH zQ?NSf3VX+EpbrIKmp|oD-t_rh(D#e)fp)dYbG{=yPj-3-#l+iu7r+~#w|(#wv@G0` z38`Yhf5CznhyDEhD;jzaz7fc8L?(n-m zR#|5hqq#yRoeTm+h^9J42mnB>BY>HSu&&O-Hxo6j!dqck)dGS&odS@Hsk2-*Z~x z0!%{@gT645S5DeF@JZeE$DFl*nJB8Z|JKvs%7d`KjbJ*AsA_=fEZ&V9=*+K{(TF^( ztjjYr(7@fV^tDs9c*#=8)ZRKO17A5Z`8v*)U+?hS>3sEfgh3`#vFO^7n}&&adV?}n zdy&BY1h|I@eBm=l*kqiJn>vNkOH4l$Op5Hw3K_w8lF!6T@-H)S2W|Km#6!-X#NqLJ zsiVDrc%*@I3^Gen$)6O0C_qw;8{aucF;}U^1%YE`?AYTtb`Z$B$vfhcHQF`VCB(Pf z_G#fV*Colv-k!O+=^nDNe(03?m+RTu&28d%>JrrwFNb{ND&?Ad(=DP@voz$usk1|w z&#gTB7F)#*LtY6@pIb(g72*LcnXRlTPQAD?)ZFnB*EsZqxM&Uk_KGXnR{4}K`I6i- zU9}R>tiO0De1Hx=kAy>7O+nKO@kGQEYOai&S9&WTY+flvR?uhI695W-xZnq4aRMh8 zwfp)+KYWVB#r=5AwwlSdM4@x7-R_{2;1iqz2lXL$7iu1>5W*+I)jlkMs>60=LN)Y= zbPw;;%U+%p_&{2Obemh$BLmbpDd31YxJ8#TpH3~3B8QLUMvx1X5Vl48hWSNN*UTlO zQgQyZbmyjGC-s$3tnB z0mfKUu2+_c`ZVvDVwUy#j3W*l^BSXXQ%=r6Z}C73jx8DAk!t7k{dK^udpHIcUejp# zyx}og$Hr+f>9kaZvno*Om`d|VTUce9tHM=R8thoG!a=NT$s;g@n_rAN%cp7nnLuav z6}j56TSSfPL$p#y#!5TVyqa3zTzi7@#IoeR=E6CdS`JrR+@i2DwZ?T*bh+(k5!a)0 zgRdF93z8XJ|5?>hDN!YAW5cK=+BwDLNT_+otd zqC@*{S0hCKZ+TnN*2&qx+WP;ZjHA`yytPcwKl~)uy)sQ}Q*0-&3X|YFYAjmolaciq zxS$r5^fxICetD*Dw78M9leVvhAOZ$=;SP7L!Vs?+0f1h*YCuTXIt03iAf)0=0KEvZ zB69o-zg`0C#hQ>`4`}1g=a~EID(j9HbjJG^tV-zumR-+fahTPveA{%0u2uQwMZ%}5 zwY!|}i0oTd&>^QSRhIKU+cMC#|C3f>|647?v1B(wH)EWb{vuJEJh~!#|J7%=h!x3| zCH6m}wg;>Q&?@5Ct1%n`lj%*>9a52d@wmvE`=aQjtz$sWj3V;fDns5<7d2*``)u1( zh!Ub>!#N0m=Vz1n1=El zwb2IVRw$6NIFRpGyUoM0iqc$IPehcmm7<0s7F*Yv+zq?_%pf*SS~~}s0M`m(rMbx% zi?|Wjr6fJN`_J8&B2$4+V+iO~m>s~Zr2T3Y3HGREFQ%%pEoU0N));AeSVM#gYQ>l} z0`RhgS`R^pJH31YQ~eTeJiI}g$&^|nv{!h?8mJK{{XDt+sG8D`7)$jvM#hjPI(5sS zfFW4s7wao%Lo| z#pJRC?iZOai;57ANs|vm6%}rPlGo}}Aso1t#xJn}%VW@~1WSjh(@JTgM$0x6ZQ)gB zdiox3f>kqGZY}+R<;wlNoWJ8#X-v)1;wRD*ec*wnvsN06Q@cZuD`deT-Bu&G;2fBC z0FE1%pG@{Yo2O87&dE;w???%`9s1gs=3GpM8xx_}=AB$K9y=cD);^iE*p4;T1RU%B zBPr)yqOBX<2}xt%g9qr>;z&|?4vhhw7@$a}Uy2b%_^VdB^VfzrebKUPnq;hliCNU% zVt3R5EHkhN^Pv`REF+npA@#HdCQN9IbQbqSDs^+zt(A6;rLwN+@Em}WrV5vPEo!w^ zSCd3RZ8{7a@d9@|IF&&G%irS7FHle?@49LctrtTt=rP$W)se*#RkFmyf)D1^U6EYI zfh+N?uH?-))O$9zM19VsuGn8?o~5`scXU?!P@_cWP&1U4PQqGus=sQzrX+YvKG%XBL3nt6!&M<#}wqA;Mo(}qrq<1lNkpQD-T#-y>grt|E+JNU) z2j+g+QPcA9VEFc0k;H(hSNOpp$I+!$ z&d&W6kBM9+c{X%vr_X0}tdB5dvEDyk5H2*T(QW8Yz-#tjvF?up=^Kfym``^!&O-X! z@HdfpHn;}_)y$Xjb-5cR$Q#-XdhKpmJG5pl>h*Q2(u*gt_4(>6?kG)%T3*&TT0qI( zL!aR~4HiJiaHlgdNcOQP6xx1f3AWx&8}(NEps|G!cO>J^rE2@&-t#_Jb7GYgnLnML~1ze1D$?~BwbgA^=pr55tC|d7w42vN11_8bS75u z_MRKqE7Xik8fk>6(VE5{qT}6rSzd|o}Zb>*aI*Bwg%ccE$_ytH;g2H z^i3qY!+aE*&s^BMH9TI6GLm&9c`D6)3{-+?2Pon+040Yuv$2(LqV*krKhTg5CHOj* zquacxc1&~=S(O@gR8aI#?R%)meONmw1rub9E2QzeM$pBBm2wbPNR3tab{op53<oFwaUbARdD5jSA_6zmKX7!VicEP1m)rYnk{P- zruRj;4c8S29Rd#Baf|fq_pA^r3K#qRHS;($XNoLI*`puZjM?bA0tH>FDiVc9qR*|3 zGn#nhqxkvqFwRfCB~2yA0pxWapfjCdAem$utuon-`*6}mUP?l%$CE(FjAwL%Oe7GQbu7*+&q>*(cAofJr^gg>xw>hx-SO7Lx2)I} zJ)tV1XKbkE4sS&La#-smSq>S9gBzGLH%v?KVezdGv%Xs}kDJZJi{lDl(FpLZupBta z3iDlkd6LlkRro}+El?GIObw06D%NTXpL{W}Ve*%u#{wTC=+VHS%o`sAez&cYz|Tn` zcK_~pvN%cd^8FlFypCjTjw9@ulLoJ^!QAK*++^wC2~}CFeoY;q6y~r&f^+0>LR6)n z$hSev@GzzGgDc>)#u5_;{T9^5y5I?m=z7=J!eVId8p6R5>NV8)h|bA}#3KUufq4CPGiWYvGj%0=H@Q66);F)#cDMND4 zX|?rg>Bb28q*a!_sgVF(A=OeC&je$C4>$0%yy;Fla-hl(|9Ww4!@Q#E2hpJMMxpQ2L+R;+ZMpS+|j*F`Fh}p)`a_*<`AaeFzNEq^- zlF$7BFKD%p@K+3$Vx%N{QOayKKWU#JOAwXiLO62cA6=|DiDG_Z=ef;f&gQ5-?+Pb+ z)4NsyEZXCdjq5tgDN39V9!6#w25+R1;PD7ss;hFvQn}Hnl3^3h<`ylzJdVEL>|Jj0 zg>=Pscwx&;pWEzMn`ld**$1F-nhqlMuX;G{lWrT<<4$7MZ^*4a2hAMf)3eYiT$lRz&9({j<=%DWIRpgu zoOns@gF}AQ_6Y5RhySg7yMtJcYQap6^hgy{`zX1Zv26q4<)g@t%aIi|-lmcySuRN8*5f*$aEFi8o#kMKRCMnrAY~l`= zez#50^@Qo+6r508>iKfAbbc3JwCnjnmw;~=mlMG`(H8EJz7W6mh@mdinO&)#zHX=| z&|fo@s`;njVkkCMczSnp+TnW8YPU4w2&QmzEh1}orF~KlT=V+`!!rH|PtULCcL!P*m0EaN0Ad2qBw%Gs40jfu=%`N*k@z2-p?&B?Yum-p+h?7(!D^ z&f2Bn_#t!4HM2y^*1GN;U+_x8T$Z2>U9Yx;p_9Qf=ww z2hxO^*{%p9-CwMKz}C4mTi8xvqhivltE|}Kgq5MK@f6tBT&`@RYzsFFi>*eMZ0Z6Y zKBl`GOh!U%C+PXJ|7PF)V*~#8eS80D@v-NL2U&;i62W}k+vJAC+7xF`eq%c0b?{PVTcqiDr%6jLBdkVcTwLJSd313SP)1r=;2`cORbMzrhqZxMWcTWru5-l_H8;f|?{^M%%7>sU zGx2{fX*t;7SewS|NvPR-6F5p(ji7d}CK#%7y}jsPkgj%F5cUbQ?b7uWpYks^|DL*n zau%X$^(%wXMS3c;C4=p*#q>ahmLH5woLsn-YcZP~mH-rGnRyl#KU4MsLu+G3z90+q zM$HCWgZYR`8_I%8)SYuBltP$sN`-6hcjnzhDsVl+Y}yqMN*4MWsJX_6R>Cyw8cHGQ z1>r%vkDxxc#ACA4+-ZO|QBMUz`YHrS{l-*$> zi(n_;4{Gn+d2gn)TA<9) zibWdKJv#s_f5K}vM=d0NaYrd;5A+Fy^=+WgKC`@bS>!P5@K4fzE#VYfMcNdbbvLPY zeR~!f3xU>|pfq-LOsoF=t94x%K!8>#8tR4KQ2G3Yr?Cb98^KL*+G8``rHMpNUN}-T z5HGAkiLh{WR;N$Nk3X_2^3pW=vOFTOb(LS0Wu)0)I{8sZj>}5ZGtD=va-72l&5`L= zhyzBWie2UrC|?(sTcuk$OwvV4oVlxc3ncXPj|cD%%*6(hoKMd5wzPQs^6g)B0xK#d zemOodB7D(!@v!|eYqMfx@M#b+D)PwAuvimOW#13i-xAR5)Ai; zXNX(A@M*y&+TVZI zGHo$F*Ipg~Rnp`KlMNAl2o86}r%Yv9#!O-oo`pe`880;-Y28tR)b4H%nqXXHxN9m0 zI&#!(XhT=T3$WS$)K4#Y=ceN`MsP0v1X{nIoQ14S2^--MnUp21=V3&Uv8|y}^}7Vl zI5tRbOp#?@ay6uncZFE0hg}kt(k%piw^M8;0yynsK_!l~uP??IqzmKJMUqAW^GG{~ z7Fg)Q&zBlp z%Tj8jOUpuR>YHP6zYsX?)aJ`)_pRwu+Tn8I;brOW_`v$u$`$9T)cO*O$j=?mg>dW$ zw=&3=v||fqCr`-$okN*$S9(Nyrs}+Lu#IwDg2xSBz_VfU*?A&26vwv>&>*U_TT7-7 zS~X}fT%9+q(Xvc0qzOG^8gmMcZE9izi5feqvY(aY=%reP+wVZ&cRd`^y6}-gJ&_6n zR%Wdl3vQ4DOt!X9ry7j%=+7pLPdus*@7dZMBo0_WKZPD1(o{=;D> zyc9_WFI3{URv=d6EXcnOG0$(J(R#8Oz$kmuSFQ{-Y20}1027!FkodTU!fouSybwqn zRO-$2BH(w4)$wiPo<1w-4*p=Q0@YKRm^cgiA>~ho)U8^e>SBk*!@xvr0CdvnLHS#CACVuQfgzF>8qV znqf{oO1}RWhiZ3g!Tx9sk!JfLqcP`>Ksx#vZuLg-DC6h4mT!vlU zqw0`0CzZgY!EN0*{sQnDNFn;T<+e_x$zY|n;p0@d^hK*n!S!=#^;P{*D^6~h!T7r6 zoiMxtovMo-dj*{qZPy*c3gaMBEDQDkINU%d8HeBZVlRuzkCId9rx{?L= z-dLlk$w&JX5wn+8`mtqCpKnx+w+$@6DEUI}8P%xN$MEsw%S1-$9PM6r^jP-@?cS<# zhg$wl0X=s3{8EZ2U9(};p{X_b1@jJuGgx`gDK{6MpF|XON_=Rv%-<Ee1cuuy?nl9xVDa~x=+8ppnOQ9 zN$53qi4QQ!co(;f!#YJ8(=Z>_9UF#(QOVjS7T!g2)*Oecrf-R^)tFugBkQsMVNua# zS;1V^#fJS{h+!O+FgS%0=Pd9;lMa0QHn?-n(<0b2$<|@r>fjiyw6u*UoGmU$ayJM@ zfp;c4@{$b*Z_v9?8ZEp{m6Q(mDHW<``n?jg-ZN)Hhvxn*l=O1f*K%{5s77WCt!ugS?*2oG5-Q)JEJd0+W5=doeD$Wh?U$ZRg)K$v8cmQ{hba9jw_mF&X zi-dV?WITgIz!!0uB~jE?(t`&qo{WGyUspX| zc6+F2K4l5$LqxERF#`I&k^^opVIMZjGhsJ^vI0c%kV+|&_k>~}ueTtj;^Dfb@xHs` z)-39elzVA~D~n_aoyBQ1>Qd2!;E!G*pZM&RX`r*y)b`yxvP2;#vM*;CQGPg|gni)} z47`Log3PUyVfdmJ2zvHBhg7T#D-H=myzkeUa$@);WC(yB4k^*$wda3=S-UH5Q1Hx6 zPcGxMP&kXBa+4$s#Sw3-V?mlHj^8&bLpIN~GkYj;!;M!$ZxvtQY4j&Ngz_mxuQRqx zYTbN6epx@-!0jRV5yiSIJ<^mCZ<|;&x2~a)t+(eAVB!1XpCZok*Z2C5P7&>z-Oy?t zf@F(_FLsSrfCus61+Vt~svP%(u<4pzT5{w*0XqfPV%~|=%aq^$=*U+_trGQaoUxbt zBV#Yqx+ULku8yPJs4gGcC?+3iRt_6)Oi0DNLxdb(!n!cup_XUZ3eDe(!DChZ!IG&L?_;T-1GB!R;;Sk;l3Y*JQ!I|l20_f}ZyC;4D7R@6F z>%z~wV;Bj1b(*kp26Ed!Y-OKxNbt3%t))xxOrazWsmwvW;uaSaJ0ou+{01vXvU>_V z6Ha@+;giVaiyg`J8ENQf)Pq>!Nf22>XFHnXTNk84&jp-^YwmlUqnOll8)5mzlO$o! z#fSMwH8Pn+Fy7O5M5#ZGr$cKfaGf8g;XN)<*TrQjMk<}_oRf&b6qZoR38Q{Zxo{V; zby+J_hCZT1>`4~jnQxo|ji%BQ0=BLzC6c!1=B(jS5+fcp%q)JI)=c3{D|=k5;0&c2 zrbRE|qxkNqah2nvextOvjYA{T43n1c6eO7B9DH)tLqB46E7;0xKM=%#wx-*-+*OY{ zQ#7gMStz%I&2&rbo>#T20OD_#g`WYbt9+!MC08%zSMhqMoRk)7VOk%~`sD%(U6zzO zdmSC9@x0GCv2_)umYc5@#%efP0_cu+=f^}k$H9$N_>piA_(5UM_o{++8+Yf8SJ)?C zDd3l=GGm3EEy;&Z6N=+XP@IM0L=uW^ooyYQYyx1vwFR?@U~BAtAqTu%Mi2 zTCQh$K=UZA{P`Cw0I$xAh_f?fq-Goe`7I38{3L8?K3`lRhSAyB)tHT@4c!Y;bJAAS z3u>Q7qx>9SJs4$EB=hxh)u`W5jp?>^g1s_MV7<1zN zXt{FSt?Mt&8aCy67<)b@eg@h0iCW@%+pF-V>p${fyEk6_Gvp|ms{Whi-9eNId?xzZ zm|MI>F;JSuaUnQp#|}k3o&ddCZEeTI608txuU4~7K(wg9 zg%+}(7h2@(%>LI1F*puF(h$ZD`Q+ar!VoVajPY0-XS$>6F_F?sc6Mr7>SL-&{pC;2 zKx@2{@ULz7RCpaKg$iu2rcY+y*~qaPo0}^7T1K$_(NPS<1;V zTj8-xC%WvgDI_YYEG{bySvyO3M>XKY)oXgGG*eB{yDgNQ3s3)A~@n>!O#lNh0! z(-dqW#_z&mMfq#2+u61N`L^({4UoU8wE5`4c}{SGFzKb(BK8hM%cf_zj_HmC48)M& z398ICVJTGzBaz7K{L+Ew=;z^0xA``wbtPs`r+Wrb^_vzzhukq{;A`t&-ktzb zbqy`Z0#D6fdVAiodjF3J+qI*vu#=OCjiL4bIIXEf4?zmN7(H|+<+WfR7@7jrMx7FY z5*0X1enhay-q^M?j}3Pd^|U9(C3#CQU3=hlc~@y9@NQD{UZNfC^5?Cuuuu{ebn_<7 zEzudv*b@QP%)N^5jP;86nQGb<*SOytCM5wmf-=rH#K{Wd$2(X#S$jF}XIxZC1)zir zU2Wq>hIB44nCTqx2x<{_wiVzLSJR}L%P!Y|lFHtA_=bDj=OqvmmSZ}ffuqPge#V-f zZDk|XX0RK}=73LxL`H%OXxK*^I2!fp&kxatErK~&tM3@j1a(Yrq$z)R()i?}p|0^Y zhW&8!IpRA1jJ3e!p66ZY=eBmEA+$A`!%s+{Cz!s$IA`{_Dh0^jt!vn;+Nw}hx019Q z_Wg=#-G-~&@>l=&H~48$L8`LX)!Bcq%(DFa2Loc91u@WcwlHzJwo{cdur>bQ;{fr_ z`rC5QRQ_)`8EadJzz-{K&sUI~>NX>P|c4l)fKS0gkuGe_P ziaQy!%CK(CtAwj-J8&#kyU=G(k%3y`!gS9dU&1xIrGRL|!&aVMEaezUIpopoET~xE zp`%~`LZfn!Lu^+00?>v4UOfM!HeeQoLZP<#o`^9oi69|$0BM?n17R~tGpY)eJiv@$ zTV-~ZZ*}C1J{a}p`>l$Bx8qRBq91;dLdmp84auzmcd|XzJG%I|r z^E-8Tm~jRn_>as(R=@~z3I2E3<=#hXn>A=0`wfOGIxiP)N2%!cG?&^w=E#TR z`lSY@Mm36zu4p3}+S#67MpL$d{gf@dnP%*ZMW=gCXK-%0E(xAC!^+b7hCSMF$m;Rn zCTErbBK#;a)>kHX5}w6PRmnw(!Gy>m_g*2opfklHyx>eb1bu|_lwJdf!ogxhk}X^v zc+^L;F7ta!8+i%6?M}XvQn4b%aOSCpDW+4#JDDG(wvXC*9%9(XBhbv4LX3R5G&(+@ z)nbdivYRQ5pW;9~@YGf{h~Rm(@MfV8Tj&T@EejO6(C#(+z7FVNBR`@j!#wScHM5ki%j+^GykUJ2m zYgpwm;#Q)~LoozUSV($?r3vQ~#ZU_}ggl~J%z*1dYt_^4K6e7o&qs_ORz{km+D+^a zqDdUO)d}|)v9h(Zz3}#DLWyRVCY!=PMCO{=PA)Upb@)1j?c)||l{6&pI=;U#bS#Jk zOOiwVH3FM!SuJDIPnN$|ZKz5fQwHmzn8f^?B+T2ew%~PSE#X_jk`Wu;a{4}9%AHg7 zZm8^bAee$bdpwklIE`$fV15=pI+tgJpll4uQjIM;Q!gvISFc_{@=lUSc-lABE%U?+ zHW$;!NcH1&F;AS~7RH=n<=!NTKnm3t`B@YeL?8d2{WGrmSjG;yBbY*9$N&DT^e?l2 z|1A2482Or7n7KF_TpRn|nmqD}`-=?QJ0z5q$C9Td^sML&aN7OGi+W$uYjDXKJg+0W@S=FoQP2dBI=48|FH>p2mh zFrdu!AwoG$NkvnZp_KT8HEo=RNNJ4IxucGXLr2N*I5Ao>Efb+pNOm9Zw0_7_s|9ac zS6}W##>$W*cBmksip;43p#a4&iTpM)8(gRGekW+AKm5zb)xpUFT>~b+FOH`Zs!$RDgpSCE z>;CL8Uu|EWeR~TvgDX@K=mtReFed;FZ!M2SjzW35i;UqfyemM?rq5yZS#hK5Y~|wt z2#^`Q6$b~uGT_++C3+B~#(oFHdSL&hh`Z8{t5#=ZkoaWVJoLm)3vT_@5HOnZGa;s~ z;4=E`3Eo@=$BxFjS`Iu|8SALB`<#TPTeE%h(dol+#CzJ=Zb&EHpw*=0H*~8x6 z`G`b<@>L2(AS*J!NVp`DN{g!8R#h(~URslf zC8PwGM$5V}+$WcoT*C~*$WmCpS6Gis&sZo|9OfRiwjX$f*&25Gjv6$YPde1smwGw( zb@y=gbl1!8>hm-il3&~zFca0~aJN!?b97+$E>2$Gn$31OR&UnE=Tm= zH44$Dx2HNN1lrCGjfuwo@+(m2j85w-oxre9FopupEV+6HACFyTbt}s-`lCCJ8om5RIE~T#Yg_DWu1u zyAp%jp;3&%D4;CRaR6g=f*ZvPqw2BadP=*ZYy_~CV3@wFx5YA(E8)jfqx z8tjEkMf>msMqi)zaY2fWrMq`lZzZdiMcluc(@(yxK(4hPEFk0~HO3^CUZk3;?Tv3` ze-rjZ8@hBrVPzA$^4hW?<33{d2)h7Jw?$t%V6(C_m+bNhXl9vXCJcBWmMeQoLDm5b zt9|A5pDHY#Y@(rlEo_WzXila!uaZE*WVc`=IM)SSc`#liZ2Wt*~fHgm9uH^ISX2d@)XGZ)_$qnbx6?J<14_=SS(ITs#LPDk03a&%x;bAuGz=P ze^<4p@tD@J|M;88;~IsEOPpB+&3C4!3q;}Kk2tb*WuuE z2u(BE$1(2AwbbBrmU-YLI4>#K((6&QZ~m2Yp;I14x0N8hos}{uoQuMG)Wy?ogaNayqmc&`I=8y6&dPf{Fky#B7 z#F=Xy213s`NFxjKuMqH3+ibWsFRi=QtH*j$9^)Zy8F|^vSmgj~l5<04MiU;BNyAn) zlM+c20Y#%@>WgdY>5kx}H)7*!D~BZJdg8d5iHx|>(jj=!MEmr)-$kH8?A#;DyBone(uz;e^|=9nIwfuWY?yw; zC|H`;8#O$vTPm5AW1Gg-Up&#Ca$<@!JZkAUDbmd*?X}QSA5$(*c+FZ|l+}F%*L1OH z{ck}P=j@=7>6ga#cqzj|ODXHD>ckIBmOd9Fh=~>?C7$uII_3rEX%UKdywsInR~{t- zg|t`~l=L1P_QPkZN53Q>!^A*QDZ zK(f;%VVQo)n1bsy)LWL#?&|wN`hL~Rnxhd3d-bOvlRQAiybH&=i;SlnwP$3P-!%x3^o)t6aoT-zXU}ARq-l^bOW-zg$@b|19Aua zF+k$V!uO;fNwCUEi;6!|5?4_MKtTq}|C`2gXh8EhWP1bTgZ)DqHZ&-x|E2*6Ka!RZ zS5jsHN&IW7%g1yUln@bn$cO!hR2b+`P~1-3dFIx!6EltRa{a z6Z@Y$_ug)~d%u)K$+?LYfc<87}bupdiK(3|m%hiA$Pc>zKNP0hqBj{X*L0rm@j(0s(f>>t{1L0?w#rS+#E)IdBKcF5|Dq-S zZ*-X3x;NeSuOSxS<3Q%uy1zwQ+?Kj&)Ou~-|2+&J{Zi^T=lx9+&+B^K_lQ;hY2H6D zeZ9T!H&;?$+kt+MLCs%i{8QEVi8<(Pft!mFt`}r~k5Y%93jAjQ!fgoD?Zh|Vi~q5A z27G^+_!lc1Zfo3}625-J{(B@p`IW|R4(!c|yX*Pn?*SA0)3iUGUB11uH>ab1{F$$g z|7q4=O#$9cezU54J)`wKI1_%J{14{0Zj0P3wEcKU`%-=?@(1PW+Zs0qGuI`%??IID dD~*3C;60WFKt@K_BOwYX49GZ$DDV2e{|AYb(KrAA literal 0 HcmV?d00001 diff --git a/ServerlessFunction/gradle/wrapper/gradle-wrapper.properties b/ServerlessFunction/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..23449a2b --- /dev/null +++ b/ServerlessFunction/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists From 3f5a880ddcdb91c4a8302ec744287da878944c12 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 09:59:29 +0900 Subject: [PATCH 412/528] fix: use single line sam package command with hardcoded bucket --- ServerlessFunction/buildspec.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/ServerlessFunction/buildspec.yml b/ServerlessFunction/buildspec.yml index 79b8ecb7..16ebf0ce 100644 --- a/ServerlessFunction/buildspec.yml +++ b/ServerlessFunction/buildspec.yml @@ -27,10 +27,7 @@ phases: - cd $CODEBUILD_SRC_DIR/ServerlessFunction - sam build - echo "Packaging SAM application..." - - sam package \ - --s3-bucket ${ARTIFACT_BUCKET} \ - --s3-prefix sam-packages \ - --output-template-file packaged-template.yaml + - sam package --s3-bucket group2-englishstudy-pipeline-artifacts --s3-prefix sam-packages --output-template-file packaged-template.yaml post_build: commands: From b26f47cbdba053d4f6105b48c28c3824d59b3cdd Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 10:09:30 +0900 Subject: [PATCH 413/528] fix: use existing stack name group2-englishstudy-chatting --- cicd/pipeline.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cicd/pipeline.yaml b/cicd/pipeline.yaml index 19b19448..74919262 100644 --- a/cicd/pipeline.yaml +++ b/cicd/pipeline.yaml @@ -268,7 +268,7 @@ Resources: Version: '1' Configuration: ActionMode: CREATE_UPDATE - StackName: group2-englishstudy-prod + StackName: group2-englishstudy-chatting TemplatePath: BuildArtifact::packaged-template.yaml Capabilities: CAPABILITY_IAM,CAPABILITY_AUTO_EXPAND RoleArn: !GetAtt CloudFormationRole.Arn From 033c98e92fd1d387fca8f9673846467e9162fa9e Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 10:27:26 +0900 Subject: [PATCH 414/528] fix: add missing WEBSOCKET_ENDPOINT env var to WebSocket connect functions --- ServerlessFunction/template.yaml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 99bdbafc..d997d830 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -232,6 +232,7 @@ Resources: Environment: Variables: WEBSOCKET_CONNECTION_TTL_SECONDS: "600" + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -1177,6 +1178,9 @@ Resources: CodeUri: . Handler: com.mzc.secondproject.serverless.domain.grammar.handler.websocket.GrammarStreamingConnectHandler::handleRequest Description: Handle Grammar WebSocket $connect with JWT auth + Environment: + Variables: + WEBSOCKET_ENDPOINT: !Sub "https://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -1196,6 +1200,9 @@ Resources: CodeUri: . Handler: com.mzc.secondproject.serverless.domain.grammar.handler.websocket.GrammarStreamingDisconnectHandler::handleRequest Description: Handle Grammar WebSocket $disconnect + Environment: + Variables: + WEBSOCKET_ENDPOINT: !Sub "https://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable From 9b3f286c16dac309ff2b1bbc454e34aaa9a8a478 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 10:49:31 +0900 Subject: [PATCH 415/528] docs: update FRONTEND-API-GUIDE with new RoomType/RoomStatus structure --- docs/FRONTEND-API-GUIDE.md | 266 ++++++++++++++++++++++++++----------- 1 file changed, 192 insertions(+), 74 deletions(-) diff --git a/docs/FRONTEND-API-GUIDE.md b/docs/FRONTEND-API-GUIDE.md index 1d138573..cae65a1a 100644 --- a/docs/FRONTEND-API-GUIDE.md +++ b/docs/FRONTEND-API-GUIDE.md @@ -1,43 +1,65 @@ # 프론트엔드 전달사항 - 채팅/게임 API 가이드 -## 1. 현재 아키텍처 구조 +## 1. 아키텍처 구조 (업데이트됨) -### 채팅방 = 게임방 (동일 엔티티) +### 채팅방과 게임방 분리 ``` -ChatRoom 모델 -├── 기본 정보: roomId, name, description, level -├── 멤버 관리: memberIds, currentMembers, maxMembers -└── 게임 상태: gameStatus, scores, currentRound, currentDrawerId... +RoomType enum +├── CHAT ("chat") - 일반 채팅방 +└── GAME ("game") - 게임방 (캐치마인드 등) + +RoomStatus enum +├── WAITING ("waiting") - 대기 중 +├── PLAYING ("playing") - 게임 진행 중 +└── FINISHED ("finished") - 종료됨 ``` -**핵심**: 채팅방과 게임방이 **분리되지 않음**. 하나의 채팅방에서 게임을 시작/종료하는 구조. +### GSI1SK 인덱스 설계 +``` +GSI1PK: "ROOMS" (고정) +GSI1SK: {type}#{gameType}#{status}#{level}#{createdAt} + +예시: +- CHAT#-#WAITING#beginner#2026-01-22T10:00:00Z (일반 채팅방) +- GAME#CATCHMIND#WAITING#intermediate#2026-01-22T10:00:00Z (대기중 게임방) +- GAME#CATCHMIND#PLAYING#advanced#2026-01-22T10:00:00Z (진행중 게임방) +``` + +**핵심**: DB 레벨에서 `type`, `gameType`, `status`, `level` 조합으로 필터링 가능 --- -## 2. 게임 상태 (gameStatus) +## 2. 방 타입 (RoomType) -| 상태 | 설명 | 게임 시작 가능 | -|------|------|:-------------:| -| `NONE` / `null` | 일반 채팅방 (게임 안함) | O | -| `WAITING` | 게임 대기 중 | X | -| `PLAYING` | 게임 진행 중 | X | -| `ROUND_END` | 라운드 종료 (다음 라운드 대기) | X | -| `FINISHED` | 게임 종료됨 | O | +| 타입 | 코드 | 설명 | +|------|------|------| +| `CHAT` | `chat` | 일반 채팅방 | +| `GAME` | `game` | 게임방 (캐치마인드 등) | --- -## 3. REST API 엔드포인트 +## 3. 방 상태 (RoomStatus) + +| 상태 | 코드 | 설명 | 게임 시작 가능 | +|------|------|------|:-------------:| +| `WAITING` | `waiting` | 대기 중 | O | +| `PLAYING` | `playing` | 게임 진행 중 | X | +| `FINISHED` | `finished` | 게임 종료됨 | O | + +--- + +## 4. REST API 엔드포인트 ### 채팅방 API (`/api/chat/rooms`) | Method | Endpoint | 설명 | |--------|----------|------| -| POST | `/rooms` | 채팅방 생성 | -| GET | `/rooms` | 채팅방 목록 조회 | -| GET | `/rooms/{roomId}` | 채팅방 상세 조회 | -| POST | `/rooms/{roomId}/join` | 채팅방 입장 (roomToken 발급) | -| POST | `/rooms/{roomId}/leave` | 채팅방 퇴장 | -| DELETE | `/rooms/{roomId}` | 채팅방 삭제 (방장만) | +| POST | `/rooms` | 채팅방/게임방 생성 | +| GET | `/rooms` | 방 목록 조회 (필터 지원) | +| GET | `/rooms/{roomId}` | 방 상세 조회 | +| POST | `/rooms/{roomId}/join` | 방 입장 (roomToken 발급) | +| POST | `/rooms/{roomId}/leave` | 방 퇴장 | +| DELETE | `/rooms/{roomId}` | 방 삭제 (방장만) | ### 게임 API (`/api/game`) @@ -50,20 +72,39 @@ ChatRoom 모델 --- -## 4. 채팅방 목록 조회 쿼리 파라미터 +## 5. 방 목록 조회 쿼리 파라미터 (업데이트됨) ``` -GET /api/chat/rooms?level=beginner&joined=true&limit=10&cursor=xxx +GET /api/chat/rooms?type=GAME&gameType=CATCHMIND&status=WAITING&level=intermediate&limit=10&cursor=xxx ``` -| 파라미터 | 타입 | 설명 | -|----------|------|------| -| `level` | string | 난이도 필터: `beginner`, `intermediate`, `advanced` | -| `joined` | boolean | `true`면 내가 참여한 방만 | -| `limit` | number | 조회 개수 (기본 10, 최대 20) | -| `cursor` | string | 페이지네이션 커서 | +| 파라미터 | 타입 | 설명 | 예시 | +|----------|------|------|------| +| `type` | string | 방 타입 필터 | `CHAT`, `GAME` | +| `gameType` | string | 게임 타입 | `CATCHMIND` | +| `status` | string | 상태 필터 | `WAITING`, `PLAYING`, `FINISHED` | +| `level` | string | 난이도 필터 | `beginner`, `intermediate`, `advanced` | +| `limit` | number | 조회 개수 (기본 10, 최대 20) | | +| `cursor` | string | 페이지네이션 커서 | | + +### 필터 조합 예시 + +```bash +# 대기 중인 게임방만 +GET /api/chat/rooms?type=GAME&status=WAITING + +# 캐치마인드 게임방만 +GET /api/chat/rooms?type=GAME&gameType=CATCHMIND + +# 초급 난이도 채팅방 +GET /api/chat/rooms?type=CHAT&level=beginner + +# 진행 중인 고급 게임방 +GET /api/chat/rooms?type=GAME&status=PLAYING&level=advanced +``` ### 응답 예시 + ```json { "success": true, @@ -73,12 +114,15 @@ GET /api/chat/rooms?level=beginner&joined=true&limit=10&cursor=xxx { "roomId": "abc-123", "name": "초보자 영어 스터디", + "type": "GAME", + "gameType": "CATCHMIND", + "status": "WAITING", "level": "beginner", "currentMembers": 3, "maxMembers": 6, - "gameStatus": "PLAYING", - "currentRound": 2, - "totalRounds": 5 + "currentRound": 0, + "totalRounds": 5, + "createdAt": "2026-01-22T10:00:00Z" } ], "nextCursor": "eyJQSyI6Ik...", @@ -89,36 +133,66 @@ GET /api/chat/rooms?level=beginner&joined=true&limit=10&cursor=xxx --- -## 5. 프론트엔드에서 게임/채팅 구분하는 방법 +## 6. 방 생성 요청 (업데이트됨) + +### 채팅방 생성 +```json +{ + "name": "영어 스터디 채팅방", + "type": "CHAT", + "level": "beginner", + "maxMembers": 6, + "description": "초보자를 위한 영어 채팅방" +} +``` -### 방법 1: 클라이언트 필터링 (현재 가능) +### 게임방 생성 +```json +{ + "name": "캐치마인드 게임", + "type": "GAME", + "gameType": "CATCHMIND", + "level": "intermediate", + "maxMembers": 8, + "description": "영어 단어 맞추기 게임" +} +``` + +--- + +## 7. 프론트엔드에서 방 타입 구분 + +### 방법 1: API 필터 사용 (권장) ```javascript -// 채팅방 목록 조회 후 클라이언트에서 필터링 -const allRooms = await fetchRooms(); +// 게임방만 조회 +const gameRooms = await fetch('/api/chat/rooms?type=GAME'); -// 게임 중인 방만 -const gamingRooms = allRooms.filter(room => - room.gameStatus === 'PLAYING' || room.gameStatus === 'WAITING' -); +// 대기 중인 게임방만 +const waitingGames = await fetch('/api/chat/rooms?type=GAME&status=WAITING'); -// 일반 채팅방만 -const chatRooms = allRooms.filter(room => - !room.gameStatus || room.gameStatus === 'NONE' || room.gameStatus === 'FINISHED' -); +// 채팅방만 +const chatRooms = await fetch('/api/chat/rooms?type=CHAT'); ``` -### 방법 2: 백엔드 필터 추가 요청 (추후 가능) -``` -GET /api/chat/rooms?gameStatus=PLAYING // 게임 중인 방 -GET /api/chat/rooms?gameStatus=NONE // 채팅만 하는 방 +### 방법 2: 전체 조회 후 클라이언트 필터링 +```javascript +const allRooms = await fetchRooms(); + +// 게임방만 +const gameRooms = allRooms.filter(room => room.type === 'GAME'); + +// 채팅방만 +const chatRooms = allRooms.filter(room => room.type === 'CHAT'); + +// 대기 중인 방만 +const waitingRooms = allRooms.filter(room => room.status === 'WAITING'); ``` -> 현재 미구현. 필요시 백엔드에 요청 --- -## 6. WebSocket 연결 +## 8. WebSocket 연결 -### 채팅 WebSocket +### 채팅/게임 WebSocket ``` wss://t378dif43l.execute-api.ap-northeast-2.amazonaws.com/dev?roomToken={roomToken} ``` @@ -134,7 +208,7 @@ wss://ltrccmteo8.execute-api.ap-northeast-2.amazonaws.com/dev?token={jwtToken} --- -## 7. WebSocket 메시지 타입 (messageType) +## 9. WebSocket 메시지 타입 (messageType) | 코드 | 타입 | 설명 | |------|------|------| @@ -153,13 +227,13 @@ wss://ltrccmteo8.execute-api.ap-northeast-2.amazonaws.com/dev?token={jwtToken} --- -## 8. 게임 명령어 (WebSocket) +## 10. 게임 명령어 (WebSocket) 채팅 메시지로 게임 명령어 전송: | 명령어 | 설명 | 권한 | |--------|------|------| -| `/start` | 게임 시작 | 누구나 (2명 이상 접속 시) | +| `/start` | 게임 시작 | 방장 (2명 이상 접속 시) | | `/stop` | 게임 중단 | 방장 또는 게임 시작자 | | `/skip` | 라운드 스킵 | 누구나 | | `/hint` | 힌트 제공 | 출제자만 | @@ -167,17 +241,20 @@ wss://ltrccmteo8.execute-api.ap-northeast-2.amazonaws.com/dev?token={jwtToken} --- -## 9. 게임 시작 응답 예시 +## 11. 게임 시작 응답 예시 ```json { "messageId": "uuid", "roomId": "abc-123", "userId": "SYSTEM", - "content": "🎮 게임 시작!\n총 5 라운드\n\n라운드 1 시작!\n출제자: user-456", + "content": "게임 시작!\n총 5 라운드\n\n라운드 1 시작!\n출제자: user-456", "messageType": "GAME_START", "createdAt": "2026-01-22T10:00:00Z", - "gameStatus": "PLAYING", + "serverTime": "2026-01-22T10:00:00Z", + "domain": "GAME", + "type": "GAME", + "status": "PLAYING", "currentRound": 1, "totalRounds": 5, "currentDrawerId": "user-456", @@ -187,7 +264,7 @@ wss://ltrccmteo8.execute-api.ap-northeast-2.amazonaws.com/dev?token={jwtToken} --- -## 10. 정답 체크 로직 +## 12. 정답 체크 로직 - **한국어** 또는 **영어** 둘 다 정답으로 인정 - 대소문자 구분 없음 @@ -204,31 +281,72 @@ wss://ltrccmteo8.execute-api.ap-northeast-2.amazonaws.com/dev?token={jwtToken} --- -## 11. 주의사항 +## 13. 게임 설정 + +| 설정 | 기본값 | 환경변수 | +|------|--------|----------| +| 총 라운드 수 | 5 | `GAME_TOTAL_ROUNDS` | +| 라운드 제한 시간(초) | 60 | `GAME_ROUND_TIME_LIMIT` | +| 빠른 정답 기준(ms) | 5000 | `GAME_QUICK_GUESS_THRESHOLD_MS` | +| 게임 전체 제한(초) | 420 (7분) | `GAME_TIME_LIMIT_SECONDS` | + +--- + +## 14. 주의사항 1. **roomToken은 한 번만 사용**: 재연결 시 새로 발급 필요 2. **WebSocket 연결 실패 시**: `POST /rooms/{roomId}/join`으로 새 토큰 발급 3. **게임 중 퇴장**: 자동으로 다음 출제자로 넘어감 (2명 미만 시 게임 종료) 4. **출제자는 정답 입력 불가**: 본인이 출제자일 때 채팅해도 정답 체크 안됨 +5. **방 타입 변경 불가**: 생성 시 지정한 type은 변경 불가 --- -## 12. 에러 코드 +## 15. 에러 코드 -| 코드 | 설명 | -|------|------| -| `ROOM_NOT_FOUND` | 채팅방 없음 | -| `ROOM_FULL` | 채팅방 인원 초과 | -| `ALREADY_JOINED` | 이미 참여 중 | -| `WRONG_PASSWORD` | 비밀번호 틀림 | -| `NOT_MEMBER` | 채팅방 멤버 아님 | -| `GAME_START_FAILED` | 게임 시작 실패 | -| `GAME_STOP_FAILED` | 게임 중단 실패 | +| 코드 | HTTP | 설명 | +|------|------|------| +| `ROOM_001` | 404 | 채팅방 없음 | +| `ROOM_002` | 409 | 채팅방 이미 존재 | +| `ROOM_003` | 400 | 채팅방 인원 초과 | +| `ROOM_004` | 400 | 채팅방 종료됨 | +| `ROOM_005` | 401 | 비밀번호 틀림 | +| `ROOM_006` | 403 | 방장 권한 없음 | +| `MEMBER_001` | 403 | 채팅방 멤버 아님 | +| `MEMBER_002` | 409 | 이미 참여 중 | +| `GAME_001` | 400 | 게임 시작 실패 | +| `GAME_002` | 400 | 게임 중단 실패 | +| `GAME_003` | 400 | 게임 진행 중 아님 | +| `GAME_004` | 409 | 게임 이미 진행 중 | +| `GAME_005` | 403 | 게임 시작자 아님 | +| `GAME_006` | 404 | 게임 없음 | +| `GAME_007` | 400 | 채팅방에서 게임 불가 | +| `GAME_008` | 400 | 게임 재시작 불가 | +| `GAME_009` | 403 | 방장만 게임 시작 가능 | --- -## 13. 추후 개선 예정 (백엔드) +## 16. UI 구현 가이드 + +### 탭 구조 (권장) +``` +[전체] [채팅방] [게임방] +``` + +### 게임방 상태 표시 +``` +대기 중 (WAITING) → 초록색 뱃지 "참여 가능" +진행 중 (PLAYING) → 빨간색 뱃지 "게임 중" +종료됨 (FINISHED) → 회색 뱃지 "종료" +``` -- [ ] `gameStatus` 필터 파라미터 추가 -- [ ] 게임 전용 방 타입 분리 (선택적) -- [ ] 관전 모드 지원 +### 게임방 카드 정보 +``` +┌─────────────────────────────┐ +│ 캐치마인드 - 영어 단어 맞추기 │ +│ [게임방] [intermediate] │ +│ │ +│ 👥 3/8명 🎮 대기 중 │ +│ 🕐 2026-01-22 10:00 │ +└─────────────────────────────┘ +``` From 8296952b74f648ea6550ed5dcf7e8f567b442621 Mon Sep 17 00:00:00 2001 From: hye-inA Date: Thu, 22 Jan 2026 10:50:53 +0900 Subject: [PATCH 416/528] =?UTF-8?q?feat=20:=20WebSocket=20Connect,=20Disco?= =?UTF-8?q?nnect=20=ED=95=B8=EB=93=A4=EB=9F=AC=20&=20SpeakingConnecion=20?= =?UTF-8?q?=EB=AA=A8=EB=8D=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../websocket/SpeakingConnectHandler.java | 88 +++++++++++++++++++ .../websocket/SpeakingDisconnectHandler.java | 44 ++++++++++ .../speaking/model/SpeakingConnection.java | 84 ++++++++++++++++++ 3 files changed, 216 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingConnectHandler.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingDisconnectHandler.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/SpeakingConnection.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingConnectHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingConnectHandler.java new file mode 100644 index 00000000..535a3fc1 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingConnectHandler.java @@ -0,0 +1,88 @@ +package com.mzc.secondproject.serverless.domain.speaking.handler.websocket; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.mzc.secondproject.serverless.common.config.WebSocketConfig; +import com.mzc.secondproject.serverless.common.util.JwtUtil; +import com.mzc.secondproject.serverless.common.util.WebSocketEventUtil; +import com.mzc.secondproject.serverless.domain.speaking.model.SpeakingConnection; +import com.mzc.secondproject.serverless.domain.speaking.repository.SpeakingConnectionRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Map; +import java.util.Optional; + +/** + * Speaking WebSocket $connect 핸들러 + * JWT 토큰 검증 후 연결 정보를 DynamoDB에 저장 + * + * 연결 방법: + * wss://{api-id}.execute-api.{region}.amazonaws.com/{stage}?token={jwt} + */ +public class SpeakingConnectHandler implements RequestHandler, Map> { + + private static final Logger logger = LoggerFactory.getLogger(SpeakingConnectHandler.class); + + private final SpeakingConnectionRepository connectionRepository; + + public SpeakingConnectHandler() { + this.connectionRepository = new SpeakingConnectionRepository(); + } + + @Override + public Map handleRequest(Map event, Context context) { + logger.info("Speaking WebSocket connect event"); + + try { + String connectionId = WebSocketEventUtil.extractConnectionId(event); + Map queryParams = WebSocketEventUtil.extractQueryStringParameters(event); + + // JWT 토큰 검증 + String token = queryParams.get("token"); + + if (token == null || token.isEmpty()) { + logger.warn("Missing token parameter"); + return WebSocketEventUtil.unauthorized("token is required"); + } + + // 토큰 유효성 검사 + if (!JwtUtil.isValid(token)) { + logger.warn("Invalid or expired token"); + return WebSocketEventUtil.unauthorized("Invalid or expired token"); + } + + // userId 추출 + Optional userIdOpt = JwtUtil.extractUserId(token); + if (userIdOpt.isEmpty()) { + logger.warn("Failed to extract userId from token"); + return WebSocketEventUtil.unauthorized("Invalid token"); + } + + String userId = userIdOpt.get(); + + // 연결 정보 저장 + SpeakingConnection connection = SpeakingConnection.create( + connectionId, + userId, + WebSocketConfig.connectionTtlSeconds() + ); + + // 레벨 파라미터가 있으면 설정 + String level = queryParams.get("level"); + if (level != null && !level.isEmpty()) { + connection.setTargetLevel(level.toUpperCase()); + } + + connectionRepository.save(connection); + + logger.info("Speaking connection established: connectionId={}, userId={}, level={}", + connectionId, userId, connection.getTargetLevel()); + return WebSocketEventUtil.ok("Connected"); + + } catch (Exception e) { + logger.error("Error handling connect: {}", e.getMessage(), e); + return WebSocketEventUtil.serverError("Internal server error"); + } + } +} \ No newline at end of file diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingDisconnectHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingDisconnectHandler.java new file mode 100644 index 00000000..4d82a9d1 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingDisconnectHandler.java @@ -0,0 +1,44 @@ +package com.mzc.secondproject.serverless.domain.speaking.handler.websocket; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.mzc.secondproject.serverless.common.util.WebSocketEventUtil; +import com.mzc.secondproject.serverless.domain.speaking.repository.SpeakingConnectionRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Map; + +/** + * Speaking WebSocket $disconnect 핸들러 + * 연결 해제 시 DynamoDB에서 연결 정보 삭제 + */ +public class SpeakingDisconnectHandler implements RequestHandler, Map> { + + private static final Logger logger = LoggerFactory.getLogger(SpeakingDisconnectHandler.class); + + private final SpeakingConnectionRepository connectionRepository; + + public SpeakingDisconnectHandler() { + this.connectionRepository = new SpeakingConnectionRepository(); + } + + @Override + public Map handleRequest(Map event, Context context) { + logger.info("Speaking WebSocket disconnect event"); + + try { + String connectionId = WebSocketEventUtil.extractConnectionId(event); + + // 연결 정보 삭제 + connectionRepository.delete(connectionId); + + logger.info("Speaking connection closed: connectionId={}", connectionId); + return WebSocketEventUtil.ok("Disconnected"); + + } catch (Exception e) { + logger.error("Error handling disconnect: {}", e.getMessage(), e); + return WebSocketEventUtil.serverError("Internal server error"); + } + } +} \ No newline at end of file diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/SpeakingConnection.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/SpeakingConnection.java new file mode 100644 index 00000000..6ea0c185 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/SpeakingConnection.java @@ -0,0 +1,84 @@ +package com.mzc.secondproject.serverless.domain.speaking.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.*; + +/** + * Speaking WebSocket 연결 정보 + * connectionId ↔ userId 매핑 + 대화 히스토리 저장 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DynamoDbBean +public class SpeakingConnection { + + // DynamoDB Key Prefixes + public static final String PK_PREFIX = "SPEAKING_CONN#"; + public static final String SK_METADATA = "METADATA"; + public static final String GSI1PK_PREFIX = "SPEAKING_USER#"; + public static final String GSI1SK_PREFIX = "CONN#"; + + private String pk; // SPEAKING_CONN#{connectionId} + private String sk; // METADATA + private String gsi1pk; // SPEAKING_USER#{userId} + private String gsi1sk; // CONN#{connectionId} + + private String connectionId; + private String userId; + private String connectedAt; + private Long ttl; // 자동 삭제용 + + // Speaking 전용 필드 + private String conversationHistory; // 대화 히스토리 (JSON) + private String targetLevel; // 목표 레벨 (BEGINNER, INTERMEDIATE, ADVANCED) + + /** + * 연결 정보 생성 팩토리 메서드 + */ + public static SpeakingConnection create(String connectionId, String userId, long ttlSeconds) { + String now = java.time.Instant.now().toString(); + long ttl = java.time.Instant.now().plusSeconds(ttlSeconds).getEpochSecond(); + + return SpeakingConnection.builder() + .pk(PK_PREFIX + connectionId) + .sk(SK_METADATA) + .gsi1pk(GSI1PK_PREFIX + userId) + .gsi1sk(GSI1SK_PREFIX + connectionId) + .connectionId(connectionId) + .userId(userId) + .connectedAt(now) + .ttl(ttl) + .conversationHistory("[]") // 빈 배열로 초기화 + .targetLevel("INTERMEDIATE") // 기본값 + .build(); + } + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { + return pk; + } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { + return sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1PK") + public String getGsi1pk() { + return gsi1pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1SK") + public String getGsi1sk() { + return gsi1sk; + } +} \ No newline at end of file From fc6f9e6d8a298ae7d9b1f7154e45303bce592b2f Mon Sep 17 00:00:00 2001 From: hye-inA Date: Thu, 22 Jan 2026 10:51:44 +0900 Subject: [PATCH 417/528] =?UTF-8?q?feat=20:=20WebSocket=20=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=20=EC=B2=98=EB=A6=AC=20handler,=20service=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../websocket/SpeakingMessageHandler.java | 217 ++++++++++++ .../speaking/service/SpeakingService.java | 317 ++++++++++++++++++ 2 files changed, 534 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingMessageHandler.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/service/SpeakingService.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingMessageHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingMessageHandler.java new file mode 100644 index 00000000..89ec0a44 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingMessageHandler.java @@ -0,0 +1,217 @@ +package com.mzc.secondproject.serverless.domain.speaking.handler.websocket; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.mzc.secondproject.serverless.common.util.WebSocketEventUtil; +import com.mzc.secondproject.serverless.domain.speaking.repository.SpeakingConnectionRepository; +import com.mzc.secondproject.serverless.domain.speaking.service.SpeakingService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.core.SdkBytes; +import software.amazon.awssdk.services.apigatewaymanagementapi.ApiGatewayManagementApiClient; +import software.amazon.awssdk.services.apigatewaymanagementapi.model.PostToConnectionRequest; + +import java.net.URI; +import java.util.Map; + +/** + * Speaking WebSocket 메시지 핸들러 + * + * 지원하는 action: + * - speak: 음성 입력 처리 (audio base64) + * - text: 텍스트 입력 처리 + * - setLevel: 레벨 변경 + * - reset: 대화 히스토리 초기화 + */ +public class SpeakingMessageHandler implements RequestHandler, Map> { + + private static final Logger logger = LoggerFactory.getLogger(SpeakingMessageHandler.class); + private static final Gson gson = new GsonBuilder().create(); + + private final SpeakingService speakingService; + private final SpeakingConnectionRepository connectionRepository; + + public SpeakingMessageHandler() { + this.speakingService = new SpeakingService(); + this.connectionRepository = new SpeakingConnectionRepository(); + } + + @Override + public Map handleRequest(Map event, Context context) { + logger.info("Speaking message event received"); + + String connectionId = null; + String endpoint = null; + + try { + connectionId = WebSocketEventUtil.extractConnectionId(event); + endpoint = WebSocketEventUtil.extractWebSocketEndpoint(event); + + // 연결 정보 확인 + if (connectionRepository.findByConnectionId(connectionId).isEmpty()) { + logger.warn("Connection not found: {}", connectionId); + return sendError(connectionId, endpoint, "Unauthorized - please reconnect"); + } + + // 요청 바디 파싱 + String body = (String) event.get("body"); + if (body == null || body.isEmpty()) { + return sendError(connectionId, endpoint, "Message body is required"); + } + + JsonObject request = JsonParser.parseString(body).getAsJsonObject(); + String action = request.has("action") ? request.get("action").getAsString() : "speak"; + + logger.info("Processing action: {} for connectionId: {}", action, connectionId); + + // 액션별 처리 + switch (action) { + case "speak" -> handleSpeak(connectionId, endpoint, request); + case "text" -> handleText(connectionId, endpoint, request); + case "setLevel" -> handleSetLevel(connectionId, endpoint, request); + case "reset" -> handleReset(connectionId, endpoint); + default -> sendError(connectionId, endpoint, "Unknown action: " + action); + } + + return WebSocketEventUtil.ok("Processed"); + + } catch (Exception e) { + logger.error("Error processing message: {}", e.getMessage(), e); + if (connectionId != null && endpoint != null) { + sendError(connectionId, endpoint, "Processing error: " + e.getMessage()); + } + return WebSocketEventUtil.serverError("Internal server error"); + } + } + + /** + * 음성 입력 처리 + */ + private void handleSpeak(String connectionId, String endpoint, JsonObject request) { + // 시작 이벤트 전송 + sendToConnection(connectionId, endpoint, Map.of( + "type", "start", + "message", "Processing your voice..." + )); + + // 음성 데이터 추출 + String audioBase64 = request.has("audio") ? request.get("audio").getAsString() : null; + if (audioBase64 == null || audioBase64.isEmpty()) { + sendError(connectionId, endpoint, "audio data is required for speak action"); + return; + } + + // 음성 처리 + SpeakingService.SpeakingResponse response = speakingService.processVoiceInput( + connectionId, audioBase64 + ); + + // 결과 전송 + sendToConnection(connectionId, endpoint, Map.of( + "type", "complete", + "userTranscript", response.userTranscript(), + "aiText", response.aiText(), + "aiAudioUrl", response.aiAudioUrl(), + "confidence", response.confidence() + )); + } + + /** + * 텍스트 입력 처리 + */ + private void handleText(String connectionId, String endpoint, JsonObject request) { + String text = request.has("text") ? request.get("text").getAsString() : null; + if (text == null || text.trim().isEmpty()) { + sendError(connectionId, endpoint, "text is required for text action"); + return; + } + + // 시작 이벤트 전송 + sendToConnection(connectionId, endpoint, Map.of( + "type", "start", + "message", "Processing your message..." + )); + + // 텍스트 처리 + SpeakingService.SpeakingResponse response = speakingService.processTextInput( + connectionId, text.trim() + ); + + // 결과 전송 + sendToConnection(connectionId, endpoint, Map.of( + "type", "complete", + "userTranscript", response.userTranscript(), + "aiText", response.aiText(), + "aiAudioUrl", response.aiAudioUrl(), + "confidence", response.confidence() + )); + } + + /** + * 레벨 변경 처리 + */ + private void handleSetLevel(String connectionId, String endpoint, JsonObject request) { + String level = request.has("level") ? request.get("level").getAsString() : null; + if (level == null || level.isEmpty()) { + sendError(connectionId, endpoint, "level is required"); + return; + } + + speakingService.updateLevel(connectionId, level); + + sendToConnection(connectionId, endpoint, Map.of( + "type", "levelChanged", + "level", level.toUpperCase() + )); + } + + /** + * 대화 초기화 처리 + */ + private void handleReset(String connectionId, String endpoint) { + speakingService.resetConversation(connectionId); + + sendToConnection(connectionId, endpoint, Map.of( + "type", "reset", + "message", "Conversation has been reset. Let's start fresh!" + )); + } + + /** + * WebSocket으로 메시지 전송 + */ + private void sendToConnection(String connectionId, String endpoint, Map data) { + try { + ApiGatewayManagementApiClient apiClient = ApiGatewayManagementApiClient.builder() + .endpointOverride(URI.create(endpoint)) + .build(); + + String message = gson.toJson(data); + + apiClient.postToConnection(PostToConnectionRequest.builder() + .connectionId(connectionId) + .data(SdkBytes.fromUtf8String(message)) + .build()); + + logger.debug("Message sent to {}: {}", connectionId, data.get("type")); + + } catch (Exception e) { + logger.error("Failed to send message to {}: {}", connectionId, e.getMessage()); + } + } + + /** + * 에러 메시지 전송 + */ + private Map sendError(String connectionId, String endpoint, String errorMessage) { + sendToConnection(connectionId, endpoint, Map.of( + "type", "error", + "message", errorMessage + )); + return WebSocketEventUtil.ok("Error sent"); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/service/SpeakingService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/service/SpeakingService.java new file mode 100644 index 00000000..3462bbfb --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/service/SpeakingService.java @@ -0,0 +1,317 @@ +package com.mzc.secondproject.serverless.domain.speaking.service; + +import com.google.gson.*; +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.config.EnvConfig; +import com.mzc.secondproject.serverless.common.service.PollyService; +import com.mzc.secondproject.serverless.domain.opic.service.TranscribeProxyService; +import com.mzc.secondproject.serverless.domain.speaking.model.SpeakingConnection; +import com.mzc.secondproject.serverless.domain.speaking.repository.SpeakingConnectionRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.core.SdkBytes; +import software.amazon.awssdk.services.bedrockruntime.model.InvokeModelRequest; +import software.amazon.awssdk.services.bedrockruntime.model.InvokeModelResponse; + + +import java.util.ArrayList; +import java.util.List; + +/** + * AI와 대화하기 서비스 + * 음성 입력 → STT → Bedrock → TTS → 음성 출력 + */ +public class SpeakingService { + + private static final Logger logger = LoggerFactory.getLogger(SpeakingService.class); + private static final Gson gson = new GsonBuilder().create(); + + private static final String MODEL_ID = "anthropic.claude-3-haiku-20240307-v1:0"; + private static final int MAX_TOKENS = 500; + private static final int MAX_HISTORY_SIZE = 10; // 최근 10턴만 유지 + + private final TranscribeProxyService transcribeService; + private final PollyService pollyService; + private final SpeakingConnectionRepository connectionRepository; + + public SpeakingService() { + this.transcribeService = new TranscribeProxyService(); + this.pollyService = new PollyService( + EnvConfig.getRequired("BUCKET_NAME"), + "speaking/voice/" + ); + this.connectionRepository = new SpeakingConnectionRepository(); + } + + /** + * 음성 입력 처리 (전체 플로우) + */ + public SpeakingResponse processVoiceInput(String connectionId, String audioBase64) { + logger.info("Processing voice input for connectionId: {}", connectionId); + + // 연결 정보 조회 + SpeakingConnection connection = connectionRepository.findByConnectionId(connectionId) + .orElseThrow(() -> new RuntimeException("Connection not found: " + connectionId)); + + String targetLevel = connection.getTargetLevel(); + + // STT: 음성 → 텍스트 (Transcribe Proxy 사용) + logger.info("Step 1: Transcribing audio..."); + TranscribeProxyService.TranscribeResult sttResult = transcribeService.transcribe( + audioBase64, + connectionId, + "en-US" + ); + String userText = sttResult.transcript(); + logger.info("Transcription complete: {} (confidence: {})", userText, sttResult.confidence()); + + // 대화 히스토리 로드 + List history = parseHistory(connection.getConversationHistory()); + + // Bedrock: AI 응답 생성 + logger.info("Step 2: Generating AI response..."); + String aiResponse = generateAiResponse(userText, history, targetLevel); + logger.info("AI response generated: {}", aiResponse); + + // 히스토리 업데이트 (최근 N턴만 유지) + history.add(new Message("user", userText)); + history.add(new Message("assistant", aiResponse)); + if (history.size() > MAX_HISTORY_SIZE * 2) { + history = new ArrayList<>(history.subList(history.size() - MAX_HISTORY_SIZE * 2, history.size())); + } + connection.setConversationHistory(toJson(history)); + connectionRepository.update(connection); + + // TTS: 텍스트 → 음성 (Polly 사용) + logger.info("Step 3: Synthesizing speech..."); + String audioId = connectionId + "_" + System.currentTimeMillis(); + PollyService.VoiceSynthesisResult ttsResult = pollyService.synthesizeSpeech( + audioId, + aiResponse, + "FEMALE" + ); + logger.info("Speech synthesis complete: cached={}", ttsResult.isCached()); + + return new SpeakingResponse( + userText, + aiResponse, + ttsResult.getAudioUrl(), + sttResult.confidence() + ); + } + + /** + * 텍스트 입력 처리 (음성 없이 텍스트만) + */ + public SpeakingResponse processTextInput(String connectionId, String userText) { + logger.info("Processing text input for connectionId: {}", connectionId); + + // 연결 정보 조회 + SpeakingConnection connection = connectionRepository.findByConnectionId(connectionId) + .orElseThrow(() -> new RuntimeException("Connection not found: " + connectionId)); + + String targetLevel = connection.getTargetLevel(); + + // 대화 히스토리 로드 + List history = parseHistory(connection.getConversationHistory()); + + // AI 응답 생성 + String aiResponse = generateAiResponse(userText, history, targetLevel); + + // 히스토리 업데이트 + history.add(new Message("user", userText)); + history.add(new Message("assistant", aiResponse)); + if (history.size() > MAX_HISTORY_SIZE * 2) { + history = new ArrayList<>(history.subList(history.size() - MAX_HISTORY_SIZE * 2, history.size())); + } + connection.setConversationHistory(toJson(history)); + connectionRepository.update(connection); + + // TTS 생성 + String audioId = connectionId + "_" + System.currentTimeMillis(); + PollyService.VoiceSynthesisResult ttsResult = pollyService.synthesizeSpeech( + audioId, aiResponse, "FEMALE" + ); + + return new SpeakingResponse(userText, aiResponse, ttsResult.getAudioUrl(), 1.0); + } + + /** + * 레벨 변경 + */ + public void updateLevel(String connectionId, String level) { + SpeakingConnection connection = connectionRepository.findByConnectionId(connectionId) + .orElseThrow(() -> new RuntimeException("Connection not found: " + connectionId)); + + connection.setTargetLevel(level.toUpperCase()); + connectionRepository.update(connection); + logger.info("Level updated for connectionId {}: {}", connectionId, level); + } + + /** + * 대화 히스토리 초기화 + */ + public void resetConversation(String connectionId) { + SpeakingConnection connection = connectionRepository.findByConnectionId(connectionId) + .orElseThrow(() -> new RuntimeException("Connection not found: " + connectionId)); + + connection.setConversationHistory("[]"); + connectionRepository.update(connection); + logger.info("Conversation reset for connectionId: {}", connectionId); + } + + + /** + * Bedrock Claude 호출하여 AI 응답 생성 + */ + private String generateAiResponse(String userText, List history, String targetLevel) { + String systemPrompt = buildSystemPrompt(targetLevel); + + JsonObject requestBody = new JsonObject(); + requestBody.addProperty("anthropic_version", "bedrock-2023-05-31"); + requestBody.addProperty("max_tokens", MAX_TOKENS); + requestBody.addProperty("system", systemPrompt); + + // 메시지 배열 구성 + JsonArray messages = new JsonArray(); + + // 기존 히스토리 추가 + for (Message msg : history) { + JsonObject m = new JsonObject(); + m.addProperty("role", msg.role()); + m.addProperty("content", msg.content()); + messages.add(m); + } + + // 현재 사용자 입력 추가 + JsonObject userMsg = new JsonObject(); + userMsg.addProperty("role", "user"); + userMsg.addProperty("content", userText); + messages.add(userMsg); + + requestBody.add("messages", messages); + + // Bedrock 호출 + InvokeModelResponse response = AwsClients.bedrock().invokeModel( + InvokeModelRequest.builder() + .modelId(MODEL_ID) + .contentType("application/json") + .body(SdkBytes.fromUtf8String(requestBody.toString())) + .build() + ); + + // 응답 파싱 + JsonObject result = JsonParser.parseString( + response.body().asUtf8String() + ).getAsJsonObject(); + + return result.getAsJsonArray("content") + .get(0).getAsJsonObject() + .get("text").getAsString(); + } + + /** + * 레벨별 시스템 프롬프트 생성 + */ + private String buildSystemPrompt(String targetLevel) { + String levelGuidance = switch (targetLevel.toUpperCase()) { + case "BEGINNER" -> """ + - Use simple vocabulary and short sentences + - Speak slowly and clearly + - Use basic grammar structures + - Provide Korean translations for difficult words in parentheses + """; + case "ADVANCED" -> """ + - Use sophisticated vocabulary and complex sentences + - Include idiomatic expressions and phrasal verbs + - Discuss abstract concepts naturally + - Challenge the user with nuanced topics + """; + default -> """ + - Use moderate vocabulary appropriate for intermediate learners + - Mix simple and compound sentences + - Introduce useful expressions gradually + - Balance challenge with accessibility + """; + }; + + return String.format(""" + You are a friendly English conversation partner for Korean learners. + Your name is "Amy" and you're an American English teacher living in Seoul. + + ## Target Level: %s + + ## Level-Specific Guidelines: + %s + + ## General Guidelines: + - Keep responses conversational (2-4 sentences) + - Be warm, encouraging, and supportive + - If the user makes grammar mistakes, gently correct them naturally in your response + - Ask follow-up questions to keep the conversation going + - Respond in English only (except for occasional Korean translations for difficult words) + - Match the conversation topic to the user's interests + - Use natural filler words occasionally (well, you know, actually) + + ## Correction Style: + Instead of: "You said 'I go to store.' It should be 'I went to the store.'" + Do this: "Oh, so you went to the store? That's nice! What did you buy?" + + Remember: Your goal is to make the user feel comfortable practicing English! + """, targetLevel, levelGuidance); + } + + /** + * 히스토리 JSON 파싱 + */ + private List parseHistory(String historyJson) { + List history = new ArrayList<>(); + + if (historyJson == null || historyJson.isEmpty() || historyJson.equals("[]")) { + return history; + } + + try { + JsonArray array = JsonParser.parseString(historyJson).getAsJsonArray(); + for (JsonElement el : array) { + JsonObject obj = el.getAsJsonObject(); + history.add(new Message( + obj.get("role").getAsString(), + obj.get("content").getAsString() + )); + } + } catch (Exception e) { + logger.warn("Failed to parse history, starting fresh: {}", e.getMessage()); + } + + return history; + } + + /** + * 히스토리 JSON 변환 + */ + private String toJson(List history) { + JsonArray array = new JsonArray(); + for (Message msg : history) { + JsonObject obj = new JsonObject(); + obj.addProperty("role", msg.role()); + obj.addProperty("content", msg.content()); + array.add(obj); + } + return array.toString(); + } + + // ==================== Inner Classes ==================== + + private record Message(String role, String content) {} + + /** + * Speaking 응답 DTO + */ + public record SpeakingResponse( + String userTranscript, // 사용자가 말한 내용 (STT 결과) + String aiText, // AI 응답 텍스트 + String aiAudioUrl, // AI 응답 음성 URL (Polly) + double confidence // STT 신뢰도comp + ) {} +} \ No newline at end of file From f39bdf971a2d22e05b0b8918f7f08b59730cdd3b Mon Sep 17 00:00:00 2001 From: hye-inA Date: Thu, 22 Jan 2026 10:52:33 +0900 Subject: [PATCH 418/528] =?UTF-8?q?feat=20:=20WebSocket=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=20=EC=A0=95=EB=B3=B4=20Repository=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SpeakingConnectionRepository.java | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java new file mode 100644 index 00000000..cbef094a --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java @@ -0,0 +1,73 @@ +현package com.mzc.secondproject.serverless.domain.speaking.repository; + +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.config.EnvConfig; +import com.mzc.secondproject.serverless.domain.speaking.model.SpeakingConnection; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; +import software.amazon.awssdk.enhanced.dynamodb.Key; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; + +import java.util.Optional; + +/** + * Speaking WebSocket 연결 정보 Repository + */ +public class SpeakingConnectionRepository { + + private static final Logger logger = LoggerFactory.getLogger(SpeakingConnectionRepository.class); + private static final String TABLE_NAME = EnvConfig.getRequired("CHAT_TABLE_NAME"); + + private final DynamoDbTable table; + + public SpeakingConnectionRepository() { + this.table = AwsClients.dynamoDbEnhanced().table( + TABLE_NAME, + TableSchema.fromBean(SpeakingConnection.class) + ); + } + + /** + * 연결 정보 저장 + */ + public void save(SpeakingConnection connection) { + table.putItem(connection); + logger.debug("Speaking connection saved: connectionId={}, userId={}", + connection.getConnectionId(), connection.getUserId()); + } + + /** + * connectionId로 연결 정보 조회 + */ + public Optional findByConnectionId(String connectionId) { + Key key = Key.builder() + .partitionValue(SpeakingConnection.PK_PREFIX + connectionId) + .sortValue(SpeakingConnection.SK_METADATA) + .build(); + + SpeakingConnection connection = table.getItem(key); + return Optional.ofNullable(connection); + } + + /** + * 연결 정보 업데이트 (대화 히스토리 등) + */ + public void update(SpeakingConnection connection) { + table.putItem(connection); + logger.debug("Speaking connection updated: connectionId={}", connection.getConnectionId()); + } + + /** + * 연결 정보 삭제 + */ + public void delete(String connectionId) { + Key key = Key.builder() + .partitionValue(SpeakingConnection.PK_PREFIX + connectionId) + .sortValue(SpeakingConnection.SK_METADATA) + .build(); + + table.deleteItem(key); + logger.info("Speaking connection deleted: connectionId={}", connectionId); + } +} \ No newline at end of file From 674f87c2e321176c1648139aa2f8e8e3b1574f5f Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 10:54:47 +0900 Subject: [PATCH 419/528] fix: update WebSocketDisconnectHandler to use GameSession model - Remove references to deleted ChatRoom game fields - Use GameSessionRepository to finish active game sessions - Use ChatRoomRepository.updateStatus() for room state management --- .../websocket/WebSocketDisconnectHandler.java | 38 +++++++++++-------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketDisconnectHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketDisconnectHandler.java index 3929f10e..ce0ed059 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketDisconnectHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketDisconnectHandler.java @@ -5,11 +5,14 @@ import com.mzc.secondproject.serverless.common.util.WebSocketEventUtil; import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; import com.mzc.secondproject.serverless.domain.chatting.model.Connection; +import com.mzc.secondproject.serverless.domain.chatting.model.GameSession; import com.mzc.secondproject.serverless.domain.chatting.repository.ChatRoomRepository; import com.mzc.secondproject.serverless.domain.chatting.repository.ConnectionRepository; +import com.mzc.secondproject.serverless.domain.chatting.repository.GameSessionRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.time.Instant; import java.util.List; import java.util.Map; import java.util.Optional; @@ -24,10 +27,12 @@ public class WebSocketDisconnectHandler implements RequestHandler handleRequest(Map event, Context cont /** * 게임 상태 초기화 + * 새 구조에서는 GameSession을 종료하고 ChatRoom의 상태를 WAITING으로 변경 */ private void resetGameState(String roomId) { try { - Optional roomOpt = chatRoomRepository.findById(roomId); + // 활성 게임 세션이 있으면 종료 + Optional activeSession = gameSessionRepository.findActiveByRoomId(roomId); + if (activeSession.isPresent()) { + GameSession session = activeSession.get(); + long now = Instant.now().toEpochMilli(); + long ttl = now / 1000 + 86400 * 7; // 7일 후 TTL + gameSessionRepository.finishGame(session.getGameSessionId(), now, ttl); + logger.info("Game session finished due to empty room: gameSessionId={}", session.getGameSessionId()); + } + // 채팅방 상태 초기화 + Optional roomOpt = chatRoomRepository.findById(roomId); if (roomOpt.isPresent()) { ChatRoom room = roomOpt.get(); - // 게임이 진행 중이었다면 초기화 - if (room.getGameStatus() != null && !"NONE".equals(room.getGameStatus())) { - room.setGameStatus("NONE"); - room.setCurrentRound(null); - room.setCurrentDrawerId(null); - room.setCurrentWord(null); - room.setCurrentWordId(null); - room.setDrawerOrder(null); - room.setScores(null); - room.setStreaks(null); - room.setCorrectGuessers(null); - room.setHintUsed(null); - room.setRoundStartTime(null); - room.setGameStartedBy(null); + // 게임이 진행 중이었다면 상태 초기화 + if ("PLAYING".equals(room.getStatus())) { + chatRoomRepository.updateStatus(room, "WAITING"); + room.setActiveGameSessionId(null); chatRoomRepository.save(room); - logger.info("Game state reset for room: {}", roomId); + logger.info("Room status reset to WAITING for room: {}", roomId); } } } catch (Exception e) { From 540f92d403308c30ab677422033d69ca5d8ce94d Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 10:57:09 +0900 Subject: [PATCH 420/528] perf: optimize CI/CD build time - Add pip cache for SAM CLI dependencies - Enable Gradle parallel builds and build cache - Add SAM build cache (.aws-sam) - Use sam build --parallel --cached - Skip SAM CLI install if already cached - Remove unnecessary 'clean' to leverage cache --- ServerlessFunction/buildspec.yml | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/ServerlessFunction/buildspec.yml b/ServerlessFunction/buildspec.yml index 16ebf0ce..4bce0c4c 100644 --- a/ServerlessFunction/buildspec.yml +++ b/ServerlessFunction/buildspec.yml @@ -3,14 +3,21 @@ version: 0.2 env: variables: SAM_CLI_TELEMETRY: 0 + GRADLE_OPTS: "-Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.caching=true" + PIP_CACHE_DIR: "/root/.cache/pip" phases: install: runtime-versions: java: corretto21 commands: - - echo "Installing SAM CLI..." - - pip3 install aws-sam-cli + - echo "Installing SAM CLI (cached)..." + - | + if ! command -v sam &> /dev/null || [ "$(sam --version 2>/dev/null | grep -oP '\d+\.\d+\.\d+')" != "1.152.0" ]; then + pip3 install --quiet aws-sam-cli + else + echo "SAM CLI already installed, skipping..." + fi - sam --version pre_build: @@ -18,14 +25,14 @@ phases: - echo "Running tests..." - cd ServerlessFunction - chmod +x gradlew - - ./gradlew clean test + - ./gradlew test --build-cache --parallel - echo "Tests completed" build: commands: - echo "Building SAM application..." - cd $CODEBUILD_SRC_DIR/ServerlessFunction - - sam build + - sam build --parallel --cached - echo "Packaging SAM application..." - sam package --s3-bucket group2-englishstudy-pipeline-artifacts --s3-prefix sam-packages --output-template-file packaged-template.yaml @@ -42,6 +49,9 @@ cache: paths: - '/root/.gradle/caches/**/*' - '/root/.gradle/wrapper/**/*' + - '/root/.cache/pip/**/*' + - '/root/.aws-sam/build/**/*' + - 'ServerlessFunction/.aws-sam/cache/**/*' reports: junit-reports: From 96dcbc20dc183789a145dcd2331e72ab4041c534 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 10:59:08 +0900 Subject: [PATCH 421/528] feat: add custom CodeBuild Docker image with pre-installed tools - Dockerfile with Java 21 + SAM CLI + Gradle pre-installed - build-and-push.sh script for ECR deployment - Updated buildspec.yml for custom image (removes SAM CLI install) - Expected build time reduction: ~30-40 seconds --- ServerlessFunction/buildspec.yml | 17 +++------- docker/Dockerfile | 55 ++++++++++++++++++++++++++++++++ docker/build-and-push.sh | 48 ++++++++++++++++++++++++++++ 3 files changed, 107 insertions(+), 13 deletions(-) create mode 100644 docker/Dockerfile create mode 100755 docker/build-and-push.sh diff --git a/ServerlessFunction/buildspec.yml b/ServerlessFunction/buildspec.yml index 4bce0c4c..49ed2a4f 100644 --- a/ServerlessFunction/buildspec.yml +++ b/ServerlessFunction/buildspec.yml @@ -4,21 +4,14 @@ env: variables: SAM_CLI_TELEMETRY: 0 GRADLE_OPTS: "-Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.caching=true" - PIP_CACHE_DIR: "/root/.cache/pip" phases: install: - runtime-versions: - java: corretto21 commands: - - echo "Installing SAM CLI (cached)..." - - | - if ! command -v sam &> /dev/null || [ "$(sam --version 2>/dev/null | grep -oP '\d+\.\d+\.\d+')" != "1.152.0" ]; then - pip3 install --quiet aws-sam-cli - else - echo "SAM CLI already installed, skipping..." - fi + - echo "Verifying pre-installed tools..." + - java -version - sam --version + - echo "Tools verified" pre_build: commands: @@ -49,9 +42,7 @@ cache: paths: - '/root/.gradle/caches/**/*' - '/root/.gradle/wrapper/**/*' - - '/root/.cache/pip/**/*' - - '/root/.aws-sam/build/**/*' - - 'ServerlessFunction/.aws-sam/cache/**/*' + - '.aws-sam/cache/**/*' reports: junit-reports: diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 00000000..58d20580 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,55 @@ +# CodeBuild Custom Image with Java 21 + SAM CLI +FROM public.ecr.aws/amazonlinux/amazonlinux:2023 + +# Install basic dependencies +RUN dnf update -y && \ + dnf install -y \ + git \ + tar \ + gzip \ + unzip \ + which \ + findutils \ + python3 \ + python3-pip \ + docker \ + && dnf clean all + +# Install Amazon Corretto 21 (Java 21) +RUN rpm --import https://yum.corretto.aws/corretto.key && \ + curl -L -o /etc/yum.repos.d/corretto.repo https://yum.corretto.aws/corretto.repo && \ + dnf install -y java-21-amazon-corretto-devel && \ + dnf clean all + +# Set JAVA_HOME +ENV JAVA_HOME=/usr/lib/jvm/java-21-amazon-corretto +ENV PATH=$JAVA_HOME/bin:$PATH + +# Install AWS CLI v2 +RUN curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip" && \ + unzip awscliv2.zip && \ + ./aws/install && \ + rm -rf aws awscliv2.zip + +# Install SAM CLI (the main optimization!) +RUN pip3 install aws-sam-cli + +# Install Gradle (optional, project uses wrapper) +ENV GRADLE_VERSION=8.5 +RUN curl -L https://services.gradle.org/distributions/gradle-${GRADLE_VERSION}-bin.zip -o gradle.zip && \ + unzip gradle.zip -d /opt && \ + rm gradle.zip && \ + ln -s /opt/gradle-${GRADLE_VERSION}/bin/gradle /usr/bin/gradle + +# Verify installations +RUN java -version && \ + aws --version && \ + sam --version && \ + gradle --version + +# Set working directory +WORKDIR /codebuild/output/src + +# Labels +LABEL maintainer="group2-englishstudy" \ + description="CodeBuild image with Java 21, SAM CLI, and Gradle pre-installed" diff --git a/docker/build-and-push.sh b/docker/build-and-push.sh new file mode 100755 index 00000000..bb4e1297 --- /dev/null +++ b/docker/build-and-push.sh @@ -0,0 +1,48 @@ +#!/bin/bash +set -e + +# Configuration +AWS_REGION="ap-northeast-2" +AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) +ECR_REPO_NAME="group2-codebuild-image" +IMAGE_TAG="java21-sam" + +ECR_URI="${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/${ECR_REPO_NAME}" + +echo "=== CodeBuild Custom Image Build & Push ===" +echo "AWS Account: ${AWS_ACCOUNT_ID}" +echo "ECR Repository: ${ECR_REPO_NAME}" +echo "Image: ${ECR_URI}:${IMAGE_TAG}" +echo "" + +# 1. Create ECR repository (if not exists) +echo "[1/4] Creating ECR repository..." +aws ecr describe-repositories --repository-names ${ECR_REPO_NAME} --region ${AWS_REGION} 2>/dev/null || \ + aws ecr create-repository --repository-name ${ECR_REPO_NAME} --region ${AWS_REGION} + +# 2. Login to ECR +echo "[2/4] Logging in to ECR..." +aws ecr get-login-password --region ${AWS_REGION} | docker login --username AWS --password-stdin ${ECR_URI} + +# 3. Build Docker image +echo "[3/4] Building Docker image..." +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +docker build -t ${ECR_REPO_NAME}:${IMAGE_TAG} ${SCRIPT_DIR} + +# 4. Tag and push +echo "[4/4] Pushing to ECR..." +docker tag ${ECR_REPO_NAME}:${IMAGE_TAG} ${ECR_URI}:${IMAGE_TAG} +docker tag ${ECR_REPO_NAME}:${IMAGE_TAG} ${ECR_URI}:latest +docker push ${ECR_URI}:${IMAGE_TAG} +docker push ${ECR_URI}:latest + +echo "" +echo "=== SUCCESS ===" +echo "Image pushed: ${ECR_URI}:${IMAGE_TAG}" +echo "" +echo "Next steps:" +echo "1. Update CodeBuild project to use custom image:" +echo " Image: ${ECR_URI}:${IMAGE_TAG}" +echo " Image pull credentials: Service role" +echo "" +echo "2. Add ECR pull permission to CodeBuild service role" From 02dab52180d3fe7a2aea4ab99564f88f504e3b41 Mon Sep 17 00:00:00 2001 From: hyein Heo <128613248+hye-inA@users.noreply.github.com> Date: Thu, 22 Jan 2026 11:12:55 +0900 Subject: [PATCH 422/528] =?UTF-8?q?feature=20:=20AI=20=EC=98=81=EC=96=B4?= =?UTF-8?q?=20=ED=9A=8C=ED=99=94=20=EC=97=B0=EC=8A=B5=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=20(#468)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 --- .../websocket/SpeakingConnectHandler.java | 88 +++++ .../websocket/SpeakingDisconnectHandler.java | 44 +++ .../websocket/SpeakingMessageHandler.java | 217 ++++++++++++ .../speaking/model/SpeakingConnection.java | 84 +++++ .../SpeakingConnectionRepository.java | 73 ++++ .../speaking/service/SpeakingService.java | 317 ++++++++++++++++++ 6 files changed, 823 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingConnectHandler.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingDisconnectHandler.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingMessageHandler.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/SpeakingConnection.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/service/SpeakingService.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingConnectHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingConnectHandler.java new file mode 100644 index 00000000..535a3fc1 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingConnectHandler.java @@ -0,0 +1,88 @@ +package com.mzc.secondproject.serverless.domain.speaking.handler.websocket; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.mzc.secondproject.serverless.common.config.WebSocketConfig; +import com.mzc.secondproject.serverless.common.util.JwtUtil; +import com.mzc.secondproject.serverless.common.util.WebSocketEventUtil; +import com.mzc.secondproject.serverless.domain.speaking.model.SpeakingConnection; +import com.mzc.secondproject.serverless.domain.speaking.repository.SpeakingConnectionRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Map; +import java.util.Optional; + +/** + * Speaking WebSocket $connect 핸들러 + * JWT 토큰 검증 후 연결 정보를 DynamoDB에 저장 + * + * 연결 방법: + * wss://{api-id}.execute-api.{region}.amazonaws.com/{stage}?token={jwt} + */ +public class SpeakingConnectHandler implements RequestHandler, Map> { + + private static final Logger logger = LoggerFactory.getLogger(SpeakingConnectHandler.class); + + private final SpeakingConnectionRepository connectionRepository; + + public SpeakingConnectHandler() { + this.connectionRepository = new SpeakingConnectionRepository(); + } + + @Override + public Map handleRequest(Map event, Context context) { + logger.info("Speaking WebSocket connect event"); + + try { + String connectionId = WebSocketEventUtil.extractConnectionId(event); + Map queryParams = WebSocketEventUtil.extractQueryStringParameters(event); + + // JWT 토큰 검증 + String token = queryParams.get("token"); + + if (token == null || token.isEmpty()) { + logger.warn("Missing token parameter"); + return WebSocketEventUtil.unauthorized("token is required"); + } + + // 토큰 유효성 검사 + if (!JwtUtil.isValid(token)) { + logger.warn("Invalid or expired token"); + return WebSocketEventUtil.unauthorized("Invalid or expired token"); + } + + // userId 추출 + Optional userIdOpt = JwtUtil.extractUserId(token); + if (userIdOpt.isEmpty()) { + logger.warn("Failed to extract userId from token"); + return WebSocketEventUtil.unauthorized("Invalid token"); + } + + String userId = userIdOpt.get(); + + // 연결 정보 저장 + SpeakingConnection connection = SpeakingConnection.create( + connectionId, + userId, + WebSocketConfig.connectionTtlSeconds() + ); + + // 레벨 파라미터가 있으면 설정 + String level = queryParams.get("level"); + if (level != null && !level.isEmpty()) { + connection.setTargetLevel(level.toUpperCase()); + } + + connectionRepository.save(connection); + + logger.info("Speaking connection established: connectionId={}, userId={}, level={}", + connectionId, userId, connection.getTargetLevel()); + return WebSocketEventUtil.ok("Connected"); + + } catch (Exception e) { + logger.error("Error handling connect: {}", e.getMessage(), e); + return WebSocketEventUtil.serverError("Internal server error"); + } + } +} \ No newline at end of file diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingDisconnectHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingDisconnectHandler.java new file mode 100644 index 00000000..4d82a9d1 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingDisconnectHandler.java @@ -0,0 +1,44 @@ +package com.mzc.secondproject.serverless.domain.speaking.handler.websocket; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.mzc.secondproject.serverless.common.util.WebSocketEventUtil; +import com.mzc.secondproject.serverless.domain.speaking.repository.SpeakingConnectionRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Map; + +/** + * Speaking WebSocket $disconnect 핸들러 + * 연결 해제 시 DynamoDB에서 연결 정보 삭제 + */ +public class SpeakingDisconnectHandler implements RequestHandler, Map> { + + private static final Logger logger = LoggerFactory.getLogger(SpeakingDisconnectHandler.class); + + private final SpeakingConnectionRepository connectionRepository; + + public SpeakingDisconnectHandler() { + this.connectionRepository = new SpeakingConnectionRepository(); + } + + @Override + public Map handleRequest(Map event, Context context) { + logger.info("Speaking WebSocket disconnect event"); + + try { + String connectionId = WebSocketEventUtil.extractConnectionId(event); + + // 연결 정보 삭제 + connectionRepository.delete(connectionId); + + logger.info("Speaking connection closed: connectionId={}", connectionId); + return WebSocketEventUtil.ok("Disconnected"); + + } catch (Exception e) { + logger.error("Error handling disconnect: {}", e.getMessage(), e); + return WebSocketEventUtil.serverError("Internal server error"); + } + } +} \ No newline at end of file diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingMessageHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingMessageHandler.java new file mode 100644 index 00000000..89ec0a44 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingMessageHandler.java @@ -0,0 +1,217 @@ +package com.mzc.secondproject.serverless.domain.speaking.handler.websocket; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.mzc.secondproject.serverless.common.util.WebSocketEventUtil; +import com.mzc.secondproject.serverless.domain.speaking.repository.SpeakingConnectionRepository; +import com.mzc.secondproject.serverless.domain.speaking.service.SpeakingService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.core.SdkBytes; +import software.amazon.awssdk.services.apigatewaymanagementapi.ApiGatewayManagementApiClient; +import software.amazon.awssdk.services.apigatewaymanagementapi.model.PostToConnectionRequest; + +import java.net.URI; +import java.util.Map; + +/** + * Speaking WebSocket 메시지 핸들러 + * + * 지원하는 action: + * - speak: 음성 입력 처리 (audio base64) + * - text: 텍스트 입력 처리 + * - setLevel: 레벨 변경 + * - reset: 대화 히스토리 초기화 + */ +public class SpeakingMessageHandler implements RequestHandler, Map> { + + private static final Logger logger = LoggerFactory.getLogger(SpeakingMessageHandler.class); + private static final Gson gson = new GsonBuilder().create(); + + private final SpeakingService speakingService; + private final SpeakingConnectionRepository connectionRepository; + + public SpeakingMessageHandler() { + this.speakingService = new SpeakingService(); + this.connectionRepository = new SpeakingConnectionRepository(); + } + + @Override + public Map handleRequest(Map event, Context context) { + logger.info("Speaking message event received"); + + String connectionId = null; + String endpoint = null; + + try { + connectionId = WebSocketEventUtil.extractConnectionId(event); + endpoint = WebSocketEventUtil.extractWebSocketEndpoint(event); + + // 연결 정보 확인 + if (connectionRepository.findByConnectionId(connectionId).isEmpty()) { + logger.warn("Connection not found: {}", connectionId); + return sendError(connectionId, endpoint, "Unauthorized - please reconnect"); + } + + // 요청 바디 파싱 + String body = (String) event.get("body"); + if (body == null || body.isEmpty()) { + return sendError(connectionId, endpoint, "Message body is required"); + } + + JsonObject request = JsonParser.parseString(body).getAsJsonObject(); + String action = request.has("action") ? request.get("action").getAsString() : "speak"; + + logger.info("Processing action: {} for connectionId: {}", action, connectionId); + + // 액션별 처리 + switch (action) { + case "speak" -> handleSpeak(connectionId, endpoint, request); + case "text" -> handleText(connectionId, endpoint, request); + case "setLevel" -> handleSetLevel(connectionId, endpoint, request); + case "reset" -> handleReset(connectionId, endpoint); + default -> sendError(connectionId, endpoint, "Unknown action: " + action); + } + + return WebSocketEventUtil.ok("Processed"); + + } catch (Exception e) { + logger.error("Error processing message: {}", e.getMessage(), e); + if (connectionId != null && endpoint != null) { + sendError(connectionId, endpoint, "Processing error: " + e.getMessage()); + } + return WebSocketEventUtil.serverError("Internal server error"); + } + } + + /** + * 음성 입력 처리 + */ + private void handleSpeak(String connectionId, String endpoint, JsonObject request) { + // 시작 이벤트 전송 + sendToConnection(connectionId, endpoint, Map.of( + "type", "start", + "message", "Processing your voice..." + )); + + // 음성 데이터 추출 + String audioBase64 = request.has("audio") ? request.get("audio").getAsString() : null; + if (audioBase64 == null || audioBase64.isEmpty()) { + sendError(connectionId, endpoint, "audio data is required for speak action"); + return; + } + + // 음성 처리 + SpeakingService.SpeakingResponse response = speakingService.processVoiceInput( + connectionId, audioBase64 + ); + + // 결과 전송 + sendToConnection(connectionId, endpoint, Map.of( + "type", "complete", + "userTranscript", response.userTranscript(), + "aiText", response.aiText(), + "aiAudioUrl", response.aiAudioUrl(), + "confidence", response.confidence() + )); + } + + /** + * 텍스트 입력 처리 + */ + private void handleText(String connectionId, String endpoint, JsonObject request) { + String text = request.has("text") ? request.get("text").getAsString() : null; + if (text == null || text.trim().isEmpty()) { + sendError(connectionId, endpoint, "text is required for text action"); + return; + } + + // 시작 이벤트 전송 + sendToConnection(connectionId, endpoint, Map.of( + "type", "start", + "message", "Processing your message..." + )); + + // 텍스트 처리 + SpeakingService.SpeakingResponse response = speakingService.processTextInput( + connectionId, text.trim() + ); + + // 결과 전송 + sendToConnection(connectionId, endpoint, Map.of( + "type", "complete", + "userTranscript", response.userTranscript(), + "aiText", response.aiText(), + "aiAudioUrl", response.aiAudioUrl(), + "confidence", response.confidence() + )); + } + + /** + * 레벨 변경 처리 + */ + private void handleSetLevel(String connectionId, String endpoint, JsonObject request) { + String level = request.has("level") ? request.get("level").getAsString() : null; + if (level == null || level.isEmpty()) { + sendError(connectionId, endpoint, "level is required"); + return; + } + + speakingService.updateLevel(connectionId, level); + + sendToConnection(connectionId, endpoint, Map.of( + "type", "levelChanged", + "level", level.toUpperCase() + )); + } + + /** + * 대화 초기화 처리 + */ + private void handleReset(String connectionId, String endpoint) { + speakingService.resetConversation(connectionId); + + sendToConnection(connectionId, endpoint, Map.of( + "type", "reset", + "message", "Conversation has been reset. Let's start fresh!" + )); + } + + /** + * WebSocket으로 메시지 전송 + */ + private void sendToConnection(String connectionId, String endpoint, Map data) { + try { + ApiGatewayManagementApiClient apiClient = ApiGatewayManagementApiClient.builder() + .endpointOverride(URI.create(endpoint)) + .build(); + + String message = gson.toJson(data); + + apiClient.postToConnection(PostToConnectionRequest.builder() + .connectionId(connectionId) + .data(SdkBytes.fromUtf8String(message)) + .build()); + + logger.debug("Message sent to {}: {}", connectionId, data.get("type")); + + } catch (Exception e) { + logger.error("Failed to send message to {}: {}", connectionId, e.getMessage()); + } + } + + /** + * 에러 메시지 전송 + */ + private Map sendError(String connectionId, String endpoint, String errorMessage) { + sendToConnection(connectionId, endpoint, Map.of( + "type", "error", + "message", errorMessage + )); + return WebSocketEventUtil.ok("Error sent"); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/SpeakingConnection.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/SpeakingConnection.java new file mode 100644 index 00000000..6ea0c185 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/SpeakingConnection.java @@ -0,0 +1,84 @@ +package com.mzc.secondproject.serverless.domain.speaking.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.*; + +/** + * Speaking WebSocket 연결 정보 + * connectionId ↔ userId 매핑 + 대화 히스토리 저장 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DynamoDbBean +public class SpeakingConnection { + + // DynamoDB Key Prefixes + public static final String PK_PREFIX = "SPEAKING_CONN#"; + public static final String SK_METADATA = "METADATA"; + public static final String GSI1PK_PREFIX = "SPEAKING_USER#"; + public static final String GSI1SK_PREFIX = "CONN#"; + + private String pk; // SPEAKING_CONN#{connectionId} + private String sk; // METADATA + private String gsi1pk; // SPEAKING_USER#{userId} + private String gsi1sk; // CONN#{connectionId} + + private String connectionId; + private String userId; + private String connectedAt; + private Long ttl; // 자동 삭제용 + + // Speaking 전용 필드 + private String conversationHistory; // 대화 히스토리 (JSON) + private String targetLevel; // 목표 레벨 (BEGINNER, INTERMEDIATE, ADVANCED) + + /** + * 연결 정보 생성 팩토리 메서드 + */ + public static SpeakingConnection create(String connectionId, String userId, long ttlSeconds) { + String now = java.time.Instant.now().toString(); + long ttl = java.time.Instant.now().plusSeconds(ttlSeconds).getEpochSecond(); + + return SpeakingConnection.builder() + .pk(PK_PREFIX + connectionId) + .sk(SK_METADATA) + .gsi1pk(GSI1PK_PREFIX + userId) + .gsi1sk(GSI1SK_PREFIX + connectionId) + .connectionId(connectionId) + .userId(userId) + .connectedAt(now) + .ttl(ttl) + .conversationHistory("[]") // 빈 배열로 초기화 + .targetLevel("INTERMEDIATE") // 기본값 + .build(); + } + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { + return pk; + } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { + return sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1PK") + public String getGsi1pk() { + return gsi1pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1SK") + public String getGsi1sk() { + return gsi1sk; + } +} \ No newline at end of file diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java new file mode 100644 index 00000000..cbef094a --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java @@ -0,0 +1,73 @@ +현package com.mzc.secondproject.serverless.domain.speaking.repository; + +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.config.EnvConfig; +import com.mzc.secondproject.serverless.domain.speaking.model.SpeakingConnection; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; +import software.amazon.awssdk.enhanced.dynamodb.Key; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; + +import java.util.Optional; + +/** + * Speaking WebSocket 연결 정보 Repository + */ +public class SpeakingConnectionRepository { + + private static final Logger logger = LoggerFactory.getLogger(SpeakingConnectionRepository.class); + private static final String TABLE_NAME = EnvConfig.getRequired("CHAT_TABLE_NAME"); + + private final DynamoDbTable table; + + public SpeakingConnectionRepository() { + this.table = AwsClients.dynamoDbEnhanced().table( + TABLE_NAME, + TableSchema.fromBean(SpeakingConnection.class) + ); + } + + /** + * 연결 정보 저장 + */ + public void save(SpeakingConnection connection) { + table.putItem(connection); + logger.debug("Speaking connection saved: connectionId={}, userId={}", + connection.getConnectionId(), connection.getUserId()); + } + + /** + * connectionId로 연결 정보 조회 + */ + public Optional findByConnectionId(String connectionId) { + Key key = Key.builder() + .partitionValue(SpeakingConnection.PK_PREFIX + connectionId) + .sortValue(SpeakingConnection.SK_METADATA) + .build(); + + SpeakingConnection connection = table.getItem(key); + return Optional.ofNullable(connection); + } + + /** + * 연결 정보 업데이트 (대화 히스토리 등) + */ + public void update(SpeakingConnection connection) { + table.putItem(connection); + logger.debug("Speaking connection updated: connectionId={}", connection.getConnectionId()); + } + + /** + * 연결 정보 삭제 + */ + public void delete(String connectionId) { + Key key = Key.builder() + .partitionValue(SpeakingConnection.PK_PREFIX + connectionId) + .sortValue(SpeakingConnection.SK_METADATA) + .build(); + + table.deleteItem(key); + logger.info("Speaking connection deleted: connectionId={}", connectionId); + } +} \ No newline at end of file diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/service/SpeakingService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/service/SpeakingService.java new file mode 100644 index 00000000..3462bbfb --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/service/SpeakingService.java @@ -0,0 +1,317 @@ +package com.mzc.secondproject.serverless.domain.speaking.service; + +import com.google.gson.*; +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.config.EnvConfig; +import com.mzc.secondproject.serverless.common.service.PollyService; +import com.mzc.secondproject.serverless.domain.opic.service.TranscribeProxyService; +import com.mzc.secondproject.serverless.domain.speaking.model.SpeakingConnection; +import com.mzc.secondproject.serverless.domain.speaking.repository.SpeakingConnectionRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.core.SdkBytes; +import software.amazon.awssdk.services.bedrockruntime.model.InvokeModelRequest; +import software.amazon.awssdk.services.bedrockruntime.model.InvokeModelResponse; + + +import java.util.ArrayList; +import java.util.List; + +/** + * AI와 대화하기 서비스 + * 음성 입력 → STT → Bedrock → TTS → 음성 출력 + */ +public class SpeakingService { + + private static final Logger logger = LoggerFactory.getLogger(SpeakingService.class); + private static final Gson gson = new GsonBuilder().create(); + + private static final String MODEL_ID = "anthropic.claude-3-haiku-20240307-v1:0"; + private static final int MAX_TOKENS = 500; + private static final int MAX_HISTORY_SIZE = 10; // 최근 10턴만 유지 + + private final TranscribeProxyService transcribeService; + private final PollyService pollyService; + private final SpeakingConnectionRepository connectionRepository; + + public SpeakingService() { + this.transcribeService = new TranscribeProxyService(); + this.pollyService = new PollyService( + EnvConfig.getRequired("BUCKET_NAME"), + "speaking/voice/" + ); + this.connectionRepository = new SpeakingConnectionRepository(); + } + + /** + * 음성 입력 처리 (전체 플로우) + */ + public SpeakingResponse processVoiceInput(String connectionId, String audioBase64) { + logger.info("Processing voice input for connectionId: {}", connectionId); + + // 연결 정보 조회 + SpeakingConnection connection = connectionRepository.findByConnectionId(connectionId) + .orElseThrow(() -> new RuntimeException("Connection not found: " + connectionId)); + + String targetLevel = connection.getTargetLevel(); + + // STT: 음성 → 텍스트 (Transcribe Proxy 사용) + logger.info("Step 1: Transcribing audio..."); + TranscribeProxyService.TranscribeResult sttResult = transcribeService.transcribe( + audioBase64, + connectionId, + "en-US" + ); + String userText = sttResult.transcript(); + logger.info("Transcription complete: {} (confidence: {})", userText, sttResult.confidence()); + + // 대화 히스토리 로드 + List history = parseHistory(connection.getConversationHistory()); + + // Bedrock: AI 응답 생성 + logger.info("Step 2: Generating AI response..."); + String aiResponse = generateAiResponse(userText, history, targetLevel); + logger.info("AI response generated: {}", aiResponse); + + // 히스토리 업데이트 (최근 N턴만 유지) + history.add(new Message("user", userText)); + history.add(new Message("assistant", aiResponse)); + if (history.size() > MAX_HISTORY_SIZE * 2) { + history = new ArrayList<>(history.subList(history.size() - MAX_HISTORY_SIZE * 2, history.size())); + } + connection.setConversationHistory(toJson(history)); + connectionRepository.update(connection); + + // TTS: 텍스트 → 음성 (Polly 사용) + logger.info("Step 3: Synthesizing speech..."); + String audioId = connectionId + "_" + System.currentTimeMillis(); + PollyService.VoiceSynthesisResult ttsResult = pollyService.synthesizeSpeech( + audioId, + aiResponse, + "FEMALE" + ); + logger.info("Speech synthesis complete: cached={}", ttsResult.isCached()); + + return new SpeakingResponse( + userText, + aiResponse, + ttsResult.getAudioUrl(), + sttResult.confidence() + ); + } + + /** + * 텍스트 입력 처리 (음성 없이 텍스트만) + */ + public SpeakingResponse processTextInput(String connectionId, String userText) { + logger.info("Processing text input for connectionId: {}", connectionId); + + // 연결 정보 조회 + SpeakingConnection connection = connectionRepository.findByConnectionId(connectionId) + .orElseThrow(() -> new RuntimeException("Connection not found: " + connectionId)); + + String targetLevel = connection.getTargetLevel(); + + // 대화 히스토리 로드 + List history = parseHistory(connection.getConversationHistory()); + + // AI 응답 생성 + String aiResponse = generateAiResponse(userText, history, targetLevel); + + // 히스토리 업데이트 + history.add(new Message("user", userText)); + history.add(new Message("assistant", aiResponse)); + if (history.size() > MAX_HISTORY_SIZE * 2) { + history = new ArrayList<>(history.subList(history.size() - MAX_HISTORY_SIZE * 2, history.size())); + } + connection.setConversationHistory(toJson(history)); + connectionRepository.update(connection); + + // TTS 생성 + String audioId = connectionId + "_" + System.currentTimeMillis(); + PollyService.VoiceSynthesisResult ttsResult = pollyService.synthesizeSpeech( + audioId, aiResponse, "FEMALE" + ); + + return new SpeakingResponse(userText, aiResponse, ttsResult.getAudioUrl(), 1.0); + } + + /** + * 레벨 변경 + */ + public void updateLevel(String connectionId, String level) { + SpeakingConnection connection = connectionRepository.findByConnectionId(connectionId) + .orElseThrow(() -> new RuntimeException("Connection not found: " + connectionId)); + + connection.setTargetLevel(level.toUpperCase()); + connectionRepository.update(connection); + logger.info("Level updated for connectionId {}: {}", connectionId, level); + } + + /** + * 대화 히스토리 초기화 + */ + public void resetConversation(String connectionId) { + SpeakingConnection connection = connectionRepository.findByConnectionId(connectionId) + .orElseThrow(() -> new RuntimeException("Connection not found: " + connectionId)); + + connection.setConversationHistory("[]"); + connectionRepository.update(connection); + logger.info("Conversation reset for connectionId: {}", connectionId); + } + + + /** + * Bedrock Claude 호출하여 AI 응답 생성 + */ + private String generateAiResponse(String userText, List history, String targetLevel) { + String systemPrompt = buildSystemPrompt(targetLevel); + + JsonObject requestBody = new JsonObject(); + requestBody.addProperty("anthropic_version", "bedrock-2023-05-31"); + requestBody.addProperty("max_tokens", MAX_TOKENS); + requestBody.addProperty("system", systemPrompt); + + // 메시지 배열 구성 + JsonArray messages = new JsonArray(); + + // 기존 히스토리 추가 + for (Message msg : history) { + JsonObject m = new JsonObject(); + m.addProperty("role", msg.role()); + m.addProperty("content", msg.content()); + messages.add(m); + } + + // 현재 사용자 입력 추가 + JsonObject userMsg = new JsonObject(); + userMsg.addProperty("role", "user"); + userMsg.addProperty("content", userText); + messages.add(userMsg); + + requestBody.add("messages", messages); + + // Bedrock 호출 + InvokeModelResponse response = AwsClients.bedrock().invokeModel( + InvokeModelRequest.builder() + .modelId(MODEL_ID) + .contentType("application/json") + .body(SdkBytes.fromUtf8String(requestBody.toString())) + .build() + ); + + // 응답 파싱 + JsonObject result = JsonParser.parseString( + response.body().asUtf8String() + ).getAsJsonObject(); + + return result.getAsJsonArray("content") + .get(0).getAsJsonObject() + .get("text").getAsString(); + } + + /** + * 레벨별 시스템 프롬프트 생성 + */ + private String buildSystemPrompt(String targetLevel) { + String levelGuidance = switch (targetLevel.toUpperCase()) { + case "BEGINNER" -> """ + - Use simple vocabulary and short sentences + - Speak slowly and clearly + - Use basic grammar structures + - Provide Korean translations for difficult words in parentheses + """; + case "ADVANCED" -> """ + - Use sophisticated vocabulary and complex sentences + - Include idiomatic expressions and phrasal verbs + - Discuss abstract concepts naturally + - Challenge the user with nuanced topics + """; + default -> """ + - Use moderate vocabulary appropriate for intermediate learners + - Mix simple and compound sentences + - Introduce useful expressions gradually + - Balance challenge with accessibility + """; + }; + + return String.format(""" + You are a friendly English conversation partner for Korean learners. + Your name is "Amy" and you're an American English teacher living in Seoul. + + ## Target Level: %s + + ## Level-Specific Guidelines: + %s + + ## General Guidelines: + - Keep responses conversational (2-4 sentences) + - Be warm, encouraging, and supportive + - If the user makes grammar mistakes, gently correct them naturally in your response + - Ask follow-up questions to keep the conversation going + - Respond in English only (except for occasional Korean translations for difficult words) + - Match the conversation topic to the user's interests + - Use natural filler words occasionally (well, you know, actually) + + ## Correction Style: + Instead of: "You said 'I go to store.' It should be 'I went to the store.'" + Do this: "Oh, so you went to the store? That's nice! What did you buy?" + + Remember: Your goal is to make the user feel comfortable practicing English! + """, targetLevel, levelGuidance); + } + + /** + * 히스토리 JSON 파싱 + */ + private List parseHistory(String historyJson) { + List history = new ArrayList<>(); + + if (historyJson == null || historyJson.isEmpty() || historyJson.equals("[]")) { + return history; + } + + try { + JsonArray array = JsonParser.parseString(historyJson).getAsJsonArray(); + for (JsonElement el : array) { + JsonObject obj = el.getAsJsonObject(); + history.add(new Message( + obj.get("role").getAsString(), + obj.get("content").getAsString() + )); + } + } catch (Exception e) { + logger.warn("Failed to parse history, starting fresh: {}", e.getMessage()); + } + + return history; + } + + /** + * 히스토리 JSON 변환 + */ + private String toJson(List history) { + JsonArray array = new JsonArray(); + for (Message msg : history) { + JsonObject obj = new JsonObject(); + obj.addProperty("role", msg.role()); + obj.addProperty("content", msg.content()); + array.add(obj); + } + return array.toString(); + } + + // ==================== Inner Classes ==================== + + private record Message(String role, String content) {} + + /** + * Speaking 응답 DTO + */ + public record SpeakingResponse( + String userTranscript, // 사용자가 말한 내용 (STT 결과) + String aiText, // AI 응답 텍스트 + String aiAudioUrl, // AI 응답 음성 URL (Polly) + double confidence // STT 신뢰도comp + ) {} +} \ No newline at end of file From 4ffd91926dad8b08d64cdb4006c927a2b374657f Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 11:16:44 +0900 Subject: [PATCH 423/528] fix: remove typo in SpeakingConnectionRepository --- .../SpeakingConnectionRepository.java | 2 +- docker/Dockerfile | 19 +++------- docker/build-and-push.sh | 38 ++++++++++++------- 3 files changed, 30 insertions(+), 29 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java index cbef094a..14b468d1 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java @@ -1,4 +1,4 @@ -현package com.mzc.secondproject.serverless.domain.speaking.repository; +package com.mzc.secondproject.serverless.domain.speaking.repository; import com.mzc.secondproject.serverless.common.config.AwsClients; import com.mzc.secondproject.serverless.common.config.EnvConfig; diff --git a/docker/Dockerfile b/docker/Dockerfile index 58d20580..9f69c063 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -25,27 +25,18 @@ RUN rpm --import https://yum.corretto.aws/corretto.key && \ ENV JAVA_HOME=/usr/lib/jvm/java-21-amazon-corretto ENV PATH=$JAVA_HOME/bin:$PATH -# Install AWS CLI v2 -RUN curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip" && \ - unzip awscliv2.zip && \ - ./aws/install && \ - rm -rf aws awscliv2.zip +# Install AWS CLI and SAM CLI via pip +RUN pip3 install --ignore-installed awscli aws-sam-cli -# Install SAM CLI (the main optimization!) -RUN pip3 install aws-sam-cli - -# Install Gradle (optional, project uses wrapper) +# Install Gradle ENV GRADLE_VERSION=8.5 RUN curl -L https://services.gradle.org/distributions/gradle-${GRADLE_VERSION}-bin.zip -o gradle.zip && \ unzip gradle.zip -d /opt && \ rm gradle.zip && \ ln -s /opt/gradle-${GRADLE_VERSION}/bin/gradle /usr/bin/gradle -# Verify installations -RUN java -version && \ - aws --version && \ - sam --version && \ - gradle --version +# Verify Java installation +RUN java -version # Set working directory WORKDIR /codebuild/output/src diff --git a/docker/build-and-push.sh b/docker/build-and-push.sh index bb4e1297..f59bb5c1 100755 --- a/docker/build-and-push.sh +++ b/docker/build-and-push.sh @@ -2,14 +2,19 @@ set -e # Configuration +AWS_PROFILE="mzc" AWS_REGION="ap-northeast-2" -AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) ECR_REPO_NAME="group2-codebuild-image" IMAGE_TAG="java21-sam" +export AWS_DEFAULT_REGION="${AWS_REGION}" +export AWS_PROFILE="${AWS_PROFILE}" + +AWS_ACCOUNT_ID=$(aws sts get-caller-identity --profile ${AWS_PROFILE} --region ${AWS_REGION} --query Account --output text) ECR_URI="${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/${ECR_REPO_NAME}" echo "=== CodeBuild Custom Image Build & Push ===" +echo "AWS Profile: ${AWS_PROFILE}" echo "AWS Account: ${AWS_ACCOUNT_ID}" echo "ECR Repository: ${ECR_REPO_NAME}" echo "Image: ${ECR_URI}:${IMAGE_TAG}" @@ -17,24 +22,29 @@ echo "" # 1. Create ECR repository (if not exists) echo "[1/4] Creating ECR repository..." -aws ecr describe-repositories --repository-names ${ECR_REPO_NAME} --region ${AWS_REGION} 2>/dev/null || \ - aws ecr create-repository --repository-name ${ECR_REPO_NAME} --region ${AWS_REGION} +aws ecr describe-repositories --repository-names ${ECR_REPO_NAME} --profile ${AWS_PROFILE} --region ${AWS_REGION} 2>/dev/null || \ + aws ecr create-repository --repository-name ${ECR_REPO_NAME} --profile ${AWS_PROFILE} --region ${AWS_REGION} # 2. Login to ECR echo "[2/4] Logging in to ECR..." -aws ecr get-login-password --region ${AWS_REGION} | docker login --username AWS --password-stdin ${ECR_URI} +aws ecr get-login-password --profile ${AWS_PROFILE} --region ${AWS_REGION} | docker login --username AWS --password-stdin ${ECR_URI} -# 3. Build Docker image -echo "[3/4] Building Docker image..." +# 3. Build and push Docker image (using buildx for cross-platform) +echo "[3/4] Building and pushing Docker image (linux/amd64)..." SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -docker build -t ${ECR_REPO_NAME}:${IMAGE_TAG} ${SCRIPT_DIR} - -# 4. Tag and push -echo "[4/4] Pushing to ECR..." -docker tag ${ECR_REPO_NAME}:${IMAGE_TAG} ${ECR_URI}:${IMAGE_TAG} -docker tag ${ECR_REPO_NAME}:${IMAGE_TAG} ${ECR_URI}:latest -docker push ${ECR_URI}:${IMAGE_TAG} -docker push ${ECR_URI}:latest + +# Create buildx builder if not exists +docker buildx create --name multiarch --use 2>/dev/null || docker buildx use multiarch + +# Build and push directly (avoids local platform issues on Apple Silicon) +docker buildx build \ + --platform linux/amd64 \ + --tag ${ECR_URI}:${IMAGE_TAG} \ + --tag ${ECR_URI}:latest \ + --push \ + ${SCRIPT_DIR} + +echo "[4/4] Push complete" echo "" echo "=== SUCCESS ===" From ccaa03411d6841bf934a29ad97b5fdebda5bc307 Mon Sep 17 00:00:00 2001 From: hye-inA Date: Thu, 22 Jan 2026 11:18:04 +0900 Subject: [PATCH 424/528] =?UTF-8?q?fix=20:=20=EC=98=A4=ED=83=80=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../speaking/repository/SpeakingConnectionRepository.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java index cbef094a..14b468d1 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java @@ -1,4 +1,4 @@ -현package com.mzc.secondproject.serverless.domain.speaking.repository; +package com.mzc.secondproject.serverless.domain.speaking.repository; import com.mzc.secondproject.serverless.common.config.AwsClients; import com.mzc.secondproject.serverless.common.config.EnvConfig; From c201a07902d8c28c5a8367a030d7a2a922589731 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 11:24:43 +0900 Subject: [PATCH 425/528] chore: trigger build test with custom Docker image From 4bacba237a42dabc22a0728f3e11c787dad3368b Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 11:53:59 +0900 Subject: [PATCH 426/528] chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes --- .sisyphus/plans/cicd-pipeline-plan.md | 145 +-- .../docs/CATCHMIND_FRONTEND_GUIDE.md | 79 +- .../serverless/common/dto/ErrorInfo.java | 16 +- .../common/exception/CommonErrorCode.java | 2 +- .../common/exception/CommonException.java | 4 +- .../common/exception/DomainErrorCode.java | 4 +- .../common/exception/ErrorCode.java | 8 +- .../common/exception/ServerlessException.java | 4 +- .../common/router/AuthenticatedHandler.java | 2 +- .../common/router/HandlerRouter.java | 8 +- .../serverless/common/router/Route.java | 2 +- .../common/util/WebSocketBroadcaster.java | 4 +- .../common/util/WebSocketMessageHelper.java | 36 +- .../common/validation/BeanValidator.java | 14 +- .../domain/badge/handler/BadgeHandler.java | 2 +- .../badge/repository/BadgeRepository.java | 4 +- .../domain/badge/service/BadgeService.java | 15 +- .../badge/strategy/AccuracyStrategy.java | 8 +- .../strategy/BadgeConditionStrategy.java | 6 +- .../BadgeConditionStrategyFactory.java | 8 +- .../badge/strategy/FirstStudyStrategy.java | 6 +- .../badge/strategy/GamesPlayedStrategy.java | 6 +- .../badge/strategy/GamesWonStrategy.java | 6 +- .../domain/badge/strategy/NoOpStrategy.java | 10 +- .../badge/strategy/PerfectDrawsStrategy.java | 6 +- .../badge/strategy/QuickGuessesStrategy.java | 6 +- .../domain/badge/strategy/StreakStrategy.java | 6 +- .../strategy/TestsCompletedStrategy.java | 6 +- .../badge/strategy/WordsLearnedStrategy.java | 8 +- .../domain/chatting/config/GameConfig.java | 6 +- .../dto/request/CreateRoomRequest.java | 8 +- .../chatting/dto/response/RoomListItem.java | 4 +- .../dto/response/ScoreboardResponse.java | 10 +- .../domain/chatting/enums/MessageType.java | 2 +- .../domain/chatting/enums/RoomStatus.java | 12 +- .../domain/chatting/enums/RoomType.java | 12 +- .../chatting/exception/ChattingErrorCode.java | 2 +- .../chatting/exception/ChattingException.java | 4 +- .../chatting/handler/ChatMessageHandler.java | 2 +- .../chatting/handler/ChatRoomHandler.java | 34 +- .../chatting/handler/ChatVoiceHandler.java | 2 +- .../handler/GameAutoCloseHandler.java | 30 +- .../domain/chatting/handler/GameHandler.java | 97 +- .../chatting/handler/GameSessionHandler.java | 100 +- .../websocket/WebSocketConnectHandler.java | 6 +- .../websocket/WebSocketDisconnectHandler.java | 26 +- .../websocket/WebSocketMessageHandler.java | 94 +- .../domain/chatting/model/ChatRoom.java | 6 +- .../domain/chatting/model/GameSession.java | 44 +- .../domain/chatting/model/GameSettings.java | 4 +- .../repository/ChatMessageRepository.java | 4 +- .../repository/ChatRoomRepository.java | 35 +- .../repository/ConnectionRepository.java | 28 +- .../repository/GameRoundRepository.java | 2 +- .../repository/GameSessionRepository.java | 100 +- .../repository/RoomTokenRepository.java | 4 +- .../chatting/service/ChatMessageService.java | 4 +- .../service/ChatRoomCommandService.java | 40 +- .../service/ChatRoomQueryService.java | 14 +- .../chatting/service/CommandService.java | 22 +- .../chatting/service/GameSchedulerClient.java | 48 +- .../domain/chatting/service/GameService.java | 264 ++--- .../chatting/service/GameStatsService.java | 10 +- .../chatting/service/RoomTokenService.java | 4 +- .../grammar/handler/GrammarHandler.java | 4 +- .../GrammarStreamingConnectHandler.java | 2 +- .../websocket/GrammarStreamingHandler.java | 10 +- .../GrammarConnectionRepository.java | 4 +- .../repository/GrammarSessionRepository.java | 4 +- .../dto/request/CreateSessionRequest.java | 9 +- .../opic/dto/request/SubmitAnswerRequest.java | 7 +- .../dto/response/AnswerFeedbackResponse.java | 15 +- .../dto/response/CreateSessionResponse.java | 11 +- .../opic/dto/response/QuestionResponse.java | 15 +- .../opic/handler/OPIcSessionHandler.java | 934 +++++++++--------- .../opic/repository/OPIcRepository.java | 488 ++++----- .../domain/opic/service/FeedbackService.java | 554 +++++------ .../websocket/SpeakingConnectHandler.java | 132 +-- .../websocket/SpeakingDisconnectHandler.java | 56 +- .../websocket/SpeakingMessageHandler.java | 374 +++---- .../speaking/model/SpeakingConnection.java | 132 +-- .../SpeakingConnectionRepository.java | 112 +-- .../speaking/service/SpeakingService.java | 513 +++++----- .../stats/handler/ScheduledStatsHandler.java | 33 +- .../stats/handler/StatsStreamHandler.java | 2 +- .../stats/handler/UserStatsHandler.java | 2 +- .../domain/stats/model/UserStats.java | 2 +- .../stats/repository/UserStatsRepository.java | 4 +- .../domain/stats/service/StatsService.java | 4 +- .../user/handler/PostConfirmationHandler.java | 2 +- .../exception/VocabularyErrorCode.java | 2 +- .../exception/VocabularyException.java | 4 +- .../vocabulary/handler/DailyStudyHandler.java | 2 +- .../vocabulary/handler/StatisticsHandler.java | 2 +- .../vocabulary/handler/StatsHandler.java | 2 +- .../vocabulary/handler/TestHandler.java | 2 +- .../vocabulary/handler/UserWordHandler.java | 2 +- .../vocabulary/handler/VoiceHandler.java | 2 +- .../vocabulary/handler/WordGroupHandler.java | 2 +- .../vocabulary/handler/WordHandler.java | 2 +- .../repository/DailyStudyRepository.java | 4 +- .../repository/TestResultRepository.java | 10 +- .../repository/UserWordRepository.java | 4 +- .../repository/WordGroupRepository.java | 4 +- .../vocabulary/repository/WordRepository.java | 2 +- .../service/DailyStudyCommandService.java | 4 +- .../service/DailyStudyQueryService.java | 4 +- .../vocabulary/service/StatisticsService.java | 4 +- .../vocabulary/service/StatsService.java | 28 +- .../service/TestCommandService.java | 4 +- .../vocabulary/service/TestQueryService.java | 4 +- .../service/UserWordCommandService.java | 4 +- .../service/UserWordQueryService.java | 4 +- .../service/WordCommandService.java | 4 +- .../service/WordGroupCommandService.java | 4 +- .../service/WordGroupQueryService.java | 4 +- .../vocabulary/service/WordQueryService.java | 4 +- .../exception/ChattingErrorCodeSpec.groovy | 50 +- .../domain/chatting/enums/RoomStatusTest.java | 7 +- .../domain/chatting/enums/RoomTypeTest.java | 7 +- .../chatting/model/GameSettingsTest.java | 11 +- docs/CATCHMIND_ARCHITECTURE_SOLUTION.md | 63 +- docs/CICD-IMPLEMENTATION-QNA.md | 95 +- docs/FRONTEND-API-GUIDE.md | 165 ++-- 124 files changed, 2767 insertions(+), 2689 deletions(-) diff --git a/.sisyphus/plans/cicd-pipeline-plan.md b/.sisyphus/plans/cicd-pipeline-plan.md index abdd5f3c..2ce6f8e5 100644 --- a/.sisyphus/plans/cicd-pipeline-plan.md +++ b/.sisyphus/plans/cicd-pipeline-plan.md @@ -8,13 +8,13 @@ ## 1. 요구사항 요약 -| 항목 | 선택 | -|------|------| -| 소스 저장소 | GitHub (유지) + CodePipeline v2 연결 | -| 배포 환경 | prod 단일 환경 | -| 트리거 | prod 브랜치 push 또는 PR merge | -| 승인 프로세스 | 완전 자동 (테스트 통과 시 자동 배포) | -| 알림 | AWS SNS → 이메일 | +| 항목 | 선택 | +|---------|----------------------------------| +| 소스 저장소 | GitHub (유지) + CodePipeline v2 연결 | +| 배포 환경 | prod 단일 환경 | +| 트리거 | prod 브랜치 push 또는 PR merge | +| 승인 프로세스 | 완전 자동 (테스트 통과 시 자동 배포) | +| 알림 | AWS SNS → 이메일 | --- @@ -55,30 +55,32 @@ ### 3.1 Source Stage -| 설정 | 값 | -|------|-----| -| Provider | GitHub (v2 Connection) | -| Repository | BE_Repository | -| Branch | `prod` | -| Trigger | Push / PR Merge | -| Output Artifact | SourceArtifact | +| 설정 | 값 | +|-----------------|------------------------| +| Provider | GitHub (v2 Connection) | +| Repository | BE_Repository | +| Branch | `prod` | +| Trigger | Push / PR Merge | +| Output Artifact | SourceArtifact | **GitHub Connection 설정 필요**: + - AWS Console → CodePipeline → Settings → Connections - GitHub App 설치 및 Repository 권한 부여 ### 3.2 Build Stage -| 설정 | 값 | -|------|-----| -| Provider | AWS CodeBuild | -| Environment | `aws/codebuild/amazonlinux2-x86_64-standard:5.0` | -| Compute | `BUILD_GENERAL1_MEDIUM` (7GB RAM, 4 vCPU) | -| Timeout | 30분 | -| Input Artifact | SourceArtifact | -| Output Artifact | BuildArtifact | +| 설정 | 값 | +|-----------------|--------------------------------------------------| +| Provider | AWS CodeBuild | +| Environment | `aws/codebuild/amazonlinux2-x86_64-standard:5.0` | +| Compute | `BUILD_GENERAL1_MEDIUM` (7GB RAM, 4 vCPU) | +| Timeout | 30분 | +| Input Artifact | SourceArtifact | +| Output Artifact | BuildArtifact | **빌드 단계**: + 1. Java 21 환경 설정 2. Gradle 빌드 및 테스트 3. SAM 빌드 @@ -86,22 +88,22 @@ ### 3.3 Deploy Stage -| 설정 | 값 | -|------|-----| -| Provider | CloudFormation | -| Action Mode | CREATE_UPDATE | -| Stack Name | `group2-englishstudy-prod` | -| Template | packaged-template.yaml | +| 설정 | 값 | +|--------------|----------------------------------------| +| Provider | CloudFormation | +| Action Mode | CREATE_UPDATE | +| Stack Name | `group2-englishstudy-prod` | +| Template | packaged-template.yaml | | Capabilities | CAPABILITY_IAM, CAPABILITY_AUTO_EXPAND | -| Role | CloudFormationExecutionRole | +| Role | CloudFormationExecutionRole | ### 3.4 Notification Stage -| 설정 | 값 | -|------|-----| -| Provider | AWS SNS | -| Topic | `cicd-pipeline-notifications` | -| Events | 성공, 실패, 시작 | +| 설정 | 값 | +|----------|-------------------------------| +| Provider | AWS SNS | +| Topic | `cicd-pipeline-notifications` | +| Events | 성공, 실패, 시작 | --- @@ -109,24 +111,24 @@ ### 4.1 신규 생성 필요 -| 리소스 | 이름 | 용도 | -|--------|------|------| -| CodePipeline | `group2-englishstudy-pipeline` | CI/CD 오케스트레이션 | -| CodeBuild Project | `group2-englishstudy-build` | 빌드 및 테스트 | -| S3 Bucket | `group2-englishstudy-pipeline-artifacts` | 파이프라인 아티팩트 저장 | -| GitHub Connection | `github-connection` | GitHub 연결 | -| SNS Topic | `cicd-pipeline-notifications` | 알림 | -| IAM Role | `CodePipelineServiceRole` | 파이프라인 실행 | -| IAM Role | `CodeBuildServiceRole` | 빌드 실행 | -| IAM Role | `CloudFormationExecutionRole` | 스택 배포 | +| 리소스 | 이름 | 용도 | +|-------------------|------------------------------------------|---------------| +| CodePipeline | `group2-englishstudy-pipeline` | CI/CD 오케스트레이션 | +| CodeBuild Project | `group2-englishstudy-build` | 빌드 및 테스트 | +| S3 Bucket | `group2-englishstudy-pipeline-artifacts` | 파이프라인 아티팩트 저장 | +| GitHub Connection | `github-connection` | GitHub 연결 | +| SNS Topic | `cicd-pipeline-notifications` | 알림 | +| IAM Role | `CodePipelineServiceRole` | 파이프라인 실행 | +| IAM Role | `CodeBuildServiceRole` | 빌드 실행 | +| IAM Role | `CloudFormationExecutionRole` | 스택 배포 | ### 4.2 기존 활용 -| 리소스 | 용도 | -|--------|------| +| 리소스 | 용도 | +|--------------------------|--------------| | S3 `group2-englishstudy` | Lambda 코드 저장 | -| DynamoDB Tables | 데이터 저장 | -| Cognito User Pool | 인증 | +| DynamoDB Tables | 데이터 저장 | +| Cognito User Pool | 인증 | --- @@ -164,9 +166,9 @@ phases: - sam build - echo "Packaging SAM application..." - sam package \ - --s3-bucket ${ARTIFACT_BUCKET} \ - --s3-prefix sam-packages \ - --output-template-file packaged-template.yaml + --s3-bucket ${ARTIFACT_BUCKET} \ + --s3-prefix sam-packages \ + --output-template-file packaged-template.yaml post_build: commands: @@ -394,11 +396,21 @@ aws sns subscribe \ ```json { - "source": ["aws.codepipeline"], - "detail-type": ["CodePipeline Pipeline Execution State Change"], + "source": [ + "aws.codepipeline" + ], + "detail-type": [ + "CodePipeline Pipeline Execution State Change" + ], "detail": { - "pipeline": ["group2-englishstudy-pipeline"], - "state": ["SUCCEEDED", "FAILED", "STARTED"] + "pipeline": [ + "group2-englishstudy-pipeline" + ], + "state": [ + "SUCCEEDED", + "FAILED", + "STARTED" + ] } } ``` @@ -407,14 +419,14 @@ aws sns subscribe \ ## 9. 비용 추정 (월간) -| 서비스 | 예상 사용량 | 예상 비용 | -|--------|------------|----------| -| CodePipeline | 1 파이프라인, ~100회 실행 | $1.00 | -| CodeBuild | ~100회 x 10분 = 1,000분 | $5.00 | -| S3 (아티팩트) | ~10GB | $0.25 | -| CloudWatch Logs | ~5GB | $2.50 | -| SNS | ~100 알림 | $0.01 | -| **총 예상 비용** | | **~$9/월** | +| 서비스 | 예상 사용량 | 예상 비용 | +|-----------------|----------------------|-----------| +| CodePipeline | 1 파이프라인, ~100회 실행 | $1.00 | +| CodeBuild | ~100회 x 10분 = 1,000분 | $5.00 | +| S3 (아티팩트) | ~10GB | $0.25 | +| CloudWatch Logs | ~5GB | $2.50 | +| SNS | ~100 알림 | $0.01 | +| **총 예상 비용** | | **~$9/월** | > 실제 비용은 배포 빈도와 빌드 시간에 따라 달라질 수 있습니다. @@ -718,24 +730,31 @@ Outputs: ## 12. 트러블슈팅 가이드 ### 빌드 실패: Java 버전 문제 + ``` Error: Unsupported class file major version 65 ``` + **해결**: CodeBuild 이미지에서 Java 21 (Corretto) 사용 확인 ### 배포 실패: IAM 권한 부족 + ``` User: arn:aws:sts::xxx is not authorized to perform: iam:CreateRole ``` + **해결**: CloudFormationExecutionRole에 IAM 권한 추가 ### SAM 빌드 실패: 메모리 부족 + ``` FATAL ERROR: CALL_AND_RETRY_LAST Allocation failed - JavaScript heap out of memory ``` + **해결**: CodeBuild compute type을 `BUILD_GENERAL1_LARGE`로 변경 ### GitHub Connection 인증 실패 + **해결**: AWS Console에서 Connection 상태 확인 → GitHub App 재인증 --- diff --git a/ServerlessFunction/docs/CATCHMIND_FRONTEND_GUIDE.md b/ServerlessFunction/docs/CATCHMIND_FRONTEND_GUIDE.md index cfc4b168..4dd03385 100644 --- a/ServerlessFunction/docs/CATCHMIND_FRONTEND_GUIDE.md +++ b/ServerlessFunction/docs/CATCHMIND_FRONTEND_GUIDE.md @@ -1,6 +1,7 @@ # Catchmind 게임 프론트엔드 연동 가이드 ## 목차 + 1. [개요](#개요) 2. [아키텍처](#아키텍처) 3. [WebSocket 연결](#websocket-연결) @@ -19,6 +20,7 @@ Catchmind는 실시간 그림 맞추기 게임입니다. WebSocket을 통한 실시간 통신과 REST API를 통한 게임 세션 관리를 지원합니다. ### 주요 특징 + - **실시간 통신**: WebSocket 기반 양방향 통신 - **도메인 분리**: `chat` / `game` 도메인으로 메시지 라우팅 - **타이머 동기화**: `serverTime` 필드를 통한 클라이언트-서버 시간 동기화 @@ -53,16 +55,19 @@ Catchmind는 실시간 그림 맞추기 게임입니다. WebSocket을 통한 실 ## WebSocket 연결 ### 연결 URL + ``` wss://{api-id}.execute-api.{region}.amazonaws.com/dev?roomToken={token} ``` ### 연결 절차 + 1. REST API로 방 토큰 발급 (`POST /chat/rooms/{roomId}/join`) 2. 토큰으로 WebSocket 연결 3. 연결 성공 시 자동으로 방에 입장 ### 연결 예시 (TypeScript) + ```typescript const connectWebSocket = (roomToken: string): WebSocket => { const ws = new WebSocket( @@ -101,12 +106,13 @@ interface BaseMessage { ### 도메인 구분 -| 도메인 | 설명 | 메시지 타입 | -|--------|------|-------------| -| `chat` | 채팅 메시지 | text, image, voice, ai_response | +| 도메인 | 설명 | 메시지 타입 | +|--------|--------|-------------------------------------------------------------------------------------------| +| `chat` | 채팅 메시지 | text, image, voice, ai_response | | `game` | 게임 메시지 | game_start, game_end, round_start, round_end, drawing, correct_answer, score_update, hint | ### 메시지 라우팅 예시 + ```typescript const handleMessage = (message: BaseMessage) => { if (message.domain === 'chat') { @@ -122,11 +128,13 @@ const handleMessage = (message: BaseMessage) => { ## 게임 흐름 ### 게임 상태 (GameStatus) + ```typescript type GameStatus = 'NONE' | 'WAITING' | 'PLAYING' | 'ROUND_END' | 'FINISHED'; ``` ### 전체 흐름 + ``` [대기] ─── /game 시작 ───► [게임 시작] ─► [라운드 1] ─► [라운드 종료] │ │ @@ -144,6 +152,7 @@ type GameStatus = 'NONE' | 'WAITING' | 'PLAYING' | 'ROUND_END' | 'FINISHED'; ### 1. 게임 시작 (game_start) **수신 메시지:** + ```json { "domain": "game", @@ -166,6 +175,7 @@ type GameStatus = 'NONE' | 'WAITING' | 'PLAYING' | 'ROUND_END' | 'FINISHED'; ``` **프론트엔드 처리:** + ```typescript const handleGameStart = (message: GameStartMessage) => { setGameStatus('PLAYING'); @@ -185,6 +195,7 @@ const handleGameStart = (message: GameStartMessage) => { ### 2. 그림 데이터 전송/수신 (drawing) **전송 (출제자만):** + ```typescript const sendDrawing = (drawingData: DrawingData) => { ws.send(JSON.stringify({ @@ -196,6 +207,7 @@ const sendDrawing = (drawingData: DrawingData) => { ``` **수신 메시지:** + ```json { "domain": "game", @@ -211,6 +223,7 @@ const sendDrawing = (drawingData: DrawingData) => { ### 3. 정답 체크 **채팅 메시지로 자동 체크됩니다:** + ```typescript const sendAnswer = (answer: string) => { ws.send(JSON.stringify({ @@ -224,6 +237,7 @@ const sendAnswer = (answer: string) => { ### 4. 정답 알림 (correct_answer) **수신 메시지:** + ```json { "domain": "game", @@ -246,6 +260,7 @@ const sendAnswer = (answer: string) => { ### 5. 점수 업데이트 (score_update) **수신 메시지:** + ```json { "domain": "game", @@ -265,6 +280,7 @@ const sendAnswer = (answer: string) => { ### 6. 라운드 종료 (round_end) **수신 메시지:** + ```json { "domain": "game", @@ -295,6 +311,7 @@ const sendAnswer = (answer: string) => { ``` **프론트엔드 처리:** + ```typescript const handleRoundEnd = (message: RoundEndMessage) => { const { data } = message; @@ -328,6 +345,7 @@ const handleRoundEnd = (message: RoundEndMessage) => { ### 7. 게임 종료 (game_end) **수신 메시지:** + ```json { "domain": "game", @@ -352,12 +370,14 @@ const handleRoundEnd = (message: RoundEndMessage) => { ## REST API ### 게임 시작 + ```http POST /chat/rooms/{roomId}/game/start Authorization: Bearer {accessToken} ``` **Response:** + ```json { "success": true, @@ -380,27 +400,32 @@ Authorization: Bearer {accessToken} } } ``` + > **Note:** `currentWord`는 출제자에게만 포함됩니다. ### 게임 종료 + ```http POST /chat/rooms/{roomId}/game/stop Authorization: Bearer {accessToken} ``` ### 게임 상태 조회 + ```http GET /chat/rooms/{roomId}/game/status Authorization: Bearer {accessToken} ``` ### 게임 세션 조회 (재접속용) + ```http GET /games/{gameSessionId} Authorization: Bearer {accessToken} ``` **Response:** + ```json { "success": true, @@ -431,6 +456,7 @@ Authorization: Bearer {accessToken} } } ``` + > **Note:** `currentWord`는 출제자에게만 포함됩니다. --- @@ -438,12 +464,15 @@ Authorization: Bearer {accessToken} ## 타이머 동기화 ### 문제 + 클라이언트와 서버 시간 차이로 인한 타이머 불일치 ### 해결책 + `serverTime` 필드를 사용하여 서버 시간 기준 타이머 계산 ### 구현 예시 + ```typescript interface TimerSync { roundStartTime: number; // 라운드 시작 시간 (서버 기준) @@ -483,6 +512,7 @@ const startTimer = ( ``` ### React Hook 예시 + ```typescript const useGameTimer = (timerSync: TimerSync | null) => { const [remainingSeconds, setRemainingSeconds] = useState(0); @@ -512,9 +542,11 @@ const useGameTimer = (timerSync: TimerSync | null) => { ## 게임 자동 종료 ### 개요 + 게임 시작 후 7분(420초)이 경과하면 자동으로 종료됩니다. ### 자동 종료 메시지 + ```json { "domain": "game", @@ -528,6 +560,7 @@ const useGameTimer = (timerSync: TimerSync | null) => { ``` ### 프론트엔드 처리 + ```typescript const handleGameEnd = (message: GameEndMessage) => { setGameStatus('FINISHED'); @@ -552,15 +585,18 @@ const handleGameEnd = (message: GameEndMessage) => { ## 재접속 처리 ### 시나리오 + 사용자가 게임 중 연결이 끊어졌다가 다시 접속하는 경우 ### 처리 절차 + 1. WebSocket 재연결 2. 게임 세션 API로 현재 상태 조회 3. UI 상태 복원 4. 타이머 동기화 ### 구현 예시 + ```typescript const handleReconnect = async (roomId: string, gameSessionId: string) => { // 1. WebSocket 재연결 @@ -600,25 +636,28 @@ const handleReconnect = async (roomId: string, gameSessionId: string) => { ## 에러 처리 ### WebSocket 에러 코드 -| 코드 | 설명 | 처리 방법 | -|------|------|-----------| -| 1000 | 정상 종료 | - | -| 1001 | 서버 종료 | 재연결 시도 | -| 1006 | 비정상 종료 | 재연결 시도 | -| 4001 | 인증 실패 | 토큰 재발급 후 재연결 | -| 4003 | 권한 없음 | 에러 표시 | + +| 코드 | 설명 | 처리 방법 | +|------|--------|--------------| +| 1000 | 정상 종료 | - | +| 1001 | 서버 종료 | 재연결 시도 | +| 1006 | 비정상 종료 | 재연결 시도 | +| 4001 | 인증 실패 | 토큰 재발급 후 재연결 | +| 4003 | 권한 없음 | 에러 표시 | ### REST API 에러 코드 -| 코드 | 설명 | -|------|------| -| `GAME_001` | 게임 시작 실패 | -| `GAME_002` | 게임 중단 실패 | -| `GAME_003` | 진행 중인 게임 없음 | -| `GAME_004` | 이미 게임 진행 중 | + +| 코드 | 설명 | +|------------|-----------------------| +| `GAME_001` | 게임 시작 실패 | +| `GAME_002` | 게임 중단 실패 | +| `GAME_003` | 진행 중인 게임 없음 | +| `GAME_004` | 이미 게임 진행 중 | | `GAME_005` | 권한 없음 (게임 시작자만 중단 가능) | -| `GAME_006` | 게임 세션을 찾을 수 없음 | +| `GAME_006` | 게임 세션을 찾을 수 없음 | ### 에러 처리 예시 + ```typescript const handleError = (error: ApiError) => { switch (error.code) { @@ -716,7 +755,7 @@ const gameReducer = (state: GameState, action: GameAction): GameState => { ## 버전 이력 -| 버전 | 날짜 | 변경 내용 | -|------|------|-----------| -| 1.0.0 | 2024-01-20 | 초기 문서 작성 | +| 버전 | 날짜 | 변경 내용 | +|-------|------------|---------------------| +| 1.0.0 | 2024-01-20 | 초기 문서 작성 | | 1.1.0 | 2024-01-20 | 게임 자동 종료 (7분) 기능 추가 | diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/ErrorInfo.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/ErrorInfo.java index 329fc230..41feeb2b 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/ErrorInfo.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/dto/ErrorInfo.java @@ -8,17 +8,17 @@ /** * RFC 7807 스타일 에러 정보 - * + *

* Problem Details for HTTP APIs (RFC 7807) 표준을 참고한 에러 응답 형식입니다. - * + *

* 응답 예시: * { - * "code": "VOCABULARY.WORD_001", - * "message": "단어를 찾을 수 없습니다", - * "status": 404, - * "details": { - * "wordId": "abc-123" - * } + * "code": "VOCABULARY.WORD_001", + * "message": "단어를 찾을 수 없습니다", + * "status": 404, + * "details": { + * "wordId": "abc-123" + * } * } * * @param code 에러 코드 (예: AUTH_001, VOCABULARY.WORD_001) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/CommonErrorCode.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/CommonErrorCode.java index d1276375..126790d9 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/CommonErrorCode.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/CommonErrorCode.java @@ -2,7 +2,7 @@ /** * 공통/시스템 에러 코드 - * + *

* 도메인에 종속되지 않는 공통 에러 코드를 정의합니다. * - 인증/인가 에러 (AUTH_XXX) * - 검증 에러 (VALIDATION_XXX) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/CommonException.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/CommonException.java index 497ff8eb..a6f67ed0 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/CommonException.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/CommonException.java @@ -2,10 +2,10 @@ /** * 공통/시스템 예외 클래스 - * + *

* 도메인에 종속되지 않는 공통 예외를 처리합니다. * 정적 팩토리 메서드를 통해 가독성 높은 예외 생성을 지원합니다. - * + *

* 사용 예시: * throw CommonException.unauthorized(); * throw CommonException.notFound("사용자"); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/DomainErrorCode.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/DomainErrorCode.java index 43a454e0..83ddb2ef 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/DomainErrorCode.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/DomainErrorCode.java @@ -2,10 +2,10 @@ /** * 도메인별 에러 코드 인터페이스 - * + *

* 각 도메인(Vocabulary, Chatting 등)의 비즈니스 로직 관련 에러 코드가 구현하는 인터페이스입니다. * ErrorCode를 확장하여 도메인 식별 기능을 추가합니다. - * + *

* 구현체: * - VocabularyErrorCode - 단어 학습 도메인 * - ChattingErrorCode - 채팅 도메인 diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/ErrorCode.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/ErrorCode.java index 5eabbd0a..35261fa0 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/ErrorCode.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/ErrorCode.java @@ -2,16 +2,16 @@ /** * 에러 코드 표준 인터페이스 (Sealed Interface) - * + *

* 모든 에러 코드 enum이 구현해야 하는 표준 계약을 정의합니다. * Sealed interface를 사용하여 허용된 구현체만 존재하도록 제한합니다. - * + *

* 계층 구조: * ErrorCode (sealed) * ├── CommonErrorCode (시스템/공통 에러) * └── DomainErrorCode (non-sealed) - 도메인별 에러 - * ├── VocabularyErrorCode - * └── ChattingErrorCode + * ├── VocabularyErrorCode + * └── ChattingErrorCode */ public sealed interface ErrorCode permits CommonErrorCode, DomainErrorCode { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/ServerlessException.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/ServerlessException.java index c9f578c0..f95ea8ab 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/ServerlessException.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/exception/ServerlessException.java @@ -6,10 +6,10 @@ /** * 서버리스 애플리케이션 기본 예외 클래스 - * + *

* 모든 비즈니스 예외의 추상 기반 클래스입니다. * ErrorCode를 통해 표준화된 에러 정보를 제공합니다. - * + *

* 사용 예시: * - CommonException: 공통/시스템 예외 * - VocabularyException: 단어 학습 도메인 예외 diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/AuthenticatedHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/AuthenticatedHandler.java index 51071fd5..b49dd275 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/AuthenticatedHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/AuthenticatedHandler.java @@ -5,7 +5,7 @@ /** * Cognito 인증이 필요한 요청 핸들러 - * + *

* userId가 자동으로 추출되어 전달됩니다. */ @FunctionalInterface diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/HandlerRouter.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/HandlerRouter.java index a7178643..1b99130e 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/HandlerRouter.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/HandlerRouter.java @@ -18,13 +18,13 @@ /** * Lambda Handler를 위한 HTTP 라우터 - * + *

* 선언적 라우팅 + 자동 Path/Query 파라미터 검증 제공 - * + *

* 사용 예시: * new HandlerRouter().addRoutes( - * Route.get("/rooms/{roomId}", this::getRoom), - * Route.delete("/rooms/{roomId}", this::deleteRoom).requireQueryParams("userId") + * Route.get("/rooms/{roomId}", this::getRoom), + * Route.delete("/rooms/{roomId}", this::deleteRoom).requireQueryParams("userId") * ); */ public class HandlerRouter { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/Route.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/Route.java index a9d9f746..44a46f6f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/Route.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/Route.java @@ -13,7 +13,7 @@ /** * HTTP 라우트 정의 - * + *

* Path 패턴에서 자동으로 필수 파라미터를 추출합니다. * 예: "/rooms/{roomId}/messages/{messageId}" → ["roomId", "messageId"] * diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketBroadcaster.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketBroadcaster.java index 516687a0..653e3d1f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketBroadcaster.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketBroadcaster.java @@ -79,10 +79,10 @@ public List broadcast(List connections, String message) { logger.info("Broadcast completed: total={}, failed={}", connections.size(), failedConnections.size()); - + return failedConnections; } - + @Override public void close() { try { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketMessageHelper.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketMessageHelper.java index 7cbf5602..c8fb1a0d 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketMessageHelper.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketMessageHelper.java @@ -10,14 +10,14 @@ * 모든 메시지에 domain 필드를 포함하여 채팅/게임 구분 지원 */ public final class WebSocketMessageHelper { - + public static final String DOMAIN_CHAT = "chat"; public static final String DOMAIN_GAME = "game"; public static final String DOMAIN_ROOM = "room"; - + private WebSocketMessageHelper() { } - + /** * 기본 메시지 생성 * @@ -34,21 +34,21 @@ public static Map createMessage(String domain, String messageTyp message.put("timestamp", System.currentTimeMillis()); return message; } - + /** * 채팅 메시지 생성 */ public static Map createChatMessage(String messageType, Object data) { return createMessage(DOMAIN_CHAT, messageType, data); } - + /** * 게임 메시지 생성 */ public static Map createGameMessage(String messageType, Object data) { return createMessage(DOMAIN_GAME, messageType, data); } - + /** * 채팅 메시지 빌더 (상세 필드 포함) */ @@ -60,7 +60,7 @@ public static Map buildChatMessage( ) { String messageId = UUID.randomUUID().toString(); String now = Instant.now().toString(); - + Map message = new HashMap<>(); message.put("domain", DOMAIN_CHAT); message.put("messageType", messageType); @@ -72,7 +72,7 @@ public static Map buildChatMessage( message.put("timestamp", System.currentTimeMillis()); return message; } - + /** * 게임 메시지 빌더 (상세 필드 포함) */ @@ -84,7 +84,7 @@ public static Map buildGameMessage( String messageId = UUID.randomUUID().toString(); String now = Instant.now().toString(); long serverTime = System.currentTimeMillis(); - + Map message = new HashMap<>(); message.put("domain", DOMAIN_GAME); message.put("messageType", messageType); @@ -94,20 +94,20 @@ public static Map buildGameMessage( message.put("createdAt", now); message.put("timestamp", serverTime); message.put("serverTime", serverTime); - + if (gameData != null) { message.put("data", gameData); } return message; } - + /** * 시스템 메시지 생성 (채팅 도메인) */ public static Map buildSystemMessage(String roomId, String content, String messageType) { return buildChatMessage(roomId, "SYSTEM", content, messageType); } - + /** * 방 상태 변경 메시지 생성 * @@ -123,7 +123,7 @@ public static Map buildRoomStatusChangeMessage( ) { String messageId = UUID.randomUUID().toString(); String now = Instant.now().toString(); - + Map message = new HashMap<>(); message.put("domain", DOMAIN_ROOM); message.put("messageType", "room_status_change"); @@ -135,13 +135,13 @@ public static Map buildRoomStatusChangeMessage( message.put("timestamp", System.currentTimeMillis()); return message; } - + /** * 방장 변경 메시지 생성 * - * @param roomId 방 ID - * @param newHostId 새 방장 ID - * @param newHostNickname 새 방장 닉네임 + * @param roomId 방 ID + * @param newHostId 새 방장 ID + * @param newHostNickname 새 방장 닉네임 * @return 방장 변경 메시지 */ public static Map buildHostChangeMessage( @@ -151,7 +151,7 @@ public static Map buildHostChangeMessage( ) { String messageId = UUID.randomUUID().toString(); String now = Instant.now().toString(); - + Map message = new HashMap<>(); message.put("domain", DOMAIN_ROOM); message.put("messageType", "host_change"); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/validation/BeanValidator.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/validation/BeanValidator.java index 9f98d4a1..685ca8b9 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/validation/BeanValidator.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/validation/BeanValidator.java @@ -15,17 +15,17 @@ /** * Jakarta Bean Validation 기반 검증 유틸리티 - * + *

* DTO에 선언된 @NotNull, @NotEmpty 등의 어노테이션을 검증합니다. - * + *

* 사용 예시: * CreateRoomRequest req = ResponseGenerator.gson().fromJson(body, CreateRoomRequest.class); * return BeanValidator.validate(req) - * .map(error -> ResponseGenerator.fail(CommonErrorCode.REQUIRED_FIELD_MISSING, error)) - * .orElseGet(() -> { - * // 비즈니스 로직 - * return ResponseGenerator.ok("Success", result); - * }); + * .map(error -> ResponseGenerator.fail(CommonErrorCode.REQUIRED_FIELD_MISSING, error)) + * .orElseGet(() -> { + * // 비즈니스 로직 + * return ResponseGenerator.ok("Success", result); + * }); */ public final class BeanValidator { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/handler/BadgeHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/handler/BadgeHandler.java index 22159d80..ec4acb70 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/handler/BadgeHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/handler/BadgeHandler.java @@ -29,7 +29,7 @@ public class BadgeHandler implements RequestHandler table; - + /** * 기본 생성자 (Lambda에서 사용) */ public BadgeRepository() { this(AwsClients.dynamoDbEnhanced()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/service/BadgeService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/service/BadgeService.java index 62e887b3..0916a5d5 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/service/BadgeService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/service/BadgeService.java @@ -5,14 +5,13 @@ import com.mzc.secondproject.serverless.domain.badge.enums.BadgeType; import com.mzc.secondproject.serverless.domain.badge.model.UserBadge; import com.mzc.secondproject.serverless.domain.badge.repository.BadgeRepository; +import com.mzc.secondproject.serverless.domain.badge.strategy.BadgeConditionStrategy; +import com.mzc.secondproject.serverless.domain.badge.strategy.BadgeConditionStrategyFactory; import com.mzc.secondproject.serverless.domain.stats.model.UserStats; import com.mzc.secondproject.serverless.domain.stats.repository.UserStatsRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.mzc.secondproject.serverless.domain.badge.strategy.BadgeConditionStrategy; -import com.mzc.secondproject.serverless.domain.badge.strategy.BadgeConditionStrategyFactory; - import java.time.Instant; import java.util.ArrayList; import java.util.List; @@ -25,14 +24,14 @@ public class BadgeService { private final BadgeRepository badgeRepository; private final UserStatsRepository userStatsRepository; - + /** * 기본 생성자 (Lambda에서 사용) */ public BadgeService() { this(new BadgeRepository(), new UserStatsRepository()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ @@ -145,14 +144,14 @@ private UserBadge createBadge(String userId, BadgeType type, String now) { private boolean checkBadgeCondition(BadgeType type, UserStats stats) { if (stats == null) return false; - + BadgeConditionStrategy strategy = BadgeConditionStrategyFactory.getStrategy(type.getCategory()); return strategy.checkCondition(type, stats); } - + private int calculateProgress(BadgeType type, UserStats stats) { if (stats == null) return 0; - + BadgeConditionStrategy strategy = BadgeConditionStrategyFactory.getStrategy(type.getCategory()); return strategy.calculateProgress(type, stats); } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/AccuracyStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/AccuracyStrategy.java index d6e5c58f..398875d0 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/AccuracyStrategy.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/AccuracyStrategy.java @@ -7,23 +7,23 @@ * 정확도 뱃지 조건 전략 */ public class AccuracyStrategy implements BadgeConditionStrategy { - + @Override public boolean checkCondition(BadgeType type, UserStats stats) { double accuracy = calculateAccuracy(stats); return accuracy >= type.getThreshold(); } - + @Override public int calculateProgress(BadgeType type, UserStats stats) { return (int) calculateAccuracy(stats); } - + @Override public String getCategory() { return "ACCURACY"; } - + private double calculateAccuracy(UserStats stats) { if (stats.getQuestionsAnswered() == null || stats.getQuestionsAnswered() == 0) { return 0.0; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/BadgeConditionStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/BadgeConditionStrategy.java index 10243bcd..03cf274f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/BadgeConditionStrategy.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/BadgeConditionStrategy.java @@ -7,17 +7,17 @@ * 뱃지 조건 확인 전략 인터페이스 */ public interface BadgeConditionStrategy { - + /** * 뱃지 획득 조건 확인 */ boolean checkCondition(BadgeType type, UserStats stats); - + /** * 현재 진행도 계산 */ int calculateProgress(BadgeType type, UserStats stats); - + /** * 지원하는 카테고리 */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/BadgeConditionStrategyFactory.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/BadgeConditionStrategyFactory.java index c855ff19..01f6ed33 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/BadgeConditionStrategyFactory.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/BadgeConditionStrategyFactory.java @@ -8,10 +8,10 @@ * 카테고리별 전략 인스턴스를 관리하고 제공 */ public class BadgeConditionStrategyFactory { - + private static final Map STRATEGIES = new HashMap<>(); private static final BadgeConditionStrategy DEFAULT_STRATEGY = new NoOpStrategy("DEFAULT"); - + static { register(new FirstStudyStrategy()); register(new StreakStrategy()); @@ -26,11 +26,11 @@ public class BadgeConditionStrategyFactory { register(new NoOpStrategy("PERFECT_TEST")); register(new NoOpStrategy("ALL_BADGES")); } - + private static void register(BadgeConditionStrategy strategy) { STRATEGIES.put(strategy.getCategory(), strategy); } - + /** * 카테고리에 해당하는 전략 반환 */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/FirstStudyStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/FirstStudyStrategy.java index ab23769f..44d5e33a 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/FirstStudyStrategy.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/FirstStudyStrategy.java @@ -7,17 +7,17 @@ * 첫 학습 뱃지 조건 전략 */ public class FirstStudyStrategy implements BadgeConditionStrategy { - + @Override public boolean checkCondition(BadgeType type, UserStats stats) { return stats.getTestsCompleted() != null && stats.getTestsCompleted() >= 1; } - + @Override public int calculateProgress(BadgeType type, UserStats stats) { return (stats.getTestsCompleted() != null && stats.getTestsCompleted() >= 1) ? 1 : 0; } - + @Override public String getCategory() { return "FIRST_STUDY"; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/GamesPlayedStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/GamesPlayedStrategy.java index c8e939f6..ade34c7a 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/GamesPlayedStrategy.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/GamesPlayedStrategy.java @@ -7,17 +7,17 @@ * 게임 플레이 횟수 뱃지 조건 전략 */ public class GamesPlayedStrategy implements BadgeConditionStrategy { - + @Override public boolean checkCondition(BadgeType type, UserStats stats) { return stats.getGamesPlayed() != null && stats.getGamesPlayed() >= type.getThreshold(); } - + @Override public int calculateProgress(BadgeType type, UserStats stats) { return stats.getGamesPlayed() != null ? stats.getGamesPlayed() : 0; } - + @Override public String getCategory() { return "GAMES_PLAYED"; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/GamesWonStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/GamesWonStrategy.java index 884ed90a..d0c810a8 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/GamesWonStrategy.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/GamesWonStrategy.java @@ -7,17 +7,17 @@ * 게임 승리 횟수 뱃지 조건 전략 */ public class GamesWonStrategy implements BadgeConditionStrategy { - + @Override public boolean checkCondition(BadgeType type, UserStats stats) { return stats.getGamesWon() != null && stats.getGamesWon() >= type.getThreshold(); } - + @Override public int calculateProgress(BadgeType type, UserStats stats) { return stats.getGamesWon() != null ? stats.getGamesWon() : 0; } - + @Override public String getCategory() { return "GAMES_WON"; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NoOpStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NoOpStrategy.java index 1f5f4c8b..0c35d127 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NoOpStrategy.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NoOpStrategy.java @@ -8,23 +8,23 @@ * PERFECT_TEST, ALL_BADGES 등은 별도 로직에서 처리 */ public class NoOpStrategy implements BadgeConditionStrategy { - + private final String category; - + public NoOpStrategy(String category) { this.category = category; } - + @Override public boolean checkCondition(BadgeType type, UserStats stats) { return false; } - + @Override public int calculateProgress(BadgeType type, UserStats stats) { return 0; } - + @Override public String getCategory() { return category; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/PerfectDrawsStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/PerfectDrawsStrategy.java index ac588a4d..abe06a48 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/PerfectDrawsStrategy.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/PerfectDrawsStrategy.java @@ -7,17 +7,17 @@ * 완벽한 출제 뱃지 조건 전략 */ public class PerfectDrawsStrategy implements BadgeConditionStrategy { - + @Override public boolean checkCondition(BadgeType type, UserStats stats) { return stats.getPerfectDraws() != null && stats.getPerfectDraws() >= type.getThreshold(); } - + @Override public int calculateProgress(BadgeType type, UserStats stats) { return stats.getPerfectDraws() != null ? stats.getPerfectDraws() : 0; } - + @Override public String getCategory() { return "PERFECT_DRAWS"; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/QuickGuessesStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/QuickGuessesStrategy.java index 610dc048..d276bec4 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/QuickGuessesStrategy.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/QuickGuessesStrategy.java @@ -7,17 +7,17 @@ * 빠른 정답 뱃지 조건 전략 */ public class QuickGuessesStrategy implements BadgeConditionStrategy { - + @Override public boolean checkCondition(BadgeType type, UserStats stats) { return stats.getQuickGuesses() != null && stats.getQuickGuesses() >= type.getThreshold(); } - + @Override public int calculateProgress(BadgeType type, UserStats stats) { return stats.getQuickGuesses() != null ? stats.getQuickGuesses() : 0; } - + @Override public String getCategory() { return "QUICK_GUESSES"; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/StreakStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/StreakStrategy.java index 18f826cb..5db1fac2 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/StreakStrategy.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/StreakStrategy.java @@ -7,17 +7,17 @@ * 연속 학습 뱃지 조건 전략 */ public class StreakStrategy implements BadgeConditionStrategy { - + @Override public boolean checkCondition(BadgeType type, UserStats stats) { return stats.getCurrentStreak() != null && stats.getCurrentStreak() >= type.getThreshold(); } - + @Override public int calculateProgress(BadgeType type, UserStats stats) { return stats.getCurrentStreak() != null ? stats.getCurrentStreak() : 0; } - + @Override public String getCategory() { return "STREAK"; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/TestsCompletedStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/TestsCompletedStrategy.java index ec791adc..0be7d097 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/TestsCompletedStrategy.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/TestsCompletedStrategy.java @@ -7,17 +7,17 @@ * 테스트 완료 횟수 뱃지 조건 전략 */ public class TestsCompletedStrategy implements BadgeConditionStrategy { - + @Override public boolean checkCondition(BadgeType type, UserStats stats) { return stats.getTestsCompleted() != null && stats.getTestsCompleted() >= type.getThreshold(); } - + @Override public int calculateProgress(BadgeType type, UserStats stats) { return stats.getTestsCompleted() != null ? stats.getTestsCompleted() : 0; } - + @Override public String getCategory() { return "TESTS_COMPLETED"; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/WordsLearnedStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/WordsLearnedStrategy.java index 5f7cbe02..cff5c410 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/WordsLearnedStrategy.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/WordsLearnedStrategy.java @@ -7,23 +7,23 @@ * 단어 학습량 뱃지 조건 전략 */ public class WordsLearnedStrategy implements BadgeConditionStrategy { - + @Override public boolean checkCondition(BadgeType type, UserStats stats) { int total = getTotalWordsLearned(stats); return total >= type.getThreshold(); } - + @Override public int calculateProgress(BadgeType type, UserStats stats) { return getTotalWordsLearned(stats); } - + @Override public String getCategory() { return "WORDS_LEARNED"; } - + private int getTotalWordsLearned(UserStats stats) { int newWords = stats.getNewWordsLearned() != null ? stats.getNewWordsLearned() : 0; int reviewedWords = stats.getWordsReviewed() != null ? stats.getWordsReviewed() : 0; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/config/GameConfig.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/config/GameConfig.java index 7cd3a921..1e3ee370 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/config/GameConfig.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/config/GameConfig.java @@ -12,12 +12,12 @@ public final class GameConfig { private static final int DEFAULT_ROUND_TIME_LIMIT = 60; private static final long DEFAULT_QUICK_GUESS_THRESHOLD_MS = 5000L; private static final int DEFAULT_GAME_TIME_LIMIT = 420; // 7분 (420초) - + private static final int TOTAL_ROUNDS = EnvConfig.getIntOrDefault("GAME_TOTAL_ROUNDS", DEFAULT_TOTAL_ROUNDS); private static final int ROUND_TIME_LIMIT = EnvConfig.getIntOrDefault("GAME_ROUND_TIME_LIMIT", DEFAULT_ROUND_TIME_LIMIT); private static final long QUICK_GUESS_THRESHOLD_MS = EnvConfig.getLongOrDefault("GAME_QUICK_GUESS_THRESHOLD_MS", DEFAULT_QUICK_GUESS_THRESHOLD_MS); private static final int GAME_TIME_LIMIT = EnvConfig.getIntOrDefault("GAME_TIME_LIMIT_SECONDS", DEFAULT_GAME_TIME_LIMIT); - + private GameConfig() { } @@ -32,7 +32,7 @@ public static int roundTimeLimit() { public static long quickGuessThresholdMs() { return QUICK_GUESS_THRESHOLD_MS; } - + /** * 게임 전체 시간 제한 (초) * 기본값: 420초 (7분) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/CreateRoomRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/CreateRoomRequest.java index 58316901..e5a888ba 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/CreateRoomRequest.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/CreateRoomRequest.java @@ -33,13 +33,13 @@ public class CreateRoomRequest { @Builder.Default private Boolean isPrivate = false; - + private String password; - + @Builder.Default private String type = "CHAT"; // CHAT or GAME - + private String gameType; // CATCHMIND (nullable) - + private GameSettings gameSettings; // 게임 설정 (nullable) } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/RoomListItem.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/RoomListItem.java index 63c8c9e2..d3b6f16a 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/RoomListItem.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/RoomListItem.java @@ -35,7 +35,7 @@ public class RoomListItem { private String hostId; private String hostNickname; private List participants; - + /** * ChatRoom과 hostNickname으로 RoomListItem 생성 */ @@ -59,7 +59,7 @@ public static RoomListItem from(ChatRoom room, String hostNickname) { .hostNickname(hostNickname) .build(); } - + /** * ChatRoom, hostNickname, participants로 RoomListItem 생성 */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/ScoreboardResponse.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/ScoreboardResponse.java index eb27a347..72e46508 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/ScoreboardResponse.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/ScoreboardResponse.java @@ -18,7 +18,7 @@ public record ScoreboardResponse( public static ScoreboardResponse from(GameSession session) { Map scores = session.getScores(); List ranking = buildRanking(scores); - + return new ScoreboardResponse( scores, ranking, @@ -27,21 +27,21 @@ public static ScoreboardResponse from(GameSession session) { session.getTotalRounds() ); } - + private static List buildRanking(Map scores) { if (scores == null || scores.isEmpty()) { return List.of(); } - + List> sorted = scores.entrySet().stream() .sorted(Map.Entry.comparingByValue().reversed()) .toList(); - + return java.util.stream.IntStream.range(0, sorted.size()) .mapToObj(i -> new RankEntry(i + 1, sorted.get(i).getKey(), sorted.get(i).getValue())) .toList(); } - + public record RankEntry( int rank, String userId, diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/MessageType.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/MessageType.java index a7433637..b8a7d453 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/MessageType.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/MessageType.java @@ -19,7 +19,7 @@ public enum MessageType { SCORE_UPDATE("score_update", "점수 업데이트"), SYSTEM_COMMAND("system_command", "시스템 명령"), HINT("hint", "힌트"), - + // 방 관련 메시지 타입 ROOM_STATUS_CHANGE("room_status_change", "방 상태 변경"), HOST_CHANGE("host_change", "방장 변경"); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomStatus.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomStatus.java index 6cfbd65b..b71acda0 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomStatus.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomStatus.java @@ -6,21 +6,21 @@ public enum RoomStatus { WAITING("waiting", "대기 중"), PLAYING("playing", "게임 중"), FINISHED("finished", "종료됨"); - + private final String code; private final String displayName; - + RoomStatus(String code, String displayName) { this.code = code; this.displayName = displayName; } - + public static boolean isValid(String value) { if (value == null) return false; return Arrays.stream(values()) .anyMatch(status -> status.name().equalsIgnoreCase(value) || status.code.equalsIgnoreCase(value)); } - + public static RoomStatus fromString(String value) { if (value == null) return WAITING; return Arrays.stream(values()) @@ -28,11 +28,11 @@ public static RoomStatus fromString(String value) { .findFirst() .orElse(WAITING); } - + public String getCode() { return code; } - + public String getDisplayName() { return displayName; } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomType.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomType.java index 8848b449..4af73fd2 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomType.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomType.java @@ -5,21 +5,21 @@ public enum RoomType { CHAT("chat", "채팅방"), GAME("game", "게임방"); - + private final String code; private final String displayName; - + RoomType(String code, String displayName) { this.code = code; this.displayName = displayName; } - + public static boolean isValid(String value) { if (value == null) return false; return Arrays.stream(values()) .anyMatch(type -> type.name().equalsIgnoreCase(value) || type.code.equalsIgnoreCase(value)); } - + public static RoomType fromString(String value) { if (value == null) return CHAT; return Arrays.stream(values()) @@ -27,11 +27,11 @@ public static RoomType fromString(String value) { .findFirst() .orElse(CHAT); } - + public String getCode() { return code; } - + public String getDisplayName() { return displayName; } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCode.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCode.java index be3394c0..ad599b53 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCode.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCode.java @@ -4,7 +4,7 @@ /** * 채팅 도메인 에러 코드 - * + *

* 채팅방(Room), 메시지(Message), 참여자(Participant) 관련 에러 코드를 정의합니다. */ public enum ChattingErrorCode implements DomainErrorCode { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingException.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingException.java index a2da178a..16b51450 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingException.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingException.java @@ -4,9 +4,9 @@ /** * 채팅 도메인 예외 클래스 - * + *

* 정적 팩토리 메서드를 통해 가독성 높은 예외 생성을 지원합니다. - * + *

* 사용 예시: * throw ChattingException.roomNotFound(roomId); * throw ChattingException.notRoomMember(userId, roomId); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatMessageHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatMessageHandler.java index b8a36d77..b156feba 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatMessageHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatMessageHandler.java @@ -37,7 +37,7 @@ public class ChatMessageHandler implements RequestHandler { String level = dto.getLevel() != null ? dto.getLevel() : "beginner"; Integer maxMembers = dto.getMaxMembers() != null ? dto.getMaxMembers() : 6; Boolean isPrivate = dto.getIsPrivate() != null ? dto.getIsPrivate() : false; - + ChatRoom room = commandService.createRoom( dto.getName(), dto.getDescription(), level, maxMembers, isPrivate, dto.getPassword(), userId, dto.getType(), dto.getGameType(), dto.getGameSettings()); - + // hostNickname 포함하여 응답 String hostNickname = queryService.getHostNickname(room); RoomListItem roomItem = RoomListItem.from(room, hostNickname); - + return ResponseGenerator.created("Room created", roomItem); }); } private APIGatewayProxyResponseEvent getRooms(APIGatewayProxyRequestEvent request, String userId) { Map queryParams = request.getQueryStringParameters(); - + String level = queryParams != null ? queryParams.get("level") : null; String joined = queryParams != null ? queryParams.get("joined") : null; String cursor = queryParams != null ? queryParams.get("cursor") : null; String type = queryParams != null ? queryParams.get("type") : null; String gameType = queryParams != null ? queryParams.get("gameType") : null; String status = queryParams != null ? queryParams.get("status") : null; - + int limit = 10; if (queryParams != null && queryParams.get("limit") != null) { limit = Math.min(Integer.parseInt(queryParams.get("limit")), 20); } - + PaginatedResult roomPage = queryService.getRooms(level, limit, cursor, type, gameType, status); List rooms = roomPage.items(); - + if ("true".equals(joined)) { rooms = queryService.filterByJoinedUser(rooms, userId); } - + // hostNickname 포함하여 RoomListItem으로 변환 List roomItems = rooms.stream() .map(room -> { @@ -116,35 +116,35 @@ private APIGatewayProxyResponseEvent getRooms(APIGatewayProxyRequestEvent reques return RoomListItem.from(room, hostNickname); }) .toList(); - + Map result = new HashMap<>(); result.put("rooms", roomItems); result.put("nextCursor", roomPage.nextCursor()); result.put("hasMore", roomPage.hasMore()); - + return ResponseGenerator.ok("Rooms retrieved", result); } private APIGatewayProxyResponseEvent getRoom(APIGatewayProxyRequestEvent request, String userId) { String roomId = request.getPathParameters().get("roomId"); - + Optional optRoom = queryService.getRoom(roomId); if (optRoom.isEmpty()) { return ResponseGenerator.fail(ChattingErrorCode.ROOM_NOT_FOUND); } - + ChatRoom room = optRoom.get(); room.setPassword(null); - + // 참가자 정보와 방장 닉네임 추가 List participants = queryService.getParticipantsWithNicknames(room); String hostNickname = queryService.getHostNickname(room); - + Map result = new HashMap<>(); result.put("room", room); result.put("participants", participants); result.put("hostNickname", hostNickname); - + return ResponseGenerator.ok("Room retrieved", result); } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatVoiceHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatVoiceHandler.java index 1f179162..f37b4d48 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatVoiceHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatVoiceHandler.java @@ -34,7 +34,7 @@ public class ChatVoiceHandler implements RequestHandler, String> { - + private static final Logger logger = LoggerFactory.getLogger(GameAutoCloseHandler.class); - + private final GameService gameService; private final ConnectionRepository connectionRepository; private final WebSocketBroadcaster broadcaster; - + /** * 기본 생성자 (Lambda에서 사용) */ public GameAutoCloseHandler() { this(new GameService(), new ConnectionRepository(), new WebSocketBroadcaster()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ public GameAutoCloseHandler(GameService gameService, ConnectionRepository connectionRepository, - WebSocketBroadcaster broadcaster) { + WebSocketBroadcaster broadcaster) { this.gameService = gameService; this.connectionRepository = connectionRepository; this.broadcaster = broadcaster; } - + @Override public String handleRequest(Map event, Context context) { String gameSessionId = event.get("gameSessionId"); String roomId = event.get("roomId"); - + logger.info("Game auto-close triggered: gameSessionId={}, roomId={}", gameSessionId, roomId); - + if (gameSessionId == null || roomId == null) { logger.error("Missing required parameters: gameSessionId={}, roomId={}", gameSessionId, roomId); return "FAILED: Missing parameters"; } - + try { // 게임 종료 처리 CommandResult result = gameService.finishGameByTimeout(gameSessionId); - + if (result.success()) { // WebSocket으로 게임 종료 알림 브로드캐스트 broadcastGameEnd(roomId, result.message()); @@ -73,20 +73,20 @@ public String handleRequest(Map event, Context context) { logger.info("Game auto-close skipped: gameSessionId={}, reason={}", gameSessionId, result.message()); return "SKIPPED: " + result.message(); } - + } catch (Exception e) { logger.error("Game auto-close failed: gameSessionId={}, error={}", gameSessionId, e.getMessage(), e); return "FAILED: " + e.getMessage(); } } - + /** * 게임 종료 메시지 브로드캐스트 */ private void broadcastGameEnd(String roomId, String message) { String messageId = UUID.randomUUID().toString(); String now = Instant.now().toString(); - + Map gameEndMessage = new HashMap<>(); gameEndMessage.put("domain", WebSocketMessageHelper.DOMAIN_GAME); gameEndMessage.put("messageId", messageId); @@ -97,11 +97,11 @@ private void broadcastGameEnd(String roomId, String message) { gameEndMessage.put("createdAt", now); gameEndMessage.put("timestamp", System.currentTimeMillis()); gameEndMessage.put("reason", "TIME_EXPIRED"); - + List connections = connectionRepository.findByRoomId(roomId); String broadcastPayload = ResponseGenerator.gson().toJson(gameEndMessage); broadcaster.broadcast(connections, broadcastPayload); - + logger.info("Game end broadcasted: roomId={}, connections={}", roomId, connections.size()); } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameHandler.java index 55a6c503..a4caaada 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameHandler.java @@ -10,7 +10,6 @@ import com.mzc.secondproject.serverless.common.util.WebSocketBroadcaster; import com.mzc.secondproject.serverless.common.util.WebSocketMessageHelper; import com.mzc.secondproject.serverless.domain.chatting.dto.response.CommandResult; -import com.mzc.secondproject.serverless.domain.chatting.dto.response.GameStatusResponse; import com.mzc.secondproject.serverless.domain.chatting.dto.response.ScoreboardResponse; import com.mzc.secondproject.serverless.domain.chatting.enums.MessageType; import com.mzc.secondproject.serverless.domain.chatting.exception.ChattingErrorCode; @@ -29,34 +28,34 @@ * 게임 REST API 핸들러 */ public class GameHandler implements RequestHandler { - + private static final Logger logger = LoggerFactory.getLogger(GameHandler.class); - + private final GameService gameService; private final GameSessionRepository gameSessionRepository; private final ConnectionRepository connectionRepository; private final WebSocketBroadcaster broadcaster; private final HandlerRouter router; - + /** * 기본 생성자 (Lambda에서 사용) */ public GameHandler() { this(new GameService(), new GameSessionRepository(), new ConnectionRepository(), new WebSocketBroadcaster()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ public GameHandler(GameService gameService, GameSessionRepository gameSessionRepository, - ConnectionRepository connectionRepository, WebSocketBroadcaster broadcaster) { + ConnectionRepository connectionRepository, WebSocketBroadcaster broadcaster) { this.gameService = gameService; this.gameSessionRepository = gameSessionRepository; this.connectionRepository = connectionRepository; this.broadcaster = broadcaster; this.router = initRouter(); } - + private HandlerRouter initRouter() { return new HandlerRouter().addRoutes( Route.postAuth("/rooms/{roomId}/game/start", this::startGame), @@ -66,91 +65,91 @@ private HandlerRouter initRouter() { Route.getAuth("/rooms/{roomId}/game/scores", this::getScores) ); } - + @Override public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { logger.info("Received request: {} {}", request.getHttpMethod(), request.getPath()); return router.route(request); } - + /** * POST /rooms/{roomId}/game/start - 게임 시작 */ private APIGatewayProxyResponseEvent startGame(APIGatewayProxyRequestEvent request, String userId) { String roomId = request.getPathParameters().get("roomId"); - + GameService.GameStartResult result = gameService.startGame(roomId, userId); - + if (!result.success()) { return ResponseGenerator.fail(ChattingErrorCode.GAME_START_FAILED, result.error()); } - + // WebSocket으로 게임 시작 알림 브로드캐스트 (출제자에게 currentWord 포함) broadcastGameStart(roomId, result); - + // REST 응답에도 출제자에게 currentWord 포함 Map response = buildGameStatusResponse(result.session(), userId); return ResponseGenerator.ok("Game started", response); } - + /** * POST /rooms/{roomId}/game/stop - 게임 중단 */ private APIGatewayProxyResponseEvent stopGame(APIGatewayProxyRequestEvent request, String userId) { String roomId = request.getPathParameters().get("roomId"); - + CommandResult result = gameService.stopGame(roomId, userId); - + if (!result.success()) { return ResponseGenerator.fail(ChattingErrorCode.GAME_STOP_FAILED, result.message()); } - + // WebSocket으로 게임 종료 알림 브로드캐스트 broadcastSystemMessage(roomId, result.message(), MessageType.GAME_END); - + return ResponseGenerator.ok("Game stopped", Map.of("message", result.message())); } - + /** * POST /rooms/{roomId}/game/restart - 게임 재시작 */ private APIGatewayProxyResponseEvent restartGame(APIGatewayProxyRequestEvent request, String userId) { String roomId = request.getPathParameters().get("roomId"); - + GameService.GameStartResult result = gameService.restartGame(roomId, userId); - + if (!result.success()) { return ResponseGenerator.fail(ChattingErrorCode.GAME_START_FAILED, result.error()); } - + // WebSocket으로 게임 시작 알림 브로드캐스트 (출제자에게 currentWord 포함) broadcastGameStart(roomId, result); - + // REST 응답에도 출제자에게 currentWord 포함 Map response = buildGameStatusResponse(result.session(), userId); return ResponseGenerator.ok("Game restarted", response); } - + /** * GET /rooms/{roomId}/game/status - 게임 상태 조회 */ private APIGatewayProxyResponseEvent getGameStatus(APIGatewayProxyRequestEvent request, String userId) { String roomId = request.getPathParameters().get("roomId"); - + Optional optSession = gameSessionRepository.findActiveByRoomId(roomId); if (optSession.isEmpty()) { // 게임이 없는 경우 빈 상태 반환 return ResponseGenerator.ok("No active game", Map.of("gameStatus", "NONE")); } - + GameSession session = optSession.get(); - + // 출제자에게만 currentWord 포함 Map response = buildGameStatusResponse(session, userId); - + return ResponseGenerator.ok("Game status retrieved", response); } - + /** * 게임 상태 응답 빌드 (출제자에게만 currentWord 포함) */ @@ -167,7 +166,7 @@ private Map buildGameStatusResponse(GameSession session, String response.put("scores", session.getScores() != null ? session.getScores() : Map.of()); response.put("hintUsed", session.getHintUsed()); response.put("correctGuessers", session.getCorrectGuessers()); - + // 출제자에게만 현재 단어 포함 if (userId != null && userId.equals(session.getCurrentDrawerId())) { Map currentWord = new HashMap<>(); @@ -175,27 +174,27 @@ private Map buildGameStatusResponse(GameSession session, String currentWord.put("word", session.getCurrentWord()); response.put("currentWord", currentWord); } - + return response; } - + /** * GET /rooms/{roomId}/game/scores - 점수 조회 */ private APIGatewayProxyResponseEvent getScores(APIGatewayProxyRequestEvent request, String userId) { String roomId = request.getPathParameters().get("roomId"); - + Optional optSession = gameSessionRepository.findActiveByRoomId(roomId); if (optSession.isEmpty()) { return ResponseGenerator.ok("No active game", Map.of("scores", Map.of())); } - + GameSession session = optSession.get(); ScoreboardResponse response = ScoreboardResponse.from(session); - + return ResponseGenerator.ok("Scores retrieved", response); } - + /** * 게임 시작 브로드캐스트 * 모든 사용자에게 게임 시작 메시지 전송, 출제자에게는 currentWord 포함 @@ -204,20 +203,20 @@ private void broadcastGameStart(String roomId, GameService.GameStartResult resul String messageId = UUID.randomUUID().toString(); String now = Instant.now().toString(); long serverTime = System.currentTimeMillis(); - + GameSession session = result.session(); String drawerId = session.getCurrentDrawerId(); - + String message = String.format(""" 🎮 게임 시작! 총 %d 라운드 - + 라운드 1 시작! 출제자: %s """, session.getTotalRounds(), drawerId); - + // 기본 게임 시작 메시지 (모든 사용자용) Map gameStartMessage = new HashMap<>(); gameStartMessage.put("domain", WebSocketMessageHelper.DOMAIN_GAME); @@ -236,35 +235,35 @@ private void broadcastGameStart(String roomId, GameService.GameStartResult resul gameStartMessage.put("roundStartTime", session.getRoundStartTime()); gameStartMessage.put("serverTime", serverTime); gameStartMessage.put("roundDuration", session.getRoundDuration()); - + List connections = connectionRepository.findByRoomId(roomId); - + // 출제자용 메시지 (currentWord 포함) Map drawerMessage = new HashMap<>(gameStartMessage); Map currentWord = new HashMap<>(); currentWord.put("wordId", session.getCurrentWordId()); currentWord.put("word", session.getCurrentWord()); drawerMessage.put("currentWord", currentWord); - + String broadcastPayload = ResponseGenerator.gson().toJson(gameStartMessage); String drawerPayload = ResponseGenerator.gson().toJson(drawerMessage); - + // 출제자와 일반 사용자에게 다른 메시지 전송 for (Connection conn : connections) { String payload = conn.getUserId().equals(drawerId) ? drawerPayload : broadcastPayload; broadcaster.sendToConnection(conn.getConnectionId(), payload); } - + logger.info("Game start broadcasted: roomId={}, drawerId={}", roomId, drawerId); } - + /** * 시스템 메시지 브로드캐스트 */ private void broadcastSystemMessage(String roomId, String message, MessageType messageType) { String messageId = UUID.randomUUID().toString(); String now = Instant.now().toString(); - + Map systemMessage = new HashMap<>(); systemMessage.put("domain", WebSocketMessageHelper.DOMAIN_GAME); systemMessage.put("messageId", messageId); @@ -274,11 +273,11 @@ private void broadcastSystemMessage(String roomId, String message, MessageType m systemMessage.put("messageType", messageType.getCode()); systemMessage.put("createdAt", now); systemMessage.put("timestamp", System.currentTimeMillis()); - + List connections = connectionRepository.findByRoomId(roomId); String broadcastPayload = ResponseGenerator.gson().toJson(systemMessage); broadcaster.broadcast(connections, broadcastPayload); - + logger.info("System message broadcasted: roomId={}, type={}", roomId, messageType); } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameSessionHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameSessionHandler.java index 7a818a64..16a9692f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameSessionHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameSessionHandler.java @@ -28,34 +28,34 @@ * 게임 세션 조회 및 재접속 지원 */ public class GameSessionHandler implements RequestHandler { - + private static final Logger logger = LoggerFactory.getLogger(GameSessionHandler.class); - + private final GameService gameService; private final GameSessionRepository gameSessionRepository; private final ConnectionRepository connectionRepository; private final WebSocketBroadcaster broadcaster; private final HandlerRouter router; - + /** * 기본 생성자 (Lambda에서 사용) */ public GameSessionHandler() { this(new GameService(), new GameSessionRepository(), new ConnectionRepository(), new WebSocketBroadcaster()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ public GameSessionHandler(GameService gameService, GameSessionRepository gameSessionRepository, - ConnectionRepository connectionRepository, WebSocketBroadcaster broadcaster) { + ConnectionRepository connectionRepository, WebSocketBroadcaster broadcaster) { this.gameService = gameService; this.gameSessionRepository = gameSessionRepository; this.connectionRepository = connectionRepository; this.broadcaster = broadcaster; this.router = initRouter(); } - + private HandlerRouter initRouter() { return new HandlerRouter().addRoutes( // 게임 세션 생성 (roomId 기반) @@ -68,117 +68,117 @@ private HandlerRouter initRouter() { Route.postAuth("/games/{gameSessionId}/stop", this::stopGame) ); } - + @Override public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { logger.info("GameSession API request: {} {}", request.getHttpMethod(), request.getPath()); return router.route(request); } - + /** * POST /rooms/{roomId}/games - 게임 세션 생성 (게임 시작) */ private APIGatewayProxyResponseEvent createGameSession(APIGatewayProxyRequestEvent request, String userId) { String roomId = request.getPathParameters().get("roomId"); - + GameService.GameStartResult result = gameService.startGame(roomId, userId); - + if (!result.success()) { return ResponseGenerator.fail(ChattingErrorCode.GAME_START_FAILED, result.error()); } - + // WebSocket으로 게임 시작 알림 브로드캐스트 broadcastGameStart(roomId, result); - + // 응답 생성 (serverTime 포함) Map response = buildGameSessionResponse(result.session(), userId); - + return ResponseGenerator.ok("Game session created", response); } - + /** * GET /games/{gameSessionId} - 게임 세션 조회 (재접속용) */ private APIGatewayProxyResponseEvent getGameSession(APIGatewayProxyRequestEvent request, String userId) { String gameSessionId = request.getPathParameters().get("gameSessionId"); - + Optional optSession = gameSessionRepository.findById(gameSessionId); if (optSession.isEmpty()) { return ResponseGenerator.fail(ChattingErrorCode.GAME_NOT_FOUND); } - + GameSession session = optSession.get(); - + // 응답 생성 (serverTime 포함, 출제자에게만 currentWord 포함) Map response = buildGameSessionResponse(session, userId); - + return ResponseGenerator.ok("Game session retrieved", response); } - + /** * POST /games/{gameSessionId}/start - 게임 시작 (세션 ID로) */ private APIGatewayProxyResponseEvent startGame(APIGatewayProxyRequestEvent request, String userId) { String gameSessionId = request.getPathParameters().get("gameSessionId"); - + Optional optSession = gameSessionRepository.findById(gameSessionId); if (optSession.isEmpty()) { return ResponseGenerator.fail(ChattingErrorCode.GAME_NOT_FOUND); } - + GameSession session = optSession.get(); - + // 이미 시작된 게임인지 확인 if (session.isActive()) { return ResponseGenerator.fail(ChattingErrorCode.GAME_START_FAILED, "게임이 이미 진행 중입니다."); } - + // roomId로 게임 시작 위임 GameService.GameStartResult result = gameService.startGame(session.getRoomId(), userId); - + if (!result.success()) { return ResponseGenerator.fail(ChattingErrorCode.GAME_START_FAILED, result.error()); } - + broadcastGameStart(session.getRoomId(), result); - + Map response = buildGameSessionResponse(result.session(), userId); return ResponseGenerator.ok("Game started", response); } - + /** * POST /games/{gameSessionId}/stop - 게임 종료 */ private APIGatewayProxyResponseEvent stopGame(APIGatewayProxyRequestEvent request, String userId) { String gameSessionId = request.getPathParameters().get("gameSessionId"); - + Optional optSession = gameSessionRepository.findById(gameSessionId); if (optSession.isEmpty()) { return ResponseGenerator.fail(ChattingErrorCode.GAME_NOT_FOUND); } - + GameSession session = optSession.get(); CommandResult result = gameService.stopGame(session.getRoomId(), userId); - + if (!result.success()) { return ResponseGenerator.fail(ChattingErrorCode.GAME_STOP_FAILED, result.message()); } - + // WebSocket으로 게임 종료 알림 브로드캐스트 broadcastSystemMessage(session.getRoomId(), result.message(), MessageType.GAME_END); - + return ResponseGenerator.ok("Game stopped", Map.of( "message", result.message(), "serverTime", System.currentTimeMillis() )); } - + /** * 게임 세션 응답 빌드 (serverTime 포함) */ private Map buildGameSessionResponse(GameSession session, String userId) { long serverTime = System.currentTimeMillis(); - + Map response = new LinkedHashMap<>(); response.put("gameSessionId", session.getGameSessionId()); response.put("roomId", session.getRoomId()); @@ -194,7 +194,7 @@ private Map buildGameSessionResponse(GameSession session, String response.put("players", session.getPlayers() != null ? session.getPlayers() : List.of()); response.put("drawerOrder", session.getDrawerOrder()); response.put("hintUsed", session.getHintUsed()); - + // 출제자에게만 현재 단어 포함 if (userId != null && userId.equals(session.getCurrentDrawerId())) { Map currentWord = new HashMap<>(); @@ -202,10 +202,10 @@ private Map buildGameSessionResponse(GameSession session, String currentWord.put("word", session.getCurrentWord()); response.put("currentWord", currentWord); } - + return response; } - + /** * 게임 시작 브로드캐스트 * 모든 사용자에게 게임 시작 메시지 전송, 출제자에게는 currentWord 포함 @@ -214,20 +214,20 @@ private void broadcastGameStart(String roomId, GameService.GameStartResult resul String messageId = UUID.randomUUID().toString(); String now = Instant.now().toString(); long serverTime = System.currentTimeMillis(); - + GameSession session = result.session(); String drawerId = session.getCurrentDrawerId(); - + String message = String.format(""" 🎮 게임 시작! 총 %d 라운드 - + 라운드 1 시작! 출제자: %s """, session.getTotalRounds(), drawerId); - + // 기본 게임 시작 메시지 (모든 사용자용) Map gameStartMessage = new HashMap<>(); gameStartMessage.put("domain", WebSocketMessageHelper.DOMAIN_GAME); @@ -246,35 +246,35 @@ private void broadcastGameStart(String roomId, GameService.GameStartResult resul gameStartMessage.put("roundStartTime", session.getRoundStartTime()); gameStartMessage.put("serverTime", serverTime); gameStartMessage.put("roundDuration", session.getRoundDuration()); - + List connections = connectionRepository.findByRoomId(roomId); - + // 출제자용 메시지 (currentWord 포함) Map drawerMessage = new HashMap<>(gameStartMessage); Map currentWord = new HashMap<>(); currentWord.put("wordId", session.getCurrentWordId()); currentWord.put("word", session.getCurrentWord()); drawerMessage.put("currentWord", currentWord); - + String broadcastPayload = ResponseGenerator.gson().toJson(gameStartMessage); String drawerPayload = ResponseGenerator.gson().toJson(drawerMessage); - + // 출제자와 일반 사용자에게 다른 메시지 전송 for (Connection conn : connections) { String payload = conn.getUserId().equals(drawerId) ? drawerPayload : broadcastPayload; broadcaster.sendToConnection(conn.getConnectionId(), payload); } - + logger.info("Game start broadcasted: roomId={}, sessionId={}, drawerId={}", roomId, session.getGameSessionId(), drawerId); } - + /** * 시스템 메시지 브로드캐스트 */ private void broadcastSystemMessage(String roomId, String message, MessageType messageType) { String messageId = UUID.randomUUID().toString(); String now = Instant.now().toString(); - + Map systemMessage = new HashMap<>(); systemMessage.put("domain", WebSocketMessageHelper.DOMAIN_GAME); systemMessage.put("messageId", messageId); @@ -284,11 +284,11 @@ private void broadcastSystemMessage(String roomId, String message, MessageType m systemMessage.put("messageType", messageType.getCode()); systemMessage.put("createdAt", now); systemMessage.put("timestamp", System.currentTimeMillis()); - + List connections = connectionRepository.findByRoomId(roomId); String broadcastPayload = ResponseGenerator.gson().toJson(systemMessage); broadcaster.broadcast(connections, broadcastPayload); - + logger.info("System message broadcasted: roomId={}, type={}", roomId, messageType); } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketConnectHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketConnectHandler.java index 0588185e..7b674a45 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketConnectHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketConnectHandler.java @@ -56,13 +56,13 @@ public Map handleRequest(Map event, Context cont RoomToken token = optToken.get(); String userId = token.getUserId(); String roomId = token.getRoomId(); - + // 같은 방에서 기존 연결 삭제 (새로고침 시 중복 연결 방지) connectionRepository.deleteUserConnectionsInRoom(userId, roomId); - + String now = Instant.now().toString(); long ttl = Instant.now().plusSeconds(WebSocketConfig.connectionTtlSeconds()).getEpochSecond(); - + Connection connection = Connection.builder() .pk("CONN#" + connectionId) .sk("METADATA") diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketDisconnectHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketDisconnectHandler.java index ce0ed059..1400b43e 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketDisconnectHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketDisconnectHandler.java @@ -22,36 +22,36 @@ * 클라이언트 연결 해제 시 Connection 정보를 DynamoDB에서 삭제 */ public class WebSocketDisconnectHandler implements RequestHandler, Map> { - + private static final Logger logger = LoggerFactory.getLogger(WebSocketDisconnectHandler.class); - + private final ConnectionRepository connectionRepository; private final ChatRoomRepository chatRoomRepository; private final GameSessionRepository gameSessionRepository; - + public WebSocketDisconnectHandler() { this.connectionRepository = new ConnectionRepository(); this.chatRoomRepository = new ChatRoomRepository(); this.gameSessionRepository = new GameSessionRepository(); } - + @Override public Map handleRequest(Map event, Context context) { logger.info("WebSocket disconnect event: {}", event); - + try { String connectionId = WebSocketEventUtil.extractConnectionId(event); - + Optional connection = connectionRepository.findByConnectionId(connectionId); - + if (connection.isPresent()) { Connection conn = connection.get(); String roomId = conn.getRoomId(); - + connectionRepository.delete(connectionId); logger.info("Connection deleted: connectionId={}, userId={}, roomId={}", connectionId, conn.getUserId(), roomId); - + // 방에 남은 연결이 없으면 게임 상태 초기화 List remainingConnections = connectionRepository.findByRoomId(roomId); if (remainingConnections.isEmpty()) { @@ -61,15 +61,15 @@ public Map handleRequest(Map event, Context cont } else { logger.warn("Connection not found for deletion: connectionId={}", connectionId); } - + return WebSocketEventUtil.ok("Disconnected"); - + } catch (Exception e) { logger.error("Error handling disconnect: {}", e.getMessage(), e); return WebSocketEventUtil.serverError("Internal server error"); } } - + /** * 게임 상태 초기화 * 새 구조에서는 GameSession을 종료하고 ChatRoom의 상태를 WAITING으로 변경 @@ -85,7 +85,7 @@ private void resetGameState(String roomId) { gameSessionRepository.finishGame(session.getGameSessionId(), now, ttl); logger.info("Game session finished due to empty room: gameSessionId={}", session.getGameSessionId()); } - + // 채팅방 상태 초기화 Optional roomOpt = chatRoomRepository.findById(roomId); if (roomOpt.isPresent()) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java index fadd7296..f8da9d75 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java @@ -44,7 +44,7 @@ public class WebSocketMessageHandler implements RequestHandler handleRegularMessage(String connectionId, MessagePay // 일반 메시지 저장 및 브로드캐스트 String messageId = UUID.randomUUID().toString(); String now = Instant.now().toString(); - + ChatMessage message = ChatMessage.builder() .pk("ROOM#" + payload.roomId) .sk("MSG#" + now + "#" + messageId) @@ -170,12 +170,12 @@ private Map handleRegularMessage(String connectionId, MessagePay .messageType(messageType) .createdAt(now) .build(); - + ChatMessage savedMessage = chatMessageService.saveMessage(message); chatRoomRepository.updateLastMessageAt(payload.roomId, now); - + logger.info("Message saved: messageId={}, roomId={}", messageId, payload.roomId); - + // 브로드캐스트 (domain 필드 포함을 위해 Map으로 변환) Map broadcastMessage = new HashMap<>(); broadcastMessage.put("domain", WebSocketMessageHelper.DOMAIN_CHAT); @@ -186,17 +186,17 @@ private Map handleRegularMessage(String connectionId, MessagePay broadcastMessage.put("messageType", savedMessage.getMessageType()); broadcastMessage.put("createdAt", savedMessage.getCreatedAt()); broadcastMessage.put("timestamp", System.currentTimeMillis()); - + List connections = connectionRepository.findByRoomId(payload.roomId); String broadcastPayload = gson.toJson(broadcastMessage); List failedConnections = broadcaster.broadcast(connections, broadcastPayload); - + // 실패한 연결 정리 for (String failedConnectionId : failedConnections) { connectionRepository.delete(failedConnectionId); logger.info("Deleted stale connection: {}", failedConnectionId); } - + return WebSocketEventUtil.ok("Message sent"); } @@ -232,23 +232,23 @@ private Map broadcastGuessMessage(MessagePayload payload) { */ private Map handleCorrectAnswer(MessagePayload payload, GameService.AnswerCheckResult result) { List connections = connectionRepository.findByRoomId(payload.roomId); - + // 1. 정답 알림 메시지 브로드캐스트 broadcastCorrectAnswerMessage(payload, result, connections); - + // 2. 점수 업데이트 메시지 브로드캐스트 (실시간 리더보드) gameSessionRepository.findActiveByRoomId(payload.roomId).ifPresent(session -> { broadcastScoreUpdate(payload.roomId, payload.userId, result.score(), result.scores(), session.getCurrentRound(), session.getTotalRounds(), connections); }); - + logger.info("Correct answer: roomId={}, userId={}, score={}", payload.roomId, payload.userId, result.score()); - + // 전원 정답 시 라운드 종료 처리 if (result.allCorrect()) { handleAllCorrect(payload.roomId); } - + return WebSocketEventUtil.ok("Correct answer"); } @@ -258,9 +258,9 @@ private Map handleCorrectAnswer(MessagePayload payload, GameServ private void broadcastCorrectAnswerMessage(MessagePayload payload, GameService.AnswerCheckResult result, List connections) { String messageId = UUID.randomUUID().toString(); String now = Instant.now().toString(); - + String message = String.format("🎉 %s님이 정답을 맞췄습니다! (+%d점)", payload.userId, result.score()); - + // domain 필드 포함을 위해 Map으로 생성 Map correctMessage = new HashMap<>(); correctMessage.put("domain", WebSocketMessageHelper.DOMAIN_GAME); @@ -271,7 +271,7 @@ private void broadcastCorrectAnswerMessage(MessagePayload payload, GameService.A correctMessage.put("messageType", MessageType.CORRECT_ANSWER.getCode()); correctMessage.put("createdAt", now); correctMessage.put("timestamp", System.currentTimeMillis()); - + String broadcastPayload = gson.toJson(correctMessage); List failedConnections = broadcaster.broadcast(connections, broadcastPayload); cleanupFailedConnections(failedConnections); @@ -320,7 +320,7 @@ private void handleAllCorrect(String roomId) { handleCommandResult(endResult, roomId, "SYSTEM"); } } - + /** * 라운드 타임아웃 처리 (프론트엔드에서 타이머 만료 시 호출) * - 실제 라운드 시간이 만료되었는지 서버에서 검증 @@ -329,31 +329,31 @@ private void handleAllCorrect(String roomId) { private Map handleRoundTimeout(MessagePayload payload) { String roomId = payload.roomId; logger.info("Round timeout request: roomId={}, userId={}", roomId, payload.userId); - + // 활성 게임 세션 조회 GameSession session = gameSessionRepository.findActiveByRoomId(roomId).orElse(null); if (session == null) { logger.warn("No active game session for round timeout: roomId={}", roomId); return WebSocketEventUtil.ok("No active game"); } - + // 라운드 시간이 실제로 만료되었는지 검증 (5초 여유) long elapsedMs = System.currentTimeMillis() - session.getRoundStartTime(); int roundDurationMs = (session.getRoundDuration() != null ? session.getRoundDuration() : 60) * 1000; - + if (elapsedMs < roundDurationMs - 5000) { logger.warn("Round timeout rejected - time not expired: elapsedMs={}, roundDurationMs={}", elapsedMs, roundDurationMs); return WebSocketEventUtil.ok("Round time not expired yet"); } - + // 라운드 종료 처리 CommandResult endResult = gameService.endRound(roomId, "TIMEOUT"); if (endResult != null && endResult.success()) { handleCommandResult(endResult, roomId, "SYSTEM"); logger.info("Round ended due to timeout: roomId={}", roomId); } - + return WebSocketEventUtil.ok("Round timeout processed"); } @@ -362,13 +362,13 @@ private Map handleRoundTimeout(MessagePayload payload) { */ private Map handleCommandResult(CommandResult result, String roomId, String userId) { List connections = connectionRepository.findByRoomId(roomId); - + // GAME_START는 특별 처리 (출제자에게만 제시어 전송 + serverTime 포함) if (result.messageType() == MessageType.GAME_START && result.data() instanceof GameService.GameStartResult gameResult) { broadcastGameStart(connections, result, gameResult, roomId); return WebSocketEventUtil.ok("Command executed"); } - + // ROUND_END는 특별 처리 (다음 출제자에게만 제시어 전송 + serverTime 포함) if (result.messageType() == MessageType.ROUND_END && result.data() instanceof Map) { @SuppressWarnings("unchecked") @@ -376,11 +376,11 @@ private Map handleCommandResult(CommandResult result, String roo broadcastRoundEnd(connections, result, data, roomId); return WebSocketEventUtil.ok("Command executed"); } - + // 일반 시스템 메시지 (게임 관련 명령어 결과) String messageId = UUID.randomUUID().toString(); String now = Instant.now().toString(); - + // domain 필드 포함을 위해 Map으로 생성 Map systemMessage = new HashMap<>(); systemMessage.put("domain", WebSocketMessageHelper.DOMAIN_GAME); @@ -391,27 +391,27 @@ private Map handleCommandResult(CommandResult result, String roo systemMessage.put("messageType", result.messageType().getCode()); systemMessage.put("createdAt", now); systemMessage.put("timestamp", System.currentTimeMillis()); - + String broadcastPayload = gson.toJson(systemMessage); List failedConnections = broadcaster.broadcast(connections, broadcastPayload); cleanupFailedConnections(failedConnections); - + logger.info("Command result broadcasted: type={}, roomId={}", result.messageType(), roomId); return WebSocketEventUtil.ok("Command executed"); } - + /** * GAME_START 메시지 브로드캐스트 - 출제자에게만 제시어 포함, serverTime 추가 */ private void broadcastGameStart(List connections, CommandResult result, - GameService.GameStartResult gameResult, String roomId) { + GameService.GameStartResult gameResult, String roomId) { String messageId = UUID.randomUUID().toString(); String now = Instant.now().toString(); long serverTime = System.currentTimeMillis(); - + GameSession session = gameResult.session(); String currentDrawerId = session.getCurrentDrawerId(); - + for (Connection conn : connections) { Map message = new HashMap<>(); message.put("domain", WebSocketMessageHelper.DOMAIN_GAME); @@ -422,19 +422,19 @@ private void broadcastGameStart(List connections, CommandResult resu message.put("messageType", result.messageType().getCode()); message.put("createdAt", now); message.put("timestamp", serverTime); - + // 게임 상태 정보 message.put("gameStatus", session.getStatus()); message.put("currentRound", session.getCurrentRound()); message.put("totalRounds", session.getTotalRounds()); message.put("currentDrawerId", currentDrawerId); message.put("drawerOrder", gameResult.drawerOrder()); - + // 타이머 동기화용 필드 (핵심!) message.put("roundStartTime", session.getRoundStartTime()); message.put("serverTime", serverTime); message.put("roundDuration", session.getRoundDuration()); - + // 출제자에게만 제시어 전송 if (conn.getUserId().equals(currentDrawerId) && gameResult.firstWord() != null) { Map wordInfo = new HashMap<>(); @@ -442,7 +442,7 @@ private void broadcastGameStart(List connections, CommandResult resu wordInfo.put("word", gameResult.firstWord().getEnglish()); message.put("currentWord", wordInfo); } - + String payload = gson.toJson(message); try { broadcaster.sendToConnection(conn.getConnectionId(), payload); @@ -451,22 +451,22 @@ private void broadcastGameStart(List connections, CommandResult resu connectionRepository.delete(conn.getConnectionId()); } } - + logger.info("GAME_START broadcasted: roomId={}, serverTime={}", roomId, serverTime); } - + /** * ROUND_END 메시지 브로드캐스트 - 다음 출제자에게만 제시어 포함, serverTime 추가 */ private void broadcastRoundEnd(List connections, CommandResult result, - Map data, String roomId) { + Map data, String roomId) { String messageId = UUID.randomUUID().toString(); String now = Instant.now().toString(); long serverTime = System.currentTimeMillis(); - + String nextDrawer = (String) data.get("nextDrawer"); Object nextWordObj = data.get("nextWord"); - + for (Connection conn : connections) { Map message = new HashMap<>(); message.put("domain", WebSocketMessageHelper.DOMAIN_GAME); @@ -477,7 +477,7 @@ private void broadcastRoundEnd(List connections, CommandResult resul message.put("messageType", result.messageType().getCode()); message.put("createdAt", now); message.put("timestamp", serverTime); - + // 기본 데이터 복사 (nextWord 제외) Map messageData = new HashMap<>(); messageData.put("answer", data.get("answer")); @@ -486,7 +486,7 @@ private void broadcastRoundEnd(List connections, CommandResult resul messageData.put("ranking", data.get("ranking")); messageData.put("currentRound", data.get("currentRound")); messageData.put("totalRounds", data.get("totalRounds")); - + // 타이머 동기화용 필드 (핵심!) messageData.put("serverTime", serverTime); if (data.get("roundStartTime") != null) { @@ -495,7 +495,7 @@ private void broadcastRoundEnd(List connections, CommandResult resul if (data.get("roundDuration") != null) { messageData.put("roundDuration", data.get("roundDuration")); } - + // 다음 출제자에게만 제시어 전송 if (conn.getUserId().equals(nextDrawer) && nextWordObj != null) { if (nextWordObj instanceof com.mzc.secondproject.serverless.domain.vocabulary.model.Word nextWord) { @@ -505,9 +505,9 @@ private void broadcastRoundEnd(List connections, CommandResult resul messageData.put("nextWord", wordInfo); } } - + message.put("data", messageData); - + String payload = gson.toJson(message); try { broadcaster.sendToConnection(conn.getConnectionId(), payload); @@ -516,7 +516,7 @@ private void broadcastRoundEnd(List connections, CommandResult resul connectionRepository.delete(conn.getConnectionId()); } } - + logger.info("ROUND_END broadcasted: roomId={}, serverTime={}", roomId, serverTime); } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/ChatRoom.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/ChatRoom.java index 44c38d39..4cc1f822 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/ChatRoom.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/ChatRoom.java @@ -33,16 +33,16 @@ public class ChatRoom { private String lastMessageAt; private List memberIds; // 참여 멤버 목록 private Long ttl; - + // 게임 세션 참조 (게임 상태는 GameSession으로 분리됨) private String activeGameSessionId; // 현재 진행중인 게임 세션 ID (nullable) - + private String type; // CHAT, GAME (기본값: CHAT) private String gameType; // CATCHMIND (nullable, GAME 타입일 때만) private GameSettings gameSettings; // 게임 설정 (nullable) private String status; // WAITING, PLAYING, FINISHED (기본값: WAITING) private String hostId; // 방장 userId (createdBy와 별도 관리) - + @DynamoDbPartitionKey @DynamoDbAttribute("PK") public String getPk() { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSession.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSession.java index 403d7805..ce21f82b 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSession.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSession.java @@ -19,22 +19,22 @@ @AllArgsConstructor @DynamoDbBean public class GameSession { - + private String pk; // GAME#{gameSessionId} private String sk; // METADATA private String gsi1pk; // ROOM#{roomId} private String gsi1sk; // GAME#{createdAt} - + private String gameSessionId; private String roomId; private String gameType; // "catchmind" - + // 게임 상태 private String status; // NONE, WAITING, PLAYING, ROUND_END, FINISHED private String startedBy; private Long startedAt; private Long endedAt; - + // 라운드 정보 private Integer currentRound; private Integer totalRounds; @@ -44,76 +44,76 @@ public class GameSession { private String currentWordEnglish; // 영어 단어 (정답 체크용) private Long roundStartTime; private Integer roundDuration; - + // 점수 및 플레이어 private Map scores; private Map streaks; private List players; private List drawerOrder; - + // 라운드 내 상태 private Boolean hintUsed; private List correctGuessers; - + // 스케줄링 (게임 자동 종료용) private Long gameEndScheduledAt; private String scheduleRuleArn; - + // TTL (게임 종료 후 일정 시간 뒤 삭제) private Long ttl; - + @DynamoDbPartitionKey @DynamoDbAttribute("PK") public String getPk() { return pk; } - + @DynamoDbSortKey @DynamoDbAttribute("SK") public String getSk() { return sk; } - + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") @DynamoDbAttribute("GSI1PK") public String getGsi1pk() { return gsi1pk; } - + @DynamoDbSecondarySortKey(indexNames = "GSI1") @DynamoDbAttribute("GSI1SK") public String getGsi1sk() { return gsi1sk; } - + /** * 게임이 활성 상태인지 확인 */ public boolean isActive() { return "PLAYING".equals(status) || "ROUND_END".equals(status); } - + /** * 게임 시작 가능 여부 확인 */ public boolean canStart() { return status == null || "NONE".equals(status) || "FINISHED".equals(status); } - + /** * 출제자 여부 확인 */ public boolean isDrawer(String userId) { return userId != null && userId.equals(currentDrawerId); } - + /** * 이미 정답을 맞춘 사용자인지 확인 */ public boolean hasAlreadyGuessedCorrect(String userId) { return correctGuessers != null && correctGuessers.contains(userId); } - + /** * 정답자 추가 */ @@ -125,7 +125,7 @@ public void addCorrectGuesser(String userId) { correctGuessers.add(userId); } } - + /** * 점수 추가 */ @@ -135,7 +135,7 @@ public void addScore(String userId, int points) { } scores.merge(userId, points, Integer::sum); } - + /** * 연속 정답 수 증가 */ @@ -147,7 +147,7 @@ public int incrementStreak(String userId) { streaks.put(userId, newStreak); return newStreak; } - + /** * 연속 정답 수 리셋 */ @@ -156,7 +156,7 @@ public void resetStreak(String userId) { streaks.put(userId, 0); } } - + /** * 다음 출제자 ID 반환 */ @@ -173,7 +173,7 @@ public String getNextDrawerId() { } return drawerOrder.get(currentIndex + 1); } - + /** * 전원이 정답을 맞췄는지 확인 */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSettings.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSettings.java index 22e0f226..d97f2fcc 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSettings.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSettings.java @@ -14,10 +14,10 @@ public class GameSettings { @Builder.Default private Integer maxRounds = 5; - + @Builder.Default private Integer roundTimeLimit = 60; - + @Builder.Default private Boolean autoDeleteOnEnd = false; } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatMessageRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatMessageRepository.java index a2f17dee..dfcdc230 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatMessageRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatMessageRepository.java @@ -26,14 +26,14 @@ public class ChatMessageRepository { private static final String TABLE_NAME = EnvConfig.getRequired("CHAT_TABLE_NAME"); private final DynamoDbTable table; - + /** * 기본 생성자 (Lambda에서 사용) */ public ChatMessageRepository() { this(AwsClients.dynamoDbEnhanced()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatRoomRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatRoomRepository.java index e351a703..e437e262 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatRoomRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatRoomRepository.java @@ -7,11 +7,7 @@ import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; -import software.amazon.awssdk.enhanced.dynamodb.DynamoDbIndex; -import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; -import software.amazon.awssdk.enhanced.dynamodb.Key; -import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.*; import software.amazon.awssdk.enhanced.dynamodb.model.Page; import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; @@ -29,14 +25,14 @@ public class ChatRoomRepository { private static final String TABLE_NAME = EnvConfig.getRequired("CHAT_TABLE_NAME"); private final DynamoDbTable table; - + /** * 기본 생성자 (Lambda에서 사용) */ public ChatRoomRepository() { this(AwsClients.dynamoDbEnhanced()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ @@ -109,7 +105,7 @@ public PaginatedResult findAllWithPagination(int limit, String cursor) public PaginatedResult findByFilters(String type, String gameType, String status, String level, int limit, String cursor) { // GSI1SK prefix 생성: {type}#{gameType}#{status}#{level}# StringBuilder prefixBuilder = new StringBuilder(); - + if (type != null && !type.isEmpty()) { prefixBuilder.append(type).append("#"); if (gameType != null && !gameType.isEmpty()) { @@ -122,9 +118,9 @@ public PaginatedResult findByFilters(String type, String gameType, Str } } } - + String prefix = prefixBuilder.toString(); - + QueryConditional queryConditional; if (prefix.isEmpty()) { // 필터 없음 - 전체 조회 @@ -138,31 +134,32 @@ public PaginatedResult findByFilters(String type, String gameType, Str .build() ); } - + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() .queryConditional(queryConditional) .scanIndexForward(false) // 최신순 .limit(limit); - + if (cursor != null && !cursor.isEmpty()) { Map exclusiveStartKey = CursorUtil.decode(cursor); if (exclusiveStartKey != null) { requestBuilder.exclusiveStartKey(exclusiveStartKey); } } - + DynamoDbIndex gsi1 = table.index("GSI1"); Page page = gsi1.query(requestBuilder.build()).iterator().next(); List rooms = page.items(); - + String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); - + logger.info("Query with prefix '{}': found {} rooms", prefix, rooms.size()); return new PaginatedResult<>(rooms, nextCursor); } - + /** * 레벨별 채팅방 조회 - 최신순, 페이지네이션 지원 + * * @deprecated findByFilters 사용 권장 */ @Deprecated @@ -187,7 +184,7 @@ public void delete(String roomId) { public void updateStatus(ChatRoom room, String newStatus) { String oldGsi1sk = room.getGsi1sk(); String[] parts = oldGsi1sk.split("#", 5); // type, gameType, oldStatus, level, createdAt - + if (parts.length < 5) { logger.warn("Invalid GSI1SK format: {}", oldGsi1sk); // 폴백: 새 포맷으로 생성 @@ -200,12 +197,12 @@ public void updateStatus(ChatRoom room, String newStatus) { // 기존 포맷에서 status만 교체 room.setGsi1sk(String.format("%s#%s#%s#%s#%s", parts[0], parts[1], newStatus, parts[3], parts[4])); } - + room.setStatus(newStatus); table.putItem(room); logger.info("Updated room {} status to {} (GSI1SK: {})", room.getRoomId(), newStatus, room.getGsi1sk()); } - + /** * 채팅방 lastMessageAt 업데이트 (N+1 방지 - UpdateExpression 사용) */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ConnectionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ConnectionRepository.java index eba332e8..612b87a5 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ConnectionRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ConnectionRepository.java @@ -5,11 +5,7 @@ import com.mzc.secondproject.serverless.domain.chatting.model.Connection; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; -import software.amazon.awssdk.enhanced.dynamodb.DynamoDbIndex; -import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; -import software.amazon.awssdk.enhanced.dynamodb.Key; -import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.*; import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; @@ -23,14 +19,14 @@ public class ConnectionRepository { private static final String TABLE_NAME = EnvConfig.getRequired("CHAT_TABLE_NAME"); private final DynamoDbTable table; - + /** * 기본 생성자 (Lambda에서 사용) */ public ConnectionRepository() { this(AwsClients.dynamoDbEnhanced()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ @@ -77,13 +73,13 @@ public List findByRoomId(String roomId) { .partitionValue("ROOM#" + roomId) .sortValue("CONN#") .build()); - + QueryEnhancedRequest request = QueryEnhancedRequest.builder() .queryConditional(queryConditional) .build(); - + DynamoDbIndex gsi1 = table.index("GSI1"); - + return gsi1.query(request).stream() .flatMap(page -> page.items().stream()) .collect(Collectors.toList()); @@ -98,25 +94,25 @@ public List findByUserId(String userId) { .keyEqualTo(Key.builder() .partitionValue("USER#" + userId) .build()); - + QueryEnhancedRequest request = QueryEnhancedRequest.builder() .queryConditional(queryConditional) .build(); - + DynamoDbIndex gsi2 = table.index("GSI2"); - + return gsi2.query(request).stream() .flatMap(page -> page.items().stream()) .collect(Collectors.toList()); } - + /** * 같은 방에서 사용자의 기존 연결 삭제 (중복 연결 방지) * 새로고침 등으로 인한 중복 연결을 정리 */ public void deleteUserConnectionsInRoom(String userId, String roomId) { List userConnections = findByUserId(userId); - + int deletedCount = 0; for (Connection conn : userConnections) { if (roomId.equals(conn.getRoomId())) { @@ -124,7 +120,7 @@ public void deleteUserConnectionsInRoom(String userId, String roomId) { deletedCount++; } } - + if (deletedCount > 0) { logger.info("Deleted {} existing connections for user {} in room {}", deletedCount, userId, roomId); } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/GameRoundRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/GameRoundRepository.java index 047df2d0..adc5b994 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/GameRoundRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/GameRoundRepository.java @@ -35,7 +35,7 @@ public class GameRoundRepository { public GameRoundRepository() { this(AwsClients.dynamoDbEnhanced()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/GameSessionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/GameSessionRepository.java index bf2493b5..70b7c238 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/GameSessionRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/GameSessionRepository.java @@ -5,11 +5,7 @@ import com.mzc.secondproject.serverless.domain.chatting.model.GameSession; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; -import software.amazon.awssdk.enhanced.dynamodb.DynamoDbIndex; -import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; -import software.amazon.awssdk.enhanced.dynamodb.Key; -import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.*; import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; import software.amazon.awssdk.services.dynamodb.model.AttributeValue; @@ -25,26 +21,26 @@ * 게임 세션 CRUD 및 조회 기능 제공 */ public class GameSessionRepository { - + private static final Logger logger = LoggerFactory.getLogger(GameSessionRepository.class); private static final String TABLE_NAME = EnvConfig.getRequired("CHAT_TABLE_NAME"); - + private final DynamoDbTable table; - + /** * 기본 생성자 (Lambda에서 사용) */ public GameSessionRepository() { this(AwsClients.dynamoDbEnhanced()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ public GameSessionRepository(DynamoDbEnhancedClient enhancedClient) { this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(GameSession.class)); } - + /** * 게임 세션 저장 */ @@ -53,7 +49,7 @@ public GameSession save(GameSession session) { table.putItem(session); return session; } - + /** * ID로 게임 세션 조회 */ @@ -62,11 +58,11 @@ public Optional findById(String gameSessionId) { .partitionValue("GAME#" + gameSessionId) .sortValue("METADATA") .build(); - + GameSession session = table.getItem(key); return Optional.ofNullable(session); } - + /** * 게임 세션 삭제 */ @@ -75,22 +71,22 @@ public void delete(String gameSessionId) { .partitionValue("GAME#" + gameSessionId) .sortValue("METADATA") .build(); - + table.deleteItem(key); logger.info("Deleted game session: {}", gameSessionId); } - + /** * roomId로 활성 게임 세션 조회 (PLAYING 또는 ROUND_END 상태) */ public Optional findActiveByRoomId(String roomId) { List sessions = findByRoomId(roomId); - + return sessions.stream() .filter(GameSession::isActive) .findFirst(); } - + /** * roomId로 모든 게임 세션 조회 (최신순) */ @@ -99,28 +95,28 @@ public List findByRoomId(String roomId) { .keyEqualTo(Key.builder() .partitionValue("ROOM#" + roomId) .build()); - + QueryEnhancedRequest request = QueryEnhancedRequest.builder() .queryConditional(queryConditional) .scanIndexForward(false) // 최신순 .build(); - + DynamoDbIndex gsi1 = table.index("GSI1"); - + return gsi1.query(request).stream() .flatMap(page -> page.items().stream()) .toList(); } - + /** * 게임 상태 업데이트 */ public void updateStatus(String gameSessionId, String status) { Map key = buildKey(gameSessionId); - + Map expressionValues = new HashMap<>(); expressionValues.put(":status", AttributeValue.builder().s(status).build()); - + UpdateItemRequest updateRequest = UpdateItemRequest.builder() .tableName(TABLE_NAME) .key(key) @@ -128,18 +124,18 @@ public void updateStatus(String gameSessionId, String status) { .expressionAttributeNames(Map.of("#status", "status")) .expressionAttributeValues(expressionValues) .build(); - + AwsClients.dynamoDb().updateItem(updateRequest); logger.info("Updated game session status: {} -> {}", gameSessionId, status); } - + /** * 라운드 정보 업데이트 */ public void updateRoundInfo(String gameSessionId, int currentRound, String drawerId, String wordId, String word, long roundStartTime, int roundDuration) { Map key = buildKey(gameSessionId); - + Map expressionValues = new HashMap<>(); expressionValues.put(":round", AttributeValue.builder().n(String.valueOf(currentRound)).build()); expressionValues.put(":drawer", AttributeValue.builder().s(drawerId).build()); @@ -149,7 +145,7 @@ public void updateRoundInfo(String gameSessionId, int currentRound, String drawe expressionValues.put(":duration", AttributeValue.builder().n(String.valueOf(roundDuration)).build()); expressionValues.put(":hintUsed", AttributeValue.builder().bool(false).build()); expressionValues.put(":emptyList", AttributeValue.builder().l(List.of()).build()); - + String updateExpression = "SET currentRound = :round, " + "currentDrawerId = :drawer, " + "currentWordId = :wordId, " + @@ -158,78 +154,78 @@ public void updateRoundInfo(String gameSessionId, int currentRound, String drawe "roundDuration = :duration, " + "hintUsed = :hintUsed, " + "correctGuessers = :emptyList"; - + UpdateItemRequest updateRequest = UpdateItemRequest.builder() .tableName(TABLE_NAME) .key(key) .updateExpression(updateExpression) .expressionAttributeValues(expressionValues) .build(); - + AwsClients.dynamoDb().updateItem(updateRequest); logger.info("Updated round info: gameSession={}, round={}, drawer={}", gameSessionId, currentRound, drawerId); } - + /** * 점수 업데이트 */ public void updateScores(String gameSessionId, Map scores) { Map key = buildKey(gameSessionId); - + Map scoresMap = new HashMap<>(); scores.forEach((userId, score) -> scoresMap.put(userId, AttributeValue.builder().n(String.valueOf(score)).build())); - + Map expressionValues = new HashMap<>(); expressionValues.put(":scores", AttributeValue.builder().m(scoresMap).build()); - + UpdateItemRequest updateRequest = UpdateItemRequest.builder() .tableName(TABLE_NAME) .key(key) .updateExpression("SET scores = :scores") .expressionAttributeValues(expressionValues) .build(); - + AwsClients.dynamoDb().updateItem(updateRequest); logger.info("Updated scores for game session: {}", gameSessionId); } - + /** * 정답자 추가 */ public void addCorrectGuesser(String gameSessionId, String userId) { Map key = buildKey(gameSessionId); - + Map expressionValues = new HashMap<>(); expressionValues.put(":userId", AttributeValue.builder().l( AttributeValue.builder().s(userId).build() ).build()); expressionValues.put(":emptyList", AttributeValue.builder().l(List.of()).build()); - + UpdateItemRequest updateRequest = UpdateItemRequest.builder() .tableName(TABLE_NAME) .key(key) .updateExpression("SET correctGuessers = list_append(if_not_exists(correctGuessers, :emptyList), :userId)") .expressionAttributeValues(expressionValues) .build(); - + AwsClients.dynamoDb().updateItem(updateRequest); logger.info("Added correct guesser: gameSession={}, userId={}", gameSessionId, userId); } - + /** * 연속 정답(streak) 업데이트 */ public void updateStreak(String gameSessionId, String userId, int streak) { Map key = buildKey(gameSessionId); - + Map expressionValues = new HashMap<>(); expressionValues.put(":streak", AttributeValue.builder().n(String.valueOf(streak)).build()); - + Map expressionNames = new HashMap<>(); expressionNames.put("#streaks", "streaks"); expressionNames.put("#userId", userId); - + UpdateItemRequest updateRequest = UpdateItemRequest.builder() .tableName(TABLE_NAME) .key(key) @@ -237,42 +233,42 @@ public void updateStreak(String gameSessionId, String userId, int streak) { .expressionAttributeNames(expressionNames) .expressionAttributeValues(expressionValues) .build(); - + AwsClients.dynamoDb().updateItem(updateRequest); logger.info("Updated streak: gameSession={}, userId={}, streak={}", gameSessionId, userId, streak); } - + /** * 힌트 사용 처리 */ public void markHintUsed(String gameSessionId) { Map key = buildKey(gameSessionId); - + Map expressionValues = new HashMap<>(); expressionValues.put(":hintUsed", AttributeValue.builder().bool(true).build()); - + UpdateItemRequest updateRequest = UpdateItemRequest.builder() .tableName(TABLE_NAME) .key(key) .updateExpression("SET hintUsed = :hintUsed") .expressionAttributeValues(expressionValues) .build(); - + AwsClients.dynamoDb().updateItem(updateRequest); logger.info("Marked hint used for game session: {}", gameSessionId); } - + /** * 게임 종료 처리 */ public void finishGame(String gameSessionId, long endedAt, long ttl) { Map key = buildKey(gameSessionId); - + Map expressionValues = new HashMap<>(); expressionValues.put(":status", AttributeValue.builder().s("FINISHED").build()); expressionValues.put(":endedAt", AttributeValue.builder().n(String.valueOf(endedAt)).build()); expressionValues.put(":ttl", AttributeValue.builder().n(String.valueOf(ttl)).build()); - + UpdateItemRequest updateRequest = UpdateItemRequest.builder() .tableName(TABLE_NAME) .key(key) @@ -280,11 +276,11 @@ public void finishGame(String gameSessionId, long endedAt, long ttl) { .expressionAttributeNames(Map.of("#status", "status", "#ttl", "ttl")) .expressionAttributeValues(expressionValues) .build(); - + AwsClients.dynamoDb().updateItem(updateRequest); logger.info("Finished game session: {}", gameSessionId); } - + /** * DynamoDB 키 빌더 헬퍼 */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/RoomTokenRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/RoomTokenRepository.java index f4b39b96..7ddadc5f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/RoomTokenRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/RoomTokenRepository.java @@ -18,14 +18,14 @@ public class RoomTokenRepository { private static final String TABLE_NAME = EnvConfig.getRequired("CHAT_TABLE_NAME"); private final DynamoDbTable table; - + /** * 기본 생성자 (Lambda에서 사용) */ public RoomTokenRepository() { this(AwsClients.dynamoDbEnhanced()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatMessageService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatMessageService.java index e0b44317..f601ceed 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatMessageService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatMessageService.java @@ -13,14 +13,14 @@ public class ChatMessageService { private static final Logger logger = LoggerFactory.getLogger(ChatMessageService.class); private final ChatMessageRepository repository; - + /** * 기본 생성자 (Lambda에서 사용) */ public ChatMessageService() { this(new ChatMessageRepository()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomCommandService.java index 90a4d594..6308a76c 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomCommandService.java @@ -18,11 +18,7 @@ import org.slf4j.LoggerFactory; import java.time.Instant; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.UUID; +import java.util.*; /** * ChatRoom 변경 전용 서비스 (CQRS Command) @@ -30,13 +26,13 @@ public class ChatRoomCommandService { private static final Logger logger = LoggerFactory.getLogger(ChatRoomCommandService.class); - + private final ChatRoomRepository roomRepository; private final RoomTokenService roomTokenService; private final ConnectionRepository connectionRepository; private final WebSocketBroadcaster broadcaster; private final UserRepository userRepository; - + /** * 기본 생성자 (Lambda에서 사용) */ @@ -44,7 +40,7 @@ public ChatRoomCommandService() { this(new ChatRoomRepository(), new RoomTokenService(), new ConnectionRepository(), new WebSocketBroadcaster(), new UserRepository()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ @@ -65,13 +61,13 @@ public ChatRoom createRoom(String name, String description, String level, Intege String type, String gameType, GameSettings gameSettings) { String roomId = UUID.randomUUID().toString(); String now = Instant.now().toString(); - + // GSI1SK 포맷: {type}#{gameType}#{status}#{level}#{createdAt} String roomType = type != null ? type : "CHAT"; String roomGameType = gameType != null ? gameType : "-"; String roomStatus = "WAITING"; String gsi1sk = String.format("%s#%s#%s#%s#%s", roomType, roomGameType, roomStatus, level, now); - + ChatRoom room = ChatRoom.builder() .pk("ROOM#" + roomId) .sk("METADATA") @@ -95,10 +91,10 @@ public ChatRoom createRoom(String name, String description, String level, Intege .status("WAITING") .hostId(createdBy) .build(); - + roomRepository.save(room); logger.info("Created room: {}", roomId); - + return room; } @@ -144,26 +140,26 @@ public LeaveResult leaveRoom(String roomId, String userId) { if (optRoom.isEmpty()) { throw ChattingException.roomNotFound(roomId); } - + ChatRoom room = optRoom.get(); - + if (room.getMemberIds() != null) { room.getMemberIds().remove(userId); room.setCurrentMembers(Math.max(0, room.getCurrentMembers() - 1)); } - + // 모든 참가자가 나갔으면 방 삭제 if (room.getCurrentMembers() <= 0 || - (room.getMemberIds() != null && room.getMemberIds().isEmpty())) { + (room.getMemberIds() != null && room.getMemberIds().isEmpty())) { roomRepository.delete(roomId); logger.info("Room {} deleted (empty)", roomId); return new LeaveResult(true, null, null); } - + // 방장이 나갔으면 다음 멤버에게 방장 이전 String oldHostId = room.getHostId() != null ? room.getHostId() : room.getCreatedBy(); String newHostId = null; - + if (userId.equals(oldHostId)) { // 첫 번째 남은 멤버가 새 방장 if (room.getMemberIds() != null && !room.getMemberIds().isEmpty()) { @@ -172,17 +168,17 @@ public LeaveResult leaveRoom(String roomId, String userId) { logger.info("Host transferred from {} to {} in room {}", oldHostId, newHostId, roomId); } } - + roomRepository.save(room); logger.info("User {} left room {}", userId, roomId); - + // 방장이 나갔으면 다음 멤버에게 방장 이전 후 WebSocket 알림 if (userId.equals(oldHostId) && newHostId != null) { // 새 방장 닉네임 조회 String newHostNickname = userRepository.findByCognitoSub(newHostId) .map(User::getNickname) .orElse(newHostId); - + // WebSocket 알림 브로드캐스트 try { List connections = connectionRepository.findByRoomId(roomId); @@ -195,7 +191,7 @@ public LeaveResult leaveRoom(String roomId, String userId) { logger.error("Failed to broadcast host change: {}", e.getMessage()); } } - + return new LeaveResult(false, room, newHostId); } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomQueryService.java index 68af7c1f..247d8476 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomQueryService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomQueryService.java @@ -18,17 +18,17 @@ public class ChatRoomQueryService { private static final Logger logger = LoggerFactory.getLogger(ChatRoomQueryService.class); - + private final ChatRoomRepository roomRepository; private final UserRepository userRepository; - + /** * 기본 생성자 (Lambda에서 사용) */ public ChatRoomQueryService() { this(new ChatRoomRepository(), new UserRepository()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ @@ -63,7 +63,7 @@ public List filterByJoinedUser(List rooms, String userId) { .filter(room -> room.getMemberIds() != null && room.getMemberIds().contains(userId)) .toList(); } - + /** * 참가자 목록을 닉네임과 함께 조회 * @@ -72,9 +72,9 @@ public List filterByJoinedUser(List rooms, String userId) { */ public List getParticipantsWithNicknames(ChatRoom room) { if (room.getMemberIds() == null) return List.of(); - + String hostId = room.getHostId() != null ? room.getHostId() : room.getCreatedBy(); - + return room.getMemberIds().stream() .map(userId -> { String nickname = userRepository.findByCognitoSub(userId) @@ -88,7 +88,7 @@ public List getParticipantsWithNicknames(ChatRoom room) { }) .toList(); } - + /** * 방장 닉네임 조회 * diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/CommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/CommandService.java index d4cf0148..71e0ddf2 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/CommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/CommandService.java @@ -18,18 +18,18 @@ public class CommandService { private static final Logger logger = LoggerFactory.getLogger(CommandService.class); - + private final ConnectionRepository connectionRepository; private final GameSessionRepository gameSessionRepository; private final GameService gameService; - + /** * 기본 생성자 (Lambda에서 사용) */ public CommandService() { this(new ConnectionRepository(), new GameSessionRepository(), new GameService()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ @@ -90,21 +90,21 @@ private CommandResult handleMemberCommand(String roomId) { */ private CommandResult handleStartCommand(String roomId, String userId) { GameService.GameStartResult result = gameService.startGame(roomId, userId); - + if (!result.success()) { return CommandResult.error(result.error()); } - + String message = String.format(""" 🎮 게임 시작! 총 %d 라운드 - + 라운드 1 시작! 출제자: %s """, result.session().getTotalRounds(), result.session().getCurrentDrawerId()); - + return CommandResult.success(MessageType.GAME_START, message, result); } @@ -123,18 +123,18 @@ private CommandResult handleScoreCommand(String roomId) { if (optSession.isEmpty()) { return CommandResult.error("진행 중인 게임이 없습니다."); } - + GameSession session = optSession.get(); - + if (session.getScores() == null || session.getScores().isEmpty()) { return CommandResult.success(MessageType.SCORE_UPDATE, "아직 점수가 없습니다."); } - + StringBuilder sb = new StringBuilder("📊 현재 점수:\n"); session.getScores().entrySet().stream() .sorted((a, b) -> b.getValue().compareTo(a.getValue())) .forEach(entry -> sb.append(String.format(" %s: %d점\n", entry.getKey(), entry.getValue()))); - + return CommandResult.success(MessageType.SCORE_UPDATE, sb.toString(), session.getScores()); } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameSchedulerClient.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameSchedulerClient.java index ab2d9f53..9a8e96c9 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameSchedulerClient.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameSchedulerClient.java @@ -15,16 +15,16 @@ * EventBridge Scheduler를 사용한 게임 자동 종료 스케줄링 */ public class GameSchedulerClient { - + private static final Logger logger = LoggerFactory.getLogger(GameSchedulerClient.class); - + private static final String SCHEDULE_GROUP = "game-auto-close"; private static final String SCHEDULE_NAME_PREFIX = "game-close-"; - + private final SchedulerClient schedulerClient; private final String targetLambdaArn; private final String roleArn; - + /** * 기본 생성자 (Lambda에서 사용) */ @@ -33,7 +33,7 @@ public GameSchedulerClient() { EnvConfig.getOrDefault("GAME_AUTO_CLOSE_LAMBDA_ARN", null), EnvConfig.getOrDefault("SCHEDULER_ROLE_ARN", null)); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ @@ -42,7 +42,7 @@ public GameSchedulerClient(SchedulerClient schedulerClient, String targetLambdaA this.targetLambdaArn = targetLambdaArn; this.roleArn = roleArn; } - + /** * 게임 자동 종료 스케줄 생성 * @@ -55,22 +55,22 @@ public ScheduleResult createGameEndSchedule(String gameSessionId, String roomId) logger.warn("Scheduler not configured: GAME_AUTO_CLOSE_LAMBDA_ARN or SCHEDULER_ROLE_ARN not set"); return new ScheduleResult(null, 0L); } - + try { // 7분 후 시간 계산 long scheduledAtMs = System.currentTimeMillis() + (GameConfig.gameTimeLimit() * 1000L); Instant scheduledAt = Instant.ofEpochMilli(scheduledAtMs); - + // at() 표현식: at(yyyy-mm-ddThh:mm:ss) String atExpression = "at(" + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss") .withZone(ZoneOffset.UTC) .format(scheduledAt) + ")"; - + String scheduleName = SCHEDULE_NAME_PREFIX + gameSessionId; - + // Lambda 호출 시 전달할 페이로드 String payload = String.format("{\"gameSessionId\":\"%s\",\"roomId\":\"%s\"}", gameSessionId, roomId); - + CreateScheduleRequest request = CreateScheduleRequest.builder() .name(scheduleName) .groupName(SCHEDULE_GROUP) @@ -86,25 +86,25 @@ public ScheduleResult createGameEndSchedule(String gameSessionId, String roomId) .build()) .actionAfterCompletion(ActionAfterCompletion.DELETE) // 실행 후 자동 삭제 .build(); - + CreateScheduleResponse response = schedulerClient.createSchedule(request); - + logger.info("Game end schedule created: gameSessionId={}, scheduledAt={}, arn={}", gameSessionId, scheduledAt, response.scheduleArn()); - + return new ScheduleResult(response.scheduleArn(), scheduledAtMs); - + } catch (ConflictException e) { logger.warn("Schedule already exists: gameSessionId={}", gameSessionId); return new ScheduleResult(null, 0L); - + } catch (Exception e) { logger.error("Failed to create game end schedule: gameSessionId={}, error={}", gameSessionId, e.getMessage()); return new ScheduleResult(null, 0L); } } - + /** * 게임 자동 종료 스케줄 취소 * @@ -115,31 +115,31 @@ public boolean cancelGameEndSchedule(String gameSessionId) { if (targetLambdaArn == null) { return true; // 스케줄러 미설정 시 무시 } - + try { String scheduleName = SCHEDULE_NAME_PREFIX + gameSessionId; - + DeleteScheduleRequest request = DeleteScheduleRequest.builder() .name(scheduleName) .groupName(SCHEDULE_GROUP) .build(); - + schedulerClient.deleteSchedule(request); - + logger.info("Game end schedule cancelled: gameSessionId={}", gameSessionId); return true; - + } catch (ResourceNotFoundException e) { logger.debug("Schedule not found (may have already executed): gameSessionId={}", gameSessionId); return true; // 이미 삭제되었거나 없는 경우 - + } catch (Exception e) { logger.error("Failed to cancel game end schedule: gameSessionId={}, error={}", gameSessionId, e.getMessage()); return false; } } - + /** * 스케줄 생성 결과 */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java index 3ca83cff..a82c16d2 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java @@ -27,9 +27,9 @@ * GameSession 모델을 사용하여 게임 상태 관리 */ public class GameService { - + private static final Logger logger = LoggerFactory.getLogger(GameService.class); - + private final ChatRoomRepository chatRoomRepository; private final ConnectionRepository connectionRepository; private final GameRoundRepository gameRoundRepository; @@ -37,7 +37,7 @@ public class GameService { private final WordRepository wordRepository; private final GameStatsService gameStatsService; private final GameSchedulerClient gameSchedulerClient; - + /** * 기본 생성자 (Lambda에서 사용) */ @@ -46,7 +46,7 @@ public GameService() { new GameRoundRepository(), new GameSessionRepository(), new WordRepository(), new GameStatsService(), new GameSchedulerClient()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ @@ -62,87 +62,87 @@ public GameService(ChatRoomRepository chatRoomRepository, ConnectionRepository c this.gameStatsService = gameStatsService; this.gameSchedulerClient = gameSchedulerClient; } - + /** * 게임 재시작 */ public GameStartResult restartGame(String roomId, String userId) { ChatRoom room = chatRoomRepository.findById(roomId) .orElseThrow(() -> new IllegalArgumentException("채팅방을 찾을 수 없습니다.")); - + // 방장 권한 확인 if (!userId.equals(room.getHostId()) && !userId.equals(room.getCreatedBy())) { return GameStartResult.error("방장만 게임을 시작할 수 있습니다."); } - + // 방 타입 검증 if (room.getType() == null || !"GAME".equalsIgnoreCase(room.getType())) { return GameStartResult.error("게임은 게임 방에서만 시작할 수 있습니다."); } - + // FINISHED 상태인지 확인 (이미 게임이 끝났어야 재시작 가능) Optional existingSession = gameSessionRepository.findActiveByRoomId(roomId); if (existingSession.isPresent()) { return GameStartResult.error("게임 진행 중에는 재시작할 수 없습니다."); } - + // 접속자 확인 List connections = connectionRepository.findByRoomId(roomId); if (connections.size() < 2) { return GameStartResult.error("최소 2명 이상 접속해야 게임을 시작할 수 있습니다."); } - + // 기존 startGame 로직 재사용 - 내부적으로 startGame 호출 return startGame(roomId, userId); } - + /** * 게임 시작 */ public GameStartResult startGame(String roomId, String userId) { ChatRoom room = chatRoomRepository.findById(roomId) .orElseThrow(() -> new IllegalArgumentException("채팅방을 찾을 수 없습니다.")); - + // 방 타입 검증 - GAME 타입만 게임 시작 가능 String roomType = room.getType(); if (roomType == null || !"GAME".equalsIgnoreCase(roomType)) { return GameStartResult.error("게임은 게임 방에서만 시작할 수 있습니다."); } - + // 이미 활성 게임 세션이 있는지 확인 Optional existingSession = gameSessionRepository.findActiveByRoomId(roomId); if (existingSession.isPresent()) { return GameStartResult.error("이미 게임이 진행 중입니다."); } - + // 접속자 확인 List connections = connectionRepository.findByRoomId(roomId); if (connections.size() < 2) { return GameStartResult.error("최소 2명 이상 접속해야 게임을 시작할 수 있습니다."); } - + // 출제 순서 생성 (랜덤 셔플) List drawerOrder = connections.stream() .map(Connection::getUserId) .collect(Collectors.toList()); Collections.shuffle(drawerOrder); - + // 제시어 추출 (난이도별) String level = room.getLevel() != null ? room.getLevel() : "beginner"; List words = getRandomWords(level, GameConfig.totalRounds()); - + if (words.size() < GameConfig.totalRounds()) { return GameStartResult.error("단어가 부족합니다. 관리자에게 문의하세요."); } - + // 게임 세션 생성 String gameSessionId = UUID.randomUUID().toString(); String now = Instant.now().toString(); long currentTime = System.currentTimeMillis(); - + String firstDrawer = drawerOrder.get(0); Word firstWord = words.get(0); - + GameSession session = GameSession.builder() .pk("GAME#" + gameSessionId) .sk("METADATA") @@ -169,9 +169,9 @@ public GameStartResult startGame(String roomId, String userId) { .hintUsed(false) .correctGuessers(new ArrayList<>()) .build(); - + gameSessionRepository.save(session); - + // 게임 자동 종료 스케줄 생성 (7분 후) GameSchedulerClient.ScheduleResult scheduleResult = gameSchedulerClient.createGameEndSchedule(gameSessionId, roomId); if (scheduleResult.success()) { @@ -179,11 +179,11 @@ public GameStartResult startGame(String roomId, String userId) { session.setGameEndScheduledAt(scheduleResult.scheduledAtMs()); gameSessionRepository.save(session); } - + // ChatRoom에 활성 게임 세션 ID 연결 및 상태 업데이트 (GSI1SK 포함) room.setActiveGameSessionId(gameSessionId); chatRoomRepository.updateStatus(room, "PLAYING"); - + // 첫 라운드 기록 생성 (7일 후 자동 삭제) long ttlSeconds = Instant.now().plusSeconds(7 * 24 * 60 * 60).getEpochSecond(); GameRound firstRound = GameRound.builder() @@ -203,162 +203,162 @@ public GameStartResult startGame(String roomId, String userId) { .createdAt(now) .ttl(ttlSeconds) .build(); - + gameRoundRepository.save(firstRound); - + logger.info("Game started: roomId={}, sessionId={}, starter={}, rounds={}", roomId, gameSessionId, userId, GameConfig.totalRounds()); - + return GameStartResult.success(session, firstWord, drawerOrder); } - + /** * 게임 종료 */ public CommandResult stopGame(String roomId, String userId) { ChatRoom room = chatRoomRepository.findById(roomId) .orElseThrow(() -> new IllegalArgumentException("채팅방을 찾을 수 없습니다.")); - + GameSession session = gameSessionRepository.findActiveByRoomId(roomId) .orElse(null); - + if (session == null || !session.isActive()) { return CommandResult.error("진행 중인 게임이 없습니다."); } - + // 권한 확인 boolean isOwner = userId.equals(room.getCreatedBy()); boolean isGameStarter = userId.equals(session.getStartedBy()); - + if (!isOwner && !isGameStarter) { return CommandResult.error("게임을 중단할 권한이 없습니다."); } - + // 게임 종료 처리 return finishGame(session, room, "STOPPED"); } - + /** * 정답 체크 */ public AnswerCheckResult checkAnswer(String roomId, String userId, String answer) { GameSession session = gameSessionRepository.findActiveByRoomId(roomId) .orElse(null); - + // 게임 진행 중인지 확인 if (session == null || !GameStatus.PLAYING.name().equals(session.getStatus())) { return AnswerCheckResult.gameNotPlaying(); } - + // 출제자는 정답 체크 제외 if (session.isDrawer(userId)) { return AnswerCheckResult.drawerCannotGuess(); } - + // 이미 맞춘 사람인지 확인 if (session.hasAlreadyGuessedCorrect(userId)) { return AnswerCheckResult.alreadyGuessedCorrect(); } - + // 정답 체크 (한국어 또는 영어 둘 다 허용) String koreanWord = session.getCurrentWord(); String englishWord = session.getCurrentWordEnglish(); if (!isCorrectAnswer(answer, koreanWord, englishWord)) { return AnswerCheckResult.wrongAnswer(); } - + // 정답 처리 long elapsedTime = System.currentTimeMillis() - session.getRoundStartTime(); - + // 연속 정답 업데이트 (점수 계산 전에) int currentStreak = session.incrementStreak(userId); - + int score = calculateScore(session, elapsedTime, userId, currentStreak); - + // 정답자 목록에 추가 session.addCorrectGuesser(userId); - + // 점수 업데이트 session.addScore(userId, score); - + // 출제자 점수도 추가 session.addScore(session.getCurrentDrawerId(), 5); - + gameSessionRepository.save(session); - + // 라운드 기록 업데이트 updateRoundRecord(roomId, session.getCurrentRound(), userId, elapsedTime, score); - + // 전원 정답 체크 List connections = connectionRepository.findByRoomId(roomId); int nonDrawerCount = (int) connections.stream() .filter(c -> !c.getUserId().equals(session.getCurrentDrawerId())) .count(); - + boolean allCorrect = session.getCorrectGuessers().size() >= nonDrawerCount; - + logger.info("Answer correct: roomId={}, userId={}, score={}, allCorrect={}", roomId, userId, score, allCorrect); - + return AnswerCheckResult.correctAnswer(score, elapsedTime, allCorrect, session.getScores()); } - + /** * 라운드 스킵 */ public CommandResult skipRound(String roomId, String userId) { ChatRoom room = chatRoomRepository.findById(roomId) .orElseThrow(() -> new IllegalArgumentException("채팅방을 찾을 수 없습니다.")); - + GameSession session = gameSessionRepository.findActiveByRoomId(roomId) .orElse(null); - + if (session == null || !GameStatus.PLAYING.name().equals(session.getStatus())) { return CommandResult.error("게임이 진행 중이 아닙니다."); } - + if (!session.isDrawer(userId)) { return CommandResult.error("출제자만 라운드를 스킵할 수 있습니다."); } - + return endRound(session, room, "SKIP"); } - + /** * 힌트 제공 */ public CommandResult provideHint(String roomId, String userId) { GameSession session = gameSessionRepository.findActiveByRoomId(roomId) .orElse(null); - + if (session == null || !GameStatus.PLAYING.name().equals(session.getStatus())) { return CommandResult.error("게임이 진행 중이 아닙니다."); } - + if (!session.isDrawer(userId)) { return CommandResult.error("출제자만 힌트를 제공할 수 있습니다."); } - + if (Boolean.TRUE.equals(session.getHintUsed())) { return CommandResult.error("이번 라운드에서 이미 힌트를 사용했습니다."); } - + String currentWord = session.getCurrentWord(); String hint = currentWord.charAt(0) + "○".repeat(currentWord.length() - 1); - + session.setHintUsed(true); gameSessionRepository.save(session); - + // 라운드 기록 업데이트 gameRoundRepository.findByRoomIdAndRound(roomId, session.getCurrentRound()) .ifPresent(round -> { round.setHintUsed(true); gameRoundRepository.save(round); }); - + return CommandResult.success(MessageType.HINT, "💡 힌트: " + hint); } - + /** * 라운드 종료 처리 (GameSession 버전) */ @@ -366,10 +366,10 @@ public CommandResult endRound(GameSession session, ChatRoom room, String reason) String roomId = session.getRoomId(); Integer currentRound = session.getCurrentRound(); String answer = session.getCurrentWord(); - + // 정답 못 맞춘 사용자 연속 정답 초기화 resetStreaksForNonGuessers(session); - + // 라운드 기록 종료 gameRoundRepository.findByRoomIdAndRound(roomId, currentRound) .ifPresent(round -> { @@ -377,27 +377,27 @@ public CommandResult endRound(GameSession session, ChatRoom room, String reason) round.setEndReason(reason); gameRoundRepository.save(round); }); - + // 다음 라운드로 진행 if (currentRound >= session.getTotalRounds()) { return finishGame(session, room, "COMPLETED"); } - + // 현재 접속 중인 사용자 목록 조회 List connections = connectionRepository.findByRoomId(roomId); Set connectedUserIds = connections.stream() .map(Connection::getUserId) .collect(Collectors.toSet()); - + // 접속자가 2명 미만이면 게임 종료 if (connectedUserIds.size() < 2) { return finishGame(session, room, "NOT_ENOUGH_PLAYERS"); } - + // 다음 라운드 준비 - 접속 중인 사용자 중에서만 출제자 선택 int nextRound = currentRound + 1; String nextDrawer = selectNextDrawer(session.getDrawerOrder(), connectedUserIds, nextRound); - + // 다음 단어 추출 String level = room.getLevel() != null ? room.getLevel() : "beginner"; List words = getRandomWords(level, 1); @@ -405,9 +405,9 @@ public CommandResult endRound(GameSession session, ChatRoom room, String reason) return finishGame(session, room, "NO_WORDS"); } Word nextWord = words.get(0); - + long currentTime = System.currentTimeMillis(); - + // 세션 상태 업데이트 session.setCurrentRound(nextRound); session.setCurrentDrawerId(nextDrawer); @@ -417,9 +417,9 @@ public CommandResult endRound(GameSession session, ChatRoom room, String reason) session.setRoundStartTime(currentTime); session.setHintUsed(false); session.setCorrectGuessers(new ArrayList<>()); - + gameSessionRepository.save(session); - + // 다음 라운드 기록 생성 (7일 후 자동 삭제) long nextTtlSeconds = Instant.now().plusSeconds(7 * 24 * 60 * 60).getEpochSecond(); GameRound nextRoundRecord = GameRound.builder() @@ -439,17 +439,17 @@ public CommandResult endRound(GameSession session, ChatRoom room, String reason) .createdAt(Instant.now().toString()) .ttl(nextTtlSeconds) .build(); - + gameRoundRepository.save(nextRoundRecord); - + String message = String.format("라운드 %d 종료! 정답: %s\n\n라운드 %d 시작! 출제자: %s", currentRound, answer, nextRound, nextDrawer); - + logger.info("Round ended: roomId={}, round={}, reason={}", roomId, currentRound, reason); - + // ranking 생성 List> ranking = buildRankingList(session.getScores()); - + Map data = new HashMap<>(); data.put("answer", answer); data.put("nextRound", nextRound); @@ -461,46 +461,46 @@ public CommandResult endRound(GameSession session, ChatRoom room, String reason) // 타이머 동기화용 필드 추가 data.put("roundStartTime", session.getRoundStartTime()); data.put("roundDuration", session.getRoundDuration() != null ? session.getRoundDuration() : GameConfig.roundTimeLimit()); - + return CommandResult.success(MessageType.ROUND_END, message, data); } - + /** * roomId로 활성 세션을 찾아 라운드 종료 (외부 호출용) */ public CommandResult endRound(String roomId, String reason) { ChatRoom room = chatRoomRepository.findById(roomId) .orElseThrow(() -> new IllegalArgumentException("채팅방을 찾을 수 없습니다.")); - + GameSession session = gameSessionRepository.findActiveByRoomId(roomId) .orElse(null); - + if (session == null) { return CommandResult.error("진행 중인 게임이 없습니다."); } - + return endRound(session, room, reason); } - + /** * 게임 완전 종료 */ private CommandResult finishGame(GameSession session, ChatRoom room, String reason) { long currentTime = System.currentTimeMillis(); long ttlSeconds = Instant.now().plusSeconds(30 * 24 * 60 * 60).getEpochSecond(); // 30일 보관 - + // 자동 종료 스케줄 취소 (TIME_EXPIRED가 아닌 경우에만) if (!"TIME_EXPIRED".equals(reason)) { gameSchedulerClient.cancelGameEndSchedule(session.getGameSessionId()); } - + // 게임 세션 종료 처리 gameSessionRepository.finishGame(session.getGameSessionId(), currentTime, ttlSeconds); - + // ChatRoom에서 활성 게임 세션 참조 제거 및 상태 업데이트 (GSI1SK 포함) room.setActiveGameSessionId(null); chatRoomRepository.updateStatus(room, "WAITING"); - + // 게임 통계 업데이트 및 뱃지 체크 try { var newBadges = gameStatsService.updateGameStats(session); @@ -508,14 +508,14 @@ private CommandResult finishGame(GameSession session, ChatRoom room, String reas } catch (Exception e) { logger.error("Failed to update game stats: roomId={}, error={}", room.getRoomId(), e.getMessage()); } - + // 최종 점수 정렬 StringBuilder sb = new StringBuilder("🎮 게임 종료!\n\n📊 최종 순위:\n"); if (session.getScores() != null && !session.getScores().isEmpty()) { List> sorted = session.getScores().entrySet().stream() .sorted((a, b) -> b.getValue().compareTo(a.getValue())) .toList(); - + int rank = 1; for (Map.Entry entry : sorted) { String medal = switch (rank) { @@ -530,13 +530,13 @@ private CommandResult finishGame(GameSession session, ChatRoom room, String reas } else { sb.append(" 점수 없음"); } - + logger.info("Game finished: roomId={}, sessionId={}, reason={}", room.getRoomId(), session.getGameSessionId(), reason); - + return CommandResult.success(MessageType.GAME_END, sb.toString(), session.getScores()); } - + /** * 시간 만료로 인한 게임 자동 종료 (GameAutoCloseHandler에서 호출) */ @@ -546,32 +546,32 @@ public CommandResult finishGameByTimeout(String gameSessionId) { logger.warn("Game session not found for auto-close: {}", gameSessionId); return CommandResult.error("게임 세션을 찾을 수 없습니다."); } - + // 이미 종료된 게임이면 무시 if (!session.isActive()) { logger.info("Game already finished, skipping auto-close: {}", gameSessionId); return CommandResult.error("이미 종료된 게임입니다."); } - + ChatRoom room = chatRoomRepository.findById(session.getRoomId()).orElse(null); if (room == null) { logger.warn("Room not found for auto-close: {}", session.getRoomId()); return CommandResult.error("채팅방을 찾을 수 없습니다."); } - + logger.info("Auto-closing game due to time expiration: sessionId={}, roomId={}", gameSessionId, session.getRoomId()); - + return finishGame(session, room, "TIME_EXPIRED"); } - + /** * 접속 중인 사용자 중에서 다음 출제자 선택 */ private String selectNextDrawer(List drawerOrder, Set connectedUserIds, int roundNumber) { // 원래 순서에서 시작 인덱스 계산 int startIndex = (roundNumber - 1) % drawerOrder.size(); - + // 접속 중인 사용자를 찾을 때까지 순회 for (int i = 0; i < drawerOrder.size(); i++) { int index = (startIndex + i) % drawerOrder.size(); @@ -580,11 +580,11 @@ private String selectNextDrawer(List drawerOrder, Set connectedU return candidate; } } - + // 원래 순서에 있는 사람이 모두 나갔으면, 접속 중인 아무나 선택 return connectedUserIds.iterator().next(); } - + /** * 랜덤 단어 추출 * VocabTable은 LEVEL#BEGINNER 형식(대문자)으로 저장되어 있으므로 @@ -598,15 +598,15 @@ private List getRandomWords(String level, int count) { Collections.shuffle(words); return words.stream().limit(count).collect(Collectors.toList()); } - + /** * 정답 체크 로직 (한국어 또는 영어 둘 다 허용) */ private boolean isCorrectAnswer(String input, String koreanAnswer, String englishAnswer) { if (input == null) return false; - + String normalizedInput = input.trim().toLowerCase().replace(" ", ""); - + // 한국어 정답 체크 if (koreanAnswer != null) { String normalizedKorean = koreanAnswer.trim().toLowerCase().replace(" ", ""); @@ -614,7 +614,7 @@ private boolean isCorrectAnswer(String input, String koreanAnswer, String englis return true; } } - + // 영어 정답 체크 if (englishAnswer != null) { String normalizedEnglish = englishAnswer.trim().toLowerCase().replace(" ", ""); @@ -622,30 +622,30 @@ private boolean isCorrectAnswer(String input, String koreanAnswer, String englis return true; } } - + return false; } - + /** * 점수 계산 */ private int calculateScore(GameSession session, long elapsedTimeMs, String userId, int streak) { int baseScore = 10; - + // 시간 보너스 (빨리 맞출수록 높은 점수) int elapsedSeconds = (int) (elapsedTimeMs / 1000); int timeLimit = session.getRoundDuration() != null ? session.getRoundDuration() : GameConfig.roundTimeLimit(); int timeBonus = Math.max(0, (int) ((timeLimit - elapsedSeconds) * 0.5)); - + // 연속 정답 보너스 int streakBonus = streak * 2; - + logger.info("Score calculation: base={}, timeBonus={}, streakBonus={}, total={}", baseScore, timeBonus, streakBonus, baseScore + timeBonus + streakBonus); - + return baseScore + timeBonus + streakBonus; } - + /** * 라운드 기록 업데이트 */ @@ -656,21 +656,21 @@ private void updateRoundRecord(String roomId, Integer roundNumber, String userId round.setCorrectGuessers(new ArrayList<>()); } round.getCorrectGuessers().add(userId); - + if (round.getGuessTimes() == null) { round.setGuessTimes(new HashMap<>()); } round.getGuessTimes().put(userId, elapsedTime); - + if (round.getRoundScores() == null) { round.setRoundScores(new HashMap<>()); } round.getRoundScores().put(userId, score); - + gameRoundRepository.save(round); }); } - + /** * 정답 못 맞춘 사용자 연속 정답 초기화 */ @@ -678,19 +678,19 @@ private void resetStreaksForNonGuessers(GameSession session) { if (session.getStreaks() == null || session.getStreaks().isEmpty()) { return; } - + List correctGuessers = session.getCorrectGuessers() != null ? session.getCorrectGuessers() : List.of(); - + // 정답 못 맞춘 사용자의 연속 정답 초기화 session.getStreaks().keySet().stream() .filter(userId -> !correctGuessers.contains(userId)) .forEach(userId -> session.getStreaks().put(userId, 0)); - + logger.info("Reset streaks for non-guessers: correctGuessers={}", correctGuessers); } - + /** * 점수 맵을 순위 리스트로 변환 */ @@ -698,11 +698,11 @@ private List> buildRankingList(Map scores) if (scores == null || scores.isEmpty()) { return List.of(); } - + List> sorted = scores.entrySet().stream() .sorted(Map.Entry.comparingByValue().reversed()) .toList(); - + List> ranking = new ArrayList<>(); for (int i = 0; i < sorted.size(); i++) { Map entry = new HashMap<>(); @@ -713,9 +713,9 @@ private List> buildRankingList(Map scores) } return ranking; } - + // ========== Result DTOs ========== - + public record GameStartResult( boolean success, String error, @@ -726,12 +726,12 @@ public record GameStartResult( public static GameStartResult success(GameSession session, Word word, List order) { return new GameStartResult(true, null, session, word, order); } - + public static GameStartResult error(String message) { return new GameStartResult(false, message, null, null, null); } } - + public record AnswerCheckResult( boolean correct, boolean drawer, @@ -745,19 +745,19 @@ public record AnswerCheckResult( public static AnswerCheckResult correctAnswer(int score, long elapsed, boolean allCorrect, Map scores) { return new AnswerCheckResult(true, false, false, false, allCorrect, score, elapsed, scores); } - + public static AnswerCheckResult wrongAnswer() { return new AnswerCheckResult(false, false, false, false, false, 0, 0, null); } - + public static AnswerCheckResult drawerCannotGuess() { return new AnswerCheckResult(false, true, false, false, false, 0, 0, null); } - + public static AnswerCheckResult alreadyGuessedCorrect() { return new AnswerCheckResult(false, false, true, false, false, 0, 0, null); } - + public static AnswerCheckResult gameNotPlaying() { return new AnswerCheckResult(false, false, false, true, false, 0, 0, null); } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameStatsService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameStatsService.java index 4ff2e235..be700975 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameStatsService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameStatsService.java @@ -3,8 +3,8 @@ import com.mzc.secondproject.serverless.domain.badge.model.UserBadge; import com.mzc.secondproject.serverless.domain.badge.service.BadgeService; import com.mzc.secondproject.serverless.domain.chatting.config.GameConfig; -import com.mzc.secondproject.serverless.domain.chatting.model.GameSession; import com.mzc.secondproject.serverless.domain.chatting.model.GameRound; +import com.mzc.secondproject.serverless.domain.chatting.model.GameSession; import com.mzc.secondproject.serverless.domain.chatting.repository.GameRoundRepository; import com.mzc.secondproject.serverless.domain.stats.model.UserStats; import com.mzc.secondproject.serverless.domain.stats.repository.UserStatsRepository; @@ -23,14 +23,14 @@ public class GameStatsService { private final UserStatsRepository userStatsRepository; private final GameRoundRepository gameRoundRepository; private final BadgeService badgeService; - + /** * 기본 생성자 (Lambda에서 사용) */ public GameStatsService() { this(new UserStatsRepository(), new GameRoundRepository(), new BadgeService()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ @@ -48,10 +48,10 @@ public GameStatsService(UserStatsRepository userStatsRepository, public Map> updateGameStats(GameSession session) { Map> newBadges = new HashMap<>(); String roomId = session.getRoomId(); - + // 모든 라운드 조회 List rounds = gameRoundRepository.findByRoomId(roomId); - + // 참가자별 통계 수집 Map scores = session.getScores() != null ? session.getScores() : Map.of(); Set participants = new HashSet<>(scores.keySet()); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/RoomTokenService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/RoomTokenService.java index 4ee6231a..38dd1b41 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/RoomTokenService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/RoomTokenService.java @@ -19,14 +19,14 @@ public class RoomTokenService { private static final Logger logger = LoggerFactory.getLogger(RoomTokenService.class); private final RoomTokenRepository tokenRepository; - + /** * 기본 생성자 (Lambda에서 사용) */ public RoomTokenService() { this(new RoomTokenRepository()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/GrammarHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/GrammarHandler.java index 9ae05c3b..284fe767 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/GrammarHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/GrammarHandler.java @@ -36,12 +36,12 @@ public class GrammarHandler implements RequestHandler * 연결 방법: * wss://{api-id}.execute-api.{region}.amazonaws.com/{stage}?token={jwt} */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/websocket/GrammarStreamingHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/websocket/GrammarStreamingHandler.java index c1d6f89e..8a0ba959 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/websocket/GrammarStreamingHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/websocket/GrammarStreamingHandler.java @@ -28,7 +28,7 @@ /** * Grammar Streaming WebSocket 핸들러 * Bedrock 스트리밍 응답을 실시간으로 클라이언트에 전송 - * + *

* 인증: $connect에서 JWT 검증 후 저장된 연결 정보에서 userId 조회 */ public class GrammarStreamingHandler implements RequestHandler, Map> { @@ -85,7 +85,7 @@ public Map handleRequest(Map event, Context cont private void processStreamingConversation(String connectionId, String endpoint, String userId, StreamingRequest request) { ApiGatewayManagementApiClient apiClient = createApiClient(endpoint); - + try { // 서비스에 스트리밍 처리 위임 (userId는 JWT 인증에서 가져온 값 사용) conversationService.chatStreaming( @@ -101,14 +101,14 @@ private void processStreamingConversation(String connectionId, String endpoint, public void onToken(String token) { sendEvent(apiClient, connectionId, new StreamingEvent.TokenEvent(token)); } - + @Override public void onComplete(ConversationResponse response) { sendEvent(apiClient, connectionId, StreamingEvent.CompleteEvent.from(response)); logger.info("Streaming completed for session: {}", response.getSessionId()); closeApiClient(apiClient); } - + @Override public void onError(Throwable error) { logger.error("Streaming error: {}", error.getMessage(), error); @@ -122,7 +122,7 @@ public void onError(Throwable error) { throw e; } } - + private void closeApiClient(ApiGatewayManagementApiClient apiClient) { try { if (apiClient != null) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/repository/GrammarConnectionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/repository/GrammarConnectionRepository.java index b1303cb5..b9adc9aa 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/repository/GrammarConnectionRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/repository/GrammarConnectionRepository.java @@ -21,14 +21,14 @@ public class GrammarConnectionRepository { private static final String TABLE_NAME = EnvConfig.getRequired("CHAT_TABLE_NAME"); private final DynamoDbTable table; - + /** * 기본 생성자 (Lambda에서 사용) */ public GrammarConnectionRepository() { this(AwsClients.dynamoDbEnhanced()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/repository/GrammarSessionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/repository/GrammarSessionRepository.java index 4c52c93c..66103008 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/repository/GrammarSessionRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/repository/GrammarSessionRepository.java @@ -29,14 +29,14 @@ public class GrammarSessionRepository { private final DynamoDbTable sessionTable; private final DynamoDbTable messageTable; - + /** * 기본 생성자 (Lambda에서 사용) */ public GrammarSessionRepository() { this(AwsClients.dynamoDbEnhanced()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/request/CreateSessionRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/request/CreateSessionRequest.java index 6dc7dc3f..d65fe836 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/request/CreateSessionRequest.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/request/CreateSessionRequest.java @@ -1,7 +1,8 @@ package com.mzc.secondproject.serverless.domain.opic.dto.request; public record CreateSessionRequest( - String topic, - String subTopic, - String targetLevel -) {} + String topic, + String subTopic, + String targetLevel +) { +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/request/SubmitAnswerRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/request/SubmitAnswerRequest.java index c425f42f..01a95852 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/request/SubmitAnswerRequest.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/request/SubmitAnswerRequest.java @@ -1,5 +1,6 @@ package com.mzc.secondproject.serverless.domain.opic.dto.request; -public record SubmitAnswerRequest ( - String audioS3Key -) {} +public record SubmitAnswerRequest( + String audioS3Key +) { +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/AnswerFeedbackResponse.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/AnswerFeedbackResponse.java index fcc6bf3b..af43e60e 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/AnswerFeedbackResponse.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/AnswerFeedbackResponse.java @@ -1,10 +1,11 @@ package com.mzc.secondproject.serverless.domain.opic.dto.response; public record AnswerFeedbackResponse( - String answerId, - String transcript, - FeedbackResponse feedback, - boolean hasNextQuestion, - Integer nextQustionNumber, - int totalQuestions -) {} + String answerId, + String transcript, + FeedbackResponse feedback, + boolean hasNextQuestion, + Integer nextQustionNumber, + int totalQuestions +) { +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/CreateSessionResponse.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/CreateSessionResponse.java index 17d1cf92..d2e3e67a 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/CreateSessionResponse.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/CreateSessionResponse.java @@ -1,7 +1,8 @@ package com.mzc.secondproject.serverless.domain.opic.dto.response; -public record CreateSessionResponse ( - String sessionId, - QuestionResponse firstQuestion, - int totalQuestions -) {} +public record CreateSessionResponse( + String sessionId, + QuestionResponse firstQuestion, + int totalQuestions +) { +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/QuestionResponse.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/QuestionResponse.java index 0ecf6230..78397faf 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/QuestionResponse.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/QuestionResponse.java @@ -1,9 +1,10 @@ package com.mzc.secondproject.serverless.domain.opic.dto.response; -public record QuestionResponse ( - String questionId, - String questionText, - String audioUrl, - int questionNumber, - int totalQuestions -) {} +public record QuestionResponse( + String questionId, + String questionText, + String audioUrl, + int questionNumber, + int totalQuestions +) { +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/handler/OPIcSessionHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/handler/OPIcSessionHandler.java index 80b34f31..0eb3b1a2 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/handler/OPIcSessionHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/handler/OPIcSessionHandler.java @@ -37,470 +37,470 @@ * - 세션 완료 (종합 리포트) */ public class OPIcSessionHandler implements RequestHandler { - - private static final Logger logger = LoggerFactory.getLogger(OPIcSessionHandler.class); - private static final Gson gson = new GsonBuilder() - .setDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'") - .registerTypeAdapter(Instant.class, new InstantTypeAdapter()) - .create(); - - private static final String OPIC_BUCKET = System.getenv("OPIC_BUCKET_NAME"); - - private final OPIcRepository repository; - private final PollyService pollyService; - private final TranscribeProxyService transcribeService; - private final FeedbackService feedbackService; - - public OPIcSessionHandler() { - this.repository = new OPIcRepository(); - this.pollyService = new PollyService(OPIC_BUCKET, "opic/voice/questions/"); - this.transcribeService = new TranscribeProxyService(); - this.feedbackService = new FeedbackService(); - } - - @Override - public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent event, Context context) { - String httpMethod = event.getHttpMethod(); - String path = event.getPath(); - - try { - - String userId = extractUserId(event); - - - // POST /opic/sessions - 세션 생성 - if ("POST".equals(httpMethod) && path.equals("/opic/sessions")) { - return createSession(event, userId); - } - - // GET /opic/sessions - 세션 목록 조회 - if ("GET".equals(httpMethod) && path.equals("/opic/sessions")) { - return getSessions(userId); - } - - // GET /opic/sessions/{sessionId} - 세션 상세 조회 - if ("GET".equals(httpMethod) && path.matches("/opic/sessions/[^/]+") - && !path.contains("/questions") && !path.contains("/upload-url")) { - return getSession(event, userId); - } - - // GET /opic/sessions/{sessionId}/questions/next - 다음 질문 조회 - if ("GET".equals(httpMethod) && path.contains("/questions/next")) { - return getNextQuestion(event, userId); - } - - // GET /opic/sessions/{sessionId}/upload-url - Presigned URL 발급 - if ("GET".equals(httpMethod) && path.contains("/upload-url")) { - return getUploadUrl(event, userId); - } - - // POST /opic/sessions/{sessionId}/answers - 답변 제출 - if ("POST".equals(httpMethod) && path.contains("/answers")) { - return submitAnswer(event, userId); - } - - // POST /opic/sessions/{sessionId}/complete - 세션 완료 - if ("POST".equals(httpMethod) && path.contains("/complete")) { - return completeSession(event, userId); - } - - return ResponseGenerator.badRequest("지원하지 않는 요청입니다: " + httpMethod + " " + path); - - } catch (Exception e) { - logger.error("OPIc Handler 에러", e); - return ResponseGenerator.serverError(e.getMessage()); - } - } - - - /** - * POST /opic/sessions - * 세션 생성 + 첫 질문 반환 - */ - private APIGatewayProxyResponseEvent createSession(APIGatewayProxyRequestEvent event, String userId) { - CreateSessionRequest request = gson.fromJson(event.getBody(), CreateSessionRequest.class); - - logger.info("세션 생성 요청: userId={}, topic={}, level={}", - userId, request.topic(), request.targetLevel()); - - // 주제 + 소주제 + 레벨로 질문 세트 조회 - List questions = repository.findQuestionsByTopicSubTopicAndLevel( - request.topic(), - request.subTopic(), - request.targetLevel() - ); - - if (questions.isEmpty()) { - return ResponseGenerator.notFound("해당 주제/레벨의 질문이 없습니다."); - } - - // 최대 3개 질문 선택 (랜덤 셔플) - Collections.shuffle(questions); - List questionIds = questions.stream() - .limit(3) - .map(OPIcQuestion::getQuestionId) - .collect(Collectors.toList()); - - // 세션 생성 - OPIcSession session = repository.createSession( - userId, - request.topic(), - request.subTopic(), - request.targetLevel(), - questionIds - ); - - // 첫 질문 Polly 음성 URL 생성 (#368 PollyService 연동) - OPIcQuestion firstQuestion = questions.get(0); - String audioUrl = generateQuestionAudioUrl(firstQuestion); - - // Response - Map response = new LinkedHashMap<>(); - response.put("sessionId", session.getSessionId()); - response.put("totalQuestions", session.getTotalQuestions()); - response.put("firstQuestion", Map.of( - "questionId", firstQuestion.getQuestionId(), - "questionText", firstQuestion.getQuestionText(), - "audioUrl", audioUrl, - "questionNumber", 1, - "totalQuestions", session.getTotalQuestions() - )); - - logger.info("세션 생성 완료: sessionId={}", session.getSessionId()); - return ResponseGenerator.created("세션이 생성되었습니다.", response); - } - - /** - * GET /opic/sessions - * 사용자의 세션 목록 조회 - */ - private APIGatewayProxyResponseEvent getSessions(String userId) { - List sessions = repository.findSessionsByUserId(userId, 20); - - Map responseBody = new LinkedHashMap<>(); - responseBody.put("isSuccess", true); - responseBody.put("data", sessions); - - return new APIGatewayProxyResponseEvent() - .withStatusCode(200) - .withHeaders(Map.of("Content-Type", "application/json")) - .withBody(gson.toJson(responseBody)); - } - - /** - * GET /opic/sessions/{sessionId} - * 세션 상세 조회 - */ - private APIGatewayProxyResponseEvent getSession(APIGatewayProxyRequestEvent event, String userId) { - String sessionId = event.getPathParameters().get("sessionId"); - - OPIcSession session = repository.findSessionById(sessionId).orElse(null); - - if (session == null) { - return ResponseGenerator.notFound("세션을 찾을 수 없습니다."); - } - - if (!session.getUserId().equals(userId)) { - return ResponseGenerator.forbidden("접근 권한이 없습니다."); - } - - // 세션에 포함된 답변들도 조회 - List answers = repository.findAnswersBySessionId(sessionId); - - Map response = new LinkedHashMap<>(); - response.put("session", session); - response.put("answers", answers); - - return ResponseGenerator.ok(response); - } - - /** - * GET /opic/sessions/{sessionId}/questions/next - * 다음 질문 조회 (Polly 음성 URL 포함) - */ - private APIGatewayProxyResponseEvent getNextQuestion(APIGatewayProxyRequestEvent event, String userId) { - String sessionId = event.getPathParameters().get("sessionId"); - - OPIcSession session = repository.findSessionById(sessionId).orElse(null); - - if (session == null) { - return ResponseGenerator.notFound("세션을 찾을 수 없습니다."); - } - - if (!session.getUserId().equals(userId)) { - return ResponseGenerator.forbidden("접근 권한이 없습니다."); - } - - // 모든 질문 완료 확인 - int currentIndex = session.getCurrentQuestionIndex(); - if (currentIndex >= session.getTotalQuestions()) { - return ResponseGenerator.ok(Map.of( - "completed", true, - "message", "모든 질문이 완료되었습니다. 세션을 완료해주세요.", - "sessionId", sessionId - )); - } - - // 다음 질문 조회 - String questionId = session.getQuestionIds().get(currentIndex); - OPIcQuestion question = repository.findQuestionById(questionId) - .orElseThrow(() -> new RuntimeException("질문을 찾을 수 없습니다: " + questionId)); - - // Polly 음성 URL - String audioUrl = generateQuestionAudioUrl(question); - - Map response = new LinkedHashMap<>(); - response.put("questionId", question.getQuestionId()); - response.put("questionText", question.getQuestionText()); - response.put("audioUrl", audioUrl); - response.put("questionNumber", currentIndex + 1); - response.put("totalQuestions", session.getTotalQuestions()); - response.put("completed", false); - - return ResponseGenerator.ok(response); - } - - /** - * GET /opic/sessions/{sessionId}/upload-url - * S3 Presigned URL 발급 (음성 업로드용) - */ - private APIGatewayProxyResponseEvent getUploadUrl(APIGatewayProxyRequestEvent event, String userId) { - String sessionId = event.getPathParameters().get("sessionId"); - - // 세션 검증 - OPIcSession session = repository.findSessionById(sessionId).orElse(null); - if (session == null || !session.getUserId().equals(userId)) { - return ResponseGenerator.forbidden("접근 권한이 없습니다."); - } - - // S3 키 생성 - String s3Key = String.format("opic/answers/%s/%s/%s.webm", - userId, - sessionId, - UUID.randomUUID().toString() - ); - - // Presigned URL 생성 (5분 유효) - PutObjectRequest putRequest = PutObjectRequest.builder() - .bucket(OPIC_BUCKET) - .key(s3Key) - .contentType("audio/webm") - .build(); - - String presignedUrl = AwsClients.s3Presigner() - .presignPutObject(PutObjectPresignRequest.builder() - .putObjectRequest(putRequest) - .signatureDuration(Duration.ofMinutes(5)) - .build()) - .url() - .toString(); - - return ResponseGenerator.ok(Map.of( - "uploadUrl", presignedUrl, - "s3Key", s3Key, - "expiresIn", 300 - )); - } - - /** - * POST /opic/sessions/{sessionId}/answers - * 답변 제출 → STT → AI 피드백 - */ - private APIGatewayProxyResponseEvent submitAnswer(APIGatewayProxyRequestEvent event, String userId) { - String sessionId = event.getPathParameters().get("sessionId"); - SubmitAnswerRequest request = gson.fromJson(event.getBody(), SubmitAnswerRequest.class); - - logger.info("답변 제출: sessionId={}, s3Key={}", sessionId, request.audioS3Key()); - - // 세션 검증 - OPIcSession session = repository.findSessionById(sessionId).orElse(null); - if (session == null) { - return ResponseGenerator.notFound("세션을 찾을 수 없습니다."); - } - if (!session.getUserId().equals(userId)) { - return ResponseGenerator.forbidden("접근 권한이 없습니다."); - } - - // 현재 질문 조회 - int currentIndex = session.getCurrentQuestionIndex(); - if (currentIndex >= session.getTotalQuestions()) { - return ResponseGenerator.badRequest("이미 모든 질문에 답변했습니다."); - } - - String questionId = session.getQuestionIds().get(currentIndex); - OPIcQuestion question = repository.findQuestionById(questionId) - .orElseThrow(() -> new RuntimeException("질문을 찾을 수 없습니다.")); - - // Transcribe Proxy 호출 (음성 → 텍스트) - logger.info("S3에서 오디오 파일 로드: {}", request.audioS3Key()); - - byte[] audioBytes = AwsClients.s3().getObjectAsBytes( - software.amazon.awssdk.services.s3.model.GetObjectRequest.builder() - .bucket(OPIC_BUCKET) - .key(request.audioS3Key()) - .build() - ).asByteArray(); - - String audioBase64 = java.util.Base64.getEncoder().encodeToString(audioBytes); - logger.info("오디오 파일 Base64 변환 완료: {} bytes → {} chars", - audioBytes.length, audioBase64.length()); - - // 4. Transcribe Proxy 호출 (Base64 데이터 전송) - TranscribeProxyService.TranscribeResult transcribeResult = - transcribeService.transcribe(audioBase64, sessionId); - - String transcript = transcribeResult.transcript(); - logger.info("STT 변환 완료: transcript 길이={}", transcript.length()); - - // Bedrock 피드백 생성 - FeedbackResponse feedback = feedbackService.generateFeedback( - question.getQuestionText(), - transcript, - session.getTargetLevel() - ); - - // Answer 저장 - 개별 필드로 분리 저장 - OPIcAnswer answer = new OPIcAnswer(); - answer.setSessionId(sessionId); - answer.setQuestionId(questionId); - answer.setQuestionIndex(currentIndex); - answer.setQuestionText(question.getQuestionText()); // 비정규화 - answer.setAudioS3Key(request.audioS3Key()); - answer.setTranscript(transcript); - answer.setTranscriptConfidence(transcribeResult.confidence()); - - // 피드백 개별 필드 저장 - answer.setGrammarFeedback(gson.toJson(feedback.errors())); // errors → grammarFeedback - answer.setContentFeedback(feedback.correctedAnswer()); // correctedAnswer → contentFeedback - answer.setSampleAnswer(feedback.sampleAnswer()); // 모범 답변 - answer.setStatus(OPIcAnswer.AnswerStatus.COMPLETED); - answer.setAttemptCount(1); - answer.setCreatedAt(Instant.now()); - answer.setCompletedAt(Instant.now()); - - repository.saveAnswer(answer); - - // 세션 진행 상태 업데이트 - session.setCurrentQuestionIndex(currentIndex + 1); - repository.updateSession(session); - - // Response - boolean hasNext = (currentIndex + 1) < session.getTotalQuestions(); - - Map response = new LinkedHashMap<>(); - response.put("transcript", transcript); - response.put("feedback", feedback); - response.put("hasNextQuestion", hasNext); - response.put("currentQuestion", currentIndex + 1); - response.put("totalQuestions", session.getTotalQuestions()); - - if (hasNext) { - response.put("nextQuestionNumber", currentIndex + 2); - } - - logger.info("답변 처리 완료: sessionId={}, questionIndex={}", sessionId, currentIndex); - return ResponseGenerator.ok("피드백이 생성되었습니다.", response); - } - - /** - * POST /opic/sessions/{sessionId}/complete - * 세션 완료 + 종합 리포트 생성 - */ - private APIGatewayProxyResponseEvent completeSession(APIGatewayProxyRequestEvent event, String userId) { - String sessionId = event.getPathParameters().get("sessionId"); - - OPIcSession session = repository.findSessionById(sessionId).orElse(null); - if (session == null) { - return ResponseGenerator.notFound("세션을 찾을 수 없습니다."); - } - if (!session.getUserId().equals(userId)) { - return ResponseGenerator.forbidden("접근 권한이 없습니다."); - } - - // 모든 질문 답변 완료 확인 - List answers = repository.findAnswersBySessionId(sessionId); - if (answers.size() < session.getTotalQuestions()) { - return ResponseGenerator.badRequest( - String.format("아직 %d개의 질문에 답변하지 않았습니다.", - session.getTotalQuestions() - answers.size()) - ); - } - - // 세션 요약 생성 (피드백용) - StringBuilder summaryBuilder = new StringBuilder(); - for (int i = 0; i < answers.size(); i++) { - OPIcAnswer answer = answers.get(i); - OPIcQuestion question = repository.findQuestionById(answer.getQuestionId()).orElse(null); - - summaryBuilder.append(String.format("### Question %d\n", i + 1)); - if (question != null) { - summaryBuilder.append("Q: ").append(question.getQuestionText()).append("\n"); - } - summaryBuilder.append("A: ").append(answer.getTranscript()).append("\n\n"); - } - - // 종합 리포트 생성 (Bedrock) - var sessionReport = feedbackService.generateSessionReport( - summaryBuilder.toString(), - session.getTargetLevel() - ); - - // 세션 완료 처리 - repository.completeSession( - session, - sessionReport.estimatedLevel(), - gson.toJson(sessionReport) - ); - - logger.info("세션 완료: sessionId={}, estimatedLevel={}", - sessionId, sessionReport.estimatedLevel()); - - return ResponseGenerator.ok("세션이 완료되었습니다.", sessionReport); - } - - // ==================== 유틸리티 ==================== - - /** - * 질문 음성 URL 생성 (Polly + S3 캐싱) - */ - private String generateQuestionAudioUrl(OPIcQuestion question) { - try { - PollyService.VoiceSynthesisResult result = pollyService.synthesizeSpeech( - question.getQuestionId(), - question.getQuestionText(), - "FEMALE" - ); - return result.getAudioUrl(); - } catch (Exception e) { - logger.warn("Polly 음성 생성 실패, 텍스트만 반환: {}", e.getMessage()); - return null; - } - } - - /** - * JWT 토큰에서 userId 추출 - */ - private String extractUserId(APIGatewayProxyRequestEvent event) { - String authHeader = event.getHeaders().get("Authorization"); - - if (authHeader == null || authHeader.isEmpty()) { - authHeader = event.getHeaders().get("authorization"); - } - - return JwtUtil.extractUserId(authHeader) - .orElseThrow(() -> new RuntimeException("인증 정보를 찾을 수 없습니다.")); - } - - private static class InstantTypeAdapter implements JsonSerializer, JsonDeserializer { - @Override - public JsonElement serialize(Instant src, Type typeOfSrc, JsonSerializationContext context) { - return new JsonPrimitive(src.toString()); - } - - @Override - public Instant deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) - throws JsonParseException { - return Instant.parse(json.getAsString()); - } - } -} \ No newline at end of file + + private static final Logger logger = LoggerFactory.getLogger(OPIcSessionHandler.class); + private static final Gson gson = new GsonBuilder() + .setDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'") + .registerTypeAdapter(Instant.class, new InstantTypeAdapter()) + .create(); + + private static final String OPIC_BUCKET = System.getenv("OPIC_BUCKET_NAME"); + + private final OPIcRepository repository; + private final PollyService pollyService; + private final TranscribeProxyService transcribeService; + private final FeedbackService feedbackService; + + public OPIcSessionHandler() { + this.repository = new OPIcRepository(); + this.pollyService = new PollyService(OPIC_BUCKET, "opic/voice/questions/"); + this.transcribeService = new TranscribeProxyService(); + this.feedbackService = new FeedbackService(); + } + + @Override + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent event, Context context) { + String httpMethod = event.getHttpMethod(); + String path = event.getPath(); + + try { + + String userId = extractUserId(event); + + + // POST /opic/sessions - 세션 생성 + if ("POST".equals(httpMethod) && path.equals("/opic/sessions")) { + return createSession(event, userId); + } + + // GET /opic/sessions - 세션 목록 조회 + if ("GET".equals(httpMethod) && path.equals("/opic/sessions")) { + return getSessions(userId); + } + + // GET /opic/sessions/{sessionId} - 세션 상세 조회 + if ("GET".equals(httpMethod) && path.matches("/opic/sessions/[^/]+") + && !path.contains("/questions") && !path.contains("/upload-url")) { + return getSession(event, userId); + } + + // GET /opic/sessions/{sessionId}/questions/next - 다음 질문 조회 + if ("GET".equals(httpMethod) && path.contains("/questions/next")) { + return getNextQuestion(event, userId); + } + + // GET /opic/sessions/{sessionId}/upload-url - Presigned URL 발급 + if ("GET".equals(httpMethod) && path.contains("/upload-url")) { + return getUploadUrl(event, userId); + } + + // POST /opic/sessions/{sessionId}/answers - 답변 제출 + if ("POST".equals(httpMethod) && path.contains("/answers")) { + return submitAnswer(event, userId); + } + + // POST /opic/sessions/{sessionId}/complete - 세션 완료 + if ("POST".equals(httpMethod) && path.contains("/complete")) { + return completeSession(event, userId); + } + + return ResponseGenerator.badRequest("지원하지 않는 요청입니다: " + httpMethod + " " + path); + + } catch (Exception e) { + logger.error("OPIc Handler 에러", e); + return ResponseGenerator.serverError(e.getMessage()); + } + } + + + /** + * POST /opic/sessions + * 세션 생성 + 첫 질문 반환 + */ + private APIGatewayProxyResponseEvent createSession(APIGatewayProxyRequestEvent event, String userId) { + CreateSessionRequest request = gson.fromJson(event.getBody(), CreateSessionRequest.class); + + logger.info("세션 생성 요청: userId={}, topic={}, level={}", + userId, request.topic(), request.targetLevel()); + + // 주제 + 소주제 + 레벨로 질문 세트 조회 + List questions = repository.findQuestionsByTopicSubTopicAndLevel( + request.topic(), + request.subTopic(), + request.targetLevel() + ); + + if (questions.isEmpty()) { + return ResponseGenerator.notFound("해당 주제/레벨의 질문이 없습니다."); + } + + // 최대 3개 질문 선택 (랜덤 셔플) + Collections.shuffle(questions); + List questionIds = questions.stream() + .limit(3) + .map(OPIcQuestion::getQuestionId) + .collect(Collectors.toList()); + + // 세션 생성 + OPIcSession session = repository.createSession( + userId, + request.topic(), + request.subTopic(), + request.targetLevel(), + questionIds + ); + + // 첫 질문 Polly 음성 URL 생성 (#368 PollyService 연동) + OPIcQuestion firstQuestion = questions.get(0); + String audioUrl = generateQuestionAudioUrl(firstQuestion); + + // Response + Map response = new LinkedHashMap<>(); + response.put("sessionId", session.getSessionId()); + response.put("totalQuestions", session.getTotalQuestions()); + response.put("firstQuestion", Map.of( + "questionId", firstQuestion.getQuestionId(), + "questionText", firstQuestion.getQuestionText(), + "audioUrl", audioUrl, + "questionNumber", 1, + "totalQuestions", session.getTotalQuestions() + )); + + logger.info("세션 생성 완료: sessionId={}", session.getSessionId()); + return ResponseGenerator.created("세션이 생성되었습니다.", response); + } + + /** + * GET /opic/sessions + * 사용자의 세션 목록 조회 + */ + private APIGatewayProxyResponseEvent getSessions(String userId) { + List sessions = repository.findSessionsByUserId(userId, 20); + + Map responseBody = new LinkedHashMap<>(); + responseBody.put("isSuccess", true); + responseBody.put("data", sessions); + + return new APIGatewayProxyResponseEvent() + .withStatusCode(200) + .withHeaders(Map.of("Content-Type", "application/json")) + .withBody(gson.toJson(responseBody)); + } + + /** + * GET /opic/sessions/{sessionId} + * 세션 상세 조회 + */ + private APIGatewayProxyResponseEvent getSession(APIGatewayProxyRequestEvent event, String userId) { + String sessionId = event.getPathParameters().get("sessionId"); + + OPIcSession session = repository.findSessionById(sessionId).orElse(null); + + if (session == null) { + return ResponseGenerator.notFound("세션을 찾을 수 없습니다."); + } + + if (!session.getUserId().equals(userId)) { + return ResponseGenerator.forbidden("접근 권한이 없습니다."); + } + + // 세션에 포함된 답변들도 조회 + List answers = repository.findAnswersBySessionId(sessionId); + + Map response = new LinkedHashMap<>(); + response.put("session", session); + response.put("answers", answers); + + return ResponseGenerator.ok(response); + } + + /** + * GET /opic/sessions/{sessionId}/questions/next + * 다음 질문 조회 (Polly 음성 URL 포함) + */ + private APIGatewayProxyResponseEvent getNextQuestion(APIGatewayProxyRequestEvent event, String userId) { + String sessionId = event.getPathParameters().get("sessionId"); + + OPIcSession session = repository.findSessionById(sessionId).orElse(null); + + if (session == null) { + return ResponseGenerator.notFound("세션을 찾을 수 없습니다."); + } + + if (!session.getUserId().equals(userId)) { + return ResponseGenerator.forbidden("접근 권한이 없습니다."); + } + + // 모든 질문 완료 확인 + int currentIndex = session.getCurrentQuestionIndex(); + if (currentIndex >= session.getTotalQuestions()) { + return ResponseGenerator.ok(Map.of( + "completed", true, + "message", "모든 질문이 완료되었습니다. 세션을 완료해주세요.", + "sessionId", sessionId + )); + } + + // 다음 질문 조회 + String questionId = session.getQuestionIds().get(currentIndex); + OPIcQuestion question = repository.findQuestionById(questionId) + .orElseThrow(() -> new RuntimeException("질문을 찾을 수 없습니다: " + questionId)); + + // Polly 음성 URL + String audioUrl = generateQuestionAudioUrl(question); + + Map response = new LinkedHashMap<>(); + response.put("questionId", question.getQuestionId()); + response.put("questionText", question.getQuestionText()); + response.put("audioUrl", audioUrl); + response.put("questionNumber", currentIndex + 1); + response.put("totalQuestions", session.getTotalQuestions()); + response.put("completed", false); + + return ResponseGenerator.ok(response); + } + + /** + * GET /opic/sessions/{sessionId}/upload-url + * S3 Presigned URL 발급 (음성 업로드용) + */ + private APIGatewayProxyResponseEvent getUploadUrl(APIGatewayProxyRequestEvent event, String userId) { + String sessionId = event.getPathParameters().get("sessionId"); + + // 세션 검증 + OPIcSession session = repository.findSessionById(sessionId).orElse(null); + if (session == null || !session.getUserId().equals(userId)) { + return ResponseGenerator.forbidden("접근 권한이 없습니다."); + } + + // S3 키 생성 + String s3Key = String.format("opic/answers/%s/%s/%s.webm", + userId, + sessionId, + UUID.randomUUID().toString() + ); + + // Presigned URL 생성 (5분 유효) + PutObjectRequest putRequest = PutObjectRequest.builder() + .bucket(OPIC_BUCKET) + .key(s3Key) + .contentType("audio/webm") + .build(); + + String presignedUrl = AwsClients.s3Presigner() + .presignPutObject(PutObjectPresignRequest.builder() + .putObjectRequest(putRequest) + .signatureDuration(Duration.ofMinutes(5)) + .build()) + .url() + .toString(); + + return ResponseGenerator.ok(Map.of( + "uploadUrl", presignedUrl, + "s3Key", s3Key, + "expiresIn", 300 + )); + } + + /** + * POST /opic/sessions/{sessionId}/answers + * 답변 제출 → STT → AI 피드백 + */ + private APIGatewayProxyResponseEvent submitAnswer(APIGatewayProxyRequestEvent event, String userId) { + String sessionId = event.getPathParameters().get("sessionId"); + SubmitAnswerRequest request = gson.fromJson(event.getBody(), SubmitAnswerRequest.class); + + logger.info("답변 제출: sessionId={}, s3Key={}", sessionId, request.audioS3Key()); + + // 세션 검증 + OPIcSession session = repository.findSessionById(sessionId).orElse(null); + if (session == null) { + return ResponseGenerator.notFound("세션을 찾을 수 없습니다."); + } + if (!session.getUserId().equals(userId)) { + return ResponseGenerator.forbidden("접근 권한이 없습니다."); + } + + // 현재 질문 조회 + int currentIndex = session.getCurrentQuestionIndex(); + if (currentIndex >= session.getTotalQuestions()) { + return ResponseGenerator.badRequest("이미 모든 질문에 답변했습니다."); + } + + String questionId = session.getQuestionIds().get(currentIndex); + OPIcQuestion question = repository.findQuestionById(questionId) + .orElseThrow(() -> new RuntimeException("질문을 찾을 수 없습니다.")); + + // Transcribe Proxy 호출 (음성 → 텍스트) + logger.info("S3에서 오디오 파일 로드: {}", request.audioS3Key()); + + byte[] audioBytes = AwsClients.s3().getObjectAsBytes( + software.amazon.awssdk.services.s3.model.GetObjectRequest.builder() + .bucket(OPIC_BUCKET) + .key(request.audioS3Key()) + .build() + ).asByteArray(); + + String audioBase64 = java.util.Base64.getEncoder().encodeToString(audioBytes); + logger.info("오디오 파일 Base64 변환 완료: {} bytes → {} chars", + audioBytes.length, audioBase64.length()); + + // 4. Transcribe Proxy 호출 (Base64 데이터 전송) + TranscribeProxyService.TranscribeResult transcribeResult = + transcribeService.transcribe(audioBase64, sessionId); + + String transcript = transcribeResult.transcript(); + logger.info("STT 변환 완료: transcript 길이={}", transcript.length()); + + // Bedrock 피드백 생성 + FeedbackResponse feedback = feedbackService.generateFeedback( + question.getQuestionText(), + transcript, + session.getTargetLevel() + ); + + // Answer 저장 - 개별 필드로 분리 저장 + OPIcAnswer answer = new OPIcAnswer(); + answer.setSessionId(sessionId); + answer.setQuestionId(questionId); + answer.setQuestionIndex(currentIndex); + answer.setQuestionText(question.getQuestionText()); // 비정규화 + answer.setAudioS3Key(request.audioS3Key()); + answer.setTranscript(transcript); + answer.setTranscriptConfidence(transcribeResult.confidence()); + + // 피드백 개별 필드 저장 + answer.setGrammarFeedback(gson.toJson(feedback.errors())); // errors → grammarFeedback + answer.setContentFeedback(feedback.correctedAnswer()); // correctedAnswer → contentFeedback + answer.setSampleAnswer(feedback.sampleAnswer()); // 모범 답변 + answer.setStatus(OPIcAnswer.AnswerStatus.COMPLETED); + answer.setAttemptCount(1); + answer.setCreatedAt(Instant.now()); + answer.setCompletedAt(Instant.now()); + + repository.saveAnswer(answer); + + // 세션 진행 상태 업데이트 + session.setCurrentQuestionIndex(currentIndex + 1); + repository.updateSession(session); + + // Response + boolean hasNext = (currentIndex + 1) < session.getTotalQuestions(); + + Map response = new LinkedHashMap<>(); + response.put("transcript", transcript); + response.put("feedback", feedback); + response.put("hasNextQuestion", hasNext); + response.put("currentQuestion", currentIndex + 1); + response.put("totalQuestions", session.getTotalQuestions()); + + if (hasNext) { + response.put("nextQuestionNumber", currentIndex + 2); + } + + logger.info("답변 처리 완료: sessionId={}, questionIndex={}", sessionId, currentIndex); + return ResponseGenerator.ok("피드백이 생성되었습니다.", response); + } + + /** + * POST /opic/sessions/{sessionId}/complete + * 세션 완료 + 종합 리포트 생성 + */ + private APIGatewayProxyResponseEvent completeSession(APIGatewayProxyRequestEvent event, String userId) { + String sessionId = event.getPathParameters().get("sessionId"); + + OPIcSession session = repository.findSessionById(sessionId).orElse(null); + if (session == null) { + return ResponseGenerator.notFound("세션을 찾을 수 없습니다."); + } + if (!session.getUserId().equals(userId)) { + return ResponseGenerator.forbidden("접근 권한이 없습니다."); + } + + // 모든 질문 답변 완료 확인 + List answers = repository.findAnswersBySessionId(sessionId); + if (answers.size() < session.getTotalQuestions()) { + return ResponseGenerator.badRequest( + String.format("아직 %d개의 질문에 답변하지 않았습니다.", + session.getTotalQuestions() - answers.size()) + ); + } + + // 세션 요약 생성 (피드백용) + StringBuilder summaryBuilder = new StringBuilder(); + for (int i = 0; i < answers.size(); i++) { + OPIcAnswer answer = answers.get(i); + OPIcQuestion question = repository.findQuestionById(answer.getQuestionId()).orElse(null); + + summaryBuilder.append(String.format("### Question %d\n", i + 1)); + if (question != null) { + summaryBuilder.append("Q: ").append(question.getQuestionText()).append("\n"); + } + summaryBuilder.append("A: ").append(answer.getTranscript()).append("\n\n"); + } + + // 종합 리포트 생성 (Bedrock) + var sessionReport = feedbackService.generateSessionReport( + summaryBuilder.toString(), + session.getTargetLevel() + ); + + // 세션 완료 처리 + repository.completeSession( + session, + sessionReport.estimatedLevel(), + gson.toJson(sessionReport) + ); + + logger.info("세션 완료: sessionId={}, estimatedLevel={}", + sessionId, sessionReport.estimatedLevel()); + + return ResponseGenerator.ok("세션이 완료되었습니다.", sessionReport); + } + + // ==================== 유틸리티 ==================== + + /** + * 질문 음성 URL 생성 (Polly + S3 캐싱) + */ + private String generateQuestionAudioUrl(OPIcQuestion question) { + try { + PollyService.VoiceSynthesisResult result = pollyService.synthesizeSpeech( + question.getQuestionId(), + question.getQuestionText(), + "FEMALE" + ); + return result.getAudioUrl(); + } catch (Exception e) { + logger.warn("Polly 음성 생성 실패, 텍스트만 반환: {}", e.getMessage()); + return null; + } + } + + /** + * JWT 토큰에서 userId 추출 + */ + private String extractUserId(APIGatewayProxyRequestEvent event) { + String authHeader = event.getHeaders().get("Authorization"); + + if (authHeader == null || authHeader.isEmpty()) { + authHeader = event.getHeaders().get("authorization"); + } + + return JwtUtil.extractUserId(authHeader) + .orElseThrow(() -> new RuntimeException("인증 정보를 찾을 수 없습니다.")); + } + + private static class InstantTypeAdapter implements JsonSerializer, JsonDeserializer { + @Override + public JsonElement serialize(Instant src, Type typeOfSrc, JsonSerializationContext context) { + return new JsonPrimitive(src.toString()); + } + + @Override + public Instant deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) + throws JsonParseException { + return Instant.parse(json.getAsString()); + } + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/repository/OPIcRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/repository/OPIcRepository.java index 92ac2541..62251d18 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/repository/OPIcRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/repository/OPIcRepository.java @@ -20,248 +20,248 @@ import java.util.stream.Collectors; public class OPIcRepository { - - private static final Logger logger = LoggerFactory.getLogger(OPIcRepository.class); - private static final String TABLE_NAME = System.getenv("OPIC_TABLE_NAME"); - - private final DynamoDbEnhancedClient enhancedClient; - private final DynamoDbTable sessionTable; - private final DynamoDbTable questionTable; - private final DynamoDbTable answerTable; - - public OPIcRepository() { - this.enhancedClient = AwsClients.dynamoDbEnhanced(); - this.sessionTable = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(OPIcSession.class)); - this.questionTable = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(OPIcQuestion.class)); - this.answerTable = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(OPIcAnswer.class)); - } - - // ==================== Session ==================== - - /** - * 새 세션 생성 - */ - public OPIcSession createSession(String userId, String topic, String subTopic, - String targetLevel, List questionIds) { - String sessionId = UUID.randomUUID().toString(); - String today = LocalDate.now(ZoneId.of("Asia/Seoul")) - .format(DateTimeFormatter.ISO_LOCAL_DATE); - Instant now = Instant.now(); - - OPIcSession session = new OPIcSession(); - session.setPk("USER#" + userId); - session.setSk("SESSION#" + today + "#" + sessionId); - session.setGsi1pk("SESSION#" + sessionId); - session.setGsi1sk("METADATA"); - - session.setSessionId(sessionId); - session.setUserId(userId); - session.setTopic(topic); - session.setSubTopic(subTopic); - session.setTargetLevel(targetLevel); - session.setStatus(OPIcSession.SessionStatus.IN_PROGRESS); - session.setCurrentQuestionIndex(0); - session.setTotalQuestions(questionIds.size()); - session.setQuestionIds(questionIds); - session.setCreatedAt(now); - session.setUpdatedAt(now); - session.setSequenceNumber(0); - - sessionTable.putItem(session); - logger.info("Session created: {}", sessionId); - - return session; - } - - /** - * 세션 ID로 조회 (GSI1 사용) - */ - public Optional findSessionById(String sessionId) { - DynamoDbIndex gsi1 = sessionTable.index("GSI1"); - - QueryConditional queryConditional = QueryConditional.keyEqualTo( - Key.builder() - .partitionValue("SESSION#" + sessionId) - .sortValue("METADATA") - .build() - ); - - return gsi1.query(QueryEnhancedRequest.builder() - .queryConditional(queryConditional) - .build()) - .stream() - .flatMap(page -> page.items().stream()) - .findFirst(); - } - - /** - * 사용자의 세션 목록 조회 (최신순) - */ - public List findSessionsByUserId(String userId, int limit) { - QueryConditional queryConditional = QueryConditional.sortBeginsWith( - Key.builder() - .partitionValue("USER#" + userId) - .sortValue("SESSION#") - .build() - ); - - return sessionTable.query(QueryEnhancedRequest.builder() - .queryConditional(queryConditional) - .scanIndexForward(false) // 최신순 - .limit(limit) - .build()) - .stream() - .flatMap(page -> page.items().stream()) - .collect(Collectors.toList()); - } - - /** - * 세션 업데이트 - */ - public void updateSession(OPIcSession session) { - session.setUpdatedAt(Instant.now()); - session.setSequenceNumber(session.getSequenceNumber() + 1); - sessionTable.putItem(session); - logger.debug("Session updated: {}", session.getSessionId()); - } - - /** - * 세션 완료 처리 - */ - public void completeSession(OPIcSession session, String overallScore, String overallFeedback) { - session.setStatus(OPIcSession.SessionStatus.COMPLETED); - session.setOverallScore(overallScore); - session.setOverallFeedback(overallFeedback); - session.setCompletedAt(Instant.now()); - updateSession(session); - logger.info("Session completed: {}", session.getSessionId()); - } - - - // ==================== Question ==================== - - /** - * 질문 ID로 조회 - */ - public Optional findQuestionById(String questionId) { - Key key = Key.builder() - .partitionValue("QUESTION#" + questionId) - .sortValue("METADATA") - .build(); - - return Optional.ofNullable(questionTable.getItem(key)); - } - - /** - * 주제 + 레벨로 질문 조회 (GSI1) - */ - public List findQuestionsByTopicAndLevel(String topic, String level) { - DynamoDbIndex gsi1 = questionTable.index("GSI1"); - - QueryConditional queryConditional = QueryConditional.keyEqualTo( - Key.builder() - .partitionValue("TOPIC#" + topic) - .sortValue("LEVEL#" + level) - .build() - ); - - return gsi1.query(queryConditional) - .stream() - .flatMap(page -> page.items().stream()) - .filter(OPIcQuestion::isActive) - .collect(Collectors.toList()); - } - - /** - * 주제 + 소주제 + 레벨로 질문 조회 (subTopic 필터 추가) - */ - public List findQuestionsByTopicSubTopicAndLevel( - String topic, String subTopic, String level) { - - return findQuestionsByTopicAndLevel(topic, level).stream() - .filter(q -> subTopic == null || subTopic.equals(q.getSubTopic())) - .collect(Collectors.toList()); - } - - /** - * 여러 질문 ID로 조회 - */ - public List findQuestionsByIds(List questionIds) { - return questionIds.stream() - .map(this::findQuestionById) - .filter(Optional::isPresent) - .map(Optional::get) - .collect(Collectors.toList()); - } - - /** - * 질문 저장 (마스터 데이터 등록용) - */ - public void saveQuestion(OPIcQuestion question) { - question.setPk("QUESTION#" + question.getQuestionId()); - question.setSk("METADATA"); - question.setGsi1pk("TOPIC#" + question.getTopic()); - question.setGsi1sk("LEVEL#" + question.getLevel()); - - questionTable.putItem(question); - logger.info("Question saved: {}", question.getQuestionId()); - } - - // ==================== Answer ==================== - - /** - * 답변 저장 - */ - public void saveAnswer(OPIcAnswer answer) { - answer.setPk("SESSION#" + answer.getSessionId()); - answer.setSk(String.format("Q#%02d", answer.getQuestionIndex())); - - if (answer.getCreatedAt() == null) { - answer.setCreatedAt(Instant.now()); - } - - answerTable.putItem(answer); - logger.debug("Answer saved: session={}, questionIndex={}", - answer.getSessionId(), answer.getQuestionIndex()); - } - - /** - * 세션의 특정 질문 답변 조회 - */ - public Optional findAnswer(String sessionId, int questionIndex) { - Key key = Key.builder() - .partitionValue("SESSION#" + sessionId) - .sortValue(String.format("Q#%02d", questionIndex)) - .build(); - - return Optional.ofNullable(answerTable.getItem(key)); - } - - /** - * 세션의 모든 답변 조회 - */ - public List findAnswersBySessionId(String sessionId) { - QueryConditional queryConditional = QueryConditional.sortBeginsWith( - Key.builder() - .partitionValue("SESSION#" + sessionId) - .sortValue("Q#") - .build() - ); - - return answerTable.query(queryConditional) - .stream() - .flatMap(page -> page.items().stream()) - .collect(Collectors.toList()); - } - - /** - * 답변 업데이트 (피드백 추가 등) - */ - public void updateAnswer(OPIcAnswer answer) { - answerTable.putItem(answer); - logger.debug("Answer updated: session={}, questionIndex={}", - answer.getSessionId(), answer.getQuestionIndex()); - } - - + + private static final Logger logger = LoggerFactory.getLogger(OPIcRepository.class); + private static final String TABLE_NAME = System.getenv("OPIC_TABLE_NAME"); + + private final DynamoDbEnhancedClient enhancedClient; + private final DynamoDbTable sessionTable; + private final DynamoDbTable questionTable; + private final DynamoDbTable answerTable; + + public OPIcRepository() { + this.enhancedClient = AwsClients.dynamoDbEnhanced(); + this.sessionTable = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(OPIcSession.class)); + this.questionTable = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(OPIcQuestion.class)); + this.answerTable = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(OPIcAnswer.class)); + } + + // ==================== Session ==================== + + /** + * 새 세션 생성 + */ + public OPIcSession createSession(String userId, String topic, String subTopic, + String targetLevel, List questionIds) { + String sessionId = UUID.randomUUID().toString(); + String today = LocalDate.now(ZoneId.of("Asia/Seoul")) + .format(DateTimeFormatter.ISO_LOCAL_DATE); + Instant now = Instant.now(); + + OPIcSession session = new OPIcSession(); + session.setPk("USER#" + userId); + session.setSk("SESSION#" + today + "#" + sessionId); + session.setGsi1pk("SESSION#" + sessionId); + session.setGsi1sk("METADATA"); + + session.setSessionId(sessionId); + session.setUserId(userId); + session.setTopic(topic); + session.setSubTopic(subTopic); + session.setTargetLevel(targetLevel); + session.setStatus(OPIcSession.SessionStatus.IN_PROGRESS); + session.setCurrentQuestionIndex(0); + session.setTotalQuestions(questionIds.size()); + session.setQuestionIds(questionIds); + session.setCreatedAt(now); + session.setUpdatedAt(now); + session.setSequenceNumber(0); + + sessionTable.putItem(session); + logger.info("Session created: {}", sessionId); + + return session; + } + + /** + * 세션 ID로 조회 (GSI1 사용) + */ + public Optional findSessionById(String sessionId) { + DynamoDbIndex gsi1 = sessionTable.index("GSI1"); + + QueryConditional queryConditional = QueryConditional.keyEqualTo( + Key.builder() + .partitionValue("SESSION#" + sessionId) + .sortValue("METADATA") + .build() + ); + + return gsi1.query(QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .build()) + .stream() + .flatMap(page -> page.items().stream()) + .findFirst(); + } + + /** + * 사용자의 세션 목록 조회 (최신순) + */ + public List findSessionsByUserId(String userId, int limit) { + QueryConditional queryConditional = QueryConditional.sortBeginsWith( + Key.builder() + .partitionValue("USER#" + userId) + .sortValue("SESSION#") + .build() + ); + + return sessionTable.query(QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .scanIndexForward(false) // 최신순 + .limit(limit) + .build()) + .stream() + .flatMap(page -> page.items().stream()) + .collect(Collectors.toList()); + } + + /** + * 세션 업데이트 + */ + public void updateSession(OPIcSession session) { + session.setUpdatedAt(Instant.now()); + session.setSequenceNumber(session.getSequenceNumber() + 1); + sessionTable.putItem(session); + logger.debug("Session updated: {}", session.getSessionId()); + } + + /** + * 세션 완료 처리 + */ + public void completeSession(OPIcSession session, String overallScore, String overallFeedback) { + session.setStatus(OPIcSession.SessionStatus.COMPLETED); + session.setOverallScore(overallScore); + session.setOverallFeedback(overallFeedback); + session.setCompletedAt(Instant.now()); + updateSession(session); + logger.info("Session completed: {}", session.getSessionId()); + } + + + // ==================== Question ==================== + + /** + * 질문 ID로 조회 + */ + public Optional findQuestionById(String questionId) { + Key key = Key.builder() + .partitionValue("QUESTION#" + questionId) + .sortValue("METADATA") + .build(); + + return Optional.ofNullable(questionTable.getItem(key)); + } + + /** + * 주제 + 레벨로 질문 조회 (GSI1) + */ + public List findQuestionsByTopicAndLevel(String topic, String level) { + DynamoDbIndex gsi1 = questionTable.index("GSI1"); + + QueryConditional queryConditional = QueryConditional.keyEqualTo( + Key.builder() + .partitionValue("TOPIC#" + topic) + .sortValue("LEVEL#" + level) + .build() + ); + + return gsi1.query(queryConditional) + .stream() + .flatMap(page -> page.items().stream()) + .filter(OPIcQuestion::isActive) + .collect(Collectors.toList()); + } + + /** + * 주제 + 소주제 + 레벨로 질문 조회 (subTopic 필터 추가) + */ + public List findQuestionsByTopicSubTopicAndLevel( + String topic, String subTopic, String level) { + + return findQuestionsByTopicAndLevel(topic, level).stream() + .filter(q -> subTopic == null || subTopic.equals(q.getSubTopic())) + .collect(Collectors.toList()); + } + + /** + * 여러 질문 ID로 조회 + */ + public List findQuestionsByIds(List questionIds) { + return questionIds.stream() + .map(this::findQuestionById) + .filter(Optional::isPresent) + .map(Optional::get) + .collect(Collectors.toList()); + } + + /** + * 질문 저장 (마스터 데이터 등록용) + */ + public void saveQuestion(OPIcQuestion question) { + question.setPk("QUESTION#" + question.getQuestionId()); + question.setSk("METADATA"); + question.setGsi1pk("TOPIC#" + question.getTopic()); + question.setGsi1sk("LEVEL#" + question.getLevel()); + + questionTable.putItem(question); + logger.info("Question saved: {}", question.getQuestionId()); + } + + // ==================== Answer ==================== + + /** + * 답변 저장 + */ + public void saveAnswer(OPIcAnswer answer) { + answer.setPk("SESSION#" + answer.getSessionId()); + answer.setSk(String.format("Q#%02d", answer.getQuestionIndex())); + + if (answer.getCreatedAt() == null) { + answer.setCreatedAt(Instant.now()); + } + + answerTable.putItem(answer); + logger.debug("Answer saved: session={}, questionIndex={}", + answer.getSessionId(), answer.getQuestionIndex()); + } + + /** + * 세션의 특정 질문 답변 조회 + */ + public Optional findAnswer(String sessionId, int questionIndex) { + Key key = Key.builder() + .partitionValue("SESSION#" + sessionId) + .sortValue(String.format("Q#%02d", questionIndex)) + .build(); + + return Optional.ofNullable(answerTable.getItem(key)); + } + + /** + * 세션의 모든 답변 조회 + */ + public List findAnswersBySessionId(String sessionId) { + QueryConditional queryConditional = QueryConditional.sortBeginsWith( + Key.builder() + .partitionValue("SESSION#" + sessionId) + .sortValue("Q#") + .build() + ); + + return answerTable.query(queryConditional) + .stream() + .flatMap(page -> page.items().stream()) + .collect(Collectors.toList()); + } + + /** + * 답변 업데이트 (피드백 추가 등) + */ + public void updateAnswer(OPIcAnswer answer) { + answerTable.putItem(answer); + logger.debug("Answer updated: session={}, questionIndex={}", + answer.getSessionId(), answer.getQuestionIndex()); + } + + } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/service/FeedbackService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/service/FeedbackService.java index fc42849d..9c15315e 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/service/FeedbackService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/service/FeedbackService.java @@ -18,282 +18,282 @@ import java.util.List; /** -* OPIc 피드백 생성 서비스 -*/ + * OPIc 피드백 생성 서비스 + */ public class FeedbackService { - - private static final Logger logger = LoggerFactory.getLogger(FeedbackService.class); - private static final Gson gson = new GsonBuilder().setPrettyPrinting().create(); - private static final String MODEL_ID = "anthropic.claude-3-haiku-20240307-v1:0"; - private static final int MAX_TOKENS = 2000; - - /** - * 사용자 답변에 대한 피드백 생성 - */ - public FeedbackResponse generateFeedback(String question, String userAnswer, String targetLevel) { - logger.info("피드백 생성, 대상 Level: {}", targetLevel); - - String prompt = buildFeedbackPrompt(question, userAnswer, targetLevel); - String response = invokeClaude(prompt); - String jsonResponse = JsonUtil.extractJson(response); - - return parseFeedbackResponse(jsonResponse); - } - - - /** - * 세션 종합 리포트 생성 - */ - public SessionReportResponse generateSessionReport(String sessionSummary, String targetLevel) { - logger.info("세션 리포트 생성, 대상 Level: {}", targetLevel); - - String prompt = buildSessionReportPrompt(sessionSummary, targetLevel); - String response = invokeClaude(prompt); - String jsonResponse = JsonUtil.extractJson(response); - - return parseSessionReportResponse(jsonResponse); - } - - - /** - * 개별 질문 피드백 프롬프트 - */ - private String buildFeedbackPrompt(String question, String userAnswer, String targetLevel) { - return String.format(""" - You are an expert OPIc speaking evaluator. - - ## Question - %s - - ## User's Answer - %s - - ## Target Level - %s - - ## Task - Analyze the answer and provide feedback in the following JSON format only: - - { - "errors": [ - { - "type": "GRAMMAR | EXPRESSION | VOCABULARY", - "original": "원본 표현", - "corrected": "교정된 표현", - "explanation": "설명 (한국어)" - } - ], - "correctedAnswer": "전체 교정된 답변 (영어)", - "sampleAnswer": "목표 레벨에 맞는 모범 답변 (영어, 4-6문장)" - } - - Error types: - - GRAMMAR: 문법 오류 (시제, 관사, 주어-동사 일치 등) - - EXPRESSION: 더 자연스러운 표현 제안 - - VOCABULARY: 더 적절하거나 풍부한 어휘 제안 - - Rules: - 1. errors 배열은 최대 5개까지만 포함 - 2. 오류가 없으면 errors는 빈 배열 [] - 3. explanation은 한국어로 간결하게 - 4. sampleAnswer는 목표 레벨에 맞는 자연스러운 답변 - - Respond with ONLY the JSON, no markdown code blocks. - """, question, userAnswer, targetLevel); - } - - /** - * 세션 종합 리포트 프롬프트 - */ - private String buildSessionReportPrompt(String sessionSummary, String targetLevel) { - return String.format(""" - You are an expert OPIc speaking coach creating a comprehensive session report. - - ## Session Summary (Questions and Answers) - %s - - ## Target Level - %s - - ## Task - Generate a detailed learning report in the following JSON format only: - - { - "estimatedLevel": "NL | NM | NH | IL | IM1 | IM2 | IM3 | IH | AL", - "overallScore": 0-100, - "strengths": ["잘한 점 1 (한국어)", "잘한 점 2", "잘한 점 3"], - "weaknesses": ["개선할 점 1 (한국어)", "개선할 점 2", "개선할 점 3"], - "feedback": "종합 피드백 (한국어, 3-4문장, 격려하는 톤)", - "recommendations": ["학습 추천 1 (한국어)", "학습 추천 2"] - } - - Evaluation criteria: - - Task completion: 질문에 적절히 답했는가 - - Fluency: 유창성, 자연스러움 - - Grammar: 문법 정확도 - - Vocabulary: 어휘 다양성 - - Content: 내용의 구체성 - - Be encouraging but honest. Provide specific, actionable feedback in Korean. - Respond with ONLY the JSON, no markdown code blocks. - """, sessionSummary, targetLevel); - } - - - /** - * Claude 호출 (일반 텍스트 응답) - */ - private String invokeClaude(String prompt) { - try { - JsonObject requestBody = buildRequestBody(prompt); - - InvokeModelRequest request = InvokeModelRequest.builder() - .modelId(MODEL_ID) - .contentType("application/json") - .body(SdkBytes.fromUtf8String(gson.toJson(requestBody))) - .build(); - - long startTime = System.currentTimeMillis(); - InvokeModelResponse response = AwsClients.bedrock().invokeModel(request); - long elapsed = System.currentTimeMillis() - startTime; - - logger.info("Bedrock 응답 수신: {}ms", elapsed); - - JsonObject responseJson = JsonParser.parseString( - response.body().asUtf8String() - ).getAsJsonObject(); - - return responseJson - .getAsJsonArray("content") - .get(0) - .getAsJsonObject() - .get("text") - .getAsString(); - - } catch (Exception e) { - logger.error("Bedrock 호출 실패", e); - throw new OPIcException.BedrockApiException(e.getMessage(), e); - } - } - - /** - * Bedrock 요청 Body 생성 - */ - private JsonObject buildRequestBody(String prompt) { - JsonObject requestBody = new JsonObject(); - requestBody.addProperty("anthropic_version", "bedrock-2023-05-31"); - requestBody.addProperty("max_tokens", MAX_TOKENS); - - JsonArray messages = new JsonArray(); - JsonObject userMessage = new JsonObject(); - userMessage.addProperty("role", "user"); - userMessage.addProperty("content", prompt); - messages.add(userMessage); - - requestBody.add("messages", messages); - return requestBody; - } - - // ==================== 응답 파싱 ==================== - - /** - * 피드백 응답 파싱 - * - * Claude 응답 JSON 구조: - * { - * "errors": [{ "type": "GRAMMAR", "original": "...", "corrected": "...", "explanation": "..." }], - * "correctedAnswer": "...", - * "sampleAnswer": "..." - * } - */ - private FeedbackResponse parseFeedbackResponse(String jsonResponse) { - try { - JsonObject json = JsonParser.parseString(jsonResponse).getAsJsonObject(); - - // errors 배열 파싱 - List errors = parseErrors(json.getAsJsonArray("errors")); - - // 응답 DTO 생성 - return new FeedbackResponse( - errors, - json.get("correctedAnswer").getAsString(), - json.get("sampleAnswer").getAsString() - ); - - } catch (Exception e) { - logger.error("피드백 파싱 실패: {}", jsonResponse, e); - throw new OPIcException.FeedbackParseException(jsonResponse, e); - } - } - - /** - * 세션 리포트 응답 파싱 - * - * Claude 응답 JSON 구조: - * { - * "estimatedLevel": "IM2", - * "overallScore": 72, - * "strengths": ["...", "..."], - * "weaknesses": ["...", "..."], - * "feedback": "...", - * "recommendations": ["...", "..."] - * } - */ - private SessionReportResponse parseSessionReportResponse(String jsonResponse) { - try { - JsonObject json = JsonParser.parseString(jsonResponse).getAsJsonObject(); - - return new SessionReportResponse( - json.get("estimatedLevel").getAsString(), - json.get("overallScore").getAsInt(), - JsonUtil.toStringList(json.getAsJsonArray("strengths")), - JsonUtil.toStringList(json.getAsJsonArray("weaknesses")), - json.get("feedback").getAsString(), - JsonUtil.toStringList(json.getAsJsonArray("recommendations")) - ); - - } catch (Exception e) { - logger.error("세션 리포트 파싱 실패: {}", jsonResponse, e); - throw new OPIcException.ReportParseException(jsonResponse, e); - } - } - - /** - * errors 배열 파싱 - */ - private List parseErrors(JsonArray errorsArray) { - List errors = new ArrayList<>(); - - if (errorsArray == null || errorsArray.isEmpty()) { - return errors; - } - - for (JsonElement el : errorsArray) { - JsonObject obj = el.getAsJsonObject(); - errors.add(SpeakingError.builder() - .type(parseErrorType(obj.get("type").getAsString())) - .original(obj.get("original").getAsString()) - .corrected(obj.get("corrected").getAsString()) - .explanation(obj.get("explanation").getAsString()) - .build()); - } - - return errors; - } - - - /** - * 오류 타입 문자열 -> Enum 변환 - */ - private SpeakingErrorType parseErrorType(String typeStr) { - try { - // "GRAMMAR | EXPRESSION | VOCABULARY" 형태 처리 - String cleaned = typeStr.replace(" ", "").split("\\|")[0].trim(); - return SpeakingErrorType.valueOf(cleaned.toUpperCase()); - } catch (Exception e) { - logger.warn("알 수 없는 오류 타입: {}, 기본값 GRAMMAR 사용", typeStr); - return SpeakingErrorType.GRAMMAR; - } - } - + + private static final Logger logger = LoggerFactory.getLogger(FeedbackService.class); + private static final Gson gson = new GsonBuilder().setPrettyPrinting().create(); + private static final String MODEL_ID = "anthropic.claude-3-haiku-20240307-v1:0"; + private static final int MAX_TOKENS = 2000; + + /** + * 사용자 답변에 대한 피드백 생성 + */ + public FeedbackResponse generateFeedback(String question, String userAnswer, String targetLevel) { + logger.info("피드백 생성, 대상 Level: {}", targetLevel); + + String prompt = buildFeedbackPrompt(question, userAnswer, targetLevel); + String response = invokeClaude(prompt); + String jsonResponse = JsonUtil.extractJson(response); + + return parseFeedbackResponse(jsonResponse); + } + + + /** + * 세션 종합 리포트 생성 + */ + public SessionReportResponse generateSessionReport(String sessionSummary, String targetLevel) { + logger.info("세션 리포트 생성, 대상 Level: {}", targetLevel); + + String prompt = buildSessionReportPrompt(sessionSummary, targetLevel); + String response = invokeClaude(prompt); + String jsonResponse = JsonUtil.extractJson(response); + + return parseSessionReportResponse(jsonResponse); + } + + + /** + * 개별 질문 피드백 프롬프트 + */ + private String buildFeedbackPrompt(String question, String userAnswer, String targetLevel) { + return String.format(""" + You are an expert OPIc speaking evaluator. + + ## Question + %s + + ## User's Answer + %s + + ## Target Level + %s + + ## Task + Analyze the answer and provide feedback in the following JSON format only: + + { + "errors": [ + { + "type": "GRAMMAR | EXPRESSION | VOCABULARY", + "original": "원본 표현", + "corrected": "교정된 표현", + "explanation": "설명 (한국어)" + } + ], + "correctedAnswer": "전체 교정된 답변 (영어)", + "sampleAnswer": "목표 레벨에 맞는 모범 답변 (영어, 4-6문장)" + } + + Error types: + - GRAMMAR: 문법 오류 (시제, 관사, 주어-동사 일치 등) + - EXPRESSION: 더 자연스러운 표현 제안 + - VOCABULARY: 더 적절하거나 풍부한 어휘 제안 + + Rules: + 1. errors 배열은 최대 5개까지만 포함 + 2. 오류가 없으면 errors는 빈 배열 [] + 3. explanation은 한국어로 간결하게 + 4. sampleAnswer는 목표 레벨에 맞는 자연스러운 답변 + + Respond with ONLY the JSON, no markdown code blocks. + """, question, userAnswer, targetLevel); + } + + /** + * 세션 종합 리포트 프롬프트 + */ + private String buildSessionReportPrompt(String sessionSummary, String targetLevel) { + return String.format(""" + You are an expert OPIc speaking coach creating a comprehensive session report. + + ## Session Summary (Questions and Answers) + %s + + ## Target Level + %s + + ## Task + Generate a detailed learning report in the following JSON format only: + + { + "estimatedLevel": "NL | NM | NH | IL | IM1 | IM2 | IM3 | IH | AL", + "overallScore": 0-100, + "strengths": ["잘한 점 1 (한국어)", "잘한 점 2", "잘한 점 3"], + "weaknesses": ["개선할 점 1 (한국어)", "개선할 점 2", "개선할 점 3"], + "feedback": "종합 피드백 (한국어, 3-4문장, 격려하는 톤)", + "recommendations": ["학습 추천 1 (한국어)", "학습 추천 2"] + } + + Evaluation criteria: + - Task completion: 질문에 적절히 답했는가 + - Fluency: 유창성, 자연스러움 + - Grammar: 문법 정확도 + - Vocabulary: 어휘 다양성 + - Content: 내용의 구체성 + + Be encouraging but honest. Provide specific, actionable feedback in Korean. + Respond with ONLY the JSON, no markdown code blocks. + """, sessionSummary, targetLevel); + } + + + /** + * Claude 호출 (일반 텍스트 응답) + */ + private String invokeClaude(String prompt) { + try { + JsonObject requestBody = buildRequestBody(prompt); + + InvokeModelRequest request = InvokeModelRequest.builder() + .modelId(MODEL_ID) + .contentType("application/json") + .body(SdkBytes.fromUtf8String(gson.toJson(requestBody))) + .build(); + + long startTime = System.currentTimeMillis(); + InvokeModelResponse response = AwsClients.bedrock().invokeModel(request); + long elapsed = System.currentTimeMillis() - startTime; + + logger.info("Bedrock 응답 수신: {}ms", elapsed); + + JsonObject responseJson = JsonParser.parseString( + response.body().asUtf8String() + ).getAsJsonObject(); + + return responseJson + .getAsJsonArray("content") + .get(0) + .getAsJsonObject() + .get("text") + .getAsString(); + + } catch (Exception e) { + logger.error("Bedrock 호출 실패", e); + throw new OPIcException.BedrockApiException(e.getMessage(), e); + } + } + + /** + * Bedrock 요청 Body 생성 + */ + private JsonObject buildRequestBody(String prompt) { + JsonObject requestBody = new JsonObject(); + requestBody.addProperty("anthropic_version", "bedrock-2023-05-31"); + requestBody.addProperty("max_tokens", MAX_TOKENS); + + JsonArray messages = new JsonArray(); + JsonObject userMessage = new JsonObject(); + userMessage.addProperty("role", "user"); + userMessage.addProperty("content", prompt); + messages.add(userMessage); + + requestBody.add("messages", messages); + return requestBody; + } + + // ==================== 응답 파싱 ==================== + + /** + * 피드백 응답 파싱 + *

+ * Claude 응답 JSON 구조: + * { + * "errors": [{ "type": "GRAMMAR", "original": "...", "corrected": "...", "explanation": "..." }], + * "correctedAnswer": "...", + * "sampleAnswer": "..." + * } + */ + private FeedbackResponse parseFeedbackResponse(String jsonResponse) { + try { + JsonObject json = JsonParser.parseString(jsonResponse).getAsJsonObject(); + + // errors 배열 파싱 + List errors = parseErrors(json.getAsJsonArray("errors")); + + // 응답 DTO 생성 + return new FeedbackResponse( + errors, + json.get("correctedAnswer").getAsString(), + json.get("sampleAnswer").getAsString() + ); + + } catch (Exception e) { + logger.error("피드백 파싱 실패: {}", jsonResponse, e); + throw new OPIcException.FeedbackParseException(jsonResponse, e); + } + } + + /** + * 세션 리포트 응답 파싱 + *

+ * Claude 응답 JSON 구조: + * { + * "estimatedLevel": "IM2", + * "overallScore": 72, + * "strengths": ["...", "..."], + * "weaknesses": ["...", "..."], + * "feedback": "...", + * "recommendations": ["...", "..."] + * } + */ + private SessionReportResponse parseSessionReportResponse(String jsonResponse) { + try { + JsonObject json = JsonParser.parseString(jsonResponse).getAsJsonObject(); + + return new SessionReportResponse( + json.get("estimatedLevel").getAsString(), + json.get("overallScore").getAsInt(), + JsonUtil.toStringList(json.getAsJsonArray("strengths")), + JsonUtil.toStringList(json.getAsJsonArray("weaknesses")), + json.get("feedback").getAsString(), + JsonUtil.toStringList(json.getAsJsonArray("recommendations")) + ); + + } catch (Exception e) { + logger.error("세션 리포트 파싱 실패: {}", jsonResponse, e); + throw new OPIcException.ReportParseException(jsonResponse, e); + } + } + + /** + * errors 배열 파싱 + */ + private List parseErrors(JsonArray errorsArray) { + List errors = new ArrayList<>(); + + if (errorsArray == null || errorsArray.isEmpty()) { + return errors; + } + + for (JsonElement el : errorsArray) { + JsonObject obj = el.getAsJsonObject(); + errors.add(SpeakingError.builder() + .type(parseErrorType(obj.get("type").getAsString())) + .original(obj.get("original").getAsString()) + .corrected(obj.get("corrected").getAsString()) + .explanation(obj.get("explanation").getAsString()) + .build()); + } + + return errors; + } + + + /** + * 오류 타입 문자열 -> Enum 변환 + */ + private SpeakingErrorType parseErrorType(String typeStr) { + try { + // "GRAMMAR | EXPRESSION | VOCABULARY" 형태 처리 + String cleaned = typeStr.replace(" ", "").split("\\|")[0].trim(); + return SpeakingErrorType.valueOf(cleaned.toUpperCase()); + } catch (Exception e) { + logger.warn("알 수 없는 오류 타입: {}, 기본값 GRAMMAR 사용", typeStr); + return SpeakingErrorType.GRAMMAR; + } + } + } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingConnectHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingConnectHandler.java index 535a3fc1..256d3990 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingConnectHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingConnectHandler.java @@ -16,73 +16,73 @@ /** * Speaking WebSocket $connect 핸들러 * JWT 토큰 검증 후 연결 정보를 DynamoDB에 저장 - * + *

* 연결 방법: * wss://{api-id}.execute-api.{region}.amazonaws.com/{stage}?token={jwt} */ public class SpeakingConnectHandler implements RequestHandler, Map> { - - private static final Logger logger = LoggerFactory.getLogger(SpeakingConnectHandler.class); - - private final SpeakingConnectionRepository connectionRepository; - - public SpeakingConnectHandler() { - this.connectionRepository = new SpeakingConnectionRepository(); - } - - @Override - public Map handleRequest(Map event, Context context) { - logger.info("Speaking WebSocket connect event"); - - try { - String connectionId = WebSocketEventUtil.extractConnectionId(event); - Map queryParams = WebSocketEventUtil.extractQueryStringParameters(event); - - // JWT 토큰 검증 - String token = queryParams.get("token"); - - if (token == null || token.isEmpty()) { - logger.warn("Missing token parameter"); - return WebSocketEventUtil.unauthorized("token is required"); - } - - // 토큰 유효성 검사 - if (!JwtUtil.isValid(token)) { - logger.warn("Invalid or expired token"); - return WebSocketEventUtil.unauthorized("Invalid or expired token"); - } - - // userId 추출 - Optional userIdOpt = JwtUtil.extractUserId(token); - if (userIdOpt.isEmpty()) { - logger.warn("Failed to extract userId from token"); - return WebSocketEventUtil.unauthorized("Invalid token"); - } - - String userId = userIdOpt.get(); - - // 연결 정보 저장 - SpeakingConnection connection = SpeakingConnection.create( - connectionId, - userId, - WebSocketConfig.connectionTtlSeconds() - ); - - // 레벨 파라미터가 있으면 설정 - String level = queryParams.get("level"); - if (level != null && !level.isEmpty()) { - connection.setTargetLevel(level.toUpperCase()); - } - - connectionRepository.save(connection); - - logger.info("Speaking connection established: connectionId={}, userId={}, level={}", - connectionId, userId, connection.getTargetLevel()); - return WebSocketEventUtil.ok("Connected"); - - } catch (Exception e) { - logger.error("Error handling connect: {}", e.getMessage(), e); - return WebSocketEventUtil.serverError("Internal server error"); - } - } -} \ No newline at end of file + + private static final Logger logger = LoggerFactory.getLogger(SpeakingConnectHandler.class); + + private final SpeakingConnectionRepository connectionRepository; + + public SpeakingConnectHandler() { + this.connectionRepository = new SpeakingConnectionRepository(); + } + + @Override + public Map handleRequest(Map event, Context context) { + logger.info("Speaking WebSocket connect event"); + + try { + String connectionId = WebSocketEventUtil.extractConnectionId(event); + Map queryParams = WebSocketEventUtil.extractQueryStringParameters(event); + + // JWT 토큰 검증 + String token = queryParams.get("token"); + + if (token == null || token.isEmpty()) { + logger.warn("Missing token parameter"); + return WebSocketEventUtil.unauthorized("token is required"); + } + + // 토큰 유효성 검사 + if (!JwtUtil.isValid(token)) { + logger.warn("Invalid or expired token"); + return WebSocketEventUtil.unauthorized("Invalid or expired token"); + } + + // userId 추출 + Optional userIdOpt = JwtUtil.extractUserId(token); + if (userIdOpt.isEmpty()) { + logger.warn("Failed to extract userId from token"); + return WebSocketEventUtil.unauthorized("Invalid token"); + } + + String userId = userIdOpt.get(); + + // 연결 정보 저장 + SpeakingConnection connection = SpeakingConnection.create( + connectionId, + userId, + WebSocketConfig.connectionTtlSeconds() + ); + + // 레벨 파라미터가 있으면 설정 + String level = queryParams.get("level"); + if (level != null && !level.isEmpty()) { + connection.setTargetLevel(level.toUpperCase()); + } + + connectionRepository.save(connection); + + logger.info("Speaking connection established: connectionId={}, userId={}, level={}", + connectionId, userId, connection.getTargetLevel()); + return WebSocketEventUtil.ok("Connected"); + + } catch (Exception e) { + logger.error("Error handling connect: {}", e.getMessage(), e); + return WebSocketEventUtil.serverError("Internal server error"); + } + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingDisconnectHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingDisconnectHandler.java index 4d82a9d1..bd46d3b4 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingDisconnectHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingDisconnectHandler.java @@ -14,31 +14,31 @@ * 연결 해제 시 DynamoDB에서 연결 정보 삭제 */ public class SpeakingDisconnectHandler implements RequestHandler, Map> { - - private static final Logger logger = LoggerFactory.getLogger(SpeakingDisconnectHandler.class); - - private final SpeakingConnectionRepository connectionRepository; - - public SpeakingDisconnectHandler() { - this.connectionRepository = new SpeakingConnectionRepository(); - } - - @Override - public Map handleRequest(Map event, Context context) { - logger.info("Speaking WebSocket disconnect event"); - - try { - String connectionId = WebSocketEventUtil.extractConnectionId(event); - - // 연결 정보 삭제 - connectionRepository.delete(connectionId); - - logger.info("Speaking connection closed: connectionId={}", connectionId); - return WebSocketEventUtil.ok("Disconnected"); - - } catch (Exception e) { - logger.error("Error handling disconnect: {}", e.getMessage(), e); - return WebSocketEventUtil.serverError("Internal server error"); - } - } -} \ No newline at end of file + + private static final Logger logger = LoggerFactory.getLogger(SpeakingDisconnectHandler.class); + + private final SpeakingConnectionRepository connectionRepository; + + public SpeakingDisconnectHandler() { + this.connectionRepository = new SpeakingConnectionRepository(); + } + + @Override + public Map handleRequest(Map event, Context context) { + logger.info("Speaking WebSocket disconnect event"); + + try { + String connectionId = WebSocketEventUtil.extractConnectionId(event); + + // 연결 정보 삭제 + connectionRepository.delete(connectionId); + + logger.info("Speaking connection closed: connectionId={}", connectionId); + return WebSocketEventUtil.ok("Disconnected"); + + } catch (Exception e) { + logger.error("Error handling disconnect: {}", e.getMessage(), e); + return WebSocketEventUtil.serverError("Internal server error"); + } + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingMessageHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingMessageHandler.java index 89ec0a44..24ecfc3c 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingMessageHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingMessageHandler.java @@ -20,7 +20,7 @@ /** * Speaking WebSocket 메시지 핸들러 - * + *

* 지원하는 action: * - speak: 음성 입력 처리 (audio base64) * - text: 텍스트 입력 처리 @@ -28,190 +28,190 @@ * - reset: 대화 히스토리 초기화 */ public class SpeakingMessageHandler implements RequestHandler, Map> { - - private static final Logger logger = LoggerFactory.getLogger(SpeakingMessageHandler.class); - private static final Gson gson = new GsonBuilder().create(); - - private final SpeakingService speakingService; - private final SpeakingConnectionRepository connectionRepository; - - public SpeakingMessageHandler() { - this.speakingService = new SpeakingService(); - this.connectionRepository = new SpeakingConnectionRepository(); - } - - @Override - public Map handleRequest(Map event, Context context) { - logger.info("Speaking message event received"); - - String connectionId = null; - String endpoint = null; - - try { - connectionId = WebSocketEventUtil.extractConnectionId(event); - endpoint = WebSocketEventUtil.extractWebSocketEndpoint(event); - - // 연결 정보 확인 - if (connectionRepository.findByConnectionId(connectionId).isEmpty()) { - logger.warn("Connection not found: {}", connectionId); - return sendError(connectionId, endpoint, "Unauthorized - please reconnect"); - } - - // 요청 바디 파싱 - String body = (String) event.get("body"); - if (body == null || body.isEmpty()) { - return sendError(connectionId, endpoint, "Message body is required"); - } - - JsonObject request = JsonParser.parseString(body).getAsJsonObject(); - String action = request.has("action") ? request.get("action").getAsString() : "speak"; - - logger.info("Processing action: {} for connectionId: {}", action, connectionId); - - // 액션별 처리 - switch (action) { - case "speak" -> handleSpeak(connectionId, endpoint, request); - case "text" -> handleText(connectionId, endpoint, request); - case "setLevel" -> handleSetLevel(connectionId, endpoint, request); - case "reset" -> handleReset(connectionId, endpoint); - default -> sendError(connectionId, endpoint, "Unknown action: " + action); - } - - return WebSocketEventUtil.ok("Processed"); - - } catch (Exception e) { - logger.error("Error processing message: {}", e.getMessage(), e); - if (connectionId != null && endpoint != null) { - sendError(connectionId, endpoint, "Processing error: " + e.getMessage()); - } - return WebSocketEventUtil.serverError("Internal server error"); - } - } - - /** - * 음성 입력 처리 - */ - private void handleSpeak(String connectionId, String endpoint, JsonObject request) { - // 시작 이벤트 전송 - sendToConnection(connectionId, endpoint, Map.of( - "type", "start", - "message", "Processing your voice..." - )); - - // 음성 데이터 추출 - String audioBase64 = request.has("audio") ? request.get("audio").getAsString() : null; - if (audioBase64 == null || audioBase64.isEmpty()) { - sendError(connectionId, endpoint, "audio data is required for speak action"); - return; - } - - // 음성 처리 - SpeakingService.SpeakingResponse response = speakingService.processVoiceInput( - connectionId, audioBase64 - ); - - // 결과 전송 - sendToConnection(connectionId, endpoint, Map.of( - "type", "complete", - "userTranscript", response.userTranscript(), - "aiText", response.aiText(), - "aiAudioUrl", response.aiAudioUrl(), - "confidence", response.confidence() - )); - } - - /** - * 텍스트 입력 처리 - */ - private void handleText(String connectionId, String endpoint, JsonObject request) { - String text = request.has("text") ? request.get("text").getAsString() : null; - if (text == null || text.trim().isEmpty()) { - sendError(connectionId, endpoint, "text is required for text action"); - return; - } - - // 시작 이벤트 전송 - sendToConnection(connectionId, endpoint, Map.of( - "type", "start", - "message", "Processing your message..." - )); - - // 텍스트 처리 - SpeakingService.SpeakingResponse response = speakingService.processTextInput( - connectionId, text.trim() - ); - - // 결과 전송 - sendToConnection(connectionId, endpoint, Map.of( - "type", "complete", - "userTranscript", response.userTranscript(), - "aiText", response.aiText(), - "aiAudioUrl", response.aiAudioUrl(), - "confidence", response.confidence() - )); - } - - /** - * 레벨 변경 처리 - */ - private void handleSetLevel(String connectionId, String endpoint, JsonObject request) { - String level = request.has("level") ? request.get("level").getAsString() : null; - if (level == null || level.isEmpty()) { - sendError(connectionId, endpoint, "level is required"); - return; - } - - speakingService.updateLevel(connectionId, level); - - sendToConnection(connectionId, endpoint, Map.of( - "type", "levelChanged", - "level", level.toUpperCase() - )); - } - - /** - * 대화 초기화 처리 - */ - private void handleReset(String connectionId, String endpoint) { - speakingService.resetConversation(connectionId); - - sendToConnection(connectionId, endpoint, Map.of( - "type", "reset", - "message", "Conversation has been reset. Let's start fresh!" - )); - } - - /** - * WebSocket으로 메시지 전송 - */ - private void sendToConnection(String connectionId, String endpoint, Map data) { - try { - ApiGatewayManagementApiClient apiClient = ApiGatewayManagementApiClient.builder() - .endpointOverride(URI.create(endpoint)) - .build(); - - String message = gson.toJson(data); - - apiClient.postToConnection(PostToConnectionRequest.builder() - .connectionId(connectionId) - .data(SdkBytes.fromUtf8String(message)) - .build()); - - logger.debug("Message sent to {}: {}", connectionId, data.get("type")); - - } catch (Exception e) { - logger.error("Failed to send message to {}: {}", connectionId, e.getMessage()); - } - } - - /** - * 에러 메시지 전송 - */ - private Map sendError(String connectionId, String endpoint, String errorMessage) { - sendToConnection(connectionId, endpoint, Map.of( - "type", "error", - "message", errorMessage - )); - return WebSocketEventUtil.ok("Error sent"); - } + + private static final Logger logger = LoggerFactory.getLogger(SpeakingMessageHandler.class); + private static final Gson gson = new GsonBuilder().create(); + + private final SpeakingService speakingService; + private final SpeakingConnectionRepository connectionRepository; + + public SpeakingMessageHandler() { + this.speakingService = new SpeakingService(); + this.connectionRepository = new SpeakingConnectionRepository(); + } + + @Override + public Map handleRequest(Map event, Context context) { + logger.info("Speaking message event received"); + + String connectionId = null; + String endpoint = null; + + try { + connectionId = WebSocketEventUtil.extractConnectionId(event); + endpoint = WebSocketEventUtil.extractWebSocketEndpoint(event); + + // 연결 정보 확인 + if (connectionRepository.findByConnectionId(connectionId).isEmpty()) { + logger.warn("Connection not found: {}", connectionId); + return sendError(connectionId, endpoint, "Unauthorized - please reconnect"); + } + + // 요청 바디 파싱 + String body = (String) event.get("body"); + if (body == null || body.isEmpty()) { + return sendError(connectionId, endpoint, "Message body is required"); + } + + JsonObject request = JsonParser.parseString(body).getAsJsonObject(); + String action = request.has("action") ? request.get("action").getAsString() : "speak"; + + logger.info("Processing action: {} for connectionId: {}", action, connectionId); + + // 액션별 처리 + switch (action) { + case "speak" -> handleSpeak(connectionId, endpoint, request); + case "text" -> handleText(connectionId, endpoint, request); + case "setLevel" -> handleSetLevel(connectionId, endpoint, request); + case "reset" -> handleReset(connectionId, endpoint); + default -> sendError(connectionId, endpoint, "Unknown action: " + action); + } + + return WebSocketEventUtil.ok("Processed"); + + } catch (Exception e) { + logger.error("Error processing message: {}", e.getMessage(), e); + if (connectionId != null && endpoint != null) { + sendError(connectionId, endpoint, "Processing error: " + e.getMessage()); + } + return WebSocketEventUtil.serverError("Internal server error"); + } + } + + /** + * 음성 입력 처리 + */ + private void handleSpeak(String connectionId, String endpoint, JsonObject request) { + // 시작 이벤트 전송 + sendToConnection(connectionId, endpoint, Map.of( + "type", "start", + "message", "Processing your voice..." + )); + + // 음성 데이터 추출 + String audioBase64 = request.has("audio") ? request.get("audio").getAsString() : null; + if (audioBase64 == null || audioBase64.isEmpty()) { + sendError(connectionId, endpoint, "audio data is required for speak action"); + return; + } + + // 음성 처리 + SpeakingService.SpeakingResponse response = speakingService.processVoiceInput( + connectionId, audioBase64 + ); + + // 결과 전송 + sendToConnection(connectionId, endpoint, Map.of( + "type", "complete", + "userTranscript", response.userTranscript(), + "aiText", response.aiText(), + "aiAudioUrl", response.aiAudioUrl(), + "confidence", response.confidence() + )); + } + + /** + * 텍스트 입력 처리 + */ + private void handleText(String connectionId, String endpoint, JsonObject request) { + String text = request.has("text") ? request.get("text").getAsString() : null; + if (text == null || text.trim().isEmpty()) { + sendError(connectionId, endpoint, "text is required for text action"); + return; + } + + // 시작 이벤트 전송 + sendToConnection(connectionId, endpoint, Map.of( + "type", "start", + "message", "Processing your message..." + )); + + // 텍스트 처리 + SpeakingService.SpeakingResponse response = speakingService.processTextInput( + connectionId, text.trim() + ); + + // 결과 전송 + sendToConnection(connectionId, endpoint, Map.of( + "type", "complete", + "userTranscript", response.userTranscript(), + "aiText", response.aiText(), + "aiAudioUrl", response.aiAudioUrl(), + "confidence", response.confidence() + )); + } + + /** + * 레벨 변경 처리 + */ + private void handleSetLevel(String connectionId, String endpoint, JsonObject request) { + String level = request.has("level") ? request.get("level").getAsString() : null; + if (level == null || level.isEmpty()) { + sendError(connectionId, endpoint, "level is required"); + return; + } + + speakingService.updateLevel(connectionId, level); + + sendToConnection(connectionId, endpoint, Map.of( + "type", "levelChanged", + "level", level.toUpperCase() + )); + } + + /** + * 대화 초기화 처리 + */ + private void handleReset(String connectionId, String endpoint) { + speakingService.resetConversation(connectionId); + + sendToConnection(connectionId, endpoint, Map.of( + "type", "reset", + "message", "Conversation has been reset. Let's start fresh!" + )); + } + + /** + * WebSocket으로 메시지 전송 + */ + private void sendToConnection(String connectionId, String endpoint, Map data) { + try { + ApiGatewayManagementApiClient apiClient = ApiGatewayManagementApiClient.builder() + .endpointOverride(URI.create(endpoint)) + .build(); + + String message = gson.toJson(data); + + apiClient.postToConnection(PostToConnectionRequest.builder() + .connectionId(connectionId) + .data(SdkBytes.fromUtf8String(message)) + .build()); + + logger.debug("Message sent to {}: {}", connectionId, data.get("type")); + + } catch (Exception e) { + logger.error("Failed to send message to {}: {}", connectionId, e.getMessage()); + } + } + + /** + * 에러 메시지 전송 + */ + private Map sendError(String connectionId, String endpoint, String errorMessage) { + sendToConnection(connectionId, endpoint, Map.of( + "type", "error", + "message", errorMessage + )); + return WebSocketEventUtil.ok("Error sent"); + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/SpeakingConnection.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/SpeakingConnection.java index 6ea0c185..133e7773 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/SpeakingConnection.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/SpeakingConnection.java @@ -16,69 +16,69 @@ @AllArgsConstructor @DynamoDbBean public class SpeakingConnection { - - // DynamoDB Key Prefixes - public static final String PK_PREFIX = "SPEAKING_CONN#"; - public static final String SK_METADATA = "METADATA"; - public static final String GSI1PK_PREFIX = "SPEAKING_USER#"; - public static final String GSI1SK_PREFIX = "CONN#"; - - private String pk; // SPEAKING_CONN#{connectionId} - private String sk; // METADATA - private String gsi1pk; // SPEAKING_USER#{userId} - private String gsi1sk; // CONN#{connectionId} - - private String connectionId; - private String userId; - private String connectedAt; - private Long ttl; // 자동 삭제용 - - // Speaking 전용 필드 - private String conversationHistory; // 대화 히스토리 (JSON) - private String targetLevel; // 목표 레벨 (BEGINNER, INTERMEDIATE, ADVANCED) - - /** - * 연결 정보 생성 팩토리 메서드 - */ - public static SpeakingConnection create(String connectionId, String userId, long ttlSeconds) { - String now = java.time.Instant.now().toString(); - long ttl = java.time.Instant.now().plusSeconds(ttlSeconds).getEpochSecond(); - - return SpeakingConnection.builder() - .pk(PK_PREFIX + connectionId) - .sk(SK_METADATA) - .gsi1pk(GSI1PK_PREFIX + userId) - .gsi1sk(GSI1SK_PREFIX + connectionId) - .connectionId(connectionId) - .userId(userId) - .connectedAt(now) - .ttl(ttl) - .conversationHistory("[]") // 빈 배열로 초기화 - .targetLevel("INTERMEDIATE") // 기본값 - .build(); - } - - @DynamoDbPartitionKey - @DynamoDbAttribute("PK") - public String getPk() { - return pk; - } - - @DynamoDbSortKey - @DynamoDbAttribute("SK") - public String getSk() { - return sk; - } - - @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") - @DynamoDbAttribute("GSI1PK") - public String getGsi1pk() { - return gsi1pk; - } - - @DynamoDbSecondarySortKey(indexNames = "GSI1") - @DynamoDbAttribute("GSI1SK") - public String getGsi1sk() { - return gsi1sk; - } -} \ No newline at end of file + + // DynamoDB Key Prefixes + public static final String PK_PREFIX = "SPEAKING_CONN#"; + public static final String SK_METADATA = "METADATA"; + public static final String GSI1PK_PREFIX = "SPEAKING_USER#"; + public static final String GSI1SK_PREFIX = "CONN#"; + + private String pk; // SPEAKING_CONN#{connectionId} + private String sk; // METADATA + private String gsi1pk; // SPEAKING_USER#{userId} + private String gsi1sk; // CONN#{connectionId} + + private String connectionId; + private String userId; + private String connectedAt; + private Long ttl; // 자동 삭제용 + + // Speaking 전용 필드 + private String conversationHistory; // 대화 히스토리 (JSON) + private String targetLevel; // 목표 레벨 (BEGINNER, INTERMEDIATE, ADVANCED) + + /** + * 연결 정보 생성 팩토리 메서드 + */ + public static SpeakingConnection create(String connectionId, String userId, long ttlSeconds) { + String now = java.time.Instant.now().toString(); + long ttl = java.time.Instant.now().plusSeconds(ttlSeconds).getEpochSecond(); + + return SpeakingConnection.builder() + .pk(PK_PREFIX + connectionId) + .sk(SK_METADATA) + .gsi1pk(GSI1PK_PREFIX + userId) + .gsi1sk(GSI1SK_PREFIX + connectionId) + .connectionId(connectionId) + .userId(userId) + .connectedAt(now) + .ttl(ttl) + .conversationHistory("[]") // 빈 배열로 초기화 + .targetLevel("INTERMEDIATE") // 기본값 + .build(); + } + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { + return pk; + } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { + return sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1PK") + public String getGsi1pk() { + return gsi1pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1SK") + public String getGsi1sk() { + return gsi1sk; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java index 14b468d1..bbb74d7c 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java @@ -15,59 +15,59 @@ * Speaking WebSocket 연결 정보 Repository */ public class SpeakingConnectionRepository { - - private static final Logger logger = LoggerFactory.getLogger(SpeakingConnectionRepository.class); - private static final String TABLE_NAME = EnvConfig.getRequired("CHAT_TABLE_NAME"); - - private final DynamoDbTable table; - - public SpeakingConnectionRepository() { - this.table = AwsClients.dynamoDbEnhanced().table( - TABLE_NAME, - TableSchema.fromBean(SpeakingConnection.class) - ); - } - - /** - * 연결 정보 저장 - */ - public void save(SpeakingConnection connection) { - table.putItem(connection); - logger.debug("Speaking connection saved: connectionId={}, userId={}", - connection.getConnectionId(), connection.getUserId()); - } - - /** - * connectionId로 연결 정보 조회 - */ - public Optional findByConnectionId(String connectionId) { - Key key = Key.builder() - .partitionValue(SpeakingConnection.PK_PREFIX + connectionId) - .sortValue(SpeakingConnection.SK_METADATA) - .build(); - - SpeakingConnection connection = table.getItem(key); - return Optional.ofNullable(connection); - } - - /** - * 연결 정보 업데이트 (대화 히스토리 등) - */ - public void update(SpeakingConnection connection) { - table.putItem(connection); - logger.debug("Speaking connection updated: connectionId={}", connection.getConnectionId()); - } - - /** - * 연결 정보 삭제 - */ - public void delete(String connectionId) { - Key key = Key.builder() - .partitionValue(SpeakingConnection.PK_PREFIX + connectionId) - .sortValue(SpeakingConnection.SK_METADATA) - .build(); - - table.deleteItem(key); - logger.info("Speaking connection deleted: connectionId={}", connectionId); - } -} \ No newline at end of file + + private static final Logger logger = LoggerFactory.getLogger(SpeakingConnectionRepository.class); + private static final String TABLE_NAME = EnvConfig.getRequired("CHAT_TABLE_NAME"); + + private final DynamoDbTable table; + + public SpeakingConnectionRepository() { + this.table = AwsClients.dynamoDbEnhanced().table( + TABLE_NAME, + TableSchema.fromBean(SpeakingConnection.class) + ); + } + + /** + * 연결 정보 저장 + */ + public void save(SpeakingConnection connection) { + table.putItem(connection); + logger.debug("Speaking connection saved: connectionId={}, userId={}", + connection.getConnectionId(), connection.getUserId()); + } + + /** + * connectionId로 연결 정보 조회 + */ + public Optional findByConnectionId(String connectionId) { + Key key = Key.builder() + .partitionValue(SpeakingConnection.PK_PREFIX + connectionId) + .sortValue(SpeakingConnection.SK_METADATA) + .build(); + + SpeakingConnection connection = table.getItem(key); + return Optional.ofNullable(connection); + } + + /** + * 연결 정보 업데이트 (대화 히스토리 등) + */ + public void update(SpeakingConnection connection) { + table.putItem(connection); + logger.debug("Speaking connection updated: connectionId={}", connection.getConnectionId()); + } + + /** + * 연결 정보 삭제 + */ + public void delete(String connectionId) { + Key key = Key.builder() + .partitionValue(SpeakingConnection.PK_PREFIX + connectionId) + .sortValue(SpeakingConnection.SK_METADATA) + .build(); + + table.deleteItem(key); + logger.info("Speaking connection deleted: connectionId={}", connectionId); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/service/SpeakingService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/service/SpeakingService.java index 3462bbfb..7c428ddc 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/service/SpeakingService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/service/SpeakingService.java @@ -13,7 +13,6 @@ import software.amazon.awssdk.services.bedrockruntime.model.InvokeModelRequest; import software.amazon.awssdk.services.bedrockruntime.model.InvokeModelResponse; - import java.util.ArrayList; import java.util.List; @@ -22,220 +21,220 @@ * 음성 입력 → STT → Bedrock → TTS → 음성 출력 */ public class SpeakingService { - - private static final Logger logger = LoggerFactory.getLogger(SpeakingService.class); - private static final Gson gson = new GsonBuilder().create(); - - private static final String MODEL_ID = "anthropic.claude-3-haiku-20240307-v1:0"; - private static final int MAX_TOKENS = 500; - private static final int MAX_HISTORY_SIZE = 10; // 최근 10턴만 유지 - - private final TranscribeProxyService transcribeService; - private final PollyService pollyService; - private final SpeakingConnectionRepository connectionRepository; - - public SpeakingService() { - this.transcribeService = new TranscribeProxyService(); - this.pollyService = new PollyService( - EnvConfig.getRequired("BUCKET_NAME"), - "speaking/voice/" - ); - this.connectionRepository = new SpeakingConnectionRepository(); - } - - /** - * 음성 입력 처리 (전체 플로우) - */ - public SpeakingResponse processVoiceInput(String connectionId, String audioBase64) { - logger.info("Processing voice input for connectionId: {}", connectionId); - - // 연결 정보 조회 - SpeakingConnection connection = connectionRepository.findByConnectionId(connectionId) - .orElseThrow(() -> new RuntimeException("Connection not found: " + connectionId)); - - String targetLevel = connection.getTargetLevel(); - - // STT: 음성 → 텍스트 (Transcribe Proxy 사용) - logger.info("Step 1: Transcribing audio..."); - TranscribeProxyService.TranscribeResult sttResult = transcribeService.transcribe( - audioBase64, - connectionId, - "en-US" - ); - String userText = sttResult.transcript(); - logger.info("Transcription complete: {} (confidence: {})", userText, sttResult.confidence()); - - // 대화 히스토리 로드 - List history = parseHistory(connection.getConversationHistory()); - - // Bedrock: AI 응답 생성 - logger.info("Step 2: Generating AI response..."); - String aiResponse = generateAiResponse(userText, history, targetLevel); - logger.info("AI response generated: {}", aiResponse); - - // 히스토리 업데이트 (최근 N턴만 유지) - history.add(new Message("user", userText)); - history.add(new Message("assistant", aiResponse)); - if (history.size() > MAX_HISTORY_SIZE * 2) { - history = new ArrayList<>(history.subList(history.size() - MAX_HISTORY_SIZE * 2, history.size())); - } - connection.setConversationHistory(toJson(history)); - connectionRepository.update(connection); - - // TTS: 텍스트 → 음성 (Polly 사용) - logger.info("Step 3: Synthesizing speech..."); - String audioId = connectionId + "_" + System.currentTimeMillis(); - PollyService.VoiceSynthesisResult ttsResult = pollyService.synthesizeSpeech( - audioId, - aiResponse, - "FEMALE" - ); - logger.info("Speech synthesis complete: cached={}", ttsResult.isCached()); - - return new SpeakingResponse( - userText, - aiResponse, - ttsResult.getAudioUrl(), - sttResult.confidence() - ); - } - - /** - * 텍스트 입력 처리 (음성 없이 텍스트만) - */ - public SpeakingResponse processTextInput(String connectionId, String userText) { - logger.info("Processing text input for connectionId: {}", connectionId); - - // 연결 정보 조회 - SpeakingConnection connection = connectionRepository.findByConnectionId(connectionId) - .orElseThrow(() -> new RuntimeException("Connection not found: " + connectionId)); - - String targetLevel = connection.getTargetLevel(); - - // 대화 히스토리 로드 - List history = parseHistory(connection.getConversationHistory()); - - // AI 응답 생성 - String aiResponse = generateAiResponse(userText, history, targetLevel); - - // 히스토리 업데이트 - history.add(new Message("user", userText)); - history.add(new Message("assistant", aiResponse)); - if (history.size() > MAX_HISTORY_SIZE * 2) { - history = new ArrayList<>(history.subList(history.size() - MAX_HISTORY_SIZE * 2, history.size())); - } - connection.setConversationHistory(toJson(history)); - connectionRepository.update(connection); - - // TTS 생성 - String audioId = connectionId + "_" + System.currentTimeMillis(); - PollyService.VoiceSynthesisResult ttsResult = pollyService.synthesizeSpeech( - audioId, aiResponse, "FEMALE" - ); - - return new SpeakingResponse(userText, aiResponse, ttsResult.getAudioUrl(), 1.0); - } - - /** - * 레벨 변경 - */ - public void updateLevel(String connectionId, String level) { - SpeakingConnection connection = connectionRepository.findByConnectionId(connectionId) - .orElseThrow(() -> new RuntimeException("Connection not found: " + connectionId)); - - connection.setTargetLevel(level.toUpperCase()); - connectionRepository.update(connection); - logger.info("Level updated for connectionId {}: {}", connectionId, level); - } - - /** - * 대화 히스토리 초기화 - */ - public void resetConversation(String connectionId) { - SpeakingConnection connection = connectionRepository.findByConnectionId(connectionId) - .orElseThrow(() -> new RuntimeException("Connection not found: " + connectionId)); - - connection.setConversationHistory("[]"); - connectionRepository.update(connection); - logger.info("Conversation reset for connectionId: {}", connectionId); - } - - - /** - * Bedrock Claude 호출하여 AI 응답 생성 - */ - private String generateAiResponse(String userText, List history, String targetLevel) { - String systemPrompt = buildSystemPrompt(targetLevel); - - JsonObject requestBody = new JsonObject(); - requestBody.addProperty("anthropic_version", "bedrock-2023-05-31"); - requestBody.addProperty("max_tokens", MAX_TOKENS); - requestBody.addProperty("system", systemPrompt); - - // 메시지 배열 구성 - JsonArray messages = new JsonArray(); - - // 기존 히스토리 추가 - for (Message msg : history) { - JsonObject m = new JsonObject(); - m.addProperty("role", msg.role()); - m.addProperty("content", msg.content()); - messages.add(m); - } - - // 현재 사용자 입력 추가 - JsonObject userMsg = new JsonObject(); - userMsg.addProperty("role", "user"); - userMsg.addProperty("content", userText); - messages.add(userMsg); - - requestBody.add("messages", messages); - - // Bedrock 호출 - InvokeModelResponse response = AwsClients.bedrock().invokeModel( - InvokeModelRequest.builder() - .modelId(MODEL_ID) - .contentType("application/json") - .body(SdkBytes.fromUtf8String(requestBody.toString())) - .build() - ); - - // 응답 파싱 - JsonObject result = JsonParser.parseString( - response.body().asUtf8String() - ).getAsJsonObject(); - - return result.getAsJsonArray("content") - .get(0).getAsJsonObject() - .get("text").getAsString(); - } - - /** - * 레벨별 시스템 프롬프트 생성 - */ - private String buildSystemPrompt(String targetLevel) { - String levelGuidance = switch (targetLevel.toUpperCase()) { - case "BEGINNER" -> """ + + private static final Logger logger = LoggerFactory.getLogger(SpeakingService.class); + private static final Gson gson = new GsonBuilder().create(); + + private static final String MODEL_ID = "anthropic.claude-3-haiku-20240307-v1:0"; + private static final int MAX_TOKENS = 500; + private static final int MAX_HISTORY_SIZE = 10; // 최근 10턴만 유지 + + private final TranscribeProxyService transcribeService; + private final PollyService pollyService; + private final SpeakingConnectionRepository connectionRepository; + + public SpeakingService() { + this.transcribeService = new TranscribeProxyService(); + this.pollyService = new PollyService( + EnvConfig.getRequired("BUCKET_NAME"), + "speaking/voice/" + ); + this.connectionRepository = new SpeakingConnectionRepository(); + } + + /** + * 음성 입력 처리 (전체 플로우) + */ + public SpeakingResponse processVoiceInput(String connectionId, String audioBase64) { + logger.info("Processing voice input for connectionId: {}", connectionId); + + // 연결 정보 조회 + SpeakingConnection connection = connectionRepository.findByConnectionId(connectionId) + .orElseThrow(() -> new RuntimeException("Connection not found: " + connectionId)); + + String targetLevel = connection.getTargetLevel(); + + // STT: 음성 → 텍스트 (Transcribe Proxy 사용) + logger.info("Step 1: Transcribing audio..."); + TranscribeProxyService.TranscribeResult sttResult = transcribeService.transcribe( + audioBase64, + connectionId, + "en-US" + ); + String userText = sttResult.transcript(); + logger.info("Transcription complete: {} (confidence: {})", userText, sttResult.confidence()); + + // 대화 히스토리 로드 + List history = parseHistory(connection.getConversationHistory()); + + // Bedrock: AI 응답 생성 + logger.info("Step 2: Generating AI response..."); + String aiResponse = generateAiResponse(userText, history, targetLevel); + logger.info("AI response generated: {}", aiResponse); + + // 히스토리 업데이트 (최근 N턴만 유지) + history.add(new Message("user", userText)); + history.add(new Message("assistant", aiResponse)); + if (history.size() > MAX_HISTORY_SIZE * 2) { + history = new ArrayList<>(history.subList(history.size() - MAX_HISTORY_SIZE * 2, history.size())); + } + connection.setConversationHistory(toJson(history)); + connectionRepository.update(connection); + + // TTS: 텍스트 → 음성 (Polly 사용) + logger.info("Step 3: Synthesizing speech..."); + String audioId = connectionId + "_" + System.currentTimeMillis(); + PollyService.VoiceSynthesisResult ttsResult = pollyService.synthesizeSpeech( + audioId, + aiResponse, + "FEMALE" + ); + logger.info("Speech synthesis complete: cached={}", ttsResult.isCached()); + + return new SpeakingResponse( + userText, + aiResponse, + ttsResult.getAudioUrl(), + sttResult.confidence() + ); + } + + /** + * 텍스트 입력 처리 (음성 없이 텍스트만) + */ + public SpeakingResponse processTextInput(String connectionId, String userText) { + logger.info("Processing text input for connectionId: {}", connectionId); + + // 연결 정보 조회 + SpeakingConnection connection = connectionRepository.findByConnectionId(connectionId) + .orElseThrow(() -> new RuntimeException("Connection not found: " + connectionId)); + + String targetLevel = connection.getTargetLevel(); + + // 대화 히스토리 로드 + List history = parseHistory(connection.getConversationHistory()); + + // AI 응답 생성 + String aiResponse = generateAiResponse(userText, history, targetLevel); + + // 히스토리 업데이트 + history.add(new Message("user", userText)); + history.add(new Message("assistant", aiResponse)); + if (history.size() > MAX_HISTORY_SIZE * 2) { + history = new ArrayList<>(history.subList(history.size() - MAX_HISTORY_SIZE * 2, history.size())); + } + connection.setConversationHistory(toJson(history)); + connectionRepository.update(connection); + + // TTS 생성 + String audioId = connectionId + "_" + System.currentTimeMillis(); + PollyService.VoiceSynthesisResult ttsResult = pollyService.synthesizeSpeech( + audioId, aiResponse, "FEMALE" + ); + + return new SpeakingResponse(userText, aiResponse, ttsResult.getAudioUrl(), 1.0); + } + + /** + * 레벨 변경 + */ + public void updateLevel(String connectionId, String level) { + SpeakingConnection connection = connectionRepository.findByConnectionId(connectionId) + .orElseThrow(() -> new RuntimeException("Connection not found: " + connectionId)); + + connection.setTargetLevel(level.toUpperCase()); + connectionRepository.update(connection); + logger.info("Level updated for connectionId {}: {}", connectionId, level); + } + + /** + * 대화 히스토리 초기화 + */ + public void resetConversation(String connectionId) { + SpeakingConnection connection = connectionRepository.findByConnectionId(connectionId) + .orElseThrow(() -> new RuntimeException("Connection not found: " + connectionId)); + + connection.setConversationHistory("[]"); + connectionRepository.update(connection); + logger.info("Conversation reset for connectionId: {}", connectionId); + } + + + /** + * Bedrock Claude 호출하여 AI 응답 생성 + */ + private String generateAiResponse(String userText, List history, String targetLevel) { + String systemPrompt = buildSystemPrompt(targetLevel); + + JsonObject requestBody = new JsonObject(); + requestBody.addProperty("anthropic_version", "bedrock-2023-05-31"); + requestBody.addProperty("max_tokens", MAX_TOKENS); + requestBody.addProperty("system", systemPrompt); + + // 메시지 배열 구성 + JsonArray messages = new JsonArray(); + + // 기존 히스토리 추가 + for (Message msg : history) { + JsonObject m = new JsonObject(); + m.addProperty("role", msg.role()); + m.addProperty("content", msg.content()); + messages.add(m); + } + + // 현재 사용자 입력 추가 + JsonObject userMsg = new JsonObject(); + userMsg.addProperty("role", "user"); + userMsg.addProperty("content", userText); + messages.add(userMsg); + + requestBody.add("messages", messages); + + // Bedrock 호출 + InvokeModelResponse response = AwsClients.bedrock().invokeModel( + InvokeModelRequest.builder() + .modelId(MODEL_ID) + .contentType("application/json") + .body(SdkBytes.fromUtf8String(requestBody.toString())) + .build() + ); + + // 응답 파싱 + JsonObject result = JsonParser.parseString( + response.body().asUtf8String() + ).getAsJsonObject(); + + return result.getAsJsonArray("content") + .get(0).getAsJsonObject() + .get("text").getAsString(); + } + + /** + * 레벨별 시스템 프롬프트 생성 + */ + private String buildSystemPrompt(String targetLevel) { + String levelGuidance = switch (targetLevel.toUpperCase()) { + case "BEGINNER" -> """ - Use simple vocabulary and short sentences - Speak slowly and clearly - Use basic grammar structures - Provide Korean translations for difficult words in parentheses """; - case "ADVANCED" -> """ + case "ADVANCED" -> """ - Use sophisticated vocabulary and complex sentences - Include idiomatic expressions and phrasal verbs - Discuss abstract concepts naturally - Challenge the user with nuanced topics """; - default -> """ + default -> """ - Use moderate vocabulary appropriate for intermediate learners - Mix simple and compound sentences - Introduce useful expressions gradually - Balance challenge with accessibility """; - }; - - return String.format(""" + }; + + return String.format(""" You are a friendly English conversation partner for Korean learners. Your name is "Amy" and you're an American English teacher living in Seoul. @@ -259,59 +258,61 @@ private String buildSystemPrompt(String targetLevel) { Remember: Your goal is to make the user feel comfortable practicing English! """, targetLevel, levelGuidance); - } - - /** - * 히스토리 JSON 파싱 - */ - private List parseHistory(String historyJson) { - List history = new ArrayList<>(); - - if (historyJson == null || historyJson.isEmpty() || historyJson.equals("[]")) { - return history; - } - - try { - JsonArray array = JsonParser.parseString(historyJson).getAsJsonArray(); - for (JsonElement el : array) { - JsonObject obj = el.getAsJsonObject(); - history.add(new Message( - obj.get("role").getAsString(), - obj.get("content").getAsString() - )); - } - } catch (Exception e) { - logger.warn("Failed to parse history, starting fresh: {}", e.getMessage()); - } - - return history; - } - - /** - * 히스토리 JSON 변환 - */ - private String toJson(List history) { - JsonArray array = new JsonArray(); - for (Message msg : history) { - JsonObject obj = new JsonObject(); - obj.addProperty("role", msg.role()); - obj.addProperty("content", msg.content()); - array.add(obj); - } - return array.toString(); - } - - // ==================== Inner Classes ==================== - - private record Message(String role, String content) {} - - /** - * Speaking 응답 DTO - */ - public record SpeakingResponse( - String userTranscript, // 사용자가 말한 내용 (STT 결과) - String aiText, // AI 응답 텍스트 - String aiAudioUrl, // AI 응답 음성 URL (Polly) - double confidence // STT 신뢰도comp - ) {} -} \ No newline at end of file + } + + /** + * 히스토리 JSON 파싱 + */ + private List parseHistory(String historyJson) { + List history = new ArrayList<>(); + + if (historyJson == null || historyJson.isEmpty() || historyJson.equals("[]")) { + return history; + } + + try { + JsonArray array = JsonParser.parseString(historyJson).getAsJsonArray(); + for (JsonElement el : array) { + JsonObject obj = el.getAsJsonObject(); + history.add(new Message( + obj.get("role").getAsString(), + obj.get("content").getAsString() + )); + } + } catch (Exception e) { + logger.warn("Failed to parse history, starting fresh: {}", e.getMessage()); + } + + return history; + } + + /** + * 히스토리 JSON 변환 + */ + private String toJson(List history) { + JsonArray array = new JsonArray(); + for (Message msg : history) { + JsonObject obj = new JsonObject(); + obj.addProperty("role", msg.role()); + obj.addProperty("content", msg.content()); + array.add(obj); + } + return array.toString(); + } + + // ==================== Inner Classes ==================== + + private record Message(String role, String content) { + } + + /** + * Speaking 응답 DTO + */ + public record SpeakingResponse( + String userTranscript, // 사용자가 말한 내용 (STT 결과) + String aiText, // AI 응답 텍스트 + String aiAudioUrl, // AI 응답 음성 URL (Polly) + double confidence // STT 신뢰도comp + ) { + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/ScheduledStatsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/ScheduledStatsHandler.java index f8890e51..97581029 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/ScheduledStatsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/ScheduledStatsHandler.java @@ -3,28 +3,27 @@ import com.amazonaws.services.lambda.runtime.Context; import com.amazonaws.services.lambda.runtime.RequestHandler; import com.amazonaws.services.lambda.runtime.events.ScheduledEvent; +import com.mzc.secondproject.serverless.common.config.AwsClients; import com.mzc.secondproject.serverless.common.config.EnvConfig; import com.mzc.secondproject.serverless.domain.stats.repository.UserStatsRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; +import software.amazon.awssdk.services.dynamodb.model.ScanRequest; +import software.amazon.awssdk.services.dynamodb.model.ScanResponse; import java.time.LocalDate; import java.util.List; import java.util.Map; -import software.amazon.awssdk.services.dynamodb.model.AttributeValue; -import software.amazon.awssdk.services.dynamodb.model.ScanRequest; -import software.amazon.awssdk.services.dynamodb.model.ScanResponse; -import com.mzc.secondproject.serverless.common.config.AwsClients; - /** * EventBridge Scheduler Handler * 매일 자정에 실행되어 Streak 리셋만 수행 - * + *

* 단어 학습 통계는 Write-through 방식으로 markWordLearned에서 직접 업데이트 */ public class ScheduledStatsHandler implements RequestHandler { - + private static final Logger logger = LoggerFactory.getLogger(ScheduledStatsHandler.class); private static final String TABLE_NAME = EnvConfig.getRequired("VOCAB_TABLE_NAME"); private static final int BATCH_SIZE = 25; @@ -37,7 +36,7 @@ public class ScheduledStatsHandler implements RequestHandler lastEvaluatedKey = null; - + do { // SK = "STATS#TOTAL"인 레코드만 스캔 (currentStreak > 0 필터) ScanRequest.Builder scanBuilder = ScanRequest.builder() @@ -82,14 +81,14 @@ private int checkAndResetStreaks(String yesterday) { ":yesterday", AttributeValue.builder().s(yesterday).build() )) .limit(BATCH_SIZE); - + if (lastEvaluatedKey != null) { scanBuilder.exclusiveStartKey(lastEvaluatedKey); } - + ScanResponse response = AwsClients.dynamoDb().scan(scanBuilder.build()); List> items = response.items(); - + for (Map item : items) { String pk = item.get("PK").s(); // PK 형식: "USERSTATS#{userId}" 에서 userId 추출 @@ -104,14 +103,14 @@ private int checkAndResetStreaks(String yesterday) { } } } - + lastEvaluatedKey = response.lastEvaluatedKey(); } while (lastEvaluatedKey != null && !lastEvaluatedKey.isEmpty()); - + logger.info("Streak reset completed: {} users processed", resetCount); return resetCount; } - + /** * 사용자의 currentStreak을 0으로 리셋 (longestStreak은 유지) */ @@ -120,7 +119,7 @@ private void resetUserStreak(String userId) { getCurrentLongestStreak(userId), LocalDate.now().minusDays(1).toString()); } - + /** * 사용자의 현재 longestStreak 조회 */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/StatsStreamHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/StatsStreamHandler.java index 78293b03..82ee6079 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/StatsStreamHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/StatsStreamHandler.java @@ -33,7 +33,7 @@ public class StatsStreamHandler implements RequestHandler { public StatsStreamHandler() { this(new UserStatsRepository(), new BadgeService()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java index c006c5fb..637151be 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java @@ -37,7 +37,7 @@ public class UserStatsHandler implements RequestHandler * Write-time Aggregation 패턴: * - 이벤트 발생 시 Atomic Counter로 증분 업데이트 * - 조회 시 Scan 없이 O(1) GetItem diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java index 807f0653..b3ad20d8 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java @@ -33,14 +33,14 @@ public class UserStatsRepository { private static final String TABLE_NAME = EnvConfig.getRequired("VOCAB_TABLE_NAME"); private final DynamoDbTable table; - + /** * 기본 생성자 (Lambda에서 사용) */ public UserStatsRepository() { this(AwsClients.dynamoDbEnhanced()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/service/StatsService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/service/StatsService.java index 510754a5..c50f52fe 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/service/StatsService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/service/StatsService.java @@ -17,14 +17,14 @@ public class StatsService { private static final Logger logger = LoggerFactory.getLogger(StatsService.class); private final UserStatsRepository userStatsRepository; - + /** * 기본 생성자 (Lambda에서 사용) */ public StatsService() { this(new UserStatsRepository()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PostConfirmationHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PostConfirmationHandler.java index 31f528f7..97bd6638 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PostConfirmationHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PostConfirmationHandler.java @@ -13,7 +13,7 @@ /** * Cognito Post Confirmation 트리거 핸들러 - * + *

* 사용자 이메일 인증을 완료한 직후 DB에 데이터 생성 */ public class PostConfirmationHandler implements RequestHandler { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/exception/VocabularyErrorCode.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/exception/VocabularyErrorCode.java index 3c04d79d..dd576d63 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/exception/VocabularyErrorCode.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/exception/VocabularyErrorCode.java @@ -4,7 +4,7 @@ /** * 단어 학습 도메인 에러 코드 - * + *

* 단어(Word), 사용자 단어(UserWord), 일일 학습(DailyStudy) 관련 에러 코드를 정의합니다. */ public enum VocabularyErrorCode implements DomainErrorCode { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/exception/VocabularyException.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/exception/VocabularyException.java index 3deec811..7ab6adea 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/exception/VocabularyException.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/exception/VocabularyException.java @@ -4,9 +4,9 @@ /** * 단어 학습 도메인 예외 클래스 - * + *

* 정적 팩토리 메서드를 통해 가독성 높은 예외 생성을 지원합니다. - * + *

* 사용 예시: * throw VocabularyException.wordNotFound(wordId); * throw VocabularyException.invalidDifficulty("INVALID"); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/DailyStudyHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/DailyStudyHandler.java index 54488ac6..62692be6 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/DailyStudyHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/DailyStudyHandler.java @@ -30,7 +30,7 @@ public class DailyStudyHandler implements RequestHandler { public StatisticsHandler() { this(new StatisticsService()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatsHandler.java index deefe73f..77b8defe 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatsHandler.java @@ -27,7 +27,7 @@ public class StatsHandler implements RequestHandler table; - + /** * 기본 생성자 (Lambda에서 사용) */ public DailyStudyRepository() { this(AwsClients.dynamoDbEnhanced()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/TestResultRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/TestResultRepository.java index 7b0ea935..703c864c 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/TestResultRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/TestResultRepository.java @@ -7,11 +7,7 @@ import com.mzc.secondproject.serverless.domain.vocabulary.model.TestResult; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; -import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; -import software.amazon.awssdk.enhanced.dynamodb.Expression; -import software.amazon.awssdk.enhanced.dynamodb.Key; -import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.*; import software.amazon.awssdk.enhanced.dynamodb.model.Page; import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; @@ -26,14 +22,14 @@ public class TestResultRepository { private static final String TABLE_NAME = EnvConfig.getRequired("VOCAB_TABLE_NAME"); private final DynamoDbTable table; - + /** * 기본 생성자 (Lambda에서 사용) */ public TestResultRepository() { this(AwsClients.dynamoDbEnhanced()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/UserWordRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/UserWordRepository.java index 015932c9..7411113b 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/UserWordRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/UserWordRepository.java @@ -22,14 +22,14 @@ public class UserWordRepository { private static final String TABLE_NAME = EnvConfig.getRequired("VOCAB_TABLE_NAME"); private final DynamoDbTable table; - + /** * 기본 생성자 (Lambda에서 사용) */ public UserWordRepository() { this(AwsClients.dynamoDbEnhanced()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordGroupRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordGroupRepository.java index f740c667..17ed84a8 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordGroupRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordGroupRepository.java @@ -25,14 +25,14 @@ public class WordGroupRepository { private static final String TABLE_NAME = EnvConfig.getRequired("VOCAB_TABLE_NAME"); private final DynamoDbTable table; - + /** * 기본 생성자 (Lambda에서 사용) */ public WordGroupRepository() { this(AwsClients.dynamoDbEnhanced()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordRepository.java index cba49b18..b2439362 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordRepository.java @@ -30,7 +30,7 @@ public class WordRepository { public WordRepository() { this(AwsClients.dynamoDbEnhanced()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyCommandService.java index f4680fe5..32dc5b24 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyCommandService.java @@ -34,7 +34,7 @@ public class DailyStudyCommandService { private final WordRepository wordRepository; private final UserStatsRepository userStatsRepository; private final BadgeService badgeService; - + /** * 기본 생성자 (Lambda에서 사용) */ @@ -42,7 +42,7 @@ public DailyStudyCommandService() { this(new DailyStudyRepository(), new UserWordRepository(), new WordRepository(), new UserStatsRepository(), new BadgeService()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyQueryService.java index 1a2af754..a9888fbb 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyQueryService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyQueryService.java @@ -19,14 +19,14 @@ public class DailyStudyQueryService { private final DailyStudyRepository dailyStudyRepository; private final WordRepository wordRepository; - + /** * 기본 생성자 (Lambda에서 사용) */ public DailyStudyQueryService() { this(new DailyStudyRepository(), new WordRepository()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/StatisticsService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/StatisticsService.java index 670f8e0d..7a206742 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/StatisticsService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/StatisticsService.java @@ -16,14 +16,14 @@ public class StatisticsService { private static final Logger logger = LoggerFactory.getLogger(StatisticsService.class); private final UserWordRepository userWordRepository; - + /** * 기본 생성자 (Lambda에서 사용) */ public StatisticsService() { this(new UserWordRepository()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/StatsService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/StatsService.java index 496014f2..abcc4a46 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/StatsService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/StatsService.java @@ -4,6 +4,7 @@ import com.mzc.secondproject.serverless.domain.vocabulary.model.DailyStudy; import com.mzc.secondproject.serverless.domain.vocabulary.model.TestResult; import com.mzc.secondproject.serverless.domain.vocabulary.model.UserWord; +import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; import com.mzc.secondproject.serverless.domain.vocabulary.repository.DailyStudyRepository; import com.mzc.secondproject.serverless.domain.vocabulary.repository.TestResultRepository; import com.mzc.secondproject.serverless.domain.vocabulary.repository.UserWordRepository; @@ -14,7 +15,6 @@ import java.util.*; import java.util.function.Function; import java.util.stream.Collectors; -import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; public class StatsService { @@ -24,7 +24,7 @@ public class StatsService { private final DailyStudyRepository dailyStudyRepository; private final TestResultRepository testResultRepository; private final WordRepository wordRepository; - + /** * 기본 생성자 (Lambda에서 사용) */ @@ -32,7 +32,7 @@ public StatsService() { this(new UserWordRepository(), new DailyStudyRepository(), new TestResultRepository(), new WordRepository()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ @@ -127,7 +127,7 @@ public Map getWeaknessAnalysis(String userId) { allUserWords.addAll(page.items()); cursor = page.nextCursor(); } while (cursor != null); - + if (allUserWords.isEmpty()) { Map emptyResult = new HashMap<>(); emptyResult.put("weakestWords", List.of()); @@ -136,7 +136,7 @@ public Map getWeaknessAnalysis(String userId) { emptyResult.put("suggestions", List.of()); return emptyResult; } - + // 배치 조회로 N+1 문제 해결: 모든 wordId를 수집하여 한 번에 조회 List wordIds = allUserWords.stream() .map(UserWord::getWordId) @@ -145,7 +145,7 @@ public Map getWeaknessAnalysis(String userId) { List words = wordRepository.findByIds(wordIds); Map wordMap = words.stream() .collect(Collectors.toMap(Word::getWordId, Function.identity(), (a, b) -> a)); - + List> weakestWords = allUserWords.stream() .filter(uw -> uw.getIncorrectCount() != null && uw.getIncorrectCount() > 0) .sorted(Comparator.comparingInt(UserWord::getIncorrectCount).reversed()) @@ -156,7 +156,7 @@ public Map getWeaknessAnalysis(String userId) { wordInfo.put("incorrectCount", uw.getIncorrectCount()); wordInfo.put("correctCount", uw.getCorrectCount()); wordInfo.put("status", uw.getStatus()); - + Word word = wordMap.get(uw.getWordId()); if (word != null) { wordInfo.put("english", word.getEnglish()); @@ -164,28 +164,28 @@ public Map getWeaknessAnalysis(String userId) { wordInfo.put("level", word.getLevel()); wordInfo.put("category", word.getCategory()); } - + int total = (uw.getCorrectCount() != null ? uw.getCorrectCount() : 0) + (uw.getIncorrectCount() != null ? uw.getIncorrectCount() : 0); wordInfo.put("accuracy", total > 0 ? (uw.getCorrectCount() != null ? uw.getCorrectCount() * 100.0 / total : 0) : 0); - + return wordInfo; }) .collect(Collectors.toList()); - + Map> categoryAnalysis = new HashMap<>(); Map> levelAnalysis = new HashMap<>(); - + for (UserWord uw : allUserWords) { Word word = wordMap.get(uw.getWordId()); if (word != null) { String category = word.getCategory(); String level = word.getLevel(); - + int correct = uw.getCorrectCount() != null ? uw.getCorrectCount() : 0; int incorrect = uw.getIncorrectCount() != null ? uw.getIncorrectCount() : 0; - + categoryAnalysis.computeIfAbsent(category, k -> { Map stats = new HashMap<>(); stats.put("totalCorrect", 0); @@ -197,7 +197,7 @@ public Map getWeaknessAnalysis(String userId) { catStats.put("totalCorrect", (Integer) catStats.get("totalCorrect") + correct); catStats.put("totalIncorrect", (Integer) catStats.get("totalIncorrect") + incorrect); catStats.put("wordCount", (Integer) catStats.get("wordCount") + 1); - + levelAnalysis.computeIfAbsent(level, k -> { Map stats = new HashMap<>(); stats.put("totalCorrect", 0); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java index 764e9288..43c0c095 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java @@ -33,7 +33,7 @@ public class TestCommandService { private final DailyStudyRepository dailyStudyRepository; private final WordRepository wordRepository; private final UserWordCommandService userWordCommandService; - + /** * 기본 생성자 (Lambda에서 사용) */ @@ -41,7 +41,7 @@ public TestCommandService() { this(new TestResultRepository(), new DailyStudyRepository(), new WordRepository(), new UserWordCommandService()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestQueryService.java index 7b422f3c..d21fc183 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestQueryService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestQueryService.java @@ -19,14 +19,14 @@ public class TestQueryService { private final TestResultRepository testResultRepository; private final WordRepository wordRepository; - + /** * 기본 생성자 (Lambda에서 사용) */ public TestQueryService() { this(new TestResultRepository(), new WordRepository()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordCommandService.java index 18540ac2..4b9c216e 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordCommandService.java @@ -25,14 +25,14 @@ public class UserWordCommandService { private static final Logger logger = LoggerFactory.getLogger(UserWordCommandService.class); private final UserWordRepository userWordRepository; - + /** * 기본 생성자 (Lambda에서 사용) */ public UserWordCommandService() { this(new UserWordRepository()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordQueryService.java index 2942c609..bf6920e5 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordQueryService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordQueryService.java @@ -20,14 +20,14 @@ public class UserWordQueryService { private final UserWordRepository userWordRepository; private final WordRepository wordRepository; - + /** * 기본 생성자 (Lambda에서 사용) */ public UserWordQueryService() { this(new UserWordRepository(), new WordRepository()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordCommandService.java index f8365c69..193cb67b 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordCommandService.java @@ -19,14 +19,14 @@ public class WordCommandService { private static final Logger logger = LoggerFactory.getLogger(WordCommandService.class); private final WordRepository wordRepository; - + /** * 기본 생성자 (Lambda에서 사용) */ public WordCommandService() { this(new WordRepository()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordGroupCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordGroupCommandService.java index 796f9d1e..847af239 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordGroupCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordGroupCommandService.java @@ -20,14 +20,14 @@ public class WordGroupCommandService { private static final Logger logger = LoggerFactory.getLogger(WordGroupCommandService.class); private final WordGroupRepository wordGroupRepository; - + /** * 기본 생성자 (Lambda에서 사용) */ public WordGroupCommandService() { this(new WordGroupRepository()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordGroupQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordGroupQueryService.java index 6ce2d668..21c43637 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordGroupQueryService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordGroupQueryService.java @@ -20,14 +20,14 @@ public class WordGroupQueryService { private final WordGroupRepository wordGroupRepository; private final WordRepository wordRepository; - + /** * 기본 생성자 (Lambda에서 사용) */ public WordGroupQueryService() { this(new WordGroupRepository(), new WordRepository()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordQueryService.java index 7257122c..4c8b4275 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordQueryService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordQueryService.java @@ -17,14 +17,14 @@ public class WordQueryService { private static final Logger logger = LoggerFactory.getLogger(WordQueryService.class); private final WordRepository wordRepository; - + /** * 기본 생성자 (Lambda에서 사용) */ public WordQueryService() { this(new WordRepository()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCodeSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCodeSpec.groovy index df2855f6..9895fc71 100644 --- a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCodeSpec.groovy +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCodeSpec.groovy @@ -19,31 +19,31 @@ class ChattingErrorCodeSpec extends Specification { !errorCode.getMessage().isEmpty() where: - errorCode | expectedCode | expectedStatusCode - ChattingErrorCode.ROOM_NOT_FOUND | "ROOM_001" | 404 - ChattingErrorCode.ROOM_ALREADY_EXISTS | "ROOM_002" | 409 - ChattingErrorCode.ROOM_FULL | "ROOM_003" | 400 - ChattingErrorCode.ROOM_CLOSED | "ROOM_004" | 400 - ChattingErrorCode.ROOM_INVALID_PASSWORD | "ROOM_005" | 401 - ChattingErrorCode.ROOM_NOT_OWNER | "ROOM_006" | 403 - ChattingErrorCode.MESSAGE_NOT_FOUND | "MSG_001" | 404 - ChattingErrorCode.MESSAGE_TOO_LONG | "MSG_002" | 400 - ChattingErrorCode.INVALID_MESSAGE_TYPE | "MSG_003" | 400 - ChattingErrorCode.NOT_ROOM_MEMBER | "MEMBER_001" | 403 - ChattingErrorCode.ALREADY_JOINED | "MEMBER_002" | 409 - ChattingErrorCode.INVALID_ROOM_TOKEN | "MEMBER_003" | 401 - ChattingErrorCode.INVALID_CHAT_LEVEL | "LEVEL_001" | 400 - ChattingErrorCode.CONNECTION_FAILED | "CONN_001" | 500 - ChattingErrorCode.CONNECTION_TIMEOUT | "CONN_002" | 408 - ChattingErrorCode.GAME_START_FAILED | "GAME_001" | 400 - ChattingErrorCode.GAME_STOP_FAILED | "GAME_002" | 400 - ChattingErrorCode.GAME_NOT_IN_PROGRESS | "GAME_003" | 400 - ChattingErrorCode.GAME_ALREADY_IN_PROGRESS| "GAME_004" | 409 - ChattingErrorCode.NOT_GAME_STARTER | "GAME_005" | 403 - ChattingErrorCode.GAME_NOT_FOUND | "GAME_006" | 404 - ChattingErrorCode.GAME_NOT_ALLOWED_IN_CHAT_ROOM | "GAME_007" | 400 - ChattingErrorCode.GAME_RESTART_NOT_ALLOWED | "GAME_008" | 400 - ChattingErrorCode.GAME_START_NOT_HOST | "GAME_009" | 403 + errorCode | expectedCode | expectedStatusCode + ChattingErrorCode.ROOM_NOT_FOUND | "ROOM_001" | 404 + ChattingErrorCode.ROOM_ALREADY_EXISTS | "ROOM_002" | 409 + ChattingErrorCode.ROOM_FULL | "ROOM_003" | 400 + ChattingErrorCode.ROOM_CLOSED | "ROOM_004" | 400 + ChattingErrorCode.ROOM_INVALID_PASSWORD | "ROOM_005" | 401 + ChattingErrorCode.ROOM_NOT_OWNER | "ROOM_006" | 403 + ChattingErrorCode.MESSAGE_NOT_FOUND | "MSG_001" | 404 + ChattingErrorCode.MESSAGE_TOO_LONG | "MSG_002" | 400 + ChattingErrorCode.INVALID_MESSAGE_TYPE | "MSG_003" | 400 + ChattingErrorCode.NOT_ROOM_MEMBER | "MEMBER_001" | 403 + ChattingErrorCode.ALREADY_JOINED | "MEMBER_002" | 409 + ChattingErrorCode.INVALID_ROOM_TOKEN | "MEMBER_003" | 401 + ChattingErrorCode.INVALID_CHAT_LEVEL | "LEVEL_001" | 400 + ChattingErrorCode.CONNECTION_FAILED | "CONN_001" | 500 + ChattingErrorCode.CONNECTION_TIMEOUT | "CONN_002" | 408 + ChattingErrorCode.GAME_START_FAILED | "GAME_001" | 400 + ChattingErrorCode.GAME_STOP_FAILED | "GAME_002" | 400 + ChattingErrorCode.GAME_NOT_IN_PROGRESS | "GAME_003" | 400 + ChattingErrorCode.GAME_ALREADY_IN_PROGRESS | "GAME_004" | 409 + ChattingErrorCode.NOT_GAME_STARTER | "GAME_005" | 403 + ChattingErrorCode.GAME_NOT_FOUND | "GAME_006" | 404 + ChattingErrorCode.GAME_NOT_ALLOWED_IN_CHAT_ROOM | "GAME_007" | 400 + ChattingErrorCode.GAME_RESTART_NOT_ALLOWED | "GAME_008" | 400 + ChattingErrorCode.GAME_START_NOT_HOST | "GAME_009" | 403 } def "모든 에러 코드 개수 확인"() { diff --git a/ServerlessFunction/src/test/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomStatusTest.java b/ServerlessFunction/src/test/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomStatusTest.java index 03c59df5..212b6761 100644 --- a/ServerlessFunction/src/test/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomStatusTest.java +++ b/ServerlessFunction/src/test/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomStatusTest.java @@ -1,6 +1,7 @@ package com.mzc.secondproject.serverless.domain.chatting.enums; import org.junit.jupiter.api.Test; + import static org.junit.jupiter.api.Assertions.*; class RoomStatusTest { @@ -15,7 +16,7 @@ void testFromString() { assertEquals(RoomStatus.WAITING, RoomStatus.fromString(null)); assertEquals(RoomStatus.WAITING, RoomStatus.fromString("invalid")); } - + @Test void testIsValid() { assertTrue(RoomStatus.isValid("WAITING")); @@ -27,14 +28,14 @@ void testIsValid() { assertFalse(RoomStatus.isValid(null)); assertFalse(RoomStatus.isValid("invalid")); } - + @Test void testGetCode() { assertEquals("waiting", RoomStatus.WAITING.getCode()); assertEquals("playing", RoomStatus.PLAYING.getCode()); assertEquals("finished", RoomStatus.FINISHED.getCode()); } - + @Test void testGetDisplayName() { assertEquals("대기 중", RoomStatus.WAITING.getDisplayName()); diff --git a/ServerlessFunction/src/test/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomTypeTest.java b/ServerlessFunction/src/test/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomTypeTest.java index 7d8d13db..37347718 100644 --- a/ServerlessFunction/src/test/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomTypeTest.java +++ b/ServerlessFunction/src/test/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomTypeTest.java @@ -1,6 +1,7 @@ package com.mzc.secondproject.serverless.domain.chatting.enums; import org.junit.jupiter.api.Test; + import static org.junit.jupiter.api.Assertions.*; class RoomTypeTest { @@ -13,7 +14,7 @@ void testFromString() { assertEquals(RoomType.CHAT, RoomType.fromString(null)); assertEquals(RoomType.CHAT, RoomType.fromString("invalid")); } - + @Test void testIsValid() { assertTrue(RoomType.isValid("CHAT")); @@ -23,13 +24,13 @@ void testIsValid() { assertFalse(RoomType.isValid(null)); assertFalse(RoomType.isValid("invalid")); } - + @Test void testGetCode() { assertEquals("chat", RoomType.CHAT.getCode()); assertEquals("game", RoomType.GAME.getCode()); } - + @Test void testGetDisplayName() { assertEquals("채팅방", RoomType.CHAT.getDisplayName()); diff --git a/ServerlessFunction/src/test/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSettingsTest.java b/ServerlessFunction/src/test/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSettingsTest.java index e762e335..9d608c90 100644 --- a/ServerlessFunction/src/test/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSettingsTest.java +++ b/ServerlessFunction/src/test/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSettingsTest.java @@ -1,6 +1,7 @@ package com.mzc.secondproject.serverless.domain.chatting.model; import org.junit.jupiter.api.Test; + import static org.junit.jupiter.api.Assertions.*; class GameSettingsTest { @@ -11,7 +12,7 @@ void testDefaultValues() { assertEquals(60, settings.getRoundTimeLimit()); assertFalse(settings.getAutoDeleteOnEnd()); } - + @Test void testCustomValues() { GameSettings settings = GameSettings.builder() @@ -23,7 +24,7 @@ void testCustomValues() { assertEquals(90, settings.getRoundTimeLimit()); assertTrue(settings.getAutoDeleteOnEnd()); } - + @Test void testNoArgsConstructor() { GameSettings settings = new GameSettings(); @@ -31,7 +32,7 @@ void testNoArgsConstructor() { assertEquals(60, settings.getRoundTimeLimit()); assertFalse(settings.getAutoDeleteOnEnd()); } - + @Test void testAllArgsConstructor() { GameSettings settings = new GameSettings(10, 90, true); @@ -39,14 +40,14 @@ void testAllArgsConstructor() { assertEquals(90, settings.getRoundTimeLimit()); assertTrue(settings.getAutoDeleteOnEnd()); } - + @Test void testSettersAndGetters() { GameSettings settings = new GameSettings(); settings.setMaxRounds(8); settings.setRoundTimeLimit(120); settings.setAutoDeleteOnEnd(true); - + assertEquals(8, settings.getMaxRounds()); assertEquals(120, settings.getRoundTimeLimit()); assertTrue(settings.getAutoDeleteOnEnd()); diff --git a/docs/CATCHMIND_ARCHITECTURE_SOLUTION.md b/docs/CATCHMIND_ARCHITECTURE_SOLUTION.md index 6c4ab164..e4c22aa4 100644 --- a/docs/CATCHMIND_ARCHITECTURE_SOLUTION.md +++ b/docs/CATCHMIND_ARCHITECTURE_SOLUTION.md @@ -21,6 +21,7 @@ ChatRoom.java (현재 - 혼합 모델) ``` **문제점:** + 1. `roundStartTime`만 전송, `serverTime` 누락 → 클라이언트 타이머 동기화 불가 2. 게임 세션이 채팅방에 종속 → 게임 상태 독립 관리 불가 3. 재접속 시 게임 상태 복구 어려움 @@ -43,6 +44,7 @@ handleRequest() { ``` **문제점:** + - 채팅/게임 구분 없이 모든 메시지가 동일 핸들러에서 처리 - 메시지에 `domain` 필드 없음 @@ -77,12 +79,12 @@ handleRequest() { ### 2.2 핵심 변경사항 -| 구분 | 현재 | 변경 후 | -|------|------|---------| -| 모델 | `ChatRoom`에 게임 필드 포함 | `ChatRoom` + `GameSession` 분리 | +| 구분 | 현재 | 변경 후 | +|-----|----------------------|---------------------------------| +| 모델 | `ChatRoom`에 게임 필드 포함 | `ChatRoom` + `GameSession` 분리 | | 타이머 | `roundStartTime`만 전송 | `roundStartTime` + `serverTime` | -| 메시지 | `messageType`만 존재 | `domain` + `messageType` | -| API | 채팅방 API만 존재 | 게임 세션 API 추가 | +| 메시지 | `messageType`만 존재 | `domain` + `messageType` | +| API | 채팅방 API만 존재 | 게임 세션 API 추가 | --- @@ -381,27 +383,27 @@ src/domains/ ### 5.2 채팅 메시지 -| Type | 방향 | data 필드 | -|------|------|-----------| -| `TEXT` | 양방향 | `messageId`, `userId`, `content`, `createdAt` | -| `USER_JOIN` | S→C | `userId`, `memberCount` | -| `USER_LEAVE` | S→C | `userId`, `memberCount` | -| `SYSTEM` | S→C | `content` | +| Type | 방향 | data 필드 | +|--------------|-----|-----------------------------------------------| +| `TEXT` | 양방향 | `messageId`, `userId`, `content`, `createdAt` | +| `USER_JOIN` | S→C | `userId`, `memberCount` | +| `USER_LEAVE` | S→C | `userId`, `memberCount` | +| `SYSTEM` | S→C | `content` | ### 5.3 게임 메시지 -| Type | 방향 | data 필드 | -|------|------|-----------| -| `GAME_START` | S→C | `gameSessionId`, `totalRounds`, `currentDrawerId`, `roundStartTime`, `serverTime`, `roundDuration`, `players` | -| `GAME_END` | S→C | `gameSessionId`, `reason`, `finalScores`, `winner` | -| `ROUND_START` | S→C | `currentRound`, `currentDrawerId`, `roundStartTime`, `serverTime`, `roundDuration`, `currentWord`(출제자만) | -| `ROUND_END` | S→C | `currentRound`, `answer`, `scores` | -| `DRAWING` | 양방향 | `drawingData` | -| `DRAWING_CLEAR` | 양방향 | - | -| `GUESS` | C→S | `content` | -| `CORRECT_ANSWER` | S→C | `userId`, `score`, `elapsedTime` | -| `SCORE_UPDATE` | S→C | `scores`, `currentRound`, `totalRounds` | -| `HINT` | S→C | `hint` | +| Type | 방향 | data 필드 | +|------------------|-----|---------------------------------------------------------------------------------------------------------------| +| `GAME_START` | S→C | `gameSessionId`, `totalRounds`, `currentDrawerId`, `roundStartTime`, `serverTime`, `roundDuration`, `players` | +| `GAME_END` | S→C | `gameSessionId`, `reason`, `finalScores`, `winner` | +| `ROUND_START` | S→C | `currentRound`, `currentDrawerId`, `roundStartTime`, `serverTime`, `roundDuration`, `currentWord`(출제자만) | +| `ROUND_END` | S→C | `currentRound`, `answer`, `scores` | +| `DRAWING` | 양방향 | `drawingData` | +| `DRAWING_CLEAR` | 양방향 | - | +| `GUESS` | C→S | `content` | +| `CORRECT_ANSWER` | S→C | `userId`, `score`, `elapsedTime` | +| `SCORE_UPDATE` | S→C | `scores`, `currentRound`, `totalRounds` | +| `HINT` | S→C | `hint` | ### 5.4 ROUND_START 상세 (핵심!) @@ -461,13 +463,13 @@ Week 4: 안정화 및 추가 기능 ## 7. 기대 효과 -| 항목 | 현재 | 개선 후 | -|------|------|---------| -| 타이머 정확도 | 클라이언트 시계 의존 | 서버 시간 기준 동기화 | -| 재접속 | 게임 상태 유실 | 완전 복구 가능 | -| 테스트 | 채팅/게임 분리 불가 | 독립 테스트 가능 | -| 확장성 | 새 게임 추가 어려움 | gameType으로 확장 용이 | -| 유지보수 | 책임 혼재 | 명확한 책임 분리 | +| 항목 | 현재 | 개선 후 | +|---------|-------------|------------------| +| 타이머 정확도 | 클라이언트 시계 의존 | 서버 시간 기준 동기화 | +| 재접속 | 게임 상태 유실 | 완전 복구 가능 | +| 테스트 | 채팅/게임 분리 불가 | 독립 테스트 가능 | +| 확장성 | 새 게임 추가 어려움 | gameType으로 확장 용이 | +| 유지보수 | 책임 혼재 | 명확한 책임 분리 | --- @@ -512,6 +514,7 @@ onRoundStart: (data) => { 3. **중기 (3-4주)**: FE/BE 완전 분리 + 자동 종료 + 재접속 복구 **핵심 원칙:** + - 단일 WebSocket 엔드포인트 유지 (비용/복잡도) - `domain` 필드로 채팅/게임 구분 - `serverTime`으로 정확한 타이머 동기화 diff --git a/docs/CICD-IMPLEMENTATION-QNA.md b/docs/CICD-IMPLEMENTATION-QNA.md index c9be8c92..e00c5a11 100644 --- a/docs/CICD-IMPLEMENTATION-QNA.md +++ b/docs/CICD-IMPLEMENTATION-QNA.md @@ -23,26 +23,30 @@ ## 2. 구성 요소 상세 설명 ### 2.1 Source Stage (GitHub) + - **트리거**: prod 브랜치에 Push 또는 PR Merge 시 자동 실행 - **연결 방식**: AWS CodeConnections (구 CodeStar Connections) - **아티팩트**: 소스 코드를 ZIP으로 압축하여 다음 스테이지로 전달 ### 2.2 Build Stage (CodeBuild) + - **런타임**: Amazon Linux 2, Java Corretto 21 - **빌드 단계**: - 1. **Install**: SAM CLI 설치 - 2. **Pre-build**: Gradle 테스트 실행 (`./gradlew clean test`) - 3. **Build**: SAM build & package - 4. **Post-build**: 완료 로그 + 1. **Install**: SAM CLI 설치 + 2. **Pre-build**: Gradle 테스트 실행 (`./gradlew clean test`) + 3. **Build**: SAM build & package + 4. **Post-build**: 완료 로그 - **캐싱**: Gradle 캐시를 S3에 저장하여 빌드 시간 단축 - **리포트**: JUnit 테스트 결과, JaCoCo 코드 커버리지 리포트 ### 2.3 Deploy Stage (CloudFormation) + - **배포 방식**: CloudFormation CREATE_UPDATE - **템플릿**: SAM으로 패키징된 `packaged-template.yaml` - **기능**: CAPABILITY_IAM, CAPABILITY_AUTO_EXPAND ### 2.4 Notification (SNS) + - **이벤트**: 파이프라인 시작, 성공, 실패 시 이메일 알림 - **구현**: CodeStar Notifications + SNS Topic @@ -60,11 +64,11 @@ BE_Repository/ ## 4. IAM 역할 구성 -| 역할 | 목적 | 주요 권한 | -|------|------|----------| -| PipelineRole | CodePipeline 서비스 역할 | S3, CodeBuild, CloudFormation, SNS | -| CodeBuildRole | CodeBuild 서비스 역할 | S3, CloudWatch Logs, CodeBuild Reports | -| CloudFormationRole | 리소스 배포 역할 | AdministratorAccess (SAM 리소스 생성용) | +| 역할 | 목적 | 주요 권한 | +|--------------------|---------------------|----------------------------------------| +| PipelineRole | CodePipeline 서비스 역할 | S3, CodeBuild, CloudFormation, SNS | +| CodeBuildRole | CodeBuild 서비스 역할 | S3, CloudWatch Logs, CodeBuild Reports | +| CloudFormationRole | 리소스 배포 역할 | AdministratorAccess (SAM 리소스 생성용) | --- @@ -106,6 +110,7 @@ AWS CodeConnections(구 CodeStar Connections)를 사용하여 연동했습니다 ``` **연동 과정:** + 1. AWS Console에서 CodeConnections 생성 2. GitHub OAuth 앱 승인 3. Connection ARN을 파이프라인에 설정 @@ -119,23 +124,23 @@ AWS CodeConnections(구 CodeStar Connections)를 사용하여 연동했습니다 ```yaml phases: - install: # 빌드 환경 설정 + install: # 빌드 환경 설정 runtime-versions: java: corretto21 commands: - pip3 install aws-sam-cli - pre_build: # 테스트 실행 (품질 게이트) + pre_build: # 테스트 실행 (품질 게이트) commands: - cd ServerlessFunction - ./gradlew clean test - build: # 실제 빌드 및 패키징 + build: # 실제 빌드 및 패키징 commands: - sam build - sam package --s3-bucket ... --output-template-file packaged-template.yaml - post_build: # 후처리 (로깅, 정리) + post_build: # 후처리 (로깅, 정리) commands: - echo "Build completed" ``` @@ -153,6 +158,7 @@ phases: 테스트 실패 시 배포가 자동으로 중단됩니다. **작동 원리:** + 1. `pre_build` 단계에서 `./gradlew clean test` 실행 2. 테스트 실패 시 Gradle이 exit code 1 반환 3. CodeBuild가 비정상 종료로 판단하여 빌드 실패 처리 @@ -176,11 +182,13 @@ Source ──▶ Build (테스트 실패) ──✗ Deploy SAM(Serverless Application Model)은 CloudFormation의 확장입니다. **관계:** + - SAM 템플릿은 CloudFormation 템플릿의 상위 집합 - `sam build`/`sam package` 실행 시 SAM 템플릿이 표준 CloudFormation 템플릿으로 변환 - 변환된 템플릿(`packaged-template.yaml`)을 CloudFormation이 배포 **SAM의 장점:** + 1. 간결한 문법: `AWS::Serverless::Function`으로 Lambda + API Gateway + IAM 역할 한번에 정의 2. 로컬 테스트: `sam local invoke`로 Lambda 로컬 실행 가능 3. 자동 패키징: 코드를 S3에 업로드하고 참조 자동 생성 @@ -210,16 +218,18 @@ Properties: CloudFormation의 기본 롤백 기능을 활용합니다. **설정:** + ```yaml # samconfig.toml disable_rollback = false # 롤백 활성화 ``` **롤백 시나리오:** + 1. **배포 실패 시**: CloudFormation이 자동으로 이전 상태로 롤백 2. **Lambda 오류 시**: - - 현재는 기본 롤백만 사용 - - 추가로 Canary/Linear 배포 설정 가능 (AWS CodeDeploy 연동) + - 현재는 기본 롤백만 사용 + - 추가로 Canary/Linear 배포 설정 가능 (AWS CodeDeploy 연동) ```yaml # 점진적 배포 예시 (선택적 구현) @@ -248,6 +258,7 @@ ArtifactBucket: ``` **아티팩트 종류:** + 1. **SourceArtifact**: GitHub에서 가져온 소스 코드 ZIP 2. **BuildArtifact**: 빌드된 `packaged-template.yaml` 3. **Cache**: Gradle 캐시 (빌드 시간 단축용) @@ -294,18 +305,22 @@ PipelineNotificationRule: **A9:** **문제 1: Gradle Wrapper를 찾을 수 없음** + - 원인: `.gitignore`에서 `gradle/` 폴더 전체가 제외됨 - 해결: `.gitignore` 수정하여 `!gradle/wrapper/` 예외 추가 **문제 2: JAVA_HOME 환경 변수 오류** + - 원인: CodeBuild에서 JAVA_HOME을 수동 설정했으나 경로 불일치 - 해결: `runtime-versions: java: corretto21`만 사용하고 JAVA_HOME 수동 설정 제거 **문제 3: SAM package S3 버킷 참조 오류** + - 원인: 환경 변수를 사용한 멀티라인 명령어에서 변수 치환 실패 - 해결: 단일 라인으로 버킷 이름 직접 지정 **문제 4: Lambda 환경 변수 누락** + - 원인: WebSocket Connect 함수에 `WEBSOCKET_ENDPOINT` 환경 변수 미설정 - 해결: `template.yaml`에 환경 변수 추가 @@ -316,22 +331,22 @@ PipelineNotificationRule: **A10:** 1. **테스트 커버리지 게이트** - - 현재: 테스트 실행만 함 - - 개선: 커버리지 80% 미만 시 빌드 실패 설정 + - 현재: 테스트 실행만 함 + - 개선: 커버리지 80% 미만 시 빌드 실패 설정 2. **점진적 배포 (Canary/Blue-Green)** - - 현재: 전체 교체 배포 - - 개선: Lambda Alias + CodeDeploy로 Canary 배포 구현 + - 현재: 전체 교체 배포 + - 개선: Lambda Alias + CodeDeploy로 Canary 배포 구현 3. **다중 환경 지원** - - 현재: prod 단일 환경 - - 개선: dev, staging, prod 분리 및 승인 단계 추가 + - 현재: prod 단일 환경 + - 개선: dev, staging, prod 분리 및 승인 단계 추가 4. **보안 스캔** - - 개선: 의존성 취약점 스캔 (OWASP Dependency-Check) 추가 + - 개선: 의존성 취약점 스캔 (OWASP Dependency-Check) 추가 5. **성능 테스트** - - 개선: 배포 전 부하 테스트 단계 추가 + - 개선: 배포 전 부하 테스트 단계 추가 --- @@ -341,6 +356,7 @@ PipelineNotificationRule: 파이프라인 자체도 CloudFormation 템플릿(`pipeline.yaml`)으로 정의했습니다. **장점:** + 1. **버전 관리**: 인프라 변경 이력을 Git으로 추적 2. **재현성**: 동일한 파이프라인을 다른 프로젝트/계정에 쉽게 복제 3. **리뷰 가능**: 인프라 변경도 코드 리뷰 프로세스 적용 @@ -353,16 +369,17 @@ PipelineNotificationRule: **A12:** -| 항목 | CodeBuild | Jenkins | -|------|-----------|---------| -| 관리 | 완전 관리형 (서버리스) | 자체 서버 운영 필요 | -| 비용 | 빌드 시간 기반 과금 | 서버 운영 비용 | -| 확장성 | 자동 확장 | 수동 확장 필요 | -| AWS 통합 | 네이티브 통합 | 플러그인 필요 | +| 항목 | CodeBuild | Jenkins | +|--------|---------------|----------------------| +| 관리 | 완전 관리형 (서버리스) | 자체 서버 운영 필요 | +| 비용 | 빌드 시간 기반 과금 | 서버 운영 비용 | +| 확장성 | 자동 확장 | 수동 확장 필요 | +| AWS 통합 | 네이티브 통합 | 플러그인 필요 | | 커스터마이징 | buildspec.yml | Jenkinsfile (Groovy) | -| 플러그인 | 제한적 | 풍부한 생태계 | +| 플러그인 | 제한적 | 풍부한 생태계 | **선택 이유:** + - AWS 서비스 중심 아키텍처에서 네이티브 통합의 이점 - 서버 관리 부담 없음 - SAM/CloudFormation과의 원활한 연동 @@ -371,15 +388,15 @@ PipelineNotificationRule: ## 6. 핵심 용어 정리 -| 용어 | 설명 | -|------|------| -| CI (Continuous Integration) | 코드 변경을 자주 통합하고 자동 테스트하는 방식 | -| CD (Continuous Delivery/Deployment) | 자동으로 프로덕션까지 배포하는 방식 | -| Pipeline | 소스-빌드-배포로 이어지는 자동화된 워크플로우 | -| Artifact | 빌드 결과물 (패키징된 코드, 템플릿 등) | -| buildspec.yml | CodeBuild의 빌드 명세 파일 | -| SAM | Serverless Application Model - 서버리스 앱 정의 프레임워크 | -| IaC | Infrastructure as Code - 코드로 인프라 관리 | +| 용어 | 설명 | +|-------------------------------------|------------------------------------------------| +| CI (Continuous Integration) | 코드 변경을 자주 통합하고 자동 테스트하는 방식 | +| CD (Continuous Delivery/Deployment) | 자동으로 프로덕션까지 배포하는 방식 | +| Pipeline | 소스-빌드-배포로 이어지는 자동화된 워크플로우 | +| Artifact | 빌드 결과물 (패키징된 코드, 템플릿 등) | +| buildspec.yml | CodeBuild의 빌드 명세 파일 | +| SAM | Serverless Application Model - 서버리스 앱 정의 프레임워크 | +| IaC | Infrastructure as Code - 코드로 인프라 관리 | --- diff --git a/docs/FRONTEND-API-GUIDE.md b/docs/FRONTEND-API-GUIDE.md index cae65a1a..697d406a 100644 --- a/docs/FRONTEND-API-GUIDE.md +++ b/docs/FRONTEND-API-GUIDE.md @@ -3,6 +3,7 @@ ## 1. 아키텍처 구조 (업데이트됨) ### 채팅방과 게임방 분리 + ``` RoomType enum ├── CHAT ("chat") - 일반 채팅방 @@ -15,6 +16,7 @@ RoomStatus enum ``` ### GSI1SK 인덱스 설계 + ``` GSI1PK: "ROOMS" (고정) GSI1SK: {type}#{gameType}#{status}#{level}#{createdAt} @@ -31,20 +33,20 @@ GSI1SK: {type}#{gameType}#{status}#{level}#{createdAt} ## 2. 방 타입 (RoomType) -| 타입 | 코드 | 설명 | -|------|------|------| -| `CHAT` | `chat` | 일반 채팅방 | +| 타입 | 코드 | 설명 | +|--------|--------|---------------| +| `CHAT` | `chat` | 일반 채팅방 | | `GAME` | `game` | 게임방 (캐치마인드 등) | --- ## 3. 방 상태 (RoomStatus) -| 상태 | 코드 | 설명 | 게임 시작 가능 | -|------|------|------|:-------------:| -| `WAITING` | `waiting` | 대기 중 | O | -| `PLAYING` | `playing` | 게임 진행 중 | X | -| `FINISHED` | `finished` | 게임 종료됨 | O | +| 상태 | 코드 | 설명 | 게임 시작 가능 | +|------------|------------|---------|:--------:| +| `WAITING` | `waiting` | 대기 중 | O | +| `PLAYING` | `playing` | 게임 진행 중 | X | +| `FINISHED` | `finished` | 게임 종료됨 | O | --- @@ -52,23 +54,23 @@ GSI1SK: {type}#{gameType}#{status}#{level}#{createdAt} ### 채팅방 API (`/api/chat/rooms`) -| Method | Endpoint | 설명 | -|--------|----------|------| -| POST | `/rooms` | 채팅방/게임방 생성 | -| GET | `/rooms` | 방 목록 조회 (필터 지원) | -| GET | `/rooms/{roomId}` | 방 상세 조회 | -| POST | `/rooms/{roomId}/join` | 방 입장 (roomToken 발급) | -| POST | `/rooms/{roomId}/leave` | 방 퇴장 | -| DELETE | `/rooms/{roomId}` | 방 삭제 (방장만) | +| Method | Endpoint | 설명 | +|--------|-------------------------|---------------------| +| POST | `/rooms` | 채팅방/게임방 생성 | +| GET | `/rooms` | 방 목록 조회 (필터 지원) | +| GET | `/rooms/{roomId}` | 방 상세 조회 | +| POST | `/rooms/{roomId}/join` | 방 입장 (roomToken 발급) | +| POST | `/rooms/{roomId}/leave` | 방 퇴장 | +| DELETE | `/rooms/{roomId}` | 방 삭제 (방장만) | ### 게임 API (`/api/game`) -| Method | Endpoint | 설명 | -|--------|----------|------| -| POST | `/rooms/{roomId}/game/start` | 게임 시작 | -| POST | `/rooms/{roomId}/game/stop` | 게임 중단 | -| GET | `/rooms/{roomId}/game/status` | 게임 상태 조회 | -| GET | `/rooms/{roomId}/game/scores` | 점수판 조회 | +| Method | Endpoint | 설명 | +|--------|-------------------------------|----------| +| POST | `/rooms/{roomId}/game/start` | 게임 시작 | +| POST | `/rooms/{roomId}/game/stop` | 게임 중단 | +| GET | `/rooms/{roomId}/game/status` | 게임 상태 조회 | +| GET | `/rooms/{roomId}/game/scores` | 점수판 조회 | --- @@ -78,14 +80,14 @@ GSI1SK: {type}#{gameType}#{status}#{level}#{createdAt} GET /api/chat/rooms?type=GAME&gameType=CATCHMIND&status=WAITING&level=intermediate&limit=10&cursor=xxx ``` -| 파라미터 | 타입 | 설명 | 예시 | -|----------|------|------|------| -| `type` | string | 방 타입 필터 | `CHAT`, `GAME` | -| `gameType` | string | 게임 타입 | `CATCHMIND` | -| `status` | string | 상태 필터 | `WAITING`, `PLAYING`, `FINISHED` | -| `level` | string | 난이도 필터 | `beginner`, `intermediate`, `advanced` | -| `limit` | number | 조회 개수 (기본 10, 최대 20) | | -| `cursor` | string | 페이지네이션 커서 | | +| 파라미터 | 타입 | 설명 | 예시 | +|------------|--------|----------------------|----------------------------------------| +| `type` | string | 방 타입 필터 | `CHAT`, `GAME` | +| `gameType` | string | 게임 타입 | `CATCHMIND` | +| `status` | string | 상태 필터 | `WAITING`, `PLAYING`, `FINISHED` | +| `level` | string | 난이도 필터 | `beginner`, `intermediate`, `advanced` | +| `limit` | number | 조회 개수 (기본 10, 최대 20) | | +| `cursor` | string | 페이지네이션 커서 | | ### 필터 조합 예시 @@ -136,6 +138,7 @@ GET /api/chat/rooms?type=GAME&status=PLAYING&level=advanced ## 6. 방 생성 요청 (업데이트됨) ### 채팅방 생성 + ```json { "name": "영어 스터디 채팅방", @@ -147,6 +150,7 @@ GET /api/chat/rooms?type=GAME&status=PLAYING&level=advanced ``` ### 게임방 생성 + ```json { "name": "캐치마인드 게임", @@ -163,6 +167,7 @@ GET /api/chat/rooms?type=GAME&status=PLAYING&level=advanced ## 7. 프론트엔드에서 방 타입 구분 ### 방법 1: API 필터 사용 (권장) + ```javascript // 게임방만 조회 const gameRooms = await fetch('/api/chat/rooms?type=GAME'); @@ -175,6 +180,7 @@ const chatRooms = await fetch('/api/chat/rooms?type=CHAT'); ``` ### 방법 2: 전체 조회 후 클라이언트 필터링 + ```javascript const allRooms = await fetchRooms(); @@ -193,16 +199,19 @@ const waitingRooms = allRooms.filter(room => room.status === 'WAITING'); ## 8. WebSocket 연결 ### 채팅/게임 WebSocket + ``` wss://t378dif43l.execute-api.ap-northeast-2.amazonaws.com/dev?roomToken={roomToken} ``` ### Grammar WebSocket + ``` wss://ltrccmteo8.execute-api.ap-northeast-2.amazonaws.com/dev?token={jwtToken} ``` ### 연결 순서 + 1. `POST /rooms/{roomId}/join` → `roomToken` 발급 2. WebSocket 연결 시 `roomToken` 쿼리 파라미터로 전달 @@ -210,20 +219,20 @@ wss://ltrccmteo8.execute-api.ap-northeast-2.amazonaws.com/dev?token={jwtToken} ## 9. WebSocket 메시지 타입 (messageType) -| 코드 | 타입 | 설명 | -|------|------|------| -| `MSG` | 일반 메시지 | 일반 채팅 메시지 | -| `VOICE` | 음성 메시지 | 음성 채팅 | -| `JOIN` | 입장 알림 | 사용자 입장 | -| `LEAVE` | 퇴장 알림 | 사용자 퇴장 | -| `GAME_START` | 게임 시작 | 게임 시작 알림 | -| `GAME_END` | 게임 종료 | 게임 종료 + 최종 순위 | -| `ROUND_START` | 라운드 시작 | 새 라운드 시작 | -| `ROUND_END` | 라운드 종료 | 정답 공개 | -| `ANSWER_CORRECT` | 정답 | 정답 맞춤 | -| `HINT` | 힌트 | 힌트 제공 | -| `SKIP` | 스킵 | 라운드 스킵 | -| `SYSTEM` | 시스템 | 시스템 메시지 | +| 코드 | 타입 | 설명 | +|------------------|--------|---------------| +| `MSG` | 일반 메시지 | 일반 채팅 메시지 | +| `VOICE` | 음성 메시지 | 음성 채팅 | +| `JOIN` | 입장 알림 | 사용자 입장 | +| `LEAVE` | 퇴장 알림 | 사용자 퇴장 | +| `GAME_START` | 게임 시작 | 게임 시작 알림 | +| `GAME_END` | 게임 종료 | 게임 종료 + 최종 순위 | +| `ROUND_START` | 라운드 시작 | 새 라운드 시작 | +| `ROUND_END` | 라운드 종료 | 정답 공개 | +| `ANSWER_CORRECT` | 정답 | 정답 맞춤 | +| `HINT` | 힌트 | 힌트 제공 | +| `SKIP` | 스킵 | 라운드 스킵 | +| `SYSTEM` | 시스템 | 시스템 메시지 | --- @@ -231,13 +240,13 @@ wss://ltrccmteo8.execute-api.ap-northeast-2.amazonaws.com/dev?token={jwtToken} 채팅 메시지로 게임 명령어 전송: -| 명령어 | 설명 | 권한 | -|--------|------|------| -| `/start` | 게임 시작 | 방장 (2명 이상 접속 시) | -| `/stop` | 게임 중단 | 방장 또는 게임 시작자 | -| `/skip` | 라운드 스킵 | 누구나 | -| `/hint` | 힌트 제공 | 출제자만 | -| `/score` | 점수 확인 | 누구나 | +| 명령어 | 설명 | 권한 | +|----------|--------|-----------------| +| `/start` | 게임 시작 | 방장 (2명 이상 접속 시) | +| `/stop` | 게임 중단 | 방장 또는 게임 시작자 | +| `/skip` | 라운드 스킵 | 누구나 | +| `/hint` | 힌트 제공 | 출제자만 | +| `/score` | 점수 확인 | 누구나 | --- @@ -271,6 +280,7 @@ wss://ltrccmteo8.execute-api.ap-northeast-2.amazonaws.com/dev?token={jwtToken} - 공백 무시 ### 점수 계산 + ``` 기본 점수: 10점 시간 보너스: (제한시간 - 경과시간) * 0.5 @@ -283,12 +293,12 @@ wss://ltrccmteo8.execute-api.ap-northeast-2.amazonaws.com/dev?token={jwtToken} ## 13. 게임 설정 -| 설정 | 기본값 | 환경변수 | -|------|--------|----------| -| 총 라운드 수 | 5 | `GAME_TOTAL_ROUNDS` | -| 라운드 제한 시간(초) | 60 | `GAME_ROUND_TIME_LIMIT` | -| 빠른 정답 기준(ms) | 5000 | `GAME_QUICK_GUESS_THRESHOLD_MS` | -| 게임 전체 제한(초) | 420 (7분) | `GAME_TIME_LIMIT_SECONDS` | +| 설정 | 기본값 | 환경변수 | +|--------------|----------|---------------------------------| +| 총 라운드 수 | 5 | `GAME_TOTAL_ROUNDS` | +| 라운드 제한 시간(초) | 60 | `GAME_ROUND_TIME_LIMIT` | +| 빠른 정답 기준(ms) | 5000 | `GAME_QUICK_GUESS_THRESHOLD_MS` | +| 게임 전체 제한(초) | 420 (7분) | `GAME_TIME_LIMIT_SECONDS` | --- @@ -304,36 +314,38 @@ wss://ltrccmteo8.execute-api.ap-northeast-2.amazonaws.com/dev?token={jwtToken} ## 15. 에러 코드 -| 코드 | HTTP | 설명 | -|------|------|------| -| `ROOM_001` | 404 | 채팅방 없음 | -| `ROOM_002` | 409 | 채팅방 이미 존재 | -| `ROOM_003` | 400 | 채팅방 인원 초과 | -| `ROOM_004` | 400 | 채팅방 종료됨 | -| `ROOM_005` | 401 | 비밀번호 틀림 | -| `ROOM_006` | 403 | 방장 권한 없음 | -| `MEMBER_001` | 403 | 채팅방 멤버 아님 | -| `MEMBER_002` | 409 | 이미 참여 중 | -| `GAME_001` | 400 | 게임 시작 실패 | -| `GAME_002` | 400 | 게임 중단 실패 | -| `GAME_003` | 400 | 게임 진행 중 아님 | -| `GAME_004` | 409 | 게임 이미 진행 중 | -| `GAME_005` | 403 | 게임 시작자 아님 | -| `GAME_006` | 404 | 게임 없음 | -| `GAME_007` | 400 | 채팅방에서 게임 불가 | -| `GAME_008` | 400 | 게임 재시작 불가 | -| `GAME_009` | 403 | 방장만 게임 시작 가능 | +| 코드 | HTTP | 설명 | +|--------------|------|--------------| +| `ROOM_001` | 404 | 채팅방 없음 | +| `ROOM_002` | 409 | 채팅방 이미 존재 | +| `ROOM_003` | 400 | 채팅방 인원 초과 | +| `ROOM_004` | 400 | 채팅방 종료됨 | +| `ROOM_005` | 401 | 비밀번호 틀림 | +| `ROOM_006` | 403 | 방장 권한 없음 | +| `MEMBER_001` | 403 | 채팅방 멤버 아님 | +| `MEMBER_002` | 409 | 이미 참여 중 | +| `GAME_001` | 400 | 게임 시작 실패 | +| `GAME_002` | 400 | 게임 중단 실패 | +| `GAME_003` | 400 | 게임 진행 중 아님 | +| `GAME_004` | 409 | 게임 이미 진행 중 | +| `GAME_005` | 403 | 게임 시작자 아님 | +| `GAME_006` | 404 | 게임 없음 | +| `GAME_007` | 400 | 채팅방에서 게임 불가 | +| `GAME_008` | 400 | 게임 재시작 불가 | +| `GAME_009` | 403 | 방장만 게임 시작 가능 | --- ## 16. UI 구현 가이드 ### 탭 구조 (권장) + ``` [전체] [채팅방] [게임방] ``` ### 게임방 상태 표시 + ``` 대기 중 (WAITING) → 초록색 뱃지 "참여 가능" 진행 중 (PLAYING) → 빨간색 뱃지 "게임 중" @@ -341,6 +353,7 @@ wss://ltrccmteo8.execute-api.ap-northeast-2.amazonaws.com/dev?token={jwtToken} ``` ### 게임방 카드 정보 + ``` ┌─────────────────────────────┐ │ 캐치마인드 - 영어 단어 맞추기 │ From dc0348a5bde3c581e30609c8951e376b3d1869cd Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 11:54:23 +0900 Subject: [PATCH 427/528] chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes --- .sisyphus/plans/cicd-pipeline-plan.md | 771 -------------------------- 1 file changed, 771 deletions(-) delete mode 100644 .sisyphus/plans/cicd-pipeline-plan.md diff --git a/.sisyphus/plans/cicd-pipeline-plan.md b/.sisyphus/plans/cicd-pipeline-plan.md deleted file mode 100644 index 2ce6f8e5..00000000 --- a/.sisyphus/plans/cicd-pipeline-plan.md +++ /dev/null @@ -1,771 +0,0 @@ -# CI/CD Pipeline 기획서 - -> **프로젝트**: Group2 English Study Backend -> **작성일**: 2026-01-22 -> **버전**: 1.0 - ---- - -## 1. 요구사항 요약 - -| 항목 | 선택 | -|---------|----------------------------------| -| 소스 저장소 | GitHub (유지) + CodePipeline v2 연결 | -| 배포 환경 | prod 단일 환경 | -| 트리거 | prod 브랜치 push 또는 PR merge | -| 승인 프로세스 | 완전 자동 (테스트 통과 시 자동 배포) | -| 알림 | AWS SNS → 이메일 | - ---- - -## 2. 전체 아키텍처 - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ AWS CodePipeline │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌──────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────┐ │ -│ │ Source │───▶│ Build │───▶│ Deploy │───▶│ Notify │ │ -│ │ (GitHub) │ │ (CodeBuild) │ │(CloudFormation)│ │ (SNS) │ │ -│ └──────────┘ └──────────────┘ └──────────────┘ └──────────┘ │ -│ │ │ │ │ │ -│ ▼ ▼ ▼ ▼ │ -│ GitHub v2 ┌────────────┐ SAM Deploy Email 알림 │ -│ Connection │ buildspec │ (sam deploy) │ -│ │ ────────── │ │ -│ │ - gradle │ │ -│ │ - sam build│ │ -│ │ - test │ │ -│ └────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────────┐ -│ AWS Resources (prod) │ -├─────────────────────────────────────────────────────────────────────────┤ -│ Lambda (20+) │ API Gateway │ WebSocket │ DynamoDB │ Cognito │ S3 │ -└─────────────────────────────────────────────────────────────────────────┘ -``` - ---- - -## 3. 파이프라인 단계 상세 - -### 3.1 Source Stage - -| 설정 | 값 | -|-----------------|------------------------| -| Provider | GitHub (v2 Connection) | -| Repository | BE_Repository | -| Branch | `prod` | -| Trigger | Push / PR Merge | -| Output Artifact | SourceArtifact | - -**GitHub Connection 설정 필요**: - -- AWS Console → CodePipeline → Settings → Connections -- GitHub App 설치 및 Repository 권한 부여 - -### 3.2 Build Stage - -| 설정 | 값 | -|-----------------|--------------------------------------------------| -| Provider | AWS CodeBuild | -| Environment | `aws/codebuild/amazonlinux2-x86_64-standard:5.0` | -| Compute | `BUILD_GENERAL1_MEDIUM` (7GB RAM, 4 vCPU) | -| Timeout | 30분 | -| Input Artifact | SourceArtifact | -| Output Artifact | BuildArtifact | - -**빌드 단계**: - -1. Java 21 환경 설정 -2. Gradle 빌드 및 테스트 -3. SAM 빌드 -4. 아티팩트 패키징 - -### 3.3 Deploy Stage - -| 설정 | 값 | -|--------------|----------------------------------------| -| Provider | CloudFormation | -| Action Mode | CREATE_UPDATE | -| Stack Name | `group2-englishstudy-prod` | -| Template | packaged-template.yaml | -| Capabilities | CAPABILITY_IAM, CAPABILITY_AUTO_EXPAND | -| Role | CloudFormationExecutionRole | - -### 3.4 Notification Stage - -| 설정 | 값 | -|----------|-------------------------------| -| Provider | AWS SNS | -| Topic | `cicd-pipeline-notifications` | -| Events | 성공, 실패, 시작 | - ---- - -## 4. 필요한 AWS 리소스 - -### 4.1 신규 생성 필요 - -| 리소스 | 이름 | 용도 | -|-------------------|------------------------------------------|---------------| -| CodePipeline | `group2-englishstudy-pipeline` | CI/CD 오케스트레이션 | -| CodeBuild Project | `group2-englishstudy-build` | 빌드 및 테스트 | -| S3 Bucket | `group2-englishstudy-pipeline-artifacts` | 파이프라인 아티팩트 저장 | -| GitHub Connection | `github-connection` | GitHub 연결 | -| SNS Topic | `cicd-pipeline-notifications` | 알림 | -| IAM Role | `CodePipelineServiceRole` | 파이프라인 실행 | -| IAM Role | `CodeBuildServiceRole` | 빌드 실행 | -| IAM Role | `CloudFormationExecutionRole` | 스택 배포 | - -### 4.2 기존 활용 - -| 리소스 | 용도 | -|--------------------------|--------------| -| S3 `group2-englishstudy` | Lambda 코드 저장 | -| DynamoDB Tables | 데이터 저장 | -| Cognito User Pool | 인증 | - ---- - -## 5. buildspec.yml - -```yaml -version: 0.2 - -env: - variables: - JAVA_HOME: /usr/lib/jvm/java-21-amazon-corretto - SAM_CLI_TELEMETRY: 0 - -phases: - install: - runtime-versions: - java: corretto21 - commands: - - echo "Installing SAM CLI..." - - pip3 install aws-sam-cli - - sam --version - - pre_build: - commands: - - echo "Running tests..." - - cd ServerlessFunction - - chmod +x gradlew - - ./gradlew clean test - - echo "Tests completed" - - build: - commands: - - echo "Building SAM application..." - - cd $CODEBUILD_SRC_DIR/ServerlessFunction - - sam build - - echo "Packaging SAM application..." - - sam package \ - --s3-bucket ${ARTIFACT_BUCKET} \ - --s3-prefix sam-packages \ - --output-template-file packaged-template.yaml - - post_build: - commands: - - echo "Build completed on $(date)" - -artifacts: - files: - - ServerlessFunction/packaged-template.yaml - - ServerlessFunction/samconfig.toml - discard-paths: no - -cache: - paths: - - '/root/.gradle/caches/**/*' - - '/root/.gradle/wrapper/**/*' - -reports: - junit-reports: - files: - - 'ServerlessFunction/build/test-results/test/*.xml' - file-format: JUNITXML - jacoco-reports: - files: - - 'ServerlessFunction/build/reports/jacoco/test/jacocoTestReport.xml' - file-format: JACOCOXML -``` - ---- - -## 6. samconfig.toml - -```toml -version = 0.1 - -[default.global.parameters] -stack_name = "group2-englishstudy" - -[prod] -[prod.deploy] -[prod.deploy.parameters] -stack_name = "group2-englishstudy-prod" -s3_bucket = "group2-englishstudy-pipeline-artifacts" -s3_prefix = "sam-deploy" -region = "ap-northeast-2" -capabilities = "CAPABILITY_IAM CAPABILITY_AUTO_EXPAND" -confirm_changeset = false -disable_rollback = false -fail_on_empty_changeset = false - -[prod.build.parameters] -cached = true -parallel = true -``` - ---- - -## 7. IAM 역할 및 정책 - -### 7.1 CodePipeline Service Role - -```json -{ - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Action": [ - "codestar-connections:UseConnection" - ], - "Resource": "arn:aws:codestar-connections:*:*:connection/*" - }, - { - "Effect": "Allow", - "Action": [ - "codebuild:BatchGetBuilds", - "codebuild:StartBuild" - ], - "Resource": "*" - }, - { - "Effect": "Allow", - "Action": [ - "cloudformation:CreateStack", - "cloudformation:DeleteStack", - "cloudformation:DescribeStacks", - "cloudformation:UpdateStack", - "cloudformation:CreateChangeSet", - "cloudformation:DeleteChangeSet", - "cloudformation:DescribeChangeSet", - "cloudformation:ExecuteChangeSet", - "cloudformation:SetStackPolicy" - ], - "Resource": "*" - }, - { - "Effect": "Allow", - "Action": [ - "s3:GetObject", - "s3:PutObject", - "s3:GetBucketVersioning" - ], - "Resource": [ - "arn:aws:s3:::group2-englishstudy-pipeline-artifacts", - "arn:aws:s3:::group2-englishstudy-pipeline-artifacts/*" - ] - }, - { - "Effect": "Allow", - "Action": [ - "iam:PassRole" - ], - "Resource": "*", - "Condition": { - "StringEqualsIfExists": { - "iam:PassedToService": "cloudformation.amazonaws.com" - } - } - }, - { - "Effect": "Allow", - "Action": [ - "sns:Publish" - ], - "Resource": "arn:aws:sns:*:*:cicd-pipeline-notifications" - } - ] -} -``` - -### 7.2 CodeBuild Service Role - -```json -{ - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Action": [ - "logs:CreateLogGroup", - "logs:CreateLogStream", - "logs:PutLogEvents" - ], - "Resource": "arn:aws:logs:*:*:*" - }, - { - "Effect": "Allow", - "Action": [ - "s3:GetObject", - "s3:PutObject", - "s3:GetBucketAcl", - "s3:GetBucketLocation" - ], - "Resource": [ - "arn:aws:s3:::group2-englishstudy-pipeline-artifacts", - "arn:aws:s3:::group2-englishstudy-pipeline-artifacts/*", - "arn:aws:s3:::group2-englishstudy", - "arn:aws:s3:::group2-englishstudy/*" - ] - }, - { - "Effect": "Allow", - "Action": [ - "codebuild:CreateReportGroup", - "codebuild:CreateReport", - "codebuild:UpdateReport", - "codebuild:BatchPutTestCases", - "codebuild:BatchPutCodeCoverages" - ], - "Resource": "arn:aws:codebuild:*:*:report-group/*" - } - ] -} -``` - -### 7.3 CloudFormation Execution Role - -```json -{ - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Action": [ - "lambda:*", - "apigateway:*", - "dynamodb:*", - "s3:*", - "cognito-idp:*", - "sns:*", - "sqs:*", - "iam:*", - "logs:*", - "events:*", - "scheduler:*", - "cloudformation:*" - ], - "Resource": "*" - } - ] -} -``` - -> **보안 주의**: 실제 운영 환경에서는 위 정책을 최소 권한 원칙에 맞게 세분화해야 합니다. - ---- - -## 8. SNS 알림 설정 - -### 8.1 SNS Topic 생성 - -```bash -aws sns create-topic --name cicd-pipeline-notifications -``` - -### 8.2 이메일 구독 추가 - -```bash -aws sns subscribe \ - --topic-arn arn:aws:sns:ap-northeast-2:ACCOUNT_ID:cicd-pipeline-notifications \ - --protocol email \ - --notification-endpoint your-email@example.com -``` - -### 8.3 CloudWatch Event Rule (파이프라인 상태 변경) - -```json -{ - "source": [ - "aws.codepipeline" - ], - "detail-type": [ - "CodePipeline Pipeline Execution State Change" - ], - "detail": { - "pipeline": [ - "group2-englishstudy-pipeline" - ], - "state": [ - "SUCCEEDED", - "FAILED", - "STARTED" - ] - } -} -``` - ---- - -## 9. 비용 추정 (월간) - -| 서비스 | 예상 사용량 | 예상 비용 | -|-----------------|----------------------|-----------| -| CodePipeline | 1 파이프라인, ~100회 실행 | $1.00 | -| CodeBuild | ~100회 x 10분 = 1,000분 | $5.00 | -| S3 (아티팩트) | ~10GB | $0.25 | -| CloudWatch Logs | ~5GB | $2.50 | -| SNS | ~100 알림 | $0.01 | -| **총 예상 비용** | | **~$9/월** | - -> 실제 비용은 배포 빈도와 빌드 시간에 따라 달라질 수 있습니다. - ---- - -## 10. 구현 체크리스트 - -### Phase 1: 사전 준비 - -- [ ] AWS 계정 ID 확인 -- [ ] ap-northeast-2 리전 선택 -- [ ] 필요한 권한 확인 (Admin 또는 필요 권한) - -### Phase 2: 기반 리소스 생성 - -- [ ] S3 버킷 생성: `group2-englishstudy-pipeline-artifacts` -- [ ] SNS Topic 생성: `cicd-pipeline-notifications` -- [ ] SNS 이메일 구독 설정 및 확인 -- [ ] GitHub Connection 생성 (AWS Console) - -### Phase 3: IAM 역할 생성 - -- [ ] CodePipelineServiceRole 생성 -- [ ] CodeBuildServiceRole 생성 -- [ ] CloudFormationExecutionRole 생성 - -### Phase 4: CodeBuild 프로젝트 생성 - -- [ ] 프로젝트 생성: `group2-englishstudy-build` -- [ ] 환경 설정 (Amazon Linux 2, Java 21) -- [ ] buildspec.yml 추가 - -### Phase 5: CodePipeline 생성 - -- [ ] 파이프라인 생성: `group2-englishstudy-pipeline` -- [ ] Source Stage 설정 (GitHub v2) -- [ ] Build Stage 설정 (CodeBuild) -- [ ] Deploy Stage 설정 (CloudFormation) -- [ ] 알림 설정 (SNS) - -### Phase 6: 테스트 및 검증 - -- [ ] prod 브랜치에 테스트 커밋 -- [ ] 파이프라인 자동 트리거 확인 -- [ ] 빌드 성공 확인 -- [ ] 배포 성공 확인 -- [ ] 이메일 알림 수신 확인 -- [ ] Lambda 함수 정상 동작 확인 - -### Phase 7: 문서화 - -- [ ] README에 CI/CD 섹션 추가 -- [ ] 트러블슈팅 가이드 작성 -- [ ] 롤백 절차 문서화 - ---- - -## 11. 파이프라인 CloudFormation 템플릿 (선택) - -아래 템플릿으로 전체 파이프라인을 IaC로 관리할 수 있습니다: - -```yaml -AWSTemplateFormatVersion: '2010-09-09' -Description: CI/CD Pipeline for Group2 English Study - -Parameters: - GitHubConnectionArn: - Type: String - Description: ARN of the GitHub Connection - - GitHubRepo: - Type: String - Default: "your-org/BE_Repository" - - GitHubBranch: - Type: String - Default: "prod" - - NotificationEmail: - Type: String - Description: Email for pipeline notifications - -Resources: - # S3 Bucket for Artifacts - ArtifactBucket: - Type: AWS::S3::Bucket - Properties: - BucketName: group2-englishstudy-pipeline-artifacts - VersioningConfiguration: - Status: Enabled - - # SNS Topic for Notifications - NotificationTopic: - Type: AWS::SNS::Topic - Properties: - TopicName: cicd-pipeline-notifications - - EmailSubscription: - Type: AWS::SNS::Subscription - Properties: - TopicArn: !Ref NotificationTopic - Protocol: email - Endpoint: !Ref NotificationEmail - - # CodeBuild Project - CodeBuildProject: - Type: AWS::CodeBuild::Project - Properties: - Name: group2-englishstudy-build - ServiceRole: !GetAtt CodeBuildRole.Arn - Artifacts: - Type: CODEPIPELINE - Environment: - Type: LINUX_CONTAINER - ComputeType: BUILD_GENERAL1_MEDIUM - Image: aws/codebuild/amazonlinux2-x86_64-standard:5.0 - EnvironmentVariables: - - Name: ARTIFACT_BUCKET - Value: !Ref ArtifactBucket - Source: - Type: CODEPIPELINE - BuildSpec: ServerlessFunction/buildspec.yml - TimeoutInMinutes: 30 - Cache: - Type: S3 - Location: !Sub "${ArtifactBucket}/cache" - - # CodePipeline - Pipeline: - Type: AWS::CodePipeline::Pipeline - Properties: - Name: group2-englishstudy-pipeline - RoleArn: !GetAtt PipelineRole.Arn - ArtifactStore: - Type: S3 - Location: !Ref ArtifactBucket - Stages: - - Name: Source - Actions: - - Name: GitHub - ActionTypeId: - Category: Source - Owner: AWS - Provider: CodeStarSourceConnection - Version: '1' - Configuration: - ConnectionArn: !Ref GitHubConnectionArn - FullRepositoryId: !Ref GitHubRepo - BranchName: !Ref GitHubBranch - OutputArtifactFormat: CODE_ZIP - OutputArtifacts: - - Name: SourceArtifact - RunOrder: 1 - - - Name: Build - Actions: - - Name: Build - ActionTypeId: - Category: Build - Owner: AWS - Provider: CodeBuild - Version: '1' - Configuration: - ProjectName: !Ref CodeBuildProject - InputArtifacts: - - Name: SourceArtifact - OutputArtifacts: - - Name: BuildArtifact - RunOrder: 1 - - - Name: Deploy - Actions: - - Name: Deploy - ActionTypeId: - Category: Deploy - Owner: AWS - Provider: CloudFormation - Version: '1' - Configuration: - ActionMode: CREATE_UPDATE - StackName: group2-englishstudy-prod - TemplatePath: BuildArtifact::ServerlessFunction/packaged-template.yaml - Capabilities: CAPABILITY_IAM,CAPABILITY_AUTO_EXPAND - RoleArn: !GetAtt CloudFormationRole.Arn - InputArtifacts: - - Name: BuildArtifact - RunOrder: 1 - - # Pipeline Notification Rule - PipelineNotificationRule: - Type: AWS::CodeStarNotifications::NotificationRule - Properties: - Name: group2-pipeline-notifications - DetailType: FULL - Resource: !Sub "arn:aws:codepipeline:${AWS::Region}:${AWS::AccountId}:${Pipeline}" - EventTypeIds: - - codepipeline-pipeline-pipeline-execution-started - - codepipeline-pipeline-pipeline-execution-succeeded - - codepipeline-pipeline-pipeline-execution-failed - Targets: - - TargetType: SNS - TargetAddress: !Ref NotificationTopic - - # IAM Roles (simplified - expand as needed) - PipelineRole: - Type: AWS::IAM::Role - Properties: - AssumeRolePolicyDocument: - Version: '2012-10-17' - Statement: - - Effect: Allow - Principal: - Service: codepipeline.amazonaws.com - Action: sts:AssumeRole - ManagedPolicyArns: - - arn:aws:iam::aws:policy/AWSCodePipeline_FullAccess - Policies: - - PolicyName: PipelinePolicy - PolicyDocument: - Version: '2012-10-17' - Statement: - - Effect: Allow - Action: - - codestar-connections:UseConnection - Resource: !Ref GitHubConnectionArn - - Effect: Allow - Action: - - s3:* - Resource: - - !GetAtt ArtifactBucket.Arn - - !Sub "${ArtifactBucket.Arn}/*" - - Effect: Allow - Action: - - codebuild:* - Resource: !GetAtt CodeBuildProject.Arn - - Effect: Allow - Action: - - cloudformation:* - Resource: "*" - - Effect: Allow - Action: - - iam:PassRole - Resource: !GetAtt CloudFormationRole.Arn - - CodeBuildRole: - Type: AWS::IAM::Role - Properties: - AssumeRolePolicyDocument: - Version: '2012-10-17' - Statement: - - Effect: Allow - Principal: - Service: codebuild.amazonaws.com - Action: sts:AssumeRole - Policies: - - PolicyName: CodeBuildPolicy - PolicyDocument: - Version: '2012-10-17' - Statement: - - Effect: Allow - Action: - - logs:* - Resource: "*" - - Effect: Allow - Action: - - s3:* - Resource: - - !GetAtt ArtifactBucket.Arn - - !Sub "${ArtifactBucket.Arn}/*" - - "arn:aws:s3:::group2-englishstudy" - - "arn:aws:s3:::group2-englishstudy/*" - - Effect: Allow - Action: - - codebuild:CreateReportGroup - - codebuild:CreateReport - - codebuild:UpdateReport - - codebuild:BatchPutTestCases - - codebuild:BatchPutCodeCoverages - Resource: "*" - - CloudFormationRole: - Type: AWS::IAM::Role - Properties: - AssumeRolePolicyDocument: - Version: '2012-10-17' - Statement: - - Effect: Allow - Principal: - Service: cloudformation.amazonaws.com - Action: sts:AssumeRole - ManagedPolicyArns: - - arn:aws:iam::aws:policy/AdministratorAccess # Narrow down in production! - -Outputs: - PipelineUrl: - Value: !Sub "https://${AWS::Region}.console.aws.amazon.com/codesuite/codepipeline/pipelines/${Pipeline}/view" -``` - ---- - -## 12. 트러블슈팅 가이드 - -### 빌드 실패: Java 버전 문제 - -``` -Error: Unsupported class file major version 65 -``` - -**해결**: CodeBuild 이미지에서 Java 21 (Corretto) 사용 확인 - -### 배포 실패: IAM 권한 부족 - -``` -User: arn:aws:sts::xxx is not authorized to perform: iam:CreateRole -``` - -**해결**: CloudFormationExecutionRole에 IAM 권한 추가 - -### SAM 빌드 실패: 메모리 부족 - -``` -FATAL ERROR: CALL_AND_RETRY_LAST Allocation failed - JavaScript heap out of memory -``` - -**해결**: CodeBuild compute type을 `BUILD_GENERAL1_LARGE`로 변경 - -### GitHub Connection 인증 실패 - -**해결**: AWS Console에서 Connection 상태 확인 → GitHub App 재인증 - ---- - -## 13. 다음 단계 - -1. **기획서 검토 및 승인** -2. **Phase 1-7 순차 실행** -3. **첫 배포 테스트** -4. **모니터링 대시보드 구성 (선택)** - ---- - -> **문의사항**: CI/CD 파이프라인 구현 중 문제가 있으면 문의하세요. From 4a40621e39d8934c05dcc7927668fee32c148c45 Mon Sep 17 00:00:00 2001 From: DDING JOO Date: Thu, 22 Jan 2026 11:56:44 +0900 Subject: [PATCH 428/528] =?UTF-8?q?Release:=20=EC=BA=90=EC=B9=98=EB=A7=88?= =?UTF-8?q?=EC=9D=B8=EB=93=9C=20=EA=B2=8C=EC=9E=84=20=EB=B6=84=EB=A6=AC,?= =?UTF-8?q?=20AI=20=ED=9A=8C=ED=99=94=20=EC=97=B0=EC=8A=B5,=20CI/CD=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8=20(#469)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feature : OPIc 세션관리 + 답변 처리 파이프라인 구현 (#413) * feat : OPIc 질문 & 세션 관련 dto 생성 * refactor : @DynamoDbBean 주석 추가 * feat : 주제 + 소주제 + 레벨로 오픽 질문 조회 추가 * feat : OPIc 세션, 질문, 답변 종합 handler 구현 * feat : 오픽 주제, 소주제별 seed 데이터 추가 * refactor : Transcribe API KEY 환경변수명 수정 * refactor : Bedrock에 사용하는 클로드 모델 변경 * refactor : S3 Key 대신 Proxy에서 요청하는 Base64 값으로 변환 * feat: GAME_START, ROUND_END 메시지에 serverTime 추가 - broadcastGameStart(): serverTime, roundDuration 필드 추가 - broadcastRoundEnd(): serverTime, roundStartTime, roundDuration 필드 추가 - GameService.endRound(): data에 roundStartTime, roundDuration 포함 - 타이머 동기화 버그 수정을 위한 서버 시간 제공 * feat: WebSocketMessageHelper 유틸리티 클래스 추가 - domain 필드 포함 메시지 생성 헬퍼 - DOMAIN_CHAT, DOMAIN_GAME 상수 정의 - buildChatMessage(), buildGameMessage() 메서드 * feat: 모든 WebSocket 메시지에 domain 필드 추가 - ScoreUpdateMessage에 domain 필드 추가 - broadcastGameStart에 domain:"game" 추가 - broadcastRoundEnd에 domain:"game" 추가 - broadcastCorrectAnswerMessage에 domain:"game" 추가 - handleCommandResult 시스템 메시지에 domain:"game" 추가 - handleRegularMessage 채팅 메시지에 domain:"chat" 추가 Closes #426, #427 * feat: GameSession 모델 클래스 생성 - DynamoDB Enhanced Client 어노테이션 적용 - GSI1: roomId로 활성 게임 세션 조회 가능 - 게임 상태 관리용 헬퍼 메서드 포함 Closes #428 * feat: GameSessionRepository 구현 - 기본 CRUD (save, findById, delete) - roomId로 활성/전체 게임 세션 조회 - 상태, 라운드, 점수 업데이트 메서드 - 정답자 추가, 힌트 사용, 게임 종료 처리 Closes #429 * refactor: ChatRoom에서 게임 필드 분리 - 게임 관련 필드 제거 (gameStatus, currentRound 등) - activeGameSessionId 필드 추가 - 게임 상태는 GameSession으로 분리됨 Note: 의존 코드 수정은 #431에서 진행 Closes #430 * refactor: GameSession 기반으로 전체 게임 로직 리팩토링 - GameService: ChatRoom 대신 GameSession 사용 - GameStatsService: GameSession 매개변수로 변경 - CommandService: GameSession 기반 점수 조회 - GameHandler: GameSession 기반 REST API - WebSocketMessageHandler: GameSession 기반 브로드캐스트 - GameStatusResponse, ScoreboardResponse: GameSession 매개변수 Closes #431 * feat: GameSessionHandler Lambda 및 게임 세션 API 구현 - POST /rooms/{roomId}/games - 게임 세션 생성 - GET /games/{gameSessionId} - 게임 상태 조회 (재접속용) - POST /games/{gameSessionId}/start - 게임 시작 - POST /games/{gameSessionId}/stop - 게임 종료 모든 응답에 serverTime 포함 (타이머 동기화) 출제자에게만 currentWord 포함 Closes #432, #433 * feat: 게임 시작 7분 후 자동 종료 기능 구현 - GameConfig에 gameTimeLimit() 메서드 추가 (기본값: 420초) - GameSchedulerClient 유틸리티 클래스 생성 (EventBridge Scheduler 연동) - GameService에 스케줄 생성/취소 로직 추가 - GameAutoCloseHandler Lambda 함수 생성 - template.yaml에 Lambda, IAM Role, Schedule Group 추가 Closes #417 * fix : 메모리 증가 및 Lambda 응답 제한 시간 Cognito 트리거 제한시간과 동일하게 수정 (#439) * feat: 캐치마인드 게임 방 분리 기능 구현 (#455) - Room 타입 분리 (CHAT/GAME) - RoomType, RoomStatus enum 추가 - ChatRoom 모델에 type, gameType, gameSettings, status, hostId 필드 추가 - GameSettings 모델 추가 - 방 생성/조회 API 수정 - CreateRoomRequest에 type, gameType, gameSettings 필드 추가 - 방 목록 조회 시 type, gameType, status 필터 지원 - 게임 시작 조건 검증 - GAME 타입 방에서만 게임 시작 가능 (GAME_007 에러) - 게임 재시작 API 구현 (#452) - POST /rooms/{roomId}/game/restart 엔드포인트 추가 - 방장만 재시작 가능 (GAME_009 에러) - 게임 진행 중 재시작 불가 (GAME_008 에러) - 방장 변경 로직 구현 (#453) - 방장 퇴장 시 다음 멤버에게 자동 이전 - 모든 멤버 퇴장 시 방 자동 삭제 - WebSocket 메시지 타입 추가 (#454) - ROOM_STATUS_CHANGE, HOST_CHANGE 메시지 타입 추가 - WebSocketMessageHelper에 빌더 메서드 추가 - 테스트 추가 - RoomType, RoomStatus enum 테스트 - GameSettings 모델 테스트 - ChattingErrorCode 테스트 업데이트 Related: #440, #441, #442, #443, #444, #445, #446 Closes: #447, #448, #449, #450, #451, #452, #453, #454 * feat: 참가자 닉네임 및 방장 변경 WebSocket 알림 구현 (#456) - RoomParticipant DTO 추가 (userId, nickname, isHost) - ChatRoomQueryService에 닉네임 조회 메서드 추가 - getParticipantsWithNicknames(): 참가자 목록 + 닉네임 - getHostNickname(): 방장 닉네임 조회 - ChatRoomHandler.getRoom() 응답에 participants, hostNickname 추가 - ChatRoomCommandService.leaveRoom()에서 방장 변경 시 WebSocket 브로드캐스트 Related: #440 * fix: ChatRoomFunction에 UserTable DynamoDB 권한 추가 - 참가자/방장 닉네임 조회를 위한 UserTable 읽기 권한 추가 - DynamoDBReadPolicy로 UserTable 접근 허용 Fixes #457 * fix: GameSettings에 @DynamoDbBean 어노테이션 추가 - DynamoDB Enhanced Client가 중첩 객체를 직렬화/역직렬화할 수 있도록 수정 - ChatRoom 저장/조회 시 GameSettings 변환 오류 해결 Fixes #457 * fix: ChatRoomFunction에 WEBSOCKET_ENDPOINT 환경변수 및 권한 추가 - leaveRoom에서 WebSocketBroadcaster 사용을 위한 환경변수 추가 - execute-api:ManageConnections 권한 추가 * fix: WebSocket Lambda 함수들에 WEBSOCKET_ENDPOINT 환경변수 추가 - WebSocketConnectFunction에 WEBSOCKET_ENDPOINT 추가 - WebSocketDisconnectFunction에 WEBSOCKET_ENDPOINT 추가 - execute-api:ManageConnections 권한 추가 * fix: Grammar WebSocket Lambda 환경 변수 및 권한 추가 - GrammarStreamingConnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - GrammarStreamingDisconnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - 두 함수에 execute-api:ManageConnections 권한 추가 * feat: GSI1SK 확장성 있는 재설계 및 DB 레벨 필터링 ## 변경 사항 ### GSI1SK 포맷 변경 - 기존: {level}#{createdAt} - 신규: {type}#{gameType}#{status}#{level}#{createdAt} ### 지원 쿼리 패턴 - 전체 방: GSI1PK = "ROOMS" - 게임방만: begins_with(GSI1SK, "GAME#") - 캐치마인드만: begins_with(GSI1SK, "GAME#CATCHMIND#") - 대기중 캐치마인드: begins_with(GSI1SK, "GAME#CATCHMIND#WAITING#") ### 파일 수정 - ChatRoomCommandService.java: 방 생성 시 새 GSI1SK 포맷 적용 - ChatRoomRepository.java: findByFilters() 메서드 추가, updateStatus() 메서드 추가 - ChatRoomQueryService.java: 메모리 필터링 제거, DB 레벨 필터링으로 변경 - GameService.java: 게임 시작/종료 시 방 상태 업데이트 (GSI1SK 포함) ### 마이그레이션 - scripts/migrate-gsi1sk.sh: 기존 데이터 마이그레이션 스크립트 - 37개 기존 방 마이그레이션 완료 * fix: increase stats query limit to 100 days - Adjust maximum allowable limit for recent stats query from 30 to 100 days to support extended data range responses. * feat: improve game round and connection management logic - Added handling for game round timeout (`ROUND_TIMEOUT`) in WebSocketMessageHandler. - Enhanced game start broadcast to include `currentWord` for the drawer only. - Updated ConnectionRepository to remove duplicate user connections in the same room. - Added `currentWordEnglish` to GameSession for better answer verification. - Normalized ChatRoom levels to match database storage format. - Updated template.yaml to include DynamoDBReadPolicy for VocabTable. * refactor: 코드 정리 및 미사용 클래스 제거 (#459) * refactor: remove unused WebSocketResponseUtil * refactor: add AutoCloseable to WebSocketBroadcaster * refactor: remove unused TestService * refactor: remove unused WordService * refactor: remove unused DailyStudyService * refactor: remove unused UserWordService * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * fix: resolve N+1 query in StatsService * refactor(all): DI 패턴 및 전략 패턴 적용 (#461) - Repository, Service, Handler에 DI 생성자 추가 (테스트 용이성) - BadgeService에 Strategy 패턴 적용 (뱃지 조건 검증 로직 분리) * refactor: relocate and restructure seed data files - Moved `question-homes.json` and `words.json` from subdirectories to `seed` folder. - Updated file paths for better organization and clarity. * chore: seed 데이터 폴더 구조 정리 * feat: add CI/CD pipeline configuration for CodePipeline * fix: add SNS topic policy and DependsOn for notification rule * fix: correct paths in buildspec.yml for CodeBuild * fix: remove hardcoded JAVA_HOME, use runtime default * fix: add gradle wrapper for CI/CD build * fix: use single line sam package command with hardcoded bucket * fix: use existing stack name group2-englishstudy-chatting * fix: add missing WEBSOCKET_ENDPOINT env var to WebSocket connect functions * docs: update FRONTEND-API-GUIDE with new RoomType/RoomStatus structure * fix: update WebSocketDisconnectHandler to use GameSession model - Remove references to deleted ChatRoom game fields - Use GameSessionRepository to finish active game sessions - Use ChatRoomRepository.updateStatus() for room state management * perf: optimize CI/CD build time - Add pip cache for SAM CLI dependencies - Enable Gradle parallel builds and build cache - Add SAM build cache (.aws-sam) - Use sam build --parallel --cached - Skip SAM CLI install if already cached - Remove unnecessary 'clean' to leverage cache * feat: add custom CodeBuild Docker image with pre-installed tools - Dockerfile with Java 21 + SAM CLI + Gradle pre-installed - build-and-push.sh script for ECR deployment - Updated buildspec.yml for custom image (removes SAM CLI install) - Expected build time reduction: ~30-40 seconds * feature : AI 영어 회화 연습 기능 (#468) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix: remove typo in SpeakingConnectionRepository * chore: trigger build test with custom Docker image * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes --------- Co-authored-by: hyein Heo <128613248+hye-inA@users.noreply.github.com> --- .gitignore | 7 +- ServerlessFunction/build.gradle | 1 + ServerlessFunction/buildspec.yml | 55 ++ .../docs/CATCHMIND_FRONTEND_GUIDE.md | 761 ++++++++++++++++++ .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 45633 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + .../common/router/HandlerRouter.java | 6 +- .../common/util/WebSocketBroadcaster.java | 13 +- .../common/util/WebSocketMessageHelper.java | 166 ++++ .../common/util/WebSocketResponseUtil.java | 40 - .../common/validation/BeanValidator.java | 3 - .../domain/badge/handler/BadgeHandler.java | 12 +- .../badge/repository/BadgeRepository.java | 13 +- .../domain/badge/service/BadgeService.java | 59 +- .../badge/strategy/AccuracyStrategy.java | 34 + .../strategy/BadgeConditionStrategy.java | 25 + .../BadgeConditionStrategyFactory.java | 40 + .../badge/strategy/FirstStudyStrategy.java | 25 + .../badge/strategy/GamesPlayedStrategy.java | 25 + .../badge/strategy/GamesWonStrategy.java | 25 + .../domain/badge/strategy/NoOpStrategy.java | 32 + .../badge/strategy/PerfectDrawsStrategy.java | 25 + .../badge/strategy/QuickGuessesStrategy.java | 25 + .../domain/badge/strategy/StreakStrategy.java | 25 + .../strategy/TestsCompletedStrategy.java | 25 + .../badge/strategy/WordsLearnedStrategy.java | 32 + .../domain/chatting/config/GameConfig.java | 10 + .../dto/request/CreateRoomRequest.java | 8 + .../dto/response/GameStatusResponse.java | 26 +- .../chatting/dto/response/RoomListItem.java | 71 ++ .../dto/response/RoomParticipant.java | 16 + .../dto/response/ScoreUpdateMessage.java | 2 + .../dto/response/ScoreboardResponse.java | 12 +- .../domain/chatting/enums/MessageType.java | 6 +- .../domain/chatting/enums/RoomStatus.java | 39 + .../domain/chatting/enums/RoomType.java | 38 + .../chatting/exception/ChattingErrorCode.java | 4 + .../chatting/handler/ChatMessageHandler.java | 14 +- .../chatting/handler/ChatRoomHandler.java | 52 +- .../chatting/handler/ChatVoiceHandler.java | 14 +- .../handler/GameAutoCloseHandler.java | 107 +++ .../domain/chatting/handler/GameHandler.java | 147 +++- .../chatting/handler/GameSessionHandler.java | 294 +++++++ .../websocket/WebSocketConnectHandler.java | 3 + .../websocket/WebSocketDisconnectHandler.java | 62 +- .../websocket/WebSocketMessageHandler.java | 275 ++++--- .../domain/chatting/model/ChatRoom.java | 24 +- .../domain/chatting/model/GameSession.java | 190 +++++ .../domain/chatting/model/GameSettings.java | 23 + .../repository/ChatMessageRepository.java | 13 +- .../repository/ChatRoomRepository.java | 103 ++- .../repository/ConnectionRepository.java | 44 +- .../repository/GameRoundRepository.java | 12 +- .../repository/GameSessionRepository.java | 293 +++++++ .../repository/RoomTokenRepository.java | 13 +- .../chatting/service/ChatMessageService.java | 12 +- .../service/ChatRoomCommandService.java | 99 ++- .../service/ChatRoomQueryService.java | 75 +- .../chatting/service/CommandService.java | 47 +- .../chatting/service/GameSchedulerClient.java | 151 ++++ .../domain/chatting/service/GameService.java | 426 ++++++---- .../chatting/service/GameStatsService.java | 30 +- .../chatting/service/RoomTokenService.java | 12 +- .../grammar/handler/GrammarHandler.java | 17 +- .../websocket/GrammarStreamingHandler.java | 76 +- .../GrammarConnectionRepository.java | 16 +- .../repository/GrammarSessionRepository.java | 15 +- .../dto/request/CreateSessionRequest.java | 8 + .../opic/dto/request/SubmitAnswerRequest.java | 6 + .../dto/response/AnswerFeedbackResponse.java | 11 + .../dto/response/CreateSessionResponse.java | 8 + .../opic/dto/response/QuestionResponse.java | 10 + .../opic/handler/OPIcSessionHandler.java | 506 ++++++++++++ .../domain/opic/model/OPIcAnswer.java | 2 + .../opic/repository/OPIcRepository.java | 11 + .../domain/opic/service/FeedbackService.java | 2 +- .../websocket/SpeakingConnectHandler.java | 88 ++ .../websocket/SpeakingDisconnectHandler.java | 44 + .../websocket/SpeakingMessageHandler.java | 217 +++++ .../speaking/model/SpeakingConnection.java | 84 ++ .../SpeakingConnectionRepository.java | 73 ++ .../speaking/service/SpeakingService.java | 318 ++++++++ .../stats/handler/ScheduledStatsHandler.java | 93 ++- .../stats/handler/StatsStreamHandler.java | 14 +- .../stats/handler/UserStatsHandler.java | 14 +- .../stats/repository/UserStatsRepository.java | 13 +- .../domain/stats/service/StatsService.java | 12 +- .../user/handler/PostConfirmationHandler.java | 2 +- .../vocabulary/handler/DailyStudyHandler.java | 14 +- .../vocabulary/handler/StatisticsHandler.java | 12 +- .../vocabulary/handler/StatsHandler.java | 12 +- .../vocabulary/handler/TestHandler.java | 14 +- .../vocabulary/handler/UserWordHandler.java | 14 +- .../vocabulary/handler/VoiceHandler.java | 14 +- .../vocabulary/handler/WordGroupHandler.java | 14 +- .../vocabulary/handler/WordHandler.java | 14 +- .../repository/DailyStudyRepository.java | 13 +- .../repository/TestResultRepository.java | 17 +- .../repository/UserWordRepository.java | 12 +- .../repository/WordGroupRepository.java | 13 +- .../vocabulary/repository/WordRepository.java | 12 +- .../service/DailyStudyCommandService.java | 25 +- .../service/DailyStudyQueryService.java | 14 +- .../vocabulary/service/DailyStudyService.java | 166 ---- .../vocabulary/service/StatisticsService.java | 12 +- .../vocabulary/service/StatsService.java | 43 +- .../service/TestCommandService.java | 22 +- .../vocabulary/service/TestQueryService.java | 14 +- .../vocabulary/service/TestService.java | 277 ------- .../service/UserWordCommandService.java | 12 +- .../service/UserWordQueryService.java | 14 +- .../vocabulary/service/UserWordService.java | 223 ----- .../service/WordCommandService.java | 12 +- .../service/WordGroupCommandService.java | 12 +- .../service/WordGroupQueryService.java | 14 +- .../vocabulary/service/WordQueryService.java | 12 +- .../vocabulary/service/WordService.java | 123 --- .../exception/ChattingErrorCodeSpec.groovy | 54 +- .../domain/chatting/enums/RoomStatusTest.java | 45 ++ .../domain/chatting/enums/RoomTypeTest.java | 39 + .../chatting/model/GameSettingsTest.java | 55 ++ ServerlessFunction/template.yaml | 128 ++- cicd/pipeline.yaml | 331 ++++++++ docker/Dockerfile | 46 ++ docker/build-and-push.sh | 58 ++ docs/CATCHMIND_ARCHITECTURE_SOLUTION.md | 521 ++++++++++++ docs/CICD-IMPLEMENTATION-QNA.md | 421 ++++++++++ docs/FRONTEND-API-GUIDE.md | 365 +++++++++ seed/README.md | 25 + seed/opic/question-homes.json | 108 +++ .../seed-data => seed/vocabulary}/words.json | 0 131 files changed, 7838 insertions(+), 1436 deletions(-) create mode 100644 ServerlessFunction/buildspec.yml create mode 100644 ServerlessFunction/docs/CATCHMIND_FRONTEND_GUIDE.md create mode 100644 ServerlessFunction/gradle/wrapper/gradle-wrapper.jar create mode 100644 ServerlessFunction/gradle/wrapper/gradle-wrapper.properties create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketMessageHelper.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketResponseUtil.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/AccuracyStrategy.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/BadgeConditionStrategy.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/BadgeConditionStrategyFactory.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/FirstStudyStrategy.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/GamesPlayedStrategy.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/GamesWonStrategy.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NoOpStrategy.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/PerfectDrawsStrategy.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/QuickGuessesStrategy.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/StreakStrategy.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/TestsCompletedStrategy.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/WordsLearnedStrategy.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/RoomListItem.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/RoomParticipant.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomStatus.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomType.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameAutoCloseHandler.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameSessionHandler.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSession.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSettings.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/GameSessionRepository.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameSchedulerClient.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/request/CreateSessionRequest.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/request/SubmitAnswerRequest.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/AnswerFeedbackResponse.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/CreateSessionResponse.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/QuestionResponse.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/handler/OPIcSessionHandler.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingConnectHandler.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingDisconnectHandler.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingMessageHandler.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/SpeakingConnection.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/service/SpeakingService.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyService.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestService.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordService.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordService.java create mode 100644 ServerlessFunction/src/test/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomStatusTest.java create mode 100644 ServerlessFunction/src/test/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomTypeTest.java create mode 100644 ServerlessFunction/src/test/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSettingsTest.java create mode 100644 cicd/pipeline.yaml create mode 100644 docker/Dockerfile create mode 100755 docker/build-and-push.sh create mode 100644 docs/CATCHMIND_ARCHITECTURE_SOLUTION.md create mode 100644 docs/CICD-IMPLEMENTATION-QNA.md create mode 100644 docs/FRONTEND-API-GUIDE.md create mode 100644 seed/README.md create mode 100644 seed/opic/question-homes.json rename {vocabulary/seed-data => seed/vocabulary}/words.json (100%) diff --git a/.gitignore b/.gitignore index f1597d4c..715a4d18 100644 --- a/.gitignore +++ b/.gitignore @@ -12,7 +12,8 @@ out/ # Gradle .gradle/ -gradle/ +# gradle wrapper는 커밋 필요 +!gradle/wrapper/ # Maven *.jar @@ -40,5 +41,5 @@ samconfig.toml *.test.ts coverage/ -# Seed Data (already uploaded to DynamoDB) -vocabulary/seed-data/ +# Seed Data +# seed/ diff --git a/ServerlessFunction/build.gradle b/ServerlessFunction/build.gradle index b6c28326..cc5e6a12 100644 --- a/ServerlessFunction/build.gradle +++ b/ServerlessFunction/build.gradle @@ -34,6 +34,7 @@ dependencies { implementation 'software.amazon.awssdk:apigatewaymanagementapi' implementation 'software.amazon.awssdk:url-connection-client' implementation 'software.amazon.awssdk:ssm' + implementation 'software.amazon.awssdk:scheduler' // AWS X-Ray SDK (다운스트림 서비스 추적용) implementation 'com.amazonaws:aws-xray-recorder-sdk-core:2.15.0' diff --git a/ServerlessFunction/buildspec.yml b/ServerlessFunction/buildspec.yml new file mode 100644 index 00000000..49ed2a4f --- /dev/null +++ b/ServerlessFunction/buildspec.yml @@ -0,0 +1,55 @@ +version: 0.2 + +env: + variables: + SAM_CLI_TELEMETRY: 0 + GRADLE_OPTS: "-Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.caching=true" + +phases: + install: + commands: + - echo "Verifying pre-installed tools..." + - java -version + - sam --version + - echo "Tools verified" + + pre_build: + commands: + - echo "Running tests..." + - cd ServerlessFunction + - chmod +x gradlew + - ./gradlew test --build-cache --parallel + - echo "Tests completed" + + build: + commands: + - echo "Building SAM application..." + - cd $CODEBUILD_SRC_DIR/ServerlessFunction + - sam build --parallel --cached + - echo "Packaging SAM application..." + - sam package --s3-bucket group2-englishstudy-pipeline-artifacts --s3-prefix sam-packages --output-template-file packaged-template.yaml + + post_build: + commands: + - echo "Build completed on $(date)" + +artifacts: + files: + - packaged-template.yaml + base-directory: ServerlessFunction + +cache: + paths: + - '/root/.gradle/caches/**/*' + - '/root/.gradle/wrapper/**/*' + - '.aws-sam/cache/**/*' + +reports: + junit-reports: + files: + - 'ServerlessFunction/build/test-results/test/*.xml' + file-format: JUNITXML + jacoco-reports: + files: + - 'ServerlessFunction/build/reports/jacoco/test/jacocoTestReport.xml' + file-format: JACOCOXML diff --git a/ServerlessFunction/docs/CATCHMIND_FRONTEND_GUIDE.md b/ServerlessFunction/docs/CATCHMIND_FRONTEND_GUIDE.md new file mode 100644 index 00000000..4dd03385 --- /dev/null +++ b/ServerlessFunction/docs/CATCHMIND_FRONTEND_GUIDE.md @@ -0,0 +1,761 @@ +# Catchmind 게임 프론트엔드 연동 가이드 + +## 목차 + +1. [개요](#개요) +2. [아키텍처](#아키텍처) +3. [WebSocket 연결](#websocket-연결) +4. [메시지 구조](#메시지-구조) +5. [게임 흐름](#게임-흐름) +6. [REST API](#rest-api) +7. [타이머 동기화](#타이머-동기화) +8. [게임 자동 종료](#게임-자동-종료) +9. [재접속 처리](#재접속-처리) +10. [에러 처리](#에러-처리) + +--- + +## 개요 + +Catchmind는 실시간 그림 맞추기 게임입니다. WebSocket을 통한 실시간 통신과 REST API를 통한 게임 세션 관리를 지원합니다. + +### 주요 특징 + +- **실시간 통신**: WebSocket 기반 양방향 통신 +- **도메인 분리**: `chat` / `game` 도메인으로 메시지 라우팅 +- **타이머 동기화**: `serverTime` 필드를 통한 클라이언트-서버 시간 동기화 +- **자동 종료**: 게임 시작 7분 후 자동 종료 +- **재접속 지원**: 게임 세션 API를 통한 상태 복원 + +--- + +## 아키텍처 + +``` +┌─────────────┐ WebSocket ┌──────────────────┐ +│ Frontend │◄──────────────────►│ API Gateway WS │ +│ (React) │ └────────┬─────────┘ +│ │ │ +│ │ REST API ┌───────▼─────────┐ +│ │◄───────────────────►│ API Gateway │ +└─────────────┘ │ REST │ + └────────┬────────┘ + │ + ┌─────────────┼─────────────┐ + │ │ │ + ┌─────▼────┐ ┌─────▼────┐ ┌─────▼────┐ + │ WS Msg │ │ Game │ │ Game │ + │ Handler │ │ Handler │ │ Session │ + └──────────┘ └──────────┘ │ Handler │ + └──────────┘ +``` + +--- + +## WebSocket 연결 + +### 연결 URL + +``` +wss://{api-id}.execute-api.{region}.amazonaws.com/dev?roomToken={token} +``` + +### 연결 절차 + +1. REST API로 방 토큰 발급 (`POST /chat/rooms/{roomId}/join`) +2. 토큰으로 WebSocket 연결 +3. 연결 성공 시 자동으로 방에 입장 + +### 연결 예시 (TypeScript) + +```typescript +const connectWebSocket = (roomToken: string): WebSocket => { + const ws = new WebSocket( + `wss://xxx.execute-api.ap-northeast-2.amazonaws.com/dev?roomToken=${roomToken}` + ); + + ws.onopen = () => console.log('WebSocket connected'); + ws.onmessage = (event) => handleMessage(JSON.parse(event.data)); + ws.onerror = (error) => console.error('WebSocket error:', error); + ws.onclose = () => console.log('WebSocket closed'); + + return ws; +}; +``` + +--- + +## 메시지 구조 + +### 공통 메시지 포맷 + +모든 WebSocket 메시지는 다음 필드를 포함합니다: + +```typescript +interface BaseMessage { + domain: 'chat' | 'game'; // 도메인 구분 + messageType: string; // 메시지 타입 + messageId: string; // 고유 메시지 ID + roomId: string; // 방 ID + userId: string; // 발신자 ID (시스템: "SYSTEM") + content?: string; // 메시지 내용 + createdAt: string; // ISO 8601 형식 시간 + timestamp: number; // Unix timestamp (ms) +} +``` + +### 도메인 구분 + +| 도메인 | 설명 | 메시지 타입 | +|--------|--------|-------------------------------------------------------------------------------------------| +| `chat` | 채팅 메시지 | text, image, voice, ai_response | +| `game` | 게임 메시지 | game_start, game_end, round_start, round_end, drawing, correct_answer, score_update, hint | + +### 메시지 라우팅 예시 + +```typescript +const handleMessage = (message: BaseMessage) => { + if (message.domain === 'chat') { + handleChatMessage(message); + } else if (message.domain === 'game') { + handleGameMessage(message); + } +}; +``` + +--- + +## 게임 흐름 + +### 게임 상태 (GameStatus) + +```typescript +type GameStatus = 'NONE' | 'WAITING' | 'PLAYING' | 'ROUND_END' | 'FINISHED'; +``` + +### 전체 흐름 + +``` +[대기] ─── /game 시작 ───► [게임 시작] ─► [라운드 1] ─► [라운드 종료] + │ │ + │ ◄───────────────────┘ + │ (반복) + ▼ + [게임 종료] + │ + ┌────┴────┐ + │ │ + 수동 종료 자동 종료 + (7분 경과) +``` + +### 1. 게임 시작 (game_start) + +**수신 메시지:** + +```json +{ + "domain": "game", + "messageType": "game_start", + "messageId": "uuid", + "roomId": "room-123", + "userId": "SYSTEM", + "content": "🎮 게임 시작!\n총 5 라운드\n\n라운드 1 시작!\n출제자: user-1", + "createdAt": "2024-01-20T10:00:00Z", + "timestamp": 1705746000000, + "serverTime": 1705746000000, + "gameStatus": "PLAYING", + "currentRound": 1, + "totalRounds": 5, + "currentDrawerId": "user-1", + "drawerOrder": ["user-1", "user-2", "user-3"], + "roundStartTime": 1705746000000, + "roundDuration": 60 +} +``` + +**프론트엔드 처리:** + +```typescript +const handleGameStart = (message: GameStartMessage) => { + setGameStatus('PLAYING'); + setCurrentRound(message.currentRound); + setTotalRounds(message.totalRounds); + setCurrentDrawer(message.currentDrawerId); + setDrawerOrder(message.drawerOrder); + + // 타이머 동기화 + startTimer(message.roundStartTime, message.roundDuration, message.serverTime); + + // 현재 사용자가 출제자인지 확인 + setIsDrawer(message.currentDrawerId === currentUserId); +}; +``` + +### 2. 그림 데이터 전송/수신 (drawing) + +**전송 (출제자만):** + +```typescript +const sendDrawing = (drawingData: DrawingData) => { + ws.send(JSON.stringify({ + action: 'sendMessage', + messageType: 'drawing', + content: JSON.stringify(drawingData) + })); +}; +``` + +**수신 메시지:** + +```json +{ + "domain": "game", + "messageType": "drawing", + "messageId": "uuid", + "roomId": "room-123", + "userId": "user-1", + "content": "{\"type\":\"path\",\"points\":[...],\"color\":\"#000\",\"width\":3}", + "timestamp": 1705746010000 +} +``` + +### 3. 정답 체크 + +**채팅 메시지로 자동 체크됩니다:** + +```typescript +const sendAnswer = (answer: string) => { + ws.send(JSON.stringify({ + action: 'sendMessage', + messageType: 'text', + content: answer + })); +}; +``` + +### 4. 정답 알림 (correct_answer) + +**수신 메시지:** + +```json +{ + "domain": "game", + "messageType": "correct_answer", + "roomId": "room-123", + "userId": "user-2", + "content": "🎉 user-2님이 정답을 맞혔습니다! (+35점)", + "timestamp": 1705746030000, + "serverTime": 1705746030000, + "score": 35, + "elapsedTime": 30000, + "allCorrect": false, + "scores": { + "user-1": 5, + "user-2": 35 + } +} +``` + +### 5. 점수 업데이트 (score_update) + +**수신 메시지:** + +```json +{ + "domain": "game", + "messageType": "score_update", + "roomId": "room-123", + "timestamp": 1705746030000, + "scores": { + "user-1": 15, + "user-2": 35, + "user-3": 20 + }, + "lastScorer": "user-2", + "lastScore": 35 +} +``` + +### 6. 라운드 종료 (round_end) + +**수신 메시지:** + +```json +{ + "domain": "game", + "messageType": "round_end", + "roomId": "room-123", + "content": "라운드 1 종료! 정답: 사과\n\n라운드 2 시작! 출제자: user-2", + "timestamp": 1705746060000, + "serverTime": 1705746060000, + "data": { + "answer": "사과", + "currentRound": 1, + "totalRounds": 5, + "nextRound": 2, + "nextDrawer": "user-2", + "nextWord": { + "wordId": "word-123", + "korean": "바나나" + }, + "roundStartTime": 1705746060000, + "roundDuration": 60, + "ranking": [ + { "rank": 1, "userId": "user-2", "score": 35 }, + { "rank": 2, "userId": "user-3", "score": 20 }, + { "rank": 3, "userId": "user-1", "score": 15 } + ] + } +} +``` + +**프론트엔드 처리:** + +```typescript +const handleRoundEnd = (message: RoundEndMessage) => { + const { data } = message; + + // 정답 표시 + showAnswer(data.answer); + + // 순위 표시 + showRanking(data.ranking); + + // 다음 라운드 준비 + if (data.nextRound) { + setCurrentRound(data.nextRound); + setCurrentDrawer(data.nextDrawer); + setIsDrawer(data.nextDrawer === currentUserId); + + // 출제자에게만 단어 표시 + if (data.nextDrawer === currentUserId && data.nextWord) { + setCurrentWord(data.nextWord.korean); + } + + // 타이머 재시작 + startTimer(data.roundStartTime, data.roundDuration, message.serverTime); + + // 캔버스 초기화 + clearCanvas(); + } +}; +``` + +### 7. 게임 종료 (game_end) + +**수신 메시지:** + +```json +{ + "domain": "game", + "messageType": "game_end", + "roomId": "room-123", + "content": "🎮 게임 종료!\n\n📊 최종 순위:\n 🥇 user-2: 120점\n 🥈 user-3: 95점\n 🥉 user-1: 80점", + "timestamp": 1705746300000, + "reason": "COMPLETED" +} +``` + +**종료 사유 (reason):** +| 값 | 설명 | +|----|------| +| `COMPLETED` | 모든 라운드 완료 | +| `STOPPED` | 수동 종료 | +| `TIME_EXPIRED` | 7분 시간 초과 | +| `NOT_ENOUGH_PLAYERS` | 인원 부족 | + +--- + +## REST API + +### 게임 시작 + +```http +POST /chat/rooms/{roomId}/game/start +Authorization: Bearer {accessToken} +``` + +**Response:** + +```json +{ + "success": true, + "message": "Game started", + "data": { + "gameSessionId": "session-123", + "roomId": "room-123", + "status": "PLAYING", + "currentRound": 1, + "totalRounds": 5, + "currentDrawerId": "user-1", + "roundStartTime": 1705746000000, + "serverTime": 1705746000000, + "roundDuration": 60, + "drawerOrder": ["user-1", "user-2", "user-3"], + "currentWord": { + "wordId": "word-1", + "word": "사과" + } + } +} +``` + +> **Note:** `currentWord`는 출제자에게만 포함됩니다. + +### 게임 종료 + +```http +POST /chat/rooms/{roomId}/game/stop +Authorization: Bearer {accessToken} +``` + +### 게임 상태 조회 + +```http +GET /chat/rooms/{roomId}/game/status +Authorization: Bearer {accessToken} +``` + +### 게임 세션 조회 (재접속용) + +```http +GET /games/{gameSessionId} +Authorization: Bearer {accessToken} +``` + +**Response:** + +```json +{ + "success": true, + "message": "Game session retrieved", + "data": { + "gameSessionId": "session-123", + "roomId": "room-123", + "gameType": "catchmind", + "status": "PLAYING", + "currentRound": 3, + "totalRounds": 5, + "currentDrawerId": "user-2", + "roundStartTime": 1705746180000, + "serverTime": 1705746200000, + "roundDuration": 60, + "scores": { + "user-1": 45, + "user-2": 60, + "user-3": 30 + }, + "players": ["user-1", "user-2", "user-3"], + "drawerOrder": ["user-1", "user-2", "user-3"], + "hintUsed": false, + "currentWord": { + "wordId": "word-5", + "word": "바나나" + } + } +} +``` + +> **Note:** `currentWord`는 출제자에게만 포함됩니다. + +--- + +## 타이머 동기화 + +### 문제 + +클라이언트와 서버 시간 차이로 인한 타이머 불일치 + +### 해결책 + +`serverTime` 필드를 사용하여 서버 시간 기준 타이머 계산 + +### 구현 예시 + +```typescript +interface TimerSync { + roundStartTime: number; // 라운드 시작 시간 (서버 기준) + roundDuration: number; // 라운드 지속 시간 (초) + serverTime: number; // 메시지 발송 시점의 서버 시간 +} + +const startTimer = ( + roundStartTime: number, + roundDuration: number, + serverTime: number +) => { + // 서버에서 이미 경과한 시간 계산 + const elapsedOnServer = serverTime - roundStartTime; + + // 남은 시간 계산 (밀리초) + const remainingTime = (roundDuration * 1000) - elapsedOnServer; + + // 음수 방지 + const safeRemainingTime = Math.max(0, remainingTime); + + setRemainingTime(safeRemainingTime); + + // 타이머 시작 + const interval = setInterval(() => { + setRemainingTime((prev) => { + if (prev <= 1000) { + clearInterval(interval); + return 0; + } + return prev - 1000; + }); + }, 1000); + + return () => clearInterval(interval); +}; +``` + +### React Hook 예시 + +```typescript +const useGameTimer = (timerSync: TimerSync | null) => { + const [remainingSeconds, setRemainingSeconds] = useState(0); + + useEffect(() => { + if (!timerSync) return; + + const { roundStartTime, roundDuration, serverTime } = timerSync; + const elapsed = (serverTime - roundStartTime) / 1000; + const remaining = Math.max(0, roundDuration - elapsed); + + setRemainingSeconds(Math.ceil(remaining)); + + const interval = setInterval(() => { + setRemainingSeconds((prev) => Math.max(0, prev - 1)); + }, 1000); + + return () => clearInterval(interval); + }, [timerSync]); + + return remainingSeconds; +}; +``` + +--- + +## 게임 자동 종료 + +### 개요 + +게임 시작 후 7분(420초)이 경과하면 자동으로 종료됩니다. + +### 자동 종료 메시지 + +```json +{ + "domain": "game", + "messageType": "game_end", + "roomId": "room-123", + "userId": "SYSTEM", + "content": "⏰ 시간 초과! 🎮 게임 종료!\n\n📊 최종 순위:\n 🥇 user-2: 120점\n 🥈 user-1: 95점", + "timestamp": 1705746420000, + "reason": "TIME_EXPIRED" +} +``` + +### 프론트엔드 처리 + +```typescript +const handleGameEnd = (message: GameEndMessage) => { + setGameStatus('FINISHED'); + + // 종료 사유에 따른 UI 처리 + if (message.reason === 'TIME_EXPIRED') { + showNotification('시간 초과로 게임이 종료되었습니다.'); + } else if (message.reason === 'STOPPED') { + showNotification('게임이 수동으로 종료되었습니다.'); + } + + // 최종 결과 표시 + showFinalResults(message.content); + + // 캔버스 초기화 + clearCanvas(); +}; +``` + +--- + +## 재접속 처리 + +### 시나리오 + +사용자가 게임 중 연결이 끊어졌다가 다시 접속하는 경우 + +### 처리 절차 + +1. WebSocket 재연결 +2. 게임 세션 API로 현재 상태 조회 +3. UI 상태 복원 +4. 타이머 동기화 + +### 구현 예시 + +```typescript +const handleReconnect = async (roomId: string, gameSessionId: string) => { + // 1. WebSocket 재연결 + const roomToken = await getRoomToken(roomId); + connectWebSocket(roomToken); + + // 2. 게임 세션 조회 + const session = await fetchGameSession(gameSessionId); + + if (session.status === 'PLAYING') { + // 3. UI 상태 복원 + setGameStatus('PLAYING'); + setCurrentRound(session.currentRound); + setScores(session.scores); + setCurrentDrawer(session.currentDrawerId); + setIsDrawer(session.currentDrawerId === currentUserId); + + // 출제자인 경우 단어 설정 + if (session.currentWord) { + setCurrentWord(session.currentWord.word); + } + + // 4. 타이머 동기화 + startTimer( + session.roundStartTime, + session.roundDuration, + session.serverTime + ); + } else if (session.status === 'FINISHED') { + setGameStatus('FINISHED'); + } +}; +``` + +--- + +## 에러 처리 + +### WebSocket 에러 코드 + +| 코드 | 설명 | 처리 방법 | +|------|--------|--------------| +| 1000 | 정상 종료 | - | +| 1001 | 서버 종료 | 재연결 시도 | +| 1006 | 비정상 종료 | 재연결 시도 | +| 4001 | 인증 실패 | 토큰 재발급 후 재연결 | +| 4003 | 권한 없음 | 에러 표시 | + +### REST API 에러 코드 + +| 코드 | 설명 | +|------------|-----------------------| +| `GAME_001` | 게임 시작 실패 | +| `GAME_002` | 게임 중단 실패 | +| `GAME_003` | 진행 중인 게임 없음 | +| `GAME_004` | 이미 게임 진행 중 | +| `GAME_005` | 권한 없음 (게임 시작자만 중단 가능) | +| `GAME_006` | 게임 세션을 찾을 수 없음 | + +### 에러 처리 예시 + +```typescript +const handleError = (error: ApiError) => { + switch (error.code) { + case 'GAME_001': + showNotification('게임을 시작할 수 없습니다. 최소 2명이 필요합니다.'); + break; + case 'GAME_004': + showNotification('이미 게임이 진행 중입니다.'); + break; + case 'GAME_006': + // 게임 세션 만료 - 목록으로 이동 + navigateToRoomList(); + break; + default: + showNotification('오류가 발생했습니다.'); + } +}; +``` + +--- + +## 전체 상태 관리 예시 (React) + +```typescript +interface GameState { + status: GameStatus; + currentRound: number; + totalRounds: number; + currentDrawerId: string | null; + currentWord: string | null; + scores: Record; + isDrawer: boolean; + remainingTime: number; + drawerOrder: string[]; +} + +const initialGameState: GameState = { + status: 'NONE', + currentRound: 0, + totalRounds: 0, + currentDrawerId: null, + currentWord: null, + scores: {}, + isDrawer: false, + remainingTime: 0, + drawerOrder: [], +}; + +const gameReducer = (state: GameState, action: GameAction): GameState => { + switch (action.type) { + case 'GAME_START': + return { + ...state, + status: 'PLAYING', + currentRound: action.payload.currentRound, + totalRounds: action.payload.totalRounds, + currentDrawerId: action.payload.currentDrawerId, + drawerOrder: action.payload.drawerOrder, + isDrawer: action.payload.currentDrawerId === action.payload.currentUserId, + scores: {}, + }; + + case 'ROUND_END': + return { + ...state, + currentRound: action.payload.nextRound, + currentDrawerId: action.payload.nextDrawer, + currentWord: action.payload.isDrawer ? action.payload.nextWord : null, + isDrawer: action.payload.isDrawer, + }; + + case 'SCORE_UPDATE': + return { + ...state, + scores: action.payload.scores, + }; + + case 'GAME_END': + return { + ...initialGameState, + status: 'FINISHED', + scores: state.scores, + }; + + case 'RESET': + return initialGameState; + + default: + return state; + } +}; +``` + +--- + +## 버전 이력 + +| 버전 | 날짜 | 변경 내용 | +|-------|------------|---------------------| +| 1.0.0 | 2024-01-20 | 초기 문서 작성 | +| 1.1.0 | 2024-01-20 | 게임 자동 종료 (7분) 기능 추가 | diff --git a/ServerlessFunction/gradle/wrapper/gradle-wrapper.jar b/ServerlessFunction/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..f8e1ee3125fe0768e9a76ee977ac089eb657005e GIT binary patch literal 45633 zcma&NV|1n6wyqu9PQ|uu+csuwn-$x(T~Woh?Nr6KUD3(A)@l1Yd+oj6Z_U=8`RAE` z#vE6_`?!1WLs1443=Ieh3JM4ai0JG2|2{}S&_HrxszP*9^5P7#QX*pVDq?D?;6T8C z{bWO1$9at%!*8ax*TT&F99vwf1Ls+3lklsb|bC`H`~Q z_w}*E9P=Wq;PYlGYhZ^lt#N97bt5aZ#mQcOr~h^B;R>f-b0gf{y(;VA{noAt`RZzU z7vQWD{%|q!urW2j0Z&%ChtL(^9m` zgaU%|B;V#N_?%iPvu0PVkX=1m9=*SEGt-Lp#&Jh%rz6EJXlV^O5B5YfM5j{PCeElx z8sipzw8d=wVhFK+@mgrWyA)Sv3BJq=+q+cL@=wuH$2;LjY z^{&+X4*HFA0{QvlM_V4PTQjIdd;d|2YuN;s|bi!@<)r-G%TuOCHz$O(_-K z)5in&6uNN<0UfwY=K>d;cL{{WK2FR|NihJMN0Q4X+(1lE)$kY?T$7UWleIU`i zQG#X-&&m-8x^(;n@o}$@vPMYRoq~|FqC~CU3MnoiifD{(CwAGd%X#kFHq#4~%_a!{ zeX{XXDT#(DvX7NtAs7S}2ZuiZ>gtd;tCR7E)3{J^`~#Vd**9qz%~JRFAiZf{zt|Dr zvQw!)n7fNUn_gH`o9?8W8t_%x6~=y*`r46bjj(t{YU*qfqd}J}*mkgUfsXTI>Uxl6 z)Fj>#RMy{`wINIR;{_-!xGLgVaTfNJ2-)%YUfO&X5z&3^E#4?k-_|Yv$`fpgYkvnA%E{CiV zP|-zAf8+1@R`sT{rSE#)-nuU7Pwr-z>0_+CLQT|3vc-R22ExKT4ym@Gj77j$aTVns zp4Kri#Ml?t7*n(;>nkxKdhOU9Qbwz%*#i9_%K<`m4T{3aPbQ?J(Mo`6E5cDdbAk%X z+4bN%E#a(&ZXe{G#V!2Nt+^L$msKVHP z|APpBhq7knz(O2yY)$$VyI_Xg4UIC*$!i7qQG~KEZnO@Q1i89@4ZKW*3^Wh?o?zSkfPxdhnTxlO!3tAqe_ zuEqHVcAk3uQIFTpP~C{d$?>7yt3G3Fo>syXTus>o0tJdFpQWC27hDiwC%O09i|xCq z@H6l|+maB;%CYQIChyhu;PVYz9e&5a@EEQs3$DS6dLIS+;N@I0)V}%B`jdYv;JDck zd|xxp(I?aedivE7*19hesoa-@Xm$^EHbbVmh$2^W-&aTejsyc$i+}A#n2W*&0Qt`5 zJS!2A|LVV;L!(*x2N)GjJC;b1RB_f(#D&g_-};a*|BTRvfdIX}Gau<;uCylMNC;UG zzL((>6KQBQ01wr%7u9qI2HLEDY!>XisIKb#6=F?pAz)!_JX}w|>1V>X^QkMdFi@Jr z`1N*V4xUl{qvECHoF?#lXuO#Dg2#gh|AU$Wc=nuIbmVPBEGd(R#&Z`TP9*o%?%#ob zWN%ByU+55yBNfjMjkJnBjT!cVDi}+PR3N&H(f8$d^Pu;A_WV*{)c2Q{IiE7&LPsd4 z!rvkUf{sco_WNSIdW+btM#O+4n`JiceH6%`7pDV zRqJ@lj=Dt(e-Gkz$b!c2>b)H$lf(fuAPdIsLSe(dZ4E~9+Ge!{3j~>nS%r)eQZ;Iq ztWGpp=2Ptc!LK_TQ8cgJXUlU5mRu|7F2{eu*;a>_5S<;bus=t*IXcfzJRPv4xIs;s zt2<&}OM>KxkTxa=dFMfNr42=DL~I}6+_{`HT_YJBiWkpVZND1Diad~Yr*Fuq{zljr z*_+jXk=qVBdwlQkYuIrB4GG*#voba$?h*u0uRNL+87-?AjzG2X_R9mzQ7BJEawutObr|ey~%in>6k%A`K*`pb-|DF5m})!`b=~osoiW2)IFh?_y9y<3Cix_ znvC=bjBX1J820!%%9FaB@v?hAsd05e@w$^ZAvtUp*=Bi+Owkl?rLa6F#yl{s+?563 zmn2 zV95%gySAJ$L!Vvk4kx!n@mo`3Mfi`2lXUkBmd%)u)7C?Pa;oK~zUQ#p0u{a|&0;zNO#9a4`v^3df90X#~l_k$q7n&L5 z?TszF842~g+}tgUP}UG?ObLCE1(Js_$e>XS7m%o7j@@VdxePtg)w{i5an+xK95r?s zDeEhgMO-2$H?@0{p-!4NJ)}zP+3LzZB?FVap)ObHV6wp}Lrxvz$cjBND1T6ln$EfJ zZRPeR2lP}K0p8x`ahxB??Ud;i7$Y5X!5}qBFS+Zp=P^#)08nQi_HuJcN$0=x;2s53 zwoH}He9BlKT4GdWfWt)@o@$4zN$B@5gVIN~aHtwIhh{O$uHiMgYl=&Vd$w#B2 zRv+xK3>4E{!)+LXA2#*K6H~HpovXAQeXV(^Pd%G_>ro0(4_@`{2Ag(+8{9pqJ>Co$ zRRV(oX;nD+Jel_2^BlNO=cQP8q*G#~R3PTERUxvug_C4T3qwb9MQE|^{5(H*nt`fn z^%*p-RwkAhT6(r>E@5w8FaB)Q<{#`H9fTdc6QBuSr9D-x!Tb9f?wI=M{^$cB5@1;0 z+yLHh?3^c-Qte@JI<SW`$bs5Vv9!yWjJD%oY z8Cdc$a(LLy@tB2)+rUCt&0$&+;&?f~W6+3Xk3g zy9L�|d9Zj^A1Dgv5yzCONAB>8LM`TRL&7v_NKg(bEl#y&Z$py}mu<4DrT@8HHjE zqD@4|aM>vt!Yvc2;9Y#V;KJ8M>vPjiS2ycq52qkxInUK*QqA3$&OJ`jZBo zpzw&PT%w0$D94KD%}VN9c)eCueh1^)utGt2OQ+DP(BXszodfc1kFPWl~BQ5Psy*d`UIf zc}zQ8TVw35jdCSc78)MljC-g3$GX2$<0<3MEQXS&i<(ZFClz9WlL}}?%u>S2hhEk_ zyzfm&@Q%YVB-vw3KH|lU#c_)0aeG^;aDG&!bwfOz_9)6gLe;et;h(?*0d-RV0V)1l zzliq#`b9Y*c`0!*6;*mU@&EFSbW>9>L5xUX+unp%@tCW#kLfz)%3vwN{1<-R*g+B_C^W8)>?n%G z<#+`!wU$L&dn)Pz(9DGGI%RlmM2RpeDy9)31OZV$c2T>-Jl&4$6nul&e7){1u-{nP zE$uZs%gyanu+yBcAb+jTYGy(^<;&EzeLeqveN12Lvv)FQFn0o&*qAaH+gLJ)*xT9y z>`Y`W?M#K7%w26w?Oen>j7=R}EbZ;+jcowV&i}P|IfW^C5GJHt5D;Q~)|=gW3iQ;N zQGl4SQFtz=&~BGon6hO@mRnjpmM79ye^LY_L2no{f_M?j80pr`o3BrI7ice#8#Zt4 zO45G97Hpef+AUEU%jN-dLmPYHY(|t#D)9|IeB^i1X|eEq+ymld_Uj$l^zVAPRilx- z^II$sL4G~{^7?sik2BK7;ZV-VIVhrKjUxBIsf^N&K`)5;PjVg-DTm1Xtw4-tGtElU zJgVTCk4^N4#-kPuX=7p~GMf5Jj5A#>)GX)FIcOqY4lf}Vv2gjrOTuFusB@ERW-&fb zTp=E0E?gXkwzn)AMMY*QCftp%MOL-cbsG{02$0~b?-JD{-nwj58 zBHO1YL~yn~RpnZ6*;XA|MSJeBfX-D?afH*E!2uGjT%k!jtx~OG_jJ`Ln}lMQb7W41 zmTIRd%o$pu;%2}}@2J$x%fg{DZEa-Wxdu6mRP~Ea0zD2+g;Dl*to|%sO-5mUrZ`~C zjJ zUe^**YRgBvlxl<(r0LjxjSQKiTx+E<7$@9VO=RYgL9ldTyKzfqR;Y&gu^ub!fVX7u z3H@;8j#tVgga~EMuXv_#Q8<*uK@R{mGzn92eDYkF1sbxh5!P|M-D)T~Ae*SO`@u$Q z7=5s)HM)w~s2j5{I67cqSn6BLLhCMcn0=OTVE?T7bAmY!T+xZ_N3op~wZ3Oxlm6(a5qB({6KghlvBd9HJ#V6YY_zxbj-zI`%FN|C*Q`DiV z#>?Kk7VbuoE*I9tJaa+}=i7tJnMRn`P+(08 za*0VeuAz!eI7giYTsd26P|d^E2p1f#oF*t{#klPhgaShQ1*J7?#CTD@iDRQIV+Z$@ z>qE^3tR3~MVu=%U%*W(1(waaFG_1i5WE}mvAax;iwZKv^g1g}qXY7lAd;!QQa#5e= z1_8KLHje1@?^|6Wb(A{HQ_krJJP1GgE*|?H0Q$5yPBQJlGi;&Lt<3Qc+W4c}Ih~@* zj8lYvme}hwf@Js%Oj=4BxXm15E}7zS0(dW`7X0|$damJ|gJ6~&qKL>gB_eC7%1&Uh zLtOkf7N0b;B`Qj^9)Bfh-( z0or96!;EwEMnxwp!CphwxxJ+DDdP4y3F0i`zZp-sQ5wxGIHIsZCCQz5>QRetx8gq{ zA33BxQ}8Lpe!_o?^u2s3b!a-$DF$OoL=|9aNa7La{$zI#JTu_tYG{m2ly$k?>Yc); zTA9ckzd+ibu>SE6Rc=Yd&?GA9S5oaQgT~ER-|EwANJIAY74|6 z($#j^GP}EJqi%)^jURCj&i;Zl^-M9{=WE69<*p-cmBIz-400wEewWVEd^21}_@A#^ z2DQMldk_N)6bhFZeo8dDTWD@-IVunEY*nYRON_FYII-1Q@@hzzFe(lTvqm}InfjQ2 zN>>_rUG0Lhaz`s;GRPklV?0 z;~t4S8M)ZBW-ED?#UNbCrsWb=??P># zVc}MW_f80ygG_o~SW+Q6oeIUdFqV2Fzys*7+vxr^ZDeXcZZc;{kqK;(kR-DKL zByDdPnUQgnX^>x?1Tz~^wZ%Flu}ma$Xmgtc7pSmBIH%&H*Tnm=L-{GzCv^UBIrTH5 zaoPO|&G@SB{-N8Xq<+RVaM_{lHo@X-q}`zjeayVZ9)5&u*Y>1!$(wh9Qoe>yWbPgw zt#=gnjCaT_+$}w^*=pgiHD8N$hzqEuY5iVL_!Diw#>NP7mEd?1I@Io+?=$?7cU=yK zdDKk_(h_dB9A?NX+&=%k8g+?-f&`vhAR}&#zP+iG%;s}kq1~c{ac1@tfK4jP65Z&O zXj8Ew>l7c|PMp!cT|&;o+(3+)-|SK&0EVU-0-c&guW?6F$S`=hcKi zpx{Z)UJcyihmN;^E?*;fxjE3kLN4|&X?H&$md+Ege&9en#nUe=m>ep3VW#C?0V=aS zLhL6v)|%$G5AO4x?Jxy8e+?*)YR~<|-qrKO7k7`jlxpl6l5H&!C4sePiVjAT#)b#h zEwhfkpFN9eY%EAqg-h&%N>E0#%`InXY?sHyptcct{roG42Mli5l)sWt66D_nG2ed@ z#4>jF?sor7ME^`pDlPyQ(|?KL9Q88;+$C&3h*UV*B+*g$L<{yT9NG>;C^ZmPbVe(a z09K^qVO2agL`Hy{ISUJ{khPKh@5-)UG|S8Sg%xbJMF)wawbgll3bxk#^WRqmdY7qv zr_bqa3{`}CCbREypKd!>oIh^IUj4yl1I55=^}2mZAAW6z}Kpt3_o1b4__sQ;b zv)1=xHO?gE-1FL}Y$0YdD-N!US;VSH>UXnyKoAS??;T%tya@-u zfFo)@YA&Q#Q^?Mtam19`(PS*DL{PHjEZa(~LV7DNt5yoo1(;KT)?C7%^Mg;F!C)q= z6$>`--hQX4r?!aPEXn;L*bykF1r8JVDZ)x4aykACQy(5~POL;InZPU&s5aZm-w1L< z`crCS5=x>k_88n(*?zn=^w*;0+8>ui2i>t*Kr!4?aA1`yj*GXi#>$h8@#P{S)%8+N zCBeL6%!Ob1YJs5+a*yh{vZ8jH>5qpZhz_>(ph}ozKy9d#>gba1x3}`-s_zi+SqIeR z0NCd7B_Z|Fl+(r$W~l@xbeAPl5{uJ{`chq}Q;y8oUN0sUr4g@1XLZQ31z9h(fE_y( z_iQ(KB39LWd;qwPIzkvNNkL(P(6{Iu{)!#HvBlsbm`g2qy&cTsOsAbwMYOEw8!+75D!>V{9SZ?IP@pR9sFG{T#R*6ez2&BmP8*m^6+H2_ z>%9pg(+R^)*(S21iHjLmdt$fmq6y!B9L!%+;wL5WHc^MZRNjpL9EqbBMaMns2F(@h zN0BEqZ3EWGLjvY&I!8@-WV-o@>biD;nx;D}8DPapQF5ivpHVim8$G%3JrHtvN~U&) zb1;=o*lGfPq#=9Moe$H_UhQPBjzHuYw;&e!iD^U2veY8)!QX_E(X@3hAlPBIc}HoD z*NH1vvCi5xy@NS41F1Q3=Jkfu&G{Syin^RWwWX|JqUIX_`}l;_UIsj&(AFQ)ST*5$ z{G&KmdZcO;jGIoI^+9dsg{#=v5eRuPO41<*Ym!>=zHAXH#=LdeROU-nzj_@T4xr4M zJI+d{Pp_{r=IPWj&?%wfdyo`DG1~|=ef?>=DR@|vTuc)w{LHqNKVz9`Dc{iCOH;@H5T{ zc<$O&s%k_AhP^gCUT=uzrzlEHI3q`Z3em0*qOrPHpfl1v=8Xkp{!f9d2p!4 zL40+eJB4@5IT=JTTawIA=Z%3AFvv=l1A~JX>r6YUMV7GGLTSaIn-PUw| z;9L`a<)`D@Qs(@P(TlafW&-87mcZuwFxo~bpa01_M9;$>;4QYkMQlFPgmWv!eU8Ut zrV2<(`u-@1BTMc$oA*fX;OvklC1T$vQlZWS@&Wl}d!72MiXjOXxmiL8oq;sP{)oBe zS#i5knjf`OfBl}6l;BSHeY31w8c~8G>$sJ9?^^!)Z*Z*Xg zbTbkcbBpgFui(*n32hX~sC7gz{L?nlnOjJBd@ zUC4gd`o&YB4}!T9JGTe9tqo0M!JnEw4KH7WbrmTRsw^Nf z^>RxG?2A33VG3>E?iN|`G6jgr`wCzKo(#+zlOIzp-^E0W0%^a>zO)&f(Gc93WgnJ2p-%H-xhe{MqmO z8Iacz=Qvx$ML>Lhz$O;3wB(UI{yTk1LJHf+KDL2JPQ6#m%^bo>+kTj4-zQ~*YhcqS z2mOX!N!Q$d+KA^P0`EEA^%>c12X(QI-Z}-;2Rr-0CdCUOZ=7QqaxjZPvR%{pzd21HtcUSU>u1nw?)ZCy+ zAaYQGz59lqhNXR4GYONpUwBU+V&<{z+xA}`Q$fajmR86j$@`MeH}@zz*ZFeBV9Ot< ze8BLzuIIDxM&8=dS!1-hxiAB-x-cVmtpN}JcP^`LE#2r9ti-k8>Jnk{?@Gw>-WhL=v+H!*tv*mcNvtwo)-XpMnV#X>U1F z?HM?tn^zY$6#|(|S~|P!BPp6mur58i)tY=Z-9(pM&QIHq+I5?=itn>u1FkXiehCRC zW_3|MNOU)$-zrjKnU~{^@i9V^OvOJMp@(|iNnQ%|iojG2_Snnt`1Cqx2t)`vW&w2l zwb#`XLNY@FsnC-~O&9|#Lpvw7n!$wL9azSk)$O}?ygN@FEY({2%bTl)@F2wevCv`; zZb{`)uMENiwE|mti*q5U4;4puX{VWFJ#QIaa*%IHKyrU*HtjW_=@!3SlL~pqLRs?L zoqi&}JLsaP)yEH!=_)zmV-^xy!*MCtc{n|d%O zRM>N>eMG*Qi_XAxg@82*#zPe+!!f#;xBxS#6T-$ziegN-`dLm z=tTN|xpfCPng06|X^6_1JgN}dM<_;WsuL9lu#zLVt!0{%%D9*$nT2E>5@F(>Fxi%Y zpLHE%4LZSJ1=_qm0;^Wi%x56}k3h2Atro;!Ey}#g&*BpbNXXS}v>|nn=Mi0O(5?=1V7y1^1Bdt5h3}oL@VsG>NAH z1;5?|Sth=0*>dbXSQ%MQKB?eN$LRu?yBy@qQVaUl*f#p+sLy$Jd>*q;(l>brvNUbIF0OCf zk%Q;Zg!#0w0_#l)!t?3iz~`X8A>Yd3!P&A4Ov6&EdZmOixeTd4J`*Wutura(}4w@KV>i#rf(0PYL&v^89QiXBP6sj=N;q8kVxS}hA! z|3QaiYz!w+xQ%9&Zg${JgQ*Ip_bg2rmmG`JkX^}&5gbZF!Z(gDD1s5{QwarPK(li- zW9y-CiQ`5Ug1ceN1w7lCxl=2}7c*8_XH8W7y0AICn19qZ`w}z0iCJ$tJ}NjzQCH90 zc!UzpKvk%3;`XfFi2;F*q2eMQQ5fzO{!`KU1T^J?Z64|2Z}b1b6h80_H%~J)J)kbM0hsj+FV6%@_~$FjK9OG7lY}YA zRzyYxxy18z<+mCBiX?3Q{h{TrNRkHsyF|eGpLo0fKUQ|19Z0BamMNE9sW z?vq)r`Qge{9wN|ezzW=@ojpVQRwp##Q91F|B5c`a0A{HaIcW>AnqQ*0WT$wj^5sWOC1S;Xw7%)n(=%^in zw#N*+9bpt?0)PY$(vnU9SGSwRS&S!rpd`8xbF<1JmD&6fwyzyUqk){#Q9FxL*Z9%#rF$} zf8SsEkE+i91VY8d>Fap#FBacbS{#V&r0|8bQa;)D($^v2R1GdsQ8YUk(_L2;=DEyN%X*3 z;O@fS(pPLRGatI93mApLsX|H9$VL2)o(?EYqlgZMP{8oDYS8)3G#TWE<(LmZ6X{YA zRdvPLLBTatiUG$g@WK9cZzw%s6TT1Chmw#wQF&&opN6^(D`(5p0~ zNG~fjdyRsZv9Y?UCK(&#Q2XLH5G{{$9Y4vgMDutsefKVVPoS__MiT%qQ#_)3UUe=2fK)*36yXbQUp#E98ah(v`E$c3kAce_8a60#pa7rq6ZRtzSx6=I^-~A|D%>Riv{Y`F9n3CUPL>d`MZdRmBzCum2K%}z@Z(b7#K!-$Hb<+R@Rl9J6<~ z4Wo8!!y~j(!4nYsDtxPIaWKp+I*yY(ib`5Pg356Wa7cmM9sG6alwr7WB4IcAS~H3@ zWmYt|TByC?wY7yODHTyXvay9$7#S?gDlC?aS147Ed7zW!&#q$^E^_1sgB7GKfhhYu zOqe*Rojm~)8(;b!gsRgQZ$vl5mN>^LDgWicjGIcK9x4frI?ZR4Z%l1J=Q$0lSd5a9 z@(o?OxC72<>Gun*Y@Z8sq@od{7GGsf8lnBW^kl6sX|j~UA2$>@^~wtceTt^AtqMIx zO6!N}OC#Bh^qdQV+B=9hrwTj>7HvH1hfOQ{^#nf%e+l)*Kgv$|!kL5od^ka#S)BNT z{F(miX_6#U3+3k;KxPyYXE0*0CfL8;hDj!QHM@)sekF9uyBU$DRZkka4ie^-J2N8w z3PK+HEv7kMnJU1Y+>rheEpHdQ3_aTQkM3`0`tC->mpV=VtvU((Cq$^(S^p=+$P|@} zueLA}Us^NTI83TNI-15}vrC7j6s_S`f6T(BH{6Jj{Lt;`C+)d}vwPGx62x7WXOX19 z2mv1;f^p6cG|M`vfxMhHmZxkkmWHRNyu2PDTEpC(iJhH^af+tl7~h?Y(?qNDa`|Ogv{=+T@7?v344o zvge%8Jw?LRgWr7IFf%{-h>9}xlP}Y#GpP_3XM7FeGT?iN;BN-qzy=B# z=r$79U4rd6o4Zdt=$|I3nYy;WwCb^`%oikowOPGRUJ3IzChrX91DUDng5_KvhiEZwXl^y z+E!`Z6>}ijz5kq$nNM8JA|5gf_(J-);?SAn^N-(q2r6w31sQh6vLYp^ z<>+GyGLUe_6eTzX7soWpw{dDbP-*CsyKVw@I|u`kVX&6_h5m!A5&3#=UbYHYJ5GK& zLcq@0`%1;8KjwLiup&i&u&rmt*LqALkIqxh-)Exk&(V)gh9@Fn+WU=6-UG^X2~*Q-hnQ$;;+<&lRZ>g0I`~yuv!#84 zy>27(l&zrfDI!2PgzQyV*R(YFd`C`YwR_oNY+;|79t{NNMN1@fp?EaNjuM2DKuG%W z5749Br2aU6K|b=g4(IR39R8_!|B`uQ)bun^C9wR4!8isr$;w$VOtYk+1L9#CiJ#F) z)L}>^6>;X~0q&CO>>ZBo0}|Ex9$p*Hor@Ej9&75b&AGqzpGpM^dx}b~E^pPKau2i5 zr#tT^S+01mMm}z480>-WjU#q`6-gw4BJMWmW?+VXBZ#JPzPW5QQm@RM#+zbQMpr>M zX$huprL(A?yhv8Y81K}pTD|Gxs#z=K(Wfh+?#!I$js5u8+}vykZh~NcoLO?ofpg0! zlV4E9BAY_$pN~e-!VETD&@v%7J~_jdtS}<_U<4aRqEBa&LDpc?V;n72lTM?pIVG+> z*5cxz_iD@3vIL5f9HdHov{o()HQ@6<+c}hfC?LkpBEZ4xzMME^~AdB8?2F=#6ff!F740l&v7FN!n_ zoc1%OfX(q}cg4LDk-1%|iZ^=`x5Vs{oJYhXufP;BgVd*&@a04pSek6OS@*UH`*dAp z7wY#70IO^kSqLhoh9!qIj)8t4W6*`Kxy!j%Bi%(HKRtASZ2%vA0#2fZ=fHe0zDg8^ zucp;9(vmuO;Zq9tlNH)GIiPufZlt?}>i|y|haP!l#dn)rvm8raz5L?wKj9wTG znpl>V@};D!M{P!IE>evm)RAn|n=z-3M9m5J+-gkZHZ{L1Syyw|vHpP%hB!tMT+rv8 zIQ=keS*PTV%R7142=?#WHFnEJsTMGeG*h)nCH)GpaTT@|DGBJ6t>3A)XO)=jKPO<# zhkrgZtDV6oMy?rW$|*NdJYo#5?e|Nj>OAvCXHg~!MC4R;Q!W5xcMwX#+vXhI+{ywS zGP-+ZNr-yZmpm-A`e|Li#ehuWB{{ul8gB&6c98(k59I%mMN9MzK}i2s>Ejv_zVmcMsnobQLkp z)jmsJo2dwCR~lcUZs@-?3D6iNa z2k@iM#mvemMo^D1bu5HYpRfz(3k*pW)~jt8UrU&;(FDI5ZLE7&|ApGRFLZa{yynWx zEOzd$N20h|=+;~w$%yg>je{MZ!E4p4x05dc#<3^#{Fa5G4ZQDWh~%MPeu*hO-6}2*)t-`@rBMoz&gn0^@c)N>z|Ikj8|7Uvdf5@ng296rq2LiM#7KrWq{Jc7;oJ@djxbC1s6^OE>R6cuCItGJ? z6AA=5i=$b;RoVo7+GqbqKzFk>QKMOf?`_`!!S!6;PSCI~IkcQ?YGxRh_v86Q%go2) zG=snIC&_n9G^|`+KOc$@QwNE$b7wxBY*;g=K1oJnw8+ZR)ye`1Sn<@P&HZm0wDJV* z=rozX4l;bJROR*PEfHHSmFVY3M#_fw=4b_={0@MP<5k4RCa-ZShp|CIGvW^9$f|BM#Z`=3&=+=p zp%*DC-rEH3N;$A(Z>k_9rDGGj2&WPH|}=Pe3(g}v3=+`$+A=C5PLB3UEGUMk92-erU%0^)5FkU z^Yx#?Gjyt*$W>Os^Fjk-r-eu`{0ZJbhlsOsR;hD=`<~eP6ScQ)%8fEGvJ15u9+M0c|LM4@D(tTx!T(sRv zWg?;1n7&)-y0oXR+eBs9O;54ZKg=9eJ4gryudL84MAMsKwGo$85q6&cz+vi)9Y zvg#u>v&pQQ1NfOhD#L@}NNZe+l_~BQ+(xC1j-+({Cg3_jrZ(YpI{3=0F1GZsf+3&f z#+sRf=v7DVwTcYw;SiNxi5As}hE-Tpt)-2+lBmcAO)8cP55d0MXS*A3yI5A!Hq&IN zzb+)*y8d8WTE~Vm3(pgOzy%VI_e4lBx&hJEVBu!!P|g}j(^!S=rNaJ>H=Ef;;{iS$$0k-N(`n#J_K40VJP^8*3YR2S`* zED;iCzkrz@mP_(>i6ol5pMh!mnhrxM-NYm0gxPF<%(&Az*pqoRTpgaeC!~-qYKZHJ z2!g(qL_+hom-fp$7r=1#mU~Dz?(UFkV|g;&XovHh~^6 z1eq4BcKE%*aMm-a?zrj+p;2t>oJxxMgsmJ^Cm%SwDO?odL%v6fXU869KBEMoC0&x>qebmE%y+W z51;V2xca9B=wtmln74g7LcEgJe1z7o>kwc1W=K1X7WAcW%73eGwExo&{SSTnXR+pA zRL)j$LV7?Djn8{-8CVk94n|P>RAw}F9uvp$bpNz<>Yw3PgWVJo?zFYH9jzq zU|S+$C6I?B?Jm>V{P67c9aRvK283bnM(uikbL=``ew5E)AfV$SR4b8&4mPDkKT&M3 zok(sTB}>Gz%RzD{hz|7(AFjB$@#3&PZFF5_Ay&V3?c&mT8O;9(vSgWdwcy?@L-|`( z@@P4$nXBmVE&Xy(PFGHEl*K;31`*ilik77?w@N11G7IW!eL@1cz~XpM^02Z?CRv1R z5&x6kevgJ5Bh74Q8p(-u#_-3`246@>kY~V4!XlYgz|zMe18m7Vs`0+D!LQwTPzh?a zp?X169uBrRvG3p%4U@q_(*^M`uaNY!T6uoKk@>x(29EcJW_eY@I|Un z*d;^-XTsE{Vjde=Pp3`In(n!ohHxqB%V`0vSVMsYsbjN6}N6NC+Ea`Hhv~yo@ z|Ab%QndSEzidwOqoXCaF-%oZ?SFWn`*`1pjc1OIk2G8qSJ$QdrMzd~dev;uoh z>SneEICV>k}mz6&xMqp=Bs_0AW81D{_hqJXl6ZWPRNm@cC#+pF&w z{{TT0=$yGcqkPQL>NN%!#+tn}4H>ct#L#Jsg_I35#t}p)nNQh>j6(dfd6ng#+}x3^ zEH`G#vyM=;7q#SBQzTc%%Dz~faHJK+H;4xaAXn)7;)d(n*@Bv5cUDNTnM#byv)DTG zaD+~o&c-Z<$c;HIOc!sERIR>*&bsB8V_ldq?_>fT!y4X-UMddUmfumowO!^#*pW$- z_&)moxY0q!ypaJva)>Bc&tDs?D=Rta*Wc^n@uBO%dd+mnsCi0aBZ3W%?tz844FkZD zzhl+RuCVk=9Q#k;8EpXtSmR;sZUa5(o>dt+PBe96@6G}h`2)tAx(WKR4TqXy(YHIT z@feU+no42!!>y5*3Iv$!rn-B_%sKf6f4Y{2UpRgGg*dxU)B@IRQ`b{ncLrg9@Q)n$ zOZ7q3%zL99j1{56$!W(Wu{#m|@(6BBb-*zV23M!PmH7nzOD@~);0aK^iixd%>#BwR zyIlVF*t4-Ww*IPTGko3RuyJ*^bo-h}wJ{YkHa2y3mIK%U%>PFunkx0#EeIm{u93PX z4L24jUh+37=~WR47l=ug2cn_}7CLR(kWaIpH8ojFsD}GN3G}v6fI-IMK2sXnpgS5O zHt<|^d9q}_znrbP0~zxoJ-hh6o81y+N;i@6M8%S@#UT)#aKPYdm-xlbL@v*`|^%VS(M$ zMQqxcVVEKe5s~61T77N=9x7ndQ=dzWp^+#cX}v`1bbnH@&{k?%I%zUPTDB(DCWY6( zR`%eblFFkL&C{Q}T6PTF0@lW0JViFzz4s5Qt?P?wep8G8+z3QFAJ{Q8 z9J41|iAs{Um!2i{R7&sV=ESh*k(9`2MM2U#EXF4!WGl(6lI!mg_V%pRenG>dEhJug z^oLZ?bErlIPc@Jo&#@jy@~D<3Xo%x$)(5Si@~}ORyawQ{z^mzNSa$nwLYTh6E%!w_ zUe?c`JJ&RqFh1h18}LE47$L1AwR#xAny*v9NWjK$&6(=e0)H_v^+ZIJ{iVg^e_K-I z|L;t=x>(vU{1+G+P5=i7QzubN=dWIe(bqeBJ2fX85qrBYh5pj*f05=8WxcP7do(_h zkfEQ1Fhf^}%V~vr>ed9*Z2aL&OaYSRhJQFWHtirwJFFkfJdT$gZo;aq70{}E#rx((U`7NMIb~uf>{Y@Fy@-kmo{)ei*VjvpSH7AU zQG&3Eol$C{Upe`034cH43cD*~Fgt?^0R|)r(uoq3ZjaJqfj@tiI~`dQnxfcQIY8o| zx?Ye>NWZK8L1(kkb1S9^8Z8O_(anGZY+b+@QY;|DoLc>{O|aq(@x2=s^G<9MAhc~H z+C1ib(J*&#`+Lg;GpaQ^sWw~f&#%lNQ~GO}O<5{cJ@iXSW4#};tQz2#pIfu71!rQ( z4kCuX$!&s;)cMU9hv?R)rQE?_vV6Kg?&KyIEObikO?6Nay}u#c#`ywL(|Y-0_4B_| zZFZ?lHfgURDmYjMmoR8@i&Z@2Gxs;4uH)`pIv#lZ&^!198Fa^Jm;?}TWtz8sulPrL zKbu$b{{4m1$lv0`@ZWKA|0h5U!uIwqUkm{p7gFZ|dl@!5af*zlF% zpT-i|4JMt%M|0c1qZ$s8LIRgm6_V5}6l6_$cFS# z83cqh6K^W(X|r?V{bTQp14v|DQg;&;fZMu?5QbEN|DizzdZSB~$ZB%UAww;P??AT_-JFKAde%=4c z*WK^Iy5_Y`*IZ+cF`jvkCv~Urz3`nP{hF!UT7Z&e;MlB~LBDvL^hy{%; z7t5+&Ik;KwQ5H^i!;(ly8mfp@O>kH67-aW0cAAT~U)M1u`B>fG=Q2uC8k}6}DEV=% z<0n@WaN%dDBTe*&LIe^r-!r&t`a?#mEwYQuwZ69QU3&}7##(|SIP*4@y+}%v^Gb3# zrJ~68hi~77ya4=W-%{<(XErMm>&kvG`{7*$QxRf(jrz|KGXJN3Hs*8BfBx&9|5sZ1 zpFJ1(B%-bD42(%cOiT@2teyYoUBS`L%<(g;$b6nECbs|ADH5$LYxj?i3+2^#L@d{%E(US^chG<>aL7o>Fg~ zW@9wW@Mb&X;BoMz+kUPUcrDQOImm;-%|nxkXJ8xRz|MlPz5zcJHP<+yvqjB4hJAPE zRv>l{lLznW~SOGRU~u77UcOZyR#kuJrIH_){hzx!6NMX z>(OKAFh@s2V;jk|$k5-Q_ufVe;(KCrD}*^oBx{IZq^AB|7z*bH+g_-tkT~8S$bzdU zhbMY*g?Qb;-m|0`&Jm}A8SEI0twaTfXhIc=no}$>)n5^cc)v!C^YmpxLt=|kf%!%f zp5L$?mnzMt!o(fg7V`O^BLyjG=rNa}=$hiZzYo~0IVX$bp^H-hQn!;9JiFAF<3~nt zVhpABVoLWDQ}2vEEF3-?zzUA(yoYw&$YeHB#WGCXkK+YrG=+t0N~!OmTN;fK*k>^! zJW_v+4Q4n2GP7vgBmK;xHg^7zFqyTTfq|0+1^H2lXhn6PpG#TB*``?1STTC#wcaj3 zG~Q9!XHZ#1oPZo zB6h(BVIW5K+S@JG_HctDLHWb;wobZ0h(3xr6(uUspOSK0WoSHeF$ZLw@)cpoIP|kL zu`GnW>gD$rMt}J0qa9kJzn0s`@JNy1Crkb&;ve|()+_%!x%us>1_Xz|BS>9oQeD3O zy#CHX#(q^~`=@_p$XV6N&RG*~oEH$z96b8S16(6wqH)$vPs=ia!(xPVX5o&5OIYQ%E(-QAR1}CnLTIy zgu1MCqL{_wE)gkj0BAezF|AzPJs=8}H2bHAT-Q@Vuff?0GL=)t3hn{$Le?|+{-2N~`HWe24?!1a^UpC~3nK$(yZ_Gp(EzP~a{qe>xK@fN zEETlwEV_%9d1aWU0&?U>p3%4%>t5Pa@kMrL4&S@ zmSn!Dllj>DIO{6w+0^gt{RO_4fDC)f+Iq4?_cU@t8(B^je`$)eOOJh1Xs)5%u3hf; zjw$47aUJ9%1n1pGWTuBfjeBumDI)#nkldRmBPRW|;l|oDBL@cq1A~Zq`dXwO)hZkI zZ=P7a{Azp06yl(!tREU`!JsmXRps!?Z~zar>ix0-1C+}&t)%ist94(Ty$M}ZKn1sDaiZpcoW{q&ns8aWPf$bRkbMdSgG+=2BSRQ6GG_f%Lu#_F z&DxHu+nKZ!GuDhb>_o^vZn&^Sl8KWHRDV;z#6r*1Vp@QUndqwscd3kK;>7H!_nvYH zUl|agIWw_LPRj95F=+Ex$J05p??T9_#uqc|q>SXS&=+;eTYdcOOCJDhz7peuvzKoZhTAj&^RulU`#c?SktERgU|C$~O)>Q^$T8ippom{6Ze0_44rQB@UpR~wB? zPsL@8C)uCKxH7xrDor zeNvVfLLATsB!DD{STl{Fn3}6{tRWwG8*@a2OTysNQz2!b6Q2)r*|tZwIovIK9Ik#- z0k=RUmu97T$+6Lz%WQYdmL*MNII&MI^0WWWGKTTi&~H&*Ay7&^6Bpm!0yoVNlSvkB z;!l3U21sJyqc`dt)82)oXA5p>P_irU*EyG72iH%fEpUkm1K$?1^#-^$$Sb=c8_? zOWxxguW7$&-qzSI=Z{}sRGAqzy3J-%QYz2Cffj6SOU|{CshhHx z6?5L$V_QIUbI)HZ9pwP9S15 zXc%$`dxETq+S3_jrfmi$k=)YO5iUeuQ&uX}rCFvz&ubO?u)tv|^-G_`h$pb+8vn@f z7@eQe#Kx|8^37a4d0GulYIUAW|@I5|NIh%=OqHU{(>(UhKvJ}i_X*>!Geb+Rs0MWf66Lf z-cQ(4QOENSbTX$6w_9w4{5eR?14#?)Jqf2UCk5US4bnz8!e>vFduH6(cZZ=5*_!M# zUTZ_b<4v@}dSQOcH@wt-s;3JhkVDct$6k9!ETdi-tplkaxl^qF=p}Q8KMVm+ zeIa2q?RYr}nM0d_W2YWv%JKyCrGSePj8GrRN)<$Nsq8l$X=>`W;?>0eME3|8t&d$~ zH`XG45lBh>-te_f0Mh0??)=Ee0~zESx=sZPv<#!sAVv$0qTn@CmCUNJU<#=`GC)&P z9zuV~9*3_n2*ZQBUh)2xIi;0yo)9XXJxM-VB*6xpyz{Rx2ZCvFnF$2aPcYFG( zyXkO(B30?mt;5GW&{m^w3?!P`#_o;Y%P2z^A`|4%Bt2@3G?C2dcSPNy1#HMXZ>{+L z3BE#xvqR@Ub}uKfzGC=RO|W%dJpUK#m8p&Dk|6Ub8S+dN3qxf9dJ_|WFdM9CSNQv~ zjaFxIX`xx-($#Fq+EI76uB@kK=B4FS0k=9(c8UQnr(nLQxa2qWbuJyD7%`zuqH|eF zNrpM@SIBy@lKb%*$uLeRJQ->ko3yaG~8&}9|f z*KE`oMHQ(HdHlb&)jIzj5~&z8r}w?IM1KSdR=|GFYzDwbn8-uUfu+^h?80e*-9h%Nr;@)Q-TI#dN1V zQPT2;!Wk)DP`kiY<{o7*{on%It(j0&qSv=fNfg3qeNjT@CW{WT<)1Eig!g9lAGx6& zk9_Zrp2I+w_f!LRFsgxKA}gO=xSPSY``kn=c~orU4+0|^K762LWuk_~oK{!-4N8p8 zUDVu0ZhvoD0fN8!3RD~9Bz5GNEn%0~#+E-Js}NTBX;JXE@29MdGln$Aoa3Nzd@%Z= z^zuGY4xk?r(ax7i4RfxA?IPe27s87(e-2Z_KJ(~YI!7bhMQvfN4QX{!68nj@lz^-& z1Zwf=V5ir;j*30AT$nKSfB;K9(inDFwbI^%ohwEDOglz}2l}0!#LsdS3IW43= zBR#E@135bu#VExrtj?)RH^PM(K4B`d=Z6^kix`8$C1&q)w1<&?bAS?70}9fZwZU7R z5RYFo?2Q>e3RW2dl&3E^!&twE<~Lk+apY?#4PM5GWJb2xuWyZs6aAH-9gqg${<1?M zoK&n+$ZyGIi=hakHqRu{^8T4h@$xl?9OM46t;~1_mPs9}jV58E-sp!_CPH4<^A|Q5 zedUHmiyxTc2zgdxU?4PyQ{ON@r+Ucn1kjWSOsh6WzLV~Bv&vWLaj#Xz4VSDs*F#@M>#e^ixNCQ-J|iC=LcB*M4WUb>?v6C z14^8h9Ktd1>XhO$kb-rRL}SFTH)kSu+Dwds$oed7qL)Jbd zhQys4$Uw~yj03)6Kq+K-BsEDftLgjDZk@qLjAyrb5UMeuO^>D43g%0GoKJ~TO0o!D z9E$WfxEDFTT?~sT?|!7aYY*mpt`}i;WTgY|Cb4{Cscrmzb(?UE+nz1wC3#QSjbg>N zleu?7MGaQ&FtejK#?07Uq$vIZX5FqR*a=(zUm`Fq$VUl){GQ{2MA)_j4H$U8FZ`=A z&GU_an)?g%ULunbBq4EUT7uT=vI6~uapKC|H6uz1#Rqt$G(!hE7|c8_#JH%wp9+F? zX`ZigNe9GzC(|Nr8GlmwPre3*Nfu+ zF=SHtv_g@vvoVpev$Jxs|F7CH`X5#HAI=ke(>G6DQQ=h^U8>*J=t5Z3Fi>eH9}1|6 znwv3k>D=kufcp= zAyK#v05qERJxS_ts79QVns}M?sIf(hCO0Q9hKe49a@PzvqzZXTAde6a)iZLw|8V-) ziK`-s)d(oQSejO?eJki$UtP0ped)5T1b)uVFQJq*`7w8liL4TX*#K`hdS!pY9aLD+ zLt=c$c_wt^$Wp~N^!_nT(HiDVibxyq2oM^dw-jC~+3m-#=n!`h^8JYkDTP2fqcVC& zA`VWy*eJC$Eo7qIe@KK;HyTYo0c{Po-_yp=>J(1h#)aH5nV8WGT(oSP)LPgusH%N$?o%U%2I@Ftso10xd z)Tx(jT_vrmTQJDx0QI%9BRI1i!wMNy(LzFXM_wucgJGRBUefc413a9+)}~*UzvNI{KL# z_t4U&srNV|0+ZqwL(<}<%8QtjUD8kSB&p$v^y}vuEC2wyW{aXp2{LTi$EBEHjVnS# z+4=G$GUllsjw&hTbh6z%D2j=cG>gkNVlh|24QUfD*-x9OMzTO93n*pE(U7Vz7BaL% z@(c!GbEjK~fH}sqbB1JNI!~b+AYb5le<-qxDA9&r2o)|epl9@5Ya7}yVkcM)yW6KY7QOX_0-N=)+M!A$NpG? z6BvZ8Tb}Pw(i9f7S00=KbWmNvJGL(-MsAz3@aR~PM$Z>t)%AiCZu?A|?P*~UdhhFT`;Nb)MxIg*0QlkYVX+46( zSd%WoWR@kYToK7)(J=#qUD-ss;4M&27w#03y6$gk6X<-VL8AJM@NFTx#Z!n)F5T357%njjKyjro(yW8ceP{!%;*Y>DN`&_18p(z2Hg$%K zohbgJcp%+ux%q6F?(sc_mYJ<$;DxgkTEi?yjT6Du@+n(KsKtFHcO%7O z=AsfLSTdE2>7a@0^`;)?Fg|s2XOPV&fo<%Q)Izaw4s&RvrX0^+aPNq|yE?oSa7 zsnNs!+vGcTM4yM|$9so*2Nv;ngDD}b0MjH6i4e|l^O`lzCRj)-qa6f%|afJpmf(S1J2k7Nt^!;Q}0 z4ejPF?^M~Sv+@LYn&IFUk2;1h?kb8lfrT`oMm=JBm{fo5N|HY~yQQ`T*e2?!tF%*t zf+ncx15$NdF82GXrpP5rJ7!PVE3>u`ME$9Hw5RlP zUh+s#pg{9kEOsAhvu2pry#@dvbB3Lti+9VkLxPZSl;fNr9}wv1cTahUw_Py7%Xp;C zaz__|kz*ydKiYbsqK{?cXhqR(!1KMoV-+!mz>3S8S`Va4kD#(aKyqecGXB^nF*>mS z1gG>fKZc?R~Tye>%x+43D8=e zf0eKr-)>VEu7^I{%T}BT-WaGXO3+x<2w2jwnXePdc2#BdofU6wbE)ZWHsyj=_NT3o z)kySji#CTEnx8*-n=88Ld+TuNy;x$+vDpZ)=XwCr_Gx-+N=;=LCE7CqKX9 zQ-0{jIr zktqqWCgBa3PYK*qQqd=BO70DfM#|JvuW*0%zmTE{mBI$55J=Y2b2UoZ)Yk z3M%rrX7!nwk#@CXTr5=J__(3cI-8~*MC+>R);Z)0Zkj2kpsifdJeH)2uhA|9^B;S$ z4lT3;_fF@g%#qFotZ#|r-IB*zSo;fokxbsmMrfNfJEU&&TF%|!+YuN=#8jFS4^f*m zazCA-2krJ-;Tkufh!-urx#z*imYo|n6+NDGT#*EH355(vRfrGnr*x z5PWMD7>3IwEh=lO^V>O>iLP~S!GjrvI5lx<7oOg(d;6uEFqo5>IwptBQz;`>zx`n$ zjZQ#Hb)qJdQy#ML&qcfmb$KT+f_1#uYNo7HHDY}7xAw8qbl;9LWO-cndfI=5$%jBw zb}K3U%88Fg^|&0Vc~99bKl|$3JzdawRZ|`7%1S<8B7>9*rWAT0U<@mHDfnL1`~1U| zDw7m@<@}C|zqeHM(OK@di6~sKHiJvk^I0^S<LBe^_xZsUOzVkYSE)Bxn*NekQYbyTn5SRt!n{EseOo-$u)vjM(PV%6cIG3Kv$>dd}HUyXi;_Lv>}OyUj38dPe8+1Pr?{LXnIBCoTnocD60@vhsz+GG5lJB9ncgP8T6@LwuzZ)J zKETBS~AvzGE!{u^+Rd-|Gn!rc@UUnioP0{@_j_>tg8YI#?y zL-H$=&xXkCJ2Qe7&exbI!z`OyPxBp|4_ zZrrc;OAb%T4Ze%7E}FBB`8t$QN0sA3vpwU>?7QAmE%-ethXdCtby$Qm3v$lNxB2a7 ze6F5eEWV`={#W(G)Va}7?$D65WF|f0nmfZT;?=LE6Yz{{W3CV2h^Ma+LXdZ(HMVKZ z!YXJ*34lo!FA>)jSo@*!Hs_)IwmTo6pBr3c^j2u_amZ~g;&Z2jZIw!}v@w8DtZz7|A%rFksD4^HYB!xFAqX;u0HxPeG!3Z(z z4}+^N5-nckKf2YSR5R_}PD+2?Wq#BOiON74#{`u=4f59WKdy_77EYq~_|X6cNtno{ zZ?WLwbV57Z6uI|uY_;vzv~~`eiiOl($Au7C*X<&MY5v0b`KEu-GW}{2UNfmmrP!^Y zAOczy!}TIJsom=}kxH)9W`&Rp&rR6T7y&~5nXbut;wcs@M?aa^9j{ZDtx=1?P8TV{ zee2kKf%CE$mogyKKT=xQQ#)OCl9bjc)}{p2X$}aG`^B0w0yi-rI!d4e-u9uR$kJK3 zhqBG9Wx<-3DFw5olJ6neF@hB;8o(r(GB_;p1i>}cjN`JNEZg-dlxtLL=8~gfLrBy_ z1~bGh{I>_xqh(}?%bCf1U6~K@+N*i}bTi+pUAW)oM0`D*PeJq=S(-|Plxe9OqxBRg zM((r)xkSH@j!8@+=cA4US0fDL&O?W~x=Mlu>7zvHO2sy7D5_7ulP+YMecP~}F0b*K z3oO2j{o&WHd<&UWcyA(&6hvBJv}qUZ!@R<(mwKB^;y3zeE1>LzbDWSkRD1|5MZPx( zxd=&MsQi1eE@@6W+4N`cF?yh!3R5JlAV--&RONWQ#?SbrQ95<@ag>C{jQmGXpQX{) z1dbFg1_`qLxuDZnX#PKfCW*Jl3F&^7@gO&{>Nb8um$VBcF1!AL=N6`A%BFj=`QaPI z+m^`n+{o)KLif;Gt|7aQ(XXRP@x)jJt}s{&S`I3}jPTY>$@W0BD3Oif^ehs~!H7T1FUSWxLS&W;0q6+azjbWn?3!q$ z9qbmdr4H4Y)p^NOACJ^L>u}NS8T0_5hW)G z%Hv}dAqM}d@t;|hf8>+NHHPi*xePsRlqr46njzhiXXZti7i5+GTKcrlxA->OJ9*Pna`02EIA5~(SMV`T@H6F2VtwwP1$tYujbC1^VE$Yd&I`WSwB^1( zT7NP3|85z#R%&wktjwY_i*n_$RRZPM^ota{LPV%*>=>sAv%fn*cnkCIX{^SJRmwZv z!?f@T&D%Lz@*!mNYTGp{J|7)~PR*ib`;l^E)rQw@)Qn0ECnB8W1S_SbLZWdqcmo?V zX5g0_3qhn4TrN27^x#Qdq*4*G1L|)I^b8GuP_8O{p|M`uvZO6McXa>OSQRW|kQTNPZ#Zyj~SZ<`6B)Y+}jxpn+YT>MhZ!Rxyd@rU>N zP>MkDBLX|<)SJaO?Ge=!D>i+Wq&PgneO?ZXUq4IQuTq z+V{ZGkuw77o~o$!b>4ov`6CKJ)$cf=S6%1ZQyYU!kz_qiuNxY2*Bh;K9J6o_YV6xQ znW|>x+#Mymu&wF9P|3wP*(ZjwE+ou|{eFqMv}d_iEyH zQ?NSf3VX+EpbrIKmp|oD-t_rh(D#e)fp)dYbG{=yPj-3-#l+iu7r+~#w|(#wv@G0` z38`Yhf5CznhyDEhD;jzaz7fc8L?(n-m zR#|5hqq#yRoeTm+h^9J42mnB>BY>HSu&&O-Hxo6j!dqck)dGS&odS@Hsk2-*Z~x z0!%{@gT645S5DeF@JZeE$DFl*nJB8Z|JKvs%7d`KjbJ*AsA_=fEZ&V9=*+K{(TF^( ztjjYr(7@fV^tDs9c*#=8)ZRKO17A5Z`8v*)U+?hS>3sEfgh3`#vFO^7n}&&adV?}n zdy&BY1h|I@eBm=l*kqiJn>vNkOH4l$Op5Hw3K_w8lF!6T@-H)S2W|Km#6!-X#NqLJ zsiVDrc%*@I3^Gen$)6O0C_qw;8{aucF;}U^1%YE`?AYTtb`Z$B$vfhcHQF`VCB(Pf z_G#fV*Colv-k!O+=^nDNe(03?m+RTu&28d%>JrrwFNb{ND&?Ad(=DP@voz$usk1|w z&#gTB7F)#*LtY6@pIb(g72*LcnXRlTPQAD?)ZFnB*EsZqxM&Uk_KGXnR{4}K`I6i- zU9}R>tiO0De1Hx=kAy>7O+nKO@kGQEYOai&S9&WTY+flvR?uhI695W-xZnq4aRMh8 zwfp)+KYWVB#r=5AwwlSdM4@x7-R_{2;1iqz2lXL$7iu1>5W*+I)jlkMs>60=LN)Y= zbPw;;%U+%p_&{2Obemh$BLmbpDd31YxJ8#TpH3~3B8QLUMvx1X5Vl48hWSNN*UTlO zQgQyZbmyjGC-s$3tnB z0mfKUu2+_c`ZVvDVwUy#j3W*l^BSXXQ%=r6Z}C73jx8DAk!t7k{dK^udpHIcUejp# zyx}og$Hr+f>9kaZvno*Om`d|VTUce9tHM=R8thoG!a=NT$s;g@n_rAN%cp7nnLuav z6}j56TSSfPL$p#y#!5TVyqa3zTzi7@#IoeR=E6CdS`JrR+@i2DwZ?T*bh+(k5!a)0 zgRdF93z8XJ|5?>hDN!YAW5cK=+BwDLNT_+otd zqC@*{S0hCKZ+TnN*2&qx+WP;ZjHA`yytPcwKl~)uy)sQ}Q*0-&3X|YFYAjmolaciq zxS$r5^fxICetD*Dw78M9leVvhAOZ$=;SP7L!Vs?+0f1h*YCuTXIt03iAf)0=0KEvZ zB69o-zg`0C#hQ>`4`}1g=a~EID(j9HbjJG^tV-zumR-+fahTPveA{%0u2uQwMZ%}5 zwY!|}i0oTd&>^QSRhIKU+cMC#|C3f>|647?v1B(wH)EWb{vuJEJh~!#|J7%=h!x3| zCH6m}wg;>Q&?@5Ct1%n`lj%*>9a52d@wmvE`=aQjtz$sWj3V;fDns5<7d2*``)u1( zh!Ub>!#N0m=Vz1n1=El zwb2IVRw$6NIFRpGyUoM0iqc$IPehcmm7<0s7F*Yv+zq?_%pf*SS~~}s0M`m(rMbx% zi?|Wjr6fJN`_J8&B2$4+V+iO~m>s~Zr2T3Y3HGREFQ%%pEoU0N));AeSVM#gYQ>l} z0`RhgS`R^pJH31YQ~eTeJiI}g$&^|nv{!h?8mJK{{XDt+sG8D`7)$jvM#hjPI(5sS zfFW4s7wao%Lo| z#pJRC?iZOai;57ANs|vm6%}rPlGo}}Aso1t#xJn}%VW@~1WSjh(@JTgM$0x6ZQ)gB zdiox3f>kqGZY}+R<;wlNoWJ8#X-v)1;wRD*ec*wnvsN06Q@cZuD`deT-Bu&G;2fBC z0FE1%pG@{Yo2O87&dE;w???%`9s1gs=3GpM8xx_}=AB$K9y=cD);^iE*p4;T1RU%B zBPr)yqOBX<2}xt%g9qr>;z&|?4vhhw7@$a}Uy2b%_^VdB^VfzrebKUPnq;hliCNU% zVt3R5EHkhN^Pv`REF+npA@#HdCQN9IbQbqSDs^+zt(A6;rLwN+@Em}WrV5vPEo!w^ zSCd3RZ8{7a@d9@|IF&&G%irS7FHle?@49LctrtTt=rP$W)se*#RkFmyf)D1^U6EYI zfh+N?uH?-))O$9zM19VsuGn8?o~5`scXU?!P@_cWP&1U4PQqGus=sQzrX+YvKG%XBL3nt6!&M<#}wqA;Mo(}qrq<1lNkpQD-T#-y>grt|E+JNU) z2j+g+QPcA9VEFc0k;H(hSNOpp$I+!$ z&d&W6kBM9+c{X%vr_X0}tdB5dvEDyk5H2*T(QW8Yz-#tjvF?up=^Kfym``^!&O-X! z@HdfpHn;}_)y$Xjb-5cR$Q#-XdhKpmJG5pl>h*Q2(u*gt_4(>6?kG)%T3*&TT0qI( zL!aR~4HiJiaHlgdNcOQP6xx1f3AWx&8}(NEps|G!cO>J^rE2@&-t#_Jb7GYgnLnML~1ze1D$?~BwbgA^=pr55tC|d7w42vN11_8bS75u z_MRKqE7Xik8fk>6(VE5{qT}6rSzd|o}Zb>*aI*Bwg%ccE$_ytH;g2H z^i3qY!+aE*&s^BMH9TI6GLm&9c`D6)3{-+?2Pon+040Yuv$2(LqV*krKhTg5CHOj* zquacxc1&~=S(O@gR8aI#?R%)meONmw1rub9E2QzeM$pBBm2wbPNR3tab{op53<oFwaUbARdD5jSA_6zmKX7!VicEP1m)rYnk{P- zruRj;4c8S29Rd#Baf|fq_pA^r3K#qRHS;($XNoLI*`puZjM?bA0tH>FDiVc9qR*|3 zGn#nhqxkvqFwRfCB~2yA0pxWapfjCdAem$utuon-`*6}mUP?l%$CE(FjAwL%Oe7GQbu7*+&q>*(cAofJr^gg>xw>hx-SO7Lx2)I} zJ)tV1XKbkE4sS&La#-smSq>S9gBzGLH%v?KVezdGv%Xs}kDJZJi{lDl(FpLZupBta z3iDlkd6LlkRro}+El?GIObw06D%NTXpL{W}Ve*%u#{wTC=+VHS%o`sAez&cYz|Tn` zcK_~pvN%cd^8FlFypCjTjw9@ulLoJ^!QAK*++^wC2~}CFeoY;q6y~r&f^+0>LR6)n z$hSev@GzzGgDc>)#u5_;{T9^5y5I?m=z7=J!eVId8p6R5>NV8)h|bA}#3KUufq4CPGiWYvGj%0=H@Q66);F)#cDMND4 zX|?rg>Bb28q*a!_sgVF(A=OeC&je$C4>$0%yy;Fla-hl(|9Ww4!@Q#E2hpJMMxpQ2L+R;+ZMpS+|j*F`Fh}p)`a_*<`AaeFzNEq^- zlF$7BFKD%p@K+3$Vx%N{QOayKKWU#JOAwXiLO62cA6=|DiDG_Z=ef;f&gQ5-?+Pb+ z)4NsyEZXCdjq5tgDN39V9!6#w25+R1;PD7ss;hFvQn}Hnl3^3h<`ylzJdVEL>|Jj0 zg>=Pscwx&;pWEzMn`ld**$1F-nhqlMuX;G{lWrT<<4$7MZ^*4a2hAMf)3eYiT$lRz&9({j<=%DWIRpgu zoOns@gF}AQ_6Y5RhySg7yMtJcYQap6^hgy{`zX1Zv26q4<)g@t%aIi|-lmcySuRN8*5f*$aEFi8o#kMKRCMnrAY~l`= zez#50^@Qo+6r508>iKfAbbc3JwCnjnmw;~=mlMG`(H8EJz7W6mh@mdinO&)#zHX=| z&|fo@s`;njVkkCMczSnp+TnW8YPU4w2&QmzEh1}orF~KlT=V+`!!rH|PtULCcL!P*m0EaN0Ad2qBw%Gs40jfu=%`N*k@z2-p?&B?Yum-p+h?7(!D^ z&f2Bn_#t!4HM2y^*1GN;U+_x8T$Z2>U9Yx;p_9Qf=ww z2hxO^*{%p9-CwMKz}C4mTi8xvqhivltE|}Kgq5MK@f6tBT&`@RYzsFFi>*eMZ0Z6Y zKBl`GOh!U%C+PXJ|7PF)V*~#8eS80D@v-NL2U&;i62W}k+vJAC+7xF`eq%c0b?{PVTcqiDr%6jLBdkVcTwLJSd313SP)1r=;2`cORbMzrhqZxMWcTWru5-l_H8;f|?{^M%%7>sU zGx2{fX*t;7SewS|NvPR-6F5p(ji7d}CK#%7y}jsPkgj%F5cUbQ?b7uWpYks^|DL*n zau%X$^(%wXMS3c;C4=p*#q>ahmLH5woLsn-YcZP~mH-rGnRyl#KU4MsLu+G3z90+q zM$HCWgZYR`8_I%8)SYuBltP$sN`-6hcjnzhDsVl+Y}yqMN*4MWsJX_6R>Cyw8cHGQ z1>r%vkDxxc#ACA4+-ZO|QBMUz`YHrS{l-*$> zi(n_;4{Gn+d2gn)TA<9) zibWdKJv#s_f5K}vM=d0NaYrd;5A+Fy^=+WgKC`@bS>!P5@K4fzE#VYfMcNdbbvLPY zeR~!f3xU>|pfq-LOsoF=t94x%K!8>#8tR4KQ2G3Yr?Cb98^KL*+G8``rHMpNUN}-T z5HGAkiLh{WR;N$Nk3X_2^3pW=vOFTOb(LS0Wu)0)I{8sZj>}5ZGtD=va-72l&5`L= zhyzBWie2UrC|?(sTcuk$OwvV4oVlxc3ncXPj|cD%%*6(hoKMd5wzPQs^6g)B0xK#d zemOodB7D(!@v!|eYqMfx@M#b+D)PwAuvimOW#13i-xAR5)Ai; zXNX(A@M*y&+TVZI zGHo$F*Ipg~Rnp`KlMNAl2o86}r%Yv9#!O-oo`pe`880;-Y28tR)b4H%nqXXHxN9m0 zI&#!(XhT=T3$WS$)K4#Y=ceN`MsP0v1X{nIoQ14S2^--MnUp21=V3&Uv8|y}^}7Vl zI5tRbOp#?@ay6uncZFE0hg}kt(k%piw^M8;0yynsK_!l~uP??IqzmKJMUqAW^GG{~ z7Fg)Q&zBlp z%Tj8jOUpuR>YHP6zYsX?)aJ`)_pRwu+Tn8I;brOW_`v$u$`$9T)cO*O$j=?mg>dW$ zw=&3=v||fqCr`-$okN*$S9(Nyrs}+Lu#IwDg2xSBz_VfU*?A&26vwv>&>*U_TT7-7 zS~X}fT%9+q(Xvc0qzOG^8gmMcZE9izi5feqvY(aY=%reP+wVZ&cRd`^y6}-gJ&_6n zR%Wdl3vQ4DOt!X9ry7j%=+7pLPdus*@7dZMBo0_WKZPD1(o{=;D> zyc9_WFI3{URv=d6EXcnOG0$(J(R#8Oz$kmuSFQ{-Y20}1027!FkodTU!fouSybwqn zRO-$2BH(w4)$wiPo<1w-4*p=Q0@YKRm^cgiA>~ho)U8^e>SBk*!@xvr0CdvnLHS#CACVuQfgzF>8qV znqf{oO1}RWhiZ3g!Tx9sk!JfLqcP`>Ksx#vZuLg-DC6h4mT!vlU zqw0`0CzZgY!EN0*{sQnDNFn;T<+e_x$zY|n;p0@d^hK*n!S!=#^;P{*D^6~h!T7r6 zoiMxtovMo-dj*{qZPy*c3gaMBEDQDkINU%d8HeBZVlRuzkCId9rx{?L= z-dLlk$w&JX5wn+8`mtqCpKnx+w+$@6DEUI}8P%xN$MEsw%S1-$9PM6r^jP-@?cS<# zhg$wl0X=s3{8EZ2U9(};p{X_b1@jJuGgx`gDK{6MpF|XON_=Rv%-<Ee1cuuy?nl9xVDa~x=+8ppnOQ9 zN$53qi4QQ!co(;f!#YJ8(=Z>_9UF#(QOVjS7T!g2)*Oecrf-R^)tFugBkQsMVNua# zS;1V^#fJS{h+!O+FgS%0=Pd9;lMa0QHn?-n(<0b2$<|@r>fjiyw6u*UoGmU$ayJM@ zfp;c4@{$b*Z_v9?8ZEp{m6Q(mDHW<``n?jg-ZN)Hhvxn*l=O1f*K%{5s77WCt!ugS?*2oG5-Q)JEJd0+W5=doeD$Wh?U$ZRg)K$v8cmQ{hba9jw_mF&X zi-dV?WITgIz!!0uB~jE?(t`&qo{WGyUspX| zc6+F2K4l5$LqxERF#`I&k^^opVIMZjGhsJ^vI0c%kV+|&_k>~}ueTtj;^Dfb@xHs` z)-39elzVA~D~n_aoyBQ1>Qd2!;E!G*pZM&RX`r*y)b`yxvP2;#vM*;CQGPg|gni)} z47`Log3PUyVfdmJ2zvHBhg7T#D-H=myzkeUa$@);WC(yB4k^*$wda3=S-UH5Q1Hx6 zPcGxMP&kXBa+4$s#Sw3-V?mlHj^8&bLpIN~GkYj;!;M!$ZxvtQY4j&Ngz_mxuQRqx zYTbN6epx@-!0jRV5yiSIJ<^mCZ<|;&x2~a)t+(eAVB!1XpCZok*Z2C5P7&>z-Oy?t zf@F(_FLsSrfCus61+Vt~svP%(u<4pzT5{w*0XqfPV%~|=%aq^$=*U+_trGQaoUxbt zBV#Yqx+ULku8yPJs4gGcC?+3iRt_6)Oi0DNLxdb(!n!cup_XUZ3eDe(!DChZ!IG&L?_;T-1GB!R;;Sk;l3Y*JQ!I|l20_f}ZyC;4D7R@6F z>%z~wV;Bj1b(*kp26Ed!Y-OKxNbt3%t))xxOrazWsmwvW;uaSaJ0ou+{01vXvU>_V z6Ha@+;giVaiyg`J8ENQf)Pq>!Nf22>XFHnXTNk84&jp-^YwmlUqnOll8)5mzlO$o! z#fSMwH8Pn+Fy7O5M5#ZGr$cKfaGf8g;XN)<*TrQjMk<}_oRf&b6qZoR38Q{Zxo{V; zby+J_hCZT1>`4~jnQxo|ji%BQ0=BLzC6c!1=B(jS5+fcp%q)JI)=c3{D|=k5;0&c2 zrbRE|qxkNqah2nvextOvjYA{T43n1c6eO7B9DH)tLqB46E7;0xKM=%#wx-*-+*OY{ zQ#7gMStz%I&2&rbo>#T20OD_#g`WYbt9+!MC08%zSMhqMoRk)7VOk%~`sD%(U6zzO zdmSC9@x0GCv2_)umYc5@#%efP0_cu+=f^}k$H9$N_>piA_(5UM_o{++8+Yf8SJ)?C zDd3l=GGm3EEy;&Z6N=+XP@IM0L=uW^ooyYQYyx1vwFR?@U~BAtAqTu%Mi2 zTCQh$K=UZA{P`Cw0I$xAh_f?fq-Goe`7I38{3L8?K3`lRhSAyB)tHT@4c!Y;bJAAS z3u>Q7qx>9SJs4$EB=hxh)u`W5jp?>^g1s_MV7<1zN zXt{FSt?Mt&8aCy67<)b@eg@h0iCW@%+pF-V>p${fyEk6_Gvp|ms{Whi-9eNId?xzZ zm|MI>F;JSuaUnQp#|}k3o&ddCZEeTI608txuU4~7K(wg9 zg%+}(7h2@(%>LI1F*puF(h$ZD`Q+ar!VoVajPY0-XS$>6F_F?sc6Mr7>SL-&{pC;2 zKx@2{@ULz7RCpaKg$iu2rcY+y*~qaPo0}^7T1K$_(NPS<1;V zTj8-xC%WvgDI_YYEG{bySvyO3M>XKY)oXgGG*eB{yDgNQ3s3)A~@n>!O#lNh0! z(-dqW#_z&mMfq#2+u61N`L^({4UoU8wE5`4c}{SGFzKb(BK8hM%cf_zj_HmC48)M& z398ICVJTGzBaz7K{L+Ew=;z^0xA``wbtPs`r+Wrb^_vzzhukq{;A`t&-ktzb zbqy`Z0#D6fdVAiodjF3J+qI*vu#=OCjiL4bIIXEf4?zmN7(H|+<+WfR7@7jrMx7FY z5*0X1enhay-q^M?j}3Pd^|U9(C3#CQU3=hlc~@y9@NQD{UZNfC^5?Cuuuu{ebn_<7 zEzudv*b@QP%)N^5jP;86nQGb<*SOytCM5wmf-=rH#K{Wd$2(X#S$jF}XIxZC1)zir zU2Wq>hIB44nCTqx2x<{_wiVzLSJR}L%P!Y|lFHtA_=bDj=OqvmmSZ}ffuqPge#V-f zZDk|XX0RK}=73LxL`H%OXxK*^I2!fp&kxatErK~&tM3@j1a(Yrq$z)R()i?}p|0^Y zhW&8!IpRA1jJ3e!p66ZY=eBmEA+$A`!%s+{Cz!s$IA`{_Dh0^jt!vn;+Nw}hx019Q z_Wg=#-G-~&@>l=&H~48$L8`LX)!Bcq%(DFa2Loc91u@WcwlHzJwo{cdur>bQ;{fr_ z`rC5QRQ_)`8EadJzz-{K&sUI~>NX>P|c4l)fKS0gkuGe_P ziaQy!%CK(CtAwj-J8&#kyU=G(k%3y`!gS9dU&1xIrGRL|!&aVMEaezUIpopoET~xE zp`%~`LZfn!Lu^+00?>v4UOfM!HeeQoLZP<#o`^9oi69|$0BM?n17R~tGpY)eJiv@$ zTV-~ZZ*}C1J{a}p`>l$Bx8qRBq91;dLdmp84auzmcd|XzJG%I|r z^E-8Tm~jRn_>as(R=@~z3I2E3<=#hXn>A=0`wfOGIxiP)N2%!cG?&^w=E#TR z`lSY@Mm36zu4p3}+S#67MpL$d{gf@dnP%*ZMW=gCXK-%0E(xAC!^+b7hCSMF$m;Rn zCTErbBK#;a)>kHX5}w6PRmnw(!Gy>m_g*2opfklHyx>eb1bu|_lwJdf!ogxhk}X^v zc+^L;F7ta!8+i%6?M}XvQn4b%aOSCpDW+4#JDDG(wvXC*9%9(XBhbv4LX3R5G&(+@ z)nbdivYRQ5pW;9~@YGf{h~Rm(@MfV8Tj&T@EejO6(C#(+z7FVNBR`@j!#wScHM5ki%j+^GykUJ2m zYgpwm;#Q)~LoozUSV($?r3vQ~#ZU_}ggl~J%z*1dYt_^4K6e7o&qs_ORz{km+D+^a zqDdUO)d}|)v9h(Zz3}#DLWyRVCY!=PMCO{=PA)Upb@)1j?c)||l{6&pI=;U#bS#Jk zOOiwVH3FM!SuJDIPnN$|ZKz5fQwHmzn8f^?B+T2ew%~PSE#X_jk`Wu;a{4}9%AHg7 zZm8^bAee$bdpwklIE`$fV15=pI+tgJpll4uQjIM;Q!gvISFc_{@=lUSc-lABE%U?+ zHW$;!NcH1&F;AS~7RH=n<=!NTKnm3t`B@YeL?8d2{WGrmSjG;yBbY*9$N&DT^e?l2 z|1A2482Or7n7KF_TpRn|nmqD}`-=?QJ0z5q$C9Td^sML&aN7OGi+W$uYjDXKJg+0W@S=FoQP2dBI=48|FH>p2mh zFrdu!AwoG$NkvnZp_KT8HEo=RNNJ4IxucGXLr2N*I5Ao>Efb+pNOm9Zw0_7_s|9ac zS6}W##>$W*cBmksip;43p#a4&iTpM)8(gRGekW+AKm5zb)xpUFT>~b+FOH`Zs!$RDgpSCE z>;CL8Uu|EWeR~TvgDX@K=mtReFed;FZ!M2SjzW35i;UqfyemM?rq5yZS#hK5Y~|wt z2#^`Q6$b~uGT_++C3+B~#(oFHdSL&hh`Z8{t5#=ZkoaWVJoLm)3vT_@5HOnZGa;s~ z;4=E`3Eo@=$BxFjS`Iu|8SALB`<#TPTeE%h(dol+#CzJ=Zb&EHpw*=0H*~8x6 z`G`b<@>L2(AS*J!NVp`DN{g!8R#h(~URslf zC8PwGM$5V}+$WcoT*C~*$WmCpS6Gis&sZo|9OfRiwjX$f*&25Gjv6$YPde1smwGw( zb@y=gbl1!8>hm-il3&~zFca0~aJN!?b97+$E>2$Gn$31OR&UnE=Tm= zH44$Dx2HNN1lrCGjfuwo@+(m2j85w-oxre9FopupEV+6HACFyTbt}s-`lCCJ8om5RIE~T#Yg_DWu1u zyAp%jp;3&%D4;CRaR6g=f*ZvPqw2BadP=*ZYy_~CV3@wFx5YA(E8)jfqx z8tjEkMf>msMqi)zaY2fWrMq`lZzZdiMcluc(@(yxK(4hPEFk0~HO3^CUZk3;?Tv3` ze-rjZ8@hBrVPzA$^4hW?<33{d2)h7Jw?$t%V6(C_m+bNhXl9vXCJcBWmMeQoLDm5b zt9|A5pDHY#Y@(rlEo_WzXila!uaZE*WVc`=IM)SSc`#liZ2Wt*~fHgm9uH^ISX2d@)XGZ)_$qnbx6?J<14_=SS(ITs#LPDk03a&%x;bAuGz=P ze^<4p@tD@J|M;88;~IsEOPpB+&3C4!3q;}Kk2tb*WuuE z2u(BE$1(2AwbbBrmU-YLI4>#K((6&QZ~m2Yp;I14x0N8hos}{uoQuMG)Wy?ogaNayqmc&`I=8y6&dPf{Fky#B7 z#F=Xy213s`NFxjKuMqH3+ibWsFRi=QtH*j$9^)Zy8F|^vSmgj~l5<04MiU;BNyAn) zlM+c20Y#%@>WgdY>5kx}H)7*!D~BZJdg8d5iHx|>(jj=!MEmr)-$kH8?A#;DyBone(uz;e^|=9nIwfuWY?yw; zC|H`;8#O$vTPm5AW1Gg-Up&#Ca$<@!JZkAUDbmd*?X}QSA5$(*c+FZ|l+}F%*L1OH z{ck}P=j@=7>6ga#cqzj|ODXHD>ckIBmOd9Fh=~>?C7$uII_3rEX%UKdywsInR~{t- zg|t`~l=L1P_QPkZN53Q>!^A*QDZ zK(f;%VVQo)n1bsy)LWL#?&|wN`hL~Rnxhd3d-bOvlRQAiybH&=i;SlnwP$3P-!%x3^o)t6aoT-zXU}ARq-l^bOW-zg$@b|19Aua zF+k$V!uO;fNwCUEi;6!|5?4_MKtTq}|C`2gXh8EhWP1bTgZ)DqHZ&-x|E2*6Ka!RZ zS5jsHN&IW7%g1yUln@bn$cO!hR2b+`P~1-3dFIx!6EltRa{a z6Z@Y$_ug)~d%u)K$+?LYfc<87}bupdiK(3|m%hiA$Pc>zKNP0hqBj{X*L0rm@j(0s(f>>t{1L0?w#rS+#E)IdBKcF5|Dq-S zZ*-X3x;NeSuOSxS<3Q%uy1zwQ+?Kj&)Ou~-|2+&J{Zi^T=lx9+&+B^K_lQ;hY2H6D zeZ9T!H&;?$+kt+MLCs%i{8QEVi8<(Pft!mFt`}r~k5Y%93jAjQ!fgoD?Zh|Vi~q5A z27G^+_!lc1Zfo3}625-J{(B@p`IW|R4(!c|yX*Pn?*SA0)3iUGUB11uH>ab1{F$$g z|7q4=O#$9cezU54J)`wKI1_%J{14{0Zj0P3wEcKU`%-=?@(1PW+Zs0qGuI`%??IID dD~*3C;60WFKt@K_BOwYX49GZ$DDV2e{|AYb(KrAA literal 0 HcmV?d00001 diff --git a/ServerlessFunction/gradle/wrapper/gradle-wrapper.properties b/ServerlessFunction/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..23449a2b --- /dev/null +++ b/ServerlessFunction/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/HandlerRouter.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/HandlerRouter.java index 92d6dc1c..1b99130e 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/HandlerRouter.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/router/HandlerRouter.java @@ -22,12 +22,10 @@ * 선언적 라우팅 + 자동 Path/Query 파라미터 검증 제공 *

* 사용 예시: - *

* new HandlerRouter().addRoutes( - * Route.get("/rooms/{roomId}", this::getRoom), // roomId 자동 검증 - * Route.delete("/rooms/{roomId}", this::deleteRoom).requireQueryParams("userId") // roomId + userId 검증 + * Route.get("/rooms/{roomId}", this::getRoom), + * Route.delete("/rooms/{roomId}", this::deleteRoom).requireQueryParams("userId") * ); - * */ public class HandlerRouter { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketBroadcaster.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketBroadcaster.java index f438c239..653e3d1f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketBroadcaster.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketBroadcaster.java @@ -17,7 +17,7 @@ /** * WebSocket 연결들에게 메시지를 브로드캐스트하는 유틸리티 */ -public class WebSocketBroadcaster { +public class WebSocketBroadcaster implements AutoCloseable { private static final Logger logger = LoggerFactory.getLogger(WebSocketBroadcaster.class); @@ -82,4 +82,15 @@ public List broadcast(List connections, String message) { return failedConnections; } + + @Override + public void close() { + try { + if (apiClient != null) { + apiClient.close(); + } + } catch (Exception e) { + logger.warn("Failed to close ApiGatewayManagementApiClient: {}", e.getMessage()); + } + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketMessageHelper.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketMessageHelper.java new file mode 100644 index 00000000..c8fb1a0d --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketMessageHelper.java @@ -0,0 +1,166 @@ +package com.mzc.secondproject.serverless.common.util; + +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +/** + * WebSocket 메시지 생성 헬퍼 + * 모든 메시지에 domain 필드를 포함하여 채팅/게임 구분 지원 + */ +public final class WebSocketMessageHelper { + + public static final String DOMAIN_CHAT = "chat"; + public static final String DOMAIN_GAME = "game"; + public static final String DOMAIN_ROOM = "room"; + + private WebSocketMessageHelper() { + } + + /** + * 기본 메시지 생성 + * + * @param domain 도메인 ("chat" 또는 "game") + * @param messageType 메시지 타입 + * @param data 메시지 데이터 + * @return 메시지 Map + */ + public static Map createMessage(String domain, String messageType, Object data) { + Map message = new HashMap<>(); + message.put("domain", domain); + message.put("messageType", messageType); + message.put("data", data); + message.put("timestamp", System.currentTimeMillis()); + return message; + } + + /** + * 채팅 메시지 생성 + */ + public static Map createChatMessage(String messageType, Object data) { + return createMessage(DOMAIN_CHAT, messageType, data); + } + + /** + * 게임 메시지 생성 + */ + public static Map createGameMessage(String messageType, Object data) { + return createMessage(DOMAIN_GAME, messageType, data); + } + + /** + * 채팅 메시지 빌더 (상세 필드 포함) + */ + public static Map buildChatMessage( + String roomId, + String userId, + String content, + String messageType + ) { + String messageId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + + Map message = new HashMap<>(); + message.put("domain", DOMAIN_CHAT); + message.put("messageType", messageType); + message.put("messageId", messageId); + message.put("roomId", roomId); + message.put("userId", userId); + message.put("content", content); + message.put("createdAt", now); + message.put("timestamp", System.currentTimeMillis()); + return message; + } + + /** + * 게임 메시지 빌더 (상세 필드 포함) + */ + public static Map buildGameMessage( + String roomId, + String messageType, + Map gameData + ) { + String messageId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + long serverTime = System.currentTimeMillis(); + + Map message = new HashMap<>(); + message.put("domain", DOMAIN_GAME); + message.put("messageType", messageType); + message.put("messageId", messageId); + message.put("roomId", roomId); + message.put("userId", "SYSTEM"); + message.put("createdAt", now); + message.put("timestamp", serverTime); + message.put("serverTime", serverTime); + + if (gameData != null) { + message.put("data", gameData); + } + return message; + } + + /** + * 시스템 메시지 생성 (채팅 도메인) + */ + public static Map buildSystemMessage(String roomId, String content, String messageType) { + return buildChatMessage(roomId, "SYSTEM", content, messageType); + } + + /** + * 방 상태 변경 메시지 생성 + * + * @param roomId 방 ID + * @param status 현재 상태 + * @param previousStatus 이전 상태 + * @return 방 상태 변경 메시지 + */ + public static Map buildRoomStatusChangeMessage( + String roomId, + String status, + String previousStatus + ) { + String messageId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + + Map message = new HashMap<>(); + message.put("domain", DOMAIN_ROOM); + message.put("messageType", "room_status_change"); + message.put("messageId", messageId); + message.put("roomId", roomId); + message.put("status", status); + message.put("previousStatus", previousStatus); + message.put("createdAt", now); + message.put("timestamp", System.currentTimeMillis()); + return message; + } + + /** + * 방장 변경 메시지 생성 + * + * @param roomId 방 ID + * @param newHostId 새 방장 ID + * @param newHostNickname 새 방장 닉네임 + * @return 방장 변경 메시지 + */ + public static Map buildHostChangeMessage( + String roomId, + String newHostId, + String newHostNickname + ) { + String messageId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + + Map message = new HashMap<>(); + message.put("domain", DOMAIN_ROOM); + message.put("messageType", "host_change"); + message.put("messageId", messageId); + message.put("roomId", roomId); + message.put("newHostId", newHostId); + message.put("newHostNickname", newHostNickname); + message.put("createdAt", now); + message.put("timestamp", System.currentTimeMillis()); + return message; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketResponseUtil.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketResponseUtil.java deleted file mode 100644 index 6c14e542..00000000 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/WebSocketResponseUtil.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.mzc.secondproject.serverless.common.util; - -import java.util.Map; - -/** - * WebSocket API Gateway 응답 생성 유틸리티 - */ -public final class WebSocketResponseUtil { - - private WebSocketResponseUtil() { - } - - public static Map ok(String message) { - return Map.of("statusCode", 200, "body", message); - } - - public static Map created(String message) { - return Map.of("statusCode", 201, "body", message); - } - - public static Map badRequest(String message) { - return Map.of("statusCode", 400, "body", message); - } - - public static Map unauthorized(String message) { - return Map.of("statusCode", 401, "body", message); - } - - public static Map forbidden(String message) { - return Map.of("statusCode", 403, "body", message); - } - - public static Map serverError(String message) { - return Map.of("statusCode", 500, "body", message); - } - - public static Map response(int statusCode, String message) { - return Map.of("statusCode", statusCode, "body", message); - } -} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/validation/BeanValidator.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/validation/BeanValidator.java index 27c204d4..685ca8b9 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/validation/BeanValidator.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/validation/BeanValidator.java @@ -19,16 +19,13 @@ * DTO에 선언된 @NotNull, @NotEmpty 등의 어노테이션을 검증합니다. *

* 사용 예시: - *

* CreateRoomRequest req = ResponseGenerator.gson().fromJson(body, CreateRoomRequest.class); - *

* return BeanValidator.validate(req) * .map(error -> ResponseGenerator.fail(CommonErrorCode.REQUIRED_FIELD_MISSING, error)) * .orElseGet(() -> { * // 비즈니스 로직 * return ResponseGenerator.ok("Success", result); * }); - * */ public final class BeanValidator { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/handler/BadgeHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/handler/BadgeHandler.java index a60eb7bc..ec4acb70 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/handler/BadgeHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/handler/BadgeHandler.java @@ -23,8 +23,18 @@ public class BadgeHandler implements RequestHandler table; + /** + * 기본 생성자 (Lambda에서 사용) + */ public BadgeRepository() { - this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(UserBadge.class)); + this(AwsClients.dynamoDbEnhanced()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public BadgeRepository(DynamoDbEnhancedClient enhancedClient) { + this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(UserBadge.class)); } public void save(UserBadge badge) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/service/BadgeService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/service/BadgeService.java index ad69394c..0916a5d5 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/service/BadgeService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/service/BadgeService.java @@ -5,6 +5,8 @@ import com.mzc.secondproject.serverless.domain.badge.enums.BadgeType; import com.mzc.secondproject.serverless.domain.badge.model.UserBadge; import com.mzc.secondproject.serverless.domain.badge.repository.BadgeRepository; +import com.mzc.secondproject.serverless.domain.badge.strategy.BadgeConditionStrategy; +import com.mzc.secondproject.serverless.domain.badge.strategy.BadgeConditionStrategyFactory; import com.mzc.secondproject.serverless.domain.stats.model.UserStats; import com.mzc.secondproject.serverless.domain.stats.repository.UserStatsRepository; import org.slf4j.Logger; @@ -23,9 +25,19 @@ public class BadgeService { private final BadgeRepository badgeRepository; private final UserStatsRepository userStatsRepository; + /** + * 기본 생성자 (Lambda에서 사용) + */ public BadgeService() { - this.badgeRepository = new BadgeRepository(); - this.userStatsRepository = new UserStatsRepository(); + this(new BadgeRepository(), new UserStatsRepository()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public BadgeService(BadgeRepository badgeRepository, UserStatsRepository userStatsRepository) { + this.badgeRepository = badgeRepository; + this.userStatsRepository = userStatsRepository; } /** @@ -133,50 +145,15 @@ private UserBadge createBadge(String userId, BadgeType type, String now) { private boolean checkBadgeCondition(BadgeType type, UserStats stats) { if (stats == null) return false; - return switch (type.getCategory()) { - case "FIRST_STUDY" -> stats.getTestsCompleted() != null && stats.getTestsCompleted() >= 1; - case "STREAK" -> stats.getCurrentStreak() != null && stats.getCurrentStreak() >= type.getThreshold(); - case "WORDS_LEARNED" -> { - int total = (stats.getNewWordsLearned() != null ? stats.getNewWordsLearned() : 0) - + (stats.getWordsReviewed() != null ? stats.getWordsReviewed() : 0); - yield total >= type.getThreshold(); - } - case "PERFECT_TEST" -> false; // 별도 로직 필요 (테스트 결과에서 체크) - case "TESTS_COMPLETED" -> - stats.getTestsCompleted() != null && stats.getTestsCompleted() >= type.getThreshold(); - case "ACCURACY" -> { - if (stats.getQuestionsAnswered() == null || stats.getQuestionsAnswered() == 0) yield false; - double accuracy = (stats.getCorrectAnswers() * 100.0) / stats.getQuestionsAnswered(); - yield accuracy >= type.getThreshold(); - } - case "GAMES_PLAYED" -> stats.getGamesPlayed() != null && stats.getGamesPlayed() >= type.getThreshold(); - case "GAMES_WON" -> stats.getGamesWon() != null && stats.getGamesWon() >= type.getThreshold(); - case "QUICK_GUESSES" -> stats.getQuickGuesses() != null && stats.getQuickGuesses() >= type.getThreshold(); - case "PERFECT_DRAWS" -> stats.getPerfectDraws() != null && stats.getPerfectDraws() >= type.getThreshold(); - case "ALL_BADGES" -> false; // 별도 로직 필요 - default -> false; - }; + BadgeConditionStrategy strategy = BadgeConditionStrategyFactory.getStrategy(type.getCategory()); + return strategy.checkCondition(type, stats); } private int calculateProgress(BadgeType type, UserStats stats) { if (stats == null) return 0; - return switch (type.getCategory()) { - case "FIRST_STUDY" -> stats.getTestsCompleted() != null && stats.getTestsCompleted() >= 1 ? 1 : 0; - case "STREAK" -> stats.getCurrentStreak() != null ? stats.getCurrentStreak() : 0; - case "WORDS_LEARNED" -> (stats.getNewWordsLearned() != null ? stats.getNewWordsLearned() : 0) - + (stats.getWordsReviewed() != null ? stats.getWordsReviewed() : 0); - case "TESTS_COMPLETED" -> stats.getTestsCompleted() != null ? stats.getTestsCompleted() : 0; - case "ACCURACY" -> { - if (stats.getQuestionsAnswered() == null || stats.getQuestionsAnswered() == 0) yield 0; - yield (int) ((stats.getCorrectAnswers() * 100.0) / stats.getQuestionsAnswered()); - } - case "GAMES_PLAYED" -> stats.getGamesPlayed() != null ? stats.getGamesPlayed() : 0; - case "GAMES_WON" -> stats.getGamesWon() != null ? stats.getGamesWon() : 0; - case "QUICK_GUESSES" -> stats.getQuickGuesses() != null ? stats.getQuickGuesses() : 0; - case "PERFECT_DRAWS" -> stats.getPerfectDraws() != null ? stats.getPerfectDraws() : 0; - default -> 0; - }; + BadgeConditionStrategy strategy = BadgeConditionStrategyFactory.getStrategy(type.getCategory()); + return strategy.calculateProgress(type, stats); } public record BadgeInfo( diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/AccuracyStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/AccuracyStrategy.java new file mode 100644 index 00000000..398875d0 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/AccuracyStrategy.java @@ -0,0 +1,34 @@ +package com.mzc.secondproject.serverless.domain.badge.strategy; + +import com.mzc.secondproject.serverless.domain.badge.enums.BadgeType; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; + +/** + * 정확도 뱃지 조건 전략 + */ +public class AccuracyStrategy implements BadgeConditionStrategy { + + @Override + public boolean checkCondition(BadgeType type, UserStats stats) { + double accuracy = calculateAccuracy(stats); + return accuracy >= type.getThreshold(); + } + + @Override + public int calculateProgress(BadgeType type, UserStats stats) { + return (int) calculateAccuracy(stats); + } + + @Override + public String getCategory() { + return "ACCURACY"; + } + + private double calculateAccuracy(UserStats stats) { + if (stats.getQuestionsAnswered() == null || stats.getQuestionsAnswered() == 0) { + return 0.0; + } + int correct = stats.getCorrectAnswers() != null ? stats.getCorrectAnswers() : 0; + return (correct * 100.0) / stats.getQuestionsAnswered(); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/BadgeConditionStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/BadgeConditionStrategy.java new file mode 100644 index 00000000..03cf274f --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/BadgeConditionStrategy.java @@ -0,0 +1,25 @@ +package com.mzc.secondproject.serverless.domain.badge.strategy; + +import com.mzc.secondproject.serverless.domain.badge.enums.BadgeType; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; + +/** + * 뱃지 조건 확인 전략 인터페이스 + */ +public interface BadgeConditionStrategy { + + /** + * 뱃지 획득 조건 확인 + */ + boolean checkCondition(BadgeType type, UserStats stats); + + /** + * 현재 진행도 계산 + */ + int calculateProgress(BadgeType type, UserStats stats); + + /** + * 지원하는 카테고리 + */ + String getCategory(); +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/BadgeConditionStrategyFactory.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/BadgeConditionStrategyFactory.java new file mode 100644 index 00000000..01f6ed33 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/BadgeConditionStrategyFactory.java @@ -0,0 +1,40 @@ +package com.mzc.secondproject.serverless.domain.badge.strategy; + +import java.util.HashMap; +import java.util.Map; + +/** + * 뱃지 조건 전략 팩토리 + * 카테고리별 전략 인스턴스를 관리하고 제공 + */ +public class BadgeConditionStrategyFactory { + + private static final Map STRATEGIES = new HashMap<>(); + private static final BadgeConditionStrategy DEFAULT_STRATEGY = new NoOpStrategy("DEFAULT"); + + static { + register(new FirstStudyStrategy()); + register(new StreakStrategy()); + register(new WordsLearnedStrategy()); + register(new TestsCompletedStrategy()); + register(new AccuracyStrategy()); + register(new GamesPlayedStrategy()); + register(new GamesWonStrategy()); + register(new QuickGuessesStrategy()); + register(new PerfectDrawsStrategy()); + // 별도 로직이 필요한 카테고리 + register(new NoOpStrategy("PERFECT_TEST")); + register(new NoOpStrategy("ALL_BADGES")); + } + + private static void register(BadgeConditionStrategy strategy) { + STRATEGIES.put(strategy.getCategory(), strategy); + } + + /** + * 카테고리에 해당하는 전략 반환 + */ + public static BadgeConditionStrategy getStrategy(String category) { + return STRATEGIES.getOrDefault(category, DEFAULT_STRATEGY); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/FirstStudyStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/FirstStudyStrategy.java new file mode 100644 index 00000000..44d5e33a --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/FirstStudyStrategy.java @@ -0,0 +1,25 @@ +package com.mzc.secondproject.serverless.domain.badge.strategy; + +import com.mzc.secondproject.serverless.domain.badge.enums.BadgeType; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; + +/** + * 첫 학습 뱃지 조건 전략 + */ +public class FirstStudyStrategy implements BadgeConditionStrategy { + + @Override + public boolean checkCondition(BadgeType type, UserStats stats) { + return stats.getTestsCompleted() != null && stats.getTestsCompleted() >= 1; + } + + @Override + public int calculateProgress(BadgeType type, UserStats stats) { + return (stats.getTestsCompleted() != null && stats.getTestsCompleted() >= 1) ? 1 : 0; + } + + @Override + public String getCategory() { + return "FIRST_STUDY"; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/GamesPlayedStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/GamesPlayedStrategy.java new file mode 100644 index 00000000..ade34c7a --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/GamesPlayedStrategy.java @@ -0,0 +1,25 @@ +package com.mzc.secondproject.serverless.domain.badge.strategy; + +import com.mzc.secondproject.serverless.domain.badge.enums.BadgeType; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; + +/** + * 게임 플레이 횟수 뱃지 조건 전략 + */ +public class GamesPlayedStrategy implements BadgeConditionStrategy { + + @Override + public boolean checkCondition(BadgeType type, UserStats stats) { + return stats.getGamesPlayed() != null && stats.getGamesPlayed() >= type.getThreshold(); + } + + @Override + public int calculateProgress(BadgeType type, UserStats stats) { + return stats.getGamesPlayed() != null ? stats.getGamesPlayed() : 0; + } + + @Override + public String getCategory() { + return "GAMES_PLAYED"; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/GamesWonStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/GamesWonStrategy.java new file mode 100644 index 00000000..d0c810a8 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/GamesWonStrategy.java @@ -0,0 +1,25 @@ +package com.mzc.secondproject.serverless.domain.badge.strategy; + +import com.mzc.secondproject.serverless.domain.badge.enums.BadgeType; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; + +/** + * 게임 승리 횟수 뱃지 조건 전략 + */ +public class GamesWonStrategy implements BadgeConditionStrategy { + + @Override + public boolean checkCondition(BadgeType type, UserStats stats) { + return stats.getGamesWon() != null && stats.getGamesWon() >= type.getThreshold(); + } + + @Override + public int calculateProgress(BadgeType type, UserStats stats) { + return stats.getGamesWon() != null ? stats.getGamesWon() : 0; + } + + @Override + public String getCategory() { + return "GAMES_WON"; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NoOpStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NoOpStrategy.java new file mode 100644 index 00000000..0c35d127 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NoOpStrategy.java @@ -0,0 +1,32 @@ +package com.mzc.secondproject.serverless.domain.badge.strategy; + +import com.mzc.secondproject.serverless.domain.badge.enums.BadgeType; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; + +/** + * 별도 로직이 필요한 뱃지용 No-Op 전략 + * PERFECT_TEST, ALL_BADGES 등은 별도 로직에서 처리 + */ +public class NoOpStrategy implements BadgeConditionStrategy { + + private final String category; + + public NoOpStrategy(String category) { + this.category = category; + } + + @Override + public boolean checkCondition(BadgeType type, UserStats stats) { + return false; + } + + @Override + public int calculateProgress(BadgeType type, UserStats stats) { + return 0; + } + + @Override + public String getCategory() { + return category; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/PerfectDrawsStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/PerfectDrawsStrategy.java new file mode 100644 index 00000000..abe06a48 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/PerfectDrawsStrategy.java @@ -0,0 +1,25 @@ +package com.mzc.secondproject.serverless.domain.badge.strategy; + +import com.mzc.secondproject.serverless.domain.badge.enums.BadgeType; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; + +/** + * 완벽한 출제 뱃지 조건 전략 + */ +public class PerfectDrawsStrategy implements BadgeConditionStrategy { + + @Override + public boolean checkCondition(BadgeType type, UserStats stats) { + return stats.getPerfectDraws() != null && stats.getPerfectDraws() >= type.getThreshold(); + } + + @Override + public int calculateProgress(BadgeType type, UserStats stats) { + return stats.getPerfectDraws() != null ? stats.getPerfectDraws() : 0; + } + + @Override + public String getCategory() { + return "PERFECT_DRAWS"; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/QuickGuessesStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/QuickGuessesStrategy.java new file mode 100644 index 00000000..d276bec4 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/QuickGuessesStrategy.java @@ -0,0 +1,25 @@ +package com.mzc.secondproject.serverless.domain.badge.strategy; + +import com.mzc.secondproject.serverless.domain.badge.enums.BadgeType; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; + +/** + * 빠른 정답 뱃지 조건 전략 + */ +public class QuickGuessesStrategy implements BadgeConditionStrategy { + + @Override + public boolean checkCondition(BadgeType type, UserStats stats) { + return stats.getQuickGuesses() != null && stats.getQuickGuesses() >= type.getThreshold(); + } + + @Override + public int calculateProgress(BadgeType type, UserStats stats) { + return stats.getQuickGuesses() != null ? stats.getQuickGuesses() : 0; + } + + @Override + public String getCategory() { + return "QUICK_GUESSES"; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/StreakStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/StreakStrategy.java new file mode 100644 index 00000000..5db1fac2 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/StreakStrategy.java @@ -0,0 +1,25 @@ +package com.mzc.secondproject.serverless.domain.badge.strategy; + +import com.mzc.secondproject.serverless.domain.badge.enums.BadgeType; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; + +/** + * 연속 학습 뱃지 조건 전략 + */ +public class StreakStrategy implements BadgeConditionStrategy { + + @Override + public boolean checkCondition(BadgeType type, UserStats stats) { + return stats.getCurrentStreak() != null && stats.getCurrentStreak() >= type.getThreshold(); + } + + @Override + public int calculateProgress(BadgeType type, UserStats stats) { + return stats.getCurrentStreak() != null ? stats.getCurrentStreak() : 0; + } + + @Override + public String getCategory() { + return "STREAK"; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/TestsCompletedStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/TestsCompletedStrategy.java new file mode 100644 index 00000000..0be7d097 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/TestsCompletedStrategy.java @@ -0,0 +1,25 @@ +package com.mzc.secondproject.serverless.domain.badge.strategy; + +import com.mzc.secondproject.serverless.domain.badge.enums.BadgeType; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; + +/** + * 테스트 완료 횟수 뱃지 조건 전략 + */ +public class TestsCompletedStrategy implements BadgeConditionStrategy { + + @Override + public boolean checkCondition(BadgeType type, UserStats stats) { + return stats.getTestsCompleted() != null && stats.getTestsCompleted() >= type.getThreshold(); + } + + @Override + public int calculateProgress(BadgeType type, UserStats stats) { + return stats.getTestsCompleted() != null ? stats.getTestsCompleted() : 0; + } + + @Override + public String getCategory() { + return "TESTS_COMPLETED"; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/WordsLearnedStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/WordsLearnedStrategy.java new file mode 100644 index 00000000..cff5c410 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/WordsLearnedStrategy.java @@ -0,0 +1,32 @@ +package com.mzc.secondproject.serverless.domain.badge.strategy; + +import com.mzc.secondproject.serverless.domain.badge.enums.BadgeType; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; + +/** + * 단어 학습량 뱃지 조건 전략 + */ +public class WordsLearnedStrategy implements BadgeConditionStrategy { + + @Override + public boolean checkCondition(BadgeType type, UserStats stats) { + int total = getTotalWordsLearned(stats); + return total >= type.getThreshold(); + } + + @Override + public int calculateProgress(BadgeType type, UserStats stats) { + return getTotalWordsLearned(stats); + } + + @Override + public String getCategory() { + return "WORDS_LEARNED"; + } + + private int getTotalWordsLearned(UserStats stats) { + int newWords = stats.getNewWordsLearned() != null ? stats.getNewWordsLearned() : 0; + int reviewedWords = stats.getWordsReviewed() != null ? stats.getWordsReviewed() : 0; + return newWords + reviewedWords; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/config/GameConfig.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/config/GameConfig.java index 26e72064..1e3ee370 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/config/GameConfig.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/config/GameConfig.java @@ -11,10 +11,12 @@ public final class GameConfig { private static final int DEFAULT_TOTAL_ROUNDS = 5; private static final int DEFAULT_ROUND_TIME_LIMIT = 60; private static final long DEFAULT_QUICK_GUESS_THRESHOLD_MS = 5000L; + private static final int DEFAULT_GAME_TIME_LIMIT = 420; // 7분 (420초) private static final int TOTAL_ROUNDS = EnvConfig.getIntOrDefault("GAME_TOTAL_ROUNDS", DEFAULT_TOTAL_ROUNDS); private static final int ROUND_TIME_LIMIT = EnvConfig.getIntOrDefault("GAME_ROUND_TIME_LIMIT", DEFAULT_ROUND_TIME_LIMIT); private static final long QUICK_GUESS_THRESHOLD_MS = EnvConfig.getLongOrDefault("GAME_QUICK_GUESS_THRESHOLD_MS", DEFAULT_QUICK_GUESS_THRESHOLD_MS); + private static final int GAME_TIME_LIMIT = EnvConfig.getIntOrDefault("GAME_TIME_LIMIT_SECONDS", DEFAULT_GAME_TIME_LIMIT); private GameConfig() { } @@ -30,4 +32,12 @@ public static int roundTimeLimit() { public static long quickGuessThresholdMs() { return QUICK_GUESS_THRESHOLD_MS; } + + /** + * 게임 전체 시간 제한 (초) + * 기본값: 420초 (7분) + */ + public static int gameTimeLimit() { + return GAME_TIME_LIMIT; + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/CreateRoomRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/CreateRoomRequest.java index 255d6fc1..e5a888ba 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/CreateRoomRequest.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/request/CreateRoomRequest.java @@ -1,5 +1,6 @@ package com.mzc.secondproject.serverless.domain.chatting.dto.request; +import com.mzc.secondproject.serverless.domain.chatting.model.GameSettings; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; @@ -34,4 +35,11 @@ public class CreateRoomRequest { private Boolean isPrivate = false; private String password; + + @Builder.Default + private String type = "CHAT"; // CHAT or GAME + + private String gameType; // CATCHMIND (nullable) + + private GameSettings gameSettings; // 게임 설정 (nullable) } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/GameStatusResponse.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/GameStatusResponse.java index 5676a93a..9a66e2d1 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/GameStatusResponse.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/GameStatusResponse.java @@ -1,6 +1,6 @@ package com.mzc.secondproject.serverless.domain.chatting.dto.response; -import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; +import com.mzc.secondproject.serverless.domain.chatting.model.GameSession; import java.util.List; import java.util.Map; @@ -14,24 +14,24 @@ public record GameStatusResponse( Integer totalRounds, String currentDrawerId, Long roundStartTime, - Integer roundTimeLimit, + Integer roundDuration, List drawerOrder, Map scores, Boolean hintUsed, List correctGuessers ) { - public static GameStatusResponse from(ChatRoom room, List drawerOrder) { + public static GameStatusResponse from(GameSession session) { return new GameStatusResponse( - room.getGameStatus(), - room.getCurrentRound(), - room.getTotalRounds(), - room.getCurrentDrawerId(), - room.getRoundStartTime(), - room.getRoundTimeLimit(), - drawerOrder != null ? drawerOrder : room.getDrawerOrder(), - room.getScores(), - room.getHintUsed(), - room.getCorrectGuessers() + session.getStatus(), + session.getCurrentRound(), + session.getTotalRounds(), + session.getCurrentDrawerId(), + session.getRoundStartTime(), + session.getRoundDuration(), + session.getDrawerOrder(), + session.getScores(), + session.getHintUsed(), + session.getCorrectGuessers() ); } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/RoomListItem.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/RoomListItem.java new file mode 100644 index 00000000..d3b6f16a --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/RoomListItem.java @@ -0,0 +1,71 @@ +package com.mzc.secondproject.serverless.domain.chatting.dto.response; + +import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; +import com.mzc.secondproject.serverless.domain.chatting.model.GameSettings; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 방 목록 조회 시 사용되는 응답 DTO + * ChatRoom + hostNickname 포함 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class RoomListItem { + private String roomId; + private String name; + private String description; + private String level; + private Integer currentMembers; + private Integer maxMembers; + private Boolean isPrivate; + private String createdBy; + private String createdAt; + private String lastMessageAt; + private String type; + private String gameType; + private GameSettings gameSettings; + private String status; + private String hostId; + private String hostNickname; + private List participants; + + /** + * ChatRoom과 hostNickname으로 RoomListItem 생성 + */ + public static RoomListItem from(ChatRoom room, String hostNickname) { + return RoomListItem.builder() + .roomId(room.getRoomId()) + .name(room.getName()) + .description(room.getDescription()) + .level(room.getLevel()) + .currentMembers(room.getCurrentMembers()) + .maxMembers(room.getMaxMembers()) + .isPrivate(room.getIsPrivate()) + .createdBy(room.getCreatedBy()) + .createdAt(room.getCreatedAt()) + .lastMessageAt(room.getLastMessageAt()) + .type(room.getType()) + .gameType(room.getGameType()) + .gameSettings(room.getGameSettings()) + .status(room.getStatus()) + .hostId(room.getHostId()) + .hostNickname(hostNickname) + .build(); + } + + /** + * ChatRoom, hostNickname, participants로 RoomListItem 생성 + */ + public static RoomListItem from(ChatRoom room, String hostNickname, List participants) { + RoomListItem item = from(room, hostNickname); + item.setParticipants(participants); + return item; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/RoomParticipant.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/RoomParticipant.java new file mode 100644 index 00000000..146fa3dc --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/RoomParticipant.java @@ -0,0 +1,16 @@ +package com.mzc.secondproject.serverless.domain.chatting.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class RoomParticipant { + private String userId; + private String nickname; + private Boolean isHost; +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/ScoreUpdateMessage.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/ScoreUpdateMessage.java index 6f9ad110..37edba8f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/ScoreUpdateMessage.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/ScoreUpdateMessage.java @@ -16,6 +16,7 @@ @NoArgsConstructor @AllArgsConstructor public class ScoreUpdateMessage { + private String domain; private String messageType; private String roomId; private String scorerId; @@ -31,6 +32,7 @@ public static ScoreUpdateMessage from(String roomId, String scorerId, int scoreG List ranking = buildRanking(scores); return ScoreUpdateMessage.builder() + .domain("game") .messageType("SCORE_UPDATE") .roomId(roomId) .scorerId(scorerId) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/ScoreboardResponse.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/ScoreboardResponse.java index 6cf2bdce..72e46508 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/ScoreboardResponse.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/dto/response/ScoreboardResponse.java @@ -1,6 +1,6 @@ package com.mzc.secondproject.serverless.domain.chatting.dto.response; -import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; +import com.mzc.secondproject.serverless.domain.chatting.model.GameSession; import java.util.List; import java.util.Map; @@ -15,16 +15,16 @@ public record ScoreboardResponse( Integer currentRound, Integer totalRounds ) { - public static ScoreboardResponse from(ChatRoom room) { - Map scores = room.getScores(); + public static ScoreboardResponse from(GameSession session) { + Map scores = session.getScores(); List ranking = buildRanking(scores); return new ScoreboardResponse( scores, ranking, - room.getGameStatus(), - room.getCurrentRound(), - room.getTotalRounds() + session.getStatus(), + session.getCurrentRound(), + session.getTotalRounds() ); } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/MessageType.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/MessageType.java index 28627177..b8a7d453 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/MessageType.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/MessageType.java @@ -18,7 +18,11 @@ public enum MessageType { CORRECT_ANSWER("correct_answer", "정답"), SCORE_UPDATE("score_update", "점수 업데이트"), SYSTEM_COMMAND("system_command", "시스템 명령"), - HINT("hint", "힌트"); + HINT("hint", "힌트"), + + // 방 관련 메시지 타입 + ROOM_STATUS_CHANGE("room_status_change", "방 상태 변경"), + HOST_CHANGE("host_change", "방장 변경"); private final String code; private final String displayName; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomStatus.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomStatus.java new file mode 100644 index 00000000..b71acda0 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomStatus.java @@ -0,0 +1,39 @@ +package com.mzc.secondproject.serverless.domain.chatting.enums; + +import java.util.Arrays; + +public enum RoomStatus { + WAITING("waiting", "대기 중"), + PLAYING("playing", "게임 중"), + FINISHED("finished", "종료됨"); + + private final String code; + private final String displayName; + + RoomStatus(String code, String displayName) { + this.code = code; + this.displayName = displayName; + } + + public static boolean isValid(String value) { + if (value == null) return false; + return Arrays.stream(values()) + .anyMatch(status -> status.name().equalsIgnoreCase(value) || status.code.equalsIgnoreCase(value)); + } + + public static RoomStatus fromString(String value) { + if (value == null) return WAITING; + return Arrays.stream(values()) + .filter(status -> status.name().equalsIgnoreCase(value) || status.code.equalsIgnoreCase(value)) + .findFirst() + .orElse(WAITING); + } + + public String getCode() { + return code; + } + + public String getDisplayName() { + return displayName; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomType.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomType.java new file mode 100644 index 00000000..4af73fd2 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomType.java @@ -0,0 +1,38 @@ +package com.mzc.secondproject.serverless.domain.chatting.enums; + +import java.util.Arrays; + +public enum RoomType { + CHAT("chat", "채팅방"), + GAME("game", "게임방"); + + private final String code; + private final String displayName; + + RoomType(String code, String displayName) { + this.code = code; + this.displayName = displayName; + } + + public static boolean isValid(String value) { + if (value == null) return false; + return Arrays.stream(values()) + .anyMatch(type -> type.name().equalsIgnoreCase(value) || type.code.equalsIgnoreCase(value)); + } + + public static RoomType fromString(String value) { + if (value == null) return CHAT; + return Arrays.stream(values()) + .filter(type -> type.name().equalsIgnoreCase(value) || type.code.equalsIgnoreCase(value)) + .findFirst() + .orElse(CHAT); + } + + public String getCode() { + return code; + } + + public String getDisplayName() { + return displayName; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCode.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCode.java index 9b0e5509..ad599b53 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCode.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCode.java @@ -40,6 +40,10 @@ public enum ChattingErrorCode implements DomainErrorCode { GAME_NOT_IN_PROGRESS("GAME_003", "진행 중인 게임이 없습니다", 400), GAME_ALREADY_IN_PROGRESS("GAME_004", "이미 게임이 진행 중입니다", 409), NOT_GAME_STARTER("GAME_005", "게임 시작자만 중단할 수 있습니다", 403), + GAME_NOT_FOUND("GAME_006", "게임 세션을 찾을 수 없습니다", 404), + GAME_NOT_ALLOWED_IN_CHAT_ROOM("GAME_007", "게임은 게임 방에서만 시작할 수 있습니다", 400), + GAME_RESTART_NOT_ALLOWED("GAME_008", "게임 진행 중에는 재시작할 수 없습니다", 400), + GAME_START_NOT_HOST("GAME_009", "방장만 게임을 시작할 수 있습니다", 403), ; private static final String DOMAIN = "CHATTING"; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatMessageHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatMessageHandler.java index 9d712f5f..b156feba 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatMessageHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatMessageHandler.java @@ -31,9 +31,19 @@ public class ChatMessageHandler implements RequestHandler roomPage = queryService.getRooms(level, limit, cursor); + PaginatedResult roomPage = queryService.getRooms(level, limit, cursor, type, gameType, status); List rooms = roomPage.items(); if ("true".equals(joined)) { rooms = queryService.filterByJoinedUser(rooms, userId); } - rooms.forEach(room -> room.setPassword(null)); + // hostNickname 포함하여 RoomListItem으로 변환 + List roomItems = rooms.stream() + .map(room -> { + String hostNickname = queryService.getHostNickname(room); + return RoomListItem.from(room, hostNickname); + }) + .toList(); Map result = new HashMap<>(); - result.put("rooms", rooms); + result.put("rooms", roomItems); result.put("nextCursor", roomPage.nextCursor()); result.put("hasMore", roomPage.hasMore()); @@ -111,7 +136,16 @@ private APIGatewayProxyResponseEvent getRoom(APIGatewayProxyRequestEvent request ChatRoom room = optRoom.get(); room.setPassword(null); - return ResponseGenerator.ok("Room retrieved", room); + // 참가자 정보와 방장 닉네임 추가 + List participants = queryService.getParticipantsWithNicknames(room); + String hostNickname = queryService.getHostNickname(room); + + Map result = new HashMap<>(); + result.put("room", room); + result.put("participants", participants); + result.put("hostNickname", hostNickname); + + return ResponseGenerator.ok("Room retrieved", result); } private APIGatewayProxyResponseEvent joinRoom(APIGatewayProxyRequestEvent request, String userId) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatVoiceHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatVoiceHandler.java index 7ac5b102..f37b4d48 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatVoiceHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/ChatVoiceHandler.java @@ -28,9 +28,19 @@ public class ChatVoiceHandler implements RequestHandler, String> { + + private static final Logger logger = LoggerFactory.getLogger(GameAutoCloseHandler.class); + + private final GameService gameService; + private final ConnectionRepository connectionRepository; + private final WebSocketBroadcaster broadcaster; + + /** + * 기본 생성자 (Lambda에서 사용) + */ + public GameAutoCloseHandler() { + this(new GameService(), new ConnectionRepository(), new WebSocketBroadcaster()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public GameAutoCloseHandler(GameService gameService, ConnectionRepository connectionRepository, + WebSocketBroadcaster broadcaster) { + this.gameService = gameService; + this.connectionRepository = connectionRepository; + this.broadcaster = broadcaster; + } + + @Override + public String handleRequest(Map event, Context context) { + String gameSessionId = event.get("gameSessionId"); + String roomId = event.get("roomId"); + + logger.info("Game auto-close triggered: gameSessionId={}, roomId={}", gameSessionId, roomId); + + if (gameSessionId == null || roomId == null) { + logger.error("Missing required parameters: gameSessionId={}, roomId={}", gameSessionId, roomId); + return "FAILED: Missing parameters"; + } + + try { + // 게임 종료 처리 + CommandResult result = gameService.finishGameByTimeout(gameSessionId); + + if (result.success()) { + // WebSocket으로 게임 종료 알림 브로드캐스트 + broadcastGameEnd(roomId, result.message()); + logger.info("Game auto-closed successfully: gameSessionId={}", gameSessionId); + return "SUCCESS: Game auto-closed"; + } else { + logger.info("Game auto-close skipped: gameSessionId={}, reason={}", gameSessionId, result.message()); + return "SKIPPED: " + result.message(); + } + + } catch (Exception e) { + logger.error("Game auto-close failed: gameSessionId={}, error={}", gameSessionId, e.getMessage(), e); + return "FAILED: " + e.getMessage(); + } + } + + /** + * 게임 종료 메시지 브로드캐스트 + */ + private void broadcastGameEnd(String roomId, String message) { + String messageId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + + Map gameEndMessage = new HashMap<>(); + gameEndMessage.put("domain", WebSocketMessageHelper.DOMAIN_GAME); + gameEndMessage.put("messageId", messageId); + gameEndMessage.put("roomId", roomId); + gameEndMessage.put("userId", "SYSTEM"); + gameEndMessage.put("content", "⏰ 시간 초과! " + message); + gameEndMessage.put("messageType", MessageType.GAME_END.getCode()); + gameEndMessage.put("createdAt", now); + gameEndMessage.put("timestamp", System.currentTimeMillis()); + gameEndMessage.put("reason", "TIME_EXPIRED"); + + List connections = connectionRepository.findByRoomId(roomId); + String broadcastPayload = ResponseGenerator.gson().toJson(gameEndMessage); + broadcaster.broadcast(connections, broadcastPayload); + + logger.info("Game end broadcasted: roomId={}, connections={}", roomId, connections.size()); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameHandler.java index 56410132..a4caaada 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameHandler.java @@ -8,15 +8,15 @@ import com.mzc.secondproject.serverless.common.router.Route; import com.mzc.secondproject.serverless.common.util.ResponseGenerator; import com.mzc.secondproject.serverless.common.util.WebSocketBroadcaster; +import com.mzc.secondproject.serverless.common.util.WebSocketMessageHelper; import com.mzc.secondproject.serverless.domain.chatting.dto.response.CommandResult; -import com.mzc.secondproject.serverless.domain.chatting.dto.response.GameStatusResponse; import com.mzc.secondproject.serverless.domain.chatting.dto.response.ScoreboardResponse; import com.mzc.secondproject.serverless.domain.chatting.enums.MessageType; import com.mzc.secondproject.serverless.domain.chatting.exception.ChattingErrorCode; -import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; import com.mzc.secondproject.serverless.domain.chatting.model.Connection; -import com.mzc.secondproject.serverless.domain.chatting.repository.ChatRoomRepository; +import com.mzc.secondproject.serverless.domain.chatting.model.GameSession; import com.mzc.secondproject.serverless.domain.chatting.repository.ConnectionRepository; +import com.mzc.secondproject.serverless.domain.chatting.repository.GameSessionRepository; import com.mzc.secondproject.serverless.domain.chatting.service.GameService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -32,16 +32,27 @@ public class GameHandler implements RequestHandler response = buildGameStatusResponse(result.session(), userId); return ResponseGenerator.ok("Game started", response); } @@ -97,46 +110,102 @@ private APIGatewayProxyResponseEvent stopGame(APIGatewayProxyRequestEvent reques return ResponseGenerator.ok("Game stopped", Map.of("message", result.message())); } + /** + * POST /rooms/{roomId}/game/restart - 게임 재시작 + */ + private APIGatewayProxyResponseEvent restartGame(APIGatewayProxyRequestEvent request, String userId) { + String roomId = request.getPathParameters().get("roomId"); + + GameService.GameStartResult result = gameService.restartGame(roomId, userId); + + if (!result.success()) { + return ResponseGenerator.fail(ChattingErrorCode.GAME_START_FAILED, result.error()); + } + + // WebSocket으로 게임 시작 알림 브로드캐스트 (출제자에게 currentWord 포함) + broadcastGameStart(roomId, result); + + // REST 응답에도 출제자에게 currentWord 포함 + Map response = buildGameStatusResponse(result.session(), userId); + return ResponseGenerator.ok("Game restarted", response); + } + /** * GET /rooms/{roomId}/game/status - 게임 상태 조회 */ private APIGatewayProxyResponseEvent getGameStatus(APIGatewayProxyRequestEvent request, String userId) { String roomId = request.getPathParameters().get("roomId"); - Optional optRoom = chatRoomRepository.findById(roomId); - if (optRoom.isEmpty()) { - return ResponseGenerator.fail(ChattingErrorCode.ROOM_NOT_FOUND); + Optional optSession = gameSessionRepository.findActiveByRoomId(roomId); + if (optSession.isEmpty()) { + // 게임이 없는 경우 빈 상태 반환 + return ResponseGenerator.ok("No active game", Map.of("gameStatus", "NONE")); } - ChatRoom room = optRoom.get(); - GameStatusResponse response = GameStatusResponse.from(room, room.getDrawerOrder()); + GameSession session = optSession.get(); + + // 출제자에게만 currentWord 포함 + Map response = buildGameStatusResponse(session, userId); return ResponseGenerator.ok("Game status retrieved", response); } + /** + * 게임 상태 응답 빌드 (출제자에게만 currentWord 포함) + */ + private Map buildGameStatusResponse(GameSession session, String userId) { + Map response = new LinkedHashMap<>(); + response.put("gameStatus", session.getStatus()); + response.put("currentRound", session.getCurrentRound()); + response.put("totalRounds", session.getTotalRounds()); + response.put("currentDrawerId", session.getCurrentDrawerId()); + response.put("roundStartTime", session.getRoundStartTime()); + response.put("serverTime", System.currentTimeMillis()); + response.put("roundDuration", session.getRoundDuration()); + response.put("drawerOrder", session.getDrawerOrder()); + response.put("scores", session.getScores() != null ? session.getScores() : Map.of()); + response.put("hintUsed", session.getHintUsed()); + response.put("correctGuessers", session.getCorrectGuessers()); + + // 출제자에게만 현재 단어 포함 + if (userId != null && userId.equals(session.getCurrentDrawerId())) { + Map currentWord = new HashMap<>(); + currentWord.put("wordId", session.getCurrentWordId()); + currentWord.put("word", session.getCurrentWord()); + response.put("currentWord", currentWord); + } + + return response; + } + /** * GET /rooms/{roomId}/game/scores - 점수 조회 */ private APIGatewayProxyResponseEvent getScores(APIGatewayProxyRequestEvent request, String userId) { String roomId = request.getPathParameters().get("roomId"); - Optional optRoom = chatRoomRepository.findById(roomId); - if (optRoom.isEmpty()) { - return ResponseGenerator.fail(ChattingErrorCode.ROOM_NOT_FOUND); + Optional optSession = gameSessionRepository.findActiveByRoomId(roomId); + if (optSession.isEmpty()) { + return ResponseGenerator.ok("No active game", Map.of("scores", Map.of())); } - ChatRoom room = optRoom.get(); - ScoreboardResponse response = ScoreboardResponse.from(room); + GameSession session = optSession.get(); + ScoreboardResponse response = ScoreboardResponse.from(session); return ResponseGenerator.ok("Scores retrieved", response); } /** * 게임 시작 브로드캐스트 + * 모든 사용자에게 게임 시작 메시지 전송, 출제자에게는 currentWord 포함 */ private void broadcastGameStart(String roomId, GameService.GameStartResult result) { String messageId = UUID.randomUUID().toString(); String now = Instant.now().toString(); + long serverTime = System.currentTimeMillis(); + + GameSession session = result.session(); + String drawerId = session.getCurrentDrawerId(); String message = String.format(""" 🎮 게임 시작! @@ -145,27 +214,47 @@ private void broadcastGameStart(String roomId, GameService.GameStartResult resul 라운드 1 시작! 출제자: %s """, - result.room().getTotalRounds(), - result.room().getCurrentDrawerId()); + session.getTotalRounds(), + drawerId); + // 기본 게임 시작 메시지 (모든 사용자용) Map gameStartMessage = new HashMap<>(); + gameStartMessage.put("domain", WebSocketMessageHelper.DOMAIN_GAME); gameStartMessage.put("messageId", messageId); gameStartMessage.put("roomId", roomId); gameStartMessage.put("userId", "SYSTEM"); gameStartMessage.put("content", message); gameStartMessage.put("messageType", MessageType.GAME_START.getCode()); gameStartMessage.put("createdAt", now); - gameStartMessage.put("gameStatus", result.room().getGameStatus()); - gameStartMessage.put("currentRound", result.room().getCurrentRound()); - gameStartMessage.put("totalRounds", result.room().getTotalRounds()); - gameStartMessage.put("currentDrawerId", result.room().getCurrentDrawerId()); + gameStartMessage.put("timestamp", serverTime); + gameStartMessage.put("gameStatus", session.getStatus()); + gameStartMessage.put("currentRound", session.getCurrentRound()); + gameStartMessage.put("totalRounds", session.getTotalRounds()); + gameStartMessage.put("currentDrawerId", drawerId); gameStartMessage.put("drawerOrder", result.drawerOrder()); + gameStartMessage.put("roundStartTime", session.getRoundStartTime()); + gameStartMessage.put("serverTime", serverTime); + gameStartMessage.put("roundDuration", session.getRoundDuration()); List connections = connectionRepository.findByRoomId(roomId); + + // 출제자용 메시지 (currentWord 포함) + Map drawerMessage = new HashMap<>(gameStartMessage); + Map currentWord = new HashMap<>(); + currentWord.put("wordId", session.getCurrentWordId()); + currentWord.put("word", session.getCurrentWord()); + drawerMessage.put("currentWord", currentWord); + String broadcastPayload = ResponseGenerator.gson().toJson(gameStartMessage); - broadcaster.broadcast(connections, broadcastPayload); + String drawerPayload = ResponseGenerator.gson().toJson(drawerMessage); + + // 출제자와 일반 사용자에게 다른 메시지 전송 + for (Connection conn : connections) { + String payload = conn.getUserId().equals(drawerId) ? drawerPayload : broadcastPayload; + broadcaster.sendToConnection(conn.getConnectionId(), payload); + } - logger.info("Game start broadcasted: roomId={}", roomId); + logger.info("Game start broadcasted: roomId={}, drawerId={}", roomId, drawerId); } /** @@ -176,12 +265,14 @@ private void broadcastSystemMessage(String roomId, String message, MessageType m String now = Instant.now().toString(); Map systemMessage = new HashMap<>(); + systemMessage.put("domain", WebSocketMessageHelper.DOMAIN_GAME); systemMessage.put("messageId", messageId); systemMessage.put("roomId", roomId); systemMessage.put("userId", "SYSTEM"); systemMessage.put("content", message); systemMessage.put("messageType", messageType.getCode()); systemMessage.put("createdAt", now); + systemMessage.put("timestamp", System.currentTimeMillis()); List connections = connectionRepository.findByRoomId(roomId); String broadcastPayload = ResponseGenerator.gson().toJson(systemMessage); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameSessionHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameSessionHandler.java new file mode 100644 index 00000000..16a9692f --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/GameSessionHandler.java @@ -0,0 +1,294 @@ +package com.mzc.secondproject.serverless.domain.chatting.handler; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import com.mzc.secondproject.serverless.common.router.HandlerRouter; +import com.mzc.secondproject.serverless.common.router.Route; +import com.mzc.secondproject.serverless.common.util.ResponseGenerator; +import com.mzc.secondproject.serverless.common.util.WebSocketBroadcaster; +import com.mzc.secondproject.serverless.common.util.WebSocketMessageHelper; +import com.mzc.secondproject.serverless.domain.chatting.dto.response.CommandResult; +import com.mzc.secondproject.serverless.domain.chatting.enums.MessageType; +import com.mzc.secondproject.serverless.domain.chatting.exception.ChattingErrorCode; +import com.mzc.secondproject.serverless.domain.chatting.model.Connection; +import com.mzc.secondproject.serverless.domain.chatting.model.GameSession; +import com.mzc.secondproject.serverless.domain.chatting.repository.ConnectionRepository; +import com.mzc.secondproject.serverless.domain.chatting.repository.GameSessionRepository; +import com.mzc.secondproject.serverless.domain.chatting.service.GameService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.util.*; + +/** + * 게임 세션 REST API 핸들러 + * 게임 세션 조회 및 재접속 지원 + */ +public class GameSessionHandler implements RequestHandler { + + private static final Logger logger = LoggerFactory.getLogger(GameSessionHandler.class); + + private final GameService gameService; + private final GameSessionRepository gameSessionRepository; + private final ConnectionRepository connectionRepository; + private final WebSocketBroadcaster broadcaster; + private final HandlerRouter router; + + /** + * 기본 생성자 (Lambda에서 사용) + */ + public GameSessionHandler() { + this(new GameService(), new GameSessionRepository(), new ConnectionRepository(), new WebSocketBroadcaster()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public GameSessionHandler(GameService gameService, GameSessionRepository gameSessionRepository, + ConnectionRepository connectionRepository, WebSocketBroadcaster broadcaster) { + this.gameService = gameService; + this.gameSessionRepository = gameSessionRepository; + this.connectionRepository = connectionRepository; + this.broadcaster = broadcaster; + this.router = initRouter(); + } + + private HandlerRouter initRouter() { + return new HandlerRouter().addRoutes( + // 게임 세션 생성 (roomId 기반) + Route.postAuth("/rooms/{roomId}/games", this::createGameSession), + // 게임 세션 조회 (재접속용) + Route.getAuth("/games/{gameSessionId}", this::getGameSession), + // 게임 시작 + Route.postAuth("/games/{gameSessionId}/start", this::startGame), + // 게임 종료 + Route.postAuth("/games/{gameSessionId}/stop", this::stopGame) + ); + } + + @Override + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { + logger.info("GameSession API request: {} {}", request.getHttpMethod(), request.getPath()); + return router.route(request); + } + + /** + * POST /rooms/{roomId}/games - 게임 세션 생성 (게임 시작) + */ + private APIGatewayProxyResponseEvent createGameSession(APIGatewayProxyRequestEvent request, String userId) { + String roomId = request.getPathParameters().get("roomId"); + + GameService.GameStartResult result = gameService.startGame(roomId, userId); + + if (!result.success()) { + return ResponseGenerator.fail(ChattingErrorCode.GAME_START_FAILED, result.error()); + } + + // WebSocket으로 게임 시작 알림 브로드캐스트 + broadcastGameStart(roomId, result); + + // 응답 생성 (serverTime 포함) + Map response = buildGameSessionResponse(result.session(), userId); + + return ResponseGenerator.ok("Game session created", response); + } + + /** + * GET /games/{gameSessionId} - 게임 세션 조회 (재접속용) + */ + private APIGatewayProxyResponseEvent getGameSession(APIGatewayProxyRequestEvent request, String userId) { + String gameSessionId = request.getPathParameters().get("gameSessionId"); + + Optional optSession = gameSessionRepository.findById(gameSessionId); + if (optSession.isEmpty()) { + return ResponseGenerator.fail(ChattingErrorCode.GAME_NOT_FOUND); + } + + GameSession session = optSession.get(); + + // 응답 생성 (serverTime 포함, 출제자에게만 currentWord 포함) + Map response = buildGameSessionResponse(session, userId); + + return ResponseGenerator.ok("Game session retrieved", response); + } + + /** + * POST /games/{gameSessionId}/start - 게임 시작 (세션 ID로) + */ + private APIGatewayProxyResponseEvent startGame(APIGatewayProxyRequestEvent request, String userId) { + String gameSessionId = request.getPathParameters().get("gameSessionId"); + + Optional optSession = gameSessionRepository.findById(gameSessionId); + if (optSession.isEmpty()) { + return ResponseGenerator.fail(ChattingErrorCode.GAME_NOT_FOUND); + } + + GameSession session = optSession.get(); + + // 이미 시작된 게임인지 확인 + if (session.isActive()) { + return ResponseGenerator.fail(ChattingErrorCode.GAME_START_FAILED, "게임이 이미 진행 중입니다."); + } + + // roomId로 게임 시작 위임 + GameService.GameStartResult result = gameService.startGame(session.getRoomId(), userId); + + if (!result.success()) { + return ResponseGenerator.fail(ChattingErrorCode.GAME_START_FAILED, result.error()); + } + + broadcastGameStart(session.getRoomId(), result); + + Map response = buildGameSessionResponse(result.session(), userId); + return ResponseGenerator.ok("Game started", response); + } + + /** + * POST /games/{gameSessionId}/stop - 게임 종료 + */ + private APIGatewayProxyResponseEvent stopGame(APIGatewayProxyRequestEvent request, String userId) { + String gameSessionId = request.getPathParameters().get("gameSessionId"); + + Optional optSession = gameSessionRepository.findById(gameSessionId); + if (optSession.isEmpty()) { + return ResponseGenerator.fail(ChattingErrorCode.GAME_NOT_FOUND); + } + + GameSession session = optSession.get(); + CommandResult result = gameService.stopGame(session.getRoomId(), userId); + + if (!result.success()) { + return ResponseGenerator.fail(ChattingErrorCode.GAME_STOP_FAILED, result.message()); + } + + // WebSocket으로 게임 종료 알림 브로드캐스트 + broadcastSystemMessage(session.getRoomId(), result.message(), MessageType.GAME_END); + + return ResponseGenerator.ok("Game stopped", Map.of( + "message", result.message(), + "serverTime", System.currentTimeMillis() + )); + } + + /** + * 게임 세션 응답 빌드 (serverTime 포함) + */ + private Map buildGameSessionResponse(GameSession session, String userId) { + long serverTime = System.currentTimeMillis(); + + Map response = new LinkedHashMap<>(); + response.put("gameSessionId", session.getGameSessionId()); + response.put("roomId", session.getRoomId()); + response.put("gameType", session.getGameType()); + response.put("status", session.getStatus()); + response.put("currentRound", session.getCurrentRound()); + response.put("totalRounds", session.getTotalRounds()); + response.put("currentDrawerId", session.getCurrentDrawerId()); + response.put("roundStartTime", session.getRoundStartTime()); + response.put("serverTime", serverTime); // 핵심! 타이머 동기화용 + response.put("roundDuration", session.getRoundDuration()); + response.put("scores", session.getScores() != null ? session.getScores() : Map.of()); + response.put("players", session.getPlayers() != null ? session.getPlayers() : List.of()); + response.put("drawerOrder", session.getDrawerOrder()); + response.put("hintUsed", session.getHintUsed()); + + // 출제자에게만 현재 단어 포함 + if (userId != null && userId.equals(session.getCurrentDrawerId())) { + Map currentWord = new HashMap<>(); + currentWord.put("wordId", session.getCurrentWordId()); + currentWord.put("word", session.getCurrentWord()); + response.put("currentWord", currentWord); + } + + return response; + } + + /** + * 게임 시작 브로드캐스트 + * 모든 사용자에게 게임 시작 메시지 전송, 출제자에게는 currentWord 포함 + */ + private void broadcastGameStart(String roomId, GameService.GameStartResult result) { + String messageId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + long serverTime = System.currentTimeMillis(); + + GameSession session = result.session(); + String drawerId = session.getCurrentDrawerId(); + + String message = String.format(""" + 🎮 게임 시작! + 총 %d 라운드 + + 라운드 1 시작! + 출제자: %s + """, + session.getTotalRounds(), + drawerId); + + // 기본 게임 시작 메시지 (모든 사용자용) + Map gameStartMessage = new HashMap<>(); + gameStartMessage.put("domain", WebSocketMessageHelper.DOMAIN_GAME); + gameStartMessage.put("messageId", messageId); + gameStartMessage.put("roomId", roomId); + gameStartMessage.put("userId", "SYSTEM"); + gameStartMessage.put("content", message); + gameStartMessage.put("messageType", MessageType.GAME_START.getCode()); + gameStartMessage.put("createdAt", now); + gameStartMessage.put("timestamp", serverTime); + gameStartMessage.put("gameStatus", session.getStatus()); + gameStartMessage.put("currentRound", session.getCurrentRound()); + gameStartMessage.put("totalRounds", session.getTotalRounds()); + gameStartMessage.put("currentDrawerId", drawerId); + gameStartMessage.put("drawerOrder", result.drawerOrder()); + gameStartMessage.put("roundStartTime", session.getRoundStartTime()); + gameStartMessage.put("serverTime", serverTime); + gameStartMessage.put("roundDuration", session.getRoundDuration()); + + List connections = connectionRepository.findByRoomId(roomId); + + // 출제자용 메시지 (currentWord 포함) + Map drawerMessage = new HashMap<>(gameStartMessage); + Map currentWord = new HashMap<>(); + currentWord.put("wordId", session.getCurrentWordId()); + currentWord.put("word", session.getCurrentWord()); + drawerMessage.put("currentWord", currentWord); + + String broadcastPayload = ResponseGenerator.gson().toJson(gameStartMessage); + String drawerPayload = ResponseGenerator.gson().toJson(drawerMessage); + + // 출제자와 일반 사용자에게 다른 메시지 전송 + for (Connection conn : connections) { + String payload = conn.getUserId().equals(drawerId) ? drawerPayload : broadcastPayload; + broadcaster.sendToConnection(conn.getConnectionId(), payload); + } + + logger.info("Game start broadcasted: roomId={}, sessionId={}, drawerId={}", roomId, session.getGameSessionId(), drawerId); + } + + /** + * 시스템 메시지 브로드캐스트 + */ + private void broadcastSystemMessage(String roomId, String message, MessageType messageType) { + String messageId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + + Map systemMessage = new HashMap<>(); + systemMessage.put("domain", WebSocketMessageHelper.DOMAIN_GAME); + systemMessage.put("messageId", messageId); + systemMessage.put("roomId", roomId); + systemMessage.put("userId", "SYSTEM"); + systemMessage.put("content", message); + systemMessage.put("messageType", messageType.getCode()); + systemMessage.put("createdAt", now); + systemMessage.put("timestamp", System.currentTimeMillis()); + + List connections = connectionRepository.findByRoomId(roomId); + String broadcastPayload = ResponseGenerator.gson().toJson(systemMessage); + broadcaster.broadcast(connections, broadcastPayload); + + logger.info("System message broadcasted: roomId={}, type={}", roomId, messageType); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketConnectHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketConnectHandler.java index 88cac6de..7b674a45 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketConnectHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketConnectHandler.java @@ -57,6 +57,9 @@ public Map handleRequest(Map event, Context cont String userId = token.getUserId(); String roomId = token.getRoomId(); + // 같은 방에서 기존 연결 삭제 (새로고침 시 중복 연결 방지) + connectionRepository.deleteUserConnectionsInRoom(userId, roomId); + String now = Instant.now().toString(); long ttl = Instant.now().plusSeconds(WebSocketConfig.connectionTtlSeconds()).getEpochSecond(); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketDisconnectHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketDisconnectHandler.java index 3929f10e..1400b43e 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketDisconnectHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketDisconnectHandler.java @@ -5,11 +5,14 @@ import com.mzc.secondproject.serverless.common.util.WebSocketEventUtil; import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; import com.mzc.secondproject.serverless.domain.chatting.model.Connection; +import com.mzc.secondproject.serverless.domain.chatting.model.GameSession; import com.mzc.secondproject.serverless.domain.chatting.repository.ChatRoomRepository; import com.mzc.secondproject.serverless.domain.chatting.repository.ConnectionRepository; +import com.mzc.secondproject.serverless.domain.chatting.repository.GameSessionRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.time.Instant; import java.util.List; import java.util.Map; import java.util.Optional; @@ -19,34 +22,36 @@ * 클라이언트 연결 해제 시 Connection 정보를 DynamoDB에서 삭제 */ public class WebSocketDisconnectHandler implements RequestHandler, Map> { - + private static final Logger logger = LoggerFactory.getLogger(WebSocketDisconnectHandler.class); - + private final ConnectionRepository connectionRepository; private final ChatRoomRepository chatRoomRepository; - + private final GameSessionRepository gameSessionRepository; + public WebSocketDisconnectHandler() { this.connectionRepository = new ConnectionRepository(); this.chatRoomRepository = new ChatRoomRepository(); + this.gameSessionRepository = new GameSessionRepository(); } - + @Override public Map handleRequest(Map event, Context context) { logger.info("WebSocket disconnect event: {}", event); - + try { String connectionId = WebSocketEventUtil.extractConnectionId(event); - + Optional connection = connectionRepository.findByConnectionId(connectionId); - + if (connection.isPresent()) { Connection conn = connection.get(); String roomId = conn.getRoomId(); - + connectionRepository.delete(connectionId); logger.info("Connection deleted: connectionId={}, userId={}, roomId={}", connectionId, conn.getUserId(), roomId); - + // 방에 남은 연결이 없으면 게임 상태 초기화 List remainingConnections = connectionRepository.findByRoomId(roomId); if (remainingConnections.isEmpty()) { @@ -56,40 +61,41 @@ public Map handleRequest(Map event, Context cont } else { logger.warn("Connection not found for deletion: connectionId={}", connectionId); } - + return WebSocketEventUtil.ok("Disconnected"); - + } catch (Exception e) { logger.error("Error handling disconnect: {}", e.getMessage(), e); return WebSocketEventUtil.serverError("Internal server error"); } } - + /** * 게임 상태 초기화 + * 새 구조에서는 GameSession을 종료하고 ChatRoom의 상태를 WAITING으로 변경 */ private void resetGameState(String roomId) { try { + // 활성 게임 세션이 있으면 종료 + Optional activeSession = gameSessionRepository.findActiveByRoomId(roomId); + if (activeSession.isPresent()) { + GameSession session = activeSession.get(); + long now = Instant.now().toEpochMilli(); + long ttl = now / 1000 + 86400 * 7; // 7일 후 TTL + gameSessionRepository.finishGame(session.getGameSessionId(), now, ttl); + logger.info("Game session finished due to empty room: gameSessionId={}", session.getGameSessionId()); + } + + // 채팅방 상태 초기화 Optional roomOpt = chatRoomRepository.findById(roomId); - if (roomOpt.isPresent()) { ChatRoom room = roomOpt.get(); - // 게임이 진행 중이었다면 초기화 - if (room.getGameStatus() != null && !"NONE".equals(room.getGameStatus())) { - room.setGameStatus("NONE"); - room.setCurrentRound(null); - room.setCurrentDrawerId(null); - room.setCurrentWord(null); - room.setCurrentWordId(null); - room.setDrawerOrder(null); - room.setScores(null); - room.setStreaks(null); - room.setCorrectGuessers(null); - room.setHintUsed(null); - room.setRoundStartTime(null); - room.setGameStartedBy(null); + // 게임이 진행 중이었다면 상태 초기화 + if ("PLAYING".equals(room.getStatus())) { + chatRoomRepository.updateStatus(room, "WAITING"); + room.setActiveGameSessionId(null); chatRoomRepository.save(room); - logger.info("Game state reset for room: {}", roomId); + logger.info("Room status reset to WAITING for room: {}", roomId); } } } catch (Exception e) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java index bde7d946..f8da9d75 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java @@ -6,17 +6,19 @@ import com.google.gson.GsonBuilder; import com.mzc.secondproject.serverless.common.util.WebSocketBroadcaster; import com.mzc.secondproject.serverless.common.util.WebSocketEventUtil; +import com.mzc.secondproject.serverless.common.util.WebSocketMessageHelper; import com.mzc.secondproject.serverless.domain.chatting.dto.response.CommandResult; import com.mzc.secondproject.serverless.domain.chatting.dto.response.ScoreUpdateMessage; import com.mzc.secondproject.serverless.domain.chatting.enums.MessageType; import com.mzc.secondproject.serverless.domain.chatting.model.ChatMessage; import com.mzc.secondproject.serverless.domain.chatting.model.Connection; +import com.mzc.secondproject.serverless.domain.chatting.model.GameSession; import com.mzc.secondproject.serverless.domain.chatting.repository.ChatRoomRepository; import com.mzc.secondproject.serverless.domain.chatting.repository.ConnectionRepository; +import com.mzc.secondproject.serverless.domain.chatting.repository.GameSessionRepository; import com.mzc.secondproject.serverless.domain.chatting.service.ChatMessageService; import com.mzc.secondproject.serverless.domain.chatting.service.CommandService; import com.mzc.secondproject.serverless.domain.chatting.service.GameService; -import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -38,6 +40,7 @@ public class WebSocketMessageHandler implements RequestHandler handleRequest(Map event, Context cont // 메시지 타입별 처리 return switch (messageType.toUpperCase()) { case "DRAWING", "DRAWING_CLEAR" -> handleDrawingMessage(connectionId, payload, messageType); + case "ROUND_TIMEOUT" -> handleRoundTimeout(payload); default -> handleRegularMessage(connectionId, payload, messageType); }; @@ -94,11 +99,13 @@ private Map handleDrawingMessage(String connectionId, MessagePay // 그림 데이터 메시지 생성 (저장 안 함) Map drawingMessage = new HashMap<>(); + drawingMessage.put("domain", WebSocketMessageHelper.DOMAIN_GAME); drawingMessage.put("messageType", messageType); drawingMessage.put("roomId", payload.roomId); drawingMessage.put("userId", payload.userId); drawingMessage.put("content", payload.content); drawingMessage.put("createdAt", Instant.now().toString()); + drawingMessage.put("timestamp", System.currentTimeMillis()); // 본인 제외 브로드캐스트 List connections = connectionRepository.findByRoomId(payload.roomId); @@ -169,9 +176,19 @@ private Map handleRegularMessage(String connectionId, MessagePay logger.info("Message saved: messageId={}, roomId={}", messageId, payload.roomId); - // 브로드캐스트 + // 브로드캐스트 (domain 필드 포함을 위해 Map으로 변환) + Map broadcastMessage = new HashMap<>(); + broadcastMessage.put("domain", WebSocketMessageHelper.DOMAIN_CHAT); + broadcastMessage.put("messageId", savedMessage.getMessageId()); + broadcastMessage.put("roomId", savedMessage.getRoomId()); + broadcastMessage.put("userId", savedMessage.getUserId()); + broadcastMessage.put("content", savedMessage.getContent()); + broadcastMessage.put("messageType", savedMessage.getMessageType()); + broadcastMessage.put("createdAt", savedMessage.getCreatedAt()); + broadcastMessage.put("timestamp", System.currentTimeMillis()); + List connections = connectionRepository.findByRoomId(payload.roomId); - String broadcastPayload = gson.toJson(savedMessage); + String broadcastPayload = gson.toJson(broadcastMessage); List failedConnections = broadcaster.broadcast(connections, broadcastPayload); // 실패한 연결 정리 @@ -192,12 +209,14 @@ private Map broadcastGuessMessage(MessagePayload payload) { // 추측 메시지 생성 (저장하지 않음) Map guessMessage = new HashMap<>(); + guessMessage.put("domain", WebSocketMessageHelper.DOMAIN_GAME); guessMessage.put("messageId", messageId); guessMessage.put("roomId", payload.roomId); guessMessage.put("userId", payload.userId); guessMessage.put("content", payload.content); guessMessage.put("messageType", "GUESS"); guessMessage.put("createdAt", now); + guessMessage.put("timestamp", System.currentTimeMillis()); List connections = connectionRepository.findByRoomId(payload.roomId); String broadcastPayload = gson.toJson(guessMessage); @@ -218,16 +237,18 @@ private Map handleCorrectAnswer(MessagePayload payload, GameServ broadcastCorrectAnswerMessage(payload, result, connections); // 2. 점수 업데이트 메시지 브로드캐스트 (실시간 리더보드) - chatRoomRepository.findById(payload.roomId).ifPresent(room -> { + gameSessionRepository.findActiveByRoomId(payload.roomId).ifPresent(session -> { broadcastScoreUpdate(payload.roomId, payload.userId, result.score(), - result.scores(), room.getCurrentRound(), room.getTotalRounds(), connections); + result.scores(), session.getCurrentRound(), session.getTotalRounds(), connections); }); logger.info("Correct answer: roomId={}, userId={}, score={}", payload.roomId, payload.userId, result.score()); - - // 정답 맞추면 즉시 다음 라운드로 이동 - endCurrentRound(payload.roomId, "CORRECT_ANSWER"); - + + // 전원 정답 시 라운드 종료 처리 + if (result.allCorrect()) { + handleAllCorrect(payload.roomId); + } + return WebSocketEventUtil.ok("Correct answer"); } @@ -240,20 +261,16 @@ private void broadcastCorrectAnswerMessage(MessagePayload payload, GameService.A String message = String.format("🎉 %s님이 정답을 맞췄습니다! (+%d점)", payload.userId, result.score()); - ChatMessage correctMessage = ChatMessage.builder() - .pk("ROOM#" + payload.roomId) - .sk("MSG#" + now + "#" + messageId) - .gsi1pk("SYSTEM") - .gsi1sk("MSG#" + now) - .gsi2pk("MSG#" + messageId) - .gsi2sk("ROOM#" + payload.roomId) - .messageId(messageId) - .roomId(payload.roomId) - .userId("SYSTEM") - .content(message) - .messageType(MessageType.CORRECT_ANSWER.getCode()) - .createdAt(now) - .build(); + // domain 필드 포함을 위해 Map으로 생성 + Map correctMessage = new HashMap<>(); + correctMessage.put("domain", WebSocketMessageHelper.DOMAIN_GAME); + correctMessage.put("messageId", messageId); + correctMessage.put("roomId", payload.roomId); + correctMessage.put("userId", "SYSTEM"); + correctMessage.put("content", message); + correctMessage.put("messageType", MessageType.CORRECT_ANSWER.getCode()); + correctMessage.put("createdAt", now); + correctMessage.put("timestamp", System.currentTimeMillis()); String broadcastPayload = gson.toJson(correctMessage); List failedConnections = broadcaster.broadcast(connections, broadcastPayload); @@ -295,136 +312,214 @@ private void cleanupFailedConnections(List failedConnections) { } /** - * 현재 라운드 종료 및 다음 라운드 진행 + * 전원 정답 시 라운드 종료 */ - private void endCurrentRound(String roomId, String reason) { - chatRoomRepository.findById(roomId).ifPresent(room -> { - CommandResult endResult = gameService.endRound(room, reason); + private void handleAllCorrect(String roomId) { + CommandResult endResult = gameService.endRound(roomId, "ALL_CORRECT"); + if (endResult != null && !endResult.message().contains("진행 중인 게임이 없습니다")) { handleCommandResult(endResult, roomId, "SYSTEM"); - }); + } + } + + /** + * 라운드 타임아웃 처리 (프론트엔드에서 타이머 만료 시 호출) + * - 실제 라운드 시간이 만료되었는지 서버에서 검증 + * - 검증 통과 시 라운드 종료 및 ROUND_END 브로드캐스트 + */ + private Map handleRoundTimeout(MessagePayload payload) { + String roomId = payload.roomId; + logger.info("Round timeout request: roomId={}, userId={}", roomId, payload.userId); + + // 활성 게임 세션 조회 + GameSession session = gameSessionRepository.findActiveByRoomId(roomId).orElse(null); + if (session == null) { + logger.warn("No active game session for round timeout: roomId={}", roomId); + return WebSocketEventUtil.ok("No active game"); + } + + // 라운드 시간이 실제로 만료되었는지 검증 (5초 여유) + long elapsedMs = System.currentTimeMillis() - session.getRoundStartTime(); + int roundDurationMs = (session.getRoundDuration() != null ? session.getRoundDuration() : 60) * 1000; + + if (elapsedMs < roundDurationMs - 5000) { + logger.warn("Round timeout rejected - time not expired: elapsedMs={}, roundDurationMs={}", + elapsedMs, roundDurationMs); + return WebSocketEventUtil.ok("Round time not expired yet"); + } + + // 라운드 종료 처리 + CommandResult endResult = gameService.endRound(roomId, "TIMEOUT"); + if (endResult != null && endResult.success()) { + handleCommandResult(endResult, roomId, "SYSTEM"); + logger.info("Round ended due to timeout: roomId={}", roomId); + } + + return WebSocketEventUtil.ok("Round timeout processed"); } /** * 명령어 처리 결과를 브로드캐스트 */ private Map handleCommandResult(CommandResult result, String roomId, String userId) { - String messageId = UUID.randomUUID().toString(); - String now = Instant.now().toString(); - List connections = connectionRepository.findByRoomId(roomId); - - // GAME_START인 경우 게임 데이터 포함하여 전송 + + // GAME_START는 특별 처리 (출제자에게만 제시어 전송 + serverTime 포함) if (result.messageType() == MessageType.GAME_START && result.data() instanceof GameService.GameStartResult gameResult) { - broadcastGameStart(connections, result, gameResult, messageId, roomId, now); - } else if (result.messageType() == MessageType.ROUND_END && result.data() instanceof Map) { - broadcastRoundEnd(connections, result, messageId, roomId, now); - } else { - // 일반 시스템 메시지 - Map systemMessage = new HashMap<>(); - systemMessage.put("messageId", messageId); - systemMessage.put("roomId", roomId); - systemMessage.put("userId", "SYSTEM"); - systemMessage.put("content", result.message()); - systemMessage.put("messageType", result.messageType().getCode()); - systemMessage.put("createdAt", now); - - String broadcastPayload = gson.toJson(systemMessage); - List failedConnections = broadcaster.broadcast(connections, broadcastPayload); - cleanupFailedConnections(failedConnections); + broadcastGameStart(connections, result, gameResult, roomId); + return WebSocketEventUtil.ok("Command executed"); } - + + // ROUND_END는 특별 처리 (다음 출제자에게만 제시어 전송 + serverTime 포함) + if (result.messageType() == MessageType.ROUND_END && result.data() instanceof Map) { + @SuppressWarnings("unchecked") + Map data = (Map) result.data(); + broadcastRoundEnd(connections, result, data, roomId); + return WebSocketEventUtil.ok("Command executed"); + } + + // 일반 시스템 메시지 (게임 관련 명령어 결과) + String messageId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + + // domain 필드 포함을 위해 Map으로 생성 + Map systemMessage = new HashMap<>(); + systemMessage.put("domain", WebSocketMessageHelper.DOMAIN_GAME); + systemMessage.put("messageId", messageId); + systemMessage.put("roomId", roomId); + systemMessage.put("userId", "SYSTEM"); + systemMessage.put("content", result.message()); + systemMessage.put("messageType", result.messageType().getCode()); + systemMessage.put("createdAt", now); + systemMessage.put("timestamp", System.currentTimeMillis()); + + String broadcastPayload = gson.toJson(systemMessage); + List failedConnections = broadcaster.broadcast(connections, broadcastPayload); + cleanupFailedConnections(failedConnections); + logger.info("Command result broadcasted: type={}, roomId={}", result.messageType(), roomId); return WebSocketEventUtil.ok("Command executed"); } - + /** - * GAME_START 메시지 브로드캐스트 - 출제자에게만 제시어 포함 + * GAME_START 메시지 브로드캐스트 - 출제자에게만 제시어 포함, serverTime 추가 */ private void broadcastGameStart(List connections, CommandResult result, - GameService.GameStartResult gameResult, String messageId, String roomId, String now) { - - String currentDrawerId = gameResult.room().getCurrentDrawerId(); - + GameService.GameStartResult gameResult, String roomId) { + String messageId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + long serverTime = System.currentTimeMillis(); + + GameSession session = gameResult.session(); + String currentDrawerId = session.getCurrentDrawerId(); + for (Connection conn : connections) { Map message = new HashMap<>(); + message.put("domain", WebSocketMessageHelper.DOMAIN_GAME); message.put("messageId", messageId); message.put("roomId", roomId); message.put("userId", "SYSTEM"); message.put("content", result.message()); message.put("messageType", result.messageType().getCode()); message.put("createdAt", now); - - // 게임 상태 정보 추가 - message.put("gameStatus", gameResult.room().getGameStatus()); - message.put("currentRound", gameResult.room().getCurrentRound()); - message.put("totalRounds", gameResult.room().getTotalRounds()); + message.put("timestamp", serverTime); + + // 게임 상태 정보 + message.put("gameStatus", session.getStatus()); + message.put("currentRound", session.getCurrentRound()); + message.put("totalRounds", session.getTotalRounds()); message.put("currentDrawerId", currentDrawerId); message.put("drawerOrder", gameResult.drawerOrder()); - message.put("roundTimeLimit", gameResult.room().getRoundTimeLimit()); - message.put("roundStartTime", gameResult.room().getRoundStartTime()); - - // 출제자에게만 제시어 전송 (영어 단어만) + + // 타이머 동기화용 필드 (핵심!) + message.put("roundStartTime", session.getRoundStartTime()); + message.put("serverTime", serverTime); + message.put("roundDuration", session.getRoundDuration()); + + // 출제자에게만 제시어 전송 if (conn.getUserId().equals(currentDrawerId) && gameResult.firstWord() != null) { Map wordInfo = new HashMap<>(); wordInfo.put("wordId", gameResult.firstWord().getWordId()); - wordInfo.put("word", gameResult.firstWord().getEnglish()); // 영어 단어만 전송 + wordInfo.put("word", gameResult.firstWord().getEnglish()); message.put("currentWord", wordInfo); } - + String payload = gson.toJson(message); try { broadcaster.sendToConnection(conn.getConnectionId(), payload); } catch (Exception e) { - logger.warn("Failed to send to connection: {}", conn.getConnectionId()); + logger.warn("Failed to send GAME_START to connection: {}", conn.getConnectionId()); connectionRepository.delete(conn.getConnectionId()); } } + + logger.info("GAME_START broadcasted: roomId={}, serverTime={}", roomId, serverTime); } - + /** - * ROUND_END 메시지 브로드캐스트 - 다음 출제자에게만 다음 제시어 포함 + * ROUND_END 메시지 브로드캐스트 - 다음 출제자에게만 제시어 포함, serverTime 추가 */ - @SuppressWarnings("unchecked") private void broadcastRoundEnd(List connections, CommandResult result, - String messageId, String roomId, String now) { - - Map data = (Map) result.data(); + Map data, String roomId) { + String messageId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + long serverTime = System.currentTimeMillis(); + String nextDrawer = (String) data.get("nextDrawer"); - Word nextWord = (Word) data.get("nextWord"); - + Object nextWordObj = data.get("nextWord"); + for (Connection conn : connections) { Map message = new HashMap<>(); + message.put("domain", WebSocketMessageHelper.DOMAIN_GAME); message.put("messageId", messageId); message.put("roomId", roomId); message.put("userId", "SYSTEM"); message.put("content", result.message()); message.put("messageType", result.messageType().getCode()); message.put("createdAt", now); - + message.put("timestamp", serverTime); + // 기본 데이터 복사 (nextWord 제외) - Map messageData = new HashMap<>(data); - messageData.remove("nextWord"); - - // 다음 출제자에게만 다음 제시어 전송 (영어 단어만) - if (conn.getUserId().equals(nextDrawer) && nextWord != null) { - Map wordInfo = new HashMap<>(); - wordInfo.put("wordId", nextWord.getWordId()); - wordInfo.put("word", nextWord.getEnglish()); // 영어 단어만 전송 - messageData.put("nextWord", wordInfo); + Map messageData = new HashMap<>(); + messageData.put("answer", data.get("answer")); + messageData.put("nextRound", data.get("nextRound")); + messageData.put("nextDrawer", nextDrawer); + messageData.put("ranking", data.get("ranking")); + messageData.put("currentRound", data.get("currentRound")); + messageData.put("totalRounds", data.get("totalRounds")); + + // 타이머 동기화용 필드 (핵심!) + messageData.put("serverTime", serverTime); + if (data.get("roundStartTime") != null) { + messageData.put("roundStartTime", data.get("roundStartTime")); } - + if (data.get("roundDuration") != null) { + messageData.put("roundDuration", data.get("roundDuration")); + } + + // 다음 출제자에게만 제시어 전송 + if (conn.getUserId().equals(nextDrawer) && nextWordObj != null) { + if (nextWordObj instanceof com.mzc.secondproject.serverless.domain.vocabulary.model.Word nextWord) { + Map wordInfo = new HashMap<>(); + wordInfo.put("wordId", nextWord.getWordId()); + wordInfo.put("word", nextWord.getEnglish()); + messageData.put("nextWord", wordInfo); + } + } + message.put("data", messageData); - + String payload = gson.toJson(message); try { broadcaster.sendToConnection(conn.getConnectionId(), payload); } catch (Exception e) { - logger.warn("Failed to send to connection: {}", conn.getConnectionId()); + logger.warn("Failed to send ROUND_END to connection: {}", conn.getConnectionId()); connectionRepository.delete(conn.getConnectionId()); } } + + logger.info("ROUND_END broadcasted: roomId={}, serverTime={}", roomId, serverTime); } - + /** * 메시지 페이로드 DTO */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/ChatRoom.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/ChatRoom.java index 5fe45aaf..4cc1f822 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/ChatRoom.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/ChatRoom.java @@ -7,7 +7,6 @@ import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.*; import java.util.List; -import java.util.Map; @Data @Builder @@ -35,21 +34,14 @@ public class ChatRoom { private List memberIds; // 참여 멤버 목록 private Long ttl; - // 게임 관련 필드 - private String gameStatus; // NONE, WAITING, PLAYING, ROUND_END, FINISHED - private String gameStartedBy; // 게임 시작한 사용자 ID - private Integer currentRound; // 현재 라운드 (1부터 시작) - private Integer totalRounds; // 총 라운드 수 - private String currentDrawerId; // 현재 출제자 userId - private String currentWordId; // 현재 제시어 wordId - private String currentWord; // 현재 제시어 (korean) - private Long roundStartTime; // 라운드 시작 시간 (Unix timestamp) - private Integer roundTimeLimit; // 라운드 제한 시간 (초) - private List drawerOrder; // 출제 순서 (userId 목록) - private Map scores; // 사용자별 점수 - private Map streaks; // 사용자별 연속 정답 수 - private Boolean hintUsed; // 현재 라운드 힌트 사용 여부 - private List correctGuessers; // 현재 라운드 정답자 목록 + // 게임 세션 참조 (게임 상태는 GameSession으로 분리됨) + private String activeGameSessionId; // 현재 진행중인 게임 세션 ID (nullable) + + private String type; // CHAT, GAME (기본값: CHAT) + private String gameType; // CATCHMIND (nullable, GAME 타입일 때만) + private GameSettings gameSettings; // 게임 설정 (nullable) + private String status; // WAITING, PLAYING, FINISHED (기본값: WAITING) + private String hostId; // 방장 userId (createdBy와 별도 관리) @DynamoDbPartitionKey @DynamoDbAttribute("PK") diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSession.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSession.java new file mode 100644 index 00000000..ce21f82b --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSession.java @@ -0,0 +1,190 @@ +package com.mzc.secondproject.serverless.domain.chatting.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.*; + +import java.util.List; +import java.util.Map; + +/** + * 게임 세션 모델 + * ChatRoom에서 분리된 게임 상태 관리용 독립 모델 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DynamoDbBean +public class GameSession { + + private String pk; // GAME#{gameSessionId} + private String sk; // METADATA + private String gsi1pk; // ROOM#{roomId} + private String gsi1sk; // GAME#{createdAt} + + private String gameSessionId; + private String roomId; + private String gameType; // "catchmind" + + // 게임 상태 + private String status; // NONE, WAITING, PLAYING, ROUND_END, FINISHED + private String startedBy; + private Long startedAt; + private Long endedAt; + + // 라운드 정보 + private Integer currentRound; + private Integer totalRounds; + private String currentDrawerId; + private String currentWordId; + private String currentWord; // 한국어 뜻 + private String currentWordEnglish; // 영어 단어 (정답 체크용) + private Long roundStartTime; + private Integer roundDuration; + + // 점수 및 플레이어 + private Map scores; + private Map streaks; + private List players; + private List drawerOrder; + + // 라운드 내 상태 + private Boolean hintUsed; + private List correctGuessers; + + // 스케줄링 (게임 자동 종료용) + private Long gameEndScheduledAt; + private String scheduleRuleArn; + + // TTL (게임 종료 후 일정 시간 뒤 삭제) + private Long ttl; + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { + return pk; + } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { + return sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1PK") + public String getGsi1pk() { + return gsi1pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1SK") + public String getGsi1sk() { + return gsi1sk; + } + + /** + * 게임이 활성 상태인지 확인 + */ + public boolean isActive() { + return "PLAYING".equals(status) || "ROUND_END".equals(status); + } + + /** + * 게임 시작 가능 여부 확인 + */ + public boolean canStart() { + return status == null || "NONE".equals(status) || "FINISHED".equals(status); + } + + /** + * 출제자 여부 확인 + */ + public boolean isDrawer(String userId) { + return userId != null && userId.equals(currentDrawerId); + } + + /** + * 이미 정답을 맞춘 사용자인지 확인 + */ + public boolean hasAlreadyGuessedCorrect(String userId) { + return correctGuessers != null && correctGuessers.contains(userId); + } + + /** + * 정답자 추가 + */ + public void addCorrectGuesser(String userId) { + if (correctGuessers == null) { + correctGuessers = new java.util.ArrayList<>(); + } + if (!correctGuessers.contains(userId)) { + correctGuessers.add(userId); + } + } + + /** + * 점수 추가 + */ + public void addScore(String userId, int points) { + if (scores == null) { + scores = new java.util.HashMap<>(); + } + scores.merge(userId, points, Integer::sum); + } + + /** + * 연속 정답 수 증가 + */ + public int incrementStreak(String userId) { + if (streaks == null) { + streaks = new java.util.HashMap<>(); + } + int newStreak = streaks.getOrDefault(userId, 0) + 1; + streaks.put(userId, newStreak); + return newStreak; + } + + /** + * 연속 정답 수 리셋 + */ + public void resetStreak(String userId) { + if (streaks != null) { + streaks.put(userId, 0); + } + } + + /** + * 다음 출제자 ID 반환 + */ + public String getNextDrawerId() { + if (drawerOrder == null || drawerOrder.isEmpty()) { + return null; + } + if (currentDrawerId == null) { + return drawerOrder.get(0); + } + int currentIndex = drawerOrder.indexOf(currentDrawerId); + if (currentIndex == -1 || currentIndex >= drawerOrder.size() - 1) { + return drawerOrder.get(0); + } + return drawerOrder.get(currentIndex + 1); + } + + /** + * 전원이 정답을 맞췄는지 확인 + */ + public boolean allPlayersGuessedCorrect() { + if (players == null || correctGuessers == null) { + return false; + } + // 출제자 제외한 인원이 모두 정답 + long guessersCount = players.stream() + .filter(p -> !p.equals(currentDrawerId)) + .count(); + return correctGuessers.size() >= guessersCount; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSettings.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSettings.java new file mode 100644 index 00000000..d97f2fcc --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSettings.java @@ -0,0 +1,23 @@ +package com.mzc.secondproject.serverless.domain.chatting.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DynamoDbBean +public class GameSettings { + @Builder.Default + private Integer maxRounds = 5; + + @Builder.Default + private Integer roundTimeLimit = 60; + + @Builder.Default + private Boolean autoDeleteOnEnd = false; +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatMessageRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatMessageRepository.java index e2a5ddbb..dfcdc230 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatMessageRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatMessageRepository.java @@ -7,6 +7,7 @@ import com.mzc.secondproject.serverless.domain.chatting.model.ChatMessage; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; import software.amazon.awssdk.enhanced.dynamodb.Key; import software.amazon.awssdk.enhanced.dynamodb.TableSchema; @@ -26,8 +27,18 @@ public class ChatMessageRepository { private final DynamoDbTable table; + /** + * 기본 생성자 (Lambda에서 사용) + */ public ChatMessageRepository() { - this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(ChatMessage.class)); + this(AwsClients.dynamoDbEnhanced()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public ChatMessageRepository(DynamoDbEnhancedClient enhancedClient) { + this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(ChatMessage.class)); } public ChatMessage save(ChatMessage message) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatRoomRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatRoomRepository.java index 59a00e85..e437e262 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatRoomRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ChatRoomRepository.java @@ -7,10 +7,7 @@ import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import software.amazon.awssdk.enhanced.dynamodb.DynamoDbIndex; -import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; -import software.amazon.awssdk.enhanced.dynamodb.Key; -import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.*; import software.amazon.awssdk.enhanced.dynamodb.model.Page; import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; @@ -29,8 +26,18 @@ public class ChatRoomRepository { private final DynamoDbTable table; + /** + * 기본 생성자 (Lambda에서 사용) + */ public ChatRoomRepository() { - this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(ChatRoom.class)); + this(AwsClients.dynamoDbEnhanced()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public ChatRoomRepository(DynamoDbEnhancedClient enhancedClient) { + this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(ChatRoom.class)); } public ChatRoom save(ChatRoom room) { @@ -84,14 +91,49 @@ public PaginatedResult findAllWithPagination(int limit, String cursor) } /** - * 레벨별 채팅방 조회 - 최신순, 페이지네이션 지원 + * 필터 조건으로 채팅방 조회 - 최신순, 페이지네이션 지원 + * GSI1SK 포맷: {type}#{gameType}#{status}#{level}#{createdAt} + * + * @param type 방 타입 (CHAT, GAME) - nullable + * @param gameType 게임 타입 (CATCHMIND 등) - nullable + * @param status 방 상태 (WAITING, PLAYING, FINISHED) - nullable + * @param level 레벨 (beginner, intermediate, advanced) - nullable + * @param limit 조회 개수 + * @param cursor 페이지네이션 커서 + * @return 필터링된 채팅방 목록 */ - public PaginatedResult findByLevelWithPagination(String level, int limit, String cursor) { - QueryConditional queryConditional = QueryConditional - .sortBeginsWith(Key.builder() - .partitionValue("ROOMS") - .sortValue(level + "#") - .build()); + public PaginatedResult findByFilters(String type, String gameType, String status, String level, int limit, String cursor) { + // GSI1SK prefix 생성: {type}#{gameType}#{status}#{level}# + StringBuilder prefixBuilder = new StringBuilder(); + + if (type != null && !type.isEmpty()) { + prefixBuilder.append(type).append("#"); + if (gameType != null && !gameType.isEmpty()) { + prefixBuilder.append(gameType).append("#"); + if (status != null && !status.isEmpty()) { + prefixBuilder.append(status).append("#"); + if (level != null && !level.isEmpty()) { + prefixBuilder.append(level).append("#"); + } + } + } + } + + String prefix = prefixBuilder.toString(); + + QueryConditional queryConditional; + if (prefix.isEmpty()) { + // 필터 없음 - 전체 조회 + queryConditional = QueryConditional.keyEqualTo(Key.builder().partitionValue("ROOMS").build()); + } else { + // prefix로 필터링 + queryConditional = QueryConditional.sortBeginsWith( + Key.builder() + .partitionValue("ROOMS") + .sortValue(prefix) + .build() + ); + } QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() .queryConditional(queryConditional) @@ -111,9 +153,20 @@ public PaginatedResult findByLevelWithPagination(String level, int lim String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); + logger.info("Query with prefix '{}': found {} rooms", prefix, rooms.size()); return new PaginatedResult<>(rooms, nextCursor); } + /** + * 레벨별 채팅방 조회 - 최신순, 페이지네이션 지원 + * + * @deprecated findByFilters 사용 권장 + */ + @Deprecated + public PaginatedResult findByLevelWithPagination(String level, int limit, String cursor) { + return findByFilters(null, null, null, null, limit, cursor); + } + public void delete(String roomId) { Key key = Key.builder() .partitionValue("ROOM#" + roomId) @@ -124,6 +177,32 @@ public void delete(String roomId) { logger.info("Deleted room: {}", roomId); } + /** + * 방 상태 변경 시 GSI1SK도 함께 업데이트 + * GSI1SK 포맷: {type}#{gameType}#{status}#{level}#{createdAt} + */ + public void updateStatus(ChatRoom room, String newStatus) { + String oldGsi1sk = room.getGsi1sk(); + String[] parts = oldGsi1sk.split("#", 5); // type, gameType, oldStatus, level, createdAt + + if (parts.length < 5) { + logger.warn("Invalid GSI1SK format: {}", oldGsi1sk); + // 폴백: 새 포맷으로 생성 + String type = room.getType() != null ? room.getType() : "CHAT"; + String gameType = room.getGameType() != null ? room.getGameType() : "-"; + String level = room.getLevel() != null ? room.getLevel() : "beginner"; + String createdAt = room.getCreatedAt(); + room.setGsi1sk(String.format("%s#%s#%s#%s#%s", type, gameType, newStatus, level, createdAt)); + } else { + // 기존 포맷에서 status만 교체 + room.setGsi1sk(String.format("%s#%s#%s#%s#%s", parts[0], parts[1], newStatus, parts[3], parts[4])); + } + + room.setStatus(newStatus); + table.putItem(room); + logger.info("Updated room {} status to {} (GSI1SK: {})", room.getRoomId(), newStatus, room.getGsi1sk()); + } + /** * 채팅방 lastMessageAt 업데이트 (N+1 방지 - UpdateExpression 사용) */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ConnectionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ConnectionRepository.java index bf59cdb8..612b87a5 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ConnectionRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/ConnectionRepository.java @@ -5,10 +5,7 @@ import com.mzc.secondproject.serverless.domain.chatting.model.Connection; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import software.amazon.awssdk.enhanced.dynamodb.DynamoDbIndex; -import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; -import software.amazon.awssdk.enhanced.dynamodb.Key; -import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.*; import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; @@ -23,8 +20,18 @@ public class ConnectionRepository { private final DynamoDbTable table; + /** + * 기본 생성자 (Lambda에서 사용) + */ public ConnectionRepository() { - this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(Connection.class)); + this(AwsClients.dynamoDbEnhanced()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public ConnectionRepository(DynamoDbEnhancedClient enhancedClient) { + this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(Connection.class)); } public Connection save(Connection connection) { @@ -56,12 +63,15 @@ public Optional findByConnectionId(String connectionId) { /** * 채팅방의 모든 연결 조회 (브로드캐스트용) - * GSI1: ROOM#{roomId}로 조회 + * GSI1: ROOM#{roomId}로 조회, GSI1SK가 CONN#으로 시작하는 항목만 반환 + * (GSI1에 GameSession도 포함되어 있으므로 CONN# prefix로 필터링) */ public List findByRoomId(String roomId) { + // GSI1SK가 CONN#으로 시작하는 항목만 조회 QueryConditional queryConditional = QueryConditional - .keyEqualTo(Key.builder() + .sortBeginsWith(Key.builder() .partitionValue("ROOM#" + roomId) + .sortValue("CONN#") .build()); QueryEnhancedRequest request = QueryEnhancedRequest.builder() @@ -95,4 +105,24 @@ public List findByUserId(String userId) { .flatMap(page -> page.items().stream()) .collect(Collectors.toList()); } + + /** + * 같은 방에서 사용자의 기존 연결 삭제 (중복 연결 방지) + * 새로고침 등으로 인한 중복 연결을 정리 + */ + public void deleteUserConnectionsInRoom(String userId, String roomId) { + List userConnections = findByUserId(userId); + + int deletedCount = 0; + for (Connection conn : userConnections) { + if (roomId.equals(conn.getRoomId())) { + delete(conn.getConnectionId()); + deletedCount++; + } + } + + if (deletedCount > 0) { + logger.info("Deleted {} existing connections for user {} in room {}", deletedCount, userId, roomId); + } + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/GameRoundRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/GameRoundRepository.java index ccb6556f..adc5b994 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/GameRoundRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/GameRoundRepository.java @@ -29,8 +29,18 @@ public class GameRoundRepository { private final DynamoDbEnhancedClient enhancedClient; private final DynamoDbTable table; + /** + * 기본 생성자 (Lambda에서 사용) + */ public GameRoundRepository() { - this.enhancedClient = AwsClients.dynamoDbEnhanced(); + this(AwsClients.dynamoDbEnhanced()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public GameRoundRepository(DynamoDbEnhancedClient enhancedClient) { + this.enhancedClient = enhancedClient; this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(GameRound.class)); } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/GameSessionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/GameSessionRepository.java new file mode 100644 index 00000000..70b7c238 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/GameSessionRepository.java @@ -0,0 +1,293 @@ +package com.mzc.secondproject.serverless.domain.chatting.repository; + +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.config.EnvConfig; +import com.mzc.secondproject.serverless.domain.chatting.model.GameSession; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.*; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; +import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * GameSession Repository + * 게임 세션 CRUD 및 조회 기능 제공 + */ +public class GameSessionRepository { + + private static final Logger logger = LoggerFactory.getLogger(GameSessionRepository.class); + private static final String TABLE_NAME = EnvConfig.getRequired("CHAT_TABLE_NAME"); + + private final DynamoDbTable table; + + /** + * 기본 생성자 (Lambda에서 사용) + */ + public GameSessionRepository() { + this(AwsClients.dynamoDbEnhanced()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public GameSessionRepository(DynamoDbEnhancedClient enhancedClient) { + this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(GameSession.class)); + } + + /** + * 게임 세션 저장 + */ + public GameSession save(GameSession session) { + logger.info("Saving game session: {}", session.getGameSessionId()); + table.putItem(session); + return session; + } + + /** + * ID로 게임 세션 조회 + */ + public Optional findById(String gameSessionId) { + Key key = Key.builder() + .partitionValue("GAME#" + gameSessionId) + .sortValue("METADATA") + .build(); + + GameSession session = table.getItem(key); + return Optional.ofNullable(session); + } + + /** + * 게임 세션 삭제 + */ + public void delete(String gameSessionId) { + Key key = Key.builder() + .partitionValue("GAME#" + gameSessionId) + .sortValue("METADATA") + .build(); + + table.deleteItem(key); + logger.info("Deleted game session: {}", gameSessionId); + } + + /** + * roomId로 활성 게임 세션 조회 (PLAYING 또는 ROUND_END 상태) + */ + public Optional findActiveByRoomId(String roomId) { + List sessions = findByRoomId(roomId); + + return sessions.stream() + .filter(GameSession::isActive) + .findFirst(); + } + + /** + * roomId로 모든 게임 세션 조회 (최신순) + */ + public List findByRoomId(String roomId) { + QueryConditional queryConditional = QueryConditional + .keyEqualTo(Key.builder() + .partitionValue("ROOM#" + roomId) + .build()); + + QueryEnhancedRequest request = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .scanIndexForward(false) // 최신순 + .build(); + + DynamoDbIndex gsi1 = table.index("GSI1"); + + return gsi1.query(request).stream() + .flatMap(page -> page.items().stream()) + .toList(); + } + + /** + * 게임 상태 업데이트 + */ + public void updateStatus(String gameSessionId, String status) { + Map key = buildKey(gameSessionId); + + Map expressionValues = new HashMap<>(); + expressionValues.put(":status", AttributeValue.builder().s(status).build()); + + UpdateItemRequest updateRequest = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(key) + .updateExpression("SET #status = :status") + .expressionAttributeNames(Map.of("#status", "status")) + .expressionAttributeValues(expressionValues) + .build(); + + AwsClients.dynamoDb().updateItem(updateRequest); + logger.info("Updated game session status: {} -> {}", gameSessionId, status); + } + + /** + * 라운드 정보 업데이트 + */ + public void updateRoundInfo(String gameSessionId, int currentRound, String drawerId, + String wordId, String word, long roundStartTime, int roundDuration) { + Map key = buildKey(gameSessionId); + + Map expressionValues = new HashMap<>(); + expressionValues.put(":round", AttributeValue.builder().n(String.valueOf(currentRound)).build()); + expressionValues.put(":drawer", AttributeValue.builder().s(drawerId).build()); + expressionValues.put(":wordId", AttributeValue.builder().s(wordId).build()); + expressionValues.put(":word", AttributeValue.builder().s(word).build()); + expressionValues.put(":startTime", AttributeValue.builder().n(String.valueOf(roundStartTime)).build()); + expressionValues.put(":duration", AttributeValue.builder().n(String.valueOf(roundDuration)).build()); + expressionValues.put(":hintUsed", AttributeValue.builder().bool(false).build()); + expressionValues.put(":emptyList", AttributeValue.builder().l(List.of()).build()); + + String updateExpression = "SET currentRound = :round, " + + "currentDrawerId = :drawer, " + + "currentWordId = :wordId, " + + "currentWord = :word, " + + "roundStartTime = :startTime, " + + "roundDuration = :duration, " + + "hintUsed = :hintUsed, " + + "correctGuessers = :emptyList"; + + UpdateItemRequest updateRequest = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(key) + .updateExpression(updateExpression) + .expressionAttributeValues(expressionValues) + .build(); + + AwsClients.dynamoDb().updateItem(updateRequest); + logger.info("Updated round info: gameSession={}, round={}, drawer={}", gameSessionId, currentRound, drawerId); + } + + /** + * 점수 업데이트 + */ + public void updateScores(String gameSessionId, Map scores) { + Map key = buildKey(gameSessionId); + + Map scoresMap = new HashMap<>(); + scores.forEach((userId, score) -> + scoresMap.put(userId, AttributeValue.builder().n(String.valueOf(score)).build())); + + Map expressionValues = new HashMap<>(); + expressionValues.put(":scores", AttributeValue.builder().m(scoresMap).build()); + + UpdateItemRequest updateRequest = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(key) + .updateExpression("SET scores = :scores") + .expressionAttributeValues(expressionValues) + .build(); + + AwsClients.dynamoDb().updateItem(updateRequest); + logger.info("Updated scores for game session: {}", gameSessionId); + } + + /** + * 정답자 추가 + */ + public void addCorrectGuesser(String gameSessionId, String userId) { + Map key = buildKey(gameSessionId); + + Map expressionValues = new HashMap<>(); + expressionValues.put(":userId", AttributeValue.builder().l( + AttributeValue.builder().s(userId).build() + ).build()); + expressionValues.put(":emptyList", AttributeValue.builder().l(List.of()).build()); + + UpdateItemRequest updateRequest = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(key) + .updateExpression("SET correctGuessers = list_append(if_not_exists(correctGuessers, :emptyList), :userId)") + .expressionAttributeValues(expressionValues) + .build(); + + AwsClients.dynamoDb().updateItem(updateRequest); + logger.info("Added correct guesser: gameSession={}, userId={}", gameSessionId, userId); + } + + /** + * 연속 정답(streak) 업데이트 + */ + public void updateStreak(String gameSessionId, String userId, int streak) { + Map key = buildKey(gameSessionId); + + Map expressionValues = new HashMap<>(); + expressionValues.put(":streak", AttributeValue.builder().n(String.valueOf(streak)).build()); + + Map expressionNames = new HashMap<>(); + expressionNames.put("#streaks", "streaks"); + expressionNames.put("#userId", userId); + + UpdateItemRequest updateRequest = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(key) + .updateExpression("SET #streaks.#userId = :streak") + .expressionAttributeNames(expressionNames) + .expressionAttributeValues(expressionValues) + .build(); + + AwsClients.dynamoDb().updateItem(updateRequest); + logger.info("Updated streak: gameSession={}, userId={}, streak={}", gameSessionId, userId, streak); + } + + /** + * 힌트 사용 처리 + */ + public void markHintUsed(String gameSessionId) { + Map key = buildKey(gameSessionId); + + Map expressionValues = new HashMap<>(); + expressionValues.put(":hintUsed", AttributeValue.builder().bool(true).build()); + + UpdateItemRequest updateRequest = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(key) + .updateExpression("SET hintUsed = :hintUsed") + .expressionAttributeValues(expressionValues) + .build(); + + AwsClients.dynamoDb().updateItem(updateRequest); + logger.info("Marked hint used for game session: {}", gameSessionId); + } + + /** + * 게임 종료 처리 + */ + public void finishGame(String gameSessionId, long endedAt, long ttl) { + Map key = buildKey(gameSessionId); + + Map expressionValues = new HashMap<>(); + expressionValues.put(":status", AttributeValue.builder().s("FINISHED").build()); + expressionValues.put(":endedAt", AttributeValue.builder().n(String.valueOf(endedAt)).build()); + expressionValues.put(":ttl", AttributeValue.builder().n(String.valueOf(ttl)).build()); + + UpdateItemRequest updateRequest = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(key) + .updateExpression("SET #status = :status, endedAt = :endedAt, #ttl = :ttl") + .expressionAttributeNames(Map.of("#status", "status", "#ttl", "ttl")) + .expressionAttributeValues(expressionValues) + .build(); + + AwsClients.dynamoDb().updateItem(updateRequest); + logger.info("Finished game session: {}", gameSessionId); + } + + /** + * DynamoDB 키 빌더 헬퍼 + */ + private Map buildKey(String gameSessionId) { + Map key = new HashMap<>(); + key.put("PK", AttributeValue.builder().s("GAME#" + gameSessionId).build()); + key.put("SK", AttributeValue.builder().s("METADATA").build()); + return key; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/RoomTokenRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/RoomTokenRepository.java index fa6aee2e..7ddadc5f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/RoomTokenRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/RoomTokenRepository.java @@ -5,6 +5,7 @@ import com.mzc.secondproject.serverless.domain.chatting.model.RoomToken; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; import software.amazon.awssdk.enhanced.dynamodb.Key; import software.amazon.awssdk.enhanced.dynamodb.TableSchema; @@ -18,8 +19,18 @@ public class RoomTokenRepository { private final DynamoDbTable table; + /** + * 기본 생성자 (Lambda에서 사용) + */ public RoomTokenRepository() { - this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(RoomToken.class)); + this(AwsClients.dynamoDbEnhanced()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public RoomTokenRepository(DynamoDbEnhancedClient enhancedClient) { + this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(RoomToken.class)); } public RoomToken save(RoomToken roomToken) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatMessageService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatMessageService.java index 1bc1c594..f601ceed 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatMessageService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatMessageService.java @@ -14,8 +14,18 @@ public class ChatMessageService { private final ChatMessageRepository repository; + /** + * 기본 생성자 (Lambda에서 사용) + */ public ChatMessageService() { - this.repository = new ChatMessageRepository(); + this(new ChatMessageRepository()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public ChatMessageService(ChatMessageRepository repository) { + this.repository = repository; } public ChatMessage saveMessage(ChatMessage message) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomCommandService.java index b278c8b9..6308a76c 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomCommandService.java @@ -1,19 +1,24 @@ package com.mzc.secondproject.serverless.domain.chatting.service; +import com.mzc.secondproject.serverless.common.util.ResponseGenerator; +import com.mzc.secondproject.serverless.common.util.WebSocketBroadcaster; +import com.mzc.secondproject.serverless.common.util.WebSocketMessageHelper; import com.mzc.secondproject.serverless.domain.chatting.dto.response.JoinRoomResponse; import com.mzc.secondproject.serverless.domain.chatting.exception.ChattingException; import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; +import com.mzc.secondproject.serverless.domain.chatting.model.Connection; +import com.mzc.secondproject.serverless.domain.chatting.model.GameSettings; import com.mzc.secondproject.serverless.domain.chatting.model.RoomToken; import com.mzc.secondproject.serverless.domain.chatting.repository.ChatRoomRepository; +import com.mzc.secondproject.serverless.domain.chatting.repository.ConnectionRepository; +import com.mzc.secondproject.serverless.domain.user.model.User; +import com.mzc.secondproject.serverless.domain.user.repository.UserRepository; import org.mindrot.jbcrypt.BCrypt; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.time.Instant; -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; -import java.util.UUID; +import java.util.*; /** * ChatRoom 변경 전용 서비스 (CQRS Command) @@ -24,22 +29,50 @@ public class ChatRoomCommandService { private final ChatRoomRepository roomRepository; private final RoomTokenService roomTokenService; + private final ConnectionRepository connectionRepository; + private final WebSocketBroadcaster broadcaster; + private final UserRepository userRepository; + /** + * 기본 생성자 (Lambda에서 사용) + */ public ChatRoomCommandService() { - this.roomRepository = new ChatRoomRepository(); - this.roomTokenService = new RoomTokenService(); + this(new ChatRoomRepository(), new RoomTokenService(), new ConnectionRepository(), + new WebSocketBroadcaster(), new UserRepository()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public ChatRoomCommandService(ChatRoomRepository roomRepository, + RoomTokenService roomTokenService, + ConnectionRepository connectionRepository, + WebSocketBroadcaster broadcaster, + UserRepository userRepository) { + this.roomRepository = roomRepository; + this.roomTokenService = roomTokenService; + this.connectionRepository = connectionRepository; + this.broadcaster = broadcaster; + this.userRepository = userRepository; } public ChatRoom createRoom(String name, String description, String level, Integer maxMembers, - Boolean isPrivate, String password, String createdBy) { + Boolean isPrivate, String password, String createdBy, + String type, String gameType, GameSettings gameSettings) { String roomId = UUID.randomUUID().toString(); String now = Instant.now().toString(); + // GSI1SK 포맷: {type}#{gameType}#{status}#{level}#{createdAt} + String roomType = type != null ? type : "CHAT"; + String roomGameType = gameType != null ? gameType : "-"; + String roomStatus = "WAITING"; + String gsi1sk = String.format("%s#%s#%s#%s#%s", roomType, roomGameType, roomStatus, level, now); + ChatRoom room = ChatRoom.builder() .pk("ROOM#" + roomId) .sk("METADATA") .gsi1pk("ROOMS") - .gsi1sk(level + "#" + now) + .gsi1sk(gsi1sk) .roomId(roomId) .name(name) .description(description) @@ -52,6 +85,11 @@ public ChatRoom createRoom(String name, String description, String level, Intege .createdAt(now) .lastMessageAt(now) .memberIds(new ArrayList<>(List.of(createdBy))) + .type(type != null ? type : "CHAT") + .gameType(gameType) + .gameSettings(gameSettings) + .status("WAITING") + .hostId(createdBy) .build(); roomRepository.save(room); @@ -110,16 +148,51 @@ public LeaveResult leaveRoom(String roomId, String userId) { room.setCurrentMembers(Math.max(0, room.getCurrentMembers() - 1)); } - if (userId.equals(room.getCreatedBy()) || room.getCurrentMembers() <= 0) { + // 모든 참가자가 나갔으면 방 삭제 + if (room.getCurrentMembers() <= 0 || + (room.getMemberIds() != null && room.getMemberIds().isEmpty())) { roomRepository.delete(roomId); - logger.info("Room {} deleted (owner left or empty)", roomId); - return new LeaveResult(true, null); + logger.info("Room {} deleted (empty)", roomId); + return new LeaveResult(true, null, null); + } + + // 방장이 나갔으면 다음 멤버에게 방장 이전 + String oldHostId = room.getHostId() != null ? room.getHostId() : room.getCreatedBy(); + String newHostId = null; + + if (userId.equals(oldHostId)) { + // 첫 번째 남은 멤버가 새 방장 + if (room.getMemberIds() != null && !room.getMemberIds().isEmpty()) { + newHostId = room.getMemberIds().get(0); + room.setHostId(newHostId); + logger.info("Host transferred from {} to {} in room {}", oldHostId, newHostId, roomId); + } } roomRepository.save(room); logger.info("User {} left room {}", userId, roomId); - return new LeaveResult(false, room); + // 방장이 나갔으면 다음 멤버에게 방장 이전 후 WebSocket 알림 + if (userId.equals(oldHostId) && newHostId != null) { + // 새 방장 닉네임 조회 + String newHostNickname = userRepository.findByCognitoSub(newHostId) + .map(User::getNickname) + .orElse(newHostId); + + // WebSocket 알림 브로드캐스트 + try { + List connections = connectionRepository.findByRoomId(roomId); + Map message = WebSocketMessageHelper.buildHostChangeMessage( + roomId, newHostId, newHostNickname); + String json = ResponseGenerator.gson().toJson(message); + broadcaster.broadcast(connections, json); + logger.info("Broadcasted host change: roomId={}, newHostId={}", roomId, newHostId); + } catch (Exception e) { + logger.error("Failed to broadcast host change: {}", e.getMessage()); + } + } + + return new LeaveResult(false, room, newHostId); } public void deleteRoom(String roomId, String userId) { @@ -137,6 +210,6 @@ public void deleteRoom(String roomId, String userId) { logger.info("Deleted room: {} by owner: {}", roomId, userId); } - public record LeaveResult(boolean deleted, ChatRoom room) { + public record LeaveResult(boolean deleted, ChatRoom room, String newHostId) { } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomQueryService.java index d99657db..247d8476 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomQueryService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/ChatRoomQueryService.java @@ -1,8 +1,11 @@ package com.mzc.secondproject.serverless.domain.chatting.service; import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.domain.chatting.dto.response.RoomParticipant; import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; import com.mzc.secondproject.serverless.domain.chatting.repository.ChatRoomRepository; +import com.mzc.secondproject.serverless.domain.user.model.User; +import com.mzc.secondproject.serverless.domain.user.repository.UserRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -17,20 +20,42 @@ public class ChatRoomQueryService { private static final Logger logger = LoggerFactory.getLogger(ChatRoomQueryService.class); private final ChatRoomRepository roomRepository; + private final UserRepository userRepository; + /** + * 기본 생성자 (Lambda에서 사용) + */ public ChatRoomQueryService() { - this.roomRepository = new ChatRoomRepository(); + this(new ChatRoomRepository(), new UserRepository()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public ChatRoomQueryService(ChatRoomRepository roomRepository, UserRepository userRepository) { + this.roomRepository = roomRepository; + this.userRepository = userRepository; } public Optional getRoom(String roomId) { return roomRepository.findById(roomId); } - public PaginatedResult getRooms(String level, int limit, String cursor) { - if (level != null && !level.isEmpty()) { - return roomRepository.findByLevelWithPagination(level, limit, cursor); - } - return roomRepository.findAllWithPagination(limit, cursor); + /** + * 필터 조건으로 방 목록 조회 (DB 레벨 필터링) + * GSI1SK 포맷: {type}#{gameType}#{status}#{level}#{createdAt} + * + * @param level 레벨 필터 (beginner, intermediate, advanced) + * @param limit 조회 개수 + * @param cursor 페이지네이션 커서 + * @param type 방 타입 (CHAT, GAME) + * @param gameType 게임 타입 (CATCHMIND 등) + * @param status 방 상태 (WAITING, PLAYING, FINISHED) + * @return 필터링된 방 목록 + */ + public PaginatedResult getRooms(String level, int limit, String cursor, String type, String gameType, String status) { + // DB 레벨에서 필터링 (메모리 필터링 제거) + return roomRepository.findByFilters(type, gameType, status, level, limit, cursor); } public List filterByJoinedUser(List rooms, String userId) { @@ -38,4 +63,42 @@ public List filterByJoinedUser(List rooms, String userId) { .filter(room -> room.getMemberIds() != null && room.getMemberIds().contains(userId)) .toList(); } + + /** + * 참가자 목록을 닉네임과 함께 조회 + * + * @param room ChatRoom 객체 + * @return 참가자 목록 (userId, nickname, isHost 포함) + */ + public List getParticipantsWithNicknames(ChatRoom room) { + if (room.getMemberIds() == null) return List.of(); + + String hostId = room.getHostId() != null ? room.getHostId() : room.getCreatedBy(); + + return room.getMemberIds().stream() + .map(userId -> { + String nickname = userRepository.findByCognitoSub(userId) + .map(User::getNickname) + .orElse(userId); // fallback to userId if not found + return RoomParticipant.builder() + .userId(userId) + .nickname(nickname) + .isHost(userId.equals(hostId)) + .build(); + }) + .toList(); + } + + /** + * 방장 닉네임 조회 + * + * @param room ChatRoom 객체 + * @return 방장 닉네임 (없으면 userId 반환) + */ + public String getHostNickname(ChatRoom room) { + String hostId = room.getHostId() != null ? room.getHostId() : room.getCreatedBy(); + return userRepository.findByCognitoSub(hostId) + .map(User::getNickname) + .orElse(hostId); + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/CommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/CommandService.java index 9fbda0b7..71e0ddf2 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/CommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/CommandService.java @@ -2,10 +2,10 @@ import com.mzc.secondproject.serverless.domain.chatting.dto.response.CommandResult; import com.mzc.secondproject.serverless.domain.chatting.enums.MessageType; -import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; import com.mzc.secondproject.serverless.domain.chatting.model.Connection; -import com.mzc.secondproject.serverless.domain.chatting.repository.ChatRoomRepository; +import com.mzc.secondproject.serverless.domain.chatting.model.GameSession; import com.mzc.secondproject.serverless.domain.chatting.repository.ConnectionRepository; +import com.mzc.secondproject.serverless.domain.chatting.repository.GameSessionRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -20,13 +20,25 @@ public class CommandService { private static final Logger logger = LoggerFactory.getLogger(CommandService.class); private final ConnectionRepository connectionRepository; - private final ChatRoomRepository chatRoomRepository; + private final GameSessionRepository gameSessionRepository; private final GameService gameService; + /** + * 기본 생성자 (Lambda에서 사용) + */ public CommandService() { - this.connectionRepository = new ConnectionRepository(); - this.chatRoomRepository = new ChatRoomRepository(); - this.gameService = new GameService(); + this(new ConnectionRepository(), new GameSessionRepository(), new GameService()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public CommandService(ConnectionRepository connectionRepository, + GameSessionRepository gameSessionRepository, + GameService gameService) { + this.connectionRepository = connectionRepository; + this.gameSessionRepository = gameSessionRepository; + this.gameService = gameService; } /** @@ -90,8 +102,8 @@ private CommandResult handleStartCommand(String roomId, String userId) { 라운드 1 시작! 출제자: %s """, - result.room().getTotalRounds(), - result.room().getCurrentDrawerId()); + result.session().getTotalRounds(), + result.session().getCurrentDrawerId()); return CommandResult.success(MessageType.GAME_START, message, result); } @@ -107,28 +119,23 @@ private CommandResult handleStopCommand(String roomId, String userId) { * /score - 현재 점수 조회 */ private CommandResult handleScoreCommand(String roomId) { - Optional optRoom = chatRoomRepository.findById(roomId); - if (optRoom.isEmpty()) { - return CommandResult.error("채팅방을 찾을 수 없습니다."); - } - - ChatRoom room = optRoom.get(); - - if (room.getGameStatus() == null || "NONE".equals(room.getGameStatus())) { + Optional optSession = gameSessionRepository.findActiveByRoomId(roomId); + if (optSession.isEmpty()) { return CommandResult.error("진행 중인 게임이 없습니다."); } - // TODO: 점수 포맷팅 (Story #225에서 구현) - if (room.getScores() == null || room.getScores().isEmpty()) { + GameSession session = optSession.get(); + + if (session.getScores() == null || session.getScores().isEmpty()) { return CommandResult.success(MessageType.SCORE_UPDATE, "아직 점수가 없습니다."); } StringBuilder sb = new StringBuilder("📊 현재 점수:\n"); - room.getScores().entrySet().stream() + session.getScores().entrySet().stream() .sorted((a, b) -> b.getValue().compareTo(a.getValue())) .forEach(entry -> sb.append(String.format(" %s: %d점\n", entry.getKey(), entry.getValue()))); - return CommandResult.success(MessageType.SCORE_UPDATE, sb.toString(), room.getScores()); + return CommandResult.success(MessageType.SCORE_UPDATE, sb.toString(), session.getScores()); } /** diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameSchedulerClient.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameSchedulerClient.java new file mode 100644 index 00000000..9a8e96c9 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameSchedulerClient.java @@ -0,0 +1,151 @@ +package com.mzc.secondproject.serverless.domain.chatting.service; + +import com.mzc.secondproject.serverless.common.config.EnvConfig; +import com.mzc.secondproject.serverless.domain.chatting.config.GameConfig; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.services.scheduler.SchedulerClient; +import software.amazon.awssdk.services.scheduler.model.*; + +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; + +/** + * EventBridge Scheduler를 사용한 게임 자동 종료 스케줄링 + */ +public class GameSchedulerClient { + + private static final Logger logger = LoggerFactory.getLogger(GameSchedulerClient.class); + + private static final String SCHEDULE_GROUP = "game-auto-close"; + private static final String SCHEDULE_NAME_PREFIX = "game-close-"; + + private final SchedulerClient schedulerClient; + private final String targetLambdaArn; + private final String roleArn; + + /** + * 기본 생성자 (Lambda에서 사용) + */ + public GameSchedulerClient() { + this(SchedulerClient.create(), + EnvConfig.getOrDefault("GAME_AUTO_CLOSE_LAMBDA_ARN", null), + EnvConfig.getOrDefault("SCHEDULER_ROLE_ARN", null)); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public GameSchedulerClient(SchedulerClient schedulerClient, String targetLambdaArn, String roleArn) { + this.schedulerClient = schedulerClient; + this.targetLambdaArn = targetLambdaArn; + this.roleArn = roleArn; + } + + /** + * 게임 자동 종료 스케줄 생성 + * + * @param gameSessionId 게임 세션 ID + * @param roomId 방 ID + * @return 스케줄 ARN (실패 시 null) + */ + public ScheduleResult createGameEndSchedule(String gameSessionId, String roomId) { + if (targetLambdaArn == null || roleArn == null) { + logger.warn("Scheduler not configured: GAME_AUTO_CLOSE_LAMBDA_ARN or SCHEDULER_ROLE_ARN not set"); + return new ScheduleResult(null, 0L); + } + + try { + // 7분 후 시간 계산 + long scheduledAtMs = System.currentTimeMillis() + (GameConfig.gameTimeLimit() * 1000L); + Instant scheduledAt = Instant.ofEpochMilli(scheduledAtMs); + + // at() 표현식: at(yyyy-mm-ddThh:mm:ss) + String atExpression = "at(" + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss") + .withZone(ZoneOffset.UTC) + .format(scheduledAt) + ")"; + + String scheduleName = SCHEDULE_NAME_PREFIX + gameSessionId; + + // Lambda 호출 시 전달할 페이로드 + String payload = String.format("{\"gameSessionId\":\"%s\",\"roomId\":\"%s\"}", gameSessionId, roomId); + + CreateScheduleRequest request = CreateScheduleRequest.builder() + .name(scheduleName) + .groupName(SCHEDULE_GROUP) + .scheduleExpression(atExpression) + .scheduleExpressionTimezone("UTC") + .flexibleTimeWindow(FlexibleTimeWindow.builder() + .mode(FlexibleTimeWindowMode.OFF) + .build()) + .target(Target.builder() + .arn(targetLambdaArn) + .roleArn(roleArn) + .input(payload) + .build()) + .actionAfterCompletion(ActionAfterCompletion.DELETE) // 실행 후 자동 삭제 + .build(); + + CreateScheduleResponse response = schedulerClient.createSchedule(request); + + logger.info("Game end schedule created: gameSessionId={}, scheduledAt={}, arn={}", + gameSessionId, scheduledAt, response.scheduleArn()); + + return new ScheduleResult(response.scheduleArn(), scheduledAtMs); + + } catch (ConflictException e) { + logger.warn("Schedule already exists: gameSessionId={}", gameSessionId); + return new ScheduleResult(null, 0L); + + } catch (Exception e) { + logger.error("Failed to create game end schedule: gameSessionId={}, error={}", + gameSessionId, e.getMessage()); + return new ScheduleResult(null, 0L); + } + } + + /** + * 게임 자동 종료 스케줄 취소 + * + * @param gameSessionId 게임 세션 ID + * @return 취소 성공 여부 + */ + public boolean cancelGameEndSchedule(String gameSessionId) { + if (targetLambdaArn == null) { + return true; // 스케줄러 미설정 시 무시 + } + + try { + String scheduleName = SCHEDULE_NAME_PREFIX + gameSessionId; + + DeleteScheduleRequest request = DeleteScheduleRequest.builder() + .name(scheduleName) + .groupName(SCHEDULE_GROUP) + .build(); + + schedulerClient.deleteSchedule(request); + + logger.info("Game end schedule cancelled: gameSessionId={}", gameSessionId); + return true; + + } catch (ResourceNotFoundException e) { + logger.debug("Schedule not found (may have already executed): gameSessionId={}", gameSessionId); + return true; // 이미 삭제되었거나 없는 경우 + + } catch (Exception e) { + logger.error("Failed to cancel game end schedule: gameSessionId={}, error={}", + gameSessionId, e.getMessage()); + return false; + } + } + + /** + * 스케줄 생성 결과 + */ + public record ScheduleResult(String scheduleArn, long scheduledAtMs) { + public boolean success() { + return scheduleArn != null; + } + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java index e19f48b3..a82c16d2 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java @@ -8,9 +8,11 @@ import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; import com.mzc.secondproject.serverless.domain.chatting.model.Connection; import com.mzc.secondproject.serverless.domain.chatting.model.GameRound; +import com.mzc.secondproject.serverless.domain.chatting.model.GameSession; import com.mzc.secondproject.serverless.domain.chatting.repository.ChatRoomRepository; import com.mzc.secondproject.serverless.domain.chatting.repository.ConnectionRepository; import com.mzc.secondproject.serverless.domain.chatting.repository.GameRoundRepository; +import com.mzc.secondproject.serverless.domain.chatting.repository.GameSessionRepository; import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; import com.mzc.secondproject.serverless.domain.vocabulary.repository.WordRepository; import org.slf4j.Logger; @@ -22,6 +24,7 @@ /** * 캐치마인드 게임 로직 서비스 + * GameSession 모델을 사용하여 게임 상태 관리 */ public class GameService { @@ -30,28 +33,67 @@ public class GameService { private final ChatRoomRepository chatRoomRepository; private final ConnectionRepository connectionRepository; private final GameRoundRepository gameRoundRepository; + private final GameSessionRepository gameSessionRepository; private final WordRepository wordRepository; private final GameStatsService gameStatsService; + private final GameSchedulerClient gameSchedulerClient; /** * 기본 생성자 (Lambda에서 사용) */ public GameService() { this(new ChatRoomRepository(), new ConnectionRepository(), - new GameRoundRepository(), new WordRepository(), new GameStatsService()); + new GameRoundRepository(), new GameSessionRepository(), + new WordRepository(), new GameStatsService(), new GameSchedulerClient()); } /** * 의존성 주입 생성자 (테스트 용이성) */ public GameService(ChatRoomRepository chatRoomRepository, ConnectionRepository connectionRepository, - GameRoundRepository gameRoundRepository, WordRepository wordRepository, - GameStatsService gameStatsService) { + GameRoundRepository gameRoundRepository, GameSessionRepository gameSessionRepository, + WordRepository wordRepository, GameStatsService gameStatsService, + GameSchedulerClient gameSchedulerClient) { this.chatRoomRepository = chatRoomRepository; this.connectionRepository = connectionRepository; this.gameRoundRepository = gameRoundRepository; + this.gameSessionRepository = gameSessionRepository; this.wordRepository = wordRepository; this.gameStatsService = gameStatsService; + this.gameSchedulerClient = gameSchedulerClient; + } + + /** + * 게임 재시작 + */ + public GameStartResult restartGame(String roomId, String userId) { + ChatRoom room = chatRoomRepository.findById(roomId) + .orElseThrow(() -> new IllegalArgumentException("채팅방을 찾을 수 없습니다.")); + + // 방장 권한 확인 + if (!userId.equals(room.getHostId()) && !userId.equals(room.getCreatedBy())) { + return GameStartResult.error("방장만 게임을 시작할 수 있습니다."); + } + + // 방 타입 검증 + if (room.getType() == null || !"GAME".equalsIgnoreCase(room.getType())) { + return GameStartResult.error("게임은 게임 방에서만 시작할 수 있습니다."); + } + + // FINISHED 상태인지 확인 (이미 게임이 끝났어야 재시작 가능) + Optional existingSession = gameSessionRepository.findActiveByRoomId(roomId); + if (existingSession.isPresent()) { + return GameStartResult.error("게임 진행 중에는 재시작할 수 없습니다."); + } + + // 접속자 확인 + List connections = connectionRepository.findByRoomId(roomId); + if (connections.size() < 2) { + return GameStartResult.error("최소 2명 이상 접속해야 게임을 시작할 수 있습니다."); + } + + // 기존 startGame 로직 재사용 - 내부적으로 startGame 호출 + return startGame(roomId, userId); } /** @@ -61,9 +103,15 @@ public GameStartResult startGame(String roomId, String userId) { ChatRoom room = chatRoomRepository.findById(roomId) .orElseThrow(() -> new IllegalArgumentException("채팅방을 찾을 수 없습니다.")); - // 이미 게임 중인지 확인 - GameStatus currentStatus = GameStatus.fromString(room.getGameStatus()); - if (!currentStatus.canStartGame()) { + // 방 타입 검증 - GAME 타입만 게임 시작 가능 + String roomType = room.getType(); + if (roomType == null || !"GAME".equalsIgnoreCase(roomType)) { + return GameStartResult.error("게임은 게임 방에서만 시작할 수 있습니다."); + } + + // 이미 활성 게임 세션이 있는지 확인 + Optional existingSession = gameSessionRepository.findActiveByRoomId(roomId); + if (existingSession.isPresent()) { return GameStartResult.error("이미 게임이 진행 중입니다."); } @@ -87,27 +135,54 @@ public GameStartResult startGame(String roomId, String userId) { return GameStartResult.error("단어가 부족합니다. 관리자에게 문의하세요."); } - // 게임 상태 업데이트 - room.setGameStatus(GameStatus.PLAYING.name()); - room.setGameStartedBy(userId); - room.setCurrentRound(1); - room.setTotalRounds(GameConfig.totalRounds()); - room.setDrawerOrder(drawerOrder); - room.setScores(new HashMap<>()); - room.setStreaks(new HashMap<>()); - room.setRoundTimeLimit(GameConfig.roundTimeLimit()); + // 게임 세션 생성 + String gameSessionId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + long currentTime = System.currentTimeMillis(); - // 첫 라운드 설정 String firstDrawer = drawerOrder.get(0); Word firstWord = words.get(0); - room.setCurrentDrawerId(firstDrawer); - room.setCurrentWordId(firstWord.getWordId()); - room.setCurrentWord(firstWord.getKorean()); - room.setRoundStartTime(System.currentTimeMillis()); - room.setHintUsed(false); - room.setCorrectGuessers(new ArrayList<>()); - chatRoomRepository.save(room); + GameSession session = GameSession.builder() + .pk("GAME#" + gameSessionId) + .sk("METADATA") + .gsi1pk("ROOM#" + roomId) + .gsi1sk("GAME#" + now) + .gameSessionId(gameSessionId) + .roomId(roomId) + .gameType("catchmind") + .status(GameStatus.PLAYING.name()) + .startedBy(userId) + .startedAt(currentTime) + .currentRound(1) + .totalRounds(GameConfig.totalRounds()) + .currentDrawerId(firstDrawer) + .currentWordId(firstWord.getWordId()) + .currentWord(firstWord.getKorean()) + .currentWordEnglish(firstWord.getEnglish()) + .roundStartTime(currentTime) + .roundDuration(GameConfig.roundTimeLimit()) + .scores(new HashMap<>()) + .streaks(new HashMap<>()) + .players(new ArrayList<>(drawerOrder)) + .drawerOrder(drawerOrder) + .hintUsed(false) + .correctGuessers(new ArrayList<>()) + .build(); + + gameSessionRepository.save(session); + + // 게임 자동 종료 스케줄 생성 (7분 후) + GameSchedulerClient.ScheduleResult scheduleResult = gameSchedulerClient.createGameEndSchedule(gameSessionId, roomId); + if (scheduleResult.success()) { + session.setScheduleRuleArn(scheduleResult.scheduleArn()); + session.setGameEndScheduledAt(scheduleResult.scheduledAtMs()); + gameSessionRepository.save(session); + } + + // ChatRoom에 활성 게임 세션 ID 연결 및 상태 업데이트 (GSI1SK 포함) + room.setActiveGameSessionId(gameSessionId); + chatRoomRepository.updateStatus(room, "PLAYING"); // 첫 라운드 기록 생성 (7일 후 자동 삭제) long ttlSeconds = Instant.now().plusSeconds(7 * 24 * 60 * 60).getEpochSecond(); @@ -120,20 +195,21 @@ public GameStartResult startGame(String roomId, String userId) { .wordId(firstWord.getWordId()) .word(firstWord.getKorean()) .wordEnglish(firstWord.getEnglish()) - .startTime(System.currentTimeMillis()) + .startTime(currentTime) .hintUsed(false) .correctGuessers(new ArrayList<>()) .guessTimes(new HashMap<>()) .roundScores(new HashMap<>()) - .createdAt(Instant.now().toString()) + .createdAt(now) .ttl(ttlSeconds) .build(); gameRoundRepository.save(firstRound); - logger.info("Game started: roomId={}, starter={}, rounds={}", roomId, userId, GameConfig.totalRounds()); + logger.info("Game started: roomId={}, sessionId={}, starter={}, rounds={}", + roomId, gameSessionId, userId, GameConfig.totalRounds()); - return GameStartResult.success(room, firstWord, drawerOrder); + return GameStartResult.success(session, firstWord, drawerOrder); } /** @@ -143,153 +219,138 @@ public CommandResult stopGame(String roomId, String userId) { ChatRoom room = chatRoomRepository.findById(roomId) .orElseThrow(() -> new IllegalArgumentException("채팅방을 찾을 수 없습니다.")); - GameStatus currentStatus = GameStatus.fromString(room.getGameStatus()); - if (!currentStatus.isGameActive()) { + GameSession session = gameSessionRepository.findActiveByRoomId(roomId) + .orElse(null); + + if (session == null || !session.isActive()) { return CommandResult.error("진행 중인 게임이 없습니다."); } // 권한 확인 boolean isOwner = userId.equals(room.getCreatedBy()); - boolean isGameStarter = userId.equals(room.getGameStartedBy()); + boolean isGameStarter = userId.equals(session.getStartedBy()); if (!isOwner && !isGameStarter) { return CommandResult.error("게임을 중단할 권한이 없습니다."); } // 게임 종료 처리 - return finishGame(room, "STOPPED"); + return finishGame(session, room, "STOPPED"); } /** * 정답 체크 */ public AnswerCheckResult checkAnswer(String roomId, String userId, String answer) { - ChatRoom room = chatRoomRepository.findById(roomId) - .orElseThrow(() -> new IllegalArgumentException("채팅방을 찾을 수 없습니다.")); + GameSession session = gameSessionRepository.findActiveByRoomId(roomId) + .orElse(null); // 게임 진행 중인지 확인 - if (!GameStatus.PLAYING.name().equals(room.getGameStatus())) { + if (session == null || !GameStatus.PLAYING.name().equals(session.getStatus())) { return AnswerCheckResult.gameNotPlaying(); } // 출제자는 정답 체크 제외 - if (userId.equals(room.getCurrentDrawerId())) { + if (session.isDrawer(userId)) { return AnswerCheckResult.drawerCannotGuess(); } // 이미 맞춘 사람인지 확인 - if (room.getCorrectGuessers() != null && room.getCorrectGuessers().contains(userId)) { + if (session.hasAlreadyGuessedCorrect(userId)) { return AnswerCheckResult.alreadyGuessedCorrect(); } // 정답 체크 (한국어 또는 영어 둘 다 허용) - String koreanWord = room.getCurrentWord(); - String englishWord = null; - - // 영어 단어 조회 - if (room.getCurrentWordId() != null) { - englishWord = wordRepository.findById(room.getCurrentWordId()) - .map(Word::getEnglish) - .orElse(null); - } - - boolean isCorrect = isCorrectAnswer(answer, koreanWord) || - (englishWord != null && isCorrectAnswer(answer, englishWord)); - - if (!isCorrect) { + String koreanWord = session.getCurrentWord(); + String englishWord = session.getCurrentWordEnglish(); + if (!isCorrectAnswer(answer, koreanWord, englishWord)) { return AnswerCheckResult.wrongAnswer(); } // 정답 처리 - long elapsedTime = System.currentTimeMillis() - room.getRoundStartTime(); + long elapsedTime = System.currentTimeMillis() - session.getRoundStartTime(); // 연속 정답 업데이트 (점수 계산 전에) - if (room.getStreaks() == null) { - room.setStreaks(new HashMap<>()); - } - int currentStreak = room.getStreaks().getOrDefault(userId, 0) + 1; - room.getStreaks().put(userId, currentStreak); + int currentStreak = session.incrementStreak(userId); - int score = calculateScore(room, elapsedTime, userId, currentStreak); + int score = calculateScore(session, elapsedTime, userId, currentStreak); // 정답자 목록에 추가 - if (room.getCorrectGuessers() == null) { - room.setCorrectGuessers(new ArrayList<>()); - } - room.getCorrectGuessers().add(userId); + session.addCorrectGuesser(userId); // 점수 업데이트 - if (room.getScores() == null) { - room.setScores(new HashMap<>()); - } - room.getScores().merge(userId, score, Integer::sum); + session.addScore(userId, score); // 출제자 점수도 추가 - room.getScores().merge(room.getCurrentDrawerId(), 5, Integer::sum); + session.addScore(session.getCurrentDrawerId(), 5); - chatRoomRepository.save(room); + gameSessionRepository.save(session); // 라운드 기록 업데이트 - updateRoundRecord(roomId, room.getCurrentRound(), userId, elapsedTime, score); + updateRoundRecord(roomId, session.getCurrentRound(), userId, elapsedTime, score); // 전원 정답 체크 List connections = connectionRepository.findByRoomId(roomId); int nonDrawerCount = (int) connections.stream() - .filter(c -> !c.getUserId().equals(room.getCurrentDrawerId())) + .filter(c -> !c.getUserId().equals(session.getCurrentDrawerId())) .count(); - boolean allCorrect = room.getCorrectGuessers().size() >= nonDrawerCount; + boolean allCorrect = session.getCorrectGuessers().size() >= nonDrawerCount; logger.info("Answer correct: roomId={}, userId={}, score={}, allCorrect={}", roomId, userId, score, allCorrect); - return AnswerCheckResult.correctAnswer(score, elapsedTime, allCorrect, room.getScores()); + return AnswerCheckResult.correctAnswer(score, elapsedTime, allCorrect, session.getScores()); } /** - * 라운드 스킵 (누구나 가능) + * 라운드 스킵 */ public CommandResult skipRound(String roomId, String userId) { ChatRoom room = chatRoomRepository.findById(roomId) .orElseThrow(() -> new IllegalArgumentException("채팅방을 찾을 수 없습니다.")); - - if (!GameStatus.PLAYING.name().equals(room.getGameStatus())) { + + GameSession session = gameSessionRepository.findActiveByRoomId(roomId) + .orElse(null); + + if (session == null || !GameStatus.PLAYING.name().equals(session.getStatus())) { return CommandResult.error("게임이 진행 중이 아닙니다."); } - - // 출제자 제한 제거 - 누구나 스킵 가능 - logger.info("Round skipped by user: {} in room: {}", userId, roomId); - - return endRound(room, "SKIP"); + + if (!session.isDrawer(userId)) { + return CommandResult.error("출제자만 라운드를 스킵할 수 있습니다."); + } + + return endRound(session, room, "SKIP"); } /** * 힌트 제공 */ public CommandResult provideHint(String roomId, String userId) { - ChatRoom room = chatRoomRepository.findById(roomId) - .orElseThrow(() -> new IllegalArgumentException("채팅방을 찾을 수 없습니다.")); + GameSession session = gameSessionRepository.findActiveByRoomId(roomId) + .orElse(null); - if (!GameStatus.PLAYING.name().equals(room.getGameStatus())) { + if (session == null || !GameStatus.PLAYING.name().equals(session.getStatus())) { return CommandResult.error("게임이 진행 중이 아닙니다."); } - if (!userId.equals(room.getCurrentDrawerId())) { + if (!session.isDrawer(userId)) { return CommandResult.error("출제자만 힌트를 제공할 수 있습니다."); } - if (Boolean.TRUE.equals(room.getHintUsed())) { + if (Boolean.TRUE.equals(session.getHintUsed())) { return CommandResult.error("이번 라운드에서 이미 힌트를 사용했습니다."); } - String currentWord = room.getCurrentWord(); + String currentWord = session.getCurrentWord(); String hint = currentWord.charAt(0) + "○".repeat(currentWord.length() - 1); - room.setHintUsed(true); - chatRoomRepository.save(room); + session.setHintUsed(true); + gameSessionRepository.save(session); // 라운드 기록 업데이트 - gameRoundRepository.findByRoomIdAndRound(roomId, room.getCurrentRound()) + gameRoundRepository.findByRoomIdAndRound(roomId, session.getCurrentRound()) .ifPresent(round -> { round.setHintUsed(true); gameRoundRepository.save(round); @@ -299,15 +360,15 @@ public CommandResult provideHint(String roomId, String userId) { } /** - * 라운드 종료 처리 + * 라운드 종료 처리 (GameSession 버전) */ - public CommandResult endRound(ChatRoom room, String reason) { - String roomId = room.getRoomId(); - Integer currentRound = room.getCurrentRound(); - String answer = room.getCurrentWord(); + public CommandResult endRound(GameSession session, ChatRoom room, String reason) { + String roomId = session.getRoomId(); + Integer currentRound = session.getCurrentRound(); + String answer = session.getCurrentWord(); // 정답 못 맞춘 사용자 연속 정답 초기화 - resetStreaksForNonGuessers(room); + resetStreaksForNonGuessers(session); // 라운드 기록 종료 gameRoundRepository.findByRoomIdAndRound(roomId, currentRound) @@ -318,8 +379,8 @@ public CommandResult endRound(ChatRoom room, String reason) { }); // 다음 라운드로 진행 - if (currentRound >= room.getTotalRounds()) { - return finishGame(room, "COMPLETED"); + if (currentRound >= session.getTotalRounds()) { + return finishGame(session, room, "COMPLETED"); } // 현재 접속 중인 사용자 목록 조회 @@ -330,31 +391,34 @@ public CommandResult endRound(ChatRoom room, String reason) { // 접속자가 2명 미만이면 게임 종료 if (connectedUserIds.size() < 2) { - return finishGame(room, "NOT_ENOUGH_PLAYERS"); + return finishGame(session, room, "NOT_ENOUGH_PLAYERS"); } // 다음 라운드 준비 - 접속 중인 사용자 중에서만 출제자 선택 int nextRound = currentRound + 1; - String nextDrawer = selectNextDrawer(room.getDrawerOrder(), connectedUserIds, nextRound); + String nextDrawer = selectNextDrawer(session.getDrawerOrder(), connectedUserIds, nextRound); // 다음 단어 추출 String level = room.getLevel() != null ? room.getLevel() : "beginner"; List words = getRandomWords(level, 1); if (words.isEmpty()) { - return finishGame(room, "NO_WORDS"); + return finishGame(session, room, "NO_WORDS"); } Word nextWord = words.get(0); - // 상태 업데이트 - room.setCurrentRound(nextRound); - room.setCurrentDrawerId(nextDrawer); - room.setCurrentWordId(nextWord.getWordId()); - room.setCurrentWord(nextWord.getKorean()); - room.setRoundStartTime(System.currentTimeMillis()); - room.setHintUsed(false); - room.setCorrectGuessers(new ArrayList<>()); + long currentTime = System.currentTimeMillis(); + + // 세션 상태 업데이트 + session.setCurrentRound(nextRound); + session.setCurrentDrawerId(nextDrawer); + session.setCurrentWordId(nextWord.getWordId()); + session.setCurrentWord(nextWord.getKorean()); + session.setCurrentWordEnglish(nextWord.getEnglish()); + session.setRoundStartTime(currentTime); + session.setHintUsed(false); + session.setCorrectGuessers(new ArrayList<>()); - chatRoomRepository.save(room); + gameSessionRepository.save(session); // 다음 라운드 기록 생성 (7일 후 자동 삭제) long nextTtlSeconds = Instant.now().plusSeconds(7 * 24 * 60 * 60).getEpochSecond(); @@ -367,7 +431,7 @@ public CommandResult endRound(ChatRoom room, String reason) { .wordId(nextWord.getWordId()) .word(nextWord.getKorean()) .wordEnglish(nextWord.getEnglish()) - .startTime(System.currentTimeMillis()) + .startTime(currentTime) .hintUsed(false) .correctGuessers(new ArrayList<>()) .guessTimes(new HashMap<>()) @@ -384,7 +448,7 @@ public CommandResult endRound(ChatRoom room, String reason) { logger.info("Round ended: roomId={}, round={}, reason={}", roomId, currentRound, reason); // ranking 생성 - List> ranking = buildRankingList(room.getScores()); + List> ranking = buildRankingList(session.getScores()); Map data = new HashMap<>(); data.put("answer", answer); @@ -393,21 +457,53 @@ public CommandResult endRound(ChatRoom room, String reason) { data.put("nextWord", nextWord); data.put("ranking", ranking); data.put("currentRound", currentRound); - data.put("totalRounds", room.getTotalRounds()); + data.put("totalRounds", session.getTotalRounds()); + // 타이머 동기화용 필드 추가 + data.put("roundStartTime", session.getRoundStartTime()); + data.put("roundDuration", session.getRoundDuration() != null ? session.getRoundDuration() : GameConfig.roundTimeLimit()); return CommandResult.success(MessageType.ROUND_END, message, data); } + /** + * roomId로 활성 세션을 찾아 라운드 종료 (외부 호출용) + */ + public CommandResult endRound(String roomId, String reason) { + ChatRoom room = chatRoomRepository.findById(roomId) + .orElseThrow(() -> new IllegalArgumentException("채팅방을 찾을 수 없습니다.")); + + GameSession session = gameSessionRepository.findActiveByRoomId(roomId) + .orElse(null); + + if (session == null) { + return CommandResult.error("진행 중인 게임이 없습니다."); + } + + return endRound(session, room, reason); + } + /** * 게임 완전 종료 */ - private CommandResult finishGame(ChatRoom room, String reason) { - room.setGameStatus(GameStatus.FINISHED.name()); - chatRoomRepository.save(room); + private CommandResult finishGame(GameSession session, ChatRoom room, String reason) { + long currentTime = System.currentTimeMillis(); + long ttlSeconds = Instant.now().plusSeconds(30 * 24 * 60 * 60).getEpochSecond(); // 30일 보관 + + // 자동 종료 스케줄 취소 (TIME_EXPIRED가 아닌 경우에만) + if (!"TIME_EXPIRED".equals(reason)) { + gameSchedulerClient.cancelGameEndSchedule(session.getGameSessionId()); + } + + // 게임 세션 종료 처리 + gameSessionRepository.finishGame(session.getGameSessionId(), currentTime, ttlSeconds); + + // ChatRoom에서 활성 게임 세션 참조 제거 및 상태 업데이트 (GSI1SK 포함) + room.setActiveGameSessionId(null); + chatRoomRepository.updateStatus(room, "WAITING"); // 게임 통계 업데이트 및 뱃지 체크 try { - var newBadges = gameStatsService.updateGameStats(room); + var newBadges = gameStatsService.updateGameStats(session); logger.info("Game stats updated: roomId={}, newBadges={}", room.getRoomId(), newBadges.size()); } catch (Exception e) { logger.error("Failed to update game stats: roomId={}, error={}", room.getRoomId(), e.getMessage()); @@ -415,8 +511,8 @@ private CommandResult finishGame(ChatRoom room, String reason) { // 최종 점수 정렬 StringBuilder sb = new StringBuilder("🎮 게임 종료!\n\n📊 최종 순위:\n"); - if (room.getScores() != null && !room.getScores().isEmpty()) { - List> sorted = room.getScores().entrySet().stream() + if (session.getScores() != null && !session.getScores().isEmpty()) { + List> sorted = session.getScores().entrySet().stream() .sorted((a, b) -> b.getValue().compareTo(a.getValue())) .toList(); @@ -435,9 +531,38 @@ private CommandResult finishGame(ChatRoom room, String reason) { sb.append(" 점수 없음"); } - logger.info("Game finished: roomId={}, reason={}", room.getRoomId(), reason); + logger.info("Game finished: roomId={}, sessionId={}, reason={}", + room.getRoomId(), session.getGameSessionId(), reason); + + return CommandResult.success(MessageType.GAME_END, sb.toString(), session.getScores()); + } + + /** + * 시간 만료로 인한 게임 자동 종료 (GameAutoCloseHandler에서 호출) + */ + public CommandResult finishGameByTimeout(String gameSessionId) { + GameSession session = gameSessionRepository.findById(gameSessionId).orElse(null); + if (session == null) { + logger.warn("Game session not found for auto-close: {}", gameSessionId); + return CommandResult.error("게임 세션을 찾을 수 없습니다."); + } + + // 이미 종료된 게임이면 무시 + if (!session.isActive()) { + logger.info("Game already finished, skipping auto-close: {}", gameSessionId); + return CommandResult.error("이미 종료된 게임입니다."); + } + + ChatRoom room = chatRoomRepository.findById(session.getRoomId()).orElse(null); + if (room == null) { + logger.warn("Room not found for auto-close: {}", session.getRoomId()); + return CommandResult.error("채팅방을 찾을 수 없습니다."); + } - return CommandResult.success(MessageType.GAME_END, sb.toString(), room.getScores()); + logger.info("Auto-closing game due to time expiration: sessionId={}, roomId={}", + gameSessionId, session.getRoomId()); + + return finishGame(session, room, "TIME_EXPIRED"); } /** @@ -462,44 +587,57 @@ private String selectNextDrawer(List drawerOrder, Set connectedU /** * 랜덤 단어 추출 + * VocabTable은 LEVEL#BEGINNER 형식(대문자)으로 저장되어 있으므로 + * ChatRoom의 level(소문자)을 대문자로 변환 */ private List getRandomWords(String level, int count) { - PaginatedResult result = wordRepository.findByLevelWithPagination(level, 50, null); + // ChatRoom.level은 소문자(beginner), VocabTable GSI1PK는 대문자(BEGINNER) + String normalizedLevel = level != null ? level.toUpperCase() : "BEGINNER"; + PaginatedResult result = wordRepository.findByLevelWithPagination(normalizedLevel, 50, null); List words = new ArrayList<>(result.items()); Collections.shuffle(words); return words.stream().limit(count).collect(Collectors.toList()); } /** - * 정답 체크 로직 + * 정답 체크 로직 (한국어 또는 영어 둘 다 허용) */ - private boolean isCorrectAnswer(String input, String answer) { - if (input == null || answer == null) return false; + private boolean isCorrectAnswer(String input, String koreanAnswer, String englishAnswer) { + if (input == null) return false; String normalizedInput = input.trim().toLowerCase().replace(" ", ""); - String normalizedAnswer = answer.trim().toLowerCase().replace(" ", ""); - return normalizedInput.equals(normalizedAnswer); + // 한국어 정답 체크 + if (koreanAnswer != null) { + String normalizedKorean = koreanAnswer.trim().toLowerCase().replace(" ", ""); + if (normalizedInput.equals(normalizedKorean)) { + return true; + } + } + + // 영어 정답 체크 + if (englishAnswer != null) { + String normalizedEnglish = englishAnswer.trim().toLowerCase().replace(" ", ""); + if (normalizedInput.equals(normalizedEnglish)) { + return true; + } + } + + return false; } /** * 점수 계산 - * - * @param room 채팅방 - * @param elapsedTimeMs 경과 시간 (밀리초) - * @param userId 사용자 ID - * @param streak 연속 정답 수 - * @return 계산된 점수 */ - private int calculateScore(ChatRoom room, long elapsedTimeMs, String userId, int streak) { + private int calculateScore(GameSession session, long elapsedTimeMs, String userId, int streak) { int baseScore = 10; - // 시간 보너스 (빨리 맞출수록 높은 점수): (제한시간 - 경과시간) * 0.5 + // 시간 보너스 (빨리 맞출수록 높은 점수) int elapsedSeconds = (int) (elapsedTimeMs / 1000); - int timeLimit = room.getRoundTimeLimit() != null ? room.getRoundTimeLimit() : GameConfig.roundTimeLimit(); + int timeLimit = session.getRoundDuration() != null ? session.getRoundDuration() : GameConfig.roundTimeLimit(); int timeBonus = Math.max(0, (int) ((timeLimit - elapsedSeconds) * 0.5)); - // 연속 정답 보너스: 연속정답수 * 2 + // 연속 정답 보너스 int streakBonus = streak * 2; logger.info("Score calculation: base={}, timeBonus={}, streakBonus={}, total={}", @@ -536,19 +674,19 @@ private void updateRoundRecord(String roomId, Integer roundNumber, String userId /** * 정답 못 맞춘 사용자 연속 정답 초기화 */ - private void resetStreaksForNonGuessers(ChatRoom room) { - if (room.getStreaks() == null || room.getStreaks().isEmpty()) { + private void resetStreaksForNonGuessers(GameSession session) { + if (session.getStreaks() == null || session.getStreaks().isEmpty()) { return; } - List correctGuessers = room.getCorrectGuessers() != null - ? room.getCorrectGuessers() + List correctGuessers = session.getCorrectGuessers() != null + ? session.getCorrectGuessers() : List.of(); // 정답 못 맞춘 사용자의 연속 정답 초기화 - room.getStreaks().keySet().stream() + session.getStreaks().keySet().stream() .filter(userId -> !correctGuessers.contains(userId)) - .forEach(userId -> room.getStreaks().put(userId, 0)); + .forEach(userId -> session.getStreaks().put(userId, 0)); logger.info("Reset streaks for non-guessers: correctGuessers={}", correctGuessers); } @@ -581,12 +719,12 @@ private List> buildRankingList(Map scores) public record GameStartResult( boolean success, String error, - ChatRoom room, + GameSession session, Word firstWord, List drawerOrder ) { - public static GameStartResult success(ChatRoom room, Word word, List order) { - return new GameStartResult(true, null, room, word, order); + public static GameStartResult success(GameSession session, Word word, List order) { + return new GameStartResult(true, null, session, word, order); } public static GameStartResult error(String message) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameStatsService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameStatsService.java index ae067951..be700975 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameStatsService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameStatsService.java @@ -3,8 +3,8 @@ import com.mzc.secondproject.serverless.domain.badge.model.UserBadge; import com.mzc.secondproject.serverless.domain.badge.service.BadgeService; import com.mzc.secondproject.serverless.domain.chatting.config.GameConfig; -import com.mzc.secondproject.serverless.domain.chatting.model.ChatRoom; import com.mzc.secondproject.serverless.domain.chatting.model.GameRound; +import com.mzc.secondproject.serverless.domain.chatting.model.GameSession; import com.mzc.secondproject.serverless.domain.chatting.repository.GameRoundRepository; import com.mzc.secondproject.serverless.domain.stats.model.UserStats; import com.mzc.secondproject.serverless.domain.stats.repository.UserStatsRepository; @@ -24,27 +24,39 @@ public class GameStatsService { private final GameRoundRepository gameRoundRepository; private final BadgeService badgeService; + /** + * 기본 생성자 (Lambda에서 사용) + */ public GameStatsService() { - this.userStatsRepository = new UserStatsRepository(); - this.gameRoundRepository = new GameRoundRepository(); - this.badgeService = new BadgeService(); + this(new UserStatsRepository(), new GameRoundRepository(), new BadgeService()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public GameStatsService(UserStatsRepository userStatsRepository, + GameRoundRepository gameRoundRepository, + BadgeService badgeService) { + this.userStatsRepository = userStatsRepository; + this.gameRoundRepository = gameRoundRepository; + this.badgeService = badgeService; } /** * 게임 종료 시 모든 참가자 통계 업데이트 */ - public Map> updateGameStats(ChatRoom room) { + public Map> updateGameStats(GameSession session) { Map> newBadges = new HashMap<>(); - String roomId = room.getRoomId(); + String roomId = session.getRoomId(); // 모든 라운드 조회 List rounds = gameRoundRepository.findByRoomId(roomId); // 참가자별 통계 수집 - Map scores = room.getScores() != null ? room.getScores() : Map.of(); + Map scores = session.getScores() != null ? session.getScores() : Map.of(); Set participants = new HashSet<>(scores.keySet()); - if (room.getDrawerOrder() != null) { - participants.addAll(room.getDrawerOrder()); + if (session.getPlayers() != null) { + participants.addAll(session.getPlayers()); } // 1등 찾기 diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/RoomTokenService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/RoomTokenService.java index ee470cfe..38dd1b41 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/RoomTokenService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/RoomTokenService.java @@ -20,8 +20,18 @@ public class RoomTokenService { private final RoomTokenRepository tokenRepository; + /** + * 기본 생성자 (Lambda에서 사용) + */ public RoomTokenService() { - this.tokenRepository = new RoomTokenRepository(); + this(new RoomTokenRepository()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public RoomTokenService(RoomTokenRepository tokenRepository) { + this.tokenRepository = tokenRepository; } /** diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/GrammarHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/GrammarHandler.java index be726d5b..284fe767 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/GrammarHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/handler/GrammarHandler.java @@ -30,10 +30,21 @@ public class GrammarHandler implements RequestHandler handleRequest(Map event, Context cont private void processStreamingConversation(String connectionId, String endpoint, String userId, StreamingRequest request) { ApiGatewayManagementApiClient apiClient = createApiClient(endpoint); - // 서비스에 스트리밍 처리 위임 (userId는 JWT 인증에서 가져온 값 사용) - conversationService.chatStreaming( - request.sessionId(), - request.message(), - userId, - request.level(), - // 세션 생성 콜백 - sessionId -> sendEvent(apiClient, connectionId, new StreamingEvent.StartEvent(sessionId)), - // 스트리밍 콜백 - new StreamingCallback() { - @Override - public void onToken(String token) { - sendEvent(apiClient, connectionId, new StreamingEvent.TokenEvent(token)); - } - - @Override - public void onComplete(ConversationResponse response) { - sendEvent(apiClient, connectionId, StreamingEvent.CompleteEvent.from(response)); - logger.info("Streaming completed for session: {}", response.getSessionId()); - } - - @Override - public void onError(Throwable error) { - logger.error("Streaming error: {}", error.getMessage(), error); - sendEvent(apiClient, connectionId, new StreamingEvent.ErrorEvent(error.getMessage())); + try { + // 서비스에 스트리밍 처리 위임 (userId는 JWT 인증에서 가져온 값 사용) + conversationService.chatStreaming( + request.sessionId(), + request.message(), + userId, + request.level(), + // 세션 생성 콜백 + sessionId -> sendEvent(apiClient, connectionId, new StreamingEvent.StartEvent(sessionId)), + // 스트리밍 콜백 + new StreamingCallback() { + @Override + public void onToken(String token) { + sendEvent(apiClient, connectionId, new StreamingEvent.TokenEvent(token)); + } + + @Override + public void onComplete(ConversationResponse response) { + sendEvent(apiClient, connectionId, StreamingEvent.CompleteEvent.from(response)); + logger.info("Streaming completed for session: {}", response.getSessionId()); + closeApiClient(apiClient); + } + + @Override + public void onError(Throwable error) { + logger.error("Streaming error: {}", error.getMessage(), error); + sendEvent(apiClient, connectionId, new StreamingEvent.ErrorEvent(error.getMessage())); + closeApiClient(apiClient); + } } - } - ); + ); + } catch (Exception e) { + closeApiClient(apiClient); + throw e; + } + } + + private void closeApiClient(ApiGatewayManagementApiClient apiClient) { + try { + if (apiClient != null) { + apiClient.close(); + } + } catch (Exception e) { + logger.warn("Failed to close ApiGatewayManagementApiClient: {}", e.getMessage()); + } } private void sendEvent(ApiGatewayManagementApiClient apiClient, String connectionId, StreamingEvent event) { @@ -162,8 +179,9 @@ private boolean sendToConnection(ApiGatewayManagementApiClient apiClient, String } private Map sendError(String connectionId, String endpoint, String message) { - ApiGatewayManagementApiClient apiClient = createApiClient(endpoint); - sendEvent(apiClient, connectionId, new StreamingEvent.ErrorEvent(message)); + try (ApiGatewayManagementApiClient apiClient = createApiClient(endpoint)) { + sendEvent(apiClient, connectionId, new StreamingEvent.ErrorEvent(message)); + } return WebSocketEventUtil.badRequest(message); } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/repository/GrammarConnectionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/repository/GrammarConnectionRepository.java index 9ec4396c..b9adc9aa 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/repository/GrammarConnectionRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/repository/GrammarConnectionRepository.java @@ -5,6 +5,7 @@ import com.mzc.secondproject.serverless.domain.grammar.model.GrammarConnection; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; import software.amazon.awssdk.enhanced.dynamodb.Key; import software.amazon.awssdk.enhanced.dynamodb.TableSchema; @@ -21,11 +22,18 @@ public class GrammarConnectionRepository { private final DynamoDbTable table; + /** + * 기본 생성자 (Lambda에서 사용) + */ public GrammarConnectionRepository() { - this.table = AwsClients.dynamoDbEnhanced().table( - TABLE_NAME, - TableSchema.fromBean(GrammarConnection.class) - ); + this(AwsClients.dynamoDbEnhanced()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public GrammarConnectionRepository(DynamoDbEnhancedClient enhancedClient) { + this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(GrammarConnection.class)); } /** diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/repository/GrammarSessionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/repository/GrammarSessionRepository.java index 8c0466f8..66103008 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/repository/GrammarSessionRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/grammar/repository/GrammarSessionRepository.java @@ -9,6 +9,7 @@ import com.mzc.secondproject.serverless.domain.grammar.model.GrammarSession; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; import software.amazon.awssdk.enhanced.dynamodb.Key; import software.amazon.awssdk.enhanced.dynamodb.TableSchema; @@ -29,9 +30,19 @@ public class GrammarSessionRepository { private final DynamoDbTable sessionTable; private final DynamoDbTable messageTable; + /** + * 기본 생성자 (Lambda에서 사용) + */ public GrammarSessionRepository() { - this.sessionTable = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(GrammarSession.class)); - this.messageTable = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(GrammarMessage.class)); + this(AwsClients.dynamoDbEnhanced()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public GrammarSessionRepository(DynamoDbEnhancedClient enhancedClient) { + this.sessionTable = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(GrammarSession.class)); + this.messageTable = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(GrammarMessage.class)); } // ============ Session CRUD ============ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/request/CreateSessionRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/request/CreateSessionRequest.java new file mode 100644 index 00000000..d65fe836 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/request/CreateSessionRequest.java @@ -0,0 +1,8 @@ +package com.mzc.secondproject.serverless.domain.opic.dto.request; + +public record CreateSessionRequest( + String topic, + String subTopic, + String targetLevel +) { +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/request/SubmitAnswerRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/request/SubmitAnswerRequest.java new file mode 100644 index 00000000..01a95852 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/request/SubmitAnswerRequest.java @@ -0,0 +1,6 @@ +package com.mzc.secondproject.serverless.domain.opic.dto.request; + +public record SubmitAnswerRequest( + String audioS3Key +) { +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/AnswerFeedbackResponse.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/AnswerFeedbackResponse.java new file mode 100644 index 00000000..af43e60e --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/AnswerFeedbackResponse.java @@ -0,0 +1,11 @@ +package com.mzc.secondproject.serverless.domain.opic.dto.response; + +public record AnswerFeedbackResponse( + String answerId, + String transcript, + FeedbackResponse feedback, + boolean hasNextQuestion, + Integer nextQustionNumber, + int totalQuestions +) { +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/CreateSessionResponse.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/CreateSessionResponse.java new file mode 100644 index 00000000..d2e3e67a --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/CreateSessionResponse.java @@ -0,0 +1,8 @@ +package com.mzc.secondproject.serverless.domain.opic.dto.response; + +public record CreateSessionResponse( + String sessionId, + QuestionResponse firstQuestion, + int totalQuestions +) { +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/QuestionResponse.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/QuestionResponse.java new file mode 100644 index 00000000..78397faf --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/dto/response/QuestionResponse.java @@ -0,0 +1,10 @@ +package com.mzc.secondproject.serverless.domain.opic.dto.response; + +public record QuestionResponse( + String questionId, + String questionText, + String audioUrl, + int questionNumber, + int totalQuestions +) { +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/handler/OPIcSessionHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/handler/OPIcSessionHandler.java new file mode 100644 index 00000000..0eb3b1a2 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/handler/OPIcSessionHandler.java @@ -0,0 +1,506 @@ +package com.mzc.secondproject.serverless.domain.opic.handler; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import com.google.gson.*; +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.service.PollyService; +import com.mzc.secondproject.serverless.common.util.JwtUtil; +import com.mzc.secondproject.serverless.common.util.ResponseGenerator; +import com.mzc.secondproject.serverless.domain.opic.dto.request.CreateSessionRequest; +import com.mzc.secondproject.serverless.domain.opic.dto.request.SubmitAnswerRequest; +import com.mzc.secondproject.serverless.domain.opic.dto.response.FeedbackResponse; +import com.mzc.secondproject.serverless.domain.opic.model.OPIcAnswer; +import com.mzc.secondproject.serverless.domain.opic.model.OPIcQuestion; +import com.mzc.secondproject.serverless.domain.opic.model.OPIcSession; +import com.mzc.secondproject.serverless.domain.opic.repository.OPIcRepository; +import com.mzc.secondproject.serverless.domain.opic.service.FeedbackService; +import com.mzc.secondproject.serverless.domain.opic.service.TranscribeProxyService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; + +import java.lang.reflect.Type; +import java.time.Duration; +import java.time.Instant; +import java.util.*; +import java.util.stream.Collectors; + +/** + * OPIc 세션 통합 Handler + * - 세션 생성/조회 + * - 질문 조회 (Polly 음성 URL 포함) + * - 답변 제출 (Transcribe + Bedrock 피드백) + * - 세션 완료 (종합 리포트) + */ +public class OPIcSessionHandler implements RequestHandler { + + private static final Logger logger = LoggerFactory.getLogger(OPIcSessionHandler.class); + private static final Gson gson = new GsonBuilder() + .setDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'") + .registerTypeAdapter(Instant.class, new InstantTypeAdapter()) + .create(); + + private static final String OPIC_BUCKET = System.getenv("OPIC_BUCKET_NAME"); + + private final OPIcRepository repository; + private final PollyService pollyService; + private final TranscribeProxyService transcribeService; + private final FeedbackService feedbackService; + + public OPIcSessionHandler() { + this.repository = new OPIcRepository(); + this.pollyService = new PollyService(OPIC_BUCKET, "opic/voice/questions/"); + this.transcribeService = new TranscribeProxyService(); + this.feedbackService = new FeedbackService(); + } + + @Override + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent event, Context context) { + String httpMethod = event.getHttpMethod(); + String path = event.getPath(); + + try { + + String userId = extractUserId(event); + + + // POST /opic/sessions - 세션 생성 + if ("POST".equals(httpMethod) && path.equals("/opic/sessions")) { + return createSession(event, userId); + } + + // GET /opic/sessions - 세션 목록 조회 + if ("GET".equals(httpMethod) && path.equals("/opic/sessions")) { + return getSessions(userId); + } + + // GET /opic/sessions/{sessionId} - 세션 상세 조회 + if ("GET".equals(httpMethod) && path.matches("/opic/sessions/[^/]+") + && !path.contains("/questions") && !path.contains("/upload-url")) { + return getSession(event, userId); + } + + // GET /opic/sessions/{sessionId}/questions/next - 다음 질문 조회 + if ("GET".equals(httpMethod) && path.contains("/questions/next")) { + return getNextQuestion(event, userId); + } + + // GET /opic/sessions/{sessionId}/upload-url - Presigned URL 발급 + if ("GET".equals(httpMethod) && path.contains("/upload-url")) { + return getUploadUrl(event, userId); + } + + // POST /opic/sessions/{sessionId}/answers - 답변 제출 + if ("POST".equals(httpMethod) && path.contains("/answers")) { + return submitAnswer(event, userId); + } + + // POST /opic/sessions/{sessionId}/complete - 세션 완료 + if ("POST".equals(httpMethod) && path.contains("/complete")) { + return completeSession(event, userId); + } + + return ResponseGenerator.badRequest("지원하지 않는 요청입니다: " + httpMethod + " " + path); + + } catch (Exception e) { + logger.error("OPIc Handler 에러", e); + return ResponseGenerator.serverError(e.getMessage()); + } + } + + + /** + * POST /opic/sessions + * 세션 생성 + 첫 질문 반환 + */ + private APIGatewayProxyResponseEvent createSession(APIGatewayProxyRequestEvent event, String userId) { + CreateSessionRequest request = gson.fromJson(event.getBody(), CreateSessionRequest.class); + + logger.info("세션 생성 요청: userId={}, topic={}, level={}", + userId, request.topic(), request.targetLevel()); + + // 주제 + 소주제 + 레벨로 질문 세트 조회 + List questions = repository.findQuestionsByTopicSubTopicAndLevel( + request.topic(), + request.subTopic(), + request.targetLevel() + ); + + if (questions.isEmpty()) { + return ResponseGenerator.notFound("해당 주제/레벨의 질문이 없습니다."); + } + + // 최대 3개 질문 선택 (랜덤 셔플) + Collections.shuffle(questions); + List questionIds = questions.stream() + .limit(3) + .map(OPIcQuestion::getQuestionId) + .collect(Collectors.toList()); + + // 세션 생성 + OPIcSession session = repository.createSession( + userId, + request.topic(), + request.subTopic(), + request.targetLevel(), + questionIds + ); + + // 첫 질문 Polly 음성 URL 생성 (#368 PollyService 연동) + OPIcQuestion firstQuestion = questions.get(0); + String audioUrl = generateQuestionAudioUrl(firstQuestion); + + // Response + Map response = new LinkedHashMap<>(); + response.put("sessionId", session.getSessionId()); + response.put("totalQuestions", session.getTotalQuestions()); + response.put("firstQuestion", Map.of( + "questionId", firstQuestion.getQuestionId(), + "questionText", firstQuestion.getQuestionText(), + "audioUrl", audioUrl, + "questionNumber", 1, + "totalQuestions", session.getTotalQuestions() + )); + + logger.info("세션 생성 완료: sessionId={}", session.getSessionId()); + return ResponseGenerator.created("세션이 생성되었습니다.", response); + } + + /** + * GET /opic/sessions + * 사용자의 세션 목록 조회 + */ + private APIGatewayProxyResponseEvent getSessions(String userId) { + List sessions = repository.findSessionsByUserId(userId, 20); + + Map responseBody = new LinkedHashMap<>(); + responseBody.put("isSuccess", true); + responseBody.put("data", sessions); + + return new APIGatewayProxyResponseEvent() + .withStatusCode(200) + .withHeaders(Map.of("Content-Type", "application/json")) + .withBody(gson.toJson(responseBody)); + } + + /** + * GET /opic/sessions/{sessionId} + * 세션 상세 조회 + */ + private APIGatewayProxyResponseEvent getSession(APIGatewayProxyRequestEvent event, String userId) { + String sessionId = event.getPathParameters().get("sessionId"); + + OPIcSession session = repository.findSessionById(sessionId).orElse(null); + + if (session == null) { + return ResponseGenerator.notFound("세션을 찾을 수 없습니다."); + } + + if (!session.getUserId().equals(userId)) { + return ResponseGenerator.forbidden("접근 권한이 없습니다."); + } + + // 세션에 포함된 답변들도 조회 + List answers = repository.findAnswersBySessionId(sessionId); + + Map response = new LinkedHashMap<>(); + response.put("session", session); + response.put("answers", answers); + + return ResponseGenerator.ok(response); + } + + /** + * GET /opic/sessions/{sessionId}/questions/next + * 다음 질문 조회 (Polly 음성 URL 포함) + */ + private APIGatewayProxyResponseEvent getNextQuestion(APIGatewayProxyRequestEvent event, String userId) { + String sessionId = event.getPathParameters().get("sessionId"); + + OPIcSession session = repository.findSessionById(sessionId).orElse(null); + + if (session == null) { + return ResponseGenerator.notFound("세션을 찾을 수 없습니다."); + } + + if (!session.getUserId().equals(userId)) { + return ResponseGenerator.forbidden("접근 권한이 없습니다."); + } + + // 모든 질문 완료 확인 + int currentIndex = session.getCurrentQuestionIndex(); + if (currentIndex >= session.getTotalQuestions()) { + return ResponseGenerator.ok(Map.of( + "completed", true, + "message", "모든 질문이 완료되었습니다. 세션을 완료해주세요.", + "sessionId", sessionId + )); + } + + // 다음 질문 조회 + String questionId = session.getQuestionIds().get(currentIndex); + OPIcQuestion question = repository.findQuestionById(questionId) + .orElseThrow(() -> new RuntimeException("질문을 찾을 수 없습니다: " + questionId)); + + // Polly 음성 URL + String audioUrl = generateQuestionAudioUrl(question); + + Map response = new LinkedHashMap<>(); + response.put("questionId", question.getQuestionId()); + response.put("questionText", question.getQuestionText()); + response.put("audioUrl", audioUrl); + response.put("questionNumber", currentIndex + 1); + response.put("totalQuestions", session.getTotalQuestions()); + response.put("completed", false); + + return ResponseGenerator.ok(response); + } + + /** + * GET /opic/sessions/{sessionId}/upload-url + * S3 Presigned URL 발급 (음성 업로드용) + */ + private APIGatewayProxyResponseEvent getUploadUrl(APIGatewayProxyRequestEvent event, String userId) { + String sessionId = event.getPathParameters().get("sessionId"); + + // 세션 검증 + OPIcSession session = repository.findSessionById(sessionId).orElse(null); + if (session == null || !session.getUserId().equals(userId)) { + return ResponseGenerator.forbidden("접근 권한이 없습니다."); + } + + // S3 키 생성 + String s3Key = String.format("opic/answers/%s/%s/%s.webm", + userId, + sessionId, + UUID.randomUUID().toString() + ); + + // Presigned URL 생성 (5분 유효) + PutObjectRequest putRequest = PutObjectRequest.builder() + .bucket(OPIC_BUCKET) + .key(s3Key) + .contentType("audio/webm") + .build(); + + String presignedUrl = AwsClients.s3Presigner() + .presignPutObject(PutObjectPresignRequest.builder() + .putObjectRequest(putRequest) + .signatureDuration(Duration.ofMinutes(5)) + .build()) + .url() + .toString(); + + return ResponseGenerator.ok(Map.of( + "uploadUrl", presignedUrl, + "s3Key", s3Key, + "expiresIn", 300 + )); + } + + /** + * POST /opic/sessions/{sessionId}/answers + * 답변 제출 → STT → AI 피드백 + */ + private APIGatewayProxyResponseEvent submitAnswer(APIGatewayProxyRequestEvent event, String userId) { + String sessionId = event.getPathParameters().get("sessionId"); + SubmitAnswerRequest request = gson.fromJson(event.getBody(), SubmitAnswerRequest.class); + + logger.info("답변 제출: sessionId={}, s3Key={}", sessionId, request.audioS3Key()); + + // 세션 검증 + OPIcSession session = repository.findSessionById(sessionId).orElse(null); + if (session == null) { + return ResponseGenerator.notFound("세션을 찾을 수 없습니다."); + } + if (!session.getUserId().equals(userId)) { + return ResponseGenerator.forbidden("접근 권한이 없습니다."); + } + + // 현재 질문 조회 + int currentIndex = session.getCurrentQuestionIndex(); + if (currentIndex >= session.getTotalQuestions()) { + return ResponseGenerator.badRequest("이미 모든 질문에 답변했습니다."); + } + + String questionId = session.getQuestionIds().get(currentIndex); + OPIcQuestion question = repository.findQuestionById(questionId) + .orElseThrow(() -> new RuntimeException("질문을 찾을 수 없습니다.")); + + // Transcribe Proxy 호출 (음성 → 텍스트) + logger.info("S3에서 오디오 파일 로드: {}", request.audioS3Key()); + + byte[] audioBytes = AwsClients.s3().getObjectAsBytes( + software.amazon.awssdk.services.s3.model.GetObjectRequest.builder() + .bucket(OPIC_BUCKET) + .key(request.audioS3Key()) + .build() + ).asByteArray(); + + String audioBase64 = java.util.Base64.getEncoder().encodeToString(audioBytes); + logger.info("오디오 파일 Base64 변환 완료: {} bytes → {} chars", + audioBytes.length, audioBase64.length()); + + // 4. Transcribe Proxy 호출 (Base64 데이터 전송) + TranscribeProxyService.TranscribeResult transcribeResult = + transcribeService.transcribe(audioBase64, sessionId); + + String transcript = transcribeResult.transcript(); + logger.info("STT 변환 완료: transcript 길이={}", transcript.length()); + + // Bedrock 피드백 생성 + FeedbackResponse feedback = feedbackService.generateFeedback( + question.getQuestionText(), + transcript, + session.getTargetLevel() + ); + + // Answer 저장 - 개별 필드로 분리 저장 + OPIcAnswer answer = new OPIcAnswer(); + answer.setSessionId(sessionId); + answer.setQuestionId(questionId); + answer.setQuestionIndex(currentIndex); + answer.setQuestionText(question.getQuestionText()); // 비정규화 + answer.setAudioS3Key(request.audioS3Key()); + answer.setTranscript(transcript); + answer.setTranscriptConfidence(transcribeResult.confidence()); + + // 피드백 개별 필드 저장 + answer.setGrammarFeedback(gson.toJson(feedback.errors())); // errors → grammarFeedback + answer.setContentFeedback(feedback.correctedAnswer()); // correctedAnswer → contentFeedback + answer.setSampleAnswer(feedback.sampleAnswer()); // 모범 답변 + answer.setStatus(OPIcAnswer.AnswerStatus.COMPLETED); + answer.setAttemptCount(1); + answer.setCreatedAt(Instant.now()); + answer.setCompletedAt(Instant.now()); + + repository.saveAnswer(answer); + + // 세션 진행 상태 업데이트 + session.setCurrentQuestionIndex(currentIndex + 1); + repository.updateSession(session); + + // Response + boolean hasNext = (currentIndex + 1) < session.getTotalQuestions(); + + Map response = new LinkedHashMap<>(); + response.put("transcript", transcript); + response.put("feedback", feedback); + response.put("hasNextQuestion", hasNext); + response.put("currentQuestion", currentIndex + 1); + response.put("totalQuestions", session.getTotalQuestions()); + + if (hasNext) { + response.put("nextQuestionNumber", currentIndex + 2); + } + + logger.info("답변 처리 완료: sessionId={}, questionIndex={}", sessionId, currentIndex); + return ResponseGenerator.ok("피드백이 생성되었습니다.", response); + } + + /** + * POST /opic/sessions/{sessionId}/complete + * 세션 완료 + 종합 리포트 생성 + */ + private APIGatewayProxyResponseEvent completeSession(APIGatewayProxyRequestEvent event, String userId) { + String sessionId = event.getPathParameters().get("sessionId"); + + OPIcSession session = repository.findSessionById(sessionId).orElse(null); + if (session == null) { + return ResponseGenerator.notFound("세션을 찾을 수 없습니다."); + } + if (!session.getUserId().equals(userId)) { + return ResponseGenerator.forbidden("접근 권한이 없습니다."); + } + + // 모든 질문 답변 완료 확인 + List answers = repository.findAnswersBySessionId(sessionId); + if (answers.size() < session.getTotalQuestions()) { + return ResponseGenerator.badRequest( + String.format("아직 %d개의 질문에 답변하지 않았습니다.", + session.getTotalQuestions() - answers.size()) + ); + } + + // 세션 요약 생성 (피드백용) + StringBuilder summaryBuilder = new StringBuilder(); + for (int i = 0; i < answers.size(); i++) { + OPIcAnswer answer = answers.get(i); + OPIcQuestion question = repository.findQuestionById(answer.getQuestionId()).orElse(null); + + summaryBuilder.append(String.format("### Question %d\n", i + 1)); + if (question != null) { + summaryBuilder.append("Q: ").append(question.getQuestionText()).append("\n"); + } + summaryBuilder.append("A: ").append(answer.getTranscript()).append("\n\n"); + } + + // 종합 리포트 생성 (Bedrock) + var sessionReport = feedbackService.generateSessionReport( + summaryBuilder.toString(), + session.getTargetLevel() + ); + + // 세션 완료 처리 + repository.completeSession( + session, + sessionReport.estimatedLevel(), + gson.toJson(sessionReport) + ); + + logger.info("세션 완료: sessionId={}, estimatedLevel={}", + sessionId, sessionReport.estimatedLevel()); + + return ResponseGenerator.ok("세션이 완료되었습니다.", sessionReport); + } + + // ==================== 유틸리티 ==================== + + /** + * 질문 음성 URL 생성 (Polly + S3 캐싱) + */ + private String generateQuestionAudioUrl(OPIcQuestion question) { + try { + PollyService.VoiceSynthesisResult result = pollyService.synthesizeSpeech( + question.getQuestionId(), + question.getQuestionText(), + "FEMALE" + ); + return result.getAudioUrl(); + } catch (Exception e) { + logger.warn("Polly 음성 생성 실패, 텍스트만 반환: {}", e.getMessage()); + return null; + } + } + + /** + * JWT 토큰에서 userId 추출 + */ + private String extractUserId(APIGatewayProxyRequestEvent event) { + String authHeader = event.getHeaders().get("Authorization"); + + if (authHeader == null || authHeader.isEmpty()) { + authHeader = event.getHeaders().get("authorization"); + } + + return JwtUtil.extractUserId(authHeader) + .orElseThrow(() -> new RuntimeException("인증 정보를 찾을 수 없습니다.")); + } + + private static class InstantTypeAdapter implements JsonSerializer, JsonDeserializer { + @Override + public JsonElement serialize(Instant src, Type typeOfSrc, JsonSerializationContext context) { + return new JsonPrimitive(src.toString()); + } + + @Override + public Instant deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) + throws JsonParseException { + return Instant.parse(json.getAsString()); + } + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/model/OPIcAnswer.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/model/OPIcAnswer.java index b45ab680..afb02d1a 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/model/OPIcAnswer.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/model/OPIcAnswer.java @@ -1,6 +1,7 @@ package com.mzc.secondproject.serverless.domain.opic.model; import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbAttribute; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey; import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSortKey; @@ -9,6 +10,7 @@ /** * OPIc 답변 + 피드백 */ +@DynamoDbBean public class OPIcAnswer { private String pk; // SESSION#sessionId diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/repository/OPIcRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/repository/OPIcRepository.java index 7f74cd7d..62251d18 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/repository/OPIcRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/repository/OPIcRepository.java @@ -172,6 +172,17 @@ public List findQuestionsByTopicAndLevel(String topic, String leve .collect(Collectors.toList()); } + /** + * 주제 + 소주제 + 레벨로 질문 조회 (subTopic 필터 추가) + */ + public List findQuestionsByTopicSubTopicAndLevel( + String topic, String subTopic, String level) { + + return findQuestionsByTopicAndLevel(topic, level).stream() + .filter(q -> subTopic == null || subTopic.equals(q.getSubTopic())) + .collect(Collectors.toList()); + } + /** * 여러 질문 ID로 조회 */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/service/FeedbackService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/service/FeedbackService.java index 0180e7a5..9c15315e 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/service/FeedbackService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/service/FeedbackService.java @@ -24,7 +24,7 @@ public class FeedbackService { private static final Logger logger = LoggerFactory.getLogger(FeedbackService.class); private static final Gson gson = new GsonBuilder().setPrettyPrinting().create(); - private static final String MODEL_ID = "anthropic.claude-3-5-sonnet-20241022-v2:0"; + private static final String MODEL_ID = "anthropic.claude-3-haiku-20240307-v1:0"; private static final int MAX_TOKENS = 2000; /** diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingConnectHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingConnectHandler.java new file mode 100644 index 00000000..256d3990 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingConnectHandler.java @@ -0,0 +1,88 @@ +package com.mzc.secondproject.serverless.domain.speaking.handler.websocket; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.mzc.secondproject.serverless.common.config.WebSocketConfig; +import com.mzc.secondproject.serverless.common.util.JwtUtil; +import com.mzc.secondproject.serverless.common.util.WebSocketEventUtil; +import com.mzc.secondproject.serverless.domain.speaking.model.SpeakingConnection; +import com.mzc.secondproject.serverless.domain.speaking.repository.SpeakingConnectionRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Map; +import java.util.Optional; + +/** + * Speaking WebSocket $connect 핸들러 + * JWT 토큰 검증 후 연결 정보를 DynamoDB에 저장 + *

+ * 연결 방법: + * wss://{api-id}.execute-api.{region}.amazonaws.com/{stage}?token={jwt} + */ +public class SpeakingConnectHandler implements RequestHandler, Map> { + + private static final Logger logger = LoggerFactory.getLogger(SpeakingConnectHandler.class); + + private final SpeakingConnectionRepository connectionRepository; + + public SpeakingConnectHandler() { + this.connectionRepository = new SpeakingConnectionRepository(); + } + + @Override + public Map handleRequest(Map event, Context context) { + logger.info("Speaking WebSocket connect event"); + + try { + String connectionId = WebSocketEventUtil.extractConnectionId(event); + Map queryParams = WebSocketEventUtil.extractQueryStringParameters(event); + + // JWT 토큰 검증 + String token = queryParams.get("token"); + + if (token == null || token.isEmpty()) { + logger.warn("Missing token parameter"); + return WebSocketEventUtil.unauthorized("token is required"); + } + + // 토큰 유효성 검사 + if (!JwtUtil.isValid(token)) { + logger.warn("Invalid or expired token"); + return WebSocketEventUtil.unauthorized("Invalid or expired token"); + } + + // userId 추출 + Optional userIdOpt = JwtUtil.extractUserId(token); + if (userIdOpt.isEmpty()) { + logger.warn("Failed to extract userId from token"); + return WebSocketEventUtil.unauthorized("Invalid token"); + } + + String userId = userIdOpt.get(); + + // 연결 정보 저장 + SpeakingConnection connection = SpeakingConnection.create( + connectionId, + userId, + WebSocketConfig.connectionTtlSeconds() + ); + + // 레벨 파라미터가 있으면 설정 + String level = queryParams.get("level"); + if (level != null && !level.isEmpty()) { + connection.setTargetLevel(level.toUpperCase()); + } + + connectionRepository.save(connection); + + logger.info("Speaking connection established: connectionId={}, userId={}, level={}", + connectionId, userId, connection.getTargetLevel()); + return WebSocketEventUtil.ok("Connected"); + + } catch (Exception e) { + logger.error("Error handling connect: {}", e.getMessage(), e); + return WebSocketEventUtil.serverError("Internal server error"); + } + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingDisconnectHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingDisconnectHandler.java new file mode 100644 index 00000000..bd46d3b4 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingDisconnectHandler.java @@ -0,0 +1,44 @@ +package com.mzc.secondproject.serverless.domain.speaking.handler.websocket; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.mzc.secondproject.serverless.common.util.WebSocketEventUtil; +import com.mzc.secondproject.serverless.domain.speaking.repository.SpeakingConnectionRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Map; + +/** + * Speaking WebSocket $disconnect 핸들러 + * 연결 해제 시 DynamoDB에서 연결 정보 삭제 + */ +public class SpeakingDisconnectHandler implements RequestHandler, Map> { + + private static final Logger logger = LoggerFactory.getLogger(SpeakingDisconnectHandler.class); + + private final SpeakingConnectionRepository connectionRepository; + + public SpeakingDisconnectHandler() { + this.connectionRepository = new SpeakingConnectionRepository(); + } + + @Override + public Map handleRequest(Map event, Context context) { + logger.info("Speaking WebSocket disconnect event"); + + try { + String connectionId = WebSocketEventUtil.extractConnectionId(event); + + // 연결 정보 삭제 + connectionRepository.delete(connectionId); + + logger.info("Speaking connection closed: connectionId={}", connectionId); + return WebSocketEventUtil.ok("Disconnected"); + + } catch (Exception e) { + logger.error("Error handling disconnect: {}", e.getMessage(), e); + return WebSocketEventUtil.serverError("Internal server error"); + } + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingMessageHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingMessageHandler.java new file mode 100644 index 00000000..24ecfc3c --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingMessageHandler.java @@ -0,0 +1,217 @@ +package com.mzc.secondproject.serverless.domain.speaking.handler.websocket; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.mzc.secondproject.serverless.common.util.WebSocketEventUtil; +import com.mzc.secondproject.serverless.domain.speaking.repository.SpeakingConnectionRepository; +import com.mzc.secondproject.serverless.domain.speaking.service.SpeakingService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.core.SdkBytes; +import software.amazon.awssdk.services.apigatewaymanagementapi.ApiGatewayManagementApiClient; +import software.amazon.awssdk.services.apigatewaymanagementapi.model.PostToConnectionRequest; + +import java.net.URI; +import java.util.Map; + +/** + * Speaking WebSocket 메시지 핸들러 + *

+ * 지원하는 action: + * - speak: 음성 입력 처리 (audio base64) + * - text: 텍스트 입력 처리 + * - setLevel: 레벨 변경 + * - reset: 대화 히스토리 초기화 + */ +public class SpeakingMessageHandler implements RequestHandler, Map> { + + private static final Logger logger = LoggerFactory.getLogger(SpeakingMessageHandler.class); + private static final Gson gson = new GsonBuilder().create(); + + private final SpeakingService speakingService; + private final SpeakingConnectionRepository connectionRepository; + + public SpeakingMessageHandler() { + this.speakingService = new SpeakingService(); + this.connectionRepository = new SpeakingConnectionRepository(); + } + + @Override + public Map handleRequest(Map event, Context context) { + logger.info("Speaking message event received"); + + String connectionId = null; + String endpoint = null; + + try { + connectionId = WebSocketEventUtil.extractConnectionId(event); + endpoint = WebSocketEventUtil.extractWebSocketEndpoint(event); + + // 연결 정보 확인 + if (connectionRepository.findByConnectionId(connectionId).isEmpty()) { + logger.warn("Connection not found: {}", connectionId); + return sendError(connectionId, endpoint, "Unauthorized - please reconnect"); + } + + // 요청 바디 파싱 + String body = (String) event.get("body"); + if (body == null || body.isEmpty()) { + return sendError(connectionId, endpoint, "Message body is required"); + } + + JsonObject request = JsonParser.parseString(body).getAsJsonObject(); + String action = request.has("action") ? request.get("action").getAsString() : "speak"; + + logger.info("Processing action: {} for connectionId: {}", action, connectionId); + + // 액션별 처리 + switch (action) { + case "speak" -> handleSpeak(connectionId, endpoint, request); + case "text" -> handleText(connectionId, endpoint, request); + case "setLevel" -> handleSetLevel(connectionId, endpoint, request); + case "reset" -> handleReset(connectionId, endpoint); + default -> sendError(connectionId, endpoint, "Unknown action: " + action); + } + + return WebSocketEventUtil.ok("Processed"); + + } catch (Exception e) { + logger.error("Error processing message: {}", e.getMessage(), e); + if (connectionId != null && endpoint != null) { + sendError(connectionId, endpoint, "Processing error: " + e.getMessage()); + } + return WebSocketEventUtil.serverError("Internal server error"); + } + } + + /** + * 음성 입력 처리 + */ + private void handleSpeak(String connectionId, String endpoint, JsonObject request) { + // 시작 이벤트 전송 + sendToConnection(connectionId, endpoint, Map.of( + "type", "start", + "message", "Processing your voice..." + )); + + // 음성 데이터 추출 + String audioBase64 = request.has("audio") ? request.get("audio").getAsString() : null; + if (audioBase64 == null || audioBase64.isEmpty()) { + sendError(connectionId, endpoint, "audio data is required for speak action"); + return; + } + + // 음성 처리 + SpeakingService.SpeakingResponse response = speakingService.processVoiceInput( + connectionId, audioBase64 + ); + + // 결과 전송 + sendToConnection(connectionId, endpoint, Map.of( + "type", "complete", + "userTranscript", response.userTranscript(), + "aiText", response.aiText(), + "aiAudioUrl", response.aiAudioUrl(), + "confidence", response.confidence() + )); + } + + /** + * 텍스트 입력 처리 + */ + private void handleText(String connectionId, String endpoint, JsonObject request) { + String text = request.has("text") ? request.get("text").getAsString() : null; + if (text == null || text.trim().isEmpty()) { + sendError(connectionId, endpoint, "text is required for text action"); + return; + } + + // 시작 이벤트 전송 + sendToConnection(connectionId, endpoint, Map.of( + "type", "start", + "message", "Processing your message..." + )); + + // 텍스트 처리 + SpeakingService.SpeakingResponse response = speakingService.processTextInput( + connectionId, text.trim() + ); + + // 결과 전송 + sendToConnection(connectionId, endpoint, Map.of( + "type", "complete", + "userTranscript", response.userTranscript(), + "aiText", response.aiText(), + "aiAudioUrl", response.aiAudioUrl(), + "confidence", response.confidence() + )); + } + + /** + * 레벨 변경 처리 + */ + private void handleSetLevel(String connectionId, String endpoint, JsonObject request) { + String level = request.has("level") ? request.get("level").getAsString() : null; + if (level == null || level.isEmpty()) { + sendError(connectionId, endpoint, "level is required"); + return; + } + + speakingService.updateLevel(connectionId, level); + + sendToConnection(connectionId, endpoint, Map.of( + "type", "levelChanged", + "level", level.toUpperCase() + )); + } + + /** + * 대화 초기화 처리 + */ + private void handleReset(String connectionId, String endpoint) { + speakingService.resetConversation(connectionId); + + sendToConnection(connectionId, endpoint, Map.of( + "type", "reset", + "message", "Conversation has been reset. Let's start fresh!" + )); + } + + /** + * WebSocket으로 메시지 전송 + */ + private void sendToConnection(String connectionId, String endpoint, Map data) { + try { + ApiGatewayManagementApiClient apiClient = ApiGatewayManagementApiClient.builder() + .endpointOverride(URI.create(endpoint)) + .build(); + + String message = gson.toJson(data); + + apiClient.postToConnection(PostToConnectionRequest.builder() + .connectionId(connectionId) + .data(SdkBytes.fromUtf8String(message)) + .build()); + + logger.debug("Message sent to {}: {}", connectionId, data.get("type")); + + } catch (Exception e) { + logger.error("Failed to send message to {}: {}", connectionId, e.getMessage()); + } + } + + /** + * 에러 메시지 전송 + */ + private Map sendError(String connectionId, String endpoint, String errorMessage) { + sendToConnection(connectionId, endpoint, Map.of( + "type", "error", + "message", errorMessage + )); + return WebSocketEventUtil.ok("Error sent"); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/SpeakingConnection.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/SpeakingConnection.java new file mode 100644 index 00000000..133e7773 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/SpeakingConnection.java @@ -0,0 +1,84 @@ +package com.mzc.secondproject.serverless.domain.speaking.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.*; + +/** + * Speaking WebSocket 연결 정보 + * connectionId ↔ userId 매핑 + 대화 히스토리 저장 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DynamoDbBean +public class SpeakingConnection { + + // DynamoDB Key Prefixes + public static final String PK_PREFIX = "SPEAKING_CONN#"; + public static final String SK_METADATA = "METADATA"; + public static final String GSI1PK_PREFIX = "SPEAKING_USER#"; + public static final String GSI1SK_PREFIX = "CONN#"; + + private String pk; // SPEAKING_CONN#{connectionId} + private String sk; // METADATA + private String gsi1pk; // SPEAKING_USER#{userId} + private String gsi1sk; // CONN#{connectionId} + + private String connectionId; + private String userId; + private String connectedAt; + private Long ttl; // 자동 삭제용 + + // Speaking 전용 필드 + private String conversationHistory; // 대화 히스토리 (JSON) + private String targetLevel; // 목표 레벨 (BEGINNER, INTERMEDIATE, ADVANCED) + + /** + * 연결 정보 생성 팩토리 메서드 + */ + public static SpeakingConnection create(String connectionId, String userId, long ttlSeconds) { + String now = java.time.Instant.now().toString(); + long ttl = java.time.Instant.now().plusSeconds(ttlSeconds).getEpochSecond(); + + return SpeakingConnection.builder() + .pk(PK_PREFIX + connectionId) + .sk(SK_METADATA) + .gsi1pk(GSI1PK_PREFIX + userId) + .gsi1sk(GSI1SK_PREFIX + connectionId) + .connectionId(connectionId) + .userId(userId) + .connectedAt(now) + .ttl(ttl) + .conversationHistory("[]") // 빈 배열로 초기화 + .targetLevel("INTERMEDIATE") // 기본값 + .build(); + } + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { + return pk; + } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { + return sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1PK") + public String getGsi1pk() { + return gsi1pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1SK") + public String getGsi1sk() { + return gsi1sk; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java new file mode 100644 index 00000000..bbb74d7c --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java @@ -0,0 +1,73 @@ +package com.mzc.secondproject.serverless.domain.speaking.repository; + +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.config.EnvConfig; +import com.mzc.secondproject.serverless.domain.speaking.model.SpeakingConnection; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; +import software.amazon.awssdk.enhanced.dynamodb.Key; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; + +import java.util.Optional; + +/** + * Speaking WebSocket 연결 정보 Repository + */ +public class SpeakingConnectionRepository { + + private static final Logger logger = LoggerFactory.getLogger(SpeakingConnectionRepository.class); + private static final String TABLE_NAME = EnvConfig.getRequired("CHAT_TABLE_NAME"); + + private final DynamoDbTable table; + + public SpeakingConnectionRepository() { + this.table = AwsClients.dynamoDbEnhanced().table( + TABLE_NAME, + TableSchema.fromBean(SpeakingConnection.class) + ); + } + + /** + * 연결 정보 저장 + */ + public void save(SpeakingConnection connection) { + table.putItem(connection); + logger.debug("Speaking connection saved: connectionId={}, userId={}", + connection.getConnectionId(), connection.getUserId()); + } + + /** + * connectionId로 연결 정보 조회 + */ + public Optional findByConnectionId(String connectionId) { + Key key = Key.builder() + .partitionValue(SpeakingConnection.PK_PREFIX + connectionId) + .sortValue(SpeakingConnection.SK_METADATA) + .build(); + + SpeakingConnection connection = table.getItem(key); + return Optional.ofNullable(connection); + } + + /** + * 연결 정보 업데이트 (대화 히스토리 등) + */ + public void update(SpeakingConnection connection) { + table.putItem(connection); + logger.debug("Speaking connection updated: connectionId={}", connection.getConnectionId()); + } + + /** + * 연결 정보 삭제 + */ + public void delete(String connectionId) { + Key key = Key.builder() + .partitionValue(SpeakingConnection.PK_PREFIX + connectionId) + .sortValue(SpeakingConnection.SK_METADATA) + .build(); + + table.deleteItem(key); + logger.info("Speaking connection deleted: connectionId={}", connectionId); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/service/SpeakingService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/service/SpeakingService.java new file mode 100644 index 00000000..7c428ddc --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/service/SpeakingService.java @@ -0,0 +1,318 @@ +package com.mzc.secondproject.serverless.domain.speaking.service; + +import com.google.gson.*; +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.config.EnvConfig; +import com.mzc.secondproject.serverless.common.service.PollyService; +import com.mzc.secondproject.serverless.domain.opic.service.TranscribeProxyService; +import com.mzc.secondproject.serverless.domain.speaking.model.SpeakingConnection; +import com.mzc.secondproject.serverless.domain.speaking.repository.SpeakingConnectionRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.core.SdkBytes; +import software.amazon.awssdk.services.bedrockruntime.model.InvokeModelRequest; +import software.amazon.awssdk.services.bedrockruntime.model.InvokeModelResponse; + +import java.util.ArrayList; +import java.util.List; + +/** + * AI와 대화하기 서비스 + * 음성 입력 → STT → Bedrock → TTS → 음성 출력 + */ +public class SpeakingService { + + private static final Logger logger = LoggerFactory.getLogger(SpeakingService.class); + private static final Gson gson = new GsonBuilder().create(); + + private static final String MODEL_ID = "anthropic.claude-3-haiku-20240307-v1:0"; + private static final int MAX_TOKENS = 500; + private static final int MAX_HISTORY_SIZE = 10; // 최근 10턴만 유지 + + private final TranscribeProxyService transcribeService; + private final PollyService pollyService; + private final SpeakingConnectionRepository connectionRepository; + + public SpeakingService() { + this.transcribeService = new TranscribeProxyService(); + this.pollyService = new PollyService( + EnvConfig.getRequired("BUCKET_NAME"), + "speaking/voice/" + ); + this.connectionRepository = new SpeakingConnectionRepository(); + } + + /** + * 음성 입력 처리 (전체 플로우) + */ + public SpeakingResponse processVoiceInput(String connectionId, String audioBase64) { + logger.info("Processing voice input for connectionId: {}", connectionId); + + // 연결 정보 조회 + SpeakingConnection connection = connectionRepository.findByConnectionId(connectionId) + .orElseThrow(() -> new RuntimeException("Connection not found: " + connectionId)); + + String targetLevel = connection.getTargetLevel(); + + // STT: 음성 → 텍스트 (Transcribe Proxy 사용) + logger.info("Step 1: Transcribing audio..."); + TranscribeProxyService.TranscribeResult sttResult = transcribeService.transcribe( + audioBase64, + connectionId, + "en-US" + ); + String userText = sttResult.transcript(); + logger.info("Transcription complete: {} (confidence: {})", userText, sttResult.confidence()); + + // 대화 히스토리 로드 + List history = parseHistory(connection.getConversationHistory()); + + // Bedrock: AI 응답 생성 + logger.info("Step 2: Generating AI response..."); + String aiResponse = generateAiResponse(userText, history, targetLevel); + logger.info("AI response generated: {}", aiResponse); + + // 히스토리 업데이트 (최근 N턴만 유지) + history.add(new Message("user", userText)); + history.add(new Message("assistant", aiResponse)); + if (history.size() > MAX_HISTORY_SIZE * 2) { + history = new ArrayList<>(history.subList(history.size() - MAX_HISTORY_SIZE * 2, history.size())); + } + connection.setConversationHistory(toJson(history)); + connectionRepository.update(connection); + + // TTS: 텍스트 → 음성 (Polly 사용) + logger.info("Step 3: Synthesizing speech..."); + String audioId = connectionId + "_" + System.currentTimeMillis(); + PollyService.VoiceSynthesisResult ttsResult = pollyService.synthesizeSpeech( + audioId, + aiResponse, + "FEMALE" + ); + logger.info("Speech synthesis complete: cached={}", ttsResult.isCached()); + + return new SpeakingResponse( + userText, + aiResponse, + ttsResult.getAudioUrl(), + sttResult.confidence() + ); + } + + /** + * 텍스트 입력 처리 (음성 없이 텍스트만) + */ + public SpeakingResponse processTextInput(String connectionId, String userText) { + logger.info("Processing text input for connectionId: {}", connectionId); + + // 연결 정보 조회 + SpeakingConnection connection = connectionRepository.findByConnectionId(connectionId) + .orElseThrow(() -> new RuntimeException("Connection not found: " + connectionId)); + + String targetLevel = connection.getTargetLevel(); + + // 대화 히스토리 로드 + List history = parseHistory(connection.getConversationHistory()); + + // AI 응답 생성 + String aiResponse = generateAiResponse(userText, history, targetLevel); + + // 히스토리 업데이트 + history.add(new Message("user", userText)); + history.add(new Message("assistant", aiResponse)); + if (history.size() > MAX_HISTORY_SIZE * 2) { + history = new ArrayList<>(history.subList(history.size() - MAX_HISTORY_SIZE * 2, history.size())); + } + connection.setConversationHistory(toJson(history)); + connectionRepository.update(connection); + + // TTS 생성 + String audioId = connectionId + "_" + System.currentTimeMillis(); + PollyService.VoiceSynthesisResult ttsResult = pollyService.synthesizeSpeech( + audioId, aiResponse, "FEMALE" + ); + + return new SpeakingResponse(userText, aiResponse, ttsResult.getAudioUrl(), 1.0); + } + + /** + * 레벨 변경 + */ + public void updateLevel(String connectionId, String level) { + SpeakingConnection connection = connectionRepository.findByConnectionId(connectionId) + .orElseThrow(() -> new RuntimeException("Connection not found: " + connectionId)); + + connection.setTargetLevel(level.toUpperCase()); + connectionRepository.update(connection); + logger.info("Level updated for connectionId {}: {}", connectionId, level); + } + + /** + * 대화 히스토리 초기화 + */ + public void resetConversation(String connectionId) { + SpeakingConnection connection = connectionRepository.findByConnectionId(connectionId) + .orElseThrow(() -> new RuntimeException("Connection not found: " + connectionId)); + + connection.setConversationHistory("[]"); + connectionRepository.update(connection); + logger.info("Conversation reset for connectionId: {}", connectionId); + } + + + /** + * Bedrock Claude 호출하여 AI 응답 생성 + */ + private String generateAiResponse(String userText, List history, String targetLevel) { + String systemPrompt = buildSystemPrompt(targetLevel); + + JsonObject requestBody = new JsonObject(); + requestBody.addProperty("anthropic_version", "bedrock-2023-05-31"); + requestBody.addProperty("max_tokens", MAX_TOKENS); + requestBody.addProperty("system", systemPrompt); + + // 메시지 배열 구성 + JsonArray messages = new JsonArray(); + + // 기존 히스토리 추가 + for (Message msg : history) { + JsonObject m = new JsonObject(); + m.addProperty("role", msg.role()); + m.addProperty("content", msg.content()); + messages.add(m); + } + + // 현재 사용자 입력 추가 + JsonObject userMsg = new JsonObject(); + userMsg.addProperty("role", "user"); + userMsg.addProperty("content", userText); + messages.add(userMsg); + + requestBody.add("messages", messages); + + // Bedrock 호출 + InvokeModelResponse response = AwsClients.bedrock().invokeModel( + InvokeModelRequest.builder() + .modelId(MODEL_ID) + .contentType("application/json") + .body(SdkBytes.fromUtf8String(requestBody.toString())) + .build() + ); + + // 응답 파싱 + JsonObject result = JsonParser.parseString( + response.body().asUtf8String() + ).getAsJsonObject(); + + return result.getAsJsonArray("content") + .get(0).getAsJsonObject() + .get("text").getAsString(); + } + + /** + * 레벨별 시스템 프롬프트 생성 + */ + private String buildSystemPrompt(String targetLevel) { + String levelGuidance = switch (targetLevel.toUpperCase()) { + case "BEGINNER" -> """ + - Use simple vocabulary and short sentences + - Speak slowly and clearly + - Use basic grammar structures + - Provide Korean translations for difficult words in parentheses + """; + case "ADVANCED" -> """ + - Use sophisticated vocabulary and complex sentences + - Include idiomatic expressions and phrasal verbs + - Discuss abstract concepts naturally + - Challenge the user with nuanced topics + """; + default -> """ + - Use moderate vocabulary appropriate for intermediate learners + - Mix simple and compound sentences + - Introduce useful expressions gradually + - Balance challenge with accessibility + """; + }; + + return String.format(""" + You are a friendly English conversation partner for Korean learners. + Your name is "Amy" and you're an American English teacher living in Seoul. + + ## Target Level: %s + + ## Level-Specific Guidelines: + %s + + ## General Guidelines: + - Keep responses conversational (2-4 sentences) + - Be warm, encouraging, and supportive + - If the user makes grammar mistakes, gently correct them naturally in your response + - Ask follow-up questions to keep the conversation going + - Respond in English only (except for occasional Korean translations for difficult words) + - Match the conversation topic to the user's interests + - Use natural filler words occasionally (well, you know, actually) + + ## Correction Style: + Instead of: "You said 'I go to store.' It should be 'I went to the store.'" + Do this: "Oh, so you went to the store? That's nice! What did you buy?" + + Remember: Your goal is to make the user feel comfortable practicing English! + """, targetLevel, levelGuidance); + } + + /** + * 히스토리 JSON 파싱 + */ + private List parseHistory(String historyJson) { + List history = new ArrayList<>(); + + if (historyJson == null || historyJson.isEmpty() || historyJson.equals("[]")) { + return history; + } + + try { + JsonArray array = JsonParser.parseString(historyJson).getAsJsonArray(); + for (JsonElement el : array) { + JsonObject obj = el.getAsJsonObject(); + history.add(new Message( + obj.get("role").getAsString(), + obj.get("content").getAsString() + )); + } + } catch (Exception e) { + logger.warn("Failed to parse history, starting fresh: {}", e.getMessage()); + } + + return history; + } + + /** + * 히스토리 JSON 변환 + */ + private String toJson(List history) { + JsonArray array = new JsonArray(); + for (Message msg : history) { + JsonObject obj = new JsonObject(); + obj.addProperty("role", msg.role()); + obj.addProperty("content", msg.content()); + array.add(obj); + } + return array.toString(); + } + + // ==================== Inner Classes ==================== + + private record Message(String role, String content) { + } + + /** + * Speaking 응답 DTO + */ + public record SpeakingResponse( + String userTranscript, // 사용자가 말한 내용 (STT 결과) + String aiText, // AI 응답 텍스트 + String aiAudioUrl, // AI 응답 음성 URL (Polly) + double confidence // STT 신뢰도comp + ) { + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/ScheduledStatsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/ScheduledStatsHandler.java index 550dd691..97581029 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/ScheduledStatsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/ScheduledStatsHandler.java @@ -3,12 +3,18 @@ import com.amazonaws.services.lambda.runtime.Context; import com.amazonaws.services.lambda.runtime.RequestHandler; import com.amazonaws.services.lambda.runtime.events.ScheduledEvent; +import com.mzc.secondproject.serverless.common.config.AwsClients; import com.mzc.secondproject.serverless.common.config.EnvConfig; import com.mzc.secondproject.serverless.domain.stats.repository.UserStatsRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; +import software.amazon.awssdk.services.dynamodb.model.ScanRequest; +import software.amazon.awssdk.services.dynamodb.model.ScanResponse; import java.time.LocalDate; +import java.util.List; +import java.util.Map; /** * EventBridge Scheduler Handler @@ -20,11 +26,22 @@ public class ScheduledStatsHandler implements RequestHandler - * 어제 학습하지 않은 사용자 중 streak이 있는 사용자만 리셋 + * TOTAL 통계 레코드 중 lastStudyDate가 어제가 아니고 currentStreak > 0인 사용자의 streak을 리셋 */ private int checkAndResetStreaks(String yesterday) { logger.info("Checking streaks for date: {}", yesterday); - // GSI1을 사용하여 TOTAL 통계 레코드만 조회 - // GSI1PK = "STATS#TOTAL" 으로 설계하면 Query 가능 - // 현재는 GSI가 없으므로 개별 사용자별로 처리하는 방식 사용 - - // 실제로는 lastStudyDate가 어제가 아닌 사용자를 찾아야 함 - // 하지만 현재 구조상 효율적인 방법은: - // 1. 활성 사용자 목록 관리 (별도 테이블/인덱스) - // 2. 또는 클라이언트에서 streak 조회 시 계산 - - // 현재는 간단하게 구현: DailyStudy가 없는 사용자의 streak을 리셋 - // 이는 학습을 한 번이라도 한 사용자 대상 - int resetCount = 0; + Map lastEvaluatedKey = null; - // Note: 실제 운영에서는 활성 사용자 목록을 별도로 관리하거나 - // GSI를 lastStudyDate로 만들어 Query 하는 것이 효율적 - // 현재는 비용 최적화를 위해 이 로직은 클라이언트에서 처리하도록 변경 가능 + do { + // SK = "STATS#TOTAL"인 레코드만 스캔 (currentStreak > 0 필터) + ScanRequest.Builder scanBuilder = ScanRequest.builder() + .tableName(TABLE_NAME) + .filterExpression("SK = :sk AND currentStreak > :zero AND (attribute_not_exists(lastStudyDate) OR lastStudyDate <> :yesterday)") + .expressionAttributeValues(Map.of( + ":sk", AttributeValue.builder().s("STATS#TOTAL").build(), + ":zero", AttributeValue.builder().n("0").build(), + ":yesterday", AttributeValue.builder().s(yesterday).build() + )) + .limit(BATCH_SIZE); + + if (lastEvaluatedKey != null) { + scanBuilder.exclusiveStartKey(lastEvaluatedKey); + } + + ScanResponse response = AwsClients.dynamoDb().scan(scanBuilder.build()); + List> items = response.items(); + + for (Map item : items) { + String pk = item.get("PK").s(); + // PK 형식: "USERSTATS#{userId}" 에서 userId 추출 + if (pk != null && pk.startsWith("USERSTATS#")) { + String userId = pk.substring("USERSTATS#".length()); + try { + resetUserStreak(userId); + resetCount++; + logger.debug("Reset streak for user: {}", userId); + } catch (Exception e) { + logger.warn("Failed to reset streak for user {}: {}", userId, e.getMessage()); + } + } + } + + lastEvaluatedKey = response.lastEvaluatedKey(); + } while (lastEvaluatedKey != null && !lastEvaluatedKey.isEmpty()); logger.info("Streak reset completed: {} users processed", resetCount); return resetCount; } + + /** + * 사용자의 currentStreak을 0으로 리셋 (longestStreak은 유지) + */ + private void resetUserStreak(String userId) { + userStatsRepository.updateStreak(userId, 0, + getCurrentLongestStreak(userId), + LocalDate.now().minusDays(1).toString()); + } + + /** + * 사용자의 현재 longestStreak 조회 + */ + private int getCurrentLongestStreak(String userId) { + return userStatsRepository.findTotalStats(userId) + .map(stats -> stats.getLongestStreak() != null ? stats.getLongestStreak() : 0) + .orElse(0); + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/StatsStreamHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/StatsStreamHandler.java index 8caab03e..82ee6079 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/StatsStreamHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/StatsStreamHandler.java @@ -27,9 +27,19 @@ public class StatsStreamHandler implements RequestHandler { private final UserStatsRepository userStatsRepository; private final BadgeService badgeService; + /** + * 기본 생성자 (Lambda에서 사용) + */ public StatsStreamHandler() { - this.userStatsRepository = new UserStatsRepository(); - this.badgeService = new BadgeService(); + this(new UserStatsRepository(), new BadgeService()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public StatsStreamHandler(UserStatsRepository userStatsRepository, BadgeService badgeService) { + this.userStatsRepository = userStatsRepository; + this.badgeService = badgeService; } @Override diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java index 47c07cd6..637151be 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java @@ -31,9 +31,19 @@ public class UserStatsHandler implements RequestHandler table; + /** + * 기본 생성자 (Lambda에서 사용) + */ public UserStatsRepository() { - this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(UserStats.class)); + this(AwsClients.dynamoDbEnhanced()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public UserStatsRepository(DynamoDbEnhancedClient enhancedClient) { + this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(UserStats.class)); } /** diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/service/StatsService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/service/StatsService.java index faba5591..c50f52fe 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/service/StatsService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/service/StatsService.java @@ -18,8 +18,18 @@ public class StatsService { private final UserStatsRepository userStatsRepository; + /** + * 기본 생성자 (Lambda에서 사용) + */ public StatsService() { - this.userStatsRepository = new UserStatsRepository(); + this(new UserStatsRepository()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public StatsService(UserStatsRepository userStatsRepository) { + this.userStatsRepository = userStatsRepository; } /** diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PostConfirmationHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PostConfirmationHandler.java index edf0ba5e..97bd6638 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PostConfirmationHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PostConfirmationHandler.java @@ -14,7 +14,7 @@ /** * Cognito Post Confirmation 트리거 핸들러 *

- * - 사용자 이메일 인증을 완료한 직후 DB에 데이터 생성 + * 사용자 이메일 인증을 완료한 직후 DB에 데이터 생성 */ public class PostConfirmationHandler implements RequestHandler { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/DailyStudyHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/DailyStudyHandler.java index 44ffa02d..62692be6 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/DailyStudyHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/DailyStudyHandler.java @@ -24,9 +24,19 @@ public class DailyStudyHandler implements RequestHandler { private final StatisticsService statisticsService; + /** + * 기본 생성자 (Lambda에서 사용) + */ public StatisticsHandler() { - this.statisticsService = new StatisticsService(); + this(new StatisticsService()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public StatisticsHandler(StatisticsService statisticsService) { + this.statisticsService = statisticsService; } @Override diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatsHandler.java index 190734ac..77b8defe 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/StatsHandler.java @@ -21,8 +21,18 @@ public class StatsHandler implements RequestHandler table; + /** + * 기본 생성자 (Lambda에서 사용) + */ public DailyStudyRepository() { - this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(DailyStudy.class)); + this(AwsClients.dynamoDbEnhanced()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public DailyStudyRepository(DynamoDbEnhancedClient enhancedClient) { + this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(DailyStudy.class)); } public DailyStudy save(DailyStudy dailyStudy) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/TestResultRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/TestResultRepository.java index 790b7a79..703c864c 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/TestResultRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/TestResultRepository.java @@ -7,10 +7,7 @@ import com.mzc.secondproject.serverless.domain.vocabulary.model.TestResult; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; -import software.amazon.awssdk.enhanced.dynamodb.Expression; -import software.amazon.awssdk.enhanced.dynamodb.Key; -import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.*; import software.amazon.awssdk.enhanced.dynamodb.model.Page; import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; @@ -26,8 +23,18 @@ public class TestResultRepository { private final DynamoDbTable table; + /** + * 기본 생성자 (Lambda에서 사용) + */ public TestResultRepository() { - this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(TestResult.class)); + this(AwsClients.dynamoDbEnhanced()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public TestResultRepository(DynamoDbEnhancedClient enhancedClient) { + this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(TestResult.class)); } public TestResult save(TestResult testResult) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/UserWordRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/UserWordRepository.java index 8ad00235..7411113b 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/UserWordRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/UserWordRepository.java @@ -23,8 +23,18 @@ public class UserWordRepository { private final DynamoDbTable table; + /** + * 기본 생성자 (Lambda에서 사용) + */ public UserWordRepository() { - this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(UserWord.class)); + this(AwsClients.dynamoDbEnhanced()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public UserWordRepository(DynamoDbEnhancedClient enhancedClient) { + this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(UserWord.class)); } public UserWord save(UserWord userWord) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordGroupRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordGroupRepository.java index ddab69b1..17ed84a8 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordGroupRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordGroupRepository.java @@ -7,6 +7,7 @@ import com.mzc.secondproject.serverless.domain.vocabulary.model.WordGroup; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; import software.amazon.awssdk.enhanced.dynamodb.Key; import software.amazon.awssdk.enhanced.dynamodb.TableSchema; @@ -25,8 +26,18 @@ public class WordGroupRepository { private final DynamoDbTable table; + /** + * 기본 생성자 (Lambda에서 사용) + */ public WordGroupRepository() { - this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(WordGroup.class)); + this(AwsClients.dynamoDbEnhanced()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public WordGroupRepository(DynamoDbEnhancedClient enhancedClient) { + this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(WordGroup.class)); } public WordGroup save(WordGroup wordGroup) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordRepository.java index deb1710d..b2439362 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/WordRepository.java @@ -24,8 +24,18 @@ public class WordRepository { private final DynamoDbEnhancedClient enhancedClient; private final DynamoDbTable table; + /** + * 기본 생성자 (Lambda에서 사용) + */ public WordRepository() { - this.enhancedClient = AwsClients.dynamoDbEnhanced(); + this(AwsClients.dynamoDbEnhanced()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public WordRepository(DynamoDbEnhancedClient enhancedClient) { + this.enhancedClient = enhancedClient; this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(Word.class)); } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyCommandService.java index 2a3114b2..32dc5b24 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyCommandService.java @@ -35,12 +35,27 @@ public class DailyStudyCommandService { private final UserStatsRepository userStatsRepository; private final BadgeService badgeService; + /** + * 기본 생성자 (Lambda에서 사용) + */ public DailyStudyCommandService() { - this.dailyStudyRepository = new DailyStudyRepository(); - this.userWordRepository = new UserWordRepository(); - this.wordRepository = new WordRepository(); - this.userStatsRepository = new UserStatsRepository(); - this.badgeService = new BadgeService(); + this(new DailyStudyRepository(), new UserWordRepository(), new WordRepository(), + new UserStatsRepository(), new BadgeService()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public DailyStudyCommandService(DailyStudyRepository dailyStudyRepository, + UserWordRepository userWordRepository, + WordRepository wordRepository, + UserStatsRepository userStatsRepository, + BadgeService badgeService) { + this.dailyStudyRepository = dailyStudyRepository; + this.userWordRepository = userWordRepository; + this.wordRepository = wordRepository; + this.userStatsRepository = userStatsRepository; + this.badgeService = badgeService; } public DailyStudyResult getDailyWords(String userId, String level) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyQueryService.java index 67c3265d..a9888fbb 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyQueryService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyQueryService.java @@ -20,9 +20,19 @@ public class DailyStudyQueryService { private final DailyStudyRepository dailyStudyRepository; private final WordRepository wordRepository; + /** + * 기본 생성자 (Lambda에서 사용) + */ public DailyStudyQueryService() { - this.dailyStudyRepository = new DailyStudyRepository(); - this.wordRepository = new WordRepository(); + this(new DailyStudyRepository(), new WordRepository()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public DailyStudyQueryService(DailyStudyRepository dailyStudyRepository, WordRepository wordRepository) { + this.dailyStudyRepository = dailyStudyRepository; + this.wordRepository = wordRepository; } public Optional getDailyStudy(String userId, String date) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyService.java deleted file mode 100644 index 2493a2ea..00000000 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyService.java +++ /dev/null @@ -1,166 +0,0 @@ -package com.mzc.secondproject.serverless.domain.vocabulary.service; - -import com.mzc.secondproject.serverless.common.dto.PaginatedResult; -import com.mzc.secondproject.serverless.domain.vocabulary.config.VocabularyConfig; -import com.mzc.secondproject.serverless.domain.vocabulary.model.DailyStudy; -import com.mzc.secondproject.serverless.domain.vocabulary.model.UserWord; -import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; -import com.mzc.secondproject.serverless.domain.vocabulary.repository.DailyStudyRepository; -import com.mzc.secondproject.serverless.domain.vocabulary.repository.UserWordRepository; -import com.mzc.secondproject.serverless.domain.vocabulary.repository.WordRepository; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.time.Instant; -import java.time.LocalDate; -import java.util.*; -import java.util.stream.Collectors; - -public class DailyStudyService { - - private static final Logger logger = LoggerFactory.getLogger(DailyStudyService.class); - - private final DailyStudyRepository dailyStudyRepository; - private final UserWordRepository userWordRepository; - private final WordRepository wordRepository; - - public DailyStudyService() { - this.dailyStudyRepository = new DailyStudyRepository(); - this.userWordRepository = new UserWordRepository(); - this.wordRepository = new WordRepository(); - } - - public DailyStudyResult getDailyWords(String userId, String level) { - String today = LocalDate.now().toString(); - - Optional optDailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today); - - DailyStudy dailyStudy; - if (optDailyStudy.isPresent()) { - dailyStudy = optDailyStudy.get(); - } else { - if (level == null || level.isEmpty()) { - throw new IllegalArgumentException("level is required for first daily study (BEGINNER, INTERMEDIATE, ADVANCED)"); - } - if (!level.equals("BEGINNER") && !level.equals("INTERMEDIATE") && !level.equals("ADVANCED")) { - throw new IllegalArgumentException("Invalid level. Must be BEGINNER, INTERMEDIATE, or ADVANCED"); - } - dailyStudy = createDailyStudy(userId, today, level); - } - - List newWords = getWordDetails(dailyStudy.getNewWordIds()); - List reviewWords = getWordDetails(dailyStudy.getReviewWordIds()); - Map progress = calculateProgress(dailyStudy); - - return new DailyStudyResult(dailyStudy, newWords, reviewWords, progress); - } - - public Map markWordLearned(String userId, String wordId) { - String today = LocalDate.now().toString(); - - Optional optDailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today); - if (optDailyStudy.isEmpty()) { - throw new IllegalStateException("Daily study not found"); - } - - DailyStudy dailyStudy = optDailyStudy.get(); - - if (dailyStudy.getLearnedWordIds() != null && dailyStudy.getLearnedWordIds().contains(wordId)) { - return calculateProgress(dailyStudy); - } - - dailyStudyRepository.addLearnedWord(userId, today, wordId); - - DailyStudy updatedDailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today).orElse(dailyStudy); - - if (updatedDailyStudy.getLearnedCount() >= updatedDailyStudy.getTotalWords()) { - updatedDailyStudy.setIsCompleted(true); - dailyStudyRepository.save(updatedDailyStudy); - } - - logger.info("Marked word as learned: userId={}, wordId={}", userId, wordId); - return calculateProgress(updatedDailyStudy); - } - - private DailyStudy createDailyStudy(String userId, String date, String level) { - String now = Instant.now().toString(); - - PaginatedResult reviewPage = userWordRepository.findReviewDueWords(userId, date, VocabularyConfig.reviewWordsCount(), null); - List reviewWordIds = reviewPage.items().stream() - .map(UserWord::getWordId) - .collect(Collectors.toList()); - - List newWordIds = getNewWordsForUser(userId, level, VocabularyConfig.newWordsCount()); - - DailyStudy dailyStudy = DailyStudy.builder() - .pk("DAILY#" + userId) - .sk("DATE#" + date) - .gsi1pk("DAILY#ALL") - .gsi1sk("DATE#" + date) - .userId(userId) - .date(date) - .newWordIds(newWordIds) - .reviewWordIds(reviewWordIds) - .learnedWordIds(new ArrayList<>()) - .totalWords(newWordIds.size() + reviewWordIds.size()) - .learnedCount(0) - .isCompleted(false) - .createdAt(now) - .updatedAt(now) - .build(); - - dailyStudyRepository.save(dailyStudy); - logger.info("Created daily study for user: {}, date: {}", userId, date); - - return dailyStudy; - } - - private List getNewWordsForUser(String userId, String level, int count) { - PaginatedResult userWordPage = userWordRepository.findByUserIdWithPagination(userId, 1000, null); - List learnedWordIds = userWordPage.items().stream() - .map(UserWord::getWordId) - .collect(Collectors.toList()); - - List newWordIds = new ArrayList<>(); - String lastEvaluatedKey = null; - - do { - PaginatedResult wordPage = wordRepository.findByLevelWithPagination(level, count * 2, lastEvaluatedKey); - for (Word word : wordPage.items()) { - if (!learnedWordIds.contains(word.getWordId()) && !newWordIds.contains(word.getWordId())) { - newWordIds.add(word.getWordId()); - if (newWordIds.size() >= count) break; - } - } - lastEvaluatedKey = wordPage.nextCursor(); - } while (newWordIds.size() < count && lastEvaluatedKey != null); - - logger.info("Selected {} new words for user {} at level {}", newWordIds.size(), userId, level); - return newWordIds; - } - - private List getWordDetails(List wordIds) { - if (wordIds == null || wordIds.isEmpty()) { - return new ArrayList<>(); - } - return wordRepository.findByIds(wordIds); - } - - private Map calculateProgress(DailyStudy dailyStudy) { - Map progress = new HashMap<>(); - int total = dailyStudy.getTotalWords(); - int learned = dailyStudy.getLearnedCount(); - - progress.put("total", total); - progress.put("learned", learned); - progress.put("remaining", total - learned); - progress.put("percentage", total > 0 ? (learned * 100.0 / total) : 0); - progress.put("isCompleted", dailyStudy.getIsCompleted()); - - return progress; - } - - public record DailyStudyResult(DailyStudy dailyStudy, List newWords, List reviewWords, - Map progress) { - } -} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/StatisticsService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/StatisticsService.java index f1d1e75c..7a206742 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/StatisticsService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/StatisticsService.java @@ -17,8 +17,18 @@ public class StatisticsService { private final UserWordRepository userWordRepository; + /** + * 기본 생성자 (Lambda에서 사용) + */ public StatisticsService() { - this.userWordRepository = new UserWordRepository(); + this(new UserWordRepository()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public StatisticsService(UserWordRepository userWordRepository) { + this.userWordRepository = userWordRepository; } /** diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/StatsService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/StatsService.java index c3664156..abcc4a46 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/StatsService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/StatsService.java @@ -4,6 +4,7 @@ import com.mzc.secondproject.serverless.domain.vocabulary.model.DailyStudy; import com.mzc.secondproject.serverless.domain.vocabulary.model.TestResult; import com.mzc.secondproject.serverless.domain.vocabulary.model.UserWord; +import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; import com.mzc.secondproject.serverless.domain.vocabulary.repository.DailyStudyRepository; import com.mzc.secondproject.serverless.domain.vocabulary.repository.TestResultRepository; import com.mzc.secondproject.serverless.domain.vocabulary.repository.UserWordRepository; @@ -12,6 +13,7 @@ import org.slf4j.LoggerFactory; import java.util.*; +import java.util.function.Function; import java.util.stream.Collectors; public class StatsService { @@ -23,11 +25,25 @@ public class StatsService { private final TestResultRepository testResultRepository; private final WordRepository wordRepository; + /** + * 기본 생성자 (Lambda에서 사용) + */ public StatsService() { - this.userWordRepository = new UserWordRepository(); - this.dailyStudyRepository = new DailyStudyRepository(); - this.testResultRepository = new TestResultRepository(); - this.wordRepository = new WordRepository(); + this(new UserWordRepository(), new DailyStudyRepository(), + new TestResultRepository(), new WordRepository()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public StatsService(UserWordRepository userWordRepository, + DailyStudyRepository dailyStudyRepository, + TestResultRepository testResultRepository, + WordRepository wordRepository) { + this.userWordRepository = userWordRepository; + this.dailyStudyRepository = dailyStudyRepository; + this.testResultRepository = testResultRepository; + this.wordRepository = wordRepository; } public Map getOverallStats(String userId) { @@ -121,6 +137,15 @@ public Map getWeaknessAnalysis(String userId) { return emptyResult; } + // 배치 조회로 N+1 문제 해결: 모든 wordId를 수집하여 한 번에 조회 + List wordIds = allUserWords.stream() + .map(UserWord::getWordId) + .distinct() + .collect(Collectors.toList()); + List words = wordRepository.findByIds(wordIds); + Map wordMap = words.stream() + .collect(Collectors.toMap(Word::getWordId, Function.identity(), (a, b) -> a)); + List> weakestWords = allUserWords.stream() .filter(uw -> uw.getIncorrectCount() != null && uw.getIncorrectCount() > 0) .sorted(Comparator.comparingInt(UserWord::getIncorrectCount).reversed()) @@ -132,12 +157,13 @@ public Map getWeaknessAnalysis(String userId) { wordInfo.put("correctCount", uw.getCorrectCount()); wordInfo.put("status", uw.getStatus()); - wordRepository.findById(uw.getWordId()).ifPresent(word -> { + Word word = wordMap.get(uw.getWordId()); + if (word != null) { wordInfo.put("english", word.getEnglish()); wordInfo.put("korean", word.getKorean()); wordInfo.put("level", word.getLevel()); wordInfo.put("category", word.getCategory()); - }); + } int total = (uw.getCorrectCount() != null ? uw.getCorrectCount() : 0) + (uw.getIncorrectCount() != null ? uw.getIncorrectCount() : 0); @@ -152,7 +178,8 @@ public Map getWeaknessAnalysis(String userId) { Map> levelAnalysis = new HashMap<>(); for (UserWord uw : allUserWords) { - wordRepository.findById(uw.getWordId()).ifPresent(word -> { + Word word = wordMap.get(uw.getWordId()); + if (word != null) { String category = word.getCategory(); String level = word.getLevel(); @@ -182,7 +209,7 @@ public Map getWeaknessAnalysis(String userId) { lvlStats.put("totalCorrect", (Integer) lvlStats.get("totalCorrect") + correct); lvlStats.put("totalIncorrect", (Integer) lvlStats.get("totalIncorrect") + incorrect); lvlStats.put("wordCount", (Integer) lvlStats.get("wordCount") + 1); - }); + } } categoryAnalysis.values().forEach(stats -> { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java index 9b3ae1a1..43c0c095 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java @@ -34,11 +34,25 @@ public class TestCommandService { private final WordRepository wordRepository; private final UserWordCommandService userWordCommandService; + /** + * 기본 생성자 (Lambda에서 사용) + */ public TestCommandService() { - this.testResultRepository = new TestResultRepository(); - this.dailyStudyRepository = new DailyStudyRepository(); - this.wordRepository = new WordRepository(); - this.userWordCommandService = new UserWordCommandService(); + this(new TestResultRepository(), new DailyStudyRepository(), + new WordRepository(), new UserWordCommandService()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public TestCommandService(TestResultRepository testResultRepository, + DailyStudyRepository dailyStudyRepository, + WordRepository wordRepository, + UserWordCommandService userWordCommandService) { + this.testResultRepository = testResultRepository; + this.dailyStudyRepository = dailyStudyRepository; + this.wordRepository = wordRepository; + this.userWordCommandService = userWordCommandService; } public StartTestResult startTest(String userId, String testType) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestQueryService.java index 47dab64b..d21fc183 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestQueryService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestQueryService.java @@ -20,9 +20,19 @@ public class TestQueryService { private final TestResultRepository testResultRepository; private final WordRepository wordRepository; + /** + * 기본 생성자 (Lambda에서 사용) + */ public TestQueryService() { - this.testResultRepository = new TestResultRepository(); - this.wordRepository = new WordRepository(); + this(new TestResultRepository(), new WordRepository()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public TestQueryService(TestResultRepository testResultRepository, WordRepository wordRepository) { + this.testResultRepository = testResultRepository; + this.wordRepository = wordRepository; } public PaginatedResult getTestResults(String userId, int limit, String cursor) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestService.java deleted file mode 100644 index f365c4ba..00000000 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestService.java +++ /dev/null @@ -1,277 +0,0 @@ -package com.mzc.secondproject.serverless.domain.vocabulary.service; - -import com.mzc.secondproject.serverless.common.config.AwsClients; -import com.mzc.secondproject.serverless.common.config.EnvConfig; -import com.mzc.secondproject.serverless.common.dto.PaginatedResult; -import com.mzc.secondproject.serverless.common.util.ResponseGenerator; -import com.mzc.secondproject.serverless.domain.vocabulary.model.DailyStudy; -import com.mzc.secondproject.serverless.domain.vocabulary.model.TestResult; -import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; -import com.mzc.secondproject.serverless.domain.vocabulary.repository.DailyStudyRepository; -import com.mzc.secondproject.serverless.domain.vocabulary.repository.TestResultRepository; -import com.mzc.secondproject.serverless.domain.vocabulary.repository.WordRepository; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import software.amazon.awssdk.services.sns.model.PublishRequest; - -import java.time.Instant; -import java.time.LocalDate; -import java.util.*; -import java.util.stream.Collectors; - -public class TestService { - - private static final Logger logger = LoggerFactory.getLogger(TestService.class); - private static final String TEST_RESULT_TOPIC_ARN = EnvConfig.getRequired("TEST_RESULT_TOPIC_ARN"); - - private final TestResultRepository testResultRepository; - private final DailyStudyRepository dailyStudyRepository; - private final WordRepository wordRepository; - - public TestService() { - this.testResultRepository = new TestResultRepository(); - this.dailyStudyRepository = new DailyStudyRepository(); - this.wordRepository = new WordRepository(); - } - - public StartTestResult startTest(String userId, String testType) { - String today = LocalDate.now().toString(); - - Optional optDailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today); - if (optDailyStudy.isEmpty()) { - throw new IllegalStateException("No daily study found for today"); - } - - DailyStudy dailyStudy = optDailyStudy.get(); - List allWordIds = new ArrayList<>(); - if (dailyStudy.getNewWordIds() != null) allWordIds.addAll(dailyStudy.getNewWordIds()); - if (dailyStudy.getReviewWordIds() != null) allWordIds.addAll(dailyStudy.getReviewWordIds()); - - if (allWordIds.isEmpty()) { - throw new IllegalStateException("No words to test"); - } - - List words = wordRepository.findByIds(allWordIds); - - Map> wordsByLevel = words.stream() - .collect(Collectors.groupingBy(Word::getLevel)); - - Map> distractorsByLevel = new HashMap<>(); - for (String level : wordsByLevel.keySet()) { - List distractors = getDistractorsForLevel(level, allWordIds); - distractorsByLevel.put(level, distractors); - } - - Random random = new Random(); - List> questions = new ArrayList<>(); - for (Word word : words) { - Map question = new HashMap<>(); - question.put("wordId", word.getWordId()); - question.put("english", word.getEnglish()); - question.put("example", word.getExample()); - - List options = generateOptions(word, wordsByLevel, distractorsByLevel, random); - question.put("options", options); - - questions.add(question); - } - - String testId = UUID.randomUUID().toString(); - String now = Instant.now().toString(); - - logger.info("Started test: userId={}, testId={}, questions={}", userId, testId, questions.size()); - - return new StartTestResult(testId, testType, questions, questions.size(), now); - } - - public SubmitTestResult submitTest(String userId, String testId, String testType, - List> answers, String startedAt) { - // 1. 답안 채점 - GradingResult gradingResult = gradeAnswers(answers); - - // 2. 테스트 결과 저장 - saveTestResult(userId, testId, testType, gradingResult, startedAt); - - // 3. SNS 알림 발행 - publishTestResultToSns(userId, gradingResult.results()); - - logger.info("Test submitted: userId={}, testId={}, successRate={}%", - userId, testId, gradingResult.successRate()); - - return new SubmitTestResult( - testId, testType, gradingResult.totalQuestions(), - gradingResult.correctCount(), gradingResult.incorrectCount(), - gradingResult.successRate(), gradingResult.results() - ); - } - - private GradingResult gradeAnswers(List> answers) { - List wordIds = answers.stream() - .map(a -> (String) a.get("wordId")) - .collect(Collectors.toList()); - - Map wordMap = wordRepository.findByIds(wordIds).stream() - .collect(Collectors.toMap(Word::getWordId, w -> w)); - - int correctCount = 0; - int incorrectCount = 0; - List incorrectWordIds = new ArrayList<>(); - List> results = new ArrayList<>(); - - for (Map answer : answers) { - String wordId = (String) answer.get("wordId"); - String userAnswer = (String) answer.get("answer"); - Word word = wordMap.get(wordId); - - if (word == null) continue; - - boolean isCorrect = isAnswerCorrect(userAnswer, word.getKorean()); - results.add(buildResultItem(word, userAnswer, isCorrect)); - - if (isCorrect) { - correctCount++; - } else { - incorrectCount++; - incorrectWordIds.add(wordId); - } - } - - int totalQuestions = answers.size(); - double successRate = totalQuestions > 0 ? (correctCount * 100.0 / totalQuestions) : 0; - - return new GradingResult(wordIds, correctCount, incorrectCount, incorrectWordIds, - totalQuestions, successRate, results); - } - - private boolean isAnswerCorrect(String userAnswer, String correctAnswer) { - return userAnswer != null - && !userAnswer.isBlank() - && correctAnswer.trim().equalsIgnoreCase(userAnswer.trim()); - } - - private Map buildResultItem(Word word, String userAnswer, boolean isCorrect) { - Map resultItem = new HashMap<>(); - resultItem.put("wordId", word.getWordId()); - resultItem.put("english", word.getEnglish()); - resultItem.put("correctAnswer", word.getKorean()); - resultItem.put("userAnswer", userAnswer != null ? userAnswer : ""); - resultItem.put("isCorrect", isCorrect); - return resultItem; - } - - private void saveTestResult(String userId, String testId, String testType, - GradingResult gradingResult, String startedAt) { - String now = Instant.now().toString(); - String today = LocalDate.now().toString(); - - TestResult testResult = TestResult.builder() - .pk("TEST#" + userId) - .sk("RESULT#" + now) - .gsi1pk("TEST#ALL") - .gsi1sk("DATE#" + today) - .testId(testId) - .userId(userId) - .testType(testType) - .totalQuestions(gradingResult.totalQuestions()) - .correctAnswers(gradingResult.correctCount()) - .incorrectAnswers(gradingResult.incorrectCount()) - .successRate(gradingResult.successRate()) - .incorrectWordIds(gradingResult.incorrectWordIds()) - .startedAt(startedAt) - .completedAt(now) - .build(); - - testResultRepository.save(testResult); - } - - public PaginatedResult getTestResults(String userId, int limit, String cursor) { - return testResultRepository.findByUserIdWithPagination(userId, limit, cursor); - } - - private List getDistractorsForLevel(String level, List excludeWordIds) { - PaginatedResult wordPage = wordRepository.findByLevelWithPagination(level, 50, null); - return wordPage.items().stream() - .filter(w -> !excludeWordIds.contains(w.getWordId())) - .map(Word::getKorean) - .collect(Collectors.toList()); - } - - private List generateOptions(Word correctWord, Map> wordsByLevel, - Map> distractorsByLevel, Random random) { - List options = new ArrayList<>(); - String correctAnswer = correctWord.getKorean(); - options.add(correctAnswer); - - String level = correctWord.getLevel(); - - List sameLevelOptions = wordsByLevel.getOrDefault(level, new ArrayList<>()).stream() - .filter(w -> !w.getWordId().equals(correctWord.getWordId())) - .map(Word::getKorean) - .collect(Collectors.toList()); - - List additionalDistractors = distractorsByLevel.getOrDefault(level, new ArrayList<>()); - - List allDistractors = new ArrayList<>(); - allDistractors.addAll(sameLevelOptions); - allDistractors.addAll(additionalDistractors); - - allDistractors = allDistractors.stream() - .filter(d -> !d.equals(correctAnswer)) - .distinct() - .collect(Collectors.toList()); - - Collections.shuffle(allDistractors, random); - int distractorCount = Math.min(3, allDistractors.size()); - for (int i = 0; i < distractorCount; i++) { - options.add(allDistractors.get(i)); - } - - Collections.shuffle(options, random); - return options; - } - - private void publishTestResultToSns(String userId, List> results) { - if (TEST_RESULT_TOPIC_ARN == null || TEST_RESULT_TOPIC_ARN.isEmpty()) { - logger.warn("TEST_RESULT_TOPIC_ARN is not configured, skipping SNS publish"); - return; - } - - try { - Map message = new HashMap<>(); - message.put("userId", userId); - message.put("results", results); - - String messageJson = ResponseGenerator.gson().toJson(message); - - PublishRequest publishRequest = PublishRequest.builder() - .topicArn(TEST_RESULT_TOPIC_ARN) - .message(messageJson) - .build(); - - AwsClients.sns().publish(publishRequest); - logger.info("Published test result to SNS for user: {}", userId); - } catch (Exception e) { - logger.error("Failed to publish test result to SNS for user: {}", userId, e); - } - } - - private record GradingResult( - List wordIds, - int correctCount, - int incorrectCount, - List incorrectWordIds, - int totalQuestions, - double successRate, - List> results - ) { - } - - public record StartTestResult(String testId, String testType, List> questions, - int totalQuestions, String startedAt) { - } - - public record SubmitTestResult(String testId, String testType, int totalQuestions, - int correctCount, int incorrectCount, double successRate, - List> results) { - } -} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordCommandService.java index d356c125..4b9c216e 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordCommandService.java @@ -26,8 +26,18 @@ public class UserWordCommandService { private final UserWordRepository userWordRepository; + /** + * 기본 생성자 (Lambda에서 사용) + */ public UserWordCommandService() { - this.userWordRepository = new UserWordRepository(); + this(new UserWordRepository()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public UserWordCommandService(UserWordRepository userWordRepository) { + this.userWordRepository = userWordRepository; } public UserWord updateUserWord(String userId, String wordId, boolean isCorrect) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordQueryService.java index 1d5fe08c..bf6920e5 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordQueryService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordQueryService.java @@ -21,9 +21,19 @@ public class UserWordQueryService { private final UserWordRepository userWordRepository; private final WordRepository wordRepository; + /** + * 기본 생성자 (Lambda에서 사용) + */ public UserWordQueryService() { - this.userWordRepository = new UserWordRepository(); - this.wordRepository = new WordRepository(); + this(new UserWordRepository(), new WordRepository()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public UserWordQueryService(UserWordRepository userWordRepository, WordRepository wordRepository) { + this.userWordRepository = userWordRepository; + this.wordRepository = wordRepository; } public UserWordsResult getUserWords(String userId, String status, String bookmarked, diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordService.java deleted file mode 100644 index c0bf7c3f..00000000 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordService.java +++ /dev/null @@ -1,223 +0,0 @@ -package com.mzc.secondproject.serverless.domain.vocabulary.service; - -import com.mzc.secondproject.serverless.common.dto.PaginatedResult; -import com.mzc.secondproject.serverless.domain.vocabulary.model.UserWord; -import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; -import com.mzc.secondproject.serverless.domain.vocabulary.repository.UserWordRepository; -import com.mzc.secondproject.serverless.domain.vocabulary.repository.WordRepository; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.time.Instant; -import java.time.LocalDate; -import java.util.*; -import java.util.stream.Collectors; - -public class UserWordService { - - private static final Logger logger = LoggerFactory.getLogger(UserWordService.class); - - private final UserWordRepository userWordRepository; - private final WordRepository wordRepository; - - public UserWordService() { - this.userWordRepository = new UserWordRepository(); - this.wordRepository = new WordRepository(); - } - - public UserWordsResult getUserWords(String userId, String status, String bookmarked, - String incorrectOnly, int limit, String cursor) { - PaginatedResult userWordPage; - - if ("true".equalsIgnoreCase(bookmarked)) { - userWordPage = userWordRepository.findBookmarkedWords(userId, limit, cursor); - } else if ("true".equalsIgnoreCase(incorrectOnly)) { - userWordPage = userWordRepository.findIncorrectWords(userId, limit, cursor); - } else if (status != null && !status.isEmpty()) { - userWordPage = userWordRepository.findByUserIdAndStatus(userId, status, limit, cursor); - } else { - userWordPage = userWordRepository.findByUserIdWithPagination(userId, limit, cursor); - } - - List> enrichedUserWords = enrichWithWordInfo(userWordPage.items()); - - return new UserWordsResult(enrichedUserWords, userWordPage.nextCursor(), userWordPage.hasMore()); - } - - public Optional getUserWord(String userId, String wordId) { - return userWordRepository.findByUserIdAndWordId(userId, wordId); - } - - public UserWord updateUserWord(String userId, String wordId, boolean isCorrect) { - Optional optUserWord = userWordRepository.findByUserIdAndWordId(userId, wordId); - UserWord userWord; - String now = Instant.now().toString(); - - if (optUserWord.isEmpty()) { - userWord = UserWord.builder() - .pk("USER#" + userId) - .sk("WORD#" + wordId) - .gsi1pk("USER#" + userId + "#REVIEW") - .gsi2pk("USER#" + userId + "#STATUS") - .userId(userId) - .wordId(wordId) - .status("NEW") - .interval(1) - .easeFactor(2.5) - .repetitions(0) - .correctCount(0) - .incorrectCount(0) - .createdAt(now) - .build(); - } else { - userWord = optUserWord.get(); - } - - applySpacedRepetition(userWord, isCorrect); - userWord.setUpdatedAt(now); - userWord.setLastReviewedAt(now); - - userWord.setGsi1sk("DATE#" + userWord.getNextReviewAt()); - userWord.setGsi2sk("STATUS#" + userWord.getStatus()); - - userWordRepository.save(userWord); - - logger.info("Updated user word: userId={}, wordId={}, isCorrect={}", userId, wordId, isCorrect); - return userWord; - } - - public UserWord updateUserWordTag(String userId, String wordId, Boolean bookmarked, - Boolean favorite, String difficulty) { - Optional optUserWord = userWordRepository.findByUserIdAndWordId(userId, wordId); - UserWord userWord; - String now = Instant.now().toString(); - - if (optUserWord.isEmpty()) { - userWord = UserWord.builder() - .pk("USER#" + userId) - .sk("WORD#" + wordId) - .gsi1pk("USER#" + userId + "#REVIEW") - .gsi2pk("USER#" + userId + "#STATUS") - .gsi2sk("STATUS#NEW") - .userId(userId) - .wordId(wordId) - .status("NEW") - .interval(1) - .easeFactor(2.5) - .repetitions(0) - .correctCount(0) - .incorrectCount(0) - .bookmarked(false) - .favorite(false) - .createdAt(now) - .build(); - } else { - userWord = optUserWord.get(); - } - - if (bookmarked != null) { - userWord.setBookmarked(bookmarked); - } - if (favorite != null) { - userWord.setFavorite(favorite); - } - if (difficulty != null) { - if (!difficulty.equals("EASY") && !difficulty.equals("NORMAL") && !difficulty.equals("HARD")) { - throw new IllegalArgumentException("difficulty must be EASY, NORMAL, or HARD"); - } - userWord.setDifficulty(difficulty); - } - - userWord.setUpdatedAt(now); - userWordRepository.save(userWord); - - logger.info("Updated user word tag: userId={}, wordId={}", userId, wordId); - return userWord; - } - - private void applySpacedRepetition(UserWord userWord, boolean isCorrect) { - if (isCorrect) { - userWord.setCorrectCount(userWord.getCorrectCount() + 1); - userWord.setRepetitions(userWord.getRepetitions() + 1); - - if (userWord.getRepetitions() == 1) { - userWord.setInterval(1); - } else if (userWord.getRepetitions() == 2) { - userWord.setInterval(6); - } else { - int newInterval = (int) Math.round(userWord.getInterval() * userWord.getEaseFactor()); - userWord.setInterval(newInterval); - } - - if (userWord.getRepetitions() >= 5) { - userWord.setStatus("MASTERED"); - } else if (userWord.getRepetitions() >= 2) { - userWord.setStatus("REVIEWING"); - } else { - userWord.setStatus("LEARNING"); - } - } else { - userWord.setIncorrectCount(userWord.getIncorrectCount() + 1); - userWord.setRepetitions(0); - userWord.setInterval(1); - userWord.setStatus("LEARNING"); - - double newEaseFactor = userWord.getEaseFactor() - 0.2; - userWord.setEaseFactor(Math.max(1.3, newEaseFactor)); - } - - LocalDate nextReview = LocalDate.now().plusDays(userWord.getInterval()); - userWord.setNextReviewAt(nextReview.toString()); - } - - private List> enrichWithWordInfo(List userWords) { - if (userWords == null || userWords.isEmpty()) { - return new ArrayList<>(); - } - - List wordIds = userWords.stream() - .map(UserWord::getWordId) - .collect(Collectors.toList()); - - List words = wordRepository.findByIds(wordIds); - - Map wordMap = words.stream() - .collect(Collectors.toMap(Word::getWordId, w -> w, (w1, w2) -> w1)); - - List> enrichedList = new ArrayList<>(); - for (UserWord userWord : userWords) { - Map enriched = new HashMap<>(); - - enriched.put("wordId", userWord.getWordId()); - enriched.put("userId", userWord.getUserId()); - enriched.put("status", userWord.getStatus()); - enriched.put("correctCount", userWord.getCorrectCount()); - enriched.put("incorrectCount", userWord.getIncorrectCount()); - enriched.put("bookmarked", userWord.getBookmarked()); - enriched.put("favorite", userWord.getFavorite()); - enriched.put("difficulty", userWord.getDifficulty()); - enriched.put("nextReviewAt", userWord.getNextReviewAt()); - enriched.put("lastReviewedAt", userWord.getLastReviewedAt()); - enriched.put("repetitions", userWord.getRepetitions()); - enriched.put("interval", userWord.getInterval()); - - Word word = wordMap.get(userWord.getWordId()); - if (word != null) { - enriched.put("english", word.getEnglish()); - enriched.put("korean", word.getKorean()); - enriched.put("level", word.getLevel()); - enriched.put("category", word.getCategory()); - enriched.put("example", word.getExample()); - enriched.put("maleVoiceKey", word.getMaleVoiceKey()); - enriched.put("femaleVoiceKey", word.getFemaleVoiceKey()); - } - - enrichedList.add(enriched); - } - - return enrichedList; - } - - public record UserWordsResult(List> userWords, String nextCursor, boolean hasMore) { - } -} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordCommandService.java index 5807055a..193cb67b 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordCommandService.java @@ -20,8 +20,18 @@ public class WordCommandService { private final WordRepository wordRepository; + /** + * 기본 생성자 (Lambda에서 사용) + */ public WordCommandService() { - this.wordRepository = new WordRepository(); + this(new WordRepository()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public WordCommandService(WordRepository wordRepository) { + this.wordRepository = wordRepository; } public Word createWord(String english, String korean, String example, String level, String category) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordGroupCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordGroupCommandService.java index 018405b5..847af239 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordGroupCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordGroupCommandService.java @@ -21,8 +21,18 @@ public class WordGroupCommandService { private final WordGroupRepository wordGroupRepository; + /** + * 기본 생성자 (Lambda에서 사용) + */ public WordGroupCommandService() { - this.wordGroupRepository = new WordGroupRepository(); + this(new WordGroupRepository()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public WordGroupCommandService(WordGroupRepository wordGroupRepository) { + this.wordGroupRepository = wordGroupRepository; } public WordGroup createGroup(String userId, String groupName, String description) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordGroupQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordGroupQueryService.java index 0cc86c3c..21c43637 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordGroupQueryService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordGroupQueryService.java @@ -21,9 +21,19 @@ public class WordGroupQueryService { private final WordGroupRepository wordGroupRepository; private final WordRepository wordRepository; + /** + * 기본 생성자 (Lambda에서 사용) + */ public WordGroupQueryService() { - this.wordGroupRepository = new WordGroupRepository(); - this.wordRepository = new WordRepository(); + this(new WordGroupRepository(), new WordRepository()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public WordGroupQueryService(WordGroupRepository wordGroupRepository, WordRepository wordRepository) { + this.wordGroupRepository = wordGroupRepository; + this.wordRepository = wordRepository; } public PaginatedResult getGroups(String userId, int limit, String cursor) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordQueryService.java index a77cac3c..4c8b4275 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordQueryService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordQueryService.java @@ -18,8 +18,18 @@ public class WordQueryService { private final WordRepository wordRepository; + /** + * 기본 생성자 (Lambda에서 사용) + */ public WordQueryService() { - this.wordRepository = new WordRepository(); + this(new WordRepository()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public WordQueryService(WordRepository wordRepository) { + this.wordRepository = wordRepository; } public Optional getWord(String wordId) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordService.java deleted file mode 100644 index bfea0b71..00000000 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/WordService.java +++ /dev/null @@ -1,123 +0,0 @@ -package com.mzc.secondproject.serverless.domain.vocabulary.service; - -import com.mzc.secondproject.serverless.common.dto.PaginatedResult; -import com.mzc.secondproject.serverless.domain.vocabulary.factory.WordFactory; -import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; -import com.mzc.secondproject.serverless.domain.vocabulary.repository.WordRepository; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.List; -import java.util.Map; -import java.util.Optional; - -public class WordService { - - private static final Logger logger = LoggerFactory.getLogger(WordService.class); - - private final WordRepository wordRepository; - private final WordFactory wordFactory; - - /** - * 기본 생성자 (Lambda에서 사용) - */ - public WordService() { - this(new WordRepository(), new WordFactory()); - } - - /** - * 의존성 주입 생성자 (테스트 용이성) - */ - public WordService(WordRepository wordRepository, WordFactory wordFactory) { - this.wordRepository = wordRepository; - this.wordFactory = wordFactory; - } - - public Word createWord(String english, String korean, String example, String level, String category) { - Word word = wordFactory.create(english, korean, example, level, category); - wordRepository.save(word); - logger.info("Created word: {}", word.getWordId()); - return word; - } - - public Optional getWord(String wordId) { - return wordRepository.findById(wordId); - } - - public PaginatedResult getWords(String level, String category, int limit, String cursor) { - if (level != null && !level.isEmpty()) { - return wordRepository.findByLevelWithPagination(level, limit, cursor); - } else if (category != null && !category.isEmpty()) { - return wordRepository.findByCategoryWithPagination(category, limit, cursor); - } - return wordRepository.findByLevelWithPagination("BEGINNER", limit, cursor); - } - - public Word updateWord(String wordId, Map updates) { - Optional optWord = wordRepository.findById(wordId); - if (optWord.isEmpty()) { - throw new IllegalArgumentException("Word not found"); - } - - Word word = optWord.get(); - wordFactory.updateFields( - word, - (String) updates.get("english"), - (String) updates.get("korean"), - (String) updates.get("example"), - (String) updates.get("level"), - (String) updates.get("category") - ); - - wordRepository.save(word); - logger.info("Updated word: {}", wordId); - return word; - } - - public void deleteWord(String wordId) { - Optional optWord = wordRepository.findById(wordId); - if (optWord.isEmpty()) { - throw new IllegalArgumentException("Word not found"); - } - - wordRepository.delete(wordId); - logger.info("Deleted word: {}", wordId); - } - - public BatchResult createWordsBatch(List> wordsList) { - int successCount = 0; - int failCount = 0; - - for (Map wordData : wordsList) { - try { - String english = (String) wordData.get("english"); - String korean = (String) wordData.get("korean"); - String example = (String) wordData.get("example"); - String level = (String) wordData.get("level"); - String category = (String) wordData.get("category"); - - if (english == null || korean == null) { - failCount++; - continue; - } - - Word word = wordFactory.create(english, korean, example, level, category); - wordRepository.save(word); - successCount++; - } catch (Exception e) { - logger.error("Failed to create word", e); - failCount++; - } - } - - logger.info("Batch created {} words, failed {}", successCount, failCount); - return new BatchResult(successCount, failCount, wordsList.size()); - } - - public PaginatedResult searchWords(String query, int limit, String cursor) { - return wordRepository.searchByKeyword(query, limit, cursor); - } - - public record BatchResult(int successCount, int failCount, int totalRequested) { - } -} diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCodeSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCodeSpec.groovy index d1348464..9895fc71 100644 --- a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCodeSpec.groovy +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCodeSpec.groovy @@ -19,32 +19,36 @@ class ChattingErrorCodeSpec extends Specification { !errorCode.getMessage().isEmpty() where: - errorCode | expectedCode | expectedStatusCode - ChattingErrorCode.ROOM_NOT_FOUND | "ROOM_001" | 404 - ChattingErrorCode.ROOM_ALREADY_EXISTS | "ROOM_002" | 409 - ChattingErrorCode.ROOM_FULL | "ROOM_003" | 400 - ChattingErrorCode.ROOM_CLOSED | "ROOM_004" | 400 - ChattingErrorCode.ROOM_INVALID_PASSWORD | "ROOM_005" | 401 - ChattingErrorCode.ROOM_NOT_OWNER | "ROOM_006" | 403 - ChattingErrorCode.MESSAGE_NOT_FOUND | "MSG_001" | 404 - ChattingErrorCode.MESSAGE_TOO_LONG | "MSG_002" | 400 - ChattingErrorCode.INVALID_MESSAGE_TYPE | "MSG_003" | 400 - ChattingErrorCode.NOT_ROOM_MEMBER | "MEMBER_001" | 403 - ChattingErrorCode.ALREADY_JOINED | "MEMBER_002" | 409 - ChattingErrorCode.INVALID_ROOM_TOKEN | "MEMBER_003" | 401 - ChattingErrorCode.INVALID_CHAT_LEVEL | "LEVEL_001" | 400 - ChattingErrorCode.CONNECTION_FAILED | "CONN_001" | 500 - ChattingErrorCode.CONNECTION_TIMEOUT | "CONN_002" | 408 - ChattingErrorCode.GAME_START_FAILED | "GAME_001" | 400 - ChattingErrorCode.GAME_STOP_FAILED | "GAME_002" | 400 - ChattingErrorCode.GAME_NOT_IN_PROGRESS | "GAME_003" | 400 - ChattingErrorCode.GAME_ALREADY_IN_PROGRESS | "GAME_004" | 409 - ChattingErrorCode.NOT_GAME_STARTER | "GAME_005" | 403 + errorCode | expectedCode | expectedStatusCode + ChattingErrorCode.ROOM_NOT_FOUND | "ROOM_001" | 404 + ChattingErrorCode.ROOM_ALREADY_EXISTS | "ROOM_002" | 409 + ChattingErrorCode.ROOM_FULL | "ROOM_003" | 400 + ChattingErrorCode.ROOM_CLOSED | "ROOM_004" | 400 + ChattingErrorCode.ROOM_INVALID_PASSWORD | "ROOM_005" | 401 + ChattingErrorCode.ROOM_NOT_OWNER | "ROOM_006" | 403 + ChattingErrorCode.MESSAGE_NOT_FOUND | "MSG_001" | 404 + ChattingErrorCode.MESSAGE_TOO_LONG | "MSG_002" | 400 + ChattingErrorCode.INVALID_MESSAGE_TYPE | "MSG_003" | 400 + ChattingErrorCode.NOT_ROOM_MEMBER | "MEMBER_001" | 403 + ChattingErrorCode.ALREADY_JOINED | "MEMBER_002" | 409 + ChattingErrorCode.INVALID_ROOM_TOKEN | "MEMBER_003" | 401 + ChattingErrorCode.INVALID_CHAT_LEVEL | "LEVEL_001" | 400 + ChattingErrorCode.CONNECTION_FAILED | "CONN_001" | 500 + ChattingErrorCode.CONNECTION_TIMEOUT | "CONN_002" | 408 + ChattingErrorCode.GAME_START_FAILED | "GAME_001" | 400 + ChattingErrorCode.GAME_STOP_FAILED | "GAME_002" | 400 + ChattingErrorCode.GAME_NOT_IN_PROGRESS | "GAME_003" | 400 + ChattingErrorCode.GAME_ALREADY_IN_PROGRESS | "GAME_004" | 409 + ChattingErrorCode.NOT_GAME_STARTER | "GAME_005" | 403 + ChattingErrorCode.GAME_NOT_FOUND | "GAME_006" | 404 + ChattingErrorCode.GAME_NOT_ALLOWED_IN_CHAT_ROOM | "GAME_007" | 400 + ChattingErrorCode.GAME_RESTART_NOT_ALLOWED | "GAME_008" | 400 + ChattingErrorCode.GAME_START_NOT_HOST | "GAME_009" | 403 } def "모든 에러 코드 개수 확인"() { - expect: "20개의 에러 코드 존재" - ChattingErrorCode.values().length == 20 + expect: "24개의 에러 코드 존재" + ChattingErrorCode.values().length == 24 } def "채팅방 관련 에러 코드들 (ROOM_XXX)"() { @@ -71,5 +75,9 @@ class ChattingErrorCodeSpec extends Specification { ChattingErrorCode.GAME_NOT_IN_PROGRESS.getCode().startsWith("GAME_") ChattingErrorCode.GAME_ALREADY_IN_PROGRESS.getCode().startsWith("GAME_") ChattingErrorCode.NOT_GAME_STARTER.getCode().startsWith("GAME_") + ChattingErrorCode.GAME_NOT_FOUND.getCode().startsWith("GAME_") + ChattingErrorCode.GAME_NOT_ALLOWED_IN_CHAT_ROOM.getCode().startsWith("GAME_") + ChattingErrorCode.GAME_RESTART_NOT_ALLOWED.getCode().startsWith("GAME_") + ChattingErrorCode.GAME_START_NOT_HOST.getCode().startsWith("GAME_") } } diff --git a/ServerlessFunction/src/test/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomStatusTest.java b/ServerlessFunction/src/test/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomStatusTest.java new file mode 100644 index 00000000..212b6761 --- /dev/null +++ b/ServerlessFunction/src/test/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomStatusTest.java @@ -0,0 +1,45 @@ +package com.mzc.secondproject.serverless.domain.chatting.enums; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class RoomStatusTest { + @Test + void testFromString() { + assertEquals(RoomStatus.WAITING, RoomStatus.fromString("waiting")); + assertEquals(RoomStatus.WAITING, RoomStatus.fromString("WAITING")); + assertEquals(RoomStatus.PLAYING, RoomStatus.fromString("playing")); + assertEquals(RoomStatus.PLAYING, RoomStatus.fromString("PLAYING")); + assertEquals(RoomStatus.FINISHED, RoomStatus.fromString("finished")); + assertEquals(RoomStatus.FINISHED, RoomStatus.fromString("FINISHED")); + assertEquals(RoomStatus.WAITING, RoomStatus.fromString(null)); + assertEquals(RoomStatus.WAITING, RoomStatus.fromString("invalid")); + } + + @Test + void testIsValid() { + assertTrue(RoomStatus.isValid("WAITING")); + assertTrue(RoomStatus.isValid("waiting")); + assertTrue(RoomStatus.isValid("PLAYING")); + assertTrue(RoomStatus.isValid("playing")); + assertTrue(RoomStatus.isValid("FINISHED")); + assertTrue(RoomStatus.isValid("finished")); + assertFalse(RoomStatus.isValid(null)); + assertFalse(RoomStatus.isValid("invalid")); + } + + @Test + void testGetCode() { + assertEquals("waiting", RoomStatus.WAITING.getCode()); + assertEquals("playing", RoomStatus.PLAYING.getCode()); + assertEquals("finished", RoomStatus.FINISHED.getCode()); + } + + @Test + void testGetDisplayName() { + assertEquals("대기 중", RoomStatus.WAITING.getDisplayName()); + assertEquals("게임 중", RoomStatus.PLAYING.getDisplayName()); + assertEquals("종료됨", RoomStatus.FINISHED.getDisplayName()); + } +} diff --git a/ServerlessFunction/src/test/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomTypeTest.java b/ServerlessFunction/src/test/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomTypeTest.java new file mode 100644 index 00000000..37347718 --- /dev/null +++ b/ServerlessFunction/src/test/java/com/mzc/secondproject/serverless/domain/chatting/enums/RoomTypeTest.java @@ -0,0 +1,39 @@ +package com.mzc.secondproject.serverless.domain.chatting.enums; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class RoomTypeTest { + @Test + void testFromString() { + assertEquals(RoomType.CHAT, RoomType.fromString("chat")); + assertEquals(RoomType.CHAT, RoomType.fromString("CHAT")); + assertEquals(RoomType.GAME, RoomType.fromString("game")); + assertEquals(RoomType.GAME, RoomType.fromString("GAME")); + assertEquals(RoomType.CHAT, RoomType.fromString(null)); + assertEquals(RoomType.CHAT, RoomType.fromString("invalid")); + } + + @Test + void testIsValid() { + assertTrue(RoomType.isValid("CHAT")); + assertTrue(RoomType.isValid("chat")); + assertTrue(RoomType.isValid("GAME")); + assertTrue(RoomType.isValid("game")); + assertFalse(RoomType.isValid(null)); + assertFalse(RoomType.isValid("invalid")); + } + + @Test + void testGetCode() { + assertEquals("chat", RoomType.CHAT.getCode()); + assertEquals("game", RoomType.GAME.getCode()); + } + + @Test + void testGetDisplayName() { + assertEquals("채팅방", RoomType.CHAT.getDisplayName()); + assertEquals("게임방", RoomType.GAME.getDisplayName()); + } +} diff --git a/ServerlessFunction/src/test/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSettingsTest.java b/ServerlessFunction/src/test/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSettingsTest.java new file mode 100644 index 00000000..9d608c90 --- /dev/null +++ b/ServerlessFunction/src/test/java/com/mzc/secondproject/serverless/domain/chatting/model/GameSettingsTest.java @@ -0,0 +1,55 @@ +package com.mzc.secondproject.serverless.domain.chatting.model; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class GameSettingsTest { + @Test + void testDefaultValues() { + GameSettings settings = GameSettings.builder().build(); + assertEquals(5, settings.getMaxRounds()); + assertEquals(60, settings.getRoundTimeLimit()); + assertFalse(settings.getAutoDeleteOnEnd()); + } + + @Test + void testCustomValues() { + GameSettings settings = GameSettings.builder() + .maxRounds(10) + .roundTimeLimit(90) + .autoDeleteOnEnd(true) + .build(); + assertEquals(10, settings.getMaxRounds()); + assertEquals(90, settings.getRoundTimeLimit()); + assertTrue(settings.getAutoDeleteOnEnd()); + } + + @Test + void testNoArgsConstructor() { + GameSettings settings = new GameSettings(); + assertEquals(5, settings.getMaxRounds()); + assertEquals(60, settings.getRoundTimeLimit()); + assertFalse(settings.getAutoDeleteOnEnd()); + } + + @Test + void testAllArgsConstructor() { + GameSettings settings = new GameSettings(10, 90, true); + assertEquals(10, settings.getMaxRounds()); + assertEquals(90, settings.getRoundTimeLimit()); + assertTrue(settings.getAutoDeleteOnEnd()); + } + + @Test + void testSettersAndGetters() { + GameSettings settings = new GameSettings(); + settings.setMaxRounds(8); + settings.setRoundTimeLimit(120); + settings.setAutoDeleteOnEnd(true); + + assertEquals(8, settings.getMaxRounds()); + assertEquals(120, settings.getRoundTimeLimit()); + assertTrue(settings.getAutoDeleteOnEnd()); + } +} diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 99bdbafc..09663db9 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -110,6 +110,8 @@ Resources: CodeUri: . Handler: com.mzc.secondproject.serverless.domain.user.handler.PostConfirmationHandler::handleRequest Description: Handle user registration - save to DynamoDB + MemorySize: 1024 + Timeout: 5 SnapStart: ApplyOn: PublishedVersions Policies: @@ -232,9 +234,15 @@ Resources: Environment: Variables: WEBSOCKET_CONNECTION_TTL_SECONDS: "600" + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable + - Statement: + - Effect: Allow + Action: + - execute-api:ManageConnections + Resource: !Sub arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${WebSocketApi}/* WebSocketConnectPermission: Type: AWS::Lambda::Permission @@ -253,9 +261,17 @@ Resources: Description: Handle WebSocket $disconnect SnapStart: ApplyOn: PublishedVersions + Environment: + Variables: + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable + - Statement: + - Effect: Allow + Action: + - execute-api:ManageConnections + Resource: !Sub arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${WebSocketApi}/* WebSocketDisconnectPermission: Type: AWS::Lambda::Permission @@ -277,6 +293,8 @@ Resources: Environment: Variables: WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" + GAME_AUTO_CLOSE_LAMBDA_ARN: !GetAtt GameAutoCloseFunction.Arn + SCHEDULER_ROLE_ARN: !GetAtt GameSchedulerRole.Arn Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -287,6 +305,19 @@ Resources: Action: - execute-api:ManageConnections Resource: !Sub arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${WebSocketApi}/* + # EventBridge Scheduler 권한 + - Statement: + - Effect: Allow + Action: + - scheduler:CreateSchedule + - scheduler:DeleteSchedule + - scheduler:GetSchedule + Resource: !Sub "arn:aws:scheduler:${AWS::Region}:${AWS::AccountId}:schedule/game-auto-close/*" + - Statement: + - Effect: Allow + Action: + - iam:PassRole + Resource: !GetAtt GameSchedulerRole.Arn WebSocketMessagePermission: Type: AWS::Lambda::Permission @@ -347,9 +378,19 @@ Resources: Description: Handle chat room CRUD operations SnapStart: ApplyOn: PublishedVersions + Environment: + Variables: + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable + - DynamoDBReadPolicy: + TableName: !Ref UserTable + - Statement: + - Effect: Allow + Action: + - execute-api:ManageConnections + Resource: !Sub arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${WebSocketApi}/* Events: CreateRoom: Type: Api @@ -412,14 +453,31 @@ Resources: Environment: Variables: WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" + GAME_AUTO_CLOSE_LAMBDA_ARN: !GetAtt GameAutoCloseFunction.Arn + SCHEDULER_ROLE_ARN: !GetAtt GameSchedulerRole.Arn Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable + - DynamoDBReadPolicy: + TableName: !Ref VocabTable - Statement: - Effect: Allow Action: - execute-api:ManageConnections Resource: !Sub "arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${WebSocketApi}/*" + # EventBridge Scheduler 권한 + - Statement: + - Effect: Allow + Action: + - scheduler:CreateSchedule + - scheduler:DeleteSchedule + - scheduler:GetSchedule + Resource: !Sub "arn:aws:scheduler:${AWS::Region}:${AWS::AccountId}:schedule/game-auto-close/*" + - Statement: + - Effect: Allow + Action: + - iam:PassRole + Resource: !GetAtt GameSchedulerRole.Arn Events: StartGame: Type: Api @@ -454,6 +512,58 @@ Resources: Auth: Authorizer: CognitoAuthorizer + # 게임 자동 종료 Lambda (EventBridge Scheduler에 의해 호출) + GameAutoCloseFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: group2-englishstudy-game-auto-close + CodeUri: . + Handler: com.mzc.secondproject.serverless.domain.chatting.handler.GameAutoCloseHandler::handleRequest + Description: Auto-close game after 7 minutes + Timeout: 30 + MemorySize: 512 + SnapStart: + ApplyOn: PublishedVersions + Environment: + Variables: + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref ChatTable + - Statement: + - Effect: Allow + Action: + - execute-api:ManageConnections + Resource: !Sub "arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${WebSocketApi}/*" + + # EventBridge Scheduler가 Lambda를 호출할 수 있는 IAM Role + GameSchedulerRole: + Type: AWS::IAM::Role + Properties: + RoleName: !Sub "${AWS::StackName}-game-scheduler-role" + AssumeRolePolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Principal: + Service: scheduler.amazonaws.com + Action: sts:AssumeRole + Policies: + - PolicyName: InvokeGameAutoCloseLambda + PolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Action: + - lambda:InvokeFunction + Resource: !GetAtt GameAutoCloseFunction.Arn + + # EventBridge Schedule Group + GameScheduleGroup: + Type: AWS::Scheduler::ScheduleGroup + Properties: + Name: game-auto-close + ChatMessageFunction: Type: AWS::Serverless::Function Properties: @@ -1177,9 +1287,17 @@ Resources: CodeUri: . Handler: com.mzc.secondproject.serverless.domain.grammar.handler.websocket.GrammarStreamingConnectHandler::handleRequest Description: Handle Grammar WebSocket $connect with JWT auth + Environment: + Variables: + WEBSOCKET_ENDPOINT: !Sub "https://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable + - Statement: + - Effect: Allow + Action: + - execute-api:ManageConnections + Resource: !Sub arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${GrammarWebSocketApi}/* GrammarStreamingConnectPermission: Type: AWS::Lambda::Permission @@ -1196,9 +1314,17 @@ Resources: CodeUri: . Handler: com.mzc.secondproject.serverless.domain.grammar.handler.websocket.GrammarStreamingDisconnectHandler::handleRequest Description: Handle Grammar WebSocket $disconnect + Environment: + Variables: + WEBSOCKET_ENDPOINT: !Sub "https://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable + - Statement: + - Effect: Allow + Action: + - execute-api:ManageConnections + Resource: !Sub arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${GrammarWebSocketApi}/* GrammarStreamingDisconnectPermission: Type: AWS::Lambda::Permission @@ -1284,7 +1410,7 @@ Resources: ApplyOn: PublishedVersions Environment: Variables: - TRANSCRIBE_API_KEY_PARAM: "/opic/transcribe-proxy-api-key" + TRANSCRIBE_API_KEY: "/opic/transcribe-proxy-api-key" Policies: - DynamoDBCrudPolicy: TableName: !Ref OPIcTable diff --git a/cicd/pipeline.yaml b/cicd/pipeline.yaml new file mode 100644 index 00000000..74919262 --- /dev/null +++ b/cicd/pipeline.yaml @@ -0,0 +1,331 @@ +AWSTemplateFormatVersion: '2010-09-09' +Description: CI/CD Pipeline for Group2 English Study Backend + +Parameters: + GitHubConnectionArn: + Type: String + Default: "arn:aws:codeconnections:ap-northeast-2:682405977339:connection/6cdbf218-483a-4e49-9c74-dd6fe84dbd2b" + Description: ARN of the GitHub Connection + + GitHubRepo: + Type: String + Default: "Language-Study-Prooject/BE_Repository" + Description: GitHub repository (owner/repo) + + GitHubBranch: + Type: String + Default: "prod" + Description: Branch to trigger pipeline + + NotificationEmail: + Type: String + Description: Email address for pipeline notifications + +Resources: + ############################################# + # S3 Bucket for Pipeline Artifacts + ############################################# + ArtifactBucket: + Type: AWS::S3::Bucket + Properties: + BucketName: group2-englishstudy-pipeline-artifacts + VersioningConfiguration: + Status: Enabled + BucketEncryption: + ServerSideEncryptionConfiguration: + - ServerSideEncryptionByDefault: + SSEAlgorithm: AES256 + PublicAccessBlockConfiguration: + BlockPublicAcls: true + BlockPublicPolicy: true + IgnorePublicAcls: true + RestrictPublicBuckets: true + + ############################################# + # SNS Topic for Notifications + ############################################# + NotificationTopic: + Type: AWS::SNS::Topic + Properties: + TopicName: cicd-pipeline-notifications + DisplayName: CI/CD Pipeline Notifications + + EmailSubscription: + Type: AWS::SNS::Subscription + Properties: + TopicArn: !Ref NotificationTopic + Protocol: email + Endpoint: !Ref NotificationEmail + + ############################################# + # IAM Roles + ############################################# + + # CodePipeline Service Role + PipelineRole: + Type: AWS::IAM::Role + Properties: + RoleName: group2-codepipeline-role + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: codepipeline.amazonaws.com + Action: sts:AssumeRole + Policies: + - PolicyName: PipelinePolicy + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - codestar-connections:UseConnection + - codeconnections:UseConnection + Resource: !Ref GitHubConnectionArn + - Effect: Allow + Action: + - s3:GetObject + - s3:GetObjectVersion + - s3:PutObject + - s3:GetBucketVersioning + Resource: + - !GetAtt ArtifactBucket.Arn + - !Sub "${ArtifactBucket.Arn}/*" + - Effect: Allow + Action: + - codebuild:BatchGetBuilds + - codebuild:StartBuild + Resource: !GetAtt CodeBuildProject.Arn + - Effect: Allow + Action: + - cloudformation:CreateStack + - cloudformation:DeleteStack + - cloudformation:DescribeStacks + - cloudformation:UpdateStack + - cloudformation:CreateChangeSet + - cloudformation:DeleteChangeSet + - cloudformation:DescribeChangeSet + - cloudformation:ExecuteChangeSet + - cloudformation:SetStackPolicy + - cloudformation:ValidateTemplate + Resource: "*" + - Effect: Allow + Action: + - iam:PassRole + Resource: !GetAtt CloudFormationRole.Arn + - Effect: Allow + Action: + - sns:Publish + Resource: !Ref NotificationTopic + + # CodeBuild Service Role + CodeBuildRole: + Type: AWS::IAM::Role + Properties: + RoleName: group2-codebuild-role + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: codebuild.amazonaws.com + Action: sts:AssumeRole + Policies: + - PolicyName: CodeBuildPolicy + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - logs:CreateLogGroup + - logs:CreateLogStream + - logs:PutLogEvents + Resource: + - !Sub "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/codebuild/*" + - Effect: Allow + Action: + - s3:GetObject + - s3:GetObjectVersion + - s3:PutObject + - s3:GetBucketAcl + - s3:GetBucketLocation + Resource: + - !GetAtt ArtifactBucket.Arn + - !Sub "${ArtifactBucket.Arn}/*" + - "arn:aws:s3:::group2-englishstudy" + - "arn:aws:s3:::group2-englishstudy/*" + - Effect: Allow + Action: + - codebuild:CreateReportGroup + - codebuild:CreateReport + - codebuild:UpdateReport + - codebuild:BatchPutTestCases + - codebuild:BatchPutCodeCoverages + Resource: !Sub "arn:aws:codebuild:${AWS::Region}:${AWS::AccountId}:report-group/*" + + # CloudFormation Execution Role + CloudFormationRole: + Type: AWS::IAM::Role + Properties: + RoleName: group2-cloudformation-role + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: cloudformation.amazonaws.com + Action: sts:AssumeRole + ManagedPolicyArns: + - arn:aws:iam::aws:policy/AdministratorAccess + + ############################################# + # CodeBuild Project + ############################################# + CodeBuildProject: + Type: AWS::CodeBuild::Project + Properties: + Name: group2-englishstudy-build + Description: Build project for Group2 English Study Backend + ServiceRole: !GetAtt CodeBuildRole.Arn + Artifacts: + Type: CODEPIPELINE + Environment: + Type: LINUX_CONTAINER + ComputeType: BUILD_GENERAL1_MEDIUM + Image: aws/codebuild/amazonlinux2-x86_64-standard:5.0 + EnvironmentVariables: + - Name: ARTIFACT_BUCKET + Value: !Ref ArtifactBucket + Source: + Type: CODEPIPELINE + BuildSpec: ServerlessFunction/buildspec.yml + TimeoutInMinutes: 30 + Cache: + Type: S3 + Location: !Sub "${ArtifactBucket}/cache" + LogsConfig: + CloudWatchLogs: + Status: ENABLED + GroupName: !Sub "/aws/codebuild/group2-englishstudy-build" + + ############################################# + # CodePipeline + ############################################# + Pipeline: + Type: AWS::CodePipeline::Pipeline + Properties: + Name: group2-englishstudy-pipeline + RoleArn: !GetAtt PipelineRole.Arn + ArtifactStore: + Type: S3 + Location: !Ref ArtifactBucket + Stages: + # Source Stage + - Name: Source + Actions: + - Name: GitHub + ActionTypeId: + Category: Source + Owner: AWS + Provider: CodeStarSourceConnection + Version: '1' + Configuration: + ConnectionArn: !Ref GitHubConnectionArn + FullRepositoryId: !Ref GitHubRepo + BranchName: !Ref GitHubBranch + OutputArtifactFormat: CODE_ZIP + DetectChanges: true + OutputArtifacts: + - Name: SourceArtifact + RunOrder: 1 + + # Build Stage + - Name: Build + Actions: + - Name: Build + ActionTypeId: + Category: Build + Owner: AWS + Provider: CodeBuild + Version: '1' + Configuration: + ProjectName: !Ref CodeBuildProject + InputArtifacts: + - Name: SourceArtifact + OutputArtifacts: + - Name: BuildArtifact + RunOrder: 1 + + # Deploy Stage + - Name: Deploy + Actions: + - Name: Deploy + ActionTypeId: + Category: Deploy + Owner: AWS + Provider: CloudFormation + Version: '1' + Configuration: + ActionMode: CREATE_UPDATE + StackName: group2-englishstudy-chatting + TemplatePath: BuildArtifact::packaged-template.yaml + Capabilities: CAPABILITY_IAM,CAPABILITY_AUTO_EXPAND + RoleArn: !GetAtt CloudFormationRole.Arn + InputArtifacts: + - Name: BuildArtifact + RunOrder: 1 + + ############################################# + # SNS Topic Policy for CodeStar Notifications + ############################################# + NotificationTopicPolicy: + Type: AWS::SNS::TopicPolicy + Properties: + Topics: + - !Ref NotificationTopic + PolicyDocument: + Version: '2012-10-17' + Statement: + - Sid: AllowCodeStarNotifications + Effect: Allow + Principal: + Service: codestar-notifications.amazonaws.com + Action: sns:Publish + Resource: !Ref NotificationTopic + + ############################################# + # Pipeline Notification Rule + ############################################# + PipelineNotificationRule: + Type: AWS::CodeStarNotifications::NotificationRule + DependsOn: + - Pipeline + - NotificationTopicPolicy + Properties: + Name: group2-pipeline-notifications + DetailType: FULL + Resource: !Sub "arn:aws:codepipeline:${AWS::Region}:${AWS::AccountId}:${Pipeline}" + EventTypeIds: + - codepipeline-pipeline-pipeline-execution-started + - codepipeline-pipeline-pipeline-execution-succeeded + - codepipeline-pipeline-pipeline-execution-failed + Targets: + - TargetType: SNS + TargetAddress: !Ref NotificationTopic + +############################################# +# Outputs +############################################# +Outputs: + PipelineUrl: + Description: URL to the CodePipeline console + Value: !Sub "https://${AWS::Region}.console.aws.amazon.com/codesuite/codepipeline/pipelines/${Pipeline}/view" + + ArtifactBucketName: + Description: S3 bucket for pipeline artifacts + Value: !Ref ArtifactBucket + + NotificationTopicArn: + Description: SNS topic for notifications + Value: !Ref NotificationTopic diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 00000000..9f69c063 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,46 @@ +# CodeBuild Custom Image with Java 21 + SAM CLI +FROM public.ecr.aws/amazonlinux/amazonlinux:2023 + +# Install basic dependencies +RUN dnf update -y && \ + dnf install -y \ + git \ + tar \ + gzip \ + unzip \ + which \ + findutils \ + python3 \ + python3-pip \ + docker \ + && dnf clean all + +# Install Amazon Corretto 21 (Java 21) +RUN rpm --import https://yum.corretto.aws/corretto.key && \ + curl -L -o /etc/yum.repos.d/corretto.repo https://yum.corretto.aws/corretto.repo && \ + dnf install -y java-21-amazon-corretto-devel && \ + dnf clean all + +# Set JAVA_HOME +ENV JAVA_HOME=/usr/lib/jvm/java-21-amazon-corretto +ENV PATH=$JAVA_HOME/bin:$PATH + +# Install AWS CLI and SAM CLI via pip +RUN pip3 install --ignore-installed awscli aws-sam-cli + +# Install Gradle +ENV GRADLE_VERSION=8.5 +RUN curl -L https://services.gradle.org/distributions/gradle-${GRADLE_VERSION}-bin.zip -o gradle.zip && \ + unzip gradle.zip -d /opt && \ + rm gradle.zip && \ + ln -s /opt/gradle-${GRADLE_VERSION}/bin/gradle /usr/bin/gradle + +# Verify Java installation +RUN java -version + +# Set working directory +WORKDIR /codebuild/output/src + +# Labels +LABEL maintainer="group2-englishstudy" \ + description="CodeBuild image with Java 21, SAM CLI, and Gradle pre-installed" diff --git a/docker/build-and-push.sh b/docker/build-and-push.sh new file mode 100755 index 00000000..f59bb5c1 --- /dev/null +++ b/docker/build-and-push.sh @@ -0,0 +1,58 @@ +#!/bin/bash +set -e + +# Configuration +AWS_PROFILE="mzc" +AWS_REGION="ap-northeast-2" +ECR_REPO_NAME="group2-codebuild-image" +IMAGE_TAG="java21-sam" + +export AWS_DEFAULT_REGION="${AWS_REGION}" +export AWS_PROFILE="${AWS_PROFILE}" + +AWS_ACCOUNT_ID=$(aws sts get-caller-identity --profile ${AWS_PROFILE} --region ${AWS_REGION} --query Account --output text) +ECR_URI="${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/${ECR_REPO_NAME}" + +echo "=== CodeBuild Custom Image Build & Push ===" +echo "AWS Profile: ${AWS_PROFILE}" +echo "AWS Account: ${AWS_ACCOUNT_ID}" +echo "ECR Repository: ${ECR_REPO_NAME}" +echo "Image: ${ECR_URI}:${IMAGE_TAG}" +echo "" + +# 1. Create ECR repository (if not exists) +echo "[1/4] Creating ECR repository..." +aws ecr describe-repositories --repository-names ${ECR_REPO_NAME} --profile ${AWS_PROFILE} --region ${AWS_REGION} 2>/dev/null || \ + aws ecr create-repository --repository-name ${ECR_REPO_NAME} --profile ${AWS_PROFILE} --region ${AWS_REGION} + +# 2. Login to ECR +echo "[2/4] Logging in to ECR..." +aws ecr get-login-password --profile ${AWS_PROFILE} --region ${AWS_REGION} | docker login --username AWS --password-stdin ${ECR_URI} + +# 3. Build and push Docker image (using buildx for cross-platform) +echo "[3/4] Building and pushing Docker image (linux/amd64)..." +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Create buildx builder if not exists +docker buildx create --name multiarch --use 2>/dev/null || docker buildx use multiarch + +# Build and push directly (avoids local platform issues on Apple Silicon) +docker buildx build \ + --platform linux/amd64 \ + --tag ${ECR_URI}:${IMAGE_TAG} \ + --tag ${ECR_URI}:latest \ + --push \ + ${SCRIPT_DIR} + +echo "[4/4] Push complete" + +echo "" +echo "=== SUCCESS ===" +echo "Image pushed: ${ECR_URI}:${IMAGE_TAG}" +echo "" +echo "Next steps:" +echo "1. Update CodeBuild project to use custom image:" +echo " Image: ${ECR_URI}:${IMAGE_TAG}" +echo " Image pull credentials: Service role" +echo "" +echo "2. Add ECR pull permission to CodeBuild service role" diff --git a/docs/CATCHMIND_ARCHITECTURE_SOLUTION.md b/docs/CATCHMIND_ARCHITECTURE_SOLUTION.md new file mode 100644 index 00000000..e4c22aa4 --- /dev/null +++ b/docs/CATCHMIND_ARCHITECTURE_SOLUTION.md @@ -0,0 +1,521 @@ +# 채팅방 / 캐치마인드 게임 분리 - 종합 솔루션 + +## 1. 현재 문제점 분석 + +### 1.1 백엔드 현황 + +``` +ChatRoom.java (현재 - 혼합 모델) +├── 채팅 필드 +│ ├── roomId, name, description +│ ├── memberIds, currentMembers +│ └── lastMessageAt +│ +└── 게임 필드 (여기에 섞여있음) + ├── gameStatus, gameStartedBy + ├── currentRound, totalRounds + ├── currentDrawerId, currentWord + ├── roundStartTime, roundTimeLimit ← serverTime 없음! + ├── scores, streaks + └── correctGuessers +``` + +**문제점:** + +1. `roundStartTime`만 전송, `serverTime` 누락 → 클라이언트 타이머 동기화 불가 +2. 게임 세션이 채팅방에 종속 → 게임 상태 독립 관리 불가 +3. 재접속 시 게임 상태 복구 어려움 +4. 게임 종료 후 상태 정리 복잡 + +### 1.2 WebSocket 메시지 현황 + +```java +// WebSocketMessageHandler.java - 현재 구조 +handleRequest() { + switch (messageType) { + case "DRAWING", "DRAWING_CLEAR" -> handleDrawingMessage() // 게임 + default -> handleRegularMessage() { + // 1. 슬래시 명령어 처리 (/start, /stop, /score...) + // 2. 게임 중 정답 체크 + // 3. 일반 채팅 메시지 + } + } +} +``` + +**문제점:** + +- 채팅/게임 구분 없이 모든 메시지가 동일 핸들러에서 처리 +- 메시지에 `domain` 필드 없음 + +--- + +## 2. 최적 솔루션 + +### 2.1 아키텍처 개요 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ WebSocket (단일 엔드포인트 유지) │ +│ │ +│ ┌──────────────────────┐ ┌────────────────────────────────┐ │ +│ │ domain: "chat" │ │ domain: "game" │ │ +│ │ │ │ │ │ +│ │ • TEXT │ │ • GAME_START / GAME_END │ │ +│ │ • USER_JOIN │ │ • ROUND_START / ROUND_END │ │ +│ │ • USER_LEAVE │ │ • DRAWING / DRAWING_CLEAR │ │ +│ │ • SYSTEM │ │ • GUESS / CORRECT_ANSWER │ │ +│ │ │ │ • SCORE_UPDATE / HINT │ │ +│ └──────────────────────┘ └────────────────────────────────┘ │ +│ │ +│ GameSession (별도 모델) │ +│ ├── gameSessionId │ +│ ├── roomId (연결용) │ +│ ├── status, currentRound │ +│ ├── roundStartTime + serverTime ← 핵심! │ +│ └── scores, players │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 2.2 핵심 변경사항 + +| 구분 | 현재 | 변경 후 | +|-----|----------------------|---------------------------------| +| 모델 | `ChatRoom`에 게임 필드 포함 | `ChatRoom` + `GameSession` 분리 | +| 타이머 | `roundStartTime`만 전송 | `roundStartTime` + `serverTime` | +| 메시지 | `messageType`만 존재 | `domain` + `messageType` | +| API | 채팅방 API만 존재 | 게임 세션 API 추가 | + +--- + +## 3. 백엔드 변경사항 + +### 3.1 Phase 1: 타이머 버그 수정 (즉시) + +**변경 파일:** `WebSocketMessageHandler.java` + +```java +// GAME_START 메시지에 serverTime 추가 +private void broadcastGameStart(...) { + Map message = new HashMap<>(); + // ... 기존 코드 ... + + message.put("roundStartTime", gameResult.room().getRoundStartTime()); + message.put("serverTime", System.currentTimeMillis()); // 추가! + message.put("roundDuration", gameResult.room().getRoundTimeLimit()); // 명확한 이름 + + // ... +} + +// ROUND_END → ROUND_START 메시지에도 동일하게 추가 +private void broadcastRoundEnd(...) { + // ... + messageData.put("roundStartTime", room.getRoundStartTime()); + messageData.put("serverTime", System.currentTimeMillis()); // 추가! + messageData.put("roundDuration", room.getRoundTimeLimit()); + // ... +} +``` + +**예상 작업량:** 30분 + +### 3.2 Phase 2: 메시지 구조 개선 (1일) + +**변경 파일:** `WebSocketMessageHandler.java`, 모든 브로드캐스트 메서드 + +```java +// 모든 메시지에 domain 필드 추가 +private Map createMessage(String domain, String messageType, Object data) { + Map message = new HashMap<>(); + message.put("domain", domain); // "chat" 또는 "game" + message.put("messageType", messageType); + message.put("data", data); + message.put("timestamp", System.currentTimeMillis()); + return message; +} + +// 채팅 메시지 +createMessage("chat", "TEXT", chatData); +createMessage("chat", "USER_JOIN", joinData); + +// 게임 메시지 +createMessage("game", "GAME_START", gameStartData); +createMessage("game", "ROUND_START", roundStartData); +createMessage("game", "DRAWING", drawingData); +``` + +### 3.3 Phase 3: 게임 세션 분리 (1주) + +#### 3.3.1 새 모델: GameSession.java + +```java +@DynamoDbBean +public class GameSession { + private String pk; // GAME#{gameSessionId} + private String sk; // METADATA + private String gsi1pk; // ROOM#{roomId} + private String gsi1sk; // GAME#{createdAt} + + // 게임 식별 + private String gameSessionId; + private String roomId; // 연결된 채팅방 + private String gameType; // "catchmind" + + // 게임 상태 + private String status; // WAITING, PLAYING, FINISHED + private String startedBy; + private Long startedAt; + private Long endedAt; + + // 라운드 정보 + private Integer currentRound; + private Integer totalRounds; + private String currentDrawerId; + private String currentWordId; + private String currentWord; + private Long roundStartTime; + private Integer roundDuration; + + // 점수 + private Map scores; + private Map streaks; + private List players; + private List drawerOrder; + + // 자동 종료 + private Long gameEndScheduledAt; + private String scheduleRuleArn; + + // TTL + private Long ttl; +} +``` + +#### 3.3.2 ChatRoom에서 게임 필드 제거 + +```java +@DynamoDbBean +public class ChatRoom { + // 채팅 필드만 유지 + private String roomId; + private String name; + private String description; + private String level; + private Integer currentMembers; + private Integer maxMembers; + private Boolean isPrivate; + private String password; + private String createdBy; + private String createdAt; + private String lastMessageAt; + private List memberIds; + + // 게임 연결 (참조만) + private String activeGameSessionId; // 현재 진행중인 게임 세션 ID + + // 게임 필드 모두 제거! + // - gameStatus, gameStartedBy, currentRound... 전부 GameSession으로 이동 +} +``` + +#### 3.3.3 게임 세션 API + +``` +# 게임 세션 생성 +POST /api/chat/rooms/{roomId}/games +Request: +{ + "gameType": "catchmind", + "settings": { + "totalRounds": 5, + "roundDuration": 60 + } +} + +Response: +{ + "gameSessionId": "game-abc123", + "roomId": "room-xyz", + "status": "WAITING", + "createdAt": "2024-01-20T10:00:00Z" +} + +# 게임 상태 조회 (재접속 시 필수!) +GET /api/games/{gameSessionId} + +Response: +{ + "gameSessionId": "game-abc123", + "roomId": "room-xyz", + "status": "PLAYING", + "currentRound": 2, + "totalRounds": 5, + "currentDrawerId": "user123", + "roundStartTime": 1705744800000, + "serverTime": 1705744830000, // 핵심! + "roundDuration": 60, + "scores": { + "user1": 150, + "user2": 120 + }, + "players": ["user1", "user2", "user3"] +} + +# 게임 시작 (기존 /start 명령어 대체) +POST /api/games/{gameSessionId}/start + +# 게임 종료 +POST /api/games/{gameSessionId}/stop +``` + +--- + +## 4. 프론트엔드 변경사항 + +### 4.1 Phase 1: 타이머 버그 수정 (즉시) + +```javascript +// useTimer.js - 독립적인 타이머 훅 +export function useTimer(roundStartTime, roundDuration, serverTime) { + const [remainingTime, setRemainingTime] = useState(roundDuration); + + useEffect(() => { + if (!roundStartTime || !roundDuration) return; + + // 서버-클라이언트 시간 차이 보정 + const timeOffset = serverTime ? (Date.now() - serverTime) : 0; + + const interval = setInterval(() => { + const adjustedNow = Date.now() - timeOffset; + const elapsed = Math.floor((adjustedNow - roundStartTime) / 1000); + const remaining = Math.max(0, roundDuration - elapsed); + setRemainingTime(remaining); + + if (remaining <= 0) { + clearInterval(interval); + } + }, 100); + + return () => clearInterval(interval); + }, [roundStartTime, roundDuration, serverTime]); + + return remainingTime; +} +``` + +### 4.2 Phase 2: 메시지 핸들러 분리 + +```javascript +// WebSocket 메시지 핸들러 +onMessage(event) { + const message = JSON.parse(event.data); + + switch (message.domain) { + case 'chat': + this.handleChatMessage(message); + break; + case 'game': + this.handleGameMessage(message); + break; + } +} + +handleChatMessage(message) { + switch (message.messageType) { + case 'TEXT': // 채팅 메시지 + case 'USER_JOIN': + case 'USER_LEAVE': + case 'SYSTEM': + } +} + +handleGameMessage(message) { + switch (message.messageType) { + case 'GAME_START': + case 'ROUND_START': + case 'DRAWING': + case 'CORRECT_ANSWER': + case 'SCORE_UPDATE': + } +} +``` + +### 4.3 Phase 3: 훅 분리 + +``` +src/domains/ +├── chat/ +│ ├── hooks/ +│ │ └── useChatWebSocket.js # 채팅만 처리 +│ └── components/ +│ ├── ChatMessages.jsx +│ └── ChatInput.jsx +│ +├── catchmind/ +│ ├── hooks/ +│ │ ├── useGameWebSocket.js # 게임만 처리 +│ │ ├── useGameState.js +│ │ └── useTimer.js +│ └── components/ +│ ├── DrawingCanvas.jsx +│ ├── ScoreBoard.jsx +│ └── Timer.jsx +│ +└── freetalk/ + └── pages/ + └── FreeTalkPage.jsx # chat + catchmind 조합 +``` + +--- + +## 5. 메시지 스펙 (최종) + +### 5.1 공통 메시지 구조 + +```json +{ + "domain": "chat" | "game", + "messageType": "...", + "data": { ... }, + "timestamp": 1705744800000 +} +``` + +### 5.2 채팅 메시지 + +| Type | 방향 | data 필드 | +|--------------|-----|-----------------------------------------------| +| `TEXT` | 양방향 | `messageId`, `userId`, `content`, `createdAt` | +| `USER_JOIN` | S→C | `userId`, `memberCount` | +| `USER_LEAVE` | S→C | `userId`, `memberCount` | +| `SYSTEM` | S→C | `content` | + +### 5.3 게임 메시지 + +| Type | 방향 | data 필드 | +|------------------|-----|---------------------------------------------------------------------------------------------------------------| +| `GAME_START` | S→C | `gameSessionId`, `totalRounds`, `currentDrawerId`, `roundStartTime`, `serverTime`, `roundDuration`, `players` | +| `GAME_END` | S→C | `gameSessionId`, `reason`, `finalScores`, `winner` | +| `ROUND_START` | S→C | `currentRound`, `currentDrawerId`, `roundStartTime`, `serverTime`, `roundDuration`, `currentWord`(출제자만) | +| `ROUND_END` | S→C | `currentRound`, `answer`, `scores` | +| `DRAWING` | 양방향 | `drawingData` | +| `DRAWING_CLEAR` | 양방향 | - | +| `GUESS` | C→S | `content` | +| `CORRECT_ANSWER` | S→C | `userId`, `score`, `elapsedTime` | +| `SCORE_UPDATE` | S→C | `scores`, `currentRound`, `totalRounds` | +| `HINT` | S→C | `hint` | + +### 5.4 ROUND_START 상세 (핵심!) + +```json +{ + "domain": "game", + "messageType": "ROUND_START", + "data": { + "gameSessionId": "game-abc123", + "currentRound": 2, + "totalRounds": 5, + "currentDrawerId": "user123", + "roundStartTime": 1705744800000, + "serverTime": 1705744800500, + "roundDuration": 60, + "currentWord": { + "wordId": "word-1", + "word": "apple" + } + }, + "timestamp": 1705744800500 +} +``` + +**중요:** `currentWord`는 출제자에게만 전송! + +--- + +## 6. 구현 일정 + +``` +Week 1: 긴급 버그 수정 +├── [BE] serverTime 필드 추가 (0.5일) +├── [FE] useTimer 훅 수정 (0.5일) +├── [BE] 메시지에 domain 필드 추가 (1일) +└── [FE] 메시지 핸들러 domain 분기 (0.5일) + +Week 2: 게임 세션 분리 (BE) +├── [BE] GameSession 모델 생성 +├── [BE] GameSessionRepository 구현 +├── [BE] GameService 리팩토링 +└── [BE] 게임 세션 API 구현 + +Week 3: 프론트엔드 리팩토링 +├── [FE] useChatWebSocket 분리 +├── [FE] useGameWebSocket 신규 +├── [FE] 컴포넌트 분리 +└── [FE/BE] 통합 테스트 + +Week 4: 안정화 및 추가 기능 +├── [BE] 게임 자동 종료 (7분) - Issue #417 +├── [BE] 재접속 시 게임 상태 복구 +└── [FE/BE] E2E 테스트 +``` + +--- + +## 7. 기대 효과 + +| 항목 | 현재 | 개선 후 | +|---------|-------------|------------------| +| 타이머 정확도 | 클라이언트 시계 의존 | 서버 시간 기준 동기화 | +| 재접속 | 게임 상태 유실 | 완전 복구 가능 | +| 테스트 | 채팅/게임 분리 불가 | 독립 테스트 가능 | +| 확장성 | 새 게임 추가 어려움 | gameType으로 확장 용이 | +| 유지보수 | 책임 혼재 | 명확한 책임 분리 | + +--- + +## 8. 즉시 적용 (백엔드 변경 전 프론트엔드 임시 조치) + +```javascript +// 백엔드 변경 전까지 프론트엔드에서 적용 가능한 임시 코드 + +onRoundStart: (data) => { + const roundData = data.data || data; + const now = Date.now(); + + // serverTime이 없으면 클라이언트 시간 사용 (임시) + const serverTime = roundData.serverTime || now; + let roundStartTime = roundData.roundStartTime || now; + + // roundStartTime이 미래 시간이면 현재로 보정 + if (roundStartTime > now + 1000) { + console.warn('Invalid roundStartTime, using current time'); + roundStartTime = now; + } + + setGameState((prev) => ({ + ...prev, + currentRound: roundData.currentRound, + currentDrawerId: roundData.currentDrawerId, + roundStartTime: roundStartTime, + serverTime: serverTime, + roundDuration: roundData.roundDuration || roundData.roundTimeLimit || 60, + })); +} +``` + +--- + +## 9. 결론 + +**우선순위:** + +1. **즉시 (이번 주)**: `serverTime` 추가 + `domain` 필드 추가 +2. **단기 (2주)**: GameSession 모델 분리 + API 구현 +3. **중기 (3-4주)**: FE/BE 완전 분리 + 자동 종료 + 재접속 복구 + +**핵심 원칙:** + +- 단일 WebSocket 엔드포인트 유지 (비용/복잡도) +- `domain` 필드로 채팅/게임 구분 +- `serverTime`으로 정확한 타이머 동기화 +- GameSession 독립 모델로 상태 관리 명확화 diff --git a/docs/CICD-IMPLEMENTATION-QNA.md b/docs/CICD-IMPLEMENTATION-QNA.md new file mode 100644 index 00000000..e00c5a11 --- /dev/null +++ b/docs/CICD-IMPLEMENTATION-QNA.md @@ -0,0 +1,421 @@ +# CI/CD 파이프라인 구현 설명 및 면접 Q&A + +## 1. CI/CD 아키텍처 개요 + +``` +┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ +│ GitHub │───▶│ CodePipeline│───▶│ CodeBuild │───▶│CloudFormation│ +│ (Source) │ │ (Pipeline) │ │ (Build) │ │ (Deploy) │ +└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ + │ │ │ │ + │ ▼ ▼ ▼ + │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ + │ │ SNS │ │ S3 │ │ Lambda │ + │ │(Notification)│ │ (Artifacts) │ │ Functions │ + │ └─────────────┘ └─────────────┘ └─────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ prod 브랜치 Push/Merge │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +## 2. 구성 요소 상세 설명 + +### 2.1 Source Stage (GitHub) + +- **트리거**: prod 브랜치에 Push 또는 PR Merge 시 자동 실행 +- **연결 방식**: AWS CodeConnections (구 CodeStar Connections) +- **아티팩트**: 소스 코드를 ZIP으로 압축하여 다음 스테이지로 전달 + +### 2.2 Build Stage (CodeBuild) + +- **런타임**: Amazon Linux 2, Java Corretto 21 +- **빌드 단계**: + 1. **Install**: SAM CLI 설치 + 2. **Pre-build**: Gradle 테스트 실행 (`./gradlew clean test`) + 3. **Build**: SAM build & package + 4. **Post-build**: 완료 로그 +- **캐싱**: Gradle 캐시를 S3에 저장하여 빌드 시간 단축 +- **리포트**: JUnit 테스트 결과, JaCoCo 코드 커버리지 리포트 + +### 2.3 Deploy Stage (CloudFormation) + +- **배포 방식**: CloudFormation CREATE_UPDATE +- **템플릿**: SAM으로 패키징된 `packaged-template.yaml` +- **기능**: CAPABILITY_IAM, CAPABILITY_AUTO_EXPAND + +### 2.4 Notification (SNS) + +- **이벤트**: 파이프라인 시작, 성공, 실패 시 이메일 알림 +- **구현**: CodeStar Notifications + SNS Topic + +## 3. 주요 파일 구조 + +``` +BE_Repository/ +├── cicd/ +│ └── pipeline.yaml # CloudFormation 파이프라인 템플릿 +├── ServerlessFunction/ +│ ├── buildspec.yml # CodeBuild 빌드 명세 +│ ├── samconfig.toml # SAM 배포 설정 +│ └── template.yaml # SAM 애플리케이션 템플릿 +``` + +## 4. IAM 역할 구성 + +| 역할 | 목적 | 주요 권한 | +|--------------------|---------------------|----------------------------------------| +| PipelineRole | CodePipeline 서비스 역할 | S3, CodeBuild, CloudFormation, SNS | +| CodeBuildRole | CodeBuild 서비스 역할 | S3, CloudWatch Logs, CodeBuild Reports | +| CloudFormationRole | 리소스 배포 역할 | AdministratorAccess (SAM 리소스 생성용) | + +--- + +## 5. 면접 예상 질문 및 답변 + +### Q1. CI/CD 파이프라인을 구축한 이유는 무엇인가요? + +**A1:** +수동 배포의 문제점을 해결하기 위해 CI/CD를 도입했습니다. + +1. **일관성**: 수동 배포 시 발생할 수 있는 휴먼 에러 방지 +2. **자동화**: 코드 푸시만으로 테스트-빌드-배포가 자동 실행 +3. **품질 보장**: 테스트 실패 시 배포가 중단되어 결함 있는 코드가 프로덕션에 배포되는 것을 방지 +4. **추적성**: 모든 배포 이력이 CodePipeline에 기록되어 문제 발생 시 원인 추적 용이 +5. **속도**: 반복적인 배포 작업 시간을 단축하여 개발 생산성 향상 + +--- + +### Q2. GitHub과 AWS CodePipeline을 어떻게 연동했나요? + +**A2:** +AWS CodeConnections(구 CodeStar Connections)를 사용하여 연동했습니다. + +```yaml +# pipeline.yaml의 Source Stage 설정 +- Name: Source + Actions: + - Name: GitHub + ActionTypeId: + Category: Source + Owner: AWS + Provider: CodeStarSourceConnection + Version: '1' + Configuration: + ConnectionArn: !Ref GitHubConnectionArn + FullRepositoryId: "Language-Study-Prooject/BE_Repository" + BranchName: "prod" + DetectChanges: true +``` + +**연동 과정:** + +1. AWS Console에서 CodeConnections 생성 +2. GitHub OAuth 앱 승인 +3. Connection ARN을 파이프라인에 설정 +4. `DetectChanges: true`로 설정하여 자동 트리거 활성화 + +--- + +### Q3. CodeBuild의 buildspec.yml에서 각 phase의 역할은 무엇인가요? + +**A3:** + +```yaml +phases: + install: # 빌드 환경 설정 + runtime-versions: + java: corretto21 + commands: + - pip3 install aws-sam-cli + + pre_build: # 테스트 실행 (품질 게이트) + commands: + - cd ServerlessFunction + - ./gradlew clean test + + build: # 실제 빌드 및 패키징 + commands: + - sam build + - sam package --s3-bucket ... --output-template-file packaged-template.yaml + + post_build: # 후처리 (로깅, 정리) + commands: + - echo "Build completed" +``` + +- **install**: 빌드에 필요한 런타임과 도구 설치 +- **pre_build**: 테스트 실행 - 실패 시 빌드 중단 (품질 게이트 역할) +- **build**: SAM 애플리케이션 빌드 및 S3에 패키징 +- **post_build**: 완료 로그 기록, 정리 작업 + +--- + +### Q4. 테스트가 실패하면 배포가 어떻게 되나요? + +**A4:** +테스트 실패 시 배포가 자동으로 중단됩니다. + +**작동 원리:** + +1. `pre_build` 단계에서 `./gradlew clean test` 실행 +2. 테스트 실패 시 Gradle이 exit code 1 반환 +3. CodeBuild가 비정상 종료로 판단하여 빌드 실패 처리 +4. CodePipeline의 Build Stage가 실패 상태가 됨 +5. Deploy Stage로 진행되지 않음 +6. SNS를 통해 실패 알림 이메일 발송 + +``` +Pipeline Flow: +Source ──▶ Build (테스트 실패) ──✗ Deploy + │ + ▼ + SNS 알림 발송 +``` + +--- + +### Q5. SAM과 CloudFormation의 관계는 무엇인가요? + +**A5:** +SAM(Serverless Application Model)은 CloudFormation의 확장입니다. + +**관계:** + +- SAM 템플릿은 CloudFormation 템플릿의 상위 집합 +- `sam build`/`sam package` 실행 시 SAM 템플릿이 표준 CloudFormation 템플릿으로 변환 +- 변환된 템플릿(`packaged-template.yaml`)을 CloudFormation이 배포 + +**SAM의 장점:** + +1. 간결한 문법: `AWS::Serverless::Function`으로 Lambda + API Gateway + IAM 역할 한번에 정의 +2. 로컬 테스트: `sam local invoke`로 Lambda 로컬 실행 가능 +3. 자동 패키징: 코드를 S3에 업로드하고 참조 자동 생성 + +```yaml +# SAM 템플릿 (간결) +Type: AWS::Serverless::Function +Properties: + Handler: handler.main + Runtime: java21 + Events: + Api: + Type: Api + Properties: + Path: /hello + Method: get + +# 변환된 CloudFormation (복잡) +# Lambda Function + API Gateway + IAM Role + Permission 등 여러 리소스로 확장 +``` + +--- + +### Q6. 배포 중 롤백은 어떻게 처리되나요? + +**A6:** +CloudFormation의 기본 롤백 기능을 활용합니다. + +**설정:** + +```yaml +# samconfig.toml +disable_rollback = false # 롤백 활성화 +``` + +**롤백 시나리오:** + +1. **배포 실패 시**: CloudFormation이 자동으로 이전 상태로 롤백 +2. **Lambda 오류 시**: + - 현재는 기본 롤백만 사용 + - 추가로 Canary/Linear 배포 설정 가능 (AWS CodeDeploy 연동) + +```yaml +# 점진적 배포 예시 (선택적 구현) +DeploymentPreference: + Type: Canary10Percent5Minutes # 10%에 5분간 배포 후 문제없으면 전체 배포 +``` + +--- + +### Q7. 파이프라인의 아티팩트는 어떻게 관리되나요? + +**A7:** +S3 버킷을 사용하여 아티팩트를 관리합니다. + +```yaml +ArtifactBucket: + Type: AWS::S3::Bucket + Properties: + BucketName: group2-englishstudy-pipeline-artifacts + VersioningConfiguration: + Status: Enabled # 버전 관리 활성화 + BucketEncryption: + ServerSideEncryptionConfiguration: + - ServerSideEncryptionByDefault: + SSEAlgorithm: AES256 # 암호화 +``` + +**아티팩트 종류:** + +1. **SourceArtifact**: GitHub에서 가져온 소스 코드 ZIP +2. **BuildArtifact**: 빌드된 `packaged-template.yaml` +3. **Cache**: Gradle 캐시 (빌드 시간 단축용) + +--- + +### Q8. 파이프라인 알림은 어떻게 구현했나요? + +**A8:** +AWS CodeStar Notifications와 SNS를 연동하여 구현했습니다. + +```yaml +# SNS Topic 생성 +NotificationTopic: + Type: AWS::SNS::Topic + Properties: + TopicName: cicd-pipeline-notifications + +# 이메일 구독 +EmailSubscription: + Type: AWS::SNS::Subscription + Properties: + TopicArn: !Ref NotificationTopic + Protocol: email + Endpoint: !Ref NotificationEmail + +# 알림 규칙 +PipelineNotificationRule: + Type: AWS::CodeStarNotifications::NotificationRule + Properties: + EventTypeIds: + - codepipeline-pipeline-pipeline-execution-started + - codepipeline-pipeline-pipeline-execution-succeeded + - codepipeline-pipeline-pipeline-execution-failed + Targets: + - TargetType: SNS + TargetAddress: !Ref NotificationTopic +``` + +--- + +### Q9. CI/CD 구축 중 겪은 문제와 해결 방법은? + +**A9:** + +**문제 1: Gradle Wrapper를 찾을 수 없음** + +- 원인: `.gitignore`에서 `gradle/` 폴더 전체가 제외됨 +- 해결: `.gitignore` 수정하여 `!gradle/wrapper/` 예외 추가 + +**문제 2: JAVA_HOME 환경 변수 오류** + +- 원인: CodeBuild에서 JAVA_HOME을 수동 설정했으나 경로 불일치 +- 해결: `runtime-versions: java: corretto21`만 사용하고 JAVA_HOME 수동 설정 제거 + +**문제 3: SAM package S3 버킷 참조 오류** + +- 원인: 환경 변수를 사용한 멀티라인 명령어에서 변수 치환 실패 +- 해결: 단일 라인으로 버킷 이름 직접 지정 + +**문제 4: Lambda 환경 변수 누락** + +- 원인: WebSocket Connect 함수에 `WEBSOCKET_ENDPOINT` 환경 변수 미설정 +- 해결: `template.yaml`에 환경 변수 추가 + +--- + +### Q10. 현재 CI/CD의 개선점이 있다면? + +**A10:** + +1. **테스트 커버리지 게이트** + - 현재: 테스트 실행만 함 + - 개선: 커버리지 80% 미만 시 빌드 실패 설정 + +2. **점진적 배포 (Canary/Blue-Green)** + - 현재: 전체 교체 배포 + - 개선: Lambda Alias + CodeDeploy로 Canary 배포 구현 + +3. **다중 환경 지원** + - 현재: prod 단일 환경 + - 개선: dev, staging, prod 분리 및 승인 단계 추가 + +4. **보안 스캔** + - 개선: 의존성 취약점 스캔 (OWASP Dependency-Check) 추가 + +5. **성능 테스트** + - 개선: 배포 전 부하 테스트 단계 추가 + +--- + +### Q11. IaC(Infrastructure as Code)를 사용한 이유는? + +**A11:** +파이프라인 자체도 CloudFormation 템플릿(`pipeline.yaml`)으로 정의했습니다. + +**장점:** + +1. **버전 관리**: 인프라 변경 이력을 Git으로 추적 +2. **재현성**: 동일한 파이프라인을 다른 프로젝트/계정에 쉽게 복제 +3. **리뷰 가능**: 인프라 변경도 코드 리뷰 프로세스 적용 +4. **자동화**: 수동 콘솔 작업 없이 `aws cloudformation deploy`로 생성/업데이트 +5. **문서화**: 템플릿 자체가 인프라 문서 역할 + +--- + +### Q12. CodeBuild와 Jenkins의 차이점은? + +**A12:** + +| 항목 | CodeBuild | Jenkins | +|--------|---------------|----------------------| +| 관리 | 완전 관리형 (서버리스) | 자체 서버 운영 필요 | +| 비용 | 빌드 시간 기반 과금 | 서버 운영 비용 | +| 확장성 | 자동 확장 | 수동 확장 필요 | +| AWS 통합 | 네이티브 통합 | 플러그인 필요 | +| 커스터마이징 | buildspec.yml | Jenkinsfile (Groovy) | +| 플러그인 | 제한적 | 풍부한 생태계 | + +**선택 이유:** + +- AWS 서비스 중심 아키텍처에서 네이티브 통합의 이점 +- 서버 관리 부담 없음 +- SAM/CloudFormation과의 원활한 연동 + +--- + +## 6. 핵심 용어 정리 + +| 용어 | 설명 | +|-------------------------------------|------------------------------------------------| +| CI (Continuous Integration) | 코드 변경을 자주 통합하고 자동 테스트하는 방식 | +| CD (Continuous Delivery/Deployment) | 자동으로 프로덕션까지 배포하는 방식 | +| Pipeline | 소스-빌드-배포로 이어지는 자동화된 워크플로우 | +| Artifact | 빌드 결과물 (패키징된 코드, 템플릿 등) | +| buildspec.yml | CodeBuild의 빌드 명세 파일 | +| SAM | Serverless Application Model - 서버리스 앱 정의 프레임워크 | +| IaC | Infrastructure as Code - 코드로 인프라 관리 | + +--- + +## 7. 참고 명령어 + +```bash +# 파이프라인 생성 +aws cloudformation deploy \ + --template-file cicd/pipeline.yaml \ + --stack-name group2-cicd-pipeline \ + --capabilities CAPABILITY_NAMED_IAM \ + --parameter-overrides NotificationEmail=your@email.com + +# 파이프라인 상태 확인 +aws codepipeline get-pipeline-state --name group2-englishstudy-pipeline + +# 수동 파이프라인 실행 +aws codepipeline start-pipeline-execution --name group2-englishstudy-pipeline + +# 빌드 로그 확인 +aws logs tail /aws/codebuild/group2-englishstudy-build --follow +``` diff --git a/docs/FRONTEND-API-GUIDE.md b/docs/FRONTEND-API-GUIDE.md new file mode 100644 index 00000000..697d406a --- /dev/null +++ b/docs/FRONTEND-API-GUIDE.md @@ -0,0 +1,365 @@ +# 프론트엔드 전달사항 - 채팅/게임 API 가이드 + +## 1. 아키텍처 구조 (업데이트됨) + +### 채팅방과 게임방 분리 + +``` +RoomType enum +├── CHAT ("chat") - 일반 채팅방 +└── GAME ("game") - 게임방 (캐치마인드 등) + +RoomStatus enum +├── WAITING ("waiting") - 대기 중 +├── PLAYING ("playing") - 게임 진행 중 +└── FINISHED ("finished") - 종료됨 +``` + +### GSI1SK 인덱스 설계 + +``` +GSI1PK: "ROOMS" (고정) +GSI1SK: {type}#{gameType}#{status}#{level}#{createdAt} + +예시: +- CHAT#-#WAITING#beginner#2026-01-22T10:00:00Z (일반 채팅방) +- GAME#CATCHMIND#WAITING#intermediate#2026-01-22T10:00:00Z (대기중 게임방) +- GAME#CATCHMIND#PLAYING#advanced#2026-01-22T10:00:00Z (진행중 게임방) +``` + +**핵심**: DB 레벨에서 `type`, `gameType`, `status`, `level` 조합으로 필터링 가능 + +--- + +## 2. 방 타입 (RoomType) + +| 타입 | 코드 | 설명 | +|--------|--------|---------------| +| `CHAT` | `chat` | 일반 채팅방 | +| `GAME` | `game` | 게임방 (캐치마인드 등) | + +--- + +## 3. 방 상태 (RoomStatus) + +| 상태 | 코드 | 설명 | 게임 시작 가능 | +|------------|------------|---------|:--------:| +| `WAITING` | `waiting` | 대기 중 | O | +| `PLAYING` | `playing` | 게임 진행 중 | X | +| `FINISHED` | `finished` | 게임 종료됨 | O | + +--- + +## 4. REST API 엔드포인트 + +### 채팅방 API (`/api/chat/rooms`) + +| Method | Endpoint | 설명 | +|--------|-------------------------|---------------------| +| POST | `/rooms` | 채팅방/게임방 생성 | +| GET | `/rooms` | 방 목록 조회 (필터 지원) | +| GET | `/rooms/{roomId}` | 방 상세 조회 | +| POST | `/rooms/{roomId}/join` | 방 입장 (roomToken 발급) | +| POST | `/rooms/{roomId}/leave` | 방 퇴장 | +| DELETE | `/rooms/{roomId}` | 방 삭제 (방장만) | + +### 게임 API (`/api/game`) + +| Method | Endpoint | 설명 | +|--------|-------------------------------|----------| +| POST | `/rooms/{roomId}/game/start` | 게임 시작 | +| POST | `/rooms/{roomId}/game/stop` | 게임 중단 | +| GET | `/rooms/{roomId}/game/status` | 게임 상태 조회 | +| GET | `/rooms/{roomId}/game/scores` | 점수판 조회 | + +--- + +## 5. 방 목록 조회 쿼리 파라미터 (업데이트됨) + +``` +GET /api/chat/rooms?type=GAME&gameType=CATCHMIND&status=WAITING&level=intermediate&limit=10&cursor=xxx +``` + +| 파라미터 | 타입 | 설명 | 예시 | +|------------|--------|----------------------|----------------------------------------| +| `type` | string | 방 타입 필터 | `CHAT`, `GAME` | +| `gameType` | string | 게임 타입 | `CATCHMIND` | +| `status` | string | 상태 필터 | `WAITING`, `PLAYING`, `FINISHED` | +| `level` | string | 난이도 필터 | `beginner`, `intermediate`, `advanced` | +| `limit` | number | 조회 개수 (기본 10, 최대 20) | | +| `cursor` | string | 페이지네이션 커서 | | + +### 필터 조합 예시 + +```bash +# 대기 중인 게임방만 +GET /api/chat/rooms?type=GAME&status=WAITING + +# 캐치마인드 게임방만 +GET /api/chat/rooms?type=GAME&gameType=CATCHMIND + +# 초급 난이도 채팅방 +GET /api/chat/rooms?type=CHAT&level=beginner + +# 진행 중인 고급 게임방 +GET /api/chat/rooms?type=GAME&status=PLAYING&level=advanced +``` + +### 응답 예시 + +```json +{ + "success": true, + "message": "Rooms retrieved", + "data": { + "rooms": [ + { + "roomId": "abc-123", + "name": "초보자 영어 스터디", + "type": "GAME", + "gameType": "CATCHMIND", + "status": "WAITING", + "level": "beginner", + "currentMembers": 3, + "maxMembers": 6, + "currentRound": 0, + "totalRounds": 5, + "createdAt": "2026-01-22T10:00:00Z" + } + ], + "nextCursor": "eyJQSyI6Ik...", + "hasMore": true + } +} +``` + +--- + +## 6. 방 생성 요청 (업데이트됨) + +### 채팅방 생성 + +```json +{ + "name": "영어 스터디 채팅방", + "type": "CHAT", + "level": "beginner", + "maxMembers": 6, + "description": "초보자를 위한 영어 채팅방" +} +``` + +### 게임방 생성 + +```json +{ + "name": "캐치마인드 게임", + "type": "GAME", + "gameType": "CATCHMIND", + "level": "intermediate", + "maxMembers": 8, + "description": "영어 단어 맞추기 게임" +} +``` + +--- + +## 7. 프론트엔드에서 방 타입 구분 + +### 방법 1: API 필터 사용 (권장) + +```javascript +// 게임방만 조회 +const gameRooms = await fetch('/api/chat/rooms?type=GAME'); + +// 대기 중인 게임방만 +const waitingGames = await fetch('/api/chat/rooms?type=GAME&status=WAITING'); + +// 채팅방만 +const chatRooms = await fetch('/api/chat/rooms?type=CHAT'); +``` + +### 방법 2: 전체 조회 후 클라이언트 필터링 + +```javascript +const allRooms = await fetchRooms(); + +// 게임방만 +const gameRooms = allRooms.filter(room => room.type === 'GAME'); + +// 채팅방만 +const chatRooms = allRooms.filter(room => room.type === 'CHAT'); + +// 대기 중인 방만 +const waitingRooms = allRooms.filter(room => room.status === 'WAITING'); +``` + +--- + +## 8. WebSocket 연결 + +### 채팅/게임 WebSocket + +``` +wss://t378dif43l.execute-api.ap-northeast-2.amazonaws.com/dev?roomToken={roomToken} +``` + +### Grammar WebSocket + +``` +wss://ltrccmteo8.execute-api.ap-northeast-2.amazonaws.com/dev?token={jwtToken} +``` + +### 연결 순서 + +1. `POST /rooms/{roomId}/join` → `roomToken` 발급 +2. WebSocket 연결 시 `roomToken` 쿼리 파라미터로 전달 + +--- + +## 9. WebSocket 메시지 타입 (messageType) + +| 코드 | 타입 | 설명 | +|------------------|--------|---------------| +| `MSG` | 일반 메시지 | 일반 채팅 메시지 | +| `VOICE` | 음성 메시지 | 음성 채팅 | +| `JOIN` | 입장 알림 | 사용자 입장 | +| `LEAVE` | 퇴장 알림 | 사용자 퇴장 | +| `GAME_START` | 게임 시작 | 게임 시작 알림 | +| `GAME_END` | 게임 종료 | 게임 종료 + 최종 순위 | +| `ROUND_START` | 라운드 시작 | 새 라운드 시작 | +| `ROUND_END` | 라운드 종료 | 정답 공개 | +| `ANSWER_CORRECT` | 정답 | 정답 맞춤 | +| `HINT` | 힌트 | 힌트 제공 | +| `SKIP` | 스킵 | 라운드 스킵 | +| `SYSTEM` | 시스템 | 시스템 메시지 | + +--- + +## 10. 게임 명령어 (WebSocket) + +채팅 메시지로 게임 명령어 전송: + +| 명령어 | 설명 | 권한 | +|----------|--------|-----------------| +| `/start` | 게임 시작 | 방장 (2명 이상 접속 시) | +| `/stop` | 게임 중단 | 방장 또는 게임 시작자 | +| `/skip` | 라운드 스킵 | 누구나 | +| `/hint` | 힌트 제공 | 출제자만 | +| `/score` | 점수 확인 | 누구나 | + +--- + +## 11. 게임 시작 응답 예시 + +```json +{ + "messageId": "uuid", + "roomId": "abc-123", + "userId": "SYSTEM", + "content": "게임 시작!\n총 5 라운드\n\n라운드 1 시작!\n출제자: user-456", + "messageType": "GAME_START", + "createdAt": "2026-01-22T10:00:00Z", + "serverTime": "2026-01-22T10:00:00Z", + "domain": "GAME", + "type": "GAME", + "status": "PLAYING", + "currentRound": 1, + "totalRounds": 5, + "currentDrawerId": "user-456", + "drawerOrder": ["user-456", "user-789", "user-123"] +} +``` + +--- + +## 12. 정답 체크 로직 + +- **한국어** 또는 **영어** 둘 다 정답으로 인정 +- 대소문자 구분 없음 +- 공백 무시 + +### 점수 계산 + +``` +기본 점수: 10점 +시간 보너스: (제한시간 - 경과시간) * 0.5 +연속 정답 보너스: 연속정답수 * 2 + +총점 = 기본점수 + 시간보너스 + 연속정답보너스 +``` + +--- + +## 13. 게임 설정 + +| 설정 | 기본값 | 환경변수 | +|--------------|----------|---------------------------------| +| 총 라운드 수 | 5 | `GAME_TOTAL_ROUNDS` | +| 라운드 제한 시간(초) | 60 | `GAME_ROUND_TIME_LIMIT` | +| 빠른 정답 기준(ms) | 5000 | `GAME_QUICK_GUESS_THRESHOLD_MS` | +| 게임 전체 제한(초) | 420 (7분) | `GAME_TIME_LIMIT_SECONDS` | + +--- + +## 14. 주의사항 + +1. **roomToken은 한 번만 사용**: 재연결 시 새로 발급 필요 +2. **WebSocket 연결 실패 시**: `POST /rooms/{roomId}/join`으로 새 토큰 발급 +3. **게임 중 퇴장**: 자동으로 다음 출제자로 넘어감 (2명 미만 시 게임 종료) +4. **출제자는 정답 입력 불가**: 본인이 출제자일 때 채팅해도 정답 체크 안됨 +5. **방 타입 변경 불가**: 생성 시 지정한 type은 변경 불가 + +--- + +## 15. 에러 코드 + +| 코드 | HTTP | 설명 | +|--------------|------|--------------| +| `ROOM_001` | 404 | 채팅방 없음 | +| `ROOM_002` | 409 | 채팅방 이미 존재 | +| `ROOM_003` | 400 | 채팅방 인원 초과 | +| `ROOM_004` | 400 | 채팅방 종료됨 | +| `ROOM_005` | 401 | 비밀번호 틀림 | +| `ROOM_006` | 403 | 방장 권한 없음 | +| `MEMBER_001` | 403 | 채팅방 멤버 아님 | +| `MEMBER_002` | 409 | 이미 참여 중 | +| `GAME_001` | 400 | 게임 시작 실패 | +| `GAME_002` | 400 | 게임 중단 실패 | +| `GAME_003` | 400 | 게임 진행 중 아님 | +| `GAME_004` | 409 | 게임 이미 진행 중 | +| `GAME_005` | 403 | 게임 시작자 아님 | +| `GAME_006` | 404 | 게임 없음 | +| `GAME_007` | 400 | 채팅방에서 게임 불가 | +| `GAME_008` | 400 | 게임 재시작 불가 | +| `GAME_009` | 403 | 방장만 게임 시작 가능 | + +--- + +## 16. UI 구현 가이드 + +### 탭 구조 (권장) + +``` +[전체] [채팅방] [게임방] +``` + +### 게임방 상태 표시 + +``` +대기 중 (WAITING) → 초록색 뱃지 "참여 가능" +진행 중 (PLAYING) → 빨간색 뱃지 "게임 중" +종료됨 (FINISHED) → 회색 뱃지 "종료" +``` + +### 게임방 카드 정보 + +``` +┌─────────────────────────────┐ +│ 캐치마인드 - 영어 단어 맞추기 │ +│ [게임방] [intermediate] │ +│ │ +│ 👥 3/8명 🎮 대기 중 │ +│ 🕐 2026-01-22 10:00 │ +└─────────────────────────────┘ +``` diff --git a/seed/README.md b/seed/README.md new file mode 100644 index 00000000..6cbd04e7 --- /dev/null +++ b/seed/README.md @@ -0,0 +1,25 @@ +# Seed Data + +DynamoDB 초기 데이터 시드 파일 + +## 구조 + +``` +seed/ +├── opic/ +│ └── question-homes.json # OPIc 질문 데이터 +└── vocabulary/ + └── words.json # 단어 학습 데이터 +``` + +## 사용법 + +AWS CLI를 사용하여 DynamoDB에 데이터 업로드: + +```bash +# Vocabulary words +aws dynamodb batch-write-item --request-items file://seed/vocabulary/words.json + +# OPIc questions +aws dynamodb batch-write-item --request-items file://seed/opic/question-homes.json +``` diff --git a/seed/opic/question-homes.json b/seed/opic/question-homes.json new file mode 100644 index 00000000..ddd67703 --- /dev/null +++ b/seed/opic/question-homes.json @@ -0,0 +1,108 @@ +{ + "questions": [ + { + "questionId": "desc-homes-001", + "topic": "DESCRIPTION", + "subTopic": "HOMES", + "questionText": "I would like to know about where you live. Talk about the different rooms at your place. Tell me about your favorite room in your home. What does it look like?", + "difficulty": "IM2", + "questionNumber": 44 + }, + { + "questionId": "desc-homes-002", + "topic": "DESCRIPTION", + "subTopic": "HOMES", + "questionText": "I would like to know where you live. Can you describe your home to me? What does it look like? How many rooms does it have? Give me a description with lots of details.", + "difficulty": "IM2", + "questionNumber": 45 + }, + { + "questionId": "desc-homes-003", + "topic": "DESCRIPTION", + "subTopic": "HOMES", + "questionText": "Now, let's talk about your bedroom. What's inside? What kind of furniture do you have in your room?", + "difficulty": "IM1", + "questionNumber": 46 + }, + { + "questionId": "habit-homes-001", + "topic": "HABIT", + "subTopic": "HOMES", + "questionText": "What kinds of home improvement projects do you enjoy doing and why?", + "difficulty": "IM2", + "questionNumber": 139 + }, + { + "questionId": "habit-homes-002", + "topic": "HABIT", + "subTopic": "HOMES", + "questionText": "Tell me about your normal routine at home. What do you do on weekdays and on weekends?", + "difficulty": "IM1", + "questionNumber": 140 + }, + { + "questionId": "habit-homes-003", + "topic": "HABIT", + "subTopic": "HOMES", + "questionText": "What is your normal routine at home? What things do you usually do on weekdays and on weekends?", + "difficulty": "IM1", + "questionNumber": 141 + }, + { + "questionId": "habit-homes-004", + "topic": "HABIT", + "subTopic": "HOMES", + "questionText": "What is your responsibility at home? What is your role? Tell me in detail.", + "difficulty": "IM2", + "questionNumber": 142 + }, + { + "questionId": "habit-homes-005", + "topic": "HABIT", + "subTopic": "HOMES", + "questionText": "What kinds of things do you do to keep your house clean and comfortable? What kinds of housework do you do at home?", + "difficulty": "IM1", + "questionNumber": 143 + }, + { + "questionId": "habit-homes-006", + "topic": "HABIT", + "subTopic": "HOMES", + "questionText": "What kind of house work do you usually do at home? Do you share your chores with your family?", + "difficulty": "IM1", + "questionNumber": 144 + }, + { + "questionId": "habit-homes-007", + "topic": "HABIT", + "subTopic": "HOMES", + "questionText": "Tell me about all the steps you take for a home improvement project. What steps do you usually take? How do you complete the project?", + "difficulty": "IH", + "questionNumber": 145 + }, + { + "questionId": "past-homes-001", + "topic": "PAST_EXPERIENCE", + "subTopic": "HOMES", + "questionText": "Tell me about a memorable experience you had at home. What happened and why was it memorable?", + "difficulty": "IM2", + "questionNumber": 0 + }, + { + "questionId": "past-homes-002", + "topic": "PAST_EXPERIENCE", + "subTopic": "HOMES", + "questionText": "Have you ever had any problems at home? Maybe something broke or there was an issue with your neighbors. Tell me about that experience and how you solved it.", + "difficulty": "IH", + "questionNumber": 0 + }, + { + "questionId": "past-homes-003", + "topic": "PAST_EXPERIENCE", + "subTopic": "HOMES", + "questionText": "Tell me about a time when you had to do a major cleaning or organizing at home. What did you do and how did it turn out?", + "difficulty": "IM2", + "questionNumber": 0 + } + ] +} diff --git a/vocabulary/seed-data/words.json b/seed/vocabulary/words.json similarity index 100% rename from vocabulary/seed-data/words.json rename to seed/vocabulary/words.json From 6810f4138bffdc0a0a4314e790ef819b321f474c Mon Sep 17 00:00:00 2001 From: hye-inA Date: Thu, 22 Jan 2026 12:36:40 +0900 Subject: [PATCH 429/528] =?UTF-8?q?refactor=20:=20websocket=20->=20rest=20?= =?UTF-8?q?api=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../websocket/SpeakingDisconnectHandler.java | 44 ---- .../websocket/SpeakingMessageHandler.java | 217 ------------------ .../SpeakingConnectionRepository.java | 73 ------ 3 files changed, 334 deletions(-) delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingDisconnectHandler.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingMessageHandler.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingDisconnectHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingDisconnectHandler.java deleted file mode 100644 index 4d82a9d1..00000000 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingDisconnectHandler.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.mzc.secondproject.serverless.domain.speaking.handler.websocket; - -import com.amazonaws.services.lambda.runtime.Context; -import com.amazonaws.services.lambda.runtime.RequestHandler; -import com.mzc.secondproject.serverless.common.util.WebSocketEventUtil; -import com.mzc.secondproject.serverless.domain.speaking.repository.SpeakingConnectionRepository; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.Map; - -/** - * Speaking WebSocket $disconnect 핸들러 - * 연결 해제 시 DynamoDB에서 연결 정보 삭제 - */ -public class SpeakingDisconnectHandler implements RequestHandler, Map> { - - private static final Logger logger = LoggerFactory.getLogger(SpeakingDisconnectHandler.class); - - private final SpeakingConnectionRepository connectionRepository; - - public SpeakingDisconnectHandler() { - this.connectionRepository = new SpeakingConnectionRepository(); - } - - @Override - public Map handleRequest(Map event, Context context) { - logger.info("Speaking WebSocket disconnect event"); - - try { - String connectionId = WebSocketEventUtil.extractConnectionId(event); - - // 연결 정보 삭제 - connectionRepository.delete(connectionId); - - logger.info("Speaking connection closed: connectionId={}", connectionId); - return WebSocketEventUtil.ok("Disconnected"); - - } catch (Exception e) { - logger.error("Error handling disconnect: {}", e.getMessage(), e); - return WebSocketEventUtil.serverError("Internal server error"); - } - } -} \ No newline at end of file diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingMessageHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingMessageHandler.java deleted file mode 100644 index 89ec0a44..00000000 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingMessageHandler.java +++ /dev/null @@ -1,217 +0,0 @@ -package com.mzc.secondproject.serverless.domain.speaking.handler.websocket; - -import com.amazonaws.services.lambda.runtime.Context; -import com.amazonaws.services.lambda.runtime.RequestHandler; -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; -import com.mzc.secondproject.serverless.common.util.WebSocketEventUtil; -import com.mzc.secondproject.serverless.domain.speaking.repository.SpeakingConnectionRepository; -import com.mzc.secondproject.serverless.domain.speaking.service.SpeakingService; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import software.amazon.awssdk.core.SdkBytes; -import software.amazon.awssdk.services.apigatewaymanagementapi.ApiGatewayManagementApiClient; -import software.amazon.awssdk.services.apigatewaymanagementapi.model.PostToConnectionRequest; - -import java.net.URI; -import java.util.Map; - -/** - * Speaking WebSocket 메시지 핸들러 - * - * 지원하는 action: - * - speak: 음성 입력 처리 (audio base64) - * - text: 텍스트 입력 처리 - * - setLevel: 레벨 변경 - * - reset: 대화 히스토리 초기화 - */ -public class SpeakingMessageHandler implements RequestHandler, Map> { - - private static final Logger logger = LoggerFactory.getLogger(SpeakingMessageHandler.class); - private static final Gson gson = new GsonBuilder().create(); - - private final SpeakingService speakingService; - private final SpeakingConnectionRepository connectionRepository; - - public SpeakingMessageHandler() { - this.speakingService = new SpeakingService(); - this.connectionRepository = new SpeakingConnectionRepository(); - } - - @Override - public Map handleRequest(Map event, Context context) { - logger.info("Speaking message event received"); - - String connectionId = null; - String endpoint = null; - - try { - connectionId = WebSocketEventUtil.extractConnectionId(event); - endpoint = WebSocketEventUtil.extractWebSocketEndpoint(event); - - // 연결 정보 확인 - if (connectionRepository.findByConnectionId(connectionId).isEmpty()) { - logger.warn("Connection not found: {}", connectionId); - return sendError(connectionId, endpoint, "Unauthorized - please reconnect"); - } - - // 요청 바디 파싱 - String body = (String) event.get("body"); - if (body == null || body.isEmpty()) { - return sendError(connectionId, endpoint, "Message body is required"); - } - - JsonObject request = JsonParser.parseString(body).getAsJsonObject(); - String action = request.has("action") ? request.get("action").getAsString() : "speak"; - - logger.info("Processing action: {} for connectionId: {}", action, connectionId); - - // 액션별 처리 - switch (action) { - case "speak" -> handleSpeak(connectionId, endpoint, request); - case "text" -> handleText(connectionId, endpoint, request); - case "setLevel" -> handleSetLevel(connectionId, endpoint, request); - case "reset" -> handleReset(connectionId, endpoint); - default -> sendError(connectionId, endpoint, "Unknown action: " + action); - } - - return WebSocketEventUtil.ok("Processed"); - - } catch (Exception e) { - logger.error("Error processing message: {}", e.getMessage(), e); - if (connectionId != null && endpoint != null) { - sendError(connectionId, endpoint, "Processing error: " + e.getMessage()); - } - return WebSocketEventUtil.serverError("Internal server error"); - } - } - - /** - * 음성 입력 처리 - */ - private void handleSpeak(String connectionId, String endpoint, JsonObject request) { - // 시작 이벤트 전송 - sendToConnection(connectionId, endpoint, Map.of( - "type", "start", - "message", "Processing your voice..." - )); - - // 음성 데이터 추출 - String audioBase64 = request.has("audio") ? request.get("audio").getAsString() : null; - if (audioBase64 == null || audioBase64.isEmpty()) { - sendError(connectionId, endpoint, "audio data is required for speak action"); - return; - } - - // 음성 처리 - SpeakingService.SpeakingResponse response = speakingService.processVoiceInput( - connectionId, audioBase64 - ); - - // 결과 전송 - sendToConnection(connectionId, endpoint, Map.of( - "type", "complete", - "userTranscript", response.userTranscript(), - "aiText", response.aiText(), - "aiAudioUrl", response.aiAudioUrl(), - "confidence", response.confidence() - )); - } - - /** - * 텍스트 입력 처리 - */ - private void handleText(String connectionId, String endpoint, JsonObject request) { - String text = request.has("text") ? request.get("text").getAsString() : null; - if (text == null || text.trim().isEmpty()) { - sendError(connectionId, endpoint, "text is required for text action"); - return; - } - - // 시작 이벤트 전송 - sendToConnection(connectionId, endpoint, Map.of( - "type", "start", - "message", "Processing your message..." - )); - - // 텍스트 처리 - SpeakingService.SpeakingResponse response = speakingService.processTextInput( - connectionId, text.trim() - ); - - // 결과 전송 - sendToConnection(connectionId, endpoint, Map.of( - "type", "complete", - "userTranscript", response.userTranscript(), - "aiText", response.aiText(), - "aiAudioUrl", response.aiAudioUrl(), - "confidence", response.confidence() - )); - } - - /** - * 레벨 변경 처리 - */ - private void handleSetLevel(String connectionId, String endpoint, JsonObject request) { - String level = request.has("level") ? request.get("level").getAsString() : null; - if (level == null || level.isEmpty()) { - sendError(connectionId, endpoint, "level is required"); - return; - } - - speakingService.updateLevel(connectionId, level); - - sendToConnection(connectionId, endpoint, Map.of( - "type", "levelChanged", - "level", level.toUpperCase() - )); - } - - /** - * 대화 초기화 처리 - */ - private void handleReset(String connectionId, String endpoint) { - speakingService.resetConversation(connectionId); - - sendToConnection(connectionId, endpoint, Map.of( - "type", "reset", - "message", "Conversation has been reset. Let's start fresh!" - )); - } - - /** - * WebSocket으로 메시지 전송 - */ - private void sendToConnection(String connectionId, String endpoint, Map data) { - try { - ApiGatewayManagementApiClient apiClient = ApiGatewayManagementApiClient.builder() - .endpointOverride(URI.create(endpoint)) - .build(); - - String message = gson.toJson(data); - - apiClient.postToConnection(PostToConnectionRequest.builder() - .connectionId(connectionId) - .data(SdkBytes.fromUtf8String(message)) - .build()); - - logger.debug("Message sent to {}: {}", connectionId, data.get("type")); - - } catch (Exception e) { - logger.error("Failed to send message to {}: {}", connectionId, e.getMessage()); - } - } - - /** - * 에러 메시지 전송 - */ - private Map sendError(String connectionId, String endpoint, String errorMessage) { - sendToConnection(connectionId, endpoint, Map.of( - "type", "error", - "message", errorMessage - )); - return WebSocketEventUtil.ok("Error sent"); - } -} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java deleted file mode 100644 index 14b468d1..00000000 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java +++ /dev/null @@ -1,73 +0,0 @@ -package com.mzc.secondproject.serverless.domain.speaking.repository; - -import com.mzc.secondproject.serverless.common.config.AwsClients; -import com.mzc.secondproject.serverless.common.config.EnvConfig; -import com.mzc.secondproject.serverless.domain.speaking.model.SpeakingConnection; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; -import software.amazon.awssdk.enhanced.dynamodb.Key; -import software.amazon.awssdk.enhanced.dynamodb.TableSchema; - -import java.util.Optional; - -/** - * Speaking WebSocket 연결 정보 Repository - */ -public class SpeakingConnectionRepository { - - private static final Logger logger = LoggerFactory.getLogger(SpeakingConnectionRepository.class); - private static final String TABLE_NAME = EnvConfig.getRequired("CHAT_TABLE_NAME"); - - private final DynamoDbTable table; - - public SpeakingConnectionRepository() { - this.table = AwsClients.dynamoDbEnhanced().table( - TABLE_NAME, - TableSchema.fromBean(SpeakingConnection.class) - ); - } - - /** - * 연결 정보 저장 - */ - public void save(SpeakingConnection connection) { - table.putItem(connection); - logger.debug("Speaking connection saved: connectionId={}, userId={}", - connection.getConnectionId(), connection.getUserId()); - } - - /** - * connectionId로 연결 정보 조회 - */ - public Optional findByConnectionId(String connectionId) { - Key key = Key.builder() - .partitionValue(SpeakingConnection.PK_PREFIX + connectionId) - .sortValue(SpeakingConnection.SK_METADATA) - .build(); - - SpeakingConnection connection = table.getItem(key); - return Optional.ofNullable(connection); - } - - /** - * 연결 정보 업데이트 (대화 히스토리 등) - */ - public void update(SpeakingConnection connection) { - table.putItem(connection); - logger.debug("Speaking connection updated: connectionId={}", connection.getConnectionId()); - } - - /** - * 연결 정보 삭제 - */ - public void delete(String connectionId) { - Key key = Key.builder() - .partitionValue(SpeakingConnection.PK_PREFIX + connectionId) - .sortValue(SpeakingConnection.SK_METADATA) - .build(); - - table.deleteItem(key); - logger.info("Speaking connection deleted: connectionId={}", connectionId); - } -} \ No newline at end of file From 76045eb86ebf27c8b2411311f9aacc5c42f3f496 Mon Sep 17 00:00:00 2001 From: hye-inA Date: Thu, 22 Jan 2026 12:37:28 +0900 Subject: [PATCH 430/528] =?UTF-8?q?feat=20:=20speaking=20handler=20REST?= =?UTF-8?q?=EB=A1=9C=20=EA=B5=90=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../handler/websocket/SpeakingHandler.java | 157 ++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingHandler.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingHandler.java new file mode 100644 index 00000000..69375925 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingHandler.java @@ -0,0 +1,157 @@ +package com.mzc.secondproject.serverless.domain.speaking.handler.websocket; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.mzc.secondproject.serverless.common.util.JwtUtil; +import com.mzc.secondproject.serverless.domain.speaking.service.SpeakingService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Map; +import java.util.Optional; + +/** + * Speaking API 핸들러 + * + * POST /api/speaking/chat - 대화 (음성 또는 텍스트) + * POST /api/speaking/reset - 대화 초기화 + */ +public class SpeakingHandler implements RequestHandler { + + private static final Logger logger = LoggerFactory.getLogger(SpeakingHandler.class); + private static final Gson gson = new GsonBuilder().create(); + + private static final Map CORS_HEADERS = Map.of( + "Content-Type", "application/json", + "Access-Control-Allow-Origin", "*", + "Access-Control-Allow-Headers", "Content-Type,Authorization", + "Access-Control-Allow-Methods", "POST,OPTIONS" + ); + + private final SpeakingService speakingService; + + public SpeakingHandler() { + this.speakingService = new SpeakingService(); + } + + @Override + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent event, Context context) { + logger.info("Speaking API request received"); + + // OPTIONS 요청 처리 (CORS preflight) + if ("OPTIONS".equalsIgnoreCase(event.getHttpMethod())) { + return response(200, Map.of("message", "OK")); + } + + try { + // JWT 토큰 검증 + String authHeader = event.getHeaders().get("Authorization"); + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + return response(401, Map.of("error", "Authorization header is required")); + } + + String token = authHeader.substring(7); + if (!JwtUtil.isValid(token)) { + return response(401, Map.of("error", "Invalid or expired token")); + } + + Optional userIdOpt = JwtUtil.extractUserId(token); + if (userIdOpt.isEmpty()) { + return response(401, Map.of("error", "Invalid token")); + } + + String userId = userIdOpt.get(); + String path = event.getPath(); + String body = event.getBody(); + + logger.info("Processing request: path={}, userId={}", path, userId); + + // 라우팅 + if (path.endsWith("/chat")) { + return handleChat(userId, body); + } else if (path.endsWith("/reset")) { + return handleReset(userId, body); + } else { + return response(404, Map.of("error", "Not found")); + } + + } catch (Exception e) { + logger.error("Error processing request: {}", e.getMessage(), e); + return response(500, Map.of("error", "Internal server error: " + e.getMessage())); + } + } + + /** + * 대화 처리 (음성 또는 텍스트) + */ + private APIGatewayProxyResponseEvent handleChat(String userId, String body) { + if (body == null || body.isEmpty()) { + return response(400, Map.of("error", "Request body is required")); + } + + JsonObject request = JsonParser.parseString(body).getAsJsonObject(); + + String sessionId = request.has("sessionId") ? request.get("sessionId").getAsString() : null; + String level = request.has("level") ? request.get("level").getAsString() : "INTERMEDIATE"; + String audio = request.has("audio") ? request.get("audio").getAsString() : null; + String text = request.has("text") ? request.get("text").getAsString() : null; + + SpeakingService.SpeakingResponse result; + + if (audio != null && !audio.isEmpty()) { + // 음성 입력 처리 + logger.info("Processing voice input"); + result = speakingService.processVoiceInput(sessionId, userId, audio, level); + } else if (text != null && !text.trim().isEmpty()) { + // 텍스트 입력 처리 + logger.info("Processing text input: {}", text); + result = speakingService.processTextInput(sessionId, userId, text.trim(), level); + } else { + return response(400, Map.of("error", "Either 'audio' or 'text' is required")); + } + + return response(200, Map.of( + "sessionId", result.sessionId(), + "userTranscript", result.userTranscript(), + "aiText", result.aiText(), + "aiAudioUrl", result.aiAudioUrl(), + "confidence", result.confidence() + )); + } + + /** + * 대화 초기화 + */ + private APIGatewayProxyResponseEvent handleReset(String userId, String body) { + if (body == null || body.isEmpty()) { + return response(400, Map.of("error", "Request body is required")); + } + + JsonObject request = JsonParser.parseString(body).getAsJsonObject(); + String sessionId = request.has("sessionId") ? request.get("sessionId").getAsString() : null; + + if (sessionId == null || sessionId.isEmpty()) { + return response(400, Map.of("error", "sessionId is required")); + } + + speakingService.resetConversation(sessionId); + + return response(200, Map.of( + "message", "Conversation reset successfully", + "sessionId", sessionId + )); + } + + private APIGatewayProxyResponseEvent response(int statusCode, Map body) { + return new APIGatewayProxyResponseEvent() + .withStatusCode(statusCode) + .withHeaders(CORS_HEADERS) + .withBody(gson.toJson(body)); + } +} From 2c7094a0099a1c9cb3a918cfeec5d4d59544819f Mon Sep 17 00:00:00 2001 From: hye-inA Date: Thu, 22 Jan 2026 12:37:44 +0900 Subject: [PATCH 431/528] =?UTF-8?q?feat=20:=20speaking=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20dto=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../speaking/dto/request/ResetRequest.java | 12 +++++++ .../speaking/dto/request/SpeakingRequest.java | 32 +++++++++++++++++++ .../dto/response/SpeakingResponse.java | 12 +++++++ 3 files changed, 56 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/dto/request/ResetRequest.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/dto/request/SpeakingRequest.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/dto/response/SpeakingResponse.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/dto/request/ResetRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/dto/request/ResetRequest.java new file mode 100644 index 00000000..8f600c66 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/dto/request/ResetRequest.java @@ -0,0 +1,12 @@ +package com.mzc.secondproject.serverless.domain.speaking.dto.request; + +/** + * 대화 초기화 요청 DTO + */ +public record ResetRequest( + String sessionId +) { + public boolean isValid() { + return sessionId != null && !sessionId.isEmpty(); + } +} \ No newline at end of file diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/dto/request/SpeakingRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/dto/request/SpeakingRequest.java new file mode 100644 index 00000000..58ad78c1 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/dto/request/SpeakingRequest.java @@ -0,0 +1,32 @@ +package com.mzc.secondproject.serverless.domain.speaking.dto.request; + +/** + * Speaking API 요청 DTO + */ +public record SpeakingRequest( + String sessionId, // 세션 ID (첫 요청 시 null) + String audio, // 음성 데이터 (base64) + String text, // 텍스트 입력 + String level // 레벨 (BEGINNER, INTERMEDIATE, ADVANCED) +) { + /** + * 기본값 적용된 레벨 반환 + */ + public String getLevelOrDefault() { + return level != null && !level.isEmpty() ? level : "INTERMEDIATE"; + } + + /** + * 음성 입력인지 확인 + */ + public boolean hasAudio() { + return audio != null && !audio.isEmpty(); + } + + /** + * 텍스트 입력인지 확인 + */ + public boolean hasText() { + return text != null && !text.trim().isEmpty(); + } +} \ No newline at end of file diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/dto/response/SpeakingResponse.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/dto/response/SpeakingResponse.java new file mode 100644 index 00000000..49d714dd --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/dto/response/SpeakingResponse.java @@ -0,0 +1,12 @@ +package com.mzc.secondproject.serverless.domain.speaking.dto.response; + +/** + * Speaking API 응답 DTO + */ +public record SpeakingResponse( + String sessionId, // 세션 ID (다음 요청에 사용) + String userTranscript, // 사용자가 말한 내용 (STT 결과) + String aiText, // AI 응답 텍스트 + String aiAudioUrl, // AI 응답 음성 URL (Polly) + double confidence // STT 신뢰도 +) {} \ No newline at end of file From 5ad8fd63fac51eaa0892d06933639836670d485e Mon Sep 17 00:00:00 2001 From: hye-inA Date: Thu, 22 Jan 2026 12:38:45 +0900 Subject: [PATCH 432/528] =?UTF-8?q?refacotor=20:=20=EA=B8=B0=EC=A1=B4=20se?= =?UTF-8?q?rvice=20=EC=BD=94=EB=93=9C=20=EB=A1=9C=EC=A7=81=20=EC=9E=AC?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=20=EB=B0=8F=20repository=20=EB=A6=AC?= =?UTF-8?q?=ED=8E=99=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../websocket/SpeakingConnectHandler.java | 88 -------------- ...ngConnection.java => SpeakingSession.java} | 48 +++++--- .../repository/SpeakingSessionRepository.java | 74 ++++++++++++ .../speaking/service/SpeakingService.java | 108 +++++++++++------- 4 files changed, 172 insertions(+), 146 deletions(-) delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingConnectHandler.java rename ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/{SpeakingConnection.java => SpeakingSession.java} (56%) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingSessionRepository.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingConnectHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingConnectHandler.java deleted file mode 100644 index 535a3fc1..00000000 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingConnectHandler.java +++ /dev/null @@ -1,88 +0,0 @@ -package com.mzc.secondproject.serverless.domain.speaking.handler.websocket; - -import com.amazonaws.services.lambda.runtime.Context; -import com.amazonaws.services.lambda.runtime.RequestHandler; -import com.mzc.secondproject.serverless.common.config.WebSocketConfig; -import com.mzc.secondproject.serverless.common.util.JwtUtil; -import com.mzc.secondproject.serverless.common.util.WebSocketEventUtil; -import com.mzc.secondproject.serverless.domain.speaking.model.SpeakingConnection; -import com.mzc.secondproject.serverless.domain.speaking.repository.SpeakingConnectionRepository; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.Map; -import java.util.Optional; - -/** - * Speaking WebSocket $connect 핸들러 - * JWT 토큰 검증 후 연결 정보를 DynamoDB에 저장 - * - * 연결 방법: - * wss://{api-id}.execute-api.{region}.amazonaws.com/{stage}?token={jwt} - */ -public class SpeakingConnectHandler implements RequestHandler, Map> { - - private static final Logger logger = LoggerFactory.getLogger(SpeakingConnectHandler.class); - - private final SpeakingConnectionRepository connectionRepository; - - public SpeakingConnectHandler() { - this.connectionRepository = new SpeakingConnectionRepository(); - } - - @Override - public Map handleRequest(Map event, Context context) { - logger.info("Speaking WebSocket connect event"); - - try { - String connectionId = WebSocketEventUtil.extractConnectionId(event); - Map queryParams = WebSocketEventUtil.extractQueryStringParameters(event); - - // JWT 토큰 검증 - String token = queryParams.get("token"); - - if (token == null || token.isEmpty()) { - logger.warn("Missing token parameter"); - return WebSocketEventUtil.unauthorized("token is required"); - } - - // 토큰 유효성 검사 - if (!JwtUtil.isValid(token)) { - logger.warn("Invalid or expired token"); - return WebSocketEventUtil.unauthorized("Invalid or expired token"); - } - - // userId 추출 - Optional userIdOpt = JwtUtil.extractUserId(token); - if (userIdOpt.isEmpty()) { - logger.warn("Failed to extract userId from token"); - return WebSocketEventUtil.unauthorized("Invalid token"); - } - - String userId = userIdOpt.get(); - - // 연결 정보 저장 - SpeakingConnection connection = SpeakingConnection.create( - connectionId, - userId, - WebSocketConfig.connectionTtlSeconds() - ); - - // 레벨 파라미터가 있으면 설정 - String level = queryParams.get("level"); - if (level != null && !level.isEmpty()) { - connection.setTargetLevel(level.toUpperCase()); - } - - connectionRepository.save(connection); - - logger.info("Speaking connection established: connectionId={}, userId={}, level={}", - connectionId, userId, connection.getTargetLevel()); - return WebSocketEventUtil.ok("Connected"); - - } catch (Exception e) { - logger.error("Error handling connect: {}", e.getMessage(), e); - return WebSocketEventUtil.serverError("Internal server error"); - } - } -} \ No newline at end of file diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/SpeakingConnection.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/SpeakingSession.java similarity index 56% rename from ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/SpeakingConnection.java rename to ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/SpeakingSession.java index 6ea0c185..07956b2f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/SpeakingConnection.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/SpeakingSession.java @@ -15,49 +15,61 @@ @NoArgsConstructor @AllArgsConstructor @DynamoDbBean -public class SpeakingConnection { +public class SpeakingSession { // DynamoDB Key Prefixes - public static final String PK_PREFIX = "SPEAKING_CONN#"; + public static final String PK_PREFIX = "SPEAKING_SESSION#"; public static final String SK_METADATA = "METADATA"; public static final String GSI1PK_PREFIX = "SPEAKING_USER#"; - public static final String GSI1SK_PREFIX = "CONN#"; + public static final String GSI1SK_PREFIX = "SESSION#"; - private String pk; // SPEAKING_CONN#{connectionId} + private String pk; // SPEAKING_SESSION#{sessionId} private String sk; // METADATA private String gsi1pk; // SPEAKING_USER#{userId} - private String gsi1sk; // CONN#{connectionId} + private String gsi1sk; // SESSION#{sessionId} - private String connectionId; + private String sessionId; private String userId; - private String connectedAt; - private Long ttl; // 자동 삭제용 + private String createdAt; + private String updatedAt; + private Long ttl; // 자동 삭제용 (24시간) // Speaking 전용 필드 private String conversationHistory; // 대화 히스토리 (JSON) private String targetLevel; // 목표 레벨 (BEGINNER, INTERMEDIATE, ADVANCED) /** - * 연결 정보 생성 팩토리 메서드 + * 세션 생성 팩토리 메서드 */ - public static SpeakingConnection create(String connectionId, String userId, long ttlSeconds) { + public static SpeakingSession create(String sessionId, String userId, String level) { String now = java.time.Instant.now().toString(); - long ttl = java.time.Instant.now().plusSeconds(ttlSeconds).getEpochSecond(); + // 24시간 후 자동 삭제 + long ttl = java.time.Instant.now().plusSeconds(86400).getEpochSecond(); - return SpeakingConnection.builder() - .pk(PK_PREFIX + connectionId) + return SpeakingSession.builder() + .pk(PK_PREFIX + sessionId) .sk(SK_METADATA) .gsi1pk(GSI1PK_PREFIX + userId) - .gsi1sk(GSI1SK_PREFIX + connectionId) - .connectionId(connectionId) + .gsi1sk(GSI1SK_PREFIX + sessionId) + .sessionId(sessionId) .userId(userId) - .connectedAt(now) + .createdAt(now) + .updatedAt(now) .ttl(ttl) - .conversationHistory("[]") // 빈 배열로 초기화 - .targetLevel("INTERMEDIATE") // 기본값 + .conversationHistory("[]") + .targetLevel(level != null ? level.toUpperCase() : "INTERMEDIATE") .build(); } + /** + * 업데이트 시간 갱신 + */ + public void touch() { + this.updatedAt = java.time.Instant.now().toString(); + // TTL 연장 (24시간) + this.ttl = java.time.Instant.now().plusSeconds(86400).getEpochSecond(); + } + @DynamoDbPartitionKey @DynamoDbAttribute("PK") public String getPk() { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingSessionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingSessionRepository.java new file mode 100644 index 00000000..fed1cd66 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingSessionRepository.java @@ -0,0 +1,74 @@ +package com.mzc.secondproject.serverless.domain.speaking.repository; + +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.config.EnvConfig; +import com.mzc.secondproject.serverless.domain.speaking.model.SpeakingSession; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; +import software.amazon.awssdk.enhanced.dynamodb.Key; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; + +import java.util.Optional; + +/** + * Speaking WebSocket 연결 정보 Repository + */ +public class SpeakingSessionRepository { + + private static final Logger logger = LoggerFactory.getLogger(SpeakingSessionRepository.class); + private static final String TABLE_NAME = EnvConfig.getRequired("CHAT_TABLE_NAME"); + + private final DynamoDbTable table; + + public SpeakingSessionRepository() { + this.table = AwsClients.dynamoDbEnhanced().table( + TABLE_NAME, + TableSchema.fromBean(SpeakingSession.class) + ); + } + + /** + * 연결 정보 저장 + */ + public void save(SpeakingSession session) { + table.putItem(session); + logger.debug("Speaking session saved: sessionId={}, userId={}", + session.getSessionId(), session.getUserId()); + } + + /** + * sessionId로 연결 정보 조회 + */ + public Optional findBySessionId(String sessionId) { + Key key = Key.builder() + .partitionValue(SpeakingSession.PK_PREFIX + sessionId) + .sortValue(SpeakingSession.SK_METADATA) + .build(); + + SpeakingSession session = table.getItem(key); + return Optional.ofNullable(session); + } + + /** + * 연결 정보 업데이트 (대화 히스토리 등) + */ + public void update(SpeakingSession session) { + session.touch(); // 업데이트 시간 및 TTL 갱신 + table.putItem(session); + logger.debug("Speaking session updated: sessionId={}", session.getSessionId()); + } + + /** + * 연결 정보 삭제 + */ + public void delete(String sessionId) { + Key key = Key.builder() + .partitionValue(SpeakingSession.PK_PREFIX + sessionId) + .sortValue(SpeakingSession.SK_METADATA) + .build(); + + table.deleteItem(key); + logger.info("Speaking session deleted: sessionId={}", sessionId); + } +} \ No newline at end of file diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/service/SpeakingService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/service/SpeakingService.java index 3462bbfb..010c4afe 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/service/SpeakingService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/service/SpeakingService.java @@ -5,8 +5,9 @@ import com.mzc.secondproject.serverless.common.config.EnvConfig; import com.mzc.secondproject.serverless.common.service.PollyService; import com.mzc.secondproject.serverless.domain.opic.service.TranscribeProxyService; -import com.mzc.secondproject.serverless.domain.speaking.model.SpeakingConnection; -import com.mzc.secondproject.serverless.domain.speaking.repository.SpeakingConnectionRepository; +import com.mzc.secondproject.serverless.domain.speaking.model.SpeakingSession; +import com.mzc.secondproject.serverless.domain.speaking.repository.SpeakingSessionRepository; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; import software.amazon.awssdk.core.SdkBytes; @@ -16,6 +17,7 @@ import java.util.ArrayList; import java.util.List; +import java.util.UUID; /** * AI와 대화하기 서비스 @@ -32,7 +34,29 @@ public class SpeakingService { private final TranscribeProxyService transcribeService; private final PollyService pollyService; - private final SpeakingConnectionRepository connectionRepository; + private final SpeakingSessionRepository sessionRepository; + + /** + * 세션 생성 또는 조회 + */ + public SpeakingSession getOrCreateSession(String sessionId, String userId, String level) { + if (sessionId != null && !sessionId.isEmpty()) { + return sessionRepository.findBySessionId(sessionId) + .orElseGet(() -> createNewSession(userId, level)); + } + return createNewSession(userId, level); + } + + /** + * 새 세션 생성 + */ + private SpeakingSession createNewSession(String userId, String level) { + String newSessionId = UUID.randomUUID().toString(); + SpeakingSession session = SpeakingSession.create(newSessionId, userId, level); + sessionRepository.save(session); + logger.info("New speaking session created: sessionId={}, userId={}", newSessionId, userId); + return session; + } public SpeakingService() { this.transcribeService = new TranscribeProxyService(); @@ -40,33 +64,32 @@ public SpeakingService() { EnvConfig.getRequired("BUCKET_NAME"), "speaking/voice/" ); - this.connectionRepository = new SpeakingConnectionRepository(); + this.sessionRepository = new SpeakingSessionRepository(); } /** * 음성 입력 처리 (전체 플로우) */ - public SpeakingResponse processVoiceInput(String connectionId, String audioBase64) { - logger.info("Processing voice input for connectionId: {}", connectionId); + public SpeakingResponse processVoiceInput(String sessionId, String userId, String audioBase64, String level) { + logger.info("Processing voice input for sessionId: {}", sessionId); - // 연결 정보 조회 - SpeakingConnection connection = connectionRepository.findByConnectionId(connectionId) - .orElseThrow(() -> new RuntimeException("Connection not found: " + connectionId)); + // 세션 조회 또는 생성 + SpeakingSession session = getOrCreateSession(sessionId, userId, level); - String targetLevel = connection.getTargetLevel(); + String targetLevel = session.getTargetLevel(); // STT: 음성 → 텍스트 (Transcribe Proxy 사용) logger.info("Step 1: Transcribing audio..."); TranscribeProxyService.TranscribeResult sttResult = transcribeService.transcribe( audioBase64, - connectionId, + sessionId, "en-US" ); String userText = sttResult.transcript(); logger.info("Transcription complete: {} (confidence: {})", userText, sttResult.confidence()); // 대화 히스토리 로드 - List history = parseHistory(connection.getConversationHistory()); + List history = parseHistory(session.getConversationHistory()); // Bedrock: AI 응답 생성 logger.info("Step 2: Generating AI response..."); @@ -79,12 +102,12 @@ public SpeakingResponse processVoiceInput(String connectionId, String audioBase6 if (history.size() > MAX_HISTORY_SIZE * 2) { history = new ArrayList<>(history.subList(history.size() - MAX_HISTORY_SIZE * 2, history.size())); } - connection.setConversationHistory(toJson(history)); - connectionRepository.update(connection); + session.setConversationHistory(toJson(history)); + sessionRepository.update(session); // TTS: 텍스트 → 음성 (Polly 사용) logger.info("Step 3: Synthesizing speech..."); - String audioId = connectionId + "_" + System.currentTimeMillis(); + String audioId = session.getSessionId() + "_" + System.currentTimeMillis(); PollyService.VoiceSynthesisResult ttsResult = pollyService.synthesizeSpeech( audioId, aiResponse, @@ -93,6 +116,7 @@ public SpeakingResponse processVoiceInput(String connectionId, String audioBase6 logger.info("Speech synthesis complete: cached={}", ttsResult.isCached()); return new SpeakingResponse( + session.getSessionId(), userText, aiResponse, ttsResult.getAudioUrl(), @@ -103,20 +127,17 @@ public SpeakingResponse processVoiceInput(String connectionId, String audioBase6 /** * 텍스트 입력 처리 (음성 없이 텍스트만) */ - public SpeakingResponse processTextInput(String connectionId, String userText) { - logger.info("Processing text input for connectionId: {}", connectionId); - - // 연결 정보 조회 - SpeakingConnection connection = connectionRepository.findByConnectionId(connectionId) - .orElseThrow(() -> new RuntimeException("Connection not found: " + connectionId)); + public SpeakingResponse processTextInput(String sessionId, String userId, String userText, String level){ + logger.info("Processing text input for sessionId: {}", sessionId); - String targetLevel = connection.getTargetLevel(); + // 세션 조회 또는 생성 + SpeakingSession session = getOrCreateSession(sessionId, userId, level); // 대화 히스토리 로드 - List history = parseHistory(connection.getConversationHistory()); + List history = parseHistory(session.getConversationHistory()); // AI 응답 생성 - String aiResponse = generateAiResponse(userText, history, targetLevel); + String aiResponse = generateAiResponse(userText, history, session.getTargetLevel()); // 히스토리 업데이트 history.add(new Message("user", userText)); @@ -124,40 +145,46 @@ public SpeakingResponse processTextInput(String connectionId, String userText) { if (history.size() > MAX_HISTORY_SIZE * 2) { history = new ArrayList<>(history.subList(history.size() - MAX_HISTORY_SIZE * 2, history.size())); } - connection.setConversationHistory(toJson(history)); - connectionRepository.update(connection); + session.setConversationHistory(toJson(history)); + sessionRepository.update(session); // TTS 생성 - String audioId = connectionId + "_" + System.currentTimeMillis(); + String audioId = session.getSessionId() + "_" + System.currentTimeMillis(); PollyService.VoiceSynthesisResult ttsResult = pollyService.synthesizeSpeech( audioId, aiResponse, "FEMALE" ); - return new SpeakingResponse(userText, aiResponse, ttsResult.getAudioUrl(), 1.0); + return new SpeakingResponse( + session.getSessionId(), + userText, + aiResponse, + ttsResult.getAudioUrl(), + 1.0 + ); } /** * 레벨 변경 */ - public void updateLevel(String connectionId, String level) { - SpeakingConnection connection = connectionRepository.findByConnectionId(connectionId) - .orElseThrow(() -> new RuntimeException("Connection not found: " + connectionId)); + public void updateLevel(String sessionId, String level) { + SpeakingSession session = sessionRepository.findBySessionId(sessionId) + .orElseThrow(() -> new RuntimeException("session not found: " + sessionId)); - connection.setTargetLevel(level.toUpperCase()); - connectionRepository.update(connection); - logger.info("Level updated for connectionId {}: {}", connectionId, level); + session.setTargetLevel(level.toUpperCase()); + sessionRepository.update(session); + logger.info("Level updated for sessionId {}: {}", sessionId, level); } /** * 대화 히스토리 초기화 */ - public void resetConversation(String connectionId) { - SpeakingConnection connection = connectionRepository.findByConnectionId(connectionId) - .orElseThrow(() -> new RuntimeException("Connection not found: " + connectionId)); + public void resetConversation(String sessionId) { + SpeakingSession session = sessionRepository.findBySessionId(sessionId) + .orElseThrow(() -> new RuntimeException("session not found: " + sessionId)); - connection.setConversationHistory("[]"); - connectionRepository.update(connection); - logger.info("Conversation reset for connectionId: {}", connectionId); + session.setConversationHistory("[]"); + sessionRepository.update(session); + logger.info("Conversation reset for sessionId: {}", sessionId); } @@ -309,6 +336,7 @@ private record Message(String role, String content) {} * Speaking 응답 DTO */ public record SpeakingResponse( + String sessionId, // 세션 ID (다음 요청에 사용) String userTranscript, // 사용자가 말한 내용 (STT 결과) String aiText, // AI 응답 텍스트 String aiAudioUrl, // AI 응답 음성 URL (Polly) From c1ca2c0a0da07c1de784f771f47b2734018d8e95 Mon Sep 17 00:00:00 2001 From: DDING JOO Date: Thu, 22 Jan 2026 12:39:43 +0900 Subject: [PATCH 433/528] fix: add CORS headers to API Gateway error responses (#479) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit API Gateway의 인증 실패 등 에러 응답에 CORS 헤더가 누락되어 CloudFront를 통한 프론트엔드 요청이 차단되는 문제 수정 - UNAUTHORIZED (401) 응답에 CORS 헤더 추가 - ACCESS_DENIED (403) 응답에 CORS 헤더 추가 - DEFAULT_4XX/5XX 응답에 CORS 헤더 추가 - EXPIRED_TOKEN 응답에 CORS 헤더 추가 --- ServerlessFunction/template.yaml | 42 +++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 09663db9..488fc843 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -143,7 +143,47 @@ Resources: AllowMethods: "'GET,POST,PUT,DELETE,OPTIONS'" AllowHeaders: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" AllowOrigin: "'*'" - + AllowCredentials: false + GatewayResponses: + UNAUTHORIZED: + StatusCode: 401 + ResponseParameters: + Headers: + Access-Control-Allow-Origin: "'*'" + Access-Control-Allow-Headers: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" + Access-Control-Allow-Methods: "'GET,POST,PUT,DELETE,OPTIONS'" + ResponseTemplates: + application/json: '{"message": "Unauthorized", "statusCode": 401}' + ACCESS_DENIED: + StatusCode: 403 + ResponseParameters: + Headers: + Access-Control-Allow-Origin: "'*'" + Access-Control-Allow-Headers: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" + Access-Control-Allow-Methods: "'GET,POST,PUT,DELETE,OPTIONS'" + ResponseTemplates: + application/json: '{"message": "Access Denied", "statusCode": 403}' + DEFAULT_4XX: + ResponseParameters: + Headers: + Access-Control-Allow-Origin: "'*'" + Access-Control-Allow-Headers: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" + Access-Control-Allow-Methods: "'GET,POST,PUT,DELETE,OPTIONS'" + DEFAULT_5XX: + ResponseParameters: + Headers: + Access-Control-Allow-Origin: "'*'" + Access-Control-Allow-Headers: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" + Access-Control-Allow-Methods: "'GET,POST,PUT,DELETE,OPTIONS'" + EXPIRED_TOKEN: + StatusCode: 401 + ResponseParameters: + Headers: + Access-Control-Allow-Origin: "'*'" + Access-Control-Allow-Headers: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" + Access-Control-Allow-Methods: "'GET,POST,PUT,DELETE,OPTIONS'" + ResponseTemplates: + application/json: '{"message": "Token expired", "statusCode": 401}' Auth: DefaultAuthorizer: CognitoAuthorizer Authorizers: From e26e2cec9346cd1ca068780cfca7c97dd131469c Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 12:53:58 +0900 Subject: [PATCH 434/528] =?UTF-8?q?feat(news):=20=EB=89=B4=EC=8A=A4=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EA=B8=B0=EB=B0=98=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=EA=B5=AC=EC=B6=95=20(#385)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NewsCategory, QuizType enum 추가 - NewsKey 상수 클래스 추가 - NewsErrorCode 예외 클래스 추가 - NewsArticle, KeywordInfo, QuizQuestion 모델 추가 - NewsArticleRepository CRUD 구현 - NewsTable DynamoDB 테이블 정의 - CORS GatewayResponses 설정 추가 --- .gitignore | 1 + .../domain/news/constants/NewsKey.java | 122 +++++++ .../domain/news/enums/NewsCategory.java | 55 ++++ .../domain/news/enums/QuizType.java | 50 +++ .../domain/news/exception/NewsErrorCode.java | 77 +++++ .../domain/news/model/KeywordInfo.java | 24 ++ .../domain/news/model/NewsArticle.java | 92 ++++++ .../domain/news/model/QuizQuestion.java | 27 ++ .../repository/NewsArticleRepository.java | 297 ++++++++++++++++++ ServerlessFunction/template.yaml | 87 ++++- 10 files changed, 831 insertions(+), 1 deletion(-) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/constants/NewsKey.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/enums/NewsCategory.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/enums/QuizType.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/exception/NewsErrorCode.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/KeywordInfo.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsArticle.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/QuizQuestion.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsArticleRepository.java diff --git a/.gitignore b/.gitignore index 715a4d18..ee058388 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ # Claude .claude/ +.sisyphus/ # Build target/ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/constants/NewsKey.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/constants/NewsKey.java new file mode 100644 index 00000000..e5ba97b8 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/constants/NewsKey.java @@ -0,0 +1,122 @@ +package com.mzc.secondproject.serverless.domain.news.constants; + +import com.mzc.secondproject.serverless.common.constants.DynamoDbKey; + +/** + * 뉴스 도메인 DynamoDB 키 상수 및 빌더 + */ +public final class NewsKey { + + // Entity Prefixes + public static final String NEWS = "NEWS#"; + public static final String ARTICLE = "ARTICLE#"; + public static final String LEVEL = "LEVEL#"; + public static final String CATEGORY = "CATEGORY#"; + public static final String READ = "READ#"; + public static final String QUIZ = "QUIZ#"; + public static final String WORD = "WORD#"; + public static final String BOOKMARK = "BOOKMARK#"; + public static final String COMMENT = "COMMENT#"; + public static final String STATS = "STATS"; + + // User Suffixes + public static final String SUFFIX_NEWS = "#NEWS"; + public static final String SUFFIX_NEWS_WORDS = "#NEWS_WORDS"; + public static final String SUFFIX_NEWS_COMMENTS = "#NEWS_COMMENTS"; + + private NewsKey() { + } + + // === Key Builders === + + /** + * 뉴스 기사 PK: NEWS#{date} + */ + public static String newsPk(String date) { + return NEWS + date; + } + + /** + * 뉴스 기사 SK: ARTICLE#{articleId} + */ + public static String articleSk(String articleId) { + return ARTICLE + articleId; + } + + /** + * 레벨별 조회 GSI1 PK: LEVEL#{level} + */ + public static String levelPk(String level) { + return LEVEL + level; + } + + /** + * 카테고리별 조회 GSI2 PK: CATEGORY#{category} + */ + public static String categoryPk(String category) { + return CATEGORY + category; + } + + /** + * 사용자 뉴스 활동 PK: USER#{userId}#NEWS + */ + public static String userNewsPk(String userId) { + return DynamoDbKey.USER + userId + SUFFIX_NEWS; + } + + /** + * 읽기 기록 SK: READ#{articleId} + */ + public static String readSk(String articleId) { + return READ + articleId; + } + + /** + * 퀴즈 결과 SK: QUIZ#{articleId} + */ + public static String quizSk(String articleId) { + return QUIZ + articleId; + } + + /** + * 단어 수집 SK: WORD#{word}#{articleId} + */ + public static String wordSk(String word, String articleId) { + return WORD + word + "#" + articleId; + } + + /** + * 북마크 SK: BOOKMARK#{articleId} + */ + public static String bookmarkSk(String articleId) { + return BOOKMARK + articleId; + } + + /** + * 사용자 수집 단어 GSI1 PK: USER#{userId}#NEWS_WORDS + */ + public static String userNewsWordsPk(String userId) { + return DynamoDbKey.USER + userId + SUFFIX_NEWS_WORDS; + } + + /** + * 댓글 PK: NEWS_COMMENT#{articleId} + */ + public static String commentPk(String articleId) { + return "NEWS_COMMENT#" + articleId; + } + + /** + * 댓글 SK: COMMENT#{commentId} + */ + public static String commentSk(String commentId) { + return COMMENT + commentId; + } + + /** + * 사용자 댓글 GSI1 PK: USER#{userId}#NEWS_COMMENTS + */ + public static String userNewsCommentsPk(String userId) { + return DynamoDbKey.USER + userId + SUFFIX_NEWS_COMMENTS; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/enums/NewsCategory.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/enums/NewsCategory.java new file mode 100644 index 00000000..7f88078f --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/enums/NewsCategory.java @@ -0,0 +1,55 @@ +package com.mzc.secondproject.serverless.domain.news.enums; + +import java.util.Arrays; + +/** + * 뉴스 카테고리 + */ +public enum NewsCategory { + TECH("tech", "기술"), + BUSINESS("business", "비즈니스"), + SPORTS("sports", "스포츠"), + ENTERTAINMENT("entertainment", "엔터테인먼트"), + WORLD("world", "세계"), + CULTURE("culture", "문화"), + SCIENCE("science", "과학"); + + private final String code; + private final String displayName; + + NewsCategory(String code, String displayName) { + this.code = code; + this.displayName = displayName; + } + + public static boolean isValid(String value) { + if (value == null) return false; + return Arrays.stream(values()) + .anyMatch(cat -> cat.name().equalsIgnoreCase(value) || cat.code.equalsIgnoreCase(value)); + } + + public static NewsCategory fromString(String value) { + if (value == null) { + throw new IllegalArgumentException("NewsCategory value cannot be null"); + } + return Arrays.stream(values()) + .filter(cat -> cat.name().equalsIgnoreCase(value) || cat.code.equalsIgnoreCase(value)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Unknown NewsCategory: " + value)); + } + + public static NewsCategory fromStringOrDefault(String value, NewsCategory defaultValue) { + if (value == null || !isValid(value)) { + return defaultValue; + } + return fromString(value); + } + + public String getCode() { + return code; + } + + public String getDisplayName() { + return displayName; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/enums/QuizType.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/enums/QuizType.java new file mode 100644 index 00000000..7b95a466 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/enums/QuizType.java @@ -0,0 +1,50 @@ +package com.mzc.secondproject.serverless.domain.news.enums; + +import java.util.Arrays; + +/** + * 뉴스 퀴즈 유형 + */ +public enum QuizType { + COMPREHENSION("comprehension", "독해 질문", 20), + WORD_MATCH("word_match", "단어-뜻 매칭", 15), + FILL_BLANK("fill_blank", "빈칸 채우기", 30); + + private final String code; + private final String displayName; + private final int defaultPoints; + + QuizType(String code, String displayName, int defaultPoints) { + this.code = code; + this.displayName = displayName; + this.defaultPoints = defaultPoints; + } + + public static boolean isValid(String value) { + if (value == null) return false; + return Arrays.stream(values()) + .anyMatch(type -> type.name().equalsIgnoreCase(value) || type.code.equalsIgnoreCase(value)); + } + + public static QuizType fromString(String value) { + if (value == null) { + throw new IllegalArgumentException("QuizType value cannot be null"); + } + return Arrays.stream(values()) + .filter(type -> type.name().equalsIgnoreCase(value) || type.code.equalsIgnoreCase(value)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Unknown QuizType: " + value)); + } + + public String getCode() { + return code; + } + + public String getDisplayName() { + return displayName; + } + + public int getDefaultPoints() { + return defaultPoints; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/exception/NewsErrorCode.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/exception/NewsErrorCode.java new file mode 100644 index 00000000..fa898252 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/exception/NewsErrorCode.java @@ -0,0 +1,77 @@ +package com.mzc.secondproject.serverless.domain.news.exception; + +import com.mzc.secondproject.serverless.common.exception.DomainErrorCode; + +/** + * 뉴스 도메인 에러 코드 + * 뉴스 기사, 퀴즈, 단어 수집, 댓글 관련 에러 코드를 정의합니다. + */ +public enum NewsErrorCode implements DomainErrorCode { + + // 뉴스 기사 관련 에러 + ARTICLE_NOT_FOUND("ARTICLE_001", "뉴스 기사를 찾을 수 없습니다", 404), + INVALID_ARTICLE_DATA("ARTICLE_002", "뉴스 기사 데이터가 유효하지 않습니다", 400), + ARTICLE_ALREADY_EXISTS("ARTICLE_003", "이미 존재하는 뉴스 기사입니다", 409), + + // 카테고리/레벨 관련 에러 + INVALID_CATEGORY("CATEGORY_001", "유효하지 않은 카테고리입니다", 400), + INVALID_LEVEL("LEVEL_001", "유효하지 않은 레벨입니다", 400), + + // 읽기 기록 관련 에러 + READ_RECORD_NOT_FOUND("READ_001", "읽기 기록을 찾을 수 없습니다", 404), + ALREADY_READ("READ_002", "이미 읽은 기사입니다", 409), + + // 퀴즈 관련 에러 + QUIZ_NOT_FOUND("QUIZ_001", "퀴즈를 찾을 수 없습니다", 404), + QUIZ_ALREADY_SUBMITTED("QUIZ_002", "이미 제출한 퀴즈입니다", 409), + INVALID_QUIZ_ANSWER("QUIZ_003", "유효하지 않은 퀴즈 답변입니다", 400), + + // 단어 수집 관련 에러 + WORD_ALREADY_COLLECTED("WORD_001", "이미 수집한 단어입니다", 409), + WORD_NOT_COLLECTED("WORD_002", "수집한 단어를 찾을 수 없습니다", 404), + + // 북마크 관련 에러 + BOOKMARK_NOT_FOUND("BOOKMARK_001", "북마크를 찾을 수 없습니다", 404), + ALREADY_BOOKMARKED("BOOKMARK_002", "이미 북마크한 기사입니다", 409), + BOOKMARK_LIMIT_EXCEEDED("BOOKMARK_003", "북마크 한도를 초과했습니다", 400), + + // 댓글 관련 에러 + COMMENT_NOT_FOUND("COMMENT_001", "댓글을 찾을 수 없습니다", 404), + COMMENT_NOT_OWNER("COMMENT_002", "댓글 작성자만 수정/삭제할 수 있습니다", 403), + INVALID_COMMENT_DATA("COMMENT_003", "유효하지 않은 댓글 데이터입니다", 400), + + // 통계 관련 에러 + STATS_NOT_FOUND("STATS_001", "통계 정보를 찾을 수 없습니다", 404); + + private static final String DOMAIN = "NEWS"; + + private final String code; + private final String message; + private final int statusCode; + + NewsErrorCode(String code, String message, int statusCode) { + this.code = code; + this.message = message; + this.statusCode = statusCode; + } + + @Override + public String getDomain() { + return DOMAIN; + } + + @Override + public String getCode() { + return code; + } + + @Override + public String getMessage() { + return message; + } + + @Override + public int getStatusCode() { + return statusCode; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/KeywordInfo.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/KeywordInfo.java new file mode 100644 index 00000000..81f1e1f5 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/KeywordInfo.java @@ -0,0 +1,24 @@ +package com.mzc.secondproject.serverless.domain.news.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; + +/** + * 뉴스 기사 내 키워드 정보 + * 단어, 뜻, 난이도, 위치 정보를 포함 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DynamoDbBean +public class KeywordInfo { + + private String word; // 영어 단어 + private String meaning; // 한국어 뜻 + private String level; // 단어 난이도 (BEGINNER, INTERMEDIATE, ADVANCED) + private Integer position; // 기사 내 위치 (문장 번호 또는 단어 인덱스) +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsArticle.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsArticle.java new file mode 100644 index 00000000..13fd8a19 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsArticle.java @@ -0,0 +1,92 @@ +package com.mzc.secondproject.serverless.domain.news.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.*; + +import java.util.List; + +/** + * 뉴스 기사 모델 + * PK: NEWS#{date} + * SK: ARTICLE#{articleId} + * GSI1: LEVEL#{level} / {publishedAt} - 레벨별 최신순 조회 + * GSI2: CATEGORY#{category} / {publishedAt} - 카테고리별 최신순 조회 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DynamoDbBean +public class NewsArticle { + + private String pk; // NEWS#{date} + private String sk; // ARTICLE#{articleId} + private String gsi1pk; // LEVEL#{level} + private String gsi1sk; // {publishedAt} + private String gsi2pk; // CATEGORY#{category} + private String gsi2sk; // {publishedAt} + + // 기본 정보 + private String articleId; + private String title; + private String summary; // AI 생성 3줄 요약 + private String originalUrl; // 원문 링크 + private String source; // BBC, VOA, NPR, NewsAPI + private String imageUrl; // 썸네일 이미지 + + // 분류 + private String category; // TECH, BUSINESS, SPORTS 등 + private String level; // BEGINNER, INTERMEDIATE, ADVANCED + private String cefrLevel; // A1, A2, B1, B2, C1, C2 (원본 CEFR 레벨) + + // AI 분석 결과 + private List keywords; // 핵심 단어 정보 + private List highlightWords; // 사용자 레벨 대비 어려운 단어 + private List quiz; // 퀴즈 문제 (5개) + + // 메타데이터 + private String publishedAt; // 원본 발행일 + private String collectedAt; // 수집일 + private Long readCount; // 조회수 + private Long commentCount; // 댓글수 + private Long ttl; + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { + return pk; + } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { + return sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1PK") + public String getGsi1pk() { + return gsi1pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1SK") + public String getGsi1sk() { + return gsi1sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI2") + @DynamoDbAttribute("GSI2PK") + public String getGsi2pk() { + return gsi2pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI2") + @DynamoDbAttribute("GSI2SK") + public String getGsi2sk() { + return gsi2sk; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/QuizQuestion.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/QuizQuestion.java new file mode 100644 index 00000000..6657ef33 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/QuizQuestion.java @@ -0,0 +1,27 @@ +package com.mzc.secondproject.serverless.domain.news.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; + +import java.util.List; + +/** + * 뉴스 퀴즈 문제 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DynamoDbBean +public class QuizQuestion { + + private String questionId; // 문제 ID (q1, q2, ...) + private String type; // COMPREHENSION, WORD_MATCH, FILL_BLANK + private String question; // 문제 내용 + private List options; // 선택지 (객관식인 경우) + private String correctAnswer; // 정답 + private Integer points; // 배점 +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsArticleRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsArticleRepository.java new file mode 100644 index 00000000..28ca35cc --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsArticleRepository.java @@ -0,0 +1,297 @@ +package com.mzc.secondproject.serverless.domain.news.repository; + +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.config.EnvConfig; +import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.common.util.CursorUtil; +import com.mzc.secondproject.serverless.domain.news.constants.NewsKey; +import com.mzc.secondproject.serverless.domain.news.model.NewsArticle; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.*; +import software.amazon.awssdk.enhanced.dynamodb.model.*; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; +import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * 뉴스 기사 Repository + */ +public class NewsArticleRepository { + + private static final Logger logger = LoggerFactory.getLogger(NewsArticleRepository.class); + private static final String TABLE_NAME = EnvConfig.getRequired("NEWS_TABLE_NAME"); + + private final DynamoDbEnhancedClient enhancedClient; + private final DynamoDbTable table; + + /** + * 기본 생성자 (Lambda에서 사용) + */ + public NewsArticleRepository() { + this(AwsClients.dynamoDbEnhanced()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public NewsArticleRepository(DynamoDbEnhancedClient enhancedClient) { + this.enhancedClient = enhancedClient; + this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(NewsArticle.class)); + } + + /** + * 뉴스 기사 저장 + */ + public NewsArticle save(NewsArticle article) { + logger.info("Saving news article: {}", article.getArticleId()); + table.putItem(article); + return article; + } + + /** + * 뉴스 기사 조회 (날짜 + 기사ID) + */ + public Optional findByDateAndId(String date, String articleId) { + Key key = Key.builder() + .partitionValue(NewsKey.newsPk(date)) + .sortValue(NewsKey.articleSk(articleId)) + .build(); + + NewsArticle article = table.getItem(key); + return Optional.ofNullable(article); + } + + /** + * 뉴스 기사 조회 (기사ID만으로 - GSI 활용 또는 Scan) + * 참고: 실제로는 articleId로 date를 알 수 있도록 설계하거나 GSI 추가 필요 + */ + public Optional findById(String articleId) { + Expression filterExpression = Expression.builder() + .expression("articleId = :articleId") + .putExpressionValue(":articleId", AttributeValue.builder().s(articleId).build()) + .build(); + + ScanEnhancedRequest request = ScanEnhancedRequest.builder() + .filterExpression(filterExpression) + .limit(1) + .build(); + + for (Page page : table.scan(request)) { + List items = page.items(); + if (!items.isEmpty()) { + return Optional.of(items.get(0)); + } + } + return Optional.empty(); + } + + /** + * 뉴스 기사 삭제 + */ + public void delete(String date, String articleId) { + Key key = Key.builder() + .partitionValue(NewsKey.newsPk(date)) + .sortValue(NewsKey.articleSk(articleId)) + .build(); + + table.deleteItem(key); + logger.info("Deleted news article: {}", articleId); + } + + /** + * 날짜별 뉴스 기사 조회 (페이지네이션) + */ + public PaginatedResult findByDate(String date, int limit, String cursor) { + QueryConditional queryConditional = QueryConditional + .keyEqualTo(Key.builder().partitionValue(NewsKey.newsPk(date)).build()); + + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .scanIndexForward(false) // 최신순 (SK 역순) + .limit(limit); + + if (cursor != null && !cursor.isEmpty()) { + Map exclusiveStartKey = CursorUtil.decode(cursor); + if (exclusiveStartKey != null) { + requestBuilder.exclusiveStartKey(exclusiveStartKey); + } + } + + Page page = table.query(requestBuilder.build()).iterator().next(); + String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); + + return new PaginatedResult<>(page.items(), nextCursor); + } + + /** + * 레벨별 뉴스 기사 조회 (GSI1 - 최신순) + */ + public PaginatedResult findByLevel(String level, int limit, String cursor) { + QueryConditional queryConditional = QueryConditional + .keyEqualTo(Key.builder().partitionValue(NewsKey.levelPk(level.toUpperCase())).build()); + + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .scanIndexForward(false) // 최신순 + .limit(limit); + + if (cursor != null && !cursor.isEmpty()) { + Map exclusiveStartKey = CursorUtil.decode(cursor); + if (exclusiveStartKey != null) { + requestBuilder.exclusiveStartKey(exclusiveStartKey); + } + } + + DynamoDbIndex gsi1 = table.index("GSI1"); + Page page = gsi1.query(requestBuilder.build()).iterator().next(); + String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); + + return new PaginatedResult<>(page.items(), nextCursor); + } + + /** + * 카테고리별 뉴스 기사 조회 (GSI2 - 최신순) + */ + public PaginatedResult findByCategory(String category, int limit, String cursor) { + QueryConditional queryConditional = QueryConditional + .keyEqualTo(Key.builder().partitionValue(NewsKey.categoryPk(category.toUpperCase())).build()); + + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .scanIndexForward(false) // 최신순 + .limit(limit); + + if (cursor != null && !cursor.isEmpty()) { + Map exclusiveStartKey = CursorUtil.decode(cursor); + if (exclusiveStartKey != null) { + requestBuilder.exclusiveStartKey(exclusiveStartKey); + } + } + + DynamoDbIndex gsi2 = table.index("GSI2"); + Page page = gsi2.query(requestBuilder.build()).iterator().next(); + String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); + + return new PaginatedResult<>(page.items(), nextCursor); + } + + /** + * 레벨 + 카테고리 필터 조회 (GSI1 쿼리 후 필터) + */ + public PaginatedResult findByLevelAndCategory(String level, String category, int limit, String cursor) { + Expression filterExpression = Expression.builder() + .expression("category = :category") + .putExpressionValue(":category", AttributeValue.builder().s(category.toUpperCase()).build()) + .build(); + + QueryConditional queryConditional = QueryConditional + .keyEqualTo(Key.builder().partitionValue(NewsKey.levelPk(level.toUpperCase())).build()); + + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .filterExpression(filterExpression) + .scanIndexForward(false) + .limit(limit * 2); // 필터 적용되므로 넉넉히 + + if (cursor != null && !cursor.isEmpty()) { + Map exclusiveStartKey = CursorUtil.decode(cursor); + if (exclusiveStartKey != null) { + requestBuilder.exclusiveStartKey(exclusiveStartKey); + } + } + + DynamoDbIndex gsi1 = table.index("GSI1"); + List results = new ArrayList<>(); + Map lastKey = null; + + for (Page page : gsi1.query(requestBuilder.build())) { + for (NewsArticle article : page.items()) { + results.add(article); + if (results.size() >= limit) break; + } + lastKey = page.lastEvaluatedKey(); + if (results.size() >= limit) break; + } + + String nextCursor = results.size() >= limit ? CursorUtil.encode(lastKey) : null; + return new PaginatedResult<>(results.subList(0, Math.min(results.size(), limit)), nextCursor); + } + + /** + * 조회수 증가 (Atomic Update) + */ + public void incrementReadCount(String date, String articleId) { + Map key = Map.of( + "PK", AttributeValue.builder().s(NewsKey.newsPk(date)).build(), + "SK", AttributeValue.builder().s(NewsKey.articleSk(articleId)).build() + ); + + Map values = Map.of( + ":zero", AttributeValue.builder().n("0").build(), + ":inc", AttributeValue.builder().n("1").build() + ); + + UpdateItemRequest request = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(key) + .updateExpression("SET readCount = if_not_exists(readCount, :zero) + :inc") + .expressionAttributeValues(values) + .build(); + + AwsClients.dynamoDb().updateItem(request); + logger.debug("Incremented read count for article: {}", articleId); + } + + /** + * 댓글수 증가 (Atomic Update) + */ + public void incrementCommentCount(String date, String articleId) { + Map key = Map.of( + "PK", AttributeValue.builder().s(NewsKey.newsPk(date)).build(), + "SK", AttributeValue.builder().s(NewsKey.articleSk(articleId)).build() + ); + + Map values = Map.of( + ":zero", AttributeValue.builder().n("0").build(), + ":inc", AttributeValue.builder().n("1").build() + ); + + UpdateItemRequest request = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(key) + .updateExpression("SET commentCount = if_not_exists(commentCount, :zero) + :inc") + .expressionAttributeValues(values) + .build(); + + AwsClients.dynamoDb().updateItem(request); + } + + /** + * 댓글수 감소 (Atomic Update) + */ + public void decrementCommentCount(String date, String articleId) { + Map key = Map.of( + "PK", AttributeValue.builder().s(NewsKey.newsPk(date)).build(), + "SK", AttributeValue.builder().s(NewsKey.articleSk(articleId)).build() + ); + + Map values = Map.of( + ":one", AttributeValue.builder().n("1").build(), + ":dec", AttributeValue.builder().n("1").build() + ); + + UpdateItemRequest request = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(key) + .updateExpression("SET commentCount = if_not_exists(commentCount, :one) - :dec") + .expressionAttributeValues(values) + .build(); + + AwsClients.dynamoDb().updateItem(request); + } +} diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 09663db9..a4cb6044 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -16,6 +16,7 @@ Globals: CHAT_TABLE_NAME: !Ref ChatTable VOCAB_TABLE_NAME: !Ref VocabTable OPIC_TABLE_NAME: !Ref OPIcTable + NEWS_TABLE_NAME: !Ref NewsTable BUCKET_NAME: group2-englishstudy CHAT_BUCKET_NAME: group2-englishstudy VOCAB_BUCKET_NAME: group2-englishstudy @@ -143,7 +144,47 @@ Resources: AllowMethods: "'GET,POST,PUT,DELETE,OPTIONS'" AllowHeaders: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" AllowOrigin: "'*'" - + AllowCredentials: false + GatewayResponses: + UNAUTHORIZED: + StatusCode: 401 + ResponseParameters: + Headers: + Access-Control-Allow-Origin: "'*'" + Access-Control-Allow-Headers: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" + Access-Control-Allow-Methods: "'GET,POST,PUT,DELETE,OPTIONS'" + ResponseTemplates: + application/json: '{"message": "Unauthorized", "statusCode": 401}' + ACCESS_DENIED: + StatusCode: 403 + ResponseParameters: + Headers: + Access-Control-Allow-Origin: "'*'" + Access-Control-Allow-Headers: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" + Access-Control-Allow-Methods: "'GET,POST,PUT,DELETE,OPTIONS'" + ResponseTemplates: + application/json: '{"message": "Access Denied", "statusCode": 403}' + DEFAULT_4XX: + ResponseParameters: + Headers: + Access-Control-Allow-Origin: "'*'" + Access-Control-Allow-Headers: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" + Access-Control-Allow-Methods: "'GET,POST,PUT,DELETE,OPTIONS'" + DEFAULT_5XX: + ResponseParameters: + Headers: + Access-Control-Allow-Origin: "'*'" + Access-Control-Allow-Headers: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" + Access-Control-Allow-Methods: "'GET,POST,PUT,DELETE,OPTIONS'" + EXPIRED_TOKEN: + StatusCode: 401 + ResponseParameters: + Headers: + Access-Control-Allow-Origin: "'*'" + Access-Control-Allow-Headers: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" + Access-Control-Allow-Methods: "'GET,POST,PUT,DELETE,OPTIONS'" + ResponseTemplates: + application/json: '{"message": "Token expired", "statusCode": 401}' Auth: DefaultAuthorizer: CognitoAuthorizer Authorizers: @@ -1684,6 +1725,50 @@ Resources: AttributeName: ttl Enabled: true + NewsTable: + Type: AWS::DynamoDB::Table + Properties: + TableName: group2-englishstudy-news + BillingMode: PAY_PER_REQUEST + AttributeDefinitions: + - AttributeName: PK + AttributeType: S + - AttributeName: SK + AttributeType: S + - AttributeName: GSI1PK + AttributeType: S + - AttributeName: GSI1SK + AttributeType: S + - AttributeName: GSI2PK + AttributeType: S + - AttributeName: GSI2SK + AttributeType: S + KeySchema: + - AttributeName: PK + KeyType: HASH + - AttributeName: SK + KeyType: RANGE + GlobalSecondaryIndexes: + - IndexName: GSI1 + KeySchema: + - AttributeName: GSI1PK + KeyType: HASH + - AttributeName: GSI1SK + KeyType: RANGE + Projection: + ProjectionType: ALL + - IndexName: GSI2 + KeySchema: + - AttributeName: GSI2PK + KeyType: HASH + - AttributeName: GSI2SK + KeyType: RANGE + Projection: + ProjectionType: ALL + TimeToLiveSpecification: + AttributeName: ttl + Enabled: true + ############################################# # SNS / SQS for Async Statistics Processing ############################################# From ba5e6652a41f9c3e2751334fcef2139bb374d12c Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 13:27:18 +0900 Subject: [PATCH 435/528] =?UTF-8?q?feat(news):=20=EB=89=B4=EC=8A=A4=20?= =?UTF-8?q?=EC=88=98=EC=A7=91=20=ED=8C=8C=EC=9D=B4=ED=94=84=EB=9D=BC?= =?UTF-8?q?=EC=9D=B8=20=EA=B5=AC=ED=98=84=20(#386)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NewsApiClient: NewsAPI 연동 서비스 - RssFeedParser: RSS 피드 파싱 (BBC, VOA, NPR) - NewsDuplicateChecker: URL 기반 중복 필터링 - NewsCollectorService: 수집 오케스트레이션 - NewsCollectionHandler: Lambda 핸들러 - EventBridge 스케줄러: 매일 18시 KST 실행 --- .../domain/news/dto/RawNewsArticle.java | 44 +++++ .../news/handler/NewsCollectionHandler.java | 58 ++++++ .../domain/news/service/NewsApiClient.java | 166 ++++++++++++++++ .../news/service/NewsCollectorService.java | 139 +++++++++++++ .../news/service/NewsDuplicateChecker.java | 94 +++++++++ .../domain/news/service/RssFeedParser.java | 183 ++++++++++++++++++ ServerlessFunction/template.yaml | 27 +++ 7 files changed, 711 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/dto/RawNewsArticle.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsCollectionHandler.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsApiClient.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsCollectorService.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsDuplicateChecker.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/RssFeedParser.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/dto/RawNewsArticle.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/dto/RawNewsArticle.java new file mode 100644 index 00000000..c9902559 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/dto/RawNewsArticle.java @@ -0,0 +1,44 @@ +package com.mzc.secondproject.serverless.domain.news.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 수집된 원본 뉴스 기사 DTO + * NewsAPI, RSS 등에서 수집한 원본 데이터를 담는 객체 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class RawNewsArticle { + + private String title; + private String description; + private String url; + private String imageUrl; + private String source; + private String publishedAt; + private String content; + + /** + * URL 기반 고유 식별자 생성 + */ + public String generateId() { + if (url == null) { + return null; + } + return String.valueOf(url.hashCode()); + } + + /** + * 유효한 기사인지 검증 + */ + public boolean isValid() { + return title != null && !title.isBlank() + && url != null && !url.isBlank() + && source != null && !source.isBlank(); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsCollectionHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsCollectionHandler.java new file mode 100644 index 00000000..b5702a5e --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsCollectionHandler.java @@ -0,0 +1,58 @@ +package com.mzc.secondproject.serverless.domain.news.handler; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.events.ScheduledEvent; +import com.mzc.secondproject.serverless.domain.news.service.NewsCollectorService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Map; + +/** + * 뉴스 수집 Lambda 핸들러 + * EventBridge 스케줄러에 의해 매일 18시에 트리거 + */ +public class NewsCollectionHandler implements RequestHandler> { + + private static final Logger logger = LoggerFactory.getLogger(NewsCollectionHandler.class); + + private final NewsCollectorService collectorService; + + public NewsCollectionHandler() { + this.collectorService = new NewsCollectorService(); + } + + public NewsCollectionHandler(NewsCollectorService collectorService) { + this.collectorService = collectorService; + } + + @Override + public Map handleRequest(ScheduledEvent event, Context context) { + logger.info("뉴스 수집 Lambda 시작 - requestId: {}", context.getAwsRequestId()); + + try { + NewsCollectorService.CollectionResult result = collectorService.collectNews(); + + logger.info("뉴스 수집 완료 - NewsAPI: {}, RSS: {}, 저장: {}, 소요: {}ms", + result.newsApiCount(), result.rssCount(), result.savedCount(), result.elapsedMs()); + + return Map.of( + "statusCode", 200, + "message", "News collection completed", + "newsApiCount", result.newsApiCount(), + "rssCount", result.rssCount(), + "savedCount", result.savedCount(), + "elapsedMs", result.elapsedMs() + ); + + } catch (Exception e) { + logger.error("뉴스 수집 실패", e); + + return Map.of( + "statusCode", 500, + "message", "News collection failed: " + e.getMessage() + ); + } + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsApiClient.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsApiClient.java new file mode 100644 index 00000000..dba1af09 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsApiClient.java @@ -0,0 +1,166 @@ +package com.mzc.secondproject.serverless.domain.news.service; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.domain.news.dto.RawNewsArticle; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.services.ssm.model.GetParameterRequest; + +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + +/** + * NewsAPI 연동 클라이언트 + * 무료 플랜: 100 requests/day, 최대 100 articles/request + */ +public class NewsApiClient { + + private static final Logger logger = LoggerFactory.getLogger(NewsApiClient.class); + private static final String NEWS_API_BASE_URL = "https://newsapi.org/v2"; + private static final String API_KEY_PARAM_NAME = "/englishstudy/news/api-key"; + + private static String cachedApiKey = null; + + private final HttpClient httpClient; + + public NewsApiClient() { + this.httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .build(); + } + + /** + * API Key 조회 (Parameter Store + 캐싱) + */ + private String getApiKey() { + if (cachedApiKey != null) { + return cachedApiKey; + } + + try { + logger.debug("Fetching NewsAPI Key from Parameter Store"); + var response = AwsClients.ssm().getParameter( + GetParameterRequest.builder() + .name(API_KEY_PARAM_NAME) + .withDecryption(true) + .build() + ); + cachedApiKey = response.parameter().value(); + logger.info("NewsAPI Key loaded from Parameter Store"); + return cachedApiKey; + } catch (Exception e) { + logger.error("Failed to get NewsAPI Key from Parameter Store", e); + throw new RuntimeException("NewsAPI Key 로드 실패", e); + } + } + + /** + * 헤드라인 뉴스 조회 + */ + public List getTopHeadlines(String category, int pageSize) { + String url = String.format("%s/top-headlines?language=en&category=%s&pageSize=%d&apiKey=%s", + NEWS_API_BASE_URL, category, pageSize, getApiKey()); + + return fetchArticles(url, "NewsAPI-Headlines"); + } + + /** + * 검색어 기반 뉴스 조회 + */ + public List searchNews(String query, int pageSize) { + String encodedQuery = URLEncoder.encode(query, StandardCharsets.UTF_8); + String url = String.format("%s/everything?q=%s&language=en&sortBy=publishedAt&pageSize=%d&apiKey=%s", + NEWS_API_BASE_URL, encodedQuery, pageSize, getApiKey()); + + return fetchArticles(url, "NewsAPI-Search"); + } + + /** + * 뉴스 API 호출 및 파싱 + */ + private List fetchArticles(String url, String source) { + List articles = new ArrayList<>(); + + try { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .header("Accept", "application/json") + .timeout(Duration.ofSeconds(30)) + .GET() + .build(); + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() != 200) { + logger.error("NewsAPI 요청 실패 - status: {}, body: {}", response.statusCode(), response.body()); + return articles; + } + + JsonObject json = JsonParser.parseString(response.body()).getAsJsonObject(); + String status = json.get("status").getAsString(); + + if (!"ok".equals(status)) { + logger.error("NewsAPI 응답 오류 - status: {}", status); + return articles; + } + + JsonArray articlesArray = json.getAsJsonArray("articles"); + for (JsonElement element : articlesArray) { + JsonObject articleJson = element.getAsJsonObject(); + RawNewsArticle article = parseArticle(articleJson, source); + if (article.isValid()) { + articles.add(article); + } + } + + logger.info("NewsAPI에서 {}개 기사 수집 완료", articles.size()); + + } catch (Exception e) { + logger.error("NewsAPI 호출 중 오류 발생", e); + } + + return articles; + } + + /** + * JSON을 RawNewsArticle로 변환 + */ + private RawNewsArticle parseArticle(JsonObject json, String defaultSource) { + String sourceName = defaultSource; + if (json.has("source") && json.get("source").isJsonObject()) { + JsonObject sourceObj = json.getAsJsonObject("source"); + if (sourceObj.has("name") && !sourceObj.get("name").isJsonNull()) { + sourceName = sourceObj.get("name").getAsString(); + } + } + + return RawNewsArticle.builder() + .title(getStringOrNull(json, "title")) + .description(getStringOrNull(json, "description")) + .url(getStringOrNull(json, "url")) + .imageUrl(getStringOrNull(json, "urlToImage")) + .source(sourceName) + .publishedAt(getStringOrNull(json, "publishedAt")) + .content(getStringOrNull(json, "content")) + .build(); + } + + private String getStringOrNull(JsonObject json, String key) { + if (json.has(key) && !json.get(key).isJsonNull()) { + return json.get(key).getAsString(); + } + return null; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsCollectorService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsCollectorService.java new file mode 100644 index 00000000..e46bb6ef --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsCollectorService.java @@ -0,0 +1,139 @@ +package com.mzc.secondproject.serverless.domain.news.service; + +import com.mzc.secondproject.serverless.domain.news.constants.NewsKey; +import com.mzc.secondproject.serverless.domain.news.dto.RawNewsArticle; +import com.mzc.secondproject.serverless.domain.news.model.NewsArticle; +import com.mzc.secondproject.serverless.domain.news.repository.NewsArticleRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +/** + * 뉴스 수집 서비스 + * NewsAPI, RSS 피드에서 뉴스를 수집하고 저장 + */ +public class NewsCollectorService { + + private static final Logger logger = LoggerFactory.getLogger(NewsCollectorService.class); + + private static final int NEWS_API_LIMIT = 10; + private static final int RSS_LIMIT_PER_SOURCE = 5; + private static final long TTL_DAYS = 30; + + private final NewsApiClient newsApiClient; + private final RssFeedParser rssFeedParser; + private final NewsDuplicateChecker duplicateChecker; + private final NewsArticleRepository articleRepository; + + public NewsCollectorService() { + this.newsApiClient = new NewsApiClient(); + this.rssFeedParser = new RssFeedParser(); + this.duplicateChecker = new NewsDuplicateChecker(); + this.articleRepository = new NewsArticleRepository(); + } + + public NewsCollectorService(NewsApiClient newsApiClient, RssFeedParser rssFeedParser, + NewsDuplicateChecker duplicateChecker, NewsArticleRepository articleRepository) { + this.newsApiClient = newsApiClient; + this.rssFeedParser = rssFeedParser; + this.duplicateChecker = duplicateChecker; + this.articleRepository = articleRepository; + } + + /** + * 뉴스 수집 실행 + */ + public CollectionResult collectNews() { + logger.info("뉴스 수집 시작"); + long startTime = System.currentTimeMillis(); + + List allArticles = new ArrayList<>(); + int newsApiCount = 0; + int rssCount = 0; + + try { + List newsApiArticles = newsApiClient.getTopHeadlines("technology", NEWS_API_LIMIT); + allArticles.addAll(newsApiArticles); + newsApiCount = newsApiArticles.size(); + logger.info("NewsAPI에서 {}개 수집", newsApiCount); + } catch (Exception e) { + logger.error("NewsAPI 수집 실패", e); + } + + try { + List rssArticles = rssFeedParser.fetchAllFeeds(RSS_LIMIT_PER_SOURCE); + allArticles.addAll(rssArticles); + rssCount = rssArticles.size(); + logger.info("RSS에서 {}개 수집", rssCount); + } catch (Exception e) { + logger.error("RSS 수집 실패", e); + } + + List uniqueArticles = duplicateChecker.filterDuplicates(allArticles); + logger.info("중복 제거 후 {}개 기사", uniqueArticles.size()); + + int savedCount = 0; + for (RawNewsArticle rawArticle : uniqueArticles) { + try { + NewsArticle article = convertToNewsArticle(rawArticle); + articleRepository.save(article); + savedCount++; + } catch (Exception e) { + logger.error("기사 저장 실패: {}", rawArticle.getTitle(), e); + } + } + + long elapsed = System.currentTimeMillis() - startTime; + logger.info("뉴스 수집 완료 - 저장: {}, 소요시간: {}ms", savedCount, elapsed); + + return new CollectionResult(newsApiCount, rssCount, savedCount, elapsed); + } + + /** + * RawNewsArticle을 NewsArticle로 변환 + * AI 분석은 별도 Story에서 처리 + */ + private NewsArticle convertToNewsArticle(RawNewsArticle raw) { + String today = LocalDate.now().toString(); + String articleId = UUID.randomUUID().toString().substring(0, 8); + String now = Instant.now().toString(); + + long ttlEpoch = Instant.now() + .atOffset(ZoneOffset.UTC) + .plusDays(TTL_DAYS) + .toEpochSecond(); + + return NewsArticle.builder() + .pk(NewsKey.newsPk(today)) + .sk(NewsKey.articleSk(articleId)) + .articleId(articleId) + .title(raw.getTitle()) + .summary(raw.getDescription()) + .originalUrl(raw.getUrl()) + .source(raw.getSource()) + .imageUrl(raw.getImageUrl()) + .publishedAt(raw.getPublishedAt() != null ? raw.getPublishedAt() : now) + .collectedAt(now) + .readCount(0L) + .commentCount(0L) + .ttl(ttlEpoch) + .build(); + } + + /** + * 수집 결과 레코드 + */ + public record CollectionResult( + int newsApiCount, + int rssCount, + int savedCount, + long elapsedMs + ) { + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsDuplicateChecker.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsDuplicateChecker.java new file mode 100644 index 00000000..d4eedd82 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsDuplicateChecker.java @@ -0,0 +1,94 @@ +package com.mzc.secondproject.serverless.domain.news.service; + +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.config.EnvConfig; +import com.mzc.secondproject.serverless.domain.news.constants.NewsKey; +import com.mzc.secondproject.serverless.domain.news.dto.RawNewsArticle; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; +import software.amazon.awssdk.services.dynamodb.model.QueryRequest; +import software.amazon.awssdk.services.dynamodb.model.QueryResponse; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 뉴스 중복 검사 서비스 + * URL 기반으로 중복 뉴스 필터링 + */ +public class NewsDuplicateChecker { + + private static final Logger logger = LoggerFactory.getLogger(NewsDuplicateChecker.class); + private static final String TABLE_NAME = EnvConfig.getRequired("NEWS_TABLE_NAME"); + + /** + * 중복 뉴스 필터링 + */ + public List filterDuplicates(List articles) { + if (articles.isEmpty()) { + return articles; + } + + Set existingUrls = getExistingUrls(); + Set seenUrls = new HashSet<>(); + List uniqueArticles = new ArrayList<>(); + + for (RawNewsArticle article : articles) { + String url = article.getUrl(); + if (url == null) { + continue; + } + + if (!existingUrls.contains(url) && !seenUrls.contains(url)) { + uniqueArticles.add(article); + seenUrls.add(url); + } + } + + int duplicateCount = articles.size() - uniqueArticles.size(); + if (duplicateCount > 0) { + logger.info("{}개 중복 기사 필터링됨", duplicateCount); + } + + return uniqueArticles; + } + + /** + * 오늘 날짜의 기존 뉴스 URL 조회 + */ + private Set getExistingUrls() { + Set urls = new HashSet<>(); + String today = LocalDate.now().toString(); + + try { + QueryRequest request = QueryRequest.builder() + .tableName(TABLE_NAME) + .keyConditionExpression("PK = :pk") + .expressionAttributeValues(Map.of( + ":pk", AttributeValue.builder().s(NewsKey.newsPk(today)).build() + )) + .projectionExpression("originalUrl") + .build(); + + QueryResponse response = AwsClients.dynamoDb().query(request); + + for (Map item : response.items()) { + if (item.containsKey("originalUrl")) { + urls.add(item.get("originalUrl").s()); + } + } + + logger.debug("기존 뉴스 {}개 URL 로드됨", urls.size()); + + } catch (Exception e) { + logger.error("기존 뉴스 URL 조회 실패", e); + } + + return urls; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/RssFeedParser.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/RssFeedParser.java new file mode 100644 index 00000000..ca2c98b8 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/RssFeedParser.java @@ -0,0 +1,183 @@ +package com.mzc.secondproject.serverless.domain.news.service; + +import com.mzc.secondproject.serverless.domain.news.dto.RawNewsArticle; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NodeList; + +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import java.io.InputStream; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * RSS 피드 파싱 서비스 + * BBC, VOA, NPR 등의 RSS 피드에서 뉴스 수집 + */ +public class RssFeedParser { + + private static final Logger logger = LoggerFactory.getLogger(RssFeedParser.class); + + private static final Map RSS_FEEDS = Map.of( + "BBC", "https://feeds.bbci.co.uk/news/world/rss.xml", + "VOA", "https://www.voanews.com/api/ziqpoe-mqm", + "NPR", "https://feeds.npr.org/1001/rss.xml" + ); + + private final HttpClient httpClient; + + public RssFeedParser() { + this.httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .followRedirects(HttpClient.Redirect.NORMAL) + .build(); + } + + /** + * 모든 RSS 피드에서 뉴스 수집 + */ + public List fetchAllFeeds(int maxPerSource) { + List allArticles = new ArrayList<>(); + + for (Map.Entry entry : RSS_FEEDS.entrySet()) { + String source = entry.getKey(); + String feedUrl = entry.getValue(); + + try { + List articles = fetchFeed(feedUrl, source, maxPerSource); + allArticles.addAll(articles); + logger.info("{}에서 {}개 기사 수집", source, articles.size()); + } catch (Exception e) { + logger.error("{} RSS 피드 수집 실패: {}", source, e.getMessage()); + } + } + + return allArticles; + } + + /** + * 특정 RSS 피드에서 뉴스 수집 + */ + public List fetchFeed(String feedUrl, String source, int maxItems) { + List articles = new ArrayList<>(); + + try { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(feedUrl)) + .header("User-Agent", "Mozilla/5.0 (compatible; NewsBot/1.0)") + .timeout(Duration.ofSeconds(30)) + .GET() + .build(); + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream()); + + if (response.statusCode() != 200) { + logger.error("RSS 피드 요청 실패 - url: {}, status: {}", feedUrl, response.statusCode()); + return articles; + } + + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + DocumentBuilder builder = factory.newDocumentBuilder(); + Document document = builder.parse(response.body()); + + NodeList items = document.getElementsByTagName("item"); + int count = Math.min(items.getLength(), maxItems); + + for (int i = 0; i < count; i++) { + Element item = (Element) items.item(i); + RawNewsArticle article = parseRssItem(item, source); + if (article.isValid()) { + articles.add(article); + } + } + + } catch (Exception e) { + logger.error("RSS 피드 파싱 중 오류 발생 - url: {}", feedUrl, e); + } + + return articles; + } + + /** + * RSS item 요소를 RawNewsArticle로 변환 + */ + private RawNewsArticle parseRssItem(Element item, String source) { + return RawNewsArticle.builder() + .title(getElementText(item, "title")) + .description(cleanHtml(getElementText(item, "description"))) + .url(getElementText(item, "link")) + .imageUrl(extractImageUrl(item)) + .source(source) + .publishedAt(parsePublishedDate(getElementText(item, "pubDate"))) + .build(); + } + + /** + * 요소에서 텍스트 추출 + */ + private String getElementText(Element parent, String tagName) { + NodeList nodes = parent.getElementsByTagName(tagName); + if (nodes.getLength() > 0) { + return nodes.item(0).getTextContent().trim(); + } + return null; + } + + /** + * 이미지 URL 추출 (media:content, enclosure 등) + */ + private String extractImageUrl(Element item) { + NodeList mediaContent = item.getElementsByTagName("media:content"); + if (mediaContent.getLength() > 0) { + Element media = (Element) mediaContent.item(0); + return media.getAttribute("url"); + } + + NodeList enclosure = item.getElementsByTagName("enclosure"); + if (enclosure.getLength() > 0) { + Element enc = (Element) enclosure.item(0); + String type = enc.getAttribute("type"); + if (type != null && type.startsWith("image/")) { + return enc.getAttribute("url"); + } + } + + NodeList mediaThumbnail = item.getElementsByTagName("media:thumbnail"); + if (mediaThumbnail.getLength() > 0) { + Element thumbnail = (Element) mediaThumbnail.item(0); + return thumbnail.getAttribute("url"); + } + + return null; + } + + /** + * RSS pubDate를 ISO 8601 형식으로 변환 + */ + private String parsePublishedDate(String pubDate) { + if (pubDate == null || pubDate.isBlank()) { + return null; + } + return pubDate; + } + + /** + * HTML 태그 제거 + */ + private String cleanHtml(String html) { + if (html == null) { + return null; + } + return html.replaceAll("<[^>]*>", "").trim(); + } +} diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index a4cb6044..7828e7a0 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -1725,6 +1725,33 @@ Resources: AttributeName: ttl Enabled: true + ############################################# + # News Collection Scheduled Lambda + ############################################# + + NewsCollectionFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: !Sub "${AWS::StackName}-news-collection" + CodeUri: . + Handler: com.mzc.secondproject.serverless.domain.news.handler.NewsCollectionHandler::handleRequest + Description: 매일 18시에 영어 뉴스를 수집하는 Lambda + MemorySize: 512 + Timeout: 300 + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref NewsTable + - SSMParameterReadPolicy: + ParameterName: englishstudy/news/* + Events: + DailySchedule: + Type: Schedule + Properties: + Schedule: cron(0 9 * * ? *) + Name: news-collection-daily-schedule + Description: 매일 18시 KST (09:00 UTC)에 뉴스 수집 + Enabled: true + NewsTable: Type: AWS::DynamoDB::Table Properties: From 231e82faccd204f80806a1f61851f9f27c36e81f Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 13:30:20 +0900 Subject: [PATCH 436/528] =?UTF-8?q?refactor(news):=20NewsAPI=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0,=20RSS=EB=A7=8C=20=EC=82=AC=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NewsApiClient 삭제 - SSM Parameter 의존성 제거 - RSS 소스당 7개씩 수집 (BBC, VOA, NPR = 약 21개/일) --- .../news/handler/NewsCollectionHandler.java | 7 +- .../domain/news/service/NewsApiClient.java | 166 ------------------ .../news/service/NewsCollectorService.java | 42 ++--- ServerlessFunction/template.yaml | 2 - 4 files changed, 15 insertions(+), 202 deletions(-) delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsApiClient.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsCollectionHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsCollectionHandler.java index b5702a5e..4d17463f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsCollectionHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsCollectionHandler.java @@ -34,14 +34,13 @@ public Map handleRequest(ScheduledEvent event, Context context) try { NewsCollectorService.CollectionResult result = collectorService.collectNews(); - logger.info("뉴스 수집 완료 - NewsAPI: {}, RSS: {}, 저장: {}, 소요: {}ms", - result.newsApiCount(), result.rssCount(), result.savedCount(), result.elapsedMs()); + logger.info("뉴스 수집 완료 - 수집: {}, 저장: {}, 소요: {}ms", + result.collectedCount(), result.savedCount(), result.elapsedMs()); return Map.of( "statusCode", 200, "message", "News collection completed", - "newsApiCount", result.newsApiCount(), - "rssCount", result.rssCount(), + "collectedCount", result.collectedCount(), "savedCount", result.savedCount(), "elapsedMs", result.elapsedMs() ); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsApiClient.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsApiClient.java deleted file mode 100644 index dba1af09..00000000 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsApiClient.java +++ /dev/null @@ -1,166 +0,0 @@ -package com.mzc.secondproject.serverless.domain.news.service; - -import com.google.gson.Gson; -import com.google.gson.JsonArray; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; -import com.mzc.secondproject.serverless.common.config.AwsClients; -import com.mzc.secondproject.serverless.domain.news.dto.RawNewsArticle; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import software.amazon.awssdk.services.ssm.model.GetParameterRequest; - -import java.net.URI; -import java.net.URLEncoder; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.nio.charset.StandardCharsets; -import java.time.Duration; -import java.util.ArrayList; -import java.util.List; - -/** - * NewsAPI 연동 클라이언트 - * 무료 플랜: 100 requests/day, 최대 100 articles/request - */ -public class NewsApiClient { - - private static final Logger logger = LoggerFactory.getLogger(NewsApiClient.class); - private static final String NEWS_API_BASE_URL = "https://newsapi.org/v2"; - private static final String API_KEY_PARAM_NAME = "/englishstudy/news/api-key"; - - private static String cachedApiKey = null; - - private final HttpClient httpClient; - - public NewsApiClient() { - this.httpClient = HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(10)) - .build(); - } - - /** - * API Key 조회 (Parameter Store + 캐싱) - */ - private String getApiKey() { - if (cachedApiKey != null) { - return cachedApiKey; - } - - try { - logger.debug("Fetching NewsAPI Key from Parameter Store"); - var response = AwsClients.ssm().getParameter( - GetParameterRequest.builder() - .name(API_KEY_PARAM_NAME) - .withDecryption(true) - .build() - ); - cachedApiKey = response.parameter().value(); - logger.info("NewsAPI Key loaded from Parameter Store"); - return cachedApiKey; - } catch (Exception e) { - logger.error("Failed to get NewsAPI Key from Parameter Store", e); - throw new RuntimeException("NewsAPI Key 로드 실패", e); - } - } - - /** - * 헤드라인 뉴스 조회 - */ - public List getTopHeadlines(String category, int pageSize) { - String url = String.format("%s/top-headlines?language=en&category=%s&pageSize=%d&apiKey=%s", - NEWS_API_BASE_URL, category, pageSize, getApiKey()); - - return fetchArticles(url, "NewsAPI-Headlines"); - } - - /** - * 검색어 기반 뉴스 조회 - */ - public List searchNews(String query, int pageSize) { - String encodedQuery = URLEncoder.encode(query, StandardCharsets.UTF_8); - String url = String.format("%s/everything?q=%s&language=en&sortBy=publishedAt&pageSize=%d&apiKey=%s", - NEWS_API_BASE_URL, encodedQuery, pageSize, getApiKey()); - - return fetchArticles(url, "NewsAPI-Search"); - } - - /** - * 뉴스 API 호출 및 파싱 - */ - private List fetchArticles(String url, String source) { - List articles = new ArrayList<>(); - - try { - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(url)) - .header("Accept", "application/json") - .timeout(Duration.ofSeconds(30)) - .GET() - .build(); - - HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - - if (response.statusCode() != 200) { - logger.error("NewsAPI 요청 실패 - status: {}, body: {}", response.statusCode(), response.body()); - return articles; - } - - JsonObject json = JsonParser.parseString(response.body()).getAsJsonObject(); - String status = json.get("status").getAsString(); - - if (!"ok".equals(status)) { - logger.error("NewsAPI 응답 오류 - status: {}", status); - return articles; - } - - JsonArray articlesArray = json.getAsJsonArray("articles"); - for (JsonElement element : articlesArray) { - JsonObject articleJson = element.getAsJsonObject(); - RawNewsArticle article = parseArticle(articleJson, source); - if (article.isValid()) { - articles.add(article); - } - } - - logger.info("NewsAPI에서 {}개 기사 수집 완료", articles.size()); - - } catch (Exception e) { - logger.error("NewsAPI 호출 중 오류 발생", e); - } - - return articles; - } - - /** - * JSON을 RawNewsArticle로 변환 - */ - private RawNewsArticle parseArticle(JsonObject json, String defaultSource) { - String sourceName = defaultSource; - if (json.has("source") && json.get("source").isJsonObject()) { - JsonObject sourceObj = json.getAsJsonObject("source"); - if (sourceObj.has("name") && !sourceObj.get("name").isJsonNull()) { - sourceName = sourceObj.get("name").getAsString(); - } - } - - return RawNewsArticle.builder() - .title(getStringOrNull(json, "title")) - .description(getStringOrNull(json, "description")) - .url(getStringOrNull(json, "url")) - .imageUrl(getStringOrNull(json, "urlToImage")) - .source(sourceName) - .publishedAt(getStringOrNull(json, "publishedAt")) - .content(getStringOrNull(json, "content")) - .build(); - } - - private String getStringOrNull(JsonObject json, String key) { - if (json.has(key) && !json.get(key).isJsonNull()) { - return json.get(key).getAsString(); - } - return null; - } -} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsCollectorService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsCollectorService.java index e46bb6ef..9842f4b3 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsCollectorService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsCollectorService.java @@ -10,37 +10,33 @@ import java.time.Instant; import java.time.LocalDate; import java.time.ZoneOffset; -import java.util.ArrayList; import java.util.List; import java.util.UUID; /** * 뉴스 수집 서비스 - * NewsAPI, RSS 피드에서 뉴스를 수집하고 저장 + * RSS 피드에서 뉴스를 수집하고 저장 (BBC, VOA, NPR) */ public class NewsCollectorService { private static final Logger logger = LoggerFactory.getLogger(NewsCollectorService.class); - private static final int NEWS_API_LIMIT = 10; - private static final int RSS_LIMIT_PER_SOURCE = 5; + private static final int RSS_LIMIT_PER_SOURCE = 7; private static final long TTL_DAYS = 30; - private final NewsApiClient newsApiClient; private final RssFeedParser rssFeedParser; private final NewsDuplicateChecker duplicateChecker; private final NewsArticleRepository articleRepository; public NewsCollectorService() { - this.newsApiClient = new NewsApiClient(); this.rssFeedParser = new RssFeedParser(); this.duplicateChecker = new NewsDuplicateChecker(); this.articleRepository = new NewsArticleRepository(); } - public NewsCollectorService(NewsApiClient newsApiClient, RssFeedParser rssFeedParser, - NewsDuplicateChecker duplicateChecker, NewsArticleRepository articleRepository) { - this.newsApiClient = newsApiClient; + public NewsCollectorService(RssFeedParser rssFeedParser, + NewsDuplicateChecker duplicateChecker, + NewsArticleRepository articleRepository) { this.rssFeedParser = rssFeedParser; this.duplicateChecker = duplicateChecker; this.articleRepository = articleRepository; @@ -53,29 +49,16 @@ public CollectionResult collectNews() { logger.info("뉴스 수집 시작"); long startTime = System.currentTimeMillis(); - List allArticles = new ArrayList<>(); - int newsApiCount = 0; - int rssCount = 0; - - try { - List newsApiArticles = newsApiClient.getTopHeadlines("technology", NEWS_API_LIMIT); - allArticles.addAll(newsApiArticles); - newsApiCount = newsApiArticles.size(); - logger.info("NewsAPI에서 {}개 수집", newsApiCount); - } catch (Exception e) { - logger.error("NewsAPI 수집 실패", e); - } - + List rssArticles; try { - List rssArticles = rssFeedParser.fetchAllFeeds(RSS_LIMIT_PER_SOURCE); - allArticles.addAll(rssArticles); - rssCount = rssArticles.size(); - logger.info("RSS에서 {}개 수집", rssCount); + rssArticles = rssFeedParser.fetchAllFeeds(RSS_LIMIT_PER_SOURCE); + logger.info("RSS에서 {}개 수집", rssArticles.size()); } catch (Exception e) { logger.error("RSS 수집 실패", e); + return new CollectionResult(0, 0, System.currentTimeMillis() - startTime); } - List uniqueArticles = duplicateChecker.filterDuplicates(allArticles); + List uniqueArticles = duplicateChecker.filterDuplicates(rssArticles); logger.info("중복 제거 후 {}개 기사", uniqueArticles.size()); int savedCount = 0; @@ -92,7 +75,7 @@ public CollectionResult collectNews() { long elapsed = System.currentTimeMillis() - startTime; logger.info("뉴스 수집 완료 - 저장: {}, 소요시간: {}ms", savedCount, elapsed); - return new CollectionResult(newsApiCount, rssCount, savedCount, elapsed); + return new CollectionResult(rssArticles.size(), savedCount, elapsed); } /** @@ -130,8 +113,7 @@ private NewsArticle convertToNewsArticle(RawNewsArticle raw) { * 수집 결과 레코드 */ public record CollectionResult( - int newsApiCount, - int rssCount, + int collectedCount, int savedCount, long elapsedMs ) { diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 7828e7a0..552cb8e9 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -1741,8 +1741,6 @@ Resources: Policies: - DynamoDBCrudPolicy: TableName: !Ref NewsTable - - SSMParameterReadPolicy: - ParameterName: englishstudy/news/* Events: DailySchedule: Type: Schedule From 2c7b2d8684de724085fec86be2de5fed629148e2 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 13:38:24 +0900 Subject: [PATCH 437/528] =?UTF-8?q?feat(news):=20AI=20=EB=89=B4=EC=8A=A4?= =?UTF-8?q?=20=EB=B6=84=EC=84=9D=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#387)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NewsAnalysisService: AI 분석 통합 서비스 - Bedrock: CEFR 난이도 분석 (A1~C2) - Bedrock: 3줄 요약 + 퀴즈 3문제 생성 - Comprehend: 핵심 키워드 추출 - NewsCollectorService: 수집 시 자동 분석 연동 - GSI1/GSI2 키 자동 설정 (레벨별, 카테고리별 조회) --- .../news/service/NewsAnalysisService.java | 321 ++++++++++++++++++ .../news/service/NewsCollectorService.java | 16 +- 2 files changed, 333 insertions(+), 4 deletions(-) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java new file mode 100644 index 00000000..af23fc5b --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java @@ -0,0 +1,321 @@ +package com.mzc.secondproject.serverless.domain.news.service; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.domain.news.model.KeywordInfo; +import com.mzc.secondproject.serverless.domain.news.model.NewsArticle; +import com.mzc.secondproject.serverless.domain.news.model.QuizQuestion; +import com.mzc.secondproject.serverless.domain.news.repository.NewsArticleRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.core.SdkBytes; +import software.amazon.awssdk.services.bedrockruntime.model.InvokeModelRequest; +import software.amazon.awssdk.services.bedrockruntime.model.InvokeModelResponse; +import software.amazon.awssdk.services.comprehend.model.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 뉴스 AI 분석 서비스 + * - CEFR 난이도 분석 (Bedrock) + * - 3줄 요약 생성 (Bedrock) + * - 핵심 단어 추출 (Comprehend) + * - 퀴즈 생성 (Bedrock) + */ +public class NewsAnalysisService { + + private static final Logger logger = LoggerFactory.getLogger(NewsAnalysisService.class); + private static final Gson gson = new Gson(); + private static final String MODEL_ID = "anthropic.claude-3-haiku-20240307-v1:0"; + + private final NewsArticleRepository articleRepository; + + public NewsAnalysisService() { + this.articleRepository = new NewsArticleRepository(); + } + + public NewsAnalysisService(NewsArticleRepository articleRepository) { + this.articleRepository = articleRepository; + } + + /** + * 뉴스 기사 전체 분석 + */ + public NewsArticle analyzeArticle(NewsArticle article) { + logger.info("뉴스 분석 시작: {}", article.getArticleId()); + long startTime = System.currentTimeMillis(); + + String content = article.getTitle() + ". " + + (article.getSummary() != null ? article.getSummary() : ""); + + try { + // 1. CEFR 난이도 분석 + String cefrLevel = analyzeDifficulty(content); + article.setCefrLevel(cefrLevel); + article.setLevel(mapCefrToLevel(cefrLevel)); + + // 2. 핵심 단어 추출 (Comprehend) + List keywords = extractKeywords(content); + article.setKeywords(keywords); + + // 3. 3줄 요약 + 퀴즈 생성 (Bedrock - 한 번에 처리) + AnalysisResult result = generateSummaryAndQuiz(content, cefrLevel); + if (result.summary() != null) { + article.setSummary(result.summary()); + } + article.setQuiz(result.quiz()); + article.setHighlightWords(result.highlightWords()); + + // 4. GSI 키 설정 + article.setGsi1pk("LEVEL#" + article.getLevel()); + article.setGsi1sk(article.getPublishedAt()); + if (article.getCategory() != null) { + article.setGsi2pk("CATEGORY#" + article.getCategory()); + article.setGsi2sk(article.getPublishedAt()); + } + + // 5. 저장 + articleRepository.save(article); + + long elapsed = System.currentTimeMillis() - startTime; + logger.info("뉴스 분석 완료: {} ({}ms)", article.getArticleId(), elapsed); + + } catch (Exception e) { + logger.error("뉴스 분석 실패: {}", article.getArticleId(), e); + // 분석 실패해도 기본값으로 저장 + article.setLevel("INTERMEDIATE"); + article.setCefrLevel("B1"); + articleRepository.save(article); + } + + return article; + } + + /** + * CEFR 난이도 분석 (Bedrock) + */ + private String analyzeDifficulty(String content) { + String systemPrompt = """ + You are an English language expert. Analyze the text and determine its CEFR level. + Consider vocabulary complexity, sentence structure, and topic familiarity. + + Respond with ONLY the CEFR level code: A1, A2, B1, B2, C1, or C2 + No explanation, just the level code. + """; + + String userPrompt = "Determine the CEFR level of this text:\n\n" + truncate(content, 1000); + + String response = invokeBedrock(systemPrompt, userPrompt); + String level = response.trim().toUpperCase(); + + // 유효한 레벨인지 확인 + if (List.of("A1", "A2", "B1", "B2", "C1", "C2").contains(level)) { + return level; + } + + // 레벨 추출 시도 + for (String validLevel : List.of("C2", "C1", "B2", "B1", "A2", "A1")) { + if (response.toUpperCase().contains(validLevel)) { + return validLevel; + } + } + + return "B1"; // 기본값 + } + + /** + * CEFR을 3단계 레벨로 매핑 + */ + private String mapCefrToLevel(String cefrLevel) { + return switch (cefrLevel) { + case "A1", "A2" -> "BEGINNER"; + case "B1", "B2" -> "INTERMEDIATE"; + case "C1", "C2" -> "ADVANCED"; + default -> "INTERMEDIATE"; + }; + } + + /** + * 핵심 단어 추출 (Comprehend) + */ + private List extractKeywords(String content) { + try { + DetectKeyPhrasesResponse response = AwsClients.comprehend().detectKeyPhrases( + DetectKeyPhrasesRequest.builder() + .text(truncate(content, 5000)) + .languageCode(LanguageCode.EN) + .build() + ); + + List keywords = new ArrayList<>(); + List phrases = response.keyPhrases(); + + for (int i = 0; i < Math.min(phrases.size(), 10); i++) { + KeyPhrase phrase = phrases.get(i); + if (phrase.score() > 0.8) { + keywords.add(KeywordInfo.builder() + .word(phrase.text()) + .position(i) + .build()); + } + } + + return keywords; + + } catch (Exception e) { + logger.error("키워드 추출 실패", e); + return new ArrayList<>(); + } + } + + /** + * 요약 + 퀴즈 생성 (Bedrock) + */ + private AnalysisResult generateSummaryAndQuiz(String content, String cefrLevel) { + String systemPrompt = """ + You are an English learning assistant. Analyze the news article and create learning materials. + + Respond in this exact JSON format: + { + "summary": "3-line summary in English (each line separated by newline)", + "highlightWords": ["word1", "word2", "word3"], + "quiz": [ + { + "questionId": "q1", + "type": "COMPREHENSION", + "question": "What is the main topic of this article?", + "options": ["Option A", "Option B", "Option C", "Option D"], + "correctAnswer": "Option A", + "points": 20 + }, + { + "questionId": "q2", + "type": "WORD_MATCH", + "question": "What does 'X' mean in this context?", + "options": ["meaning1", "meaning2", "meaning3", "meaning4"], + "correctAnswer": "meaning1", + "points": 15 + }, + { + "questionId": "q3", + "type": "FILL_BLANK", + "question": "The article mentions that _____ is important.", + "options": ["word1", "word2", "word3", "word4"], + "correctAnswer": "word1", + "points": 30 + } + ] + } + + Create exactly 3 quiz questions. + highlightWords should contain 3-5 difficult words for learners. + Adjust difficulty based on CEFR level: """ + cefrLevel; + + String userPrompt = "Create learning materials for this article:\n\n" + truncate(content, 1500); + + try { + String response = invokeBedrock(systemPrompt, userPrompt); + return parseAnalysisResult(response); + } catch (Exception e) { + logger.error("요약/퀴즈 생성 실패", e); + return new AnalysisResult(null, new ArrayList<>(), new ArrayList<>()); + } + } + + /** + * Bedrock API 호출 + */ + private String invokeBedrock(String systemPrompt, String userPrompt) { + JsonObject requestBody = new JsonObject(); + requestBody.addProperty("anthropic_version", "bedrock-2023-05-31"); + requestBody.addProperty("max_tokens", 2000); + requestBody.addProperty("system", systemPrompt); + + JsonArray messages = new JsonArray(); + JsonObject userMessage = new JsonObject(); + userMessage.addProperty("role", "user"); + userMessage.addProperty("content", userPrompt); + messages.add(userMessage); + requestBody.add("messages", messages); + + InvokeModelRequest request = InvokeModelRequest.builder() + .modelId(MODEL_ID) + .contentType("application/json") + .accept("application/json") + .body(SdkBytes.fromUtf8String(gson.toJson(requestBody))) + .build(); + + InvokeModelResponse response = AwsClients.bedrock().invokeModel(request); + JsonObject jsonResponse = gson.fromJson(response.body().asUtf8String(), JsonObject.class); + + JsonArray contentArray = jsonResponse.getAsJsonArray("content"); + if (contentArray != null && !contentArray.isEmpty()) { + return contentArray.get(0).getAsJsonObject().get("text").getAsString(); + } + + throw new RuntimeException("Empty response from Bedrock"); + } + + /** + * 분석 결과 파싱 + */ + private AnalysisResult parseAnalysisResult(String response) { + String jsonStr = extractJson(response); + JsonObject json = gson.fromJson(jsonStr, JsonObject.class); + + String summary = json.has("summary") ? json.get("summary").getAsString() : null; + + List highlightWords = new ArrayList<>(); + if (json.has("highlightWords")) { + json.getAsJsonArray("highlightWords").forEach(e -> highlightWords.add(e.getAsString())); + } + + List quiz = new ArrayList<>(); + if (json.has("quiz")) { + json.getAsJsonArray("quiz").forEach(e -> { + JsonObject q = e.getAsJsonObject(); + List options = new ArrayList<>(); + if (q.has("options")) { + q.getAsJsonArray("options").forEach(opt -> options.add(opt.getAsString())); + } + quiz.add(QuizQuestion.builder() + .questionId(q.has("questionId") ? q.get("questionId").getAsString() : null) + .type(q.has("type") ? q.get("type").getAsString() : "COMPREHENSION") + .question(q.has("question") ? q.get("question").getAsString() : "") + .options(options) + .correctAnswer(q.has("correctAnswer") ? q.get("correctAnswer").getAsString() : "") + .points(q.has("points") ? q.get("points").getAsInt() : 20) + .build()); + }); + } + + return new AnalysisResult(summary, highlightWords, quiz); + } + + private String extractJson(String response) { + int start = response.indexOf('{'); + int end = response.lastIndexOf('}'); + if (start != -1 && end != -1 && end > start) { + return response.substring(start, end + 1); + } + return response; + } + + private String truncate(String text, int maxLength) { + if (text == null) return ""; + return text.length() > maxLength ? text.substring(0, maxLength) : text; + } + + /** + * 분석 결과 레코드 + */ + private record AnalysisResult( + String summary, + List highlightWords, + List quiz + ) {} +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsCollectorService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsCollectorService.java index 9842f4b3..ecac47df 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsCollectorService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsCollectorService.java @@ -27,19 +27,23 @@ public class NewsCollectorService { private final RssFeedParser rssFeedParser; private final NewsDuplicateChecker duplicateChecker; private final NewsArticleRepository articleRepository; + private final NewsAnalysisService analysisService; public NewsCollectorService() { this.rssFeedParser = new RssFeedParser(); this.duplicateChecker = new NewsDuplicateChecker(); this.articleRepository = new NewsArticleRepository(); + this.analysisService = new NewsAnalysisService(); } public NewsCollectorService(RssFeedParser rssFeedParser, NewsDuplicateChecker duplicateChecker, - NewsArticleRepository articleRepository) { + NewsArticleRepository articleRepository, + NewsAnalysisService analysisService) { this.rssFeedParser = rssFeedParser; this.duplicateChecker = duplicateChecker; this.articleRepository = articleRepository; + this.analysisService = analysisService; } /** @@ -62,18 +66,22 @@ public CollectionResult collectNews() { logger.info("중복 제거 후 {}개 기사", uniqueArticles.size()); int savedCount = 0; + int analyzedCount = 0; for (RawNewsArticle rawArticle : uniqueArticles) { try { NewsArticle article = convertToNewsArticle(rawArticle); - articleRepository.save(article); + + // AI 분석 수행 (난이도, 요약, 키워드, 퀴즈) + analysisService.analyzeArticle(article); + analyzedCount++; savedCount++; } catch (Exception e) { - logger.error("기사 저장 실패: {}", rawArticle.getTitle(), e); + logger.error("기사 처리 실패: {}", rawArticle.getTitle(), e); } } long elapsed = System.currentTimeMillis() - startTime; - logger.info("뉴스 수집 완료 - 저장: {}, 소요시간: {}ms", savedCount, elapsed); + logger.info("뉴스 수집/분석 완료 - 저장: {}, 분석: {}, 소요시간: {}ms", savedCount, analyzedCount, elapsed); return new CollectionResult(rssArticles.size(), savedCount, elapsed); } From 2801acfaa59dcffe4dd80474e84014efb64e8f4f Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 13:43:13 +0900 Subject: [PATCH 438/528] =?UTF-8?q?feat(news):=20=EB=89=B4=EC=8A=A4=20?= =?UTF-8?q?=ED=95=99=EC=8A=B5=20API=20=EA=B5=AC=ED=98=84=20(#388)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NewsQueryService: 뉴스 조회 서비스 - NewsHandler: API 핸들러 - GET /news - 목록 조회 (level, category 필터) - GET /news/today - 오늘의 뉴스 - GET /news/recommended - 내 레벨 맞춤 추천 - GET /news/{articleId} - 상세 조회 (조회수 증가) - template.yaml: NewsFunction Lambda 추가 --- .../domain/news/handler/NewsHandler.java | 166 ++++++++++++++++++ .../domain/news/service/NewsQueryService.java | 98 +++++++++++ ServerlessFunction/template.yaml | 38 ++++ 3 files changed, 302 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQueryService.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java new file mode 100644 index 00000000..3f53332b --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java @@ -0,0 +1,166 @@ +package com.mzc.secondproject.serverless.domain.news.handler; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.common.router.HandlerRouter; +import com.mzc.secondproject.serverless.common.router.Route; +import com.mzc.secondproject.serverless.common.util.CognitoUtil; +import com.mzc.secondproject.serverless.common.util.ResponseGenerator; +import com.mzc.secondproject.serverless.domain.news.exception.NewsErrorCode; +import com.mzc.secondproject.serverless.domain.news.model.NewsArticle; +import com.mzc.secondproject.serverless.domain.news.service.NewsQueryService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +/** + * 뉴스 학습 API 핸들러 + */ +public class NewsHandler implements RequestHandler { + + private static final Logger logger = LoggerFactory.getLogger(NewsHandler.class); + private static final int DEFAULT_LIMIT = 10; + private static final int MAX_LIMIT = 50; + + private final NewsQueryService queryService; + private final HandlerRouter router; + + public NewsHandler() { + this(new NewsQueryService()); + } + + public NewsHandler(NewsQueryService queryService) { + this.queryService = queryService; + this.router = initRouter(); + } + + private HandlerRouter initRouter() { + return new HandlerRouter().addRoutes( + Route.get("/news/today", this::getTodayNews), + Route.get("/news/recommended", this::getRecommendedNews), + Route.get("/news/{articleId}", this::getNewsDetail), + Route.get("/news", this::getNewsList) + ); + } + + @Override + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { + logger.info("News API 요청: {} {}", request.getHttpMethod(), request.getPath()); + return router.route(request); + } + + /** + * 뉴스 목록 조회 (필터링 지원) + * GET /news?level=INTERMEDIATE&category=TECH&limit=10&cursor=xxx + */ + private APIGatewayProxyResponseEvent getNewsList(APIGatewayProxyRequestEvent request) { + Map params = request.getQueryStringParameters(); + if (params == null) params = new HashMap<>(); + + String level = params.get("level"); + String category = params.get("category"); + String cursor = params.get("cursor"); + int limit = parseLimit(params.get("limit")); + + PaginatedResult result; + + if (level != null && category != null) { + result = queryService.getNewsByLevelAndCategory(level.toUpperCase(), category.toUpperCase(), limit, cursor); + } else if (level != null) { + result = queryService.getNewsByLevel(level.toUpperCase(), limit, cursor); + } else if (category != null) { + result = queryService.getNewsByCategory(category.toUpperCase(), limit, cursor); + } else { + result = queryService.getTodayNews(limit, cursor); + } + + return buildPaginatedResponse(result); + } + + /** + * 오늘의 뉴스 조회 + * GET /news/today?limit=10&cursor=xxx + */ + private APIGatewayProxyResponseEvent getTodayNews(APIGatewayProxyRequestEvent request) { + Map params = request.getQueryStringParameters(); + if (params == null) params = new HashMap<>(); + + String cursor = params.get("cursor"); + int limit = parseLimit(params.get("limit")); + + PaginatedResult result = queryService.getTodayNews(limit, cursor); + return buildPaginatedResponse(result); + } + + /** + * 내 레벨 맞춤 뉴스 추천 + * GET /news/recommended?limit=10&cursor=xxx + */ + private APIGatewayProxyResponseEvent getRecommendedNews(APIGatewayProxyRequestEvent request) { + Map params = request.getQueryStringParameters(); + if (params == null) params = new HashMap<>(); + + // 사용자 레벨 조회 (Cognito 토큰에서) + String userLevel = getUserLevel(request); + String cursor = params.get("cursor"); + int limit = parseLimit(params.get("limit")); + + PaginatedResult result = queryService.getRecommendedNews(userLevel, limit, cursor); + return buildPaginatedResponse(result); + } + + /** + * 뉴스 상세 조회 + * GET /news/{articleId} + */ + private APIGatewayProxyResponseEvent getNewsDetail(APIGatewayProxyRequestEvent request) { + String articleId = request.getPathParameters().get("articleId"); + + Optional article = queryService.getArticle(articleId); + if (article.isEmpty()) { + return ResponseGenerator.fail(NewsErrorCode.ARTICLE_NOT_FOUND); + } + + return ResponseGenerator.ok("뉴스 조회 성공", article.get()); + } + + /** + * 페이지네이션 응답 생성 + */ + private APIGatewayProxyResponseEvent buildPaginatedResponse(PaginatedResult result) { + Map response = new HashMap<>(); + response.put("articles", result.items()); + response.put("nextCursor", result.nextCursor()); + response.put("hasMore", result.hasMore()); + response.put("count", result.items().size()); + + return ResponseGenerator.ok("뉴스 목록 조회 성공", response); + } + + /** + * limit 파싱 + */ + private int parseLimit(String limitStr) { + if (limitStr == null) return DEFAULT_LIMIT; + try { + int limit = Integer.parseInt(limitStr); + return Math.min(Math.max(limit, 1), MAX_LIMIT); + } catch (NumberFormatException e) { + return DEFAULT_LIMIT; + } + } + + /** + * 사용자 레벨 조회 + */ + private String getUserLevel(APIGatewayProxyRequestEvent request) { + return CognitoUtil.extractClaim(request, "custom:level") + .orElse("INTERMEDIATE"); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQueryService.java new file mode 100644 index 00000000..5a3930ed --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQueryService.java @@ -0,0 +1,98 @@ +package com.mzc.secondproject.serverless.domain.news.service; + +import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.domain.news.model.NewsArticle; +import com.mzc.secondproject.serverless.domain.news.repository.NewsArticleRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.LocalDate; +import java.util.Optional; + +/** + * 뉴스 조회 서비스 + */ +public class NewsQueryService { + + private static final Logger logger = LoggerFactory.getLogger(NewsQueryService.class); + + private final NewsArticleRepository articleRepository; + + public NewsQueryService() { + this.articleRepository = new NewsArticleRepository(); + } + + public NewsQueryService(NewsArticleRepository articleRepository) { + this.articleRepository = articleRepository; + } + + /** + * 뉴스 상세 조회 + */ + public Optional getArticle(String articleId) { + logger.debug("뉴스 상세 조회: {}", articleId); + Optional article = articleRepository.findById(articleId); + + // 조회수 증가 + article.ifPresent(a -> { + String date = extractDateFromPk(a.getPk()); + if (date != null) { + articleRepository.incrementReadCount(date, articleId); + } + }); + + return article; + } + + /** + * 오늘의 뉴스 목록 조회 + */ + public PaginatedResult getTodayNews(int limit, String cursor) { + String today = LocalDate.now().toString(); + logger.debug("오늘의 뉴스 조회: date={}, limit={}", today, limit); + return articleRepository.findByDate(today, limit, cursor); + } + + /** + * 레벨별 뉴스 조회 + */ + public PaginatedResult getNewsByLevel(String level, int limit, String cursor) { + logger.debug("레벨별 뉴스 조회: level={}, limit={}", level, limit); + return articleRepository.findByLevel(level, limit, cursor); + } + + /** + * 카테고리별 뉴스 조회 + */ + public PaginatedResult getNewsByCategory(String category, int limit, String cursor) { + logger.debug("카테고리별 뉴스 조회: category={}, limit={}", category, limit); + return articleRepository.findByCategory(category, limit, cursor); + } + + /** + * 레벨 + 카테고리 복합 필터 조회 + */ + public PaginatedResult getNewsByLevelAndCategory(String level, String category, int limit, String cursor) { + logger.debug("레벨+카테고리 뉴스 조회: level={}, category={}, limit={}", level, category, limit); + return articleRepository.findByLevelAndCategory(level, category, limit, cursor); + } + + /** + * 사용자 레벨 맞춤 뉴스 추천 + */ + public PaginatedResult getRecommendedNews(String userLevel, int limit, String cursor) { + logger.debug("맞춤 뉴스 추천: userLevel={}, limit={}", userLevel, limit); + // 사용자 레벨에 맞는 뉴스 조회 + return articleRepository.findByLevel(userLevel, limit, cursor); + } + + /** + * PK에서 날짜 추출 (NEWS#2024-01-15 → 2024-01-15) + */ + private String extractDateFromPk(String pk) { + if (pk == null || !pk.startsWith("NEWS#")) { + return null; + } + return pk.substring(5); + } +} diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 552cb8e9..8288972b 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -1750,6 +1750,44 @@ Resources: Description: 매일 18시 KST (09:00 UTC)에 뉴스 수집 Enabled: true + NewsFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: !Sub "${AWS::StackName}-news" + CodeUri: . + Handler: com.mzc.secondproject.serverless.domain.news.handler.NewsHandler::handleRequest + Description: 뉴스 학습 API + MemorySize: 256 + Timeout: 30 + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref NewsTable + Events: + GetNewsList: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news + Method: GET + GetTodayNews: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/today + Method: GET + GetRecommendedNews: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/recommended + Method: GET + GetNewsDetail: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/{articleId} + Method: GET + NewsTable: Type: AWS::DynamoDB::Table Properties: From c70c703dddae6bbaf0af40ee2f9516a5dd06608c Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 13:51:43 +0900 Subject: [PATCH 439/528] =?UTF-8?q?feat(news):=20=EB=89=B4=EC=8A=A4=20?= =?UTF-8?q?=ED=95=99=EC=8A=B5=20=EB=B6=80=EA=B0=80=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(#389)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 읽기 완료 기록 API (POST /news/{articleId}/read) - 북마크 토글 API (POST /news/{articleId}/bookmark) - 북마크 목록 조회 API (GET /news/bookmarks) - 학습 통계 조회 API (GET /news/stats) - TTS 오디오 URL 조회 API (GET /news/{articleId}/audio) - UserNewsRecord 모델 추가 - UserNewsRepository 추가 - NewsLearningService 추가 --- .../domain/news/constants/NewsKey.java | 7 + .../domain/news/exception/NewsErrorCode.java | 3 + .../domain/news/handler/NewsHandler.java | 112 +++++++++- .../domain/news/model/UserNewsRecord.java | 58 +++++ .../news/repository/UserNewsRepository.java | 208 ++++++++++++++++++ .../news/service/NewsLearningService.java | 160 ++++++++++++++ ServerlessFunction/template.yaml | 37 ++++ 7 files changed, 583 insertions(+), 2 deletions(-) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/UserNewsRecord.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/UserNewsRepository.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/constants/NewsKey.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/constants/NewsKey.java index e5ba97b8..4c44a58f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/constants/NewsKey.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/constants/NewsKey.java @@ -119,4 +119,11 @@ public static String commentSk(String commentId) { public static String userNewsCommentsPk(String userId) { return DynamoDbKey.USER + userId + SUFFIX_NEWS_COMMENTS; } + + /** + * 사용자 뉴스 통계 GSI1 PK: USER_NEWS_STAT#{userId} + */ + public static String userNewsStatPk(String userId) { + return "USER_NEWS_STAT#" + userId; + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/exception/NewsErrorCode.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/exception/NewsErrorCode.java index fa898252..58197f0e 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/exception/NewsErrorCode.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/exception/NewsErrorCode.java @@ -8,6 +8,9 @@ */ public enum NewsErrorCode implements DomainErrorCode { + // 인증 관련 에러 + UNAUTHORIZED("AUTH_001", "인증이 필요합니다", 401), + // 뉴스 기사 관련 에러 ARTICLE_NOT_FOUND("ARTICLE_001", "뉴스 기사를 찾을 수 없습니다", 404), INVALID_ARTICLE_DATA("ARTICLE_002", "뉴스 기사 데이터가 유효하지 않습니다", 400), diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java index 3f53332b..8e42762d 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java @@ -11,11 +11,14 @@ import com.mzc.secondproject.serverless.common.util.ResponseGenerator; import com.mzc.secondproject.serverless.domain.news.exception.NewsErrorCode; import com.mzc.secondproject.serverless.domain.news.model.NewsArticle; +import com.mzc.secondproject.serverless.domain.news.model.UserNewsRecord; +import com.mzc.secondproject.serverless.domain.news.service.NewsLearningService; import com.mzc.secondproject.serverless.domain.news.service.NewsQueryService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Optional; @@ -29,14 +32,16 @@ public class NewsHandler implements RequestHandler stats = learningService.getUserStats(userId); + return ResponseGenerator.ok("뉴스 학습 통계 조회 성공", stats); + } + + /** + * 북마크 목록 조회 + * GET /news/bookmarks?limit=10 + */ + private APIGatewayProxyResponseEvent getBookmarks(APIGatewayProxyRequestEvent request) { + String userId = getUserId(request); + if (userId == null) { + return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); + } + + Map params = request.getQueryStringParameters(); + if (params == null) params = new HashMap<>(); + + int limit = parseLimit(params.get("limit")); + List bookmarks = learningService.getUserBookmarks(userId, limit); + + Map response = new HashMap<>(); + response.put("bookmarks", bookmarks); + response.put("count", bookmarks.size()); + + return ResponseGenerator.ok("북마크 목록 조회 성공", response); + } + + /** + * 뉴스 읽기 완료 기록 + * POST /news/{articleId}/read + */ + private APIGatewayProxyResponseEvent markAsRead(APIGatewayProxyRequestEvent request) { + String userId = getUserId(request); + if (userId == null) { + return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); + } + + String articleId = request.getPathParameters().get("articleId"); + learningService.markAsRead(userId, articleId); + + return ResponseGenerator.ok("읽기 완료 기록 성공", Map.of("articleId", articleId)); + } + + /** + * 북마크 토글 + * POST /news/{articleId}/bookmark + */ + private APIGatewayProxyResponseEvent toggleBookmark(APIGatewayProxyRequestEvent request) { + String userId = getUserId(request); + if (userId == null) { + return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); + } + + String articleId = request.getPathParameters().get("articleId"); + boolean isBookmarked = learningService.toggleBookmark(userId, articleId); + + return ResponseGenerator.ok( + isBookmarked ? "북마크 추가 성공" : "북마크 해제 성공", + Map.of("articleId", articleId, "bookmarked", isBookmarked) + ); + } + + /** + * 뉴스 TTS 오디오 URL 조회 + * GET /news/{articleId}/audio?voice=Joanna + */ + private APIGatewayProxyResponseEvent getAudio(APIGatewayProxyRequestEvent request) { + String articleId = request.getPathParameters().get("articleId"); + + Map params = request.getQueryStringParameters(); + String voice = (params != null) ? params.getOrDefault("voice", "Joanna") : "Joanna"; + + String audioUrl = learningService.getAudioUrl(articleId, voice); + if (audioUrl == null) { + return ResponseGenerator.fail(NewsErrorCode.ARTICLE_NOT_FOUND); + } + + return ResponseGenerator.ok("TTS 오디오 URL 조회 성공", Map.of("audioUrl", audioUrl)); + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/UserNewsRecord.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/UserNewsRecord.java new file mode 100644 index 00000000..eedfa8c5 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/UserNewsRecord.java @@ -0,0 +1,58 @@ +package com.mzc.secondproject.serverless.domain.news.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.*; + +/** + * 사용자 뉴스 학습 기록 + * PK: USER_NEWS#{userId} + * SK: READ#{articleId} 또는 BOOKMARK#{articleId} + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DynamoDbBean +public class UserNewsRecord { + + private String pk; // USER_NEWS#{userId} + private String sk; // READ#{articleId} 또는 BOOKMARK#{articleId} + private String gsi1pk; // USER_NEWS_STAT#{userId} + private String gsi1sk; // {date}#{type} + + private String userId; + private String articleId; + private String type; // READ, BOOKMARK + private String articleTitle; + private String articleLevel; + private String articleCategory; + private String createdAt; + private Long ttl; + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { + return pk; + } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { + return sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1PK") + public String getGsi1pk() { + return gsi1pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1SK") + public String getGsi1sk() { + return gsi1sk; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/UserNewsRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/UserNewsRepository.java new file mode 100644 index 00000000..25f8e651 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/UserNewsRepository.java @@ -0,0 +1,208 @@ +package com.mzc.secondproject.serverless.domain.news.repository; + +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.config.EnvConfig; +import com.mzc.secondproject.serverless.domain.news.constants.NewsKey; +import com.mzc.secondproject.serverless.domain.news.model.UserNewsRecord; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.*; +import software.amazon.awssdk.enhanced.dynamodb.model.*; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.*; + +/** + * 사용자 뉴스 학습 기록 Repository + */ +public class UserNewsRepository { + + private static final Logger logger = LoggerFactory.getLogger(UserNewsRepository.class); + private static final String TABLE_NAME = EnvConfig.getRequired("NEWS_TABLE_NAME"); + + private final DynamoDbTable table; + + public UserNewsRepository() { + this(AwsClients.dynamoDbEnhanced()); + } + + public UserNewsRepository(DynamoDbEnhancedClient enhancedClient) { + this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(UserNewsRecord.class)); + } + + /** + * 읽기 기록 저장 + */ + public void saveReadRecord(String userId, String articleId, String title, String level, String category) { + String now = Instant.now().toString(); + String today = LocalDate.now().toString(); + + UserNewsRecord record = UserNewsRecord.builder() + .pk(NewsKey.userNewsPk(userId)) + .sk(NewsKey.readSk(articleId)) + .gsi1pk(NewsKey.userNewsStatPk(userId)) + .gsi1sk(today + "#READ") + .userId(userId) + .articleId(articleId) + .type("READ") + .articleTitle(title) + .articleLevel(level) + .articleCategory(category) + .createdAt(now) + .build(); + + table.putItem(record); + logger.debug("읽기 기록 저장: userId={}, articleId={}", userId, articleId); + } + + /** + * 북마크 저장 + */ + public void saveBookmark(String userId, String articleId, String title, String level, String category) { + String now = Instant.now().toString(); + String today = LocalDate.now().toString(); + + UserNewsRecord record = UserNewsRecord.builder() + .pk(NewsKey.userNewsPk(userId)) + .sk(NewsKey.bookmarkSk(articleId)) + .gsi1pk(NewsKey.userNewsStatPk(userId)) + .gsi1sk(today + "#BOOKMARK") + .userId(userId) + .articleId(articleId) + .type("BOOKMARK") + .articleTitle(title) + .articleLevel(level) + .articleCategory(category) + .createdAt(now) + .build(); + + table.putItem(record); + logger.debug("북마크 저장: userId={}, articleId={}", userId, articleId); + } + + /** + * 북마크 삭제 + */ + public void deleteBookmark(String userId, String articleId) { + Key key = Key.builder() + .partitionValue(NewsKey.userNewsPk(userId)) + .sortValue(NewsKey.bookmarkSk(articleId)) + .build(); + + table.deleteItem(key); + logger.debug("북마크 삭제: userId={}, articleId={}", userId, articleId); + } + + /** + * 북마크 여부 확인 + */ + public boolean isBookmarked(String userId, String articleId) { + Key key = Key.builder() + .partitionValue(NewsKey.userNewsPk(userId)) + .sortValue(NewsKey.bookmarkSk(articleId)) + .build(); + + return table.getItem(key) != null; + } + + /** + * 읽기 기록 여부 확인 + */ + public boolean hasRead(String userId, String articleId) { + Key key = Key.builder() + .partitionValue(NewsKey.userNewsPk(userId)) + .sortValue(NewsKey.readSk(articleId)) + .build(); + + return table.getItem(key) != null; + } + + /** + * 사용자 북마크 목록 조회 + */ + public List getUserBookmarks(String userId, int limit) { + QueryConditional queryConditional = QueryConditional.sortBeginsWith( + Key.builder() + .partitionValue(NewsKey.userNewsPk(userId)) + .sortValue("BOOKMARK#") + .build() + ); + + QueryEnhancedRequest request = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .scanIndexForward(false) + .limit(limit) + .build(); + + List results = new ArrayList<>(); + for (Page page : table.query(request)) { + results.addAll(page.items()); + if (results.size() >= limit) break; + } + + return results.subList(0, Math.min(results.size(), limit)); + } + + /** + * 사용자 뉴스 통계 조회 + */ + public NewsStats getUserStats(String userId) { + QueryConditional queryConditional = QueryConditional.keyEqualTo( + Key.builder().partitionValue(NewsKey.userNewsPk(userId)).build() + ); + + int totalRead = 0; + int thisWeekRead = 0; + int totalBookmarks = 0; + Map byLevel = new HashMap<>(); + Map byCategory = new HashMap<>(); + + LocalDate weekAgo = LocalDate.now().minusDays(7); + + for (Page page : table.query(queryConditional)) { + for (UserNewsRecord record : page.items()) { + if ("READ".equals(record.getType())) { + totalRead++; + + // 이번 주 읽은 것 + if (record.getCreatedAt() != null) { + LocalDate readDate = Instant.parse(record.getCreatedAt()) + .atZone(java.time.ZoneId.systemDefault()).toLocalDate(); + if (readDate.isAfter(weekAgo)) { + thisWeekRead++; + } + } + + // 레벨별 통계 + String level = record.getArticleLevel(); + if (level != null) { + byLevel.merge(level, 1, Integer::sum); + } + + // 카테고리별 통계 + String category = record.getArticleCategory(); + if (category != null) { + byCategory.merge(category, 1, Integer::sum); + } + } else if ("BOOKMARK".equals(record.getType())) { + totalBookmarks++; + } + } + } + + return new NewsStats(totalRead, thisWeekRead, totalBookmarks, byLevel, byCategory); + } + + /** + * 뉴스 통계 레코드 + */ + public record NewsStats( + int totalRead, + int thisWeekRead, + int totalBookmarks, + Map byLevel, + Map byCategory + ) {} +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java new file mode 100644 index 00000000..8eba8522 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java @@ -0,0 +1,160 @@ +package com.mzc.secondproject.serverless.domain.news.service; + +import com.mzc.secondproject.serverless.common.config.EnvConfig; +import com.mzc.secondproject.serverless.common.service.PollyService; +import com.mzc.secondproject.serverless.domain.news.model.NewsArticle; +import com.mzc.secondproject.serverless.domain.news.model.UserNewsRecord; +import com.mzc.secondproject.serverless.domain.news.repository.NewsArticleRepository; +import com.mzc.secondproject.serverless.domain.news.repository.UserNewsRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * 뉴스 학습 부가 기능 서비스 + */ +public class NewsLearningService { + + private static final Logger logger = LoggerFactory.getLogger(NewsLearningService.class); + private static final String BUCKET_NAME = EnvConfig.getOrDefault("NEWS_BUCKET_NAME", "group2-englishstudy"); + + private final NewsArticleRepository articleRepository; + private final UserNewsRepository userNewsRepository; + private final PollyService pollyService; + + public NewsLearningService() { + this.articleRepository = new NewsArticleRepository(); + this.userNewsRepository = new UserNewsRepository(); + this.pollyService = new PollyService(BUCKET_NAME, "news/audio/"); + } + + public NewsLearningService(NewsArticleRepository articleRepository, + UserNewsRepository userNewsRepository, + PollyService pollyService) { + this.articleRepository = articleRepository; + this.userNewsRepository = userNewsRepository; + this.pollyService = pollyService; + } + + /** + * 뉴스 읽기 완료 기록 + */ + public void markAsRead(String userId, String articleId) { + Optional article = articleRepository.findById(articleId); + if (article.isEmpty()) { + logger.warn("기사를 찾을 수 없음: {}", articleId); + return; + } + + NewsArticle a = article.get(); + userNewsRepository.saveReadRecord( + userId, + articleId, + a.getTitle(), + a.getLevel(), + a.getCategory() + ); + + // 조회수 증가 + String date = extractDateFromPk(a.getPk()); + if (date != null) { + articleRepository.incrementReadCount(date, articleId); + } + + logger.info("읽기 완료 기록: userId={}, articleId={}", userId, articleId); + } + + /** + * 북마크 토글 + */ + public boolean toggleBookmark(String userId, String articleId) { + boolean isBookmarked = userNewsRepository.isBookmarked(userId, articleId); + + if (isBookmarked) { + userNewsRepository.deleteBookmark(userId, articleId); + logger.info("북마크 해제: userId={}, articleId={}", userId, articleId); + return false; + } else { + Optional article = articleRepository.findById(articleId); + if (article.isEmpty()) { + logger.warn("기사를 찾을 수 없음: {}", articleId); + return false; + } + + NewsArticle a = article.get(); + userNewsRepository.saveBookmark( + userId, + articleId, + a.getTitle(), + a.getLevel(), + a.getCategory() + ); + logger.info("북마크 추가: userId={}, articleId={}", userId, articleId); + return true; + } + } + + /** + * 북마크 여부 확인 + */ + public boolean isBookmarked(String userId, String articleId) { + return userNewsRepository.isBookmarked(userId, articleId); + } + + /** + * 사용자 북마크 목록 조회 + */ + public List getUserBookmarks(String userId, int limit) { + return userNewsRepository.getUserBookmarks(userId, limit); + } + + /** + * 뉴스 TTS 오디오 URL 생성 + */ + public String getAudioUrl(String articleId, String voice) { + Optional article = articleRepository.findById(articleId); + if (article.isEmpty()) { + logger.warn("기사를 찾을 수 없음: {}", articleId); + return null; + } + + NewsArticle a = article.get(); + String text = a.getTitle() + ". " + (a.getSummary() != null ? a.getSummary() : ""); + + // 텍스트가 너무 길면 제한 + if (text.length() > 3000) { + text = text.substring(0, 3000); + } + + PollyService.VoiceSynthesisResult result = pollyService.synthesizeSpeech(articleId, text, voice); + return result.getAudioUrl(); + } + + /** + * 사용자 뉴스 학습 통계 조회 + */ + public Map getUserStats(String userId) { + UserNewsRepository.NewsStats stats = userNewsRepository.getUserStats(userId); + + return Map.of( + "totalRead", stats.totalRead(), + "thisWeekRead", stats.thisWeekRead(), + "totalBookmarks", stats.totalBookmarks(), + "byLevel", stats.byLevel(), + "byCategory", stats.byCategory() + ); + } + + /** + * PK에서 날짜 추출 + */ + private String extractDateFromPk(String pk) { + if (pk == null || !pk.startsWith("NEWS#")) { + return null; + } + return pk.substring(5); + } +} diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 8288972b..8bc1de01 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -1762,6 +1762,13 @@ Resources: Policies: - DynamoDBCrudPolicy: TableName: !Ref NewsTable + - S3CrudPolicy: + BucketName: !Ref ContentBucket + - Statement: + - Effect: Allow + Action: + - polly:SynthesizeSpeech + Resource: "*" Events: GetNewsList: Type: Api @@ -1781,6 +1788,36 @@ Resources: RestApiId: !Ref MainApi Path: /news/recommended Method: GET + GetNewsStats: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/stats + Method: GET + GetBookmarks: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/bookmarks + Method: GET + MarkAsRead: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/{articleId}/read + Method: POST + ToggleBookmark: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/{articleId}/bookmark + Method: POST + GetAudio: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/{articleId}/audio + Method: GET GetNewsDetail: Type: Api Properties: From ddbdc1b912f13fb682e6774168bd41c37757af7f Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 14:01:27 +0900 Subject: [PATCH 440/528] =?UTF-8?q?feat(news):=20=EB=B3=B5=ED=95=A9=20?= =?UTF-8?q?=ED=80=B4=EC=A6=88=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#471)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 퀴즈 조회 API (GET /news/{articleId}/quiz) - 퀴즈 제출 API (POST /news/{articleId}/quiz) - 퀴즈 기록 조회 API (GET /news/quiz/history) - NewsQuizResult 모델 추가 - QuizAnswerResult 모델 추가 - NewsQuizRepository 추가 - NewsQuizService 추가 --- .../domain/news/handler/NewsHandler.java | 97 ++++++- .../domain/news/model/NewsQuizResult.java | 63 +++++ .../domain/news/model/QuizAnswerResult.java | 25 ++ .../news/repository/NewsQuizRepository.java | 126 +++++++++ .../domain/news/service/NewsQuizService.java | 263 ++++++++++++++++++ ServerlessFunction/template.yaml | 18 ++ 6 files changed, 590 insertions(+), 2 deletions(-) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsQuizResult.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/QuizAnswerResult.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsQuizRepository.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQuizService.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java index 8e42762d..ac23fd5d 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java @@ -11,9 +11,14 @@ import com.mzc.secondproject.serverless.common.util.ResponseGenerator; import com.mzc.secondproject.serverless.domain.news.exception.NewsErrorCode; import com.mzc.secondproject.serverless.domain.news.model.NewsArticle; +import com.mzc.secondproject.serverless.domain.news.model.NewsQuizResult; import com.mzc.secondproject.serverless.domain.news.model.UserNewsRecord; import com.mzc.secondproject.serverless.domain.news.service.NewsLearningService; import com.mzc.secondproject.serverless.domain.news.service.NewsQueryService; +import com.mzc.secondproject.serverless.domain.news.service.NewsQuizService; +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -30,18 +35,21 @@ public class NewsHandler implements RequestHandler quizData = quizService.getQuiz(articleId, userId); + + if (quizData.isEmpty()) { + return ResponseGenerator.fail(NewsErrorCode.QUIZ_NOT_FOUND); + } + + return ResponseGenerator.ok("퀴즈 조회 성공", quizData.get()); + } + + /** + * 퀴즈 제출 + * POST /news/{articleId}/quiz + */ + private APIGatewayProxyResponseEvent submitQuiz(APIGatewayProxyRequestEvent request) { + String userId = getUserId(request); + if (userId == null) { + return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); + } + + String articleId = request.getPathParameters().get("articleId"); + + // 요청 바디 파싱 + JsonObject body = gson.fromJson(request.getBody(), JsonObject.class); + JsonArray answersArray = body.getAsJsonArray("answers"); + Integer timeTaken = body.has("timeTaken") ? body.get("timeTaken").getAsInt() : null; + + List answers = new java.util.ArrayList<>(); + if (answersArray != null) { + answersArray.forEach(e -> { + JsonObject a = e.getAsJsonObject(); + answers.add(new NewsQuizService.QuizAnswer( + a.get("questionId").getAsString(), + a.get("answer").getAsString() + )); + }); + } + + NewsQuizService.QuizSubmitResult result = quizService.submitQuiz(userId, articleId, answers, timeTaken); + + if (result == null) { + return ResponseGenerator.fail(NewsErrorCode.QUIZ_ALREADY_SUBMITTED); + } + + return ResponseGenerator.ok("퀴즈 제출 성공", result); + } + + /** + * 퀴즈 기록 조회 + * GET /news/quiz/history?limit=10 + */ + private APIGatewayProxyResponseEvent getQuizHistory(APIGatewayProxyRequestEvent request) { + String userId = getUserId(request); + if (userId == null) { + return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); + } + + Map params = request.getQueryStringParameters(); + if (params == null) params = new HashMap<>(); + + int limit = parseLimit(params.get("limit")); + List history = quizService.getUserQuizHistory(userId, limit); + Map quizStats = quizService.getUserQuizStats(userId); + + Map response = new HashMap<>(); + response.put("history", history); + response.put("stats", quizStats); + response.put("count", history.size()); + + return ResponseGenerator.ok("퀴즈 기록 조회 성공", response); + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsQuizResult.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsQuizResult.java new file mode 100644 index 00000000..23b47f62 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsQuizResult.java @@ -0,0 +1,63 @@ +package com.mzc.secondproject.serverless.domain.news.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.*; + +import java.util.List; + +/** + * 뉴스 퀴즈 결과 + * PK: USER#{userId}#NEWS + * SK: QUIZ#{articleId} + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DynamoDbBean +public class NewsQuizResult { + + private String pk; // USER#{userId}#NEWS + private String sk; // QUIZ#{articleId} + private String gsi1pk; // USER_NEWS_STAT#{userId} + private String gsi1sk; // {date}#QUIZ + + private String userId; + private String articleId; + private String articleTitle; + private String articleLevel; + private int score; // 0-100 + private int totalPoints; // 총 배점 + private int earnedPoints; // 획득 점수 + private List answers; + private Integer timeTaken; // 소요 시간 (초) + private String submittedAt; + private Long ttl; + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { + return pk; + } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { + return sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1PK") + public String getGsi1pk() { + return gsi1pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1SK") + public String getGsi1sk() { + return gsi1sk; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/QuizAnswerResult.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/QuizAnswerResult.java new file mode 100644 index 00000000..3dee95b6 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/QuizAnswerResult.java @@ -0,0 +1,25 @@ +package com.mzc.secondproject.serverless.domain.news.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; + +/** + * 퀴즈 답변 결과 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DynamoDbBean +public class QuizAnswerResult { + + private String questionId; + private String type; // COMPREHENSION, WORD_MATCH, FILL_BLANK + private String userAnswer; + private String correctAnswer; + private boolean correct; + private int points; // 획득 점수 (정답시 배점, 오답시 0) +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsQuizRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsQuizRepository.java new file mode 100644 index 00000000..b2786f99 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsQuizRepository.java @@ -0,0 +1,126 @@ +package com.mzc.secondproject.serverless.domain.news.repository; + +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.config.EnvConfig; +import com.mzc.secondproject.serverless.domain.news.constants.NewsKey; +import com.mzc.secondproject.serverless.domain.news.model.NewsQuizResult; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.*; +import software.amazon.awssdk.enhanced.dynamodb.model.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/** + * 뉴스 퀴즈 결과 Repository + */ +public class NewsQuizRepository { + + private static final Logger logger = LoggerFactory.getLogger(NewsQuizRepository.class); + private static final String TABLE_NAME = EnvConfig.getRequired("NEWS_TABLE_NAME"); + + private final DynamoDbTable table; + + public NewsQuizRepository() { + this(AwsClients.dynamoDbEnhanced()); + } + + public NewsQuizRepository(DynamoDbEnhancedClient enhancedClient) { + this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(NewsQuizResult.class)); + } + + /** + * 퀴즈 결과 저장 + */ + public void save(NewsQuizResult result) { + table.putItem(result); + logger.debug("퀴즈 결과 저장: userId={}, articleId={}, score={}", + result.getUserId(), result.getArticleId(), result.getScore()); + } + + /** + * 퀴즈 결과 조회 + */ + public Optional findByUserAndArticle(String userId, String articleId) { + Key key = Key.builder() + .partitionValue(NewsKey.userNewsPk(userId)) + .sortValue(NewsKey.quizSk(articleId)) + .build(); + + NewsQuizResult result = table.getItem(key); + return Optional.ofNullable(result); + } + + /** + * 퀴즈 제출 여부 확인 + */ + public boolean hasSubmitted(String userId, String articleId) { + return findByUserAndArticle(userId, articleId).isPresent(); + } + + /** + * 사용자 퀴즈 결과 목록 조회 + */ + public List getUserQuizResults(String userId, int limit) { + QueryConditional queryConditional = QueryConditional.sortBeginsWith( + Key.builder() + .partitionValue(NewsKey.userNewsPk(userId)) + .sortValue("QUIZ#") + .build() + ); + + QueryEnhancedRequest request = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .scanIndexForward(false) + .limit(limit) + .build(); + + List results = new ArrayList<>(); + for (Page page : table.query(request)) { + results.addAll(page.items()); + if (results.size() >= limit) break; + } + + return results.subList(0, Math.min(results.size(), limit)); + } + + /** + * 사용자 퀴즈 통계 조회 + */ + public QuizStats getUserQuizStats(String userId) { + QueryConditional queryConditional = QueryConditional.sortBeginsWith( + Key.builder() + .partitionValue(NewsKey.userNewsPk(userId)) + .sortValue("QUIZ#") + .build() + ); + + int totalQuizzes = 0; + int totalScore = 0; + int perfectScores = 0; + + for (Page page : table.query(queryConditional)) { + for (NewsQuizResult result : page.items()) { + totalQuizzes++; + totalScore += result.getScore(); + if (result.getScore() == 100) { + perfectScores++; + } + } + } + + int avgScore = totalQuizzes > 0 ? totalScore / totalQuizzes : 0; + return new QuizStats(totalQuizzes, avgScore, perfectScores); + } + + /** + * 퀴즈 통계 레코드 + */ + public record QuizStats( + int totalQuizzes, + int avgScore, + int perfectScores + ) {} +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQuizService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQuizService.java new file mode 100644 index 00000000..bb22fc90 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQuizService.java @@ -0,0 +1,263 @@ +package com.mzc.secondproject.serverless.domain.news.service; + +import com.mzc.secondproject.serverless.domain.news.constants.NewsKey; +import com.mzc.secondproject.serverless.domain.news.model.*; +import com.mzc.secondproject.serverless.domain.news.repository.NewsArticleRepository; +import com.mzc.secondproject.serverless.domain.news.repository.NewsQuizRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.*; + +/** + * 뉴스 퀴즈 서비스 + */ +public class NewsQuizService { + + private static final Logger logger = LoggerFactory.getLogger(NewsQuizService.class); + + private final NewsArticleRepository articleRepository; + private final NewsQuizRepository quizRepository; + + public NewsQuizService() { + this.articleRepository = new NewsArticleRepository(); + this.quizRepository = new NewsQuizRepository(); + } + + public NewsQuizService(NewsArticleRepository articleRepository, NewsQuizRepository quizRepository) { + this.articleRepository = articleRepository; + this.quizRepository = quizRepository; + } + + /** + * 퀴즈 조회 + */ + public Optional getQuiz(String articleId, String userId) { + Optional articleOpt = articleRepository.findById(articleId); + if (articleOpt.isEmpty()) { + logger.warn("기사를 찾을 수 없음: {}", articleId); + return Optional.empty(); + } + + NewsArticle article = articleOpt.get(); + List questions = article.getQuiz(); + + if (questions == null || questions.isEmpty()) { + logger.warn("퀴즈가 없는 기사: {}", articleId); + return Optional.empty(); + } + + // 이미 제출했는지 확인 + boolean submitted = quizRepository.hasSubmitted(userId, articleId); + + // 정답 제거한 퀴즈 반환 + List questionViews = questions.stream() + .map(q -> QuizQuestionView.builder() + .questionId(q.getQuestionId()) + .type(q.getType()) + .question(q.getQuestion()) + .options(q.getOptions()) + .points(q.getPoints()) + .build()) + .toList(); + + return Optional.of(QuizData.builder() + .articleId(articleId) + .articleTitle(article.getTitle()) + .level(article.getLevel()) + .questions(questionViews) + .totalPoints(questions.stream().mapToInt(QuizQuestion::getPoints).sum()) + .submitted(submitted) + .build()); + } + + /** + * 퀴즈 제출 및 채점 + */ + public QuizSubmitResult submitQuiz(String userId, String articleId, List answers, Integer timeTaken) { + // 이미 제출했는지 확인 + if (quizRepository.hasSubmitted(userId, articleId)) { + logger.warn("이미 제출한 퀴즈: userId={}, articleId={}", userId, articleId); + return null; + } + + // 기사 조회 + Optional articleOpt = articleRepository.findById(articleId); + if (articleOpt.isEmpty()) { + logger.warn("기사를 찾을 수 없음: {}", articleId); + return null; + } + + NewsArticle article = articleOpt.get(); + List questions = article.getQuiz(); + + if (questions == null || questions.isEmpty()) { + logger.warn("퀴즈가 없는 기사: {}", articleId); + return null; + } + + // 정답 맵 생성 + Map questionMap = new HashMap<>(); + for (QuizQuestion q : questions) { + questionMap.put(q.getQuestionId(), q); + } + + // 채점 + List answerResults = new ArrayList<>(); + int earnedPoints = 0; + int totalPoints = 0; + + for (QuizAnswer answer : answers) { + QuizQuestion question = questionMap.get(answer.questionId()); + if (question == null) continue; + + boolean correct = question.getCorrectAnswer().equalsIgnoreCase(answer.answer()); + int points = correct ? question.getPoints() : 0; + earnedPoints += points; + totalPoints += question.getPoints(); + + answerResults.add(QuizAnswerResult.builder() + .questionId(answer.questionId()) + .type(question.getType()) + .userAnswer(answer.answer()) + .correctAnswer(question.getCorrectAnswer()) + .correct(correct) + .points(points) + .build()); + } + + // 점수 계산 (100점 만점) + int score = totalPoints > 0 ? (earnedPoints * 100) / totalPoints : 0; + + // 결과 저장 + String now = Instant.now().toString(); + String today = LocalDate.now().toString(); + + NewsQuizResult result = NewsQuizResult.builder() + .pk(NewsKey.userNewsPk(userId)) + .sk(NewsKey.quizSk(articleId)) + .gsi1pk(NewsKey.userNewsStatPk(userId)) + .gsi1sk(today + "#QUIZ") + .userId(userId) + .articleId(articleId) + .articleTitle(article.getTitle()) + .articleLevel(article.getLevel()) + .score(score) + .totalPoints(totalPoints) + .earnedPoints(earnedPoints) + .answers(answerResults) + .timeTaken(timeTaken) + .submittedAt(now) + .build(); + + quizRepository.save(result); + logger.info("퀴즈 제출 완료: userId={}, articleId={}, score={}", userId, articleId, score); + + // 피드백 생성 + String feedback = generateFeedback(score, answerResults); + + return QuizSubmitResult.builder() + .score(score) + .earnedPoints(earnedPoints) + .totalPoints(totalPoints) + .results(answerResults) + .feedback(feedback) + .build(); + } + + /** + * 사용자 퀴즈 결과 조회 + */ + public Optional getQuizResult(String userId, String articleId) { + return quizRepository.findByUserAndArticle(userId, articleId); + } + + /** + * 사용자 퀴즈 기록 목록 조회 + */ + public List getUserQuizHistory(String userId, int limit) { + return quizRepository.getUserQuizResults(userId, limit); + } + + /** + * 사용자 퀴즈 통계 조회 + */ + public Map getUserQuizStats(String userId) { + NewsQuizRepository.QuizStats stats = quizRepository.getUserQuizStats(userId); + return Map.of( + "totalQuizzes", stats.totalQuizzes(), + "avgScore", stats.avgScore(), + "perfectScores", stats.perfectScores() + ); + } + + /** + * 피드백 생성 + */ + private String generateFeedback(int score, List results) { + if (score == 100) { + return "Perfect! You understood the article completely."; + } else if (score >= 80) { + return "Great job! You have a solid understanding of the article."; + } else if (score >= 60) { + return "Good effort! Review the highlighted words for better comprehension."; + } else if (score >= 40) { + return "Keep practicing! Try reading the article again before retaking the quiz."; + } else { + return "Don't give up! Focus on vocabulary and main ideas."; + } + } + + /** + * 퀴즈 데이터 (정답 제외) + */ + @lombok.Data + @lombok.Builder + @lombok.NoArgsConstructor + @lombok.AllArgsConstructor + public static class QuizData { + private String articleId; + private String articleTitle; + private String level; + private List questions; + private int totalPoints; + private boolean submitted; + } + + /** + * 퀴즈 문제 뷰 (정답 제외) + */ + @lombok.Data + @lombok.Builder + @lombok.NoArgsConstructor + @lombok.AllArgsConstructor + public static class QuizQuestionView { + private String questionId; + private String type; + private String question; + private List options; + private int points; + } + + /** + * 사용자 답변 + */ + public record QuizAnswer(String questionId, String answer) {} + + /** + * 퀴즈 제출 결과 + */ + @lombok.Data + @lombok.Builder + @lombok.NoArgsConstructor + @lombok.AllArgsConstructor + public static class QuizSubmitResult { + private int score; + private int earnedPoints; + private int totalPoints; + private List results; + private String feedback; + } +} diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 8bc1de01..f701a42f 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -1800,6 +1800,24 @@ Resources: RestApiId: !Ref MainApi Path: /news/bookmarks Method: GET + GetQuizHistory: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/quiz/history + Method: GET + GetQuiz: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/{articleId}/quiz + Method: GET + SubmitQuiz: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/{articleId}/quiz + Method: POST MarkAsRead: Type: Api Properties: From fff1ab5d61d1f829e37817960354714721a22a86 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 14:10:24 +0900 Subject: [PATCH 441/528] =?UTF-8?q?feat(news):=20=EB=8B=A8=EC=96=B4=20?= =?UTF-8?q?=EC=88=98=EC=A7=91=20&=20Vocabulary=20=EC=97=B0=EB=8F=99=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(#472)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 단어 수집 API (POST /news/{articleId}/words) - 수집 단어 목록 API (GET /news/words) - 단어 상세 조회 API (GET /news/{articleId}/words/{word}) - 단어 삭제 API (DELETE /news/{articleId}/words/{word}) - Vocabulary 연동 API (POST /news/words/{word}/sync) - NewsWordCollect 모델 추가 - NewsWordRepository 추가 - NewsWordService 추가 --- .../domain/news/handler/NewsHandler.java | 122 ++++++++++- .../domain/news/model/NewsWordCollect.java | 62 ++++++ .../news/repository/NewsWordRepository.java | 133 ++++++++++++ .../domain/news/service/NewsWordService.java | 203 ++++++++++++++++++ ServerlessFunction/template.yaml | 32 +++ 5 files changed, 550 insertions(+), 2 deletions(-) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsWordCollect.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsWordRepository.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsWordService.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java index ac23fd5d..180bb7cb 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java @@ -12,10 +12,12 @@ import com.mzc.secondproject.serverless.domain.news.exception.NewsErrorCode; import com.mzc.secondproject.serverless.domain.news.model.NewsArticle; import com.mzc.secondproject.serverless.domain.news.model.NewsQuizResult; +import com.mzc.secondproject.serverless.domain.news.model.NewsWordCollect; import com.mzc.secondproject.serverless.domain.news.model.UserNewsRecord; import com.mzc.secondproject.serverless.domain.news.service.NewsLearningService; import com.mzc.secondproject.serverless.domain.news.service.NewsQueryService; import com.mzc.secondproject.serverless.domain.news.service.NewsQuizService; +import com.mzc.secondproject.serverless.domain.news.service.NewsWordService; import com.google.gson.Gson; import com.google.gson.JsonArray; import com.google.gson.JsonObject; @@ -40,16 +42,19 @@ public class NewsHandler implements RequestHandler params = request.getQueryStringParameters(); + if (params == null) params = new HashMap<>(); + + int limit = parseLimit(params.get("limit")); + List words = wordService.getUserWords(userId, limit); + Map stats = wordService.getUserWordStats(userId); + + Map response = new HashMap<>(); + response.put("words", words); + response.put("stats", stats); + response.put("count", words.size()); + + return ResponseGenerator.ok("수집 단어 목록 조회 성공", response); + } + + /** + * 단어 수집 + * POST /news/{articleId}/words + */ + private APIGatewayProxyResponseEvent collectWord(APIGatewayProxyRequestEvent request) { + String userId = getUserId(request); + if (userId == null) { + return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); + } + + String articleId = request.getPathParameters().get("articleId"); + + JsonObject body = gson.fromJson(request.getBody(), JsonObject.class); + String word = body.get("word").getAsString(); + String context = body.has("context") ? body.get("context").getAsString() : ""; + + NewsWordCollect collected = wordService.collectWord(userId, articleId, word, context); + + if (collected == null) { + return ResponseGenerator.fail(NewsErrorCode.WORD_ALREADY_COLLECTED); + } + + return ResponseGenerator.ok("단어 수집 성공", collected); + } + + /** + * 단어 삭제 + * DELETE /news/{articleId}/words/{word} + */ + private APIGatewayProxyResponseEvent deleteWord(APIGatewayProxyRequestEvent request) { + String userId = getUserId(request); + if (userId == null) { + return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); + } + + String articleId = request.getPathParameters().get("articleId"); + String word = request.getPathParameters().get("word"); + + wordService.deleteWord(userId, word, articleId); + + return ResponseGenerator.ok("단어 삭제 성공", Map.of("word", word)); + } + + /** + * 단어 상세 정보 조회 + * GET /news/{articleId}/words/{word} + */ + private APIGatewayProxyResponseEvent getWordDetail(APIGatewayProxyRequestEvent request) { + String word = request.getPathParameters().get("word"); + + Optional detail = wordService.getWordDetail(word); + + if (detail.isEmpty()) { + return ResponseGenerator.fail(NewsErrorCode.WORD_NOT_COLLECTED); + } + + return ResponseGenerator.ok("단어 상세 조회 성공", detail.get()); + } + + /** + * 단어 Vocabulary 연동 + * POST /news/words/{word}/sync + */ + private APIGatewayProxyResponseEvent syncWordToVocab(APIGatewayProxyRequestEvent request) { + String userId = getUserId(request); + if (userId == null) { + return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); + } + + String word = request.getPathParameters().get("word"); + + JsonObject body = gson.fromJson(request.getBody(), JsonObject.class); + String articleId = body.get("articleId").getAsString(); + + boolean synced = wordService.syncToVocabulary(userId, word, articleId); + + if (!synced) { + return ResponseGenerator.fail(NewsErrorCode.WORD_NOT_COLLECTED); + } + + return ResponseGenerator.ok("Vocabulary 연동 성공", Map.of("word", word, "synced", true)); + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsWordCollect.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsWordCollect.java new file mode 100644 index 00000000..59f4fa93 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsWordCollect.java @@ -0,0 +1,62 @@ +package com.mzc.secondproject.serverless.domain.news.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.*; + +/** + * 뉴스 단어 수집 + * PK: USER#{userId}#NEWS + * SK: WORD#{word}#{articleId} + * GSI1: USER#{userId}#NEWS_WORDS / {collectedAt} + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DynamoDbBean +public class NewsWordCollect { + + private String pk; // USER#{userId}#NEWS + private String sk; // WORD#{word}#{articleId} + private String gsi1pk; // USER#{userId}#NEWS_WORDS + private String gsi1sk; // {collectedAt} + + private String userId; + private String word; + private String meaning; + private String pronunciation; + private String context; // 문맥 문장 + private String articleId; + private String articleTitle; + private String collectedAt; + private Boolean syncedToVocab; // Vocabulary 연동 여부 + private String vocabUserWordId; // 연동된 UserWord ID + private Long ttl; + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { + return pk; + } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { + return sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1PK") + public String getGsi1pk() { + return gsi1pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1SK") + public String getGsi1sk() { + return gsi1sk; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsWordRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsWordRepository.java new file mode 100644 index 00000000..5dfebc80 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsWordRepository.java @@ -0,0 +1,133 @@ +package com.mzc.secondproject.serverless.domain.news.repository; + +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.config.EnvConfig; +import com.mzc.secondproject.serverless.domain.news.constants.NewsKey; +import com.mzc.secondproject.serverless.domain.news.model.NewsWordCollect; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.*; +import software.amazon.awssdk.enhanced.dynamodb.model.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/** + * 뉴스 단어 수집 Repository + */ +public class NewsWordRepository { + + private static final Logger logger = LoggerFactory.getLogger(NewsWordRepository.class); + private static final String TABLE_NAME = EnvConfig.getRequired("NEWS_TABLE_NAME"); + + private final DynamoDbTable table; + private final DynamoDbIndex gsi1Index; + + public NewsWordRepository() { + this(AwsClients.dynamoDbEnhanced()); + } + + public NewsWordRepository(DynamoDbEnhancedClient enhancedClient) { + this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(NewsWordCollect.class)); + this.gsi1Index = table.index("GSI1"); + } + + /** + * 단어 수집 저장 + */ + public void save(NewsWordCollect wordCollect) { + table.putItem(wordCollect); + logger.debug("단어 수집 저장: userId={}, word={}", wordCollect.getUserId(), wordCollect.getWord()); + } + + /** + * 단어 수집 조회 + */ + public Optional findByUserWordArticle(String userId, String word, String articleId) { + Key key = Key.builder() + .partitionValue(NewsKey.userNewsPk(userId)) + .sortValue(NewsKey.wordSk(word, articleId)) + .build(); + + NewsWordCollect result = table.getItem(key); + return Optional.ofNullable(result); + } + + /** + * 이미 수집했는지 확인 + */ + public boolean hasCollected(String userId, String word, String articleId) { + return findByUserWordArticle(userId, word, articleId).isPresent(); + } + + /** + * 단어 수집 삭제 + */ + public void delete(String userId, String word, String articleId) { + Key key = Key.builder() + .partitionValue(NewsKey.userNewsPk(userId)) + .sortValue(NewsKey.wordSk(word, articleId)) + .build(); + + table.deleteItem(key); + logger.debug("단어 수집 삭제: userId={}, word={}", userId, word); + } + + /** + * 사용자 수집 단어 목록 조회 (최신순) + */ + public List getUserWords(String userId, int limit) { + QueryConditional queryConditional = QueryConditional.keyEqualTo( + Key.builder() + .partitionValue(NewsKey.userNewsWordsPk(userId)) + .build() + ); + + QueryEnhancedRequest request = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .scanIndexForward(false) + .limit(limit) + .build(); + + List results = new ArrayList<>(); + for (Page page : gsi1Index.query(request)) { + results.addAll(page.items()); + if (results.size() >= limit) break; + } + + return results.subList(0, Math.min(results.size(), limit)); + } + + /** + * 사용자 수집 단어 수 조회 + */ + public int countUserWords(String userId) { + QueryConditional queryConditional = QueryConditional.sortBeginsWith( + Key.builder() + .partitionValue(NewsKey.userNewsPk(userId)) + .sortValue("WORD#") + .build() + ); + + int count = 0; + for (Page page : table.query(queryConditional)) { + count += page.items().size(); + } + return count; + } + + /** + * Vocabulary 연동 상태 업데이트 + */ + public void updateSyncStatus(String userId, String word, String articleId, String vocabUserWordId) { + Optional wordOpt = findByUserWordArticle(userId, word, articleId); + if (wordOpt.isPresent()) { + NewsWordCollect wordCollect = wordOpt.get(); + wordCollect.setSyncedToVocab(true); + wordCollect.setVocabUserWordId(vocabUserWordId); + table.putItem(wordCollect); + logger.debug("Vocabulary 연동 완료: userId={}, word={}", userId, word); + } + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsWordService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsWordService.java new file mode 100644 index 00000000..6c3c23ec --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsWordService.java @@ -0,0 +1,203 @@ +package com.mzc.secondproject.serverless.domain.news.service; + +import com.mzc.secondproject.serverless.domain.news.constants.NewsKey; +import com.mzc.secondproject.serverless.domain.news.model.NewsArticle; +import com.mzc.secondproject.serverless.domain.news.model.NewsWordCollect; +import com.mzc.secondproject.serverless.domain.news.repository.NewsArticleRepository; +import com.mzc.secondproject.serverless.domain.news.repository.NewsWordRepository; +import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; +import com.mzc.secondproject.serverless.domain.vocabulary.repository.WordRepository; +import com.mzc.secondproject.serverless.domain.vocabulary.service.UserWordCommandService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * 뉴스 단어 수집 서비스 + */ +public class NewsWordService { + + private static final Logger logger = LoggerFactory.getLogger(NewsWordService.class); + + private final NewsWordRepository newsWordRepository; + private final NewsArticleRepository articleRepository; + private final WordRepository wordRepository; + private final UserWordCommandService userWordCommandService; + + public NewsWordService() { + this.newsWordRepository = new NewsWordRepository(); + this.articleRepository = new NewsArticleRepository(); + this.wordRepository = new WordRepository(); + this.userWordCommandService = new UserWordCommandService(); + } + + public NewsWordService(NewsWordRepository newsWordRepository, + NewsArticleRepository articleRepository, + WordRepository wordRepository, + UserWordCommandService userWordCommandService) { + this.newsWordRepository = newsWordRepository; + this.articleRepository = articleRepository; + this.wordRepository = wordRepository; + this.userWordCommandService = userWordCommandService; + } + + /** + * 단어 수집 + */ + public NewsWordCollect collectWord(String userId, String articleId, String word, String context) { + // 이미 수집했는지 확인 + if (newsWordRepository.hasCollected(userId, word, articleId)) { + logger.warn("이미 수집한 단어: userId={}, word={}", userId, word); + return newsWordRepository.findByUserWordArticle(userId, word, articleId).orElse(null); + } + + // 기사 조회 + Optional articleOpt = articleRepository.findById(articleId); + String articleTitle = articleOpt.map(NewsArticle::getTitle).orElse(""); + + // 단어 정보 조회 (Word 테이블에서) + String wordId = word.toLowerCase().trim(); + Optional wordOpt = wordRepository.findById(wordId); + String meaning = wordOpt.map(Word::getKorean).orElse(""); + String pronunciation = ""; + + String now = Instant.now().toString(); + + NewsWordCollect wordCollect = NewsWordCollect.builder() + .pk(NewsKey.userNewsPk(userId)) + .sk(NewsKey.wordSk(word, articleId)) + .gsi1pk(NewsKey.userNewsWordsPk(userId)) + .gsi1sk(now) + .userId(userId) + .word(word) + .meaning(meaning) + .pronunciation(pronunciation) + .context(context) + .articleId(articleId) + .articleTitle(articleTitle) + .collectedAt(now) + .syncedToVocab(false) + .build(); + + newsWordRepository.save(wordCollect); + logger.info("단어 수집 완료: userId={}, word={}, articleId={}", userId, word, articleId); + + return wordCollect; + } + + /** + * 수집한 단어 삭제 + */ + public void deleteWord(String userId, String word, String articleId) { + newsWordRepository.delete(userId, word, articleId); + logger.info("단어 삭제: userId={}, word={}", userId, word); + } + + /** + * 사용자 수집 단어 목록 조회 + */ + public List getUserWords(String userId, int limit) { + return newsWordRepository.getUserWords(userId, limit); + } + + /** + * 사용자 수집 단어 수 조회 + */ + public int countUserWords(String userId) { + return newsWordRepository.countUserWords(userId); + } + + /** + * 단어 상세 정보 조회 + */ + public Optional getWordDetail(String word) { + String wordId = word.toLowerCase().trim(); + Optional wordOpt = wordRepository.findById(wordId); + + if (wordOpt.isEmpty()) { + return Optional.empty(); + } + + Word w = wordOpt.get(); + return Optional.of(WordDetail.builder() + .word(w.getEnglish()) + .meaning(w.getKorean()) + .pronunciation("") + .example(w.getExample()) + .level(w.getLevel()) + .build()); + } + + /** + * Vocabulary 도메인으로 단어 연동 + */ + public boolean syncToVocabulary(String userId, String word, String articleId) { + Optional wordOpt = newsWordRepository.findByUserWordArticle(userId, word, articleId); + if (wordOpt.isEmpty()) { + logger.warn("수집한 단어를 찾을 수 없음: userId={}, word={}", userId, word); + return false; + } + + NewsWordCollect wordCollect = wordOpt.get(); + + // 이미 연동됐는지 확인 + if (Boolean.TRUE.equals(wordCollect.getSyncedToVocab())) { + logger.info("이미 Vocabulary에 연동됨: userId={}, word={}", userId, word); + return true; + } + + // Word 테이블에서 단어 조회 + String wordId = word.toLowerCase().trim(); + Optional vocabWord = wordRepository.findById(wordId); + + if (vocabWord.isEmpty()) { + logger.warn("Vocabulary에 없는 단어: {}", word); + return false; + } + + // UserWord 생성 (NEW 상태로) + userWordCommandService.updateWordStatus(userId, wordId, "NEW"); + + // 연동 상태 업데이트 + newsWordRepository.updateSyncStatus(userId, word, articleId, wordId); + + logger.info("Vocabulary 연동 완료: userId={}, word={}", userId, word); + return true; + } + + /** + * 사용자 단어 수집 통계 + */ + public Map getUserWordStats(String userId) { + int totalWords = newsWordRepository.countUserWords(userId); + List recentWords = newsWordRepository.getUserWords(userId, 5); + long syncedCount = recentWords.stream() + .filter(w -> Boolean.TRUE.equals(w.getSyncedToVocab())) + .count(); + + return Map.of( + "totalCollected", totalWords, + "recentWords", recentWords, + "syncedToVocab", syncedCount + ); + } + + /** + * 단어 상세 정보 + */ + @lombok.Data + @lombok.Builder + @lombok.NoArgsConstructor + @lombok.AllArgsConstructor + public static class WordDetail { + private String word; + private String meaning; + private String pronunciation; + private String example; + private String level; + } +} diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index f701a42f..15fd4280 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -1762,6 +1762,8 @@ Resources: Policies: - DynamoDBCrudPolicy: TableName: !Ref NewsTable + - DynamoDBCrudPolicy: + TableName: !Ref VocabularyTable - S3CrudPolicy: BucketName: !Ref ContentBucket - Statement: @@ -1800,6 +1802,36 @@ Resources: RestApiId: !Ref MainApi Path: /news/bookmarks Method: GET + GetUserWords: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/words + Method: GET + SyncWordToVocab: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/words/{word}/sync + Method: POST + CollectWord: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/{articleId}/words + Method: POST + DeleteWord: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/{articleId}/words/{word} + Method: DELETE + GetWordDetail: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/{articleId}/words/{word} + Method: GET GetQuizHistory: Type: Api Properties: From 3f947e28a7938f612524c84cda6d79126601255d Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 14:39:26 +0900 Subject: [PATCH 442/528] feat: add multi-environment deployment support (dev/test/prod) --- ServerlessFunction/buildspec-dev.yml | 59 ++++++++++++++ ServerlessFunction/buildspec-prod.yml | 59 ++++++++++++++ ServerlessFunction/buildspec-test.yml | 59 ++++++++++++++ ServerlessFunction/template.yaml | 111 ++++++++++++++------------ 4 files changed, 238 insertions(+), 50 deletions(-) create mode 100644 ServerlessFunction/buildspec-dev.yml create mode 100644 ServerlessFunction/buildspec-prod.yml create mode 100644 ServerlessFunction/buildspec-test.yml diff --git a/ServerlessFunction/buildspec-dev.yml b/ServerlessFunction/buildspec-dev.yml new file mode 100644 index 00000000..78a12758 --- /dev/null +++ b/ServerlessFunction/buildspec-dev.yml @@ -0,0 +1,59 @@ +version: 0.2 + +env: + variables: + SAM_CLI_TELEMETRY: 0 + GRADLE_OPTS: "-Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.caching=true" + ENVIRONMENT: dev + STACK_NAME: group2-englishstudy-dev + +phases: + install: + commands: + - echo "Verifying pre-installed tools..." + - java -version + - sam --version + - echo "Tools verified" + + pre_build: + commands: + - echo "Running tests..." + - cd ServerlessFunction + - chmod +x gradlew + - ./gradlew test --build-cache --parallel + - echo "Tests completed" + + build: + commands: + - echo "Building SAM application for $ENVIRONMENT..." + - cd $CODEBUILD_SRC_DIR/ServerlessFunction + - sam build --parallel --cached + - echo "Build completed" + + post_build: + commands: + - echo "Deploying to $ENVIRONMENT environment..." + - cd $CODEBUILD_SRC_DIR/ServerlessFunction + - | + sam deploy \ + --stack-name $STACK_NAME \ + --resolve-s3 \ + --s3-prefix $STACK_NAME \ + --region ap-northeast-2 \ + --capabilities CAPABILITY_IAM CAPABILITY_AUTO_EXPAND \ + --no-confirm-changeset \ + --no-fail-on-empty-changeset \ + --parameter-overrides Environment=$ENVIRONMENT + - echo "Deployment completed on $(date)" + +cache: + paths: + - '/root/.gradle/caches/**/*' + - '/root/.gradle/wrapper/**/*' + - '.aws-sam/cache/**/*' + +reports: + junit-reports: + files: + - 'ServerlessFunction/build/test-results/test/*.xml' + file-format: JUNITXML diff --git a/ServerlessFunction/buildspec-prod.yml b/ServerlessFunction/buildspec-prod.yml new file mode 100644 index 00000000..0aa83787 --- /dev/null +++ b/ServerlessFunction/buildspec-prod.yml @@ -0,0 +1,59 @@ +version: 0.2 + +env: + variables: + SAM_CLI_TELEMETRY: 0 + GRADLE_OPTS: "-Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.caching=true" + ENVIRONMENT: prod + STACK_NAME: group2-englishstudy-prod + +phases: + install: + commands: + - echo "Verifying pre-installed tools..." + - java -version + - sam --version + - echo "Tools verified" + + pre_build: + commands: + - echo "Running tests..." + - cd ServerlessFunction + - chmod +x gradlew + - ./gradlew test --build-cache --parallel + - echo "Tests completed" + + build: + commands: + - echo "Building SAM application for $ENVIRONMENT..." + - cd $CODEBUILD_SRC_DIR/ServerlessFunction + - sam build --parallel --cached + - echo "Build completed" + + post_build: + commands: + - echo "Deploying to $ENVIRONMENT environment..." + - cd $CODEBUILD_SRC_DIR/ServerlessFunction + - | + sam deploy \ + --stack-name $STACK_NAME \ + --s3-bucket group2-englishstudy-pipeline-artifacts \ + --s3-prefix sam-deploy/prod \ + --region ap-northeast-2 \ + --capabilities CAPABILITY_IAM CAPABILITY_AUTO_EXPAND \ + --no-confirm-changeset \ + --no-fail-on-empty-changeset \ + --parameter-overrides Environment=$ENVIRONMENT + - echo "Deployment completed on $(date)" + +cache: + paths: + - '/root/.gradle/caches/**/*' + - '/root/.gradle/wrapper/**/*' + - '.aws-sam/cache/**/*' + +reports: + junit-reports: + files: + - 'ServerlessFunction/build/test-results/test/*.xml' + file-format: JUNITXML diff --git a/ServerlessFunction/buildspec-test.yml b/ServerlessFunction/buildspec-test.yml new file mode 100644 index 00000000..b74b041c --- /dev/null +++ b/ServerlessFunction/buildspec-test.yml @@ -0,0 +1,59 @@ +version: 0.2 + +env: + variables: + SAM_CLI_TELEMETRY: 0 + GRADLE_OPTS: "-Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.caching=true" + ENVIRONMENT: test + STACK_NAME: group2-englishstudy-test + +phases: + install: + commands: + - echo "Verifying pre-installed tools..." + - java -version + - sam --version + - echo "Tools verified" + + pre_build: + commands: + - echo "Running tests..." + - cd ServerlessFunction + - chmod +x gradlew + - ./gradlew test --build-cache --parallel + - echo "Tests completed" + + build: + commands: + - echo "Building SAM application for $ENVIRONMENT..." + - cd $CODEBUILD_SRC_DIR/ServerlessFunction + - sam build --parallel --cached + - echo "Build completed" + + post_build: + commands: + - echo "Deploying to $ENVIRONMENT environment..." + - cd $CODEBUILD_SRC_DIR/ServerlessFunction + - | + sam deploy \ + --stack-name $STACK_NAME \ + --resolve-s3 \ + --s3-prefix $STACK_NAME \ + --region ap-northeast-2 \ + --capabilities CAPABILITY_IAM CAPABILITY_AUTO_EXPAND \ + --no-confirm-changeset \ + --no-fail-on-empty-changeset \ + --parameter-overrides Environment=$ENVIRONMENT + - echo "Deployment completed on $(date)" + +cache: + paths: + - '/root/.gradle/caches/**/*' + - '/root/.gradle/wrapper/**/*' + - '.aws-sam/cache/**/*' + +reports: + junit-reports: + files: + - 'ServerlessFunction/build/test-results/test/*.xml' + file-format: JUNITXML diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 15fd4280..c7e28a37 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -2,6 +2,16 @@ AWSTemplateFormatVersion: '2010-09-09' Transform: AWS::Serverless-2016-10-31 Description: Group2 English Study - Unified API (Chatting + Vocabulary) +Parameters: + Environment: + Type: String + Default: dev + AllowedValues: + - dev + - test + - prod + Description: Deployment environment + Globals: Function: Timeout: 30 @@ -17,11 +27,12 @@ Globals: VOCAB_TABLE_NAME: !Ref VocabTable OPIC_TABLE_NAME: !Ref OPIcTable NEWS_TABLE_NAME: !Ref NewsTable - BUCKET_NAME: group2-englishstudy - CHAT_BUCKET_NAME: group2-englishstudy - VOCAB_BUCKET_NAME: group2-englishstudy - PROFILE_BUCKET_NAME: group2-englishstudy - OPIC_BUCKET_NAME: group2-englishstudy + BUCKET_NAME: !Sub "${AWS::StackName}" + CHAT_BUCKET_NAME: !Sub "${AWS::StackName}" + VOCAB_BUCKET_NAME: !Sub "${AWS::StackName}" + PROFILE_BUCKET_NAME: !Sub "${AWS::StackName}" + OPIC_BUCKET_NAME: !Sub "${AWS::StackName}" + NEWS_BUCKET_NAME: !Sub "${AWS::StackName}" AWS_REGION_NAME: !Ref AWS::Region ROOM_TOKEN_TTL_SECONDS: "300" TRANSCRIBE_PROXY_URL: "https://tfo1zm7vec.execute-api.ap-northeast-2.amazonaws.com/prod/transcribe" @@ -101,7 +112,7 @@ Resources: Timeout: 10 Environment: Variables: - DEFAULT_PROFILE_URL: https://group2-englishstudy.s3.amazonaws.com/profile/default.png + DEFAULT_PROFILE_URL: !Sub "https://${AWS::StackName}.s3.amazonaws.com/profile/default.png" # 회원가입 시점에 사용자 모든 정보가 DB에 저장 Lambda 함수 PostConfirmationFunction: @@ -138,8 +149,8 @@ Resources: MainApi: Type: AWS::Serverless::Api Properties: - Name: group2-englishstudy-api - StageName: dev + Name: !Sub "${AWS::StackName}-api" + StageName: !Ref Environment Cors: AllowMethods: "'GET,POST,PUT,DELETE,OPTIONS'" AllowHeaders: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" @@ -200,7 +211,7 @@ Resources: WebSocketApi: Type: AWS::ApiGatewayV2::Api Properties: - Name: group2-englishstudy-websocket + Name: !Sub "${AWS::StackName}-websocket" ProtocolType: WEBSOCKET RouteSelectionExpression: "$request.body.action" @@ -266,7 +277,7 @@ Resources: WebSocketConnectFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-ws-connect + FunctionName: !Sub "${AWS::StackName}-ws-connect" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.websocket.WebSocketConnectHandler::handleRequest Description: Handle WebSocket $connect @@ -296,7 +307,7 @@ Resources: WebSocketDisconnectFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-ws-disconnect + FunctionName: !Sub "${AWS::StackName}-ws-disconnect" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.websocket.WebSocketDisconnectHandler::handleRequest Description: Handle WebSocket $disconnect @@ -325,7 +336,7 @@ Resources: WebSocketMessageFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-ws-message + FunctionName: !Sub "${AWS::StackName}-ws-message" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.websocket.WebSocketMessageHandler::handleRequest Description: Handle WebSocket sendMessage @@ -385,7 +396,7 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref UserTable - S3CrudPolicy: - BucketName: group2-englishstudy + BucketName: !Sub "${AWS::StackName}" Events: GetMyProfile: Type: Api @@ -413,7 +424,7 @@ Resources: ChatRoomFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-chat-room-handler + FunctionName: !Sub "${AWS::StackName}-chat-room-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.ChatRoomHandler::handleRequest Description: Handle chat room CRUD operations @@ -485,7 +496,7 @@ Resources: GameFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-game-handler + FunctionName: !Sub "${AWS::StackName}-game-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.GameHandler::handleRequest Description: Handle catch-mind game operations @@ -557,7 +568,7 @@ Resources: GameAutoCloseFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-game-auto-close + FunctionName: !Sub "${AWS::StackName}-game-auto-close" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.GameAutoCloseHandler::handleRequest Description: Auto-close game after 7 minutes @@ -608,7 +619,7 @@ Resources: ChatMessageFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-chat-message-handler + FunctionName: !Sub "${AWS::StackName}-chat-message-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.ChatMessageHandler::handleRequest Description: Handle chat messages @@ -618,7 +629,7 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref ChatTable - S3CrudPolicy: - BucketName: group2-englishstudy + BucketName: !Sub "${AWS::StackName}" - Statement: - Effect: Allow Action: @@ -660,7 +671,7 @@ Resources: ChatVoiceFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-chat-voice-handler + FunctionName: !Sub "${AWS::StackName}-chat-voice-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.ChatVoiceHandler::handleRequest Description: Convert text to speech using Polly @@ -670,7 +681,7 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref ChatTable - S3CrudPolicy: - BucketName: group2-englishstudy + BucketName: !Sub "${AWS::StackName}" - Statement: - Effect: Allow Action: @@ -694,7 +705,7 @@ Resources: WordFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-vocab-word-handler + FunctionName: !Sub "${AWS::StackName}-vocab-word-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.WordHandler::handleRequest Description: Handle word CRUD operations @@ -772,7 +783,7 @@ Resources: UserWordFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-vocab-userword-handler + FunctionName: !Sub "${AWS::StackName}-vocab-userword-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.UserWordHandler::handleRequest Description: Handle user word learning status @@ -834,7 +845,7 @@ Resources: WordGroupFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-vocab-wordgroup-handler + FunctionName: !Sub "${AWS::StackName}-vocab-wordgroup-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.WordGroupHandler::handleRequest Description: Handle user custom word groups @@ -904,7 +915,7 @@ Resources: DailyStudyFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-vocab-daily-handler + FunctionName: !Sub "${AWS::StackName}-vocab-daily-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.DailyStudyHandler::handleRequest Description: Handle daily study word assignment @@ -934,7 +945,7 @@ Resources: TestFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-vocab-test-handler + FunctionName: !Sub "${AWS::StackName}-vocab-test-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.TestHandler::handleRequest Description: Handle vocabulary tests @@ -993,7 +1004,7 @@ Resources: StatsFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-vocab-stats-handler + FunctionName: !Sub "${AWS::StackName}-vocab-stats-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.StatsHandler::handleRequest Description: Handle user learning statistics @@ -1031,7 +1042,7 @@ Resources: VocabVoiceFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-vocab-voice-handler + FunctionName: !Sub "${AWS::StackName}-vocab-voice-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.VoiceHandler::handleRequest Description: Convert word to speech using Polly @@ -1041,7 +1052,7 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref VocabTable - S3CrudPolicy: - BucketName: group2-englishstudy + BucketName: !Sub "${AWS::StackName}" - Statement: - Effect: Allow Action: @@ -1066,7 +1077,7 @@ Resources: StatsStreamFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-stats-stream-handler + FunctionName: !Sub "${AWS::StackName}-stats-stream-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.stats.handler.StatsStreamHandler::handleRequest Description: Process DynamoDB Streams for stats aggregation @@ -1091,7 +1102,7 @@ Resources: UserStatsFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-user-stats-handler + FunctionName: !Sub "${AWS::StackName}-user-stats-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.stats.handler.UserStatsHandler::handleRequest Description: Handle user learning statistics API @@ -1146,7 +1157,7 @@ Resources: BadgeFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-badge-handler + FunctionName: !Sub "${AWS::StackName}-badge-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.badge.handler.BadgeHandler::handleRequest Description: Handle user badges and achievements @@ -1156,7 +1167,7 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref VocabTable - S3ReadPolicy: - BucketName: group2-englishstudy + BucketName: !Sub "${AWS::StackName}" Events: GetAllBadges: Type: Api @@ -1182,7 +1193,7 @@ Resources: GrammarFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-grammar-handler + FunctionName: !Sub "${AWS::StackName}-grammar-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.grammar.handler.GrammarHandler::handleRequest Description: Handle grammar check using Bedrock AI @@ -1261,7 +1272,7 @@ Resources: GrammarWebSocketApi: Type: AWS::ApiGatewayV2::Api Properties: - Name: group2-englishstudy-grammar-websocket + Name: !Sub "${AWS::StackName}-grammar-websocket" ProtocolType: WEBSOCKET RouteSelectionExpression: "$request.body.action" @@ -1324,7 +1335,7 @@ Resources: GrammarStreamingConnectFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-grammar-ws-connect + FunctionName: !Sub "${AWS::StackName}-grammar-ws-connect" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.grammar.handler.websocket.GrammarStreamingConnectHandler::handleRequest Description: Handle Grammar WebSocket $connect with JWT auth @@ -1351,7 +1362,7 @@ Resources: GrammarStreamingDisconnectFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-grammar-ws-disconnect + FunctionName: !Sub "${AWS::StackName}-grammar-ws-disconnect" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.grammar.handler.websocket.GrammarStreamingDisconnectHandler::handleRequest Description: Handle Grammar WebSocket $disconnect @@ -1378,7 +1389,7 @@ Resources: GrammarStreamingFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-grammar-ws-streaming + FunctionName: !Sub "${AWS::StackName}-grammar-ws-streaming" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.grammar.handler.websocket.GrammarStreamingHandler::handleRequest Description: Handle Grammar streaming with Bedrock @@ -1414,7 +1425,7 @@ Resources: ScheduledStatsFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-scheduled-stats + FunctionName: !Sub "${AWS::StackName}-scheduled-stats" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.stats.handler.ScheduledStatsHandler::handleRequest Description: Daily scheduled job for word learning stats aggregation @@ -1441,7 +1452,7 @@ Resources: OPIcSessionFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-opic-session-handler + FunctionName: !Sub "${AWS::StackName}-opic-session-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.opic.handler.OPIcSessionHandler::handleRequest Description: Handle OPIc speaking practice sessions @@ -1456,7 +1467,7 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref OPIcTable - S3CrudPolicy: - BucketName: group2-englishstudy + BucketName: !Sub "${AWS::StackName}" - Statement: - Effect: Allow Action: @@ -1549,7 +1560,7 @@ Resources: DeletionPolicy: Retain # UpdateReplacePolicy: Retain Properties: - TableName: group2-englishstudy-user + TableName: !Sub "${AWS::StackName}-user" BillingMode: PAY_PER_REQUEST AttributeDefinitions: - AttributeName: PK @@ -1594,7 +1605,7 @@ Resources: ChatTable: Type: AWS::DynamoDB::Table Properties: - TableName: group2-englishstudy-chat + TableName: !Sub "${AWS::StackName}-chat" BillingMode: PAY_PER_REQUEST AttributeDefinitions: - AttributeName: PK @@ -1638,7 +1649,7 @@ Resources: VocabTable: Type: AWS::DynamoDB::Table Properties: - TableName: group2-englishstudy-vocab + TableName: !Sub "${AWS::StackName}-vocab" BillingMode: PAY_PER_REQUEST StreamSpecification: StreamViewType: NEW_IMAGE @@ -1696,7 +1707,7 @@ Resources: OPIcTable: Type: AWS::DynamoDB::Table Properties: - TableName: group2-englishstudy-opic + TableName: !Sub "${AWS::StackName}-opic" BillingMode: PAY_PER_REQUEST AttributeDefinitions: - AttributeName: PK @@ -1878,7 +1889,7 @@ Resources: NewsTable: Type: AWS::DynamoDB::Table Properties: - TableName: group2-englishstudy-news + TableName: !Sub "${AWS::StackName}-news" BillingMode: PAY_PER_REQUEST AttributeDefinitions: - AttributeName: PK @@ -1927,20 +1938,20 @@ Resources: TestResultTopic: Type: AWS::SNS::Topic Properties: - TopicName: group2-englishstudy-test-result-topic + TopicName: !Sub "${AWS::StackName}-test-result-topic" # SQS Dead Letter Queue - 실패한 메시지 보관 StatisticsDeadLetterQueue: Type: AWS::SQS::Queue Properties: - QueueName: group2-englishstudy-statistics-dlq + QueueName: !Sub "${AWS::StackName}-statistics-dlq" MessageRetentionPeriod: 1209600 # 14일 # SQS Queue - 통계 처리용 StatisticsQueue: Type: AWS::SQS::Queue Properties: - QueueName: group2-englishstudy-statistics-queue + QueueName: !Sub "${AWS::StackName}-statistics-queue" VisibilityTimeout: 60 RedrivePolicy: deadLetterTargetArn: !GetAtt StatisticsDeadLetterQueue.Arn @@ -1976,7 +1987,7 @@ Resources: StatisticsProcessorFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-statistics-processor + FunctionName: !Sub "${AWS::StackName}-statistics-processor" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.StatisticsHandler::handleRequest Description: Process test results and update user word statistics @@ -2022,7 +2033,7 @@ Outputs: BucketName: Description: S3 Bucket Name - Value: group2-englishstudy + Value: !Sub "${AWS::StackName}" CognitoUserPoolId: Description: Cognito User Pool ID From 618d4d8ca6ee359451a22a0b855e918c5d22c7e0 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 14:47:22 +0900 Subject: [PATCH 443/528] fix: update buildspec.yml to deploy prod environment with parameter overrides --- ServerlessFunction/buildspec.yml | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/ServerlessFunction/buildspec.yml b/ServerlessFunction/buildspec.yml index 49ed2a4f..0779bea5 100644 --- a/ServerlessFunction/buildspec.yml +++ b/ServerlessFunction/buildspec.yml @@ -4,6 +4,8 @@ env: variables: SAM_CLI_TELEMETRY: 0 GRADLE_OPTS: "-Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.caching=true" + ENVIRONMENT: prod + STACK_NAME: group2-englishstudy-prod phases: install: @@ -23,20 +25,26 @@ phases: build: commands: - - echo "Building SAM application..." + - echo "Building SAM application for $ENVIRONMENT..." - cd $CODEBUILD_SRC_DIR/ServerlessFunction - sam build --parallel --cached - - echo "Packaging SAM application..." - - sam package --s3-bucket group2-englishstudy-pipeline-artifacts --s3-prefix sam-packages --output-template-file packaged-template.yaml + - echo "Build completed" post_build: commands: - - echo "Build completed on $(date)" - -artifacts: - files: - - packaged-template.yaml - base-directory: ServerlessFunction + - echo "Deploying to $ENVIRONMENT environment..." + - cd $CODEBUILD_SRC_DIR/ServerlessFunction + - | + sam deploy \ + --stack-name $STACK_NAME \ + --s3-bucket group2-englishstudy-pipeline-artifacts \ + --s3-prefix sam-deploy/prod \ + --region ap-northeast-2 \ + --capabilities CAPABILITY_IAM CAPABILITY_AUTO_EXPAND \ + --no-confirm-changeset \ + --no-fail-on-empty-changeset \ + --parameter-overrides Environment=$ENVIRONMENT + - echo "Deployment completed on $(date)" cache: paths: From 481a54928d99147e4d631b121fc6078186cb8dbf Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 12:53:58 +0900 Subject: [PATCH 444/528] =?UTF-8?q?feat(news):=20=EB=89=B4=EC=8A=A4=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EA=B8=B0=EB=B0=98=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=EA=B5=AC=EC=B6=95=20(#385)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NewsCategory, QuizType enum 추가 - NewsKey 상수 클래스 추가 - NewsErrorCode 예외 클래스 추가 - NewsArticle, KeywordInfo, QuizQuestion 모델 추가 - NewsArticleRepository CRUD 구현 - NewsTable DynamoDB 테이블 정의 - CORS GatewayResponses 설정 추가 --- .gitignore | 1 + .../domain/news/constants/NewsKey.java | 122 +++++++ .../domain/news/enums/NewsCategory.java | 55 ++++ .../domain/news/enums/QuizType.java | 50 +++ .../domain/news/exception/NewsErrorCode.java | 77 +++++ .../domain/news/model/KeywordInfo.java | 24 ++ .../domain/news/model/NewsArticle.java | 92 ++++++ .../domain/news/model/QuizQuestion.java | 27 ++ .../repository/NewsArticleRepository.java | 297 ++++++++++++++++++ ServerlessFunction/template.yaml | 45 +++ 10 files changed, 790 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/constants/NewsKey.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/enums/NewsCategory.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/enums/QuizType.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/exception/NewsErrorCode.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/KeywordInfo.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsArticle.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/QuizQuestion.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsArticleRepository.java diff --git a/.gitignore b/.gitignore index 715a4d18..ee058388 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ # Claude .claude/ +.sisyphus/ # Build target/ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/constants/NewsKey.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/constants/NewsKey.java new file mode 100644 index 00000000..e5ba97b8 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/constants/NewsKey.java @@ -0,0 +1,122 @@ +package com.mzc.secondproject.serverless.domain.news.constants; + +import com.mzc.secondproject.serverless.common.constants.DynamoDbKey; + +/** + * 뉴스 도메인 DynamoDB 키 상수 및 빌더 + */ +public final class NewsKey { + + // Entity Prefixes + public static final String NEWS = "NEWS#"; + public static final String ARTICLE = "ARTICLE#"; + public static final String LEVEL = "LEVEL#"; + public static final String CATEGORY = "CATEGORY#"; + public static final String READ = "READ#"; + public static final String QUIZ = "QUIZ#"; + public static final String WORD = "WORD#"; + public static final String BOOKMARK = "BOOKMARK#"; + public static final String COMMENT = "COMMENT#"; + public static final String STATS = "STATS"; + + // User Suffixes + public static final String SUFFIX_NEWS = "#NEWS"; + public static final String SUFFIX_NEWS_WORDS = "#NEWS_WORDS"; + public static final String SUFFIX_NEWS_COMMENTS = "#NEWS_COMMENTS"; + + private NewsKey() { + } + + // === Key Builders === + + /** + * 뉴스 기사 PK: NEWS#{date} + */ + public static String newsPk(String date) { + return NEWS + date; + } + + /** + * 뉴스 기사 SK: ARTICLE#{articleId} + */ + public static String articleSk(String articleId) { + return ARTICLE + articleId; + } + + /** + * 레벨별 조회 GSI1 PK: LEVEL#{level} + */ + public static String levelPk(String level) { + return LEVEL + level; + } + + /** + * 카테고리별 조회 GSI2 PK: CATEGORY#{category} + */ + public static String categoryPk(String category) { + return CATEGORY + category; + } + + /** + * 사용자 뉴스 활동 PK: USER#{userId}#NEWS + */ + public static String userNewsPk(String userId) { + return DynamoDbKey.USER + userId + SUFFIX_NEWS; + } + + /** + * 읽기 기록 SK: READ#{articleId} + */ + public static String readSk(String articleId) { + return READ + articleId; + } + + /** + * 퀴즈 결과 SK: QUIZ#{articleId} + */ + public static String quizSk(String articleId) { + return QUIZ + articleId; + } + + /** + * 단어 수집 SK: WORD#{word}#{articleId} + */ + public static String wordSk(String word, String articleId) { + return WORD + word + "#" + articleId; + } + + /** + * 북마크 SK: BOOKMARK#{articleId} + */ + public static String bookmarkSk(String articleId) { + return BOOKMARK + articleId; + } + + /** + * 사용자 수집 단어 GSI1 PK: USER#{userId}#NEWS_WORDS + */ + public static String userNewsWordsPk(String userId) { + return DynamoDbKey.USER + userId + SUFFIX_NEWS_WORDS; + } + + /** + * 댓글 PK: NEWS_COMMENT#{articleId} + */ + public static String commentPk(String articleId) { + return "NEWS_COMMENT#" + articleId; + } + + /** + * 댓글 SK: COMMENT#{commentId} + */ + public static String commentSk(String commentId) { + return COMMENT + commentId; + } + + /** + * 사용자 댓글 GSI1 PK: USER#{userId}#NEWS_COMMENTS + */ + public static String userNewsCommentsPk(String userId) { + return DynamoDbKey.USER + userId + SUFFIX_NEWS_COMMENTS; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/enums/NewsCategory.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/enums/NewsCategory.java new file mode 100644 index 00000000..7f88078f --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/enums/NewsCategory.java @@ -0,0 +1,55 @@ +package com.mzc.secondproject.serverless.domain.news.enums; + +import java.util.Arrays; + +/** + * 뉴스 카테고리 + */ +public enum NewsCategory { + TECH("tech", "기술"), + BUSINESS("business", "비즈니스"), + SPORTS("sports", "스포츠"), + ENTERTAINMENT("entertainment", "엔터테인먼트"), + WORLD("world", "세계"), + CULTURE("culture", "문화"), + SCIENCE("science", "과학"); + + private final String code; + private final String displayName; + + NewsCategory(String code, String displayName) { + this.code = code; + this.displayName = displayName; + } + + public static boolean isValid(String value) { + if (value == null) return false; + return Arrays.stream(values()) + .anyMatch(cat -> cat.name().equalsIgnoreCase(value) || cat.code.equalsIgnoreCase(value)); + } + + public static NewsCategory fromString(String value) { + if (value == null) { + throw new IllegalArgumentException("NewsCategory value cannot be null"); + } + return Arrays.stream(values()) + .filter(cat -> cat.name().equalsIgnoreCase(value) || cat.code.equalsIgnoreCase(value)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Unknown NewsCategory: " + value)); + } + + public static NewsCategory fromStringOrDefault(String value, NewsCategory defaultValue) { + if (value == null || !isValid(value)) { + return defaultValue; + } + return fromString(value); + } + + public String getCode() { + return code; + } + + public String getDisplayName() { + return displayName; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/enums/QuizType.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/enums/QuizType.java new file mode 100644 index 00000000..7b95a466 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/enums/QuizType.java @@ -0,0 +1,50 @@ +package com.mzc.secondproject.serverless.domain.news.enums; + +import java.util.Arrays; + +/** + * 뉴스 퀴즈 유형 + */ +public enum QuizType { + COMPREHENSION("comprehension", "독해 질문", 20), + WORD_MATCH("word_match", "단어-뜻 매칭", 15), + FILL_BLANK("fill_blank", "빈칸 채우기", 30); + + private final String code; + private final String displayName; + private final int defaultPoints; + + QuizType(String code, String displayName, int defaultPoints) { + this.code = code; + this.displayName = displayName; + this.defaultPoints = defaultPoints; + } + + public static boolean isValid(String value) { + if (value == null) return false; + return Arrays.stream(values()) + .anyMatch(type -> type.name().equalsIgnoreCase(value) || type.code.equalsIgnoreCase(value)); + } + + public static QuizType fromString(String value) { + if (value == null) { + throw new IllegalArgumentException("QuizType value cannot be null"); + } + return Arrays.stream(values()) + .filter(type -> type.name().equalsIgnoreCase(value) || type.code.equalsIgnoreCase(value)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Unknown QuizType: " + value)); + } + + public String getCode() { + return code; + } + + public String getDisplayName() { + return displayName; + } + + public int getDefaultPoints() { + return defaultPoints; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/exception/NewsErrorCode.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/exception/NewsErrorCode.java new file mode 100644 index 00000000..fa898252 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/exception/NewsErrorCode.java @@ -0,0 +1,77 @@ +package com.mzc.secondproject.serverless.domain.news.exception; + +import com.mzc.secondproject.serverless.common.exception.DomainErrorCode; + +/** + * 뉴스 도메인 에러 코드 + * 뉴스 기사, 퀴즈, 단어 수집, 댓글 관련 에러 코드를 정의합니다. + */ +public enum NewsErrorCode implements DomainErrorCode { + + // 뉴스 기사 관련 에러 + ARTICLE_NOT_FOUND("ARTICLE_001", "뉴스 기사를 찾을 수 없습니다", 404), + INVALID_ARTICLE_DATA("ARTICLE_002", "뉴스 기사 데이터가 유효하지 않습니다", 400), + ARTICLE_ALREADY_EXISTS("ARTICLE_003", "이미 존재하는 뉴스 기사입니다", 409), + + // 카테고리/레벨 관련 에러 + INVALID_CATEGORY("CATEGORY_001", "유효하지 않은 카테고리입니다", 400), + INVALID_LEVEL("LEVEL_001", "유효하지 않은 레벨입니다", 400), + + // 읽기 기록 관련 에러 + READ_RECORD_NOT_FOUND("READ_001", "읽기 기록을 찾을 수 없습니다", 404), + ALREADY_READ("READ_002", "이미 읽은 기사입니다", 409), + + // 퀴즈 관련 에러 + QUIZ_NOT_FOUND("QUIZ_001", "퀴즈를 찾을 수 없습니다", 404), + QUIZ_ALREADY_SUBMITTED("QUIZ_002", "이미 제출한 퀴즈입니다", 409), + INVALID_QUIZ_ANSWER("QUIZ_003", "유효하지 않은 퀴즈 답변입니다", 400), + + // 단어 수집 관련 에러 + WORD_ALREADY_COLLECTED("WORD_001", "이미 수집한 단어입니다", 409), + WORD_NOT_COLLECTED("WORD_002", "수집한 단어를 찾을 수 없습니다", 404), + + // 북마크 관련 에러 + BOOKMARK_NOT_FOUND("BOOKMARK_001", "북마크를 찾을 수 없습니다", 404), + ALREADY_BOOKMARKED("BOOKMARK_002", "이미 북마크한 기사입니다", 409), + BOOKMARK_LIMIT_EXCEEDED("BOOKMARK_003", "북마크 한도를 초과했습니다", 400), + + // 댓글 관련 에러 + COMMENT_NOT_FOUND("COMMENT_001", "댓글을 찾을 수 없습니다", 404), + COMMENT_NOT_OWNER("COMMENT_002", "댓글 작성자만 수정/삭제할 수 있습니다", 403), + INVALID_COMMENT_DATA("COMMENT_003", "유효하지 않은 댓글 데이터입니다", 400), + + // 통계 관련 에러 + STATS_NOT_FOUND("STATS_001", "통계 정보를 찾을 수 없습니다", 404); + + private static final String DOMAIN = "NEWS"; + + private final String code; + private final String message; + private final int statusCode; + + NewsErrorCode(String code, String message, int statusCode) { + this.code = code; + this.message = message; + this.statusCode = statusCode; + } + + @Override + public String getDomain() { + return DOMAIN; + } + + @Override + public String getCode() { + return code; + } + + @Override + public String getMessage() { + return message; + } + + @Override + public int getStatusCode() { + return statusCode; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/KeywordInfo.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/KeywordInfo.java new file mode 100644 index 00000000..81f1e1f5 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/KeywordInfo.java @@ -0,0 +1,24 @@ +package com.mzc.secondproject.serverless.domain.news.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; + +/** + * 뉴스 기사 내 키워드 정보 + * 단어, 뜻, 난이도, 위치 정보를 포함 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DynamoDbBean +public class KeywordInfo { + + private String word; // 영어 단어 + private String meaning; // 한국어 뜻 + private String level; // 단어 난이도 (BEGINNER, INTERMEDIATE, ADVANCED) + private Integer position; // 기사 내 위치 (문장 번호 또는 단어 인덱스) +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsArticle.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsArticle.java new file mode 100644 index 00000000..13fd8a19 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsArticle.java @@ -0,0 +1,92 @@ +package com.mzc.secondproject.serverless.domain.news.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.*; + +import java.util.List; + +/** + * 뉴스 기사 모델 + * PK: NEWS#{date} + * SK: ARTICLE#{articleId} + * GSI1: LEVEL#{level} / {publishedAt} - 레벨별 최신순 조회 + * GSI2: CATEGORY#{category} / {publishedAt} - 카테고리별 최신순 조회 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DynamoDbBean +public class NewsArticle { + + private String pk; // NEWS#{date} + private String sk; // ARTICLE#{articleId} + private String gsi1pk; // LEVEL#{level} + private String gsi1sk; // {publishedAt} + private String gsi2pk; // CATEGORY#{category} + private String gsi2sk; // {publishedAt} + + // 기본 정보 + private String articleId; + private String title; + private String summary; // AI 생성 3줄 요약 + private String originalUrl; // 원문 링크 + private String source; // BBC, VOA, NPR, NewsAPI + private String imageUrl; // 썸네일 이미지 + + // 분류 + private String category; // TECH, BUSINESS, SPORTS 등 + private String level; // BEGINNER, INTERMEDIATE, ADVANCED + private String cefrLevel; // A1, A2, B1, B2, C1, C2 (원본 CEFR 레벨) + + // AI 분석 결과 + private List keywords; // 핵심 단어 정보 + private List highlightWords; // 사용자 레벨 대비 어려운 단어 + private List quiz; // 퀴즈 문제 (5개) + + // 메타데이터 + private String publishedAt; // 원본 발행일 + private String collectedAt; // 수집일 + private Long readCount; // 조회수 + private Long commentCount; // 댓글수 + private Long ttl; + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { + return pk; + } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { + return sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1PK") + public String getGsi1pk() { + return gsi1pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1SK") + public String getGsi1sk() { + return gsi1sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI2") + @DynamoDbAttribute("GSI2PK") + public String getGsi2pk() { + return gsi2pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI2") + @DynamoDbAttribute("GSI2SK") + public String getGsi2sk() { + return gsi2sk; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/QuizQuestion.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/QuizQuestion.java new file mode 100644 index 00000000..6657ef33 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/QuizQuestion.java @@ -0,0 +1,27 @@ +package com.mzc.secondproject.serverless.domain.news.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; + +import java.util.List; + +/** + * 뉴스 퀴즈 문제 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DynamoDbBean +public class QuizQuestion { + + private String questionId; // 문제 ID (q1, q2, ...) + private String type; // COMPREHENSION, WORD_MATCH, FILL_BLANK + private String question; // 문제 내용 + private List options; // 선택지 (객관식인 경우) + private String correctAnswer; // 정답 + private Integer points; // 배점 +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsArticleRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsArticleRepository.java new file mode 100644 index 00000000..28ca35cc --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsArticleRepository.java @@ -0,0 +1,297 @@ +package com.mzc.secondproject.serverless.domain.news.repository; + +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.config.EnvConfig; +import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.common.util.CursorUtil; +import com.mzc.secondproject.serverless.domain.news.constants.NewsKey; +import com.mzc.secondproject.serverless.domain.news.model.NewsArticle; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.*; +import software.amazon.awssdk.enhanced.dynamodb.model.*; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; +import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * 뉴스 기사 Repository + */ +public class NewsArticleRepository { + + private static final Logger logger = LoggerFactory.getLogger(NewsArticleRepository.class); + private static final String TABLE_NAME = EnvConfig.getRequired("NEWS_TABLE_NAME"); + + private final DynamoDbEnhancedClient enhancedClient; + private final DynamoDbTable table; + + /** + * 기본 생성자 (Lambda에서 사용) + */ + public NewsArticleRepository() { + this(AwsClients.dynamoDbEnhanced()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public NewsArticleRepository(DynamoDbEnhancedClient enhancedClient) { + this.enhancedClient = enhancedClient; + this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(NewsArticle.class)); + } + + /** + * 뉴스 기사 저장 + */ + public NewsArticle save(NewsArticle article) { + logger.info("Saving news article: {}", article.getArticleId()); + table.putItem(article); + return article; + } + + /** + * 뉴스 기사 조회 (날짜 + 기사ID) + */ + public Optional findByDateAndId(String date, String articleId) { + Key key = Key.builder() + .partitionValue(NewsKey.newsPk(date)) + .sortValue(NewsKey.articleSk(articleId)) + .build(); + + NewsArticle article = table.getItem(key); + return Optional.ofNullable(article); + } + + /** + * 뉴스 기사 조회 (기사ID만으로 - GSI 활용 또는 Scan) + * 참고: 실제로는 articleId로 date를 알 수 있도록 설계하거나 GSI 추가 필요 + */ + public Optional findById(String articleId) { + Expression filterExpression = Expression.builder() + .expression("articleId = :articleId") + .putExpressionValue(":articleId", AttributeValue.builder().s(articleId).build()) + .build(); + + ScanEnhancedRequest request = ScanEnhancedRequest.builder() + .filterExpression(filterExpression) + .limit(1) + .build(); + + for (Page page : table.scan(request)) { + List items = page.items(); + if (!items.isEmpty()) { + return Optional.of(items.get(0)); + } + } + return Optional.empty(); + } + + /** + * 뉴스 기사 삭제 + */ + public void delete(String date, String articleId) { + Key key = Key.builder() + .partitionValue(NewsKey.newsPk(date)) + .sortValue(NewsKey.articleSk(articleId)) + .build(); + + table.deleteItem(key); + logger.info("Deleted news article: {}", articleId); + } + + /** + * 날짜별 뉴스 기사 조회 (페이지네이션) + */ + public PaginatedResult findByDate(String date, int limit, String cursor) { + QueryConditional queryConditional = QueryConditional + .keyEqualTo(Key.builder().partitionValue(NewsKey.newsPk(date)).build()); + + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .scanIndexForward(false) // 최신순 (SK 역순) + .limit(limit); + + if (cursor != null && !cursor.isEmpty()) { + Map exclusiveStartKey = CursorUtil.decode(cursor); + if (exclusiveStartKey != null) { + requestBuilder.exclusiveStartKey(exclusiveStartKey); + } + } + + Page page = table.query(requestBuilder.build()).iterator().next(); + String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); + + return new PaginatedResult<>(page.items(), nextCursor); + } + + /** + * 레벨별 뉴스 기사 조회 (GSI1 - 최신순) + */ + public PaginatedResult findByLevel(String level, int limit, String cursor) { + QueryConditional queryConditional = QueryConditional + .keyEqualTo(Key.builder().partitionValue(NewsKey.levelPk(level.toUpperCase())).build()); + + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .scanIndexForward(false) // 최신순 + .limit(limit); + + if (cursor != null && !cursor.isEmpty()) { + Map exclusiveStartKey = CursorUtil.decode(cursor); + if (exclusiveStartKey != null) { + requestBuilder.exclusiveStartKey(exclusiveStartKey); + } + } + + DynamoDbIndex gsi1 = table.index("GSI1"); + Page page = gsi1.query(requestBuilder.build()).iterator().next(); + String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); + + return new PaginatedResult<>(page.items(), nextCursor); + } + + /** + * 카테고리별 뉴스 기사 조회 (GSI2 - 최신순) + */ + public PaginatedResult findByCategory(String category, int limit, String cursor) { + QueryConditional queryConditional = QueryConditional + .keyEqualTo(Key.builder().partitionValue(NewsKey.categoryPk(category.toUpperCase())).build()); + + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .scanIndexForward(false) // 최신순 + .limit(limit); + + if (cursor != null && !cursor.isEmpty()) { + Map exclusiveStartKey = CursorUtil.decode(cursor); + if (exclusiveStartKey != null) { + requestBuilder.exclusiveStartKey(exclusiveStartKey); + } + } + + DynamoDbIndex gsi2 = table.index("GSI2"); + Page page = gsi2.query(requestBuilder.build()).iterator().next(); + String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); + + return new PaginatedResult<>(page.items(), nextCursor); + } + + /** + * 레벨 + 카테고리 필터 조회 (GSI1 쿼리 후 필터) + */ + public PaginatedResult findByLevelAndCategory(String level, String category, int limit, String cursor) { + Expression filterExpression = Expression.builder() + .expression("category = :category") + .putExpressionValue(":category", AttributeValue.builder().s(category.toUpperCase()).build()) + .build(); + + QueryConditional queryConditional = QueryConditional + .keyEqualTo(Key.builder().partitionValue(NewsKey.levelPk(level.toUpperCase())).build()); + + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .filterExpression(filterExpression) + .scanIndexForward(false) + .limit(limit * 2); // 필터 적용되므로 넉넉히 + + if (cursor != null && !cursor.isEmpty()) { + Map exclusiveStartKey = CursorUtil.decode(cursor); + if (exclusiveStartKey != null) { + requestBuilder.exclusiveStartKey(exclusiveStartKey); + } + } + + DynamoDbIndex gsi1 = table.index("GSI1"); + List results = new ArrayList<>(); + Map lastKey = null; + + for (Page page : gsi1.query(requestBuilder.build())) { + for (NewsArticle article : page.items()) { + results.add(article); + if (results.size() >= limit) break; + } + lastKey = page.lastEvaluatedKey(); + if (results.size() >= limit) break; + } + + String nextCursor = results.size() >= limit ? CursorUtil.encode(lastKey) : null; + return new PaginatedResult<>(results.subList(0, Math.min(results.size(), limit)), nextCursor); + } + + /** + * 조회수 증가 (Atomic Update) + */ + public void incrementReadCount(String date, String articleId) { + Map key = Map.of( + "PK", AttributeValue.builder().s(NewsKey.newsPk(date)).build(), + "SK", AttributeValue.builder().s(NewsKey.articleSk(articleId)).build() + ); + + Map values = Map.of( + ":zero", AttributeValue.builder().n("0").build(), + ":inc", AttributeValue.builder().n("1").build() + ); + + UpdateItemRequest request = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(key) + .updateExpression("SET readCount = if_not_exists(readCount, :zero) + :inc") + .expressionAttributeValues(values) + .build(); + + AwsClients.dynamoDb().updateItem(request); + logger.debug("Incremented read count for article: {}", articleId); + } + + /** + * 댓글수 증가 (Atomic Update) + */ + public void incrementCommentCount(String date, String articleId) { + Map key = Map.of( + "PK", AttributeValue.builder().s(NewsKey.newsPk(date)).build(), + "SK", AttributeValue.builder().s(NewsKey.articleSk(articleId)).build() + ); + + Map values = Map.of( + ":zero", AttributeValue.builder().n("0").build(), + ":inc", AttributeValue.builder().n("1").build() + ); + + UpdateItemRequest request = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(key) + .updateExpression("SET commentCount = if_not_exists(commentCount, :zero) + :inc") + .expressionAttributeValues(values) + .build(); + + AwsClients.dynamoDb().updateItem(request); + } + + /** + * 댓글수 감소 (Atomic Update) + */ + public void decrementCommentCount(String date, String articleId) { + Map key = Map.of( + "PK", AttributeValue.builder().s(NewsKey.newsPk(date)).build(), + "SK", AttributeValue.builder().s(NewsKey.articleSk(articleId)).build() + ); + + Map values = Map.of( + ":one", AttributeValue.builder().n("1").build(), + ":dec", AttributeValue.builder().n("1").build() + ); + + UpdateItemRequest request = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(key) + .updateExpression("SET commentCount = if_not_exists(commentCount, :one) - :dec") + .expressionAttributeValues(values) + .build(); + + AwsClients.dynamoDb().updateItem(request); + } +} diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 488fc843..a4cb6044 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -16,6 +16,7 @@ Globals: CHAT_TABLE_NAME: !Ref ChatTable VOCAB_TABLE_NAME: !Ref VocabTable OPIC_TABLE_NAME: !Ref OPIcTable + NEWS_TABLE_NAME: !Ref NewsTable BUCKET_NAME: group2-englishstudy CHAT_BUCKET_NAME: group2-englishstudy VOCAB_BUCKET_NAME: group2-englishstudy @@ -1724,6 +1725,50 @@ Resources: AttributeName: ttl Enabled: true + NewsTable: + Type: AWS::DynamoDB::Table + Properties: + TableName: group2-englishstudy-news + BillingMode: PAY_PER_REQUEST + AttributeDefinitions: + - AttributeName: PK + AttributeType: S + - AttributeName: SK + AttributeType: S + - AttributeName: GSI1PK + AttributeType: S + - AttributeName: GSI1SK + AttributeType: S + - AttributeName: GSI2PK + AttributeType: S + - AttributeName: GSI2SK + AttributeType: S + KeySchema: + - AttributeName: PK + KeyType: HASH + - AttributeName: SK + KeyType: RANGE + GlobalSecondaryIndexes: + - IndexName: GSI1 + KeySchema: + - AttributeName: GSI1PK + KeyType: HASH + - AttributeName: GSI1SK + KeyType: RANGE + Projection: + ProjectionType: ALL + - IndexName: GSI2 + KeySchema: + - AttributeName: GSI2PK + KeyType: HASH + - AttributeName: GSI2SK + KeyType: RANGE + Projection: + ProjectionType: ALL + TimeToLiveSpecification: + AttributeName: ttl + Enabled: true + ############################################# # SNS / SQS for Async Statistics Processing ############################################# From 0346084fae51dae17f45117c46fda2fb3d042f51 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 13:27:18 +0900 Subject: [PATCH 445/528] =?UTF-8?q?feat(news):=20=EB=89=B4=EC=8A=A4=20?= =?UTF-8?q?=EC=88=98=EC=A7=91=20=ED=8C=8C=EC=9D=B4=ED=94=84=EB=9D=BC?= =?UTF-8?q?=EC=9D=B8=20=EA=B5=AC=ED=98=84=20(#386)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NewsApiClient: NewsAPI 연동 서비스 - RssFeedParser: RSS 피드 파싱 (BBC, VOA, NPR) - NewsDuplicateChecker: URL 기반 중복 필터링 - NewsCollectorService: 수집 오케스트레이션 - NewsCollectionHandler: Lambda 핸들러 - EventBridge 스케줄러: 매일 18시 KST 실행 --- .../domain/news/dto/RawNewsArticle.java | 44 +++++ .../news/handler/NewsCollectionHandler.java | 58 ++++++ .../domain/news/service/NewsApiClient.java | 166 ++++++++++++++++ .../news/service/NewsCollectorService.java | 139 +++++++++++++ .../news/service/NewsDuplicateChecker.java | 94 +++++++++ .../domain/news/service/RssFeedParser.java | 183 ++++++++++++++++++ ServerlessFunction/template.yaml | 27 +++ 7 files changed, 711 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/dto/RawNewsArticle.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsCollectionHandler.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsApiClient.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsCollectorService.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsDuplicateChecker.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/RssFeedParser.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/dto/RawNewsArticle.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/dto/RawNewsArticle.java new file mode 100644 index 00000000..c9902559 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/dto/RawNewsArticle.java @@ -0,0 +1,44 @@ +package com.mzc.secondproject.serverless.domain.news.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 수집된 원본 뉴스 기사 DTO + * NewsAPI, RSS 등에서 수집한 원본 데이터를 담는 객체 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class RawNewsArticle { + + private String title; + private String description; + private String url; + private String imageUrl; + private String source; + private String publishedAt; + private String content; + + /** + * URL 기반 고유 식별자 생성 + */ + public String generateId() { + if (url == null) { + return null; + } + return String.valueOf(url.hashCode()); + } + + /** + * 유효한 기사인지 검증 + */ + public boolean isValid() { + return title != null && !title.isBlank() + && url != null && !url.isBlank() + && source != null && !source.isBlank(); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsCollectionHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsCollectionHandler.java new file mode 100644 index 00000000..b5702a5e --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsCollectionHandler.java @@ -0,0 +1,58 @@ +package com.mzc.secondproject.serverless.domain.news.handler; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.events.ScheduledEvent; +import com.mzc.secondproject.serverless.domain.news.service.NewsCollectorService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Map; + +/** + * 뉴스 수집 Lambda 핸들러 + * EventBridge 스케줄러에 의해 매일 18시에 트리거 + */ +public class NewsCollectionHandler implements RequestHandler> { + + private static final Logger logger = LoggerFactory.getLogger(NewsCollectionHandler.class); + + private final NewsCollectorService collectorService; + + public NewsCollectionHandler() { + this.collectorService = new NewsCollectorService(); + } + + public NewsCollectionHandler(NewsCollectorService collectorService) { + this.collectorService = collectorService; + } + + @Override + public Map handleRequest(ScheduledEvent event, Context context) { + logger.info("뉴스 수집 Lambda 시작 - requestId: {}", context.getAwsRequestId()); + + try { + NewsCollectorService.CollectionResult result = collectorService.collectNews(); + + logger.info("뉴스 수집 완료 - NewsAPI: {}, RSS: {}, 저장: {}, 소요: {}ms", + result.newsApiCount(), result.rssCount(), result.savedCount(), result.elapsedMs()); + + return Map.of( + "statusCode", 200, + "message", "News collection completed", + "newsApiCount", result.newsApiCount(), + "rssCount", result.rssCount(), + "savedCount", result.savedCount(), + "elapsedMs", result.elapsedMs() + ); + + } catch (Exception e) { + logger.error("뉴스 수집 실패", e); + + return Map.of( + "statusCode", 500, + "message", "News collection failed: " + e.getMessage() + ); + } + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsApiClient.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsApiClient.java new file mode 100644 index 00000000..dba1af09 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsApiClient.java @@ -0,0 +1,166 @@ +package com.mzc.secondproject.serverless.domain.news.service; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.domain.news.dto.RawNewsArticle; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.services.ssm.model.GetParameterRequest; + +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + +/** + * NewsAPI 연동 클라이언트 + * 무료 플랜: 100 requests/day, 최대 100 articles/request + */ +public class NewsApiClient { + + private static final Logger logger = LoggerFactory.getLogger(NewsApiClient.class); + private static final String NEWS_API_BASE_URL = "https://newsapi.org/v2"; + private static final String API_KEY_PARAM_NAME = "/englishstudy/news/api-key"; + + private static String cachedApiKey = null; + + private final HttpClient httpClient; + + public NewsApiClient() { + this.httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .build(); + } + + /** + * API Key 조회 (Parameter Store + 캐싱) + */ + private String getApiKey() { + if (cachedApiKey != null) { + return cachedApiKey; + } + + try { + logger.debug("Fetching NewsAPI Key from Parameter Store"); + var response = AwsClients.ssm().getParameter( + GetParameterRequest.builder() + .name(API_KEY_PARAM_NAME) + .withDecryption(true) + .build() + ); + cachedApiKey = response.parameter().value(); + logger.info("NewsAPI Key loaded from Parameter Store"); + return cachedApiKey; + } catch (Exception e) { + logger.error("Failed to get NewsAPI Key from Parameter Store", e); + throw new RuntimeException("NewsAPI Key 로드 실패", e); + } + } + + /** + * 헤드라인 뉴스 조회 + */ + public List getTopHeadlines(String category, int pageSize) { + String url = String.format("%s/top-headlines?language=en&category=%s&pageSize=%d&apiKey=%s", + NEWS_API_BASE_URL, category, pageSize, getApiKey()); + + return fetchArticles(url, "NewsAPI-Headlines"); + } + + /** + * 검색어 기반 뉴스 조회 + */ + public List searchNews(String query, int pageSize) { + String encodedQuery = URLEncoder.encode(query, StandardCharsets.UTF_8); + String url = String.format("%s/everything?q=%s&language=en&sortBy=publishedAt&pageSize=%d&apiKey=%s", + NEWS_API_BASE_URL, encodedQuery, pageSize, getApiKey()); + + return fetchArticles(url, "NewsAPI-Search"); + } + + /** + * 뉴스 API 호출 및 파싱 + */ + private List fetchArticles(String url, String source) { + List articles = new ArrayList<>(); + + try { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .header("Accept", "application/json") + .timeout(Duration.ofSeconds(30)) + .GET() + .build(); + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() != 200) { + logger.error("NewsAPI 요청 실패 - status: {}, body: {}", response.statusCode(), response.body()); + return articles; + } + + JsonObject json = JsonParser.parseString(response.body()).getAsJsonObject(); + String status = json.get("status").getAsString(); + + if (!"ok".equals(status)) { + logger.error("NewsAPI 응답 오류 - status: {}", status); + return articles; + } + + JsonArray articlesArray = json.getAsJsonArray("articles"); + for (JsonElement element : articlesArray) { + JsonObject articleJson = element.getAsJsonObject(); + RawNewsArticle article = parseArticle(articleJson, source); + if (article.isValid()) { + articles.add(article); + } + } + + logger.info("NewsAPI에서 {}개 기사 수집 완료", articles.size()); + + } catch (Exception e) { + logger.error("NewsAPI 호출 중 오류 발생", e); + } + + return articles; + } + + /** + * JSON을 RawNewsArticle로 변환 + */ + private RawNewsArticle parseArticle(JsonObject json, String defaultSource) { + String sourceName = defaultSource; + if (json.has("source") && json.get("source").isJsonObject()) { + JsonObject sourceObj = json.getAsJsonObject("source"); + if (sourceObj.has("name") && !sourceObj.get("name").isJsonNull()) { + sourceName = sourceObj.get("name").getAsString(); + } + } + + return RawNewsArticle.builder() + .title(getStringOrNull(json, "title")) + .description(getStringOrNull(json, "description")) + .url(getStringOrNull(json, "url")) + .imageUrl(getStringOrNull(json, "urlToImage")) + .source(sourceName) + .publishedAt(getStringOrNull(json, "publishedAt")) + .content(getStringOrNull(json, "content")) + .build(); + } + + private String getStringOrNull(JsonObject json, String key) { + if (json.has(key) && !json.get(key).isJsonNull()) { + return json.get(key).getAsString(); + } + return null; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsCollectorService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsCollectorService.java new file mode 100644 index 00000000..e46bb6ef --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsCollectorService.java @@ -0,0 +1,139 @@ +package com.mzc.secondproject.serverless.domain.news.service; + +import com.mzc.secondproject.serverless.domain.news.constants.NewsKey; +import com.mzc.secondproject.serverless.domain.news.dto.RawNewsArticle; +import com.mzc.secondproject.serverless.domain.news.model.NewsArticle; +import com.mzc.secondproject.serverless.domain.news.repository.NewsArticleRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +/** + * 뉴스 수집 서비스 + * NewsAPI, RSS 피드에서 뉴스를 수집하고 저장 + */ +public class NewsCollectorService { + + private static final Logger logger = LoggerFactory.getLogger(NewsCollectorService.class); + + private static final int NEWS_API_LIMIT = 10; + private static final int RSS_LIMIT_PER_SOURCE = 5; + private static final long TTL_DAYS = 30; + + private final NewsApiClient newsApiClient; + private final RssFeedParser rssFeedParser; + private final NewsDuplicateChecker duplicateChecker; + private final NewsArticleRepository articleRepository; + + public NewsCollectorService() { + this.newsApiClient = new NewsApiClient(); + this.rssFeedParser = new RssFeedParser(); + this.duplicateChecker = new NewsDuplicateChecker(); + this.articleRepository = new NewsArticleRepository(); + } + + public NewsCollectorService(NewsApiClient newsApiClient, RssFeedParser rssFeedParser, + NewsDuplicateChecker duplicateChecker, NewsArticleRepository articleRepository) { + this.newsApiClient = newsApiClient; + this.rssFeedParser = rssFeedParser; + this.duplicateChecker = duplicateChecker; + this.articleRepository = articleRepository; + } + + /** + * 뉴스 수집 실행 + */ + public CollectionResult collectNews() { + logger.info("뉴스 수집 시작"); + long startTime = System.currentTimeMillis(); + + List allArticles = new ArrayList<>(); + int newsApiCount = 0; + int rssCount = 0; + + try { + List newsApiArticles = newsApiClient.getTopHeadlines("technology", NEWS_API_LIMIT); + allArticles.addAll(newsApiArticles); + newsApiCount = newsApiArticles.size(); + logger.info("NewsAPI에서 {}개 수집", newsApiCount); + } catch (Exception e) { + logger.error("NewsAPI 수집 실패", e); + } + + try { + List rssArticles = rssFeedParser.fetchAllFeeds(RSS_LIMIT_PER_SOURCE); + allArticles.addAll(rssArticles); + rssCount = rssArticles.size(); + logger.info("RSS에서 {}개 수집", rssCount); + } catch (Exception e) { + logger.error("RSS 수집 실패", e); + } + + List uniqueArticles = duplicateChecker.filterDuplicates(allArticles); + logger.info("중복 제거 후 {}개 기사", uniqueArticles.size()); + + int savedCount = 0; + for (RawNewsArticle rawArticle : uniqueArticles) { + try { + NewsArticle article = convertToNewsArticle(rawArticle); + articleRepository.save(article); + savedCount++; + } catch (Exception e) { + logger.error("기사 저장 실패: {}", rawArticle.getTitle(), e); + } + } + + long elapsed = System.currentTimeMillis() - startTime; + logger.info("뉴스 수집 완료 - 저장: {}, 소요시간: {}ms", savedCount, elapsed); + + return new CollectionResult(newsApiCount, rssCount, savedCount, elapsed); + } + + /** + * RawNewsArticle을 NewsArticle로 변환 + * AI 분석은 별도 Story에서 처리 + */ + private NewsArticle convertToNewsArticle(RawNewsArticle raw) { + String today = LocalDate.now().toString(); + String articleId = UUID.randomUUID().toString().substring(0, 8); + String now = Instant.now().toString(); + + long ttlEpoch = Instant.now() + .atOffset(ZoneOffset.UTC) + .plusDays(TTL_DAYS) + .toEpochSecond(); + + return NewsArticle.builder() + .pk(NewsKey.newsPk(today)) + .sk(NewsKey.articleSk(articleId)) + .articleId(articleId) + .title(raw.getTitle()) + .summary(raw.getDescription()) + .originalUrl(raw.getUrl()) + .source(raw.getSource()) + .imageUrl(raw.getImageUrl()) + .publishedAt(raw.getPublishedAt() != null ? raw.getPublishedAt() : now) + .collectedAt(now) + .readCount(0L) + .commentCount(0L) + .ttl(ttlEpoch) + .build(); + } + + /** + * 수집 결과 레코드 + */ + public record CollectionResult( + int newsApiCount, + int rssCount, + int savedCount, + long elapsedMs + ) { + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsDuplicateChecker.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsDuplicateChecker.java new file mode 100644 index 00000000..d4eedd82 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsDuplicateChecker.java @@ -0,0 +1,94 @@ +package com.mzc.secondproject.serverless.domain.news.service; + +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.config.EnvConfig; +import com.mzc.secondproject.serverless.domain.news.constants.NewsKey; +import com.mzc.secondproject.serverless.domain.news.dto.RawNewsArticle; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; +import software.amazon.awssdk.services.dynamodb.model.QueryRequest; +import software.amazon.awssdk.services.dynamodb.model.QueryResponse; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 뉴스 중복 검사 서비스 + * URL 기반으로 중복 뉴스 필터링 + */ +public class NewsDuplicateChecker { + + private static final Logger logger = LoggerFactory.getLogger(NewsDuplicateChecker.class); + private static final String TABLE_NAME = EnvConfig.getRequired("NEWS_TABLE_NAME"); + + /** + * 중복 뉴스 필터링 + */ + public List filterDuplicates(List articles) { + if (articles.isEmpty()) { + return articles; + } + + Set existingUrls = getExistingUrls(); + Set seenUrls = new HashSet<>(); + List uniqueArticles = new ArrayList<>(); + + for (RawNewsArticle article : articles) { + String url = article.getUrl(); + if (url == null) { + continue; + } + + if (!existingUrls.contains(url) && !seenUrls.contains(url)) { + uniqueArticles.add(article); + seenUrls.add(url); + } + } + + int duplicateCount = articles.size() - uniqueArticles.size(); + if (duplicateCount > 0) { + logger.info("{}개 중복 기사 필터링됨", duplicateCount); + } + + return uniqueArticles; + } + + /** + * 오늘 날짜의 기존 뉴스 URL 조회 + */ + private Set getExistingUrls() { + Set urls = new HashSet<>(); + String today = LocalDate.now().toString(); + + try { + QueryRequest request = QueryRequest.builder() + .tableName(TABLE_NAME) + .keyConditionExpression("PK = :pk") + .expressionAttributeValues(Map.of( + ":pk", AttributeValue.builder().s(NewsKey.newsPk(today)).build() + )) + .projectionExpression("originalUrl") + .build(); + + QueryResponse response = AwsClients.dynamoDb().query(request); + + for (Map item : response.items()) { + if (item.containsKey("originalUrl")) { + urls.add(item.get("originalUrl").s()); + } + } + + logger.debug("기존 뉴스 {}개 URL 로드됨", urls.size()); + + } catch (Exception e) { + logger.error("기존 뉴스 URL 조회 실패", e); + } + + return urls; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/RssFeedParser.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/RssFeedParser.java new file mode 100644 index 00000000..ca2c98b8 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/RssFeedParser.java @@ -0,0 +1,183 @@ +package com.mzc.secondproject.serverless.domain.news.service; + +import com.mzc.secondproject.serverless.domain.news.dto.RawNewsArticle; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NodeList; + +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import java.io.InputStream; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * RSS 피드 파싱 서비스 + * BBC, VOA, NPR 등의 RSS 피드에서 뉴스 수집 + */ +public class RssFeedParser { + + private static final Logger logger = LoggerFactory.getLogger(RssFeedParser.class); + + private static final Map RSS_FEEDS = Map.of( + "BBC", "https://feeds.bbci.co.uk/news/world/rss.xml", + "VOA", "https://www.voanews.com/api/ziqpoe-mqm", + "NPR", "https://feeds.npr.org/1001/rss.xml" + ); + + private final HttpClient httpClient; + + public RssFeedParser() { + this.httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .followRedirects(HttpClient.Redirect.NORMAL) + .build(); + } + + /** + * 모든 RSS 피드에서 뉴스 수집 + */ + public List fetchAllFeeds(int maxPerSource) { + List allArticles = new ArrayList<>(); + + for (Map.Entry entry : RSS_FEEDS.entrySet()) { + String source = entry.getKey(); + String feedUrl = entry.getValue(); + + try { + List articles = fetchFeed(feedUrl, source, maxPerSource); + allArticles.addAll(articles); + logger.info("{}에서 {}개 기사 수집", source, articles.size()); + } catch (Exception e) { + logger.error("{} RSS 피드 수집 실패: {}", source, e.getMessage()); + } + } + + return allArticles; + } + + /** + * 특정 RSS 피드에서 뉴스 수집 + */ + public List fetchFeed(String feedUrl, String source, int maxItems) { + List articles = new ArrayList<>(); + + try { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(feedUrl)) + .header("User-Agent", "Mozilla/5.0 (compatible; NewsBot/1.0)") + .timeout(Duration.ofSeconds(30)) + .GET() + .build(); + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream()); + + if (response.statusCode() != 200) { + logger.error("RSS 피드 요청 실패 - url: {}, status: {}", feedUrl, response.statusCode()); + return articles; + } + + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + DocumentBuilder builder = factory.newDocumentBuilder(); + Document document = builder.parse(response.body()); + + NodeList items = document.getElementsByTagName("item"); + int count = Math.min(items.getLength(), maxItems); + + for (int i = 0; i < count; i++) { + Element item = (Element) items.item(i); + RawNewsArticle article = parseRssItem(item, source); + if (article.isValid()) { + articles.add(article); + } + } + + } catch (Exception e) { + logger.error("RSS 피드 파싱 중 오류 발생 - url: {}", feedUrl, e); + } + + return articles; + } + + /** + * RSS item 요소를 RawNewsArticle로 변환 + */ + private RawNewsArticle parseRssItem(Element item, String source) { + return RawNewsArticle.builder() + .title(getElementText(item, "title")) + .description(cleanHtml(getElementText(item, "description"))) + .url(getElementText(item, "link")) + .imageUrl(extractImageUrl(item)) + .source(source) + .publishedAt(parsePublishedDate(getElementText(item, "pubDate"))) + .build(); + } + + /** + * 요소에서 텍스트 추출 + */ + private String getElementText(Element parent, String tagName) { + NodeList nodes = parent.getElementsByTagName(tagName); + if (nodes.getLength() > 0) { + return nodes.item(0).getTextContent().trim(); + } + return null; + } + + /** + * 이미지 URL 추출 (media:content, enclosure 등) + */ + private String extractImageUrl(Element item) { + NodeList mediaContent = item.getElementsByTagName("media:content"); + if (mediaContent.getLength() > 0) { + Element media = (Element) mediaContent.item(0); + return media.getAttribute("url"); + } + + NodeList enclosure = item.getElementsByTagName("enclosure"); + if (enclosure.getLength() > 0) { + Element enc = (Element) enclosure.item(0); + String type = enc.getAttribute("type"); + if (type != null && type.startsWith("image/")) { + return enc.getAttribute("url"); + } + } + + NodeList mediaThumbnail = item.getElementsByTagName("media:thumbnail"); + if (mediaThumbnail.getLength() > 0) { + Element thumbnail = (Element) mediaThumbnail.item(0); + return thumbnail.getAttribute("url"); + } + + return null; + } + + /** + * RSS pubDate를 ISO 8601 형식으로 변환 + */ + private String parsePublishedDate(String pubDate) { + if (pubDate == null || pubDate.isBlank()) { + return null; + } + return pubDate; + } + + /** + * HTML 태그 제거 + */ + private String cleanHtml(String html) { + if (html == null) { + return null; + } + return html.replaceAll("<[^>]*>", "").trim(); + } +} diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index a4cb6044..7828e7a0 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -1725,6 +1725,33 @@ Resources: AttributeName: ttl Enabled: true + ############################################# + # News Collection Scheduled Lambda + ############################################# + + NewsCollectionFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: !Sub "${AWS::StackName}-news-collection" + CodeUri: . + Handler: com.mzc.secondproject.serverless.domain.news.handler.NewsCollectionHandler::handleRequest + Description: 매일 18시에 영어 뉴스를 수집하는 Lambda + MemorySize: 512 + Timeout: 300 + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref NewsTable + - SSMParameterReadPolicy: + ParameterName: englishstudy/news/* + Events: + DailySchedule: + Type: Schedule + Properties: + Schedule: cron(0 9 * * ? *) + Name: news-collection-daily-schedule + Description: 매일 18시 KST (09:00 UTC)에 뉴스 수집 + Enabled: true + NewsTable: Type: AWS::DynamoDB::Table Properties: From 4f42c61931e21ba1b0c96eb63847905fe99bb960 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 13:30:20 +0900 Subject: [PATCH 446/528] =?UTF-8?q?refactor(news):=20NewsAPI=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0,=20RSS=EB=A7=8C=20=EC=82=AC=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NewsApiClient 삭제 - SSM Parameter 의존성 제거 - RSS 소스당 7개씩 수집 (BBC, VOA, NPR = 약 21개/일) --- .../news/handler/NewsCollectionHandler.java | 7 +- .../domain/news/service/NewsApiClient.java | 166 ------------------ .../news/service/NewsCollectorService.java | 42 ++--- ServerlessFunction/template.yaml | 2 - 4 files changed, 15 insertions(+), 202 deletions(-) delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsApiClient.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsCollectionHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsCollectionHandler.java index b5702a5e..4d17463f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsCollectionHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsCollectionHandler.java @@ -34,14 +34,13 @@ public Map handleRequest(ScheduledEvent event, Context context) try { NewsCollectorService.CollectionResult result = collectorService.collectNews(); - logger.info("뉴스 수집 완료 - NewsAPI: {}, RSS: {}, 저장: {}, 소요: {}ms", - result.newsApiCount(), result.rssCount(), result.savedCount(), result.elapsedMs()); + logger.info("뉴스 수집 완료 - 수집: {}, 저장: {}, 소요: {}ms", + result.collectedCount(), result.savedCount(), result.elapsedMs()); return Map.of( "statusCode", 200, "message", "News collection completed", - "newsApiCount", result.newsApiCount(), - "rssCount", result.rssCount(), + "collectedCount", result.collectedCount(), "savedCount", result.savedCount(), "elapsedMs", result.elapsedMs() ); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsApiClient.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsApiClient.java deleted file mode 100644 index dba1af09..00000000 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsApiClient.java +++ /dev/null @@ -1,166 +0,0 @@ -package com.mzc.secondproject.serverless.domain.news.service; - -import com.google.gson.Gson; -import com.google.gson.JsonArray; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; -import com.mzc.secondproject.serverless.common.config.AwsClients; -import com.mzc.secondproject.serverless.domain.news.dto.RawNewsArticle; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import software.amazon.awssdk.services.ssm.model.GetParameterRequest; - -import java.net.URI; -import java.net.URLEncoder; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.nio.charset.StandardCharsets; -import java.time.Duration; -import java.util.ArrayList; -import java.util.List; - -/** - * NewsAPI 연동 클라이언트 - * 무료 플랜: 100 requests/day, 최대 100 articles/request - */ -public class NewsApiClient { - - private static final Logger logger = LoggerFactory.getLogger(NewsApiClient.class); - private static final String NEWS_API_BASE_URL = "https://newsapi.org/v2"; - private static final String API_KEY_PARAM_NAME = "/englishstudy/news/api-key"; - - private static String cachedApiKey = null; - - private final HttpClient httpClient; - - public NewsApiClient() { - this.httpClient = HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(10)) - .build(); - } - - /** - * API Key 조회 (Parameter Store + 캐싱) - */ - private String getApiKey() { - if (cachedApiKey != null) { - return cachedApiKey; - } - - try { - logger.debug("Fetching NewsAPI Key from Parameter Store"); - var response = AwsClients.ssm().getParameter( - GetParameterRequest.builder() - .name(API_KEY_PARAM_NAME) - .withDecryption(true) - .build() - ); - cachedApiKey = response.parameter().value(); - logger.info("NewsAPI Key loaded from Parameter Store"); - return cachedApiKey; - } catch (Exception e) { - logger.error("Failed to get NewsAPI Key from Parameter Store", e); - throw new RuntimeException("NewsAPI Key 로드 실패", e); - } - } - - /** - * 헤드라인 뉴스 조회 - */ - public List getTopHeadlines(String category, int pageSize) { - String url = String.format("%s/top-headlines?language=en&category=%s&pageSize=%d&apiKey=%s", - NEWS_API_BASE_URL, category, pageSize, getApiKey()); - - return fetchArticles(url, "NewsAPI-Headlines"); - } - - /** - * 검색어 기반 뉴스 조회 - */ - public List searchNews(String query, int pageSize) { - String encodedQuery = URLEncoder.encode(query, StandardCharsets.UTF_8); - String url = String.format("%s/everything?q=%s&language=en&sortBy=publishedAt&pageSize=%d&apiKey=%s", - NEWS_API_BASE_URL, encodedQuery, pageSize, getApiKey()); - - return fetchArticles(url, "NewsAPI-Search"); - } - - /** - * 뉴스 API 호출 및 파싱 - */ - private List fetchArticles(String url, String source) { - List articles = new ArrayList<>(); - - try { - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(url)) - .header("Accept", "application/json") - .timeout(Duration.ofSeconds(30)) - .GET() - .build(); - - HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - - if (response.statusCode() != 200) { - logger.error("NewsAPI 요청 실패 - status: {}, body: {}", response.statusCode(), response.body()); - return articles; - } - - JsonObject json = JsonParser.parseString(response.body()).getAsJsonObject(); - String status = json.get("status").getAsString(); - - if (!"ok".equals(status)) { - logger.error("NewsAPI 응답 오류 - status: {}", status); - return articles; - } - - JsonArray articlesArray = json.getAsJsonArray("articles"); - for (JsonElement element : articlesArray) { - JsonObject articleJson = element.getAsJsonObject(); - RawNewsArticle article = parseArticle(articleJson, source); - if (article.isValid()) { - articles.add(article); - } - } - - logger.info("NewsAPI에서 {}개 기사 수집 완료", articles.size()); - - } catch (Exception e) { - logger.error("NewsAPI 호출 중 오류 발생", e); - } - - return articles; - } - - /** - * JSON을 RawNewsArticle로 변환 - */ - private RawNewsArticle parseArticle(JsonObject json, String defaultSource) { - String sourceName = defaultSource; - if (json.has("source") && json.get("source").isJsonObject()) { - JsonObject sourceObj = json.getAsJsonObject("source"); - if (sourceObj.has("name") && !sourceObj.get("name").isJsonNull()) { - sourceName = sourceObj.get("name").getAsString(); - } - } - - return RawNewsArticle.builder() - .title(getStringOrNull(json, "title")) - .description(getStringOrNull(json, "description")) - .url(getStringOrNull(json, "url")) - .imageUrl(getStringOrNull(json, "urlToImage")) - .source(sourceName) - .publishedAt(getStringOrNull(json, "publishedAt")) - .content(getStringOrNull(json, "content")) - .build(); - } - - private String getStringOrNull(JsonObject json, String key) { - if (json.has(key) && !json.get(key).isJsonNull()) { - return json.get(key).getAsString(); - } - return null; - } -} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsCollectorService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsCollectorService.java index e46bb6ef..9842f4b3 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsCollectorService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsCollectorService.java @@ -10,37 +10,33 @@ import java.time.Instant; import java.time.LocalDate; import java.time.ZoneOffset; -import java.util.ArrayList; import java.util.List; import java.util.UUID; /** * 뉴스 수집 서비스 - * NewsAPI, RSS 피드에서 뉴스를 수집하고 저장 + * RSS 피드에서 뉴스를 수집하고 저장 (BBC, VOA, NPR) */ public class NewsCollectorService { private static final Logger logger = LoggerFactory.getLogger(NewsCollectorService.class); - private static final int NEWS_API_LIMIT = 10; - private static final int RSS_LIMIT_PER_SOURCE = 5; + private static final int RSS_LIMIT_PER_SOURCE = 7; private static final long TTL_DAYS = 30; - private final NewsApiClient newsApiClient; private final RssFeedParser rssFeedParser; private final NewsDuplicateChecker duplicateChecker; private final NewsArticleRepository articleRepository; public NewsCollectorService() { - this.newsApiClient = new NewsApiClient(); this.rssFeedParser = new RssFeedParser(); this.duplicateChecker = new NewsDuplicateChecker(); this.articleRepository = new NewsArticleRepository(); } - public NewsCollectorService(NewsApiClient newsApiClient, RssFeedParser rssFeedParser, - NewsDuplicateChecker duplicateChecker, NewsArticleRepository articleRepository) { - this.newsApiClient = newsApiClient; + public NewsCollectorService(RssFeedParser rssFeedParser, + NewsDuplicateChecker duplicateChecker, + NewsArticleRepository articleRepository) { this.rssFeedParser = rssFeedParser; this.duplicateChecker = duplicateChecker; this.articleRepository = articleRepository; @@ -53,29 +49,16 @@ public CollectionResult collectNews() { logger.info("뉴스 수집 시작"); long startTime = System.currentTimeMillis(); - List allArticles = new ArrayList<>(); - int newsApiCount = 0; - int rssCount = 0; - - try { - List newsApiArticles = newsApiClient.getTopHeadlines("technology", NEWS_API_LIMIT); - allArticles.addAll(newsApiArticles); - newsApiCount = newsApiArticles.size(); - logger.info("NewsAPI에서 {}개 수집", newsApiCount); - } catch (Exception e) { - logger.error("NewsAPI 수집 실패", e); - } - + List rssArticles; try { - List rssArticles = rssFeedParser.fetchAllFeeds(RSS_LIMIT_PER_SOURCE); - allArticles.addAll(rssArticles); - rssCount = rssArticles.size(); - logger.info("RSS에서 {}개 수집", rssCount); + rssArticles = rssFeedParser.fetchAllFeeds(RSS_LIMIT_PER_SOURCE); + logger.info("RSS에서 {}개 수집", rssArticles.size()); } catch (Exception e) { logger.error("RSS 수집 실패", e); + return new CollectionResult(0, 0, System.currentTimeMillis() - startTime); } - List uniqueArticles = duplicateChecker.filterDuplicates(allArticles); + List uniqueArticles = duplicateChecker.filterDuplicates(rssArticles); logger.info("중복 제거 후 {}개 기사", uniqueArticles.size()); int savedCount = 0; @@ -92,7 +75,7 @@ public CollectionResult collectNews() { long elapsed = System.currentTimeMillis() - startTime; logger.info("뉴스 수집 완료 - 저장: {}, 소요시간: {}ms", savedCount, elapsed); - return new CollectionResult(newsApiCount, rssCount, savedCount, elapsed); + return new CollectionResult(rssArticles.size(), savedCount, elapsed); } /** @@ -130,8 +113,7 @@ private NewsArticle convertToNewsArticle(RawNewsArticle raw) { * 수집 결과 레코드 */ public record CollectionResult( - int newsApiCount, - int rssCount, + int collectedCount, int savedCount, long elapsedMs ) { diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 7828e7a0..552cb8e9 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -1741,8 +1741,6 @@ Resources: Policies: - DynamoDBCrudPolicy: TableName: !Ref NewsTable - - SSMParameterReadPolicy: - ParameterName: englishstudy/news/* Events: DailySchedule: Type: Schedule From 682c07a2bbbfa04ef8fd83971a70d76ab5606aaa Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 13:38:24 +0900 Subject: [PATCH 447/528] =?UTF-8?q?feat(news):=20AI=20=EB=89=B4=EC=8A=A4?= =?UTF-8?q?=20=EB=B6=84=EC=84=9D=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#387)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NewsAnalysisService: AI 분석 통합 서비스 - Bedrock: CEFR 난이도 분석 (A1~C2) - Bedrock: 3줄 요약 + 퀴즈 3문제 생성 - Comprehend: 핵심 키워드 추출 - NewsCollectorService: 수집 시 자동 분석 연동 - GSI1/GSI2 키 자동 설정 (레벨별, 카테고리별 조회) --- .../news/service/NewsAnalysisService.java | 321 ++++++++++++++++++ .../news/service/NewsCollectorService.java | 16 +- 2 files changed, 333 insertions(+), 4 deletions(-) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java new file mode 100644 index 00000000..af23fc5b --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java @@ -0,0 +1,321 @@ +package com.mzc.secondproject.serverless.domain.news.service; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.domain.news.model.KeywordInfo; +import com.mzc.secondproject.serverless.domain.news.model.NewsArticle; +import com.mzc.secondproject.serverless.domain.news.model.QuizQuestion; +import com.mzc.secondproject.serverless.domain.news.repository.NewsArticleRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.core.SdkBytes; +import software.amazon.awssdk.services.bedrockruntime.model.InvokeModelRequest; +import software.amazon.awssdk.services.bedrockruntime.model.InvokeModelResponse; +import software.amazon.awssdk.services.comprehend.model.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 뉴스 AI 분석 서비스 + * - CEFR 난이도 분석 (Bedrock) + * - 3줄 요약 생성 (Bedrock) + * - 핵심 단어 추출 (Comprehend) + * - 퀴즈 생성 (Bedrock) + */ +public class NewsAnalysisService { + + private static final Logger logger = LoggerFactory.getLogger(NewsAnalysisService.class); + private static final Gson gson = new Gson(); + private static final String MODEL_ID = "anthropic.claude-3-haiku-20240307-v1:0"; + + private final NewsArticleRepository articleRepository; + + public NewsAnalysisService() { + this.articleRepository = new NewsArticleRepository(); + } + + public NewsAnalysisService(NewsArticleRepository articleRepository) { + this.articleRepository = articleRepository; + } + + /** + * 뉴스 기사 전체 분석 + */ + public NewsArticle analyzeArticle(NewsArticle article) { + logger.info("뉴스 분석 시작: {}", article.getArticleId()); + long startTime = System.currentTimeMillis(); + + String content = article.getTitle() + ". " + + (article.getSummary() != null ? article.getSummary() : ""); + + try { + // 1. CEFR 난이도 분석 + String cefrLevel = analyzeDifficulty(content); + article.setCefrLevel(cefrLevel); + article.setLevel(mapCefrToLevel(cefrLevel)); + + // 2. 핵심 단어 추출 (Comprehend) + List keywords = extractKeywords(content); + article.setKeywords(keywords); + + // 3. 3줄 요약 + 퀴즈 생성 (Bedrock - 한 번에 처리) + AnalysisResult result = generateSummaryAndQuiz(content, cefrLevel); + if (result.summary() != null) { + article.setSummary(result.summary()); + } + article.setQuiz(result.quiz()); + article.setHighlightWords(result.highlightWords()); + + // 4. GSI 키 설정 + article.setGsi1pk("LEVEL#" + article.getLevel()); + article.setGsi1sk(article.getPublishedAt()); + if (article.getCategory() != null) { + article.setGsi2pk("CATEGORY#" + article.getCategory()); + article.setGsi2sk(article.getPublishedAt()); + } + + // 5. 저장 + articleRepository.save(article); + + long elapsed = System.currentTimeMillis() - startTime; + logger.info("뉴스 분석 완료: {} ({}ms)", article.getArticleId(), elapsed); + + } catch (Exception e) { + logger.error("뉴스 분석 실패: {}", article.getArticleId(), e); + // 분석 실패해도 기본값으로 저장 + article.setLevel("INTERMEDIATE"); + article.setCefrLevel("B1"); + articleRepository.save(article); + } + + return article; + } + + /** + * CEFR 난이도 분석 (Bedrock) + */ + private String analyzeDifficulty(String content) { + String systemPrompt = """ + You are an English language expert. Analyze the text and determine its CEFR level. + Consider vocabulary complexity, sentence structure, and topic familiarity. + + Respond with ONLY the CEFR level code: A1, A2, B1, B2, C1, or C2 + No explanation, just the level code. + """; + + String userPrompt = "Determine the CEFR level of this text:\n\n" + truncate(content, 1000); + + String response = invokeBedrock(systemPrompt, userPrompt); + String level = response.trim().toUpperCase(); + + // 유효한 레벨인지 확인 + if (List.of("A1", "A2", "B1", "B2", "C1", "C2").contains(level)) { + return level; + } + + // 레벨 추출 시도 + for (String validLevel : List.of("C2", "C1", "B2", "B1", "A2", "A1")) { + if (response.toUpperCase().contains(validLevel)) { + return validLevel; + } + } + + return "B1"; // 기본값 + } + + /** + * CEFR을 3단계 레벨로 매핑 + */ + private String mapCefrToLevel(String cefrLevel) { + return switch (cefrLevel) { + case "A1", "A2" -> "BEGINNER"; + case "B1", "B2" -> "INTERMEDIATE"; + case "C1", "C2" -> "ADVANCED"; + default -> "INTERMEDIATE"; + }; + } + + /** + * 핵심 단어 추출 (Comprehend) + */ + private List extractKeywords(String content) { + try { + DetectKeyPhrasesResponse response = AwsClients.comprehend().detectKeyPhrases( + DetectKeyPhrasesRequest.builder() + .text(truncate(content, 5000)) + .languageCode(LanguageCode.EN) + .build() + ); + + List keywords = new ArrayList<>(); + List phrases = response.keyPhrases(); + + for (int i = 0; i < Math.min(phrases.size(), 10); i++) { + KeyPhrase phrase = phrases.get(i); + if (phrase.score() > 0.8) { + keywords.add(KeywordInfo.builder() + .word(phrase.text()) + .position(i) + .build()); + } + } + + return keywords; + + } catch (Exception e) { + logger.error("키워드 추출 실패", e); + return new ArrayList<>(); + } + } + + /** + * 요약 + 퀴즈 생성 (Bedrock) + */ + private AnalysisResult generateSummaryAndQuiz(String content, String cefrLevel) { + String systemPrompt = """ + You are an English learning assistant. Analyze the news article and create learning materials. + + Respond in this exact JSON format: + { + "summary": "3-line summary in English (each line separated by newline)", + "highlightWords": ["word1", "word2", "word3"], + "quiz": [ + { + "questionId": "q1", + "type": "COMPREHENSION", + "question": "What is the main topic of this article?", + "options": ["Option A", "Option B", "Option C", "Option D"], + "correctAnswer": "Option A", + "points": 20 + }, + { + "questionId": "q2", + "type": "WORD_MATCH", + "question": "What does 'X' mean in this context?", + "options": ["meaning1", "meaning2", "meaning3", "meaning4"], + "correctAnswer": "meaning1", + "points": 15 + }, + { + "questionId": "q3", + "type": "FILL_BLANK", + "question": "The article mentions that _____ is important.", + "options": ["word1", "word2", "word3", "word4"], + "correctAnswer": "word1", + "points": 30 + } + ] + } + + Create exactly 3 quiz questions. + highlightWords should contain 3-5 difficult words for learners. + Adjust difficulty based on CEFR level: """ + cefrLevel; + + String userPrompt = "Create learning materials for this article:\n\n" + truncate(content, 1500); + + try { + String response = invokeBedrock(systemPrompt, userPrompt); + return parseAnalysisResult(response); + } catch (Exception e) { + logger.error("요약/퀴즈 생성 실패", e); + return new AnalysisResult(null, new ArrayList<>(), new ArrayList<>()); + } + } + + /** + * Bedrock API 호출 + */ + private String invokeBedrock(String systemPrompt, String userPrompt) { + JsonObject requestBody = new JsonObject(); + requestBody.addProperty("anthropic_version", "bedrock-2023-05-31"); + requestBody.addProperty("max_tokens", 2000); + requestBody.addProperty("system", systemPrompt); + + JsonArray messages = new JsonArray(); + JsonObject userMessage = new JsonObject(); + userMessage.addProperty("role", "user"); + userMessage.addProperty("content", userPrompt); + messages.add(userMessage); + requestBody.add("messages", messages); + + InvokeModelRequest request = InvokeModelRequest.builder() + .modelId(MODEL_ID) + .contentType("application/json") + .accept("application/json") + .body(SdkBytes.fromUtf8String(gson.toJson(requestBody))) + .build(); + + InvokeModelResponse response = AwsClients.bedrock().invokeModel(request); + JsonObject jsonResponse = gson.fromJson(response.body().asUtf8String(), JsonObject.class); + + JsonArray contentArray = jsonResponse.getAsJsonArray("content"); + if (contentArray != null && !contentArray.isEmpty()) { + return contentArray.get(0).getAsJsonObject().get("text").getAsString(); + } + + throw new RuntimeException("Empty response from Bedrock"); + } + + /** + * 분석 결과 파싱 + */ + private AnalysisResult parseAnalysisResult(String response) { + String jsonStr = extractJson(response); + JsonObject json = gson.fromJson(jsonStr, JsonObject.class); + + String summary = json.has("summary") ? json.get("summary").getAsString() : null; + + List highlightWords = new ArrayList<>(); + if (json.has("highlightWords")) { + json.getAsJsonArray("highlightWords").forEach(e -> highlightWords.add(e.getAsString())); + } + + List quiz = new ArrayList<>(); + if (json.has("quiz")) { + json.getAsJsonArray("quiz").forEach(e -> { + JsonObject q = e.getAsJsonObject(); + List options = new ArrayList<>(); + if (q.has("options")) { + q.getAsJsonArray("options").forEach(opt -> options.add(opt.getAsString())); + } + quiz.add(QuizQuestion.builder() + .questionId(q.has("questionId") ? q.get("questionId").getAsString() : null) + .type(q.has("type") ? q.get("type").getAsString() : "COMPREHENSION") + .question(q.has("question") ? q.get("question").getAsString() : "") + .options(options) + .correctAnswer(q.has("correctAnswer") ? q.get("correctAnswer").getAsString() : "") + .points(q.has("points") ? q.get("points").getAsInt() : 20) + .build()); + }); + } + + return new AnalysisResult(summary, highlightWords, quiz); + } + + private String extractJson(String response) { + int start = response.indexOf('{'); + int end = response.lastIndexOf('}'); + if (start != -1 && end != -1 && end > start) { + return response.substring(start, end + 1); + } + return response; + } + + private String truncate(String text, int maxLength) { + if (text == null) return ""; + return text.length() > maxLength ? text.substring(0, maxLength) : text; + } + + /** + * 분석 결과 레코드 + */ + private record AnalysisResult( + String summary, + List highlightWords, + List quiz + ) {} +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsCollectorService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsCollectorService.java index 9842f4b3..ecac47df 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsCollectorService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsCollectorService.java @@ -27,19 +27,23 @@ public class NewsCollectorService { private final RssFeedParser rssFeedParser; private final NewsDuplicateChecker duplicateChecker; private final NewsArticleRepository articleRepository; + private final NewsAnalysisService analysisService; public NewsCollectorService() { this.rssFeedParser = new RssFeedParser(); this.duplicateChecker = new NewsDuplicateChecker(); this.articleRepository = new NewsArticleRepository(); + this.analysisService = new NewsAnalysisService(); } public NewsCollectorService(RssFeedParser rssFeedParser, NewsDuplicateChecker duplicateChecker, - NewsArticleRepository articleRepository) { + NewsArticleRepository articleRepository, + NewsAnalysisService analysisService) { this.rssFeedParser = rssFeedParser; this.duplicateChecker = duplicateChecker; this.articleRepository = articleRepository; + this.analysisService = analysisService; } /** @@ -62,18 +66,22 @@ public CollectionResult collectNews() { logger.info("중복 제거 후 {}개 기사", uniqueArticles.size()); int savedCount = 0; + int analyzedCount = 0; for (RawNewsArticle rawArticle : uniqueArticles) { try { NewsArticle article = convertToNewsArticle(rawArticle); - articleRepository.save(article); + + // AI 분석 수행 (난이도, 요약, 키워드, 퀴즈) + analysisService.analyzeArticle(article); + analyzedCount++; savedCount++; } catch (Exception e) { - logger.error("기사 저장 실패: {}", rawArticle.getTitle(), e); + logger.error("기사 처리 실패: {}", rawArticle.getTitle(), e); } } long elapsed = System.currentTimeMillis() - startTime; - logger.info("뉴스 수집 완료 - 저장: {}, 소요시간: {}ms", savedCount, elapsed); + logger.info("뉴스 수집/분석 완료 - 저장: {}, 분석: {}, 소요시간: {}ms", savedCount, analyzedCount, elapsed); return new CollectionResult(rssArticles.size(), savedCount, elapsed); } From 626efcdb568480928c6bad22f133ed9c01df948d Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 13:43:13 +0900 Subject: [PATCH 448/528] =?UTF-8?q?feat(news):=20=EB=89=B4=EC=8A=A4=20?= =?UTF-8?q?=ED=95=99=EC=8A=B5=20API=20=EA=B5=AC=ED=98=84=20(#388)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NewsQueryService: 뉴스 조회 서비스 - NewsHandler: API 핸들러 - GET /news - 목록 조회 (level, category 필터) - GET /news/today - 오늘의 뉴스 - GET /news/recommended - 내 레벨 맞춤 추천 - GET /news/{articleId} - 상세 조회 (조회수 증가) - template.yaml: NewsFunction Lambda 추가 --- .../domain/news/handler/NewsHandler.java | 166 ++++++++++++++++++ .../domain/news/service/NewsQueryService.java | 98 +++++++++++ ServerlessFunction/template.yaml | 38 ++++ 3 files changed, 302 insertions(+) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQueryService.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java new file mode 100644 index 00000000..3f53332b --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java @@ -0,0 +1,166 @@ +package com.mzc.secondproject.serverless.domain.news.handler; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.common.router.HandlerRouter; +import com.mzc.secondproject.serverless.common.router.Route; +import com.mzc.secondproject.serverless.common.util.CognitoUtil; +import com.mzc.secondproject.serverless.common.util.ResponseGenerator; +import com.mzc.secondproject.serverless.domain.news.exception.NewsErrorCode; +import com.mzc.secondproject.serverless.domain.news.model.NewsArticle; +import com.mzc.secondproject.serverless.domain.news.service.NewsQueryService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +/** + * 뉴스 학습 API 핸들러 + */ +public class NewsHandler implements RequestHandler { + + private static final Logger logger = LoggerFactory.getLogger(NewsHandler.class); + private static final int DEFAULT_LIMIT = 10; + private static final int MAX_LIMIT = 50; + + private final NewsQueryService queryService; + private final HandlerRouter router; + + public NewsHandler() { + this(new NewsQueryService()); + } + + public NewsHandler(NewsQueryService queryService) { + this.queryService = queryService; + this.router = initRouter(); + } + + private HandlerRouter initRouter() { + return new HandlerRouter().addRoutes( + Route.get("/news/today", this::getTodayNews), + Route.get("/news/recommended", this::getRecommendedNews), + Route.get("/news/{articleId}", this::getNewsDetail), + Route.get("/news", this::getNewsList) + ); + } + + @Override + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { + logger.info("News API 요청: {} {}", request.getHttpMethod(), request.getPath()); + return router.route(request); + } + + /** + * 뉴스 목록 조회 (필터링 지원) + * GET /news?level=INTERMEDIATE&category=TECH&limit=10&cursor=xxx + */ + private APIGatewayProxyResponseEvent getNewsList(APIGatewayProxyRequestEvent request) { + Map params = request.getQueryStringParameters(); + if (params == null) params = new HashMap<>(); + + String level = params.get("level"); + String category = params.get("category"); + String cursor = params.get("cursor"); + int limit = parseLimit(params.get("limit")); + + PaginatedResult result; + + if (level != null && category != null) { + result = queryService.getNewsByLevelAndCategory(level.toUpperCase(), category.toUpperCase(), limit, cursor); + } else if (level != null) { + result = queryService.getNewsByLevel(level.toUpperCase(), limit, cursor); + } else if (category != null) { + result = queryService.getNewsByCategory(category.toUpperCase(), limit, cursor); + } else { + result = queryService.getTodayNews(limit, cursor); + } + + return buildPaginatedResponse(result); + } + + /** + * 오늘의 뉴스 조회 + * GET /news/today?limit=10&cursor=xxx + */ + private APIGatewayProxyResponseEvent getTodayNews(APIGatewayProxyRequestEvent request) { + Map params = request.getQueryStringParameters(); + if (params == null) params = new HashMap<>(); + + String cursor = params.get("cursor"); + int limit = parseLimit(params.get("limit")); + + PaginatedResult result = queryService.getTodayNews(limit, cursor); + return buildPaginatedResponse(result); + } + + /** + * 내 레벨 맞춤 뉴스 추천 + * GET /news/recommended?limit=10&cursor=xxx + */ + private APIGatewayProxyResponseEvent getRecommendedNews(APIGatewayProxyRequestEvent request) { + Map params = request.getQueryStringParameters(); + if (params == null) params = new HashMap<>(); + + // 사용자 레벨 조회 (Cognito 토큰에서) + String userLevel = getUserLevel(request); + String cursor = params.get("cursor"); + int limit = parseLimit(params.get("limit")); + + PaginatedResult result = queryService.getRecommendedNews(userLevel, limit, cursor); + return buildPaginatedResponse(result); + } + + /** + * 뉴스 상세 조회 + * GET /news/{articleId} + */ + private APIGatewayProxyResponseEvent getNewsDetail(APIGatewayProxyRequestEvent request) { + String articleId = request.getPathParameters().get("articleId"); + + Optional article = queryService.getArticle(articleId); + if (article.isEmpty()) { + return ResponseGenerator.fail(NewsErrorCode.ARTICLE_NOT_FOUND); + } + + return ResponseGenerator.ok("뉴스 조회 성공", article.get()); + } + + /** + * 페이지네이션 응답 생성 + */ + private APIGatewayProxyResponseEvent buildPaginatedResponse(PaginatedResult result) { + Map response = new HashMap<>(); + response.put("articles", result.items()); + response.put("nextCursor", result.nextCursor()); + response.put("hasMore", result.hasMore()); + response.put("count", result.items().size()); + + return ResponseGenerator.ok("뉴스 목록 조회 성공", response); + } + + /** + * limit 파싱 + */ + private int parseLimit(String limitStr) { + if (limitStr == null) return DEFAULT_LIMIT; + try { + int limit = Integer.parseInt(limitStr); + return Math.min(Math.max(limit, 1), MAX_LIMIT); + } catch (NumberFormatException e) { + return DEFAULT_LIMIT; + } + } + + /** + * 사용자 레벨 조회 + */ + private String getUserLevel(APIGatewayProxyRequestEvent request) { + return CognitoUtil.extractClaim(request, "custom:level") + .orElse("INTERMEDIATE"); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQueryService.java new file mode 100644 index 00000000..5a3930ed --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQueryService.java @@ -0,0 +1,98 @@ +package com.mzc.secondproject.serverless.domain.news.service; + +import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.domain.news.model.NewsArticle; +import com.mzc.secondproject.serverless.domain.news.repository.NewsArticleRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.LocalDate; +import java.util.Optional; + +/** + * 뉴스 조회 서비스 + */ +public class NewsQueryService { + + private static final Logger logger = LoggerFactory.getLogger(NewsQueryService.class); + + private final NewsArticleRepository articleRepository; + + public NewsQueryService() { + this.articleRepository = new NewsArticleRepository(); + } + + public NewsQueryService(NewsArticleRepository articleRepository) { + this.articleRepository = articleRepository; + } + + /** + * 뉴스 상세 조회 + */ + public Optional getArticle(String articleId) { + logger.debug("뉴스 상세 조회: {}", articleId); + Optional article = articleRepository.findById(articleId); + + // 조회수 증가 + article.ifPresent(a -> { + String date = extractDateFromPk(a.getPk()); + if (date != null) { + articleRepository.incrementReadCount(date, articleId); + } + }); + + return article; + } + + /** + * 오늘의 뉴스 목록 조회 + */ + public PaginatedResult getTodayNews(int limit, String cursor) { + String today = LocalDate.now().toString(); + logger.debug("오늘의 뉴스 조회: date={}, limit={}", today, limit); + return articleRepository.findByDate(today, limit, cursor); + } + + /** + * 레벨별 뉴스 조회 + */ + public PaginatedResult getNewsByLevel(String level, int limit, String cursor) { + logger.debug("레벨별 뉴스 조회: level={}, limit={}", level, limit); + return articleRepository.findByLevel(level, limit, cursor); + } + + /** + * 카테고리별 뉴스 조회 + */ + public PaginatedResult getNewsByCategory(String category, int limit, String cursor) { + logger.debug("카테고리별 뉴스 조회: category={}, limit={}", category, limit); + return articleRepository.findByCategory(category, limit, cursor); + } + + /** + * 레벨 + 카테고리 복합 필터 조회 + */ + public PaginatedResult getNewsByLevelAndCategory(String level, String category, int limit, String cursor) { + logger.debug("레벨+카테고리 뉴스 조회: level={}, category={}, limit={}", level, category, limit); + return articleRepository.findByLevelAndCategory(level, category, limit, cursor); + } + + /** + * 사용자 레벨 맞춤 뉴스 추천 + */ + public PaginatedResult getRecommendedNews(String userLevel, int limit, String cursor) { + logger.debug("맞춤 뉴스 추천: userLevel={}, limit={}", userLevel, limit); + // 사용자 레벨에 맞는 뉴스 조회 + return articleRepository.findByLevel(userLevel, limit, cursor); + } + + /** + * PK에서 날짜 추출 (NEWS#2024-01-15 → 2024-01-15) + */ + private String extractDateFromPk(String pk) { + if (pk == null || !pk.startsWith("NEWS#")) { + return null; + } + return pk.substring(5); + } +} diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 552cb8e9..8288972b 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -1750,6 +1750,44 @@ Resources: Description: 매일 18시 KST (09:00 UTC)에 뉴스 수집 Enabled: true + NewsFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: !Sub "${AWS::StackName}-news" + CodeUri: . + Handler: com.mzc.secondproject.serverless.domain.news.handler.NewsHandler::handleRequest + Description: 뉴스 학습 API + MemorySize: 256 + Timeout: 30 + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref NewsTable + Events: + GetNewsList: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news + Method: GET + GetTodayNews: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/today + Method: GET + GetRecommendedNews: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/recommended + Method: GET + GetNewsDetail: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/{articleId} + Method: GET + NewsTable: Type: AWS::DynamoDB::Table Properties: From db6dd875c6f69a96b959b7bdde72abf1b6214bc6 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 13:51:43 +0900 Subject: [PATCH 449/528] =?UTF-8?q?feat(news):=20=EB=89=B4=EC=8A=A4=20?= =?UTF-8?q?=ED=95=99=EC=8A=B5=20=EB=B6=80=EA=B0=80=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(#389)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 읽기 완료 기록 API (POST /news/{articleId}/read) - 북마크 토글 API (POST /news/{articleId}/bookmark) - 북마크 목록 조회 API (GET /news/bookmarks) - 학습 통계 조회 API (GET /news/stats) - TTS 오디오 URL 조회 API (GET /news/{articleId}/audio) - UserNewsRecord 모델 추가 - UserNewsRepository 추가 - NewsLearningService 추가 --- .../domain/news/constants/NewsKey.java | 7 + .../domain/news/exception/NewsErrorCode.java | 3 + .../domain/news/handler/NewsHandler.java | 112 +++++++++- .../domain/news/model/UserNewsRecord.java | 58 +++++ .../news/repository/UserNewsRepository.java | 208 ++++++++++++++++++ .../news/service/NewsLearningService.java | 160 ++++++++++++++ ServerlessFunction/template.yaml | 37 ++++ 7 files changed, 583 insertions(+), 2 deletions(-) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/UserNewsRecord.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/UserNewsRepository.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/constants/NewsKey.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/constants/NewsKey.java index e5ba97b8..4c44a58f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/constants/NewsKey.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/constants/NewsKey.java @@ -119,4 +119,11 @@ public static String commentSk(String commentId) { public static String userNewsCommentsPk(String userId) { return DynamoDbKey.USER + userId + SUFFIX_NEWS_COMMENTS; } + + /** + * 사용자 뉴스 통계 GSI1 PK: USER_NEWS_STAT#{userId} + */ + public static String userNewsStatPk(String userId) { + return "USER_NEWS_STAT#" + userId; + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/exception/NewsErrorCode.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/exception/NewsErrorCode.java index fa898252..58197f0e 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/exception/NewsErrorCode.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/exception/NewsErrorCode.java @@ -8,6 +8,9 @@ */ public enum NewsErrorCode implements DomainErrorCode { + // 인증 관련 에러 + UNAUTHORIZED("AUTH_001", "인증이 필요합니다", 401), + // 뉴스 기사 관련 에러 ARTICLE_NOT_FOUND("ARTICLE_001", "뉴스 기사를 찾을 수 없습니다", 404), INVALID_ARTICLE_DATA("ARTICLE_002", "뉴스 기사 데이터가 유효하지 않습니다", 400), diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java index 3f53332b..8e42762d 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java @@ -11,11 +11,14 @@ import com.mzc.secondproject.serverless.common.util.ResponseGenerator; import com.mzc.secondproject.serverless.domain.news.exception.NewsErrorCode; import com.mzc.secondproject.serverless.domain.news.model.NewsArticle; +import com.mzc.secondproject.serverless.domain.news.model.UserNewsRecord; +import com.mzc.secondproject.serverless.domain.news.service.NewsLearningService; import com.mzc.secondproject.serverless.domain.news.service.NewsQueryService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Optional; @@ -29,14 +32,16 @@ public class NewsHandler implements RequestHandler stats = learningService.getUserStats(userId); + return ResponseGenerator.ok("뉴스 학습 통계 조회 성공", stats); + } + + /** + * 북마크 목록 조회 + * GET /news/bookmarks?limit=10 + */ + private APIGatewayProxyResponseEvent getBookmarks(APIGatewayProxyRequestEvent request) { + String userId = getUserId(request); + if (userId == null) { + return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); + } + + Map params = request.getQueryStringParameters(); + if (params == null) params = new HashMap<>(); + + int limit = parseLimit(params.get("limit")); + List bookmarks = learningService.getUserBookmarks(userId, limit); + + Map response = new HashMap<>(); + response.put("bookmarks", bookmarks); + response.put("count", bookmarks.size()); + + return ResponseGenerator.ok("북마크 목록 조회 성공", response); + } + + /** + * 뉴스 읽기 완료 기록 + * POST /news/{articleId}/read + */ + private APIGatewayProxyResponseEvent markAsRead(APIGatewayProxyRequestEvent request) { + String userId = getUserId(request); + if (userId == null) { + return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); + } + + String articleId = request.getPathParameters().get("articleId"); + learningService.markAsRead(userId, articleId); + + return ResponseGenerator.ok("읽기 완료 기록 성공", Map.of("articleId", articleId)); + } + + /** + * 북마크 토글 + * POST /news/{articleId}/bookmark + */ + private APIGatewayProxyResponseEvent toggleBookmark(APIGatewayProxyRequestEvent request) { + String userId = getUserId(request); + if (userId == null) { + return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); + } + + String articleId = request.getPathParameters().get("articleId"); + boolean isBookmarked = learningService.toggleBookmark(userId, articleId); + + return ResponseGenerator.ok( + isBookmarked ? "북마크 추가 성공" : "북마크 해제 성공", + Map.of("articleId", articleId, "bookmarked", isBookmarked) + ); + } + + /** + * 뉴스 TTS 오디오 URL 조회 + * GET /news/{articleId}/audio?voice=Joanna + */ + private APIGatewayProxyResponseEvent getAudio(APIGatewayProxyRequestEvent request) { + String articleId = request.getPathParameters().get("articleId"); + + Map params = request.getQueryStringParameters(); + String voice = (params != null) ? params.getOrDefault("voice", "Joanna") : "Joanna"; + + String audioUrl = learningService.getAudioUrl(articleId, voice); + if (audioUrl == null) { + return ResponseGenerator.fail(NewsErrorCode.ARTICLE_NOT_FOUND); + } + + return ResponseGenerator.ok("TTS 오디오 URL 조회 성공", Map.of("audioUrl", audioUrl)); + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/UserNewsRecord.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/UserNewsRecord.java new file mode 100644 index 00000000..eedfa8c5 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/UserNewsRecord.java @@ -0,0 +1,58 @@ +package com.mzc.secondproject.serverless.domain.news.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.*; + +/** + * 사용자 뉴스 학습 기록 + * PK: USER_NEWS#{userId} + * SK: READ#{articleId} 또는 BOOKMARK#{articleId} + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DynamoDbBean +public class UserNewsRecord { + + private String pk; // USER_NEWS#{userId} + private String sk; // READ#{articleId} 또는 BOOKMARK#{articleId} + private String gsi1pk; // USER_NEWS_STAT#{userId} + private String gsi1sk; // {date}#{type} + + private String userId; + private String articleId; + private String type; // READ, BOOKMARK + private String articleTitle; + private String articleLevel; + private String articleCategory; + private String createdAt; + private Long ttl; + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { + return pk; + } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { + return sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1PK") + public String getGsi1pk() { + return gsi1pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1SK") + public String getGsi1sk() { + return gsi1sk; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/UserNewsRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/UserNewsRepository.java new file mode 100644 index 00000000..25f8e651 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/UserNewsRepository.java @@ -0,0 +1,208 @@ +package com.mzc.secondproject.serverless.domain.news.repository; + +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.config.EnvConfig; +import com.mzc.secondproject.serverless.domain.news.constants.NewsKey; +import com.mzc.secondproject.serverless.domain.news.model.UserNewsRecord; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.*; +import software.amazon.awssdk.enhanced.dynamodb.model.*; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.*; + +/** + * 사용자 뉴스 학습 기록 Repository + */ +public class UserNewsRepository { + + private static final Logger logger = LoggerFactory.getLogger(UserNewsRepository.class); + private static final String TABLE_NAME = EnvConfig.getRequired("NEWS_TABLE_NAME"); + + private final DynamoDbTable table; + + public UserNewsRepository() { + this(AwsClients.dynamoDbEnhanced()); + } + + public UserNewsRepository(DynamoDbEnhancedClient enhancedClient) { + this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(UserNewsRecord.class)); + } + + /** + * 읽기 기록 저장 + */ + public void saveReadRecord(String userId, String articleId, String title, String level, String category) { + String now = Instant.now().toString(); + String today = LocalDate.now().toString(); + + UserNewsRecord record = UserNewsRecord.builder() + .pk(NewsKey.userNewsPk(userId)) + .sk(NewsKey.readSk(articleId)) + .gsi1pk(NewsKey.userNewsStatPk(userId)) + .gsi1sk(today + "#READ") + .userId(userId) + .articleId(articleId) + .type("READ") + .articleTitle(title) + .articleLevel(level) + .articleCategory(category) + .createdAt(now) + .build(); + + table.putItem(record); + logger.debug("읽기 기록 저장: userId={}, articleId={}", userId, articleId); + } + + /** + * 북마크 저장 + */ + public void saveBookmark(String userId, String articleId, String title, String level, String category) { + String now = Instant.now().toString(); + String today = LocalDate.now().toString(); + + UserNewsRecord record = UserNewsRecord.builder() + .pk(NewsKey.userNewsPk(userId)) + .sk(NewsKey.bookmarkSk(articleId)) + .gsi1pk(NewsKey.userNewsStatPk(userId)) + .gsi1sk(today + "#BOOKMARK") + .userId(userId) + .articleId(articleId) + .type("BOOKMARK") + .articleTitle(title) + .articleLevel(level) + .articleCategory(category) + .createdAt(now) + .build(); + + table.putItem(record); + logger.debug("북마크 저장: userId={}, articleId={}", userId, articleId); + } + + /** + * 북마크 삭제 + */ + public void deleteBookmark(String userId, String articleId) { + Key key = Key.builder() + .partitionValue(NewsKey.userNewsPk(userId)) + .sortValue(NewsKey.bookmarkSk(articleId)) + .build(); + + table.deleteItem(key); + logger.debug("북마크 삭제: userId={}, articleId={}", userId, articleId); + } + + /** + * 북마크 여부 확인 + */ + public boolean isBookmarked(String userId, String articleId) { + Key key = Key.builder() + .partitionValue(NewsKey.userNewsPk(userId)) + .sortValue(NewsKey.bookmarkSk(articleId)) + .build(); + + return table.getItem(key) != null; + } + + /** + * 읽기 기록 여부 확인 + */ + public boolean hasRead(String userId, String articleId) { + Key key = Key.builder() + .partitionValue(NewsKey.userNewsPk(userId)) + .sortValue(NewsKey.readSk(articleId)) + .build(); + + return table.getItem(key) != null; + } + + /** + * 사용자 북마크 목록 조회 + */ + public List getUserBookmarks(String userId, int limit) { + QueryConditional queryConditional = QueryConditional.sortBeginsWith( + Key.builder() + .partitionValue(NewsKey.userNewsPk(userId)) + .sortValue("BOOKMARK#") + .build() + ); + + QueryEnhancedRequest request = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .scanIndexForward(false) + .limit(limit) + .build(); + + List results = new ArrayList<>(); + for (Page page : table.query(request)) { + results.addAll(page.items()); + if (results.size() >= limit) break; + } + + return results.subList(0, Math.min(results.size(), limit)); + } + + /** + * 사용자 뉴스 통계 조회 + */ + public NewsStats getUserStats(String userId) { + QueryConditional queryConditional = QueryConditional.keyEqualTo( + Key.builder().partitionValue(NewsKey.userNewsPk(userId)).build() + ); + + int totalRead = 0; + int thisWeekRead = 0; + int totalBookmarks = 0; + Map byLevel = new HashMap<>(); + Map byCategory = new HashMap<>(); + + LocalDate weekAgo = LocalDate.now().minusDays(7); + + for (Page page : table.query(queryConditional)) { + for (UserNewsRecord record : page.items()) { + if ("READ".equals(record.getType())) { + totalRead++; + + // 이번 주 읽은 것 + if (record.getCreatedAt() != null) { + LocalDate readDate = Instant.parse(record.getCreatedAt()) + .atZone(java.time.ZoneId.systemDefault()).toLocalDate(); + if (readDate.isAfter(weekAgo)) { + thisWeekRead++; + } + } + + // 레벨별 통계 + String level = record.getArticleLevel(); + if (level != null) { + byLevel.merge(level, 1, Integer::sum); + } + + // 카테고리별 통계 + String category = record.getArticleCategory(); + if (category != null) { + byCategory.merge(category, 1, Integer::sum); + } + } else if ("BOOKMARK".equals(record.getType())) { + totalBookmarks++; + } + } + } + + return new NewsStats(totalRead, thisWeekRead, totalBookmarks, byLevel, byCategory); + } + + /** + * 뉴스 통계 레코드 + */ + public record NewsStats( + int totalRead, + int thisWeekRead, + int totalBookmarks, + Map byLevel, + Map byCategory + ) {} +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java new file mode 100644 index 00000000..8eba8522 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java @@ -0,0 +1,160 @@ +package com.mzc.secondproject.serverless.domain.news.service; + +import com.mzc.secondproject.serverless.common.config.EnvConfig; +import com.mzc.secondproject.serverless.common.service.PollyService; +import com.mzc.secondproject.serverless.domain.news.model.NewsArticle; +import com.mzc.secondproject.serverless.domain.news.model.UserNewsRecord; +import com.mzc.secondproject.serverless.domain.news.repository.NewsArticleRepository; +import com.mzc.secondproject.serverless.domain.news.repository.UserNewsRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * 뉴스 학습 부가 기능 서비스 + */ +public class NewsLearningService { + + private static final Logger logger = LoggerFactory.getLogger(NewsLearningService.class); + private static final String BUCKET_NAME = EnvConfig.getOrDefault("NEWS_BUCKET_NAME", "group2-englishstudy"); + + private final NewsArticleRepository articleRepository; + private final UserNewsRepository userNewsRepository; + private final PollyService pollyService; + + public NewsLearningService() { + this.articleRepository = new NewsArticleRepository(); + this.userNewsRepository = new UserNewsRepository(); + this.pollyService = new PollyService(BUCKET_NAME, "news/audio/"); + } + + public NewsLearningService(NewsArticleRepository articleRepository, + UserNewsRepository userNewsRepository, + PollyService pollyService) { + this.articleRepository = articleRepository; + this.userNewsRepository = userNewsRepository; + this.pollyService = pollyService; + } + + /** + * 뉴스 읽기 완료 기록 + */ + public void markAsRead(String userId, String articleId) { + Optional article = articleRepository.findById(articleId); + if (article.isEmpty()) { + logger.warn("기사를 찾을 수 없음: {}", articleId); + return; + } + + NewsArticle a = article.get(); + userNewsRepository.saveReadRecord( + userId, + articleId, + a.getTitle(), + a.getLevel(), + a.getCategory() + ); + + // 조회수 증가 + String date = extractDateFromPk(a.getPk()); + if (date != null) { + articleRepository.incrementReadCount(date, articleId); + } + + logger.info("읽기 완료 기록: userId={}, articleId={}", userId, articleId); + } + + /** + * 북마크 토글 + */ + public boolean toggleBookmark(String userId, String articleId) { + boolean isBookmarked = userNewsRepository.isBookmarked(userId, articleId); + + if (isBookmarked) { + userNewsRepository.deleteBookmark(userId, articleId); + logger.info("북마크 해제: userId={}, articleId={}", userId, articleId); + return false; + } else { + Optional article = articleRepository.findById(articleId); + if (article.isEmpty()) { + logger.warn("기사를 찾을 수 없음: {}", articleId); + return false; + } + + NewsArticle a = article.get(); + userNewsRepository.saveBookmark( + userId, + articleId, + a.getTitle(), + a.getLevel(), + a.getCategory() + ); + logger.info("북마크 추가: userId={}, articleId={}", userId, articleId); + return true; + } + } + + /** + * 북마크 여부 확인 + */ + public boolean isBookmarked(String userId, String articleId) { + return userNewsRepository.isBookmarked(userId, articleId); + } + + /** + * 사용자 북마크 목록 조회 + */ + public List getUserBookmarks(String userId, int limit) { + return userNewsRepository.getUserBookmarks(userId, limit); + } + + /** + * 뉴스 TTS 오디오 URL 생성 + */ + public String getAudioUrl(String articleId, String voice) { + Optional article = articleRepository.findById(articleId); + if (article.isEmpty()) { + logger.warn("기사를 찾을 수 없음: {}", articleId); + return null; + } + + NewsArticle a = article.get(); + String text = a.getTitle() + ". " + (a.getSummary() != null ? a.getSummary() : ""); + + // 텍스트가 너무 길면 제한 + if (text.length() > 3000) { + text = text.substring(0, 3000); + } + + PollyService.VoiceSynthesisResult result = pollyService.synthesizeSpeech(articleId, text, voice); + return result.getAudioUrl(); + } + + /** + * 사용자 뉴스 학습 통계 조회 + */ + public Map getUserStats(String userId) { + UserNewsRepository.NewsStats stats = userNewsRepository.getUserStats(userId); + + return Map.of( + "totalRead", stats.totalRead(), + "thisWeekRead", stats.thisWeekRead(), + "totalBookmarks", stats.totalBookmarks(), + "byLevel", stats.byLevel(), + "byCategory", stats.byCategory() + ); + } + + /** + * PK에서 날짜 추출 + */ + private String extractDateFromPk(String pk) { + if (pk == null || !pk.startsWith("NEWS#")) { + return null; + } + return pk.substring(5); + } +} diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 8288972b..8bc1de01 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -1762,6 +1762,13 @@ Resources: Policies: - DynamoDBCrudPolicy: TableName: !Ref NewsTable + - S3CrudPolicy: + BucketName: !Ref ContentBucket + - Statement: + - Effect: Allow + Action: + - polly:SynthesizeSpeech + Resource: "*" Events: GetNewsList: Type: Api @@ -1781,6 +1788,36 @@ Resources: RestApiId: !Ref MainApi Path: /news/recommended Method: GET + GetNewsStats: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/stats + Method: GET + GetBookmarks: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/bookmarks + Method: GET + MarkAsRead: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/{articleId}/read + Method: POST + ToggleBookmark: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/{articleId}/bookmark + Method: POST + GetAudio: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/{articleId}/audio + Method: GET GetNewsDetail: Type: Api Properties: From 63d748ddca2e1cdef1b575a7886d247d9f588f83 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 14:01:27 +0900 Subject: [PATCH 450/528] =?UTF-8?q?feat(news):=20=EB=B3=B5=ED=95=A9=20?= =?UTF-8?q?=ED=80=B4=EC=A6=88=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#471)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 퀴즈 조회 API (GET /news/{articleId}/quiz) - 퀴즈 제출 API (POST /news/{articleId}/quiz) - 퀴즈 기록 조회 API (GET /news/quiz/history) - NewsQuizResult 모델 추가 - QuizAnswerResult 모델 추가 - NewsQuizRepository 추가 - NewsQuizService 추가 --- .../domain/news/handler/NewsHandler.java | 97 ++++++- .../domain/news/model/NewsQuizResult.java | 63 +++++ .../domain/news/model/QuizAnswerResult.java | 25 ++ .../news/repository/NewsQuizRepository.java | 126 +++++++++ .../domain/news/service/NewsQuizService.java | 263 ++++++++++++++++++ ServerlessFunction/template.yaml | 18 ++ 6 files changed, 590 insertions(+), 2 deletions(-) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsQuizResult.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/QuizAnswerResult.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsQuizRepository.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQuizService.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java index 8e42762d..ac23fd5d 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java @@ -11,9 +11,14 @@ import com.mzc.secondproject.serverless.common.util.ResponseGenerator; import com.mzc.secondproject.serverless.domain.news.exception.NewsErrorCode; import com.mzc.secondproject.serverless.domain.news.model.NewsArticle; +import com.mzc.secondproject.serverless.domain.news.model.NewsQuizResult; import com.mzc.secondproject.serverless.domain.news.model.UserNewsRecord; import com.mzc.secondproject.serverless.domain.news.service.NewsLearningService; import com.mzc.secondproject.serverless.domain.news.service.NewsQueryService; +import com.mzc.secondproject.serverless.domain.news.service.NewsQuizService; +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -30,18 +35,21 @@ public class NewsHandler implements RequestHandler quizData = quizService.getQuiz(articleId, userId); + + if (quizData.isEmpty()) { + return ResponseGenerator.fail(NewsErrorCode.QUIZ_NOT_FOUND); + } + + return ResponseGenerator.ok("퀴즈 조회 성공", quizData.get()); + } + + /** + * 퀴즈 제출 + * POST /news/{articleId}/quiz + */ + private APIGatewayProxyResponseEvent submitQuiz(APIGatewayProxyRequestEvent request) { + String userId = getUserId(request); + if (userId == null) { + return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); + } + + String articleId = request.getPathParameters().get("articleId"); + + // 요청 바디 파싱 + JsonObject body = gson.fromJson(request.getBody(), JsonObject.class); + JsonArray answersArray = body.getAsJsonArray("answers"); + Integer timeTaken = body.has("timeTaken") ? body.get("timeTaken").getAsInt() : null; + + List answers = new java.util.ArrayList<>(); + if (answersArray != null) { + answersArray.forEach(e -> { + JsonObject a = e.getAsJsonObject(); + answers.add(new NewsQuizService.QuizAnswer( + a.get("questionId").getAsString(), + a.get("answer").getAsString() + )); + }); + } + + NewsQuizService.QuizSubmitResult result = quizService.submitQuiz(userId, articleId, answers, timeTaken); + + if (result == null) { + return ResponseGenerator.fail(NewsErrorCode.QUIZ_ALREADY_SUBMITTED); + } + + return ResponseGenerator.ok("퀴즈 제출 성공", result); + } + + /** + * 퀴즈 기록 조회 + * GET /news/quiz/history?limit=10 + */ + private APIGatewayProxyResponseEvent getQuizHistory(APIGatewayProxyRequestEvent request) { + String userId = getUserId(request); + if (userId == null) { + return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); + } + + Map params = request.getQueryStringParameters(); + if (params == null) params = new HashMap<>(); + + int limit = parseLimit(params.get("limit")); + List history = quizService.getUserQuizHistory(userId, limit); + Map quizStats = quizService.getUserQuizStats(userId); + + Map response = new HashMap<>(); + response.put("history", history); + response.put("stats", quizStats); + response.put("count", history.size()); + + return ResponseGenerator.ok("퀴즈 기록 조회 성공", response); + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsQuizResult.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsQuizResult.java new file mode 100644 index 00000000..23b47f62 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsQuizResult.java @@ -0,0 +1,63 @@ +package com.mzc.secondproject.serverless.domain.news.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.*; + +import java.util.List; + +/** + * 뉴스 퀴즈 결과 + * PK: USER#{userId}#NEWS + * SK: QUIZ#{articleId} + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DynamoDbBean +public class NewsQuizResult { + + private String pk; // USER#{userId}#NEWS + private String sk; // QUIZ#{articleId} + private String gsi1pk; // USER_NEWS_STAT#{userId} + private String gsi1sk; // {date}#QUIZ + + private String userId; + private String articleId; + private String articleTitle; + private String articleLevel; + private int score; // 0-100 + private int totalPoints; // 총 배점 + private int earnedPoints; // 획득 점수 + private List answers; + private Integer timeTaken; // 소요 시간 (초) + private String submittedAt; + private Long ttl; + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { + return pk; + } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { + return sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1PK") + public String getGsi1pk() { + return gsi1pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1SK") + public String getGsi1sk() { + return gsi1sk; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/QuizAnswerResult.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/QuizAnswerResult.java new file mode 100644 index 00000000..3dee95b6 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/QuizAnswerResult.java @@ -0,0 +1,25 @@ +package com.mzc.secondproject.serverless.domain.news.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; + +/** + * 퀴즈 답변 결과 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DynamoDbBean +public class QuizAnswerResult { + + private String questionId; + private String type; // COMPREHENSION, WORD_MATCH, FILL_BLANK + private String userAnswer; + private String correctAnswer; + private boolean correct; + private int points; // 획득 점수 (정답시 배점, 오답시 0) +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsQuizRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsQuizRepository.java new file mode 100644 index 00000000..b2786f99 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsQuizRepository.java @@ -0,0 +1,126 @@ +package com.mzc.secondproject.serverless.domain.news.repository; + +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.config.EnvConfig; +import com.mzc.secondproject.serverless.domain.news.constants.NewsKey; +import com.mzc.secondproject.serverless.domain.news.model.NewsQuizResult; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.*; +import software.amazon.awssdk.enhanced.dynamodb.model.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/** + * 뉴스 퀴즈 결과 Repository + */ +public class NewsQuizRepository { + + private static final Logger logger = LoggerFactory.getLogger(NewsQuizRepository.class); + private static final String TABLE_NAME = EnvConfig.getRequired("NEWS_TABLE_NAME"); + + private final DynamoDbTable table; + + public NewsQuizRepository() { + this(AwsClients.dynamoDbEnhanced()); + } + + public NewsQuizRepository(DynamoDbEnhancedClient enhancedClient) { + this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(NewsQuizResult.class)); + } + + /** + * 퀴즈 결과 저장 + */ + public void save(NewsQuizResult result) { + table.putItem(result); + logger.debug("퀴즈 결과 저장: userId={}, articleId={}, score={}", + result.getUserId(), result.getArticleId(), result.getScore()); + } + + /** + * 퀴즈 결과 조회 + */ + public Optional findByUserAndArticle(String userId, String articleId) { + Key key = Key.builder() + .partitionValue(NewsKey.userNewsPk(userId)) + .sortValue(NewsKey.quizSk(articleId)) + .build(); + + NewsQuizResult result = table.getItem(key); + return Optional.ofNullable(result); + } + + /** + * 퀴즈 제출 여부 확인 + */ + public boolean hasSubmitted(String userId, String articleId) { + return findByUserAndArticle(userId, articleId).isPresent(); + } + + /** + * 사용자 퀴즈 결과 목록 조회 + */ + public List getUserQuizResults(String userId, int limit) { + QueryConditional queryConditional = QueryConditional.sortBeginsWith( + Key.builder() + .partitionValue(NewsKey.userNewsPk(userId)) + .sortValue("QUIZ#") + .build() + ); + + QueryEnhancedRequest request = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .scanIndexForward(false) + .limit(limit) + .build(); + + List results = new ArrayList<>(); + for (Page page : table.query(request)) { + results.addAll(page.items()); + if (results.size() >= limit) break; + } + + return results.subList(0, Math.min(results.size(), limit)); + } + + /** + * 사용자 퀴즈 통계 조회 + */ + public QuizStats getUserQuizStats(String userId) { + QueryConditional queryConditional = QueryConditional.sortBeginsWith( + Key.builder() + .partitionValue(NewsKey.userNewsPk(userId)) + .sortValue("QUIZ#") + .build() + ); + + int totalQuizzes = 0; + int totalScore = 0; + int perfectScores = 0; + + for (Page page : table.query(queryConditional)) { + for (NewsQuizResult result : page.items()) { + totalQuizzes++; + totalScore += result.getScore(); + if (result.getScore() == 100) { + perfectScores++; + } + } + } + + int avgScore = totalQuizzes > 0 ? totalScore / totalQuizzes : 0; + return new QuizStats(totalQuizzes, avgScore, perfectScores); + } + + /** + * 퀴즈 통계 레코드 + */ + public record QuizStats( + int totalQuizzes, + int avgScore, + int perfectScores + ) {} +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQuizService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQuizService.java new file mode 100644 index 00000000..bb22fc90 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQuizService.java @@ -0,0 +1,263 @@ +package com.mzc.secondproject.serverless.domain.news.service; + +import com.mzc.secondproject.serverless.domain.news.constants.NewsKey; +import com.mzc.secondproject.serverless.domain.news.model.*; +import com.mzc.secondproject.serverless.domain.news.repository.NewsArticleRepository; +import com.mzc.secondproject.serverless.domain.news.repository.NewsQuizRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.*; + +/** + * 뉴스 퀴즈 서비스 + */ +public class NewsQuizService { + + private static final Logger logger = LoggerFactory.getLogger(NewsQuizService.class); + + private final NewsArticleRepository articleRepository; + private final NewsQuizRepository quizRepository; + + public NewsQuizService() { + this.articleRepository = new NewsArticleRepository(); + this.quizRepository = new NewsQuizRepository(); + } + + public NewsQuizService(NewsArticleRepository articleRepository, NewsQuizRepository quizRepository) { + this.articleRepository = articleRepository; + this.quizRepository = quizRepository; + } + + /** + * 퀴즈 조회 + */ + public Optional getQuiz(String articleId, String userId) { + Optional articleOpt = articleRepository.findById(articleId); + if (articleOpt.isEmpty()) { + logger.warn("기사를 찾을 수 없음: {}", articleId); + return Optional.empty(); + } + + NewsArticle article = articleOpt.get(); + List questions = article.getQuiz(); + + if (questions == null || questions.isEmpty()) { + logger.warn("퀴즈가 없는 기사: {}", articleId); + return Optional.empty(); + } + + // 이미 제출했는지 확인 + boolean submitted = quizRepository.hasSubmitted(userId, articleId); + + // 정답 제거한 퀴즈 반환 + List questionViews = questions.stream() + .map(q -> QuizQuestionView.builder() + .questionId(q.getQuestionId()) + .type(q.getType()) + .question(q.getQuestion()) + .options(q.getOptions()) + .points(q.getPoints()) + .build()) + .toList(); + + return Optional.of(QuizData.builder() + .articleId(articleId) + .articleTitle(article.getTitle()) + .level(article.getLevel()) + .questions(questionViews) + .totalPoints(questions.stream().mapToInt(QuizQuestion::getPoints).sum()) + .submitted(submitted) + .build()); + } + + /** + * 퀴즈 제출 및 채점 + */ + public QuizSubmitResult submitQuiz(String userId, String articleId, List answers, Integer timeTaken) { + // 이미 제출했는지 확인 + if (quizRepository.hasSubmitted(userId, articleId)) { + logger.warn("이미 제출한 퀴즈: userId={}, articleId={}", userId, articleId); + return null; + } + + // 기사 조회 + Optional articleOpt = articleRepository.findById(articleId); + if (articleOpt.isEmpty()) { + logger.warn("기사를 찾을 수 없음: {}", articleId); + return null; + } + + NewsArticle article = articleOpt.get(); + List questions = article.getQuiz(); + + if (questions == null || questions.isEmpty()) { + logger.warn("퀴즈가 없는 기사: {}", articleId); + return null; + } + + // 정답 맵 생성 + Map questionMap = new HashMap<>(); + for (QuizQuestion q : questions) { + questionMap.put(q.getQuestionId(), q); + } + + // 채점 + List answerResults = new ArrayList<>(); + int earnedPoints = 0; + int totalPoints = 0; + + for (QuizAnswer answer : answers) { + QuizQuestion question = questionMap.get(answer.questionId()); + if (question == null) continue; + + boolean correct = question.getCorrectAnswer().equalsIgnoreCase(answer.answer()); + int points = correct ? question.getPoints() : 0; + earnedPoints += points; + totalPoints += question.getPoints(); + + answerResults.add(QuizAnswerResult.builder() + .questionId(answer.questionId()) + .type(question.getType()) + .userAnswer(answer.answer()) + .correctAnswer(question.getCorrectAnswer()) + .correct(correct) + .points(points) + .build()); + } + + // 점수 계산 (100점 만점) + int score = totalPoints > 0 ? (earnedPoints * 100) / totalPoints : 0; + + // 결과 저장 + String now = Instant.now().toString(); + String today = LocalDate.now().toString(); + + NewsQuizResult result = NewsQuizResult.builder() + .pk(NewsKey.userNewsPk(userId)) + .sk(NewsKey.quizSk(articleId)) + .gsi1pk(NewsKey.userNewsStatPk(userId)) + .gsi1sk(today + "#QUIZ") + .userId(userId) + .articleId(articleId) + .articleTitle(article.getTitle()) + .articleLevel(article.getLevel()) + .score(score) + .totalPoints(totalPoints) + .earnedPoints(earnedPoints) + .answers(answerResults) + .timeTaken(timeTaken) + .submittedAt(now) + .build(); + + quizRepository.save(result); + logger.info("퀴즈 제출 완료: userId={}, articleId={}, score={}", userId, articleId, score); + + // 피드백 생성 + String feedback = generateFeedback(score, answerResults); + + return QuizSubmitResult.builder() + .score(score) + .earnedPoints(earnedPoints) + .totalPoints(totalPoints) + .results(answerResults) + .feedback(feedback) + .build(); + } + + /** + * 사용자 퀴즈 결과 조회 + */ + public Optional getQuizResult(String userId, String articleId) { + return quizRepository.findByUserAndArticle(userId, articleId); + } + + /** + * 사용자 퀴즈 기록 목록 조회 + */ + public List getUserQuizHistory(String userId, int limit) { + return quizRepository.getUserQuizResults(userId, limit); + } + + /** + * 사용자 퀴즈 통계 조회 + */ + public Map getUserQuizStats(String userId) { + NewsQuizRepository.QuizStats stats = quizRepository.getUserQuizStats(userId); + return Map.of( + "totalQuizzes", stats.totalQuizzes(), + "avgScore", stats.avgScore(), + "perfectScores", stats.perfectScores() + ); + } + + /** + * 피드백 생성 + */ + private String generateFeedback(int score, List results) { + if (score == 100) { + return "Perfect! You understood the article completely."; + } else if (score >= 80) { + return "Great job! You have a solid understanding of the article."; + } else if (score >= 60) { + return "Good effort! Review the highlighted words for better comprehension."; + } else if (score >= 40) { + return "Keep practicing! Try reading the article again before retaking the quiz."; + } else { + return "Don't give up! Focus on vocabulary and main ideas."; + } + } + + /** + * 퀴즈 데이터 (정답 제외) + */ + @lombok.Data + @lombok.Builder + @lombok.NoArgsConstructor + @lombok.AllArgsConstructor + public static class QuizData { + private String articleId; + private String articleTitle; + private String level; + private List questions; + private int totalPoints; + private boolean submitted; + } + + /** + * 퀴즈 문제 뷰 (정답 제외) + */ + @lombok.Data + @lombok.Builder + @lombok.NoArgsConstructor + @lombok.AllArgsConstructor + public static class QuizQuestionView { + private String questionId; + private String type; + private String question; + private List options; + private int points; + } + + /** + * 사용자 답변 + */ + public record QuizAnswer(String questionId, String answer) {} + + /** + * 퀴즈 제출 결과 + */ + @lombok.Data + @lombok.Builder + @lombok.NoArgsConstructor + @lombok.AllArgsConstructor + public static class QuizSubmitResult { + private int score; + private int earnedPoints; + private int totalPoints; + private List results; + private String feedback; + } +} diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 8bc1de01..f701a42f 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -1800,6 +1800,24 @@ Resources: RestApiId: !Ref MainApi Path: /news/bookmarks Method: GET + GetQuizHistory: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/quiz/history + Method: GET + GetQuiz: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/{articleId}/quiz + Method: GET + SubmitQuiz: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/{articleId}/quiz + Method: POST MarkAsRead: Type: Api Properties: From 399f188f2e9f92c288f2b82fed5cd96dcd20a874 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 14:10:24 +0900 Subject: [PATCH 451/528] =?UTF-8?q?feat(news):=20=EB=8B=A8=EC=96=B4=20?= =?UTF-8?q?=EC=88=98=EC=A7=91=20&=20Vocabulary=20=EC=97=B0=EB=8F=99=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(#472)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 단어 수집 API (POST /news/{articleId}/words) - 수집 단어 목록 API (GET /news/words) - 단어 상세 조회 API (GET /news/{articleId}/words/{word}) - 단어 삭제 API (DELETE /news/{articleId}/words/{word}) - Vocabulary 연동 API (POST /news/words/{word}/sync) - NewsWordCollect 모델 추가 - NewsWordRepository 추가 - NewsWordService 추가 --- .../domain/news/handler/NewsHandler.java | 122 ++++++++++- .../domain/news/model/NewsWordCollect.java | 62 ++++++ .../news/repository/NewsWordRepository.java | 133 ++++++++++++ .../domain/news/service/NewsWordService.java | 203 ++++++++++++++++++ ServerlessFunction/template.yaml | 32 +++ 5 files changed, 550 insertions(+), 2 deletions(-) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsWordCollect.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsWordRepository.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsWordService.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java index ac23fd5d..180bb7cb 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java @@ -12,10 +12,12 @@ import com.mzc.secondproject.serverless.domain.news.exception.NewsErrorCode; import com.mzc.secondproject.serverless.domain.news.model.NewsArticle; import com.mzc.secondproject.serverless.domain.news.model.NewsQuizResult; +import com.mzc.secondproject.serverless.domain.news.model.NewsWordCollect; import com.mzc.secondproject.serverless.domain.news.model.UserNewsRecord; import com.mzc.secondproject.serverless.domain.news.service.NewsLearningService; import com.mzc.secondproject.serverless.domain.news.service.NewsQueryService; import com.mzc.secondproject.serverless.domain.news.service.NewsQuizService; +import com.mzc.secondproject.serverless.domain.news.service.NewsWordService; import com.google.gson.Gson; import com.google.gson.JsonArray; import com.google.gson.JsonObject; @@ -40,16 +42,19 @@ public class NewsHandler implements RequestHandler params = request.getQueryStringParameters(); + if (params == null) params = new HashMap<>(); + + int limit = parseLimit(params.get("limit")); + List words = wordService.getUserWords(userId, limit); + Map stats = wordService.getUserWordStats(userId); + + Map response = new HashMap<>(); + response.put("words", words); + response.put("stats", stats); + response.put("count", words.size()); + + return ResponseGenerator.ok("수집 단어 목록 조회 성공", response); + } + + /** + * 단어 수집 + * POST /news/{articleId}/words + */ + private APIGatewayProxyResponseEvent collectWord(APIGatewayProxyRequestEvent request) { + String userId = getUserId(request); + if (userId == null) { + return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); + } + + String articleId = request.getPathParameters().get("articleId"); + + JsonObject body = gson.fromJson(request.getBody(), JsonObject.class); + String word = body.get("word").getAsString(); + String context = body.has("context") ? body.get("context").getAsString() : ""; + + NewsWordCollect collected = wordService.collectWord(userId, articleId, word, context); + + if (collected == null) { + return ResponseGenerator.fail(NewsErrorCode.WORD_ALREADY_COLLECTED); + } + + return ResponseGenerator.ok("단어 수집 성공", collected); + } + + /** + * 단어 삭제 + * DELETE /news/{articleId}/words/{word} + */ + private APIGatewayProxyResponseEvent deleteWord(APIGatewayProxyRequestEvent request) { + String userId = getUserId(request); + if (userId == null) { + return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); + } + + String articleId = request.getPathParameters().get("articleId"); + String word = request.getPathParameters().get("word"); + + wordService.deleteWord(userId, word, articleId); + + return ResponseGenerator.ok("단어 삭제 성공", Map.of("word", word)); + } + + /** + * 단어 상세 정보 조회 + * GET /news/{articleId}/words/{word} + */ + private APIGatewayProxyResponseEvent getWordDetail(APIGatewayProxyRequestEvent request) { + String word = request.getPathParameters().get("word"); + + Optional detail = wordService.getWordDetail(word); + + if (detail.isEmpty()) { + return ResponseGenerator.fail(NewsErrorCode.WORD_NOT_COLLECTED); + } + + return ResponseGenerator.ok("단어 상세 조회 성공", detail.get()); + } + + /** + * 단어 Vocabulary 연동 + * POST /news/words/{word}/sync + */ + private APIGatewayProxyResponseEvent syncWordToVocab(APIGatewayProxyRequestEvent request) { + String userId = getUserId(request); + if (userId == null) { + return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); + } + + String word = request.getPathParameters().get("word"); + + JsonObject body = gson.fromJson(request.getBody(), JsonObject.class); + String articleId = body.get("articleId").getAsString(); + + boolean synced = wordService.syncToVocabulary(userId, word, articleId); + + if (!synced) { + return ResponseGenerator.fail(NewsErrorCode.WORD_NOT_COLLECTED); + } + + return ResponseGenerator.ok("Vocabulary 연동 성공", Map.of("word", word, "synced", true)); + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsWordCollect.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsWordCollect.java new file mode 100644 index 00000000..59f4fa93 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsWordCollect.java @@ -0,0 +1,62 @@ +package com.mzc.secondproject.serverless.domain.news.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.*; + +/** + * 뉴스 단어 수집 + * PK: USER#{userId}#NEWS + * SK: WORD#{word}#{articleId} + * GSI1: USER#{userId}#NEWS_WORDS / {collectedAt} + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DynamoDbBean +public class NewsWordCollect { + + private String pk; // USER#{userId}#NEWS + private String sk; // WORD#{word}#{articleId} + private String gsi1pk; // USER#{userId}#NEWS_WORDS + private String gsi1sk; // {collectedAt} + + private String userId; + private String word; + private String meaning; + private String pronunciation; + private String context; // 문맥 문장 + private String articleId; + private String articleTitle; + private String collectedAt; + private Boolean syncedToVocab; // Vocabulary 연동 여부 + private String vocabUserWordId; // 연동된 UserWord ID + private Long ttl; + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { + return pk; + } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { + return sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1PK") + public String getGsi1pk() { + return gsi1pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1SK") + public String getGsi1sk() { + return gsi1sk; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsWordRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsWordRepository.java new file mode 100644 index 00000000..5dfebc80 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsWordRepository.java @@ -0,0 +1,133 @@ +package com.mzc.secondproject.serverless.domain.news.repository; + +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.config.EnvConfig; +import com.mzc.secondproject.serverless.domain.news.constants.NewsKey; +import com.mzc.secondproject.serverless.domain.news.model.NewsWordCollect; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.*; +import software.amazon.awssdk.enhanced.dynamodb.model.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/** + * 뉴스 단어 수집 Repository + */ +public class NewsWordRepository { + + private static final Logger logger = LoggerFactory.getLogger(NewsWordRepository.class); + private static final String TABLE_NAME = EnvConfig.getRequired("NEWS_TABLE_NAME"); + + private final DynamoDbTable table; + private final DynamoDbIndex gsi1Index; + + public NewsWordRepository() { + this(AwsClients.dynamoDbEnhanced()); + } + + public NewsWordRepository(DynamoDbEnhancedClient enhancedClient) { + this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(NewsWordCollect.class)); + this.gsi1Index = table.index("GSI1"); + } + + /** + * 단어 수집 저장 + */ + public void save(NewsWordCollect wordCollect) { + table.putItem(wordCollect); + logger.debug("단어 수집 저장: userId={}, word={}", wordCollect.getUserId(), wordCollect.getWord()); + } + + /** + * 단어 수집 조회 + */ + public Optional findByUserWordArticle(String userId, String word, String articleId) { + Key key = Key.builder() + .partitionValue(NewsKey.userNewsPk(userId)) + .sortValue(NewsKey.wordSk(word, articleId)) + .build(); + + NewsWordCollect result = table.getItem(key); + return Optional.ofNullable(result); + } + + /** + * 이미 수집했는지 확인 + */ + public boolean hasCollected(String userId, String word, String articleId) { + return findByUserWordArticle(userId, word, articleId).isPresent(); + } + + /** + * 단어 수집 삭제 + */ + public void delete(String userId, String word, String articleId) { + Key key = Key.builder() + .partitionValue(NewsKey.userNewsPk(userId)) + .sortValue(NewsKey.wordSk(word, articleId)) + .build(); + + table.deleteItem(key); + logger.debug("단어 수집 삭제: userId={}, word={}", userId, word); + } + + /** + * 사용자 수집 단어 목록 조회 (최신순) + */ + public List getUserWords(String userId, int limit) { + QueryConditional queryConditional = QueryConditional.keyEqualTo( + Key.builder() + .partitionValue(NewsKey.userNewsWordsPk(userId)) + .build() + ); + + QueryEnhancedRequest request = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .scanIndexForward(false) + .limit(limit) + .build(); + + List results = new ArrayList<>(); + for (Page page : gsi1Index.query(request)) { + results.addAll(page.items()); + if (results.size() >= limit) break; + } + + return results.subList(0, Math.min(results.size(), limit)); + } + + /** + * 사용자 수집 단어 수 조회 + */ + public int countUserWords(String userId) { + QueryConditional queryConditional = QueryConditional.sortBeginsWith( + Key.builder() + .partitionValue(NewsKey.userNewsPk(userId)) + .sortValue("WORD#") + .build() + ); + + int count = 0; + for (Page page : table.query(queryConditional)) { + count += page.items().size(); + } + return count; + } + + /** + * Vocabulary 연동 상태 업데이트 + */ + public void updateSyncStatus(String userId, String word, String articleId, String vocabUserWordId) { + Optional wordOpt = findByUserWordArticle(userId, word, articleId); + if (wordOpt.isPresent()) { + NewsWordCollect wordCollect = wordOpt.get(); + wordCollect.setSyncedToVocab(true); + wordCollect.setVocabUserWordId(vocabUserWordId); + table.putItem(wordCollect); + logger.debug("Vocabulary 연동 완료: userId={}, word={}", userId, word); + } + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsWordService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsWordService.java new file mode 100644 index 00000000..6c3c23ec --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsWordService.java @@ -0,0 +1,203 @@ +package com.mzc.secondproject.serverless.domain.news.service; + +import com.mzc.secondproject.serverless.domain.news.constants.NewsKey; +import com.mzc.secondproject.serverless.domain.news.model.NewsArticle; +import com.mzc.secondproject.serverless.domain.news.model.NewsWordCollect; +import com.mzc.secondproject.serverless.domain.news.repository.NewsArticleRepository; +import com.mzc.secondproject.serverless.domain.news.repository.NewsWordRepository; +import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; +import com.mzc.secondproject.serverless.domain.vocabulary.repository.WordRepository; +import com.mzc.secondproject.serverless.domain.vocabulary.service.UserWordCommandService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * 뉴스 단어 수집 서비스 + */ +public class NewsWordService { + + private static final Logger logger = LoggerFactory.getLogger(NewsWordService.class); + + private final NewsWordRepository newsWordRepository; + private final NewsArticleRepository articleRepository; + private final WordRepository wordRepository; + private final UserWordCommandService userWordCommandService; + + public NewsWordService() { + this.newsWordRepository = new NewsWordRepository(); + this.articleRepository = new NewsArticleRepository(); + this.wordRepository = new WordRepository(); + this.userWordCommandService = new UserWordCommandService(); + } + + public NewsWordService(NewsWordRepository newsWordRepository, + NewsArticleRepository articleRepository, + WordRepository wordRepository, + UserWordCommandService userWordCommandService) { + this.newsWordRepository = newsWordRepository; + this.articleRepository = articleRepository; + this.wordRepository = wordRepository; + this.userWordCommandService = userWordCommandService; + } + + /** + * 단어 수집 + */ + public NewsWordCollect collectWord(String userId, String articleId, String word, String context) { + // 이미 수집했는지 확인 + if (newsWordRepository.hasCollected(userId, word, articleId)) { + logger.warn("이미 수집한 단어: userId={}, word={}", userId, word); + return newsWordRepository.findByUserWordArticle(userId, word, articleId).orElse(null); + } + + // 기사 조회 + Optional articleOpt = articleRepository.findById(articleId); + String articleTitle = articleOpt.map(NewsArticle::getTitle).orElse(""); + + // 단어 정보 조회 (Word 테이블에서) + String wordId = word.toLowerCase().trim(); + Optional wordOpt = wordRepository.findById(wordId); + String meaning = wordOpt.map(Word::getKorean).orElse(""); + String pronunciation = ""; + + String now = Instant.now().toString(); + + NewsWordCollect wordCollect = NewsWordCollect.builder() + .pk(NewsKey.userNewsPk(userId)) + .sk(NewsKey.wordSk(word, articleId)) + .gsi1pk(NewsKey.userNewsWordsPk(userId)) + .gsi1sk(now) + .userId(userId) + .word(word) + .meaning(meaning) + .pronunciation(pronunciation) + .context(context) + .articleId(articleId) + .articleTitle(articleTitle) + .collectedAt(now) + .syncedToVocab(false) + .build(); + + newsWordRepository.save(wordCollect); + logger.info("단어 수집 완료: userId={}, word={}, articleId={}", userId, word, articleId); + + return wordCollect; + } + + /** + * 수집한 단어 삭제 + */ + public void deleteWord(String userId, String word, String articleId) { + newsWordRepository.delete(userId, word, articleId); + logger.info("단어 삭제: userId={}, word={}", userId, word); + } + + /** + * 사용자 수집 단어 목록 조회 + */ + public List getUserWords(String userId, int limit) { + return newsWordRepository.getUserWords(userId, limit); + } + + /** + * 사용자 수집 단어 수 조회 + */ + public int countUserWords(String userId) { + return newsWordRepository.countUserWords(userId); + } + + /** + * 단어 상세 정보 조회 + */ + public Optional getWordDetail(String word) { + String wordId = word.toLowerCase().trim(); + Optional wordOpt = wordRepository.findById(wordId); + + if (wordOpt.isEmpty()) { + return Optional.empty(); + } + + Word w = wordOpt.get(); + return Optional.of(WordDetail.builder() + .word(w.getEnglish()) + .meaning(w.getKorean()) + .pronunciation("") + .example(w.getExample()) + .level(w.getLevel()) + .build()); + } + + /** + * Vocabulary 도메인으로 단어 연동 + */ + public boolean syncToVocabulary(String userId, String word, String articleId) { + Optional wordOpt = newsWordRepository.findByUserWordArticle(userId, word, articleId); + if (wordOpt.isEmpty()) { + logger.warn("수집한 단어를 찾을 수 없음: userId={}, word={}", userId, word); + return false; + } + + NewsWordCollect wordCollect = wordOpt.get(); + + // 이미 연동됐는지 확인 + if (Boolean.TRUE.equals(wordCollect.getSyncedToVocab())) { + logger.info("이미 Vocabulary에 연동됨: userId={}, word={}", userId, word); + return true; + } + + // Word 테이블에서 단어 조회 + String wordId = word.toLowerCase().trim(); + Optional vocabWord = wordRepository.findById(wordId); + + if (vocabWord.isEmpty()) { + logger.warn("Vocabulary에 없는 단어: {}", word); + return false; + } + + // UserWord 생성 (NEW 상태로) + userWordCommandService.updateWordStatus(userId, wordId, "NEW"); + + // 연동 상태 업데이트 + newsWordRepository.updateSyncStatus(userId, word, articleId, wordId); + + logger.info("Vocabulary 연동 완료: userId={}, word={}", userId, word); + return true; + } + + /** + * 사용자 단어 수집 통계 + */ + public Map getUserWordStats(String userId) { + int totalWords = newsWordRepository.countUserWords(userId); + List recentWords = newsWordRepository.getUserWords(userId, 5); + long syncedCount = recentWords.stream() + .filter(w -> Boolean.TRUE.equals(w.getSyncedToVocab())) + .count(); + + return Map.of( + "totalCollected", totalWords, + "recentWords", recentWords, + "syncedToVocab", syncedCount + ); + } + + /** + * 단어 상세 정보 + */ + @lombok.Data + @lombok.Builder + @lombok.NoArgsConstructor + @lombok.AllArgsConstructor + public static class WordDetail { + private String word; + private String meaning; + private String pronunciation; + private String example; + private String level; + } +} diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index f701a42f..15fd4280 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -1762,6 +1762,8 @@ Resources: Policies: - DynamoDBCrudPolicy: TableName: !Ref NewsTable + - DynamoDBCrudPolicy: + TableName: !Ref VocabularyTable - S3CrudPolicy: BucketName: !Ref ContentBucket - Statement: @@ -1800,6 +1802,36 @@ Resources: RestApiId: !Ref MainApi Path: /news/bookmarks Method: GET + GetUserWords: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/words + Method: GET + SyncWordToVocab: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/words/{word}/sync + Method: POST + CollectWord: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/{articleId}/words + Method: POST + DeleteWord: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/{articleId}/words/{word} + Method: DELETE + GetWordDetail: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/{articleId}/words/{word} + Method: GET GetQuizHistory: Type: Api Properties: From 488a2872cfabc3d1ee3c994b216223f55bb3cdeb Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 14:39:26 +0900 Subject: [PATCH 452/528] feat: add multi-environment deployment support (dev/test/prod) --- ServerlessFunction/buildspec-dev.yml | 59 ++++++++++++++ ServerlessFunction/buildspec-prod.yml | 59 ++++++++++++++ ServerlessFunction/buildspec-test.yml | 59 ++++++++++++++ ServerlessFunction/template.yaml | 111 ++++++++++++++------------ 4 files changed, 238 insertions(+), 50 deletions(-) create mode 100644 ServerlessFunction/buildspec-dev.yml create mode 100644 ServerlessFunction/buildspec-prod.yml create mode 100644 ServerlessFunction/buildspec-test.yml diff --git a/ServerlessFunction/buildspec-dev.yml b/ServerlessFunction/buildspec-dev.yml new file mode 100644 index 00000000..78a12758 --- /dev/null +++ b/ServerlessFunction/buildspec-dev.yml @@ -0,0 +1,59 @@ +version: 0.2 + +env: + variables: + SAM_CLI_TELEMETRY: 0 + GRADLE_OPTS: "-Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.caching=true" + ENVIRONMENT: dev + STACK_NAME: group2-englishstudy-dev + +phases: + install: + commands: + - echo "Verifying pre-installed tools..." + - java -version + - sam --version + - echo "Tools verified" + + pre_build: + commands: + - echo "Running tests..." + - cd ServerlessFunction + - chmod +x gradlew + - ./gradlew test --build-cache --parallel + - echo "Tests completed" + + build: + commands: + - echo "Building SAM application for $ENVIRONMENT..." + - cd $CODEBUILD_SRC_DIR/ServerlessFunction + - sam build --parallel --cached + - echo "Build completed" + + post_build: + commands: + - echo "Deploying to $ENVIRONMENT environment..." + - cd $CODEBUILD_SRC_DIR/ServerlessFunction + - | + sam deploy \ + --stack-name $STACK_NAME \ + --resolve-s3 \ + --s3-prefix $STACK_NAME \ + --region ap-northeast-2 \ + --capabilities CAPABILITY_IAM CAPABILITY_AUTO_EXPAND \ + --no-confirm-changeset \ + --no-fail-on-empty-changeset \ + --parameter-overrides Environment=$ENVIRONMENT + - echo "Deployment completed on $(date)" + +cache: + paths: + - '/root/.gradle/caches/**/*' + - '/root/.gradle/wrapper/**/*' + - '.aws-sam/cache/**/*' + +reports: + junit-reports: + files: + - 'ServerlessFunction/build/test-results/test/*.xml' + file-format: JUNITXML diff --git a/ServerlessFunction/buildspec-prod.yml b/ServerlessFunction/buildspec-prod.yml new file mode 100644 index 00000000..0aa83787 --- /dev/null +++ b/ServerlessFunction/buildspec-prod.yml @@ -0,0 +1,59 @@ +version: 0.2 + +env: + variables: + SAM_CLI_TELEMETRY: 0 + GRADLE_OPTS: "-Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.caching=true" + ENVIRONMENT: prod + STACK_NAME: group2-englishstudy-prod + +phases: + install: + commands: + - echo "Verifying pre-installed tools..." + - java -version + - sam --version + - echo "Tools verified" + + pre_build: + commands: + - echo "Running tests..." + - cd ServerlessFunction + - chmod +x gradlew + - ./gradlew test --build-cache --parallel + - echo "Tests completed" + + build: + commands: + - echo "Building SAM application for $ENVIRONMENT..." + - cd $CODEBUILD_SRC_DIR/ServerlessFunction + - sam build --parallel --cached + - echo "Build completed" + + post_build: + commands: + - echo "Deploying to $ENVIRONMENT environment..." + - cd $CODEBUILD_SRC_DIR/ServerlessFunction + - | + sam deploy \ + --stack-name $STACK_NAME \ + --s3-bucket group2-englishstudy-pipeline-artifacts \ + --s3-prefix sam-deploy/prod \ + --region ap-northeast-2 \ + --capabilities CAPABILITY_IAM CAPABILITY_AUTO_EXPAND \ + --no-confirm-changeset \ + --no-fail-on-empty-changeset \ + --parameter-overrides Environment=$ENVIRONMENT + - echo "Deployment completed on $(date)" + +cache: + paths: + - '/root/.gradle/caches/**/*' + - '/root/.gradle/wrapper/**/*' + - '.aws-sam/cache/**/*' + +reports: + junit-reports: + files: + - 'ServerlessFunction/build/test-results/test/*.xml' + file-format: JUNITXML diff --git a/ServerlessFunction/buildspec-test.yml b/ServerlessFunction/buildspec-test.yml new file mode 100644 index 00000000..b74b041c --- /dev/null +++ b/ServerlessFunction/buildspec-test.yml @@ -0,0 +1,59 @@ +version: 0.2 + +env: + variables: + SAM_CLI_TELEMETRY: 0 + GRADLE_OPTS: "-Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.caching=true" + ENVIRONMENT: test + STACK_NAME: group2-englishstudy-test + +phases: + install: + commands: + - echo "Verifying pre-installed tools..." + - java -version + - sam --version + - echo "Tools verified" + + pre_build: + commands: + - echo "Running tests..." + - cd ServerlessFunction + - chmod +x gradlew + - ./gradlew test --build-cache --parallel + - echo "Tests completed" + + build: + commands: + - echo "Building SAM application for $ENVIRONMENT..." + - cd $CODEBUILD_SRC_DIR/ServerlessFunction + - sam build --parallel --cached + - echo "Build completed" + + post_build: + commands: + - echo "Deploying to $ENVIRONMENT environment..." + - cd $CODEBUILD_SRC_DIR/ServerlessFunction + - | + sam deploy \ + --stack-name $STACK_NAME \ + --resolve-s3 \ + --s3-prefix $STACK_NAME \ + --region ap-northeast-2 \ + --capabilities CAPABILITY_IAM CAPABILITY_AUTO_EXPAND \ + --no-confirm-changeset \ + --no-fail-on-empty-changeset \ + --parameter-overrides Environment=$ENVIRONMENT + - echo "Deployment completed on $(date)" + +cache: + paths: + - '/root/.gradle/caches/**/*' + - '/root/.gradle/wrapper/**/*' + - '.aws-sam/cache/**/*' + +reports: + junit-reports: + files: + - 'ServerlessFunction/build/test-results/test/*.xml' + file-format: JUNITXML diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 15fd4280..c7e28a37 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -2,6 +2,16 @@ AWSTemplateFormatVersion: '2010-09-09' Transform: AWS::Serverless-2016-10-31 Description: Group2 English Study - Unified API (Chatting + Vocabulary) +Parameters: + Environment: + Type: String + Default: dev + AllowedValues: + - dev + - test + - prod + Description: Deployment environment + Globals: Function: Timeout: 30 @@ -17,11 +27,12 @@ Globals: VOCAB_TABLE_NAME: !Ref VocabTable OPIC_TABLE_NAME: !Ref OPIcTable NEWS_TABLE_NAME: !Ref NewsTable - BUCKET_NAME: group2-englishstudy - CHAT_BUCKET_NAME: group2-englishstudy - VOCAB_BUCKET_NAME: group2-englishstudy - PROFILE_BUCKET_NAME: group2-englishstudy - OPIC_BUCKET_NAME: group2-englishstudy + BUCKET_NAME: !Sub "${AWS::StackName}" + CHAT_BUCKET_NAME: !Sub "${AWS::StackName}" + VOCAB_BUCKET_NAME: !Sub "${AWS::StackName}" + PROFILE_BUCKET_NAME: !Sub "${AWS::StackName}" + OPIC_BUCKET_NAME: !Sub "${AWS::StackName}" + NEWS_BUCKET_NAME: !Sub "${AWS::StackName}" AWS_REGION_NAME: !Ref AWS::Region ROOM_TOKEN_TTL_SECONDS: "300" TRANSCRIBE_PROXY_URL: "https://tfo1zm7vec.execute-api.ap-northeast-2.amazonaws.com/prod/transcribe" @@ -101,7 +112,7 @@ Resources: Timeout: 10 Environment: Variables: - DEFAULT_PROFILE_URL: https://group2-englishstudy.s3.amazonaws.com/profile/default.png + DEFAULT_PROFILE_URL: !Sub "https://${AWS::StackName}.s3.amazonaws.com/profile/default.png" # 회원가입 시점에 사용자 모든 정보가 DB에 저장 Lambda 함수 PostConfirmationFunction: @@ -138,8 +149,8 @@ Resources: MainApi: Type: AWS::Serverless::Api Properties: - Name: group2-englishstudy-api - StageName: dev + Name: !Sub "${AWS::StackName}-api" + StageName: !Ref Environment Cors: AllowMethods: "'GET,POST,PUT,DELETE,OPTIONS'" AllowHeaders: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" @@ -200,7 +211,7 @@ Resources: WebSocketApi: Type: AWS::ApiGatewayV2::Api Properties: - Name: group2-englishstudy-websocket + Name: !Sub "${AWS::StackName}-websocket" ProtocolType: WEBSOCKET RouteSelectionExpression: "$request.body.action" @@ -266,7 +277,7 @@ Resources: WebSocketConnectFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-ws-connect + FunctionName: !Sub "${AWS::StackName}-ws-connect" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.websocket.WebSocketConnectHandler::handleRequest Description: Handle WebSocket $connect @@ -296,7 +307,7 @@ Resources: WebSocketDisconnectFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-ws-disconnect + FunctionName: !Sub "${AWS::StackName}-ws-disconnect" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.websocket.WebSocketDisconnectHandler::handleRequest Description: Handle WebSocket $disconnect @@ -325,7 +336,7 @@ Resources: WebSocketMessageFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-ws-message + FunctionName: !Sub "${AWS::StackName}-ws-message" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.websocket.WebSocketMessageHandler::handleRequest Description: Handle WebSocket sendMessage @@ -385,7 +396,7 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref UserTable - S3CrudPolicy: - BucketName: group2-englishstudy + BucketName: !Sub "${AWS::StackName}" Events: GetMyProfile: Type: Api @@ -413,7 +424,7 @@ Resources: ChatRoomFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-chat-room-handler + FunctionName: !Sub "${AWS::StackName}-chat-room-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.ChatRoomHandler::handleRequest Description: Handle chat room CRUD operations @@ -485,7 +496,7 @@ Resources: GameFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-game-handler + FunctionName: !Sub "${AWS::StackName}-game-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.GameHandler::handleRequest Description: Handle catch-mind game operations @@ -557,7 +568,7 @@ Resources: GameAutoCloseFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-game-auto-close + FunctionName: !Sub "${AWS::StackName}-game-auto-close" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.GameAutoCloseHandler::handleRequest Description: Auto-close game after 7 minutes @@ -608,7 +619,7 @@ Resources: ChatMessageFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-chat-message-handler + FunctionName: !Sub "${AWS::StackName}-chat-message-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.ChatMessageHandler::handleRequest Description: Handle chat messages @@ -618,7 +629,7 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref ChatTable - S3CrudPolicy: - BucketName: group2-englishstudy + BucketName: !Sub "${AWS::StackName}" - Statement: - Effect: Allow Action: @@ -660,7 +671,7 @@ Resources: ChatVoiceFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-chat-voice-handler + FunctionName: !Sub "${AWS::StackName}-chat-voice-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.ChatVoiceHandler::handleRequest Description: Convert text to speech using Polly @@ -670,7 +681,7 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref ChatTable - S3CrudPolicy: - BucketName: group2-englishstudy + BucketName: !Sub "${AWS::StackName}" - Statement: - Effect: Allow Action: @@ -694,7 +705,7 @@ Resources: WordFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-vocab-word-handler + FunctionName: !Sub "${AWS::StackName}-vocab-word-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.WordHandler::handleRequest Description: Handle word CRUD operations @@ -772,7 +783,7 @@ Resources: UserWordFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-vocab-userword-handler + FunctionName: !Sub "${AWS::StackName}-vocab-userword-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.UserWordHandler::handleRequest Description: Handle user word learning status @@ -834,7 +845,7 @@ Resources: WordGroupFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-vocab-wordgroup-handler + FunctionName: !Sub "${AWS::StackName}-vocab-wordgroup-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.WordGroupHandler::handleRequest Description: Handle user custom word groups @@ -904,7 +915,7 @@ Resources: DailyStudyFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-vocab-daily-handler + FunctionName: !Sub "${AWS::StackName}-vocab-daily-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.DailyStudyHandler::handleRequest Description: Handle daily study word assignment @@ -934,7 +945,7 @@ Resources: TestFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-vocab-test-handler + FunctionName: !Sub "${AWS::StackName}-vocab-test-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.TestHandler::handleRequest Description: Handle vocabulary tests @@ -993,7 +1004,7 @@ Resources: StatsFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-vocab-stats-handler + FunctionName: !Sub "${AWS::StackName}-vocab-stats-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.StatsHandler::handleRequest Description: Handle user learning statistics @@ -1031,7 +1042,7 @@ Resources: VocabVoiceFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-vocab-voice-handler + FunctionName: !Sub "${AWS::StackName}-vocab-voice-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.VoiceHandler::handleRequest Description: Convert word to speech using Polly @@ -1041,7 +1052,7 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref VocabTable - S3CrudPolicy: - BucketName: group2-englishstudy + BucketName: !Sub "${AWS::StackName}" - Statement: - Effect: Allow Action: @@ -1066,7 +1077,7 @@ Resources: StatsStreamFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-stats-stream-handler + FunctionName: !Sub "${AWS::StackName}-stats-stream-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.stats.handler.StatsStreamHandler::handleRequest Description: Process DynamoDB Streams for stats aggregation @@ -1091,7 +1102,7 @@ Resources: UserStatsFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-user-stats-handler + FunctionName: !Sub "${AWS::StackName}-user-stats-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.stats.handler.UserStatsHandler::handleRequest Description: Handle user learning statistics API @@ -1146,7 +1157,7 @@ Resources: BadgeFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-badge-handler + FunctionName: !Sub "${AWS::StackName}-badge-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.badge.handler.BadgeHandler::handleRequest Description: Handle user badges and achievements @@ -1156,7 +1167,7 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref VocabTable - S3ReadPolicy: - BucketName: group2-englishstudy + BucketName: !Sub "${AWS::StackName}" Events: GetAllBadges: Type: Api @@ -1182,7 +1193,7 @@ Resources: GrammarFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-grammar-handler + FunctionName: !Sub "${AWS::StackName}-grammar-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.grammar.handler.GrammarHandler::handleRequest Description: Handle grammar check using Bedrock AI @@ -1261,7 +1272,7 @@ Resources: GrammarWebSocketApi: Type: AWS::ApiGatewayV2::Api Properties: - Name: group2-englishstudy-grammar-websocket + Name: !Sub "${AWS::StackName}-grammar-websocket" ProtocolType: WEBSOCKET RouteSelectionExpression: "$request.body.action" @@ -1324,7 +1335,7 @@ Resources: GrammarStreamingConnectFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-grammar-ws-connect + FunctionName: !Sub "${AWS::StackName}-grammar-ws-connect" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.grammar.handler.websocket.GrammarStreamingConnectHandler::handleRequest Description: Handle Grammar WebSocket $connect with JWT auth @@ -1351,7 +1362,7 @@ Resources: GrammarStreamingDisconnectFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-grammar-ws-disconnect + FunctionName: !Sub "${AWS::StackName}-grammar-ws-disconnect" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.grammar.handler.websocket.GrammarStreamingDisconnectHandler::handleRequest Description: Handle Grammar WebSocket $disconnect @@ -1378,7 +1389,7 @@ Resources: GrammarStreamingFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-grammar-ws-streaming + FunctionName: !Sub "${AWS::StackName}-grammar-ws-streaming" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.grammar.handler.websocket.GrammarStreamingHandler::handleRequest Description: Handle Grammar streaming with Bedrock @@ -1414,7 +1425,7 @@ Resources: ScheduledStatsFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-scheduled-stats + FunctionName: !Sub "${AWS::StackName}-scheduled-stats" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.stats.handler.ScheduledStatsHandler::handleRequest Description: Daily scheduled job for word learning stats aggregation @@ -1441,7 +1452,7 @@ Resources: OPIcSessionFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-opic-session-handler + FunctionName: !Sub "${AWS::StackName}-opic-session-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.opic.handler.OPIcSessionHandler::handleRequest Description: Handle OPIc speaking practice sessions @@ -1456,7 +1467,7 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref OPIcTable - S3CrudPolicy: - BucketName: group2-englishstudy + BucketName: !Sub "${AWS::StackName}" - Statement: - Effect: Allow Action: @@ -1549,7 +1560,7 @@ Resources: DeletionPolicy: Retain # UpdateReplacePolicy: Retain Properties: - TableName: group2-englishstudy-user + TableName: !Sub "${AWS::StackName}-user" BillingMode: PAY_PER_REQUEST AttributeDefinitions: - AttributeName: PK @@ -1594,7 +1605,7 @@ Resources: ChatTable: Type: AWS::DynamoDB::Table Properties: - TableName: group2-englishstudy-chat + TableName: !Sub "${AWS::StackName}-chat" BillingMode: PAY_PER_REQUEST AttributeDefinitions: - AttributeName: PK @@ -1638,7 +1649,7 @@ Resources: VocabTable: Type: AWS::DynamoDB::Table Properties: - TableName: group2-englishstudy-vocab + TableName: !Sub "${AWS::StackName}-vocab" BillingMode: PAY_PER_REQUEST StreamSpecification: StreamViewType: NEW_IMAGE @@ -1696,7 +1707,7 @@ Resources: OPIcTable: Type: AWS::DynamoDB::Table Properties: - TableName: group2-englishstudy-opic + TableName: !Sub "${AWS::StackName}-opic" BillingMode: PAY_PER_REQUEST AttributeDefinitions: - AttributeName: PK @@ -1878,7 +1889,7 @@ Resources: NewsTable: Type: AWS::DynamoDB::Table Properties: - TableName: group2-englishstudy-news + TableName: !Sub "${AWS::StackName}-news" BillingMode: PAY_PER_REQUEST AttributeDefinitions: - AttributeName: PK @@ -1927,20 +1938,20 @@ Resources: TestResultTopic: Type: AWS::SNS::Topic Properties: - TopicName: group2-englishstudy-test-result-topic + TopicName: !Sub "${AWS::StackName}-test-result-topic" # SQS Dead Letter Queue - 실패한 메시지 보관 StatisticsDeadLetterQueue: Type: AWS::SQS::Queue Properties: - QueueName: group2-englishstudy-statistics-dlq + QueueName: !Sub "${AWS::StackName}-statistics-dlq" MessageRetentionPeriod: 1209600 # 14일 # SQS Queue - 통계 처리용 StatisticsQueue: Type: AWS::SQS::Queue Properties: - QueueName: group2-englishstudy-statistics-queue + QueueName: !Sub "${AWS::StackName}-statistics-queue" VisibilityTimeout: 60 RedrivePolicy: deadLetterTargetArn: !GetAtt StatisticsDeadLetterQueue.Arn @@ -1976,7 +1987,7 @@ Resources: StatisticsProcessorFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-statistics-processor + FunctionName: !Sub "${AWS::StackName}-statistics-processor" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.StatisticsHandler::handleRequest Description: Process test results and update user word statistics @@ -2022,7 +2033,7 @@ Outputs: BucketName: Description: S3 Bucket Name - Value: group2-englishstudy + Value: !Sub "${AWS::StackName}" CognitoUserPoolId: Description: Cognito User Pool ID From cee91b6af339879f943168f02d26f3a6377bad18 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 14:47:22 +0900 Subject: [PATCH 453/528] fix: update buildspec.yml to deploy prod environment with parameter overrides --- ServerlessFunction/buildspec.yml | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/ServerlessFunction/buildspec.yml b/ServerlessFunction/buildspec.yml index 49ed2a4f..0779bea5 100644 --- a/ServerlessFunction/buildspec.yml +++ b/ServerlessFunction/buildspec.yml @@ -4,6 +4,8 @@ env: variables: SAM_CLI_TELEMETRY: 0 GRADLE_OPTS: "-Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.caching=true" + ENVIRONMENT: prod + STACK_NAME: group2-englishstudy-prod phases: install: @@ -23,20 +25,26 @@ phases: build: commands: - - echo "Building SAM application..." + - echo "Building SAM application for $ENVIRONMENT..." - cd $CODEBUILD_SRC_DIR/ServerlessFunction - sam build --parallel --cached - - echo "Packaging SAM application..." - - sam package --s3-bucket group2-englishstudy-pipeline-artifacts --s3-prefix sam-packages --output-template-file packaged-template.yaml + - echo "Build completed" post_build: commands: - - echo "Build completed on $(date)" - -artifacts: - files: - - packaged-template.yaml - base-directory: ServerlessFunction + - echo "Deploying to $ENVIRONMENT environment..." + - cd $CODEBUILD_SRC_DIR/ServerlessFunction + - | + sam deploy \ + --stack-name $STACK_NAME \ + --s3-bucket group2-englishstudy-pipeline-artifacts \ + --s3-prefix sam-deploy/prod \ + --region ap-northeast-2 \ + --capabilities CAPABILITY_IAM CAPABILITY_AUTO_EXPAND \ + --no-confirm-changeset \ + --no-fail-on-empty-changeset \ + --parameter-overrides Environment=$ENVIRONMENT + - echo "Deployment completed on $(date)" cache: paths: From 8d3c10104556a80cdbdbbdbbba51f7af4c512e75 Mon Sep 17 00:00:00 2001 From: hyein Heo <128613248+hye-inA@users.noreply.github.com> Date: Thu, 22 Jan 2026 14:51:20 +0900 Subject: [PATCH 454/528] =?UTF-8?q?refactor=20:=20AI=20=EB=A7=90=ED=95=98?= =?UTF-8?q?=EA=B8=B0=20Websocket=20=EA=B5=AC=ED=98=84=20->=20REST=20API=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=EC=9C=BC=EB=A1=9C=20=EB=A6=AC=ED=8C=A9?= =?UTF-8?q?=ED=86=A0=EB=A7=81=20(#490)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix : 오타 수정 * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 --- .../speaking/dto/request/ResetRequest.java | 12 + .../speaking/dto/request/SpeakingRequest.java | 32 + .../dto/response/SpeakingResponse.java | 12 + .../websocket/SpeakingConnectHandler.java | 88 --- .../websocket/SpeakingDisconnectHandler.java | 44 -- .../handler/websocket/SpeakingHandler.java | 157 +++++ .../websocket/SpeakingMessageHandler.java | 217 ------- .../speaking/model/SpeakingConnection.java | 84 --- .../speaking/model/SpeakingSession.java | 96 +++ .../SpeakingConnectionRepository.java | 73 --- .../repository/SpeakingSessionRepository.java | 74 +++ .../speaking/service/SpeakingService.java | 545 +++++++++--------- 12 files changed, 669 insertions(+), 765 deletions(-) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/dto/request/ResetRequest.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/dto/request/SpeakingRequest.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/dto/response/SpeakingResponse.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingHandler.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/SpeakingConnection.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/SpeakingSession.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingSessionRepository.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/dto/request/ResetRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/dto/request/ResetRequest.java new file mode 100644 index 00000000..8f600c66 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/dto/request/ResetRequest.java @@ -0,0 +1,12 @@ +package com.mzc.secondproject.serverless.domain.speaking.dto.request; + +/** + * 대화 초기화 요청 DTO + */ +public record ResetRequest( + String sessionId +) { + public boolean isValid() { + return sessionId != null && !sessionId.isEmpty(); + } +} \ No newline at end of file diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/dto/request/SpeakingRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/dto/request/SpeakingRequest.java new file mode 100644 index 00000000..58ad78c1 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/dto/request/SpeakingRequest.java @@ -0,0 +1,32 @@ +package com.mzc.secondproject.serverless.domain.speaking.dto.request; + +/** + * Speaking API 요청 DTO + */ +public record SpeakingRequest( + String sessionId, // 세션 ID (첫 요청 시 null) + String audio, // 음성 데이터 (base64) + String text, // 텍스트 입력 + String level // 레벨 (BEGINNER, INTERMEDIATE, ADVANCED) +) { + /** + * 기본값 적용된 레벨 반환 + */ + public String getLevelOrDefault() { + return level != null && !level.isEmpty() ? level : "INTERMEDIATE"; + } + + /** + * 음성 입력인지 확인 + */ + public boolean hasAudio() { + return audio != null && !audio.isEmpty(); + } + + /** + * 텍스트 입력인지 확인 + */ + public boolean hasText() { + return text != null && !text.trim().isEmpty(); + } +} \ No newline at end of file diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/dto/response/SpeakingResponse.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/dto/response/SpeakingResponse.java new file mode 100644 index 00000000..49d714dd --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/dto/response/SpeakingResponse.java @@ -0,0 +1,12 @@ +package com.mzc.secondproject.serverless.domain.speaking.dto.response; + +/** + * Speaking API 응답 DTO + */ +public record SpeakingResponse( + String sessionId, // 세션 ID (다음 요청에 사용) + String userTranscript, // 사용자가 말한 내용 (STT 결과) + String aiText, // AI 응답 텍스트 + String aiAudioUrl, // AI 응답 음성 URL (Polly) + double confidence // STT 신뢰도 +) {} \ No newline at end of file diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingConnectHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingConnectHandler.java index 256d3990..e69de29b 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingConnectHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingConnectHandler.java @@ -1,88 +0,0 @@ -package com.mzc.secondproject.serverless.domain.speaking.handler.websocket; - -import com.amazonaws.services.lambda.runtime.Context; -import com.amazonaws.services.lambda.runtime.RequestHandler; -import com.mzc.secondproject.serverless.common.config.WebSocketConfig; -import com.mzc.secondproject.serverless.common.util.JwtUtil; -import com.mzc.secondproject.serverless.common.util.WebSocketEventUtil; -import com.mzc.secondproject.serverless.domain.speaking.model.SpeakingConnection; -import com.mzc.secondproject.serverless.domain.speaking.repository.SpeakingConnectionRepository; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.Map; -import java.util.Optional; - -/** - * Speaking WebSocket $connect 핸들러 - * JWT 토큰 검증 후 연결 정보를 DynamoDB에 저장 - *

- * 연결 방법: - * wss://{api-id}.execute-api.{region}.amazonaws.com/{stage}?token={jwt} - */ -public class SpeakingConnectHandler implements RequestHandler, Map> { - - private static final Logger logger = LoggerFactory.getLogger(SpeakingConnectHandler.class); - - private final SpeakingConnectionRepository connectionRepository; - - public SpeakingConnectHandler() { - this.connectionRepository = new SpeakingConnectionRepository(); - } - - @Override - public Map handleRequest(Map event, Context context) { - logger.info("Speaking WebSocket connect event"); - - try { - String connectionId = WebSocketEventUtil.extractConnectionId(event); - Map queryParams = WebSocketEventUtil.extractQueryStringParameters(event); - - // JWT 토큰 검증 - String token = queryParams.get("token"); - - if (token == null || token.isEmpty()) { - logger.warn("Missing token parameter"); - return WebSocketEventUtil.unauthorized("token is required"); - } - - // 토큰 유효성 검사 - if (!JwtUtil.isValid(token)) { - logger.warn("Invalid or expired token"); - return WebSocketEventUtil.unauthorized("Invalid or expired token"); - } - - // userId 추출 - Optional userIdOpt = JwtUtil.extractUserId(token); - if (userIdOpt.isEmpty()) { - logger.warn("Failed to extract userId from token"); - return WebSocketEventUtil.unauthorized("Invalid token"); - } - - String userId = userIdOpt.get(); - - // 연결 정보 저장 - SpeakingConnection connection = SpeakingConnection.create( - connectionId, - userId, - WebSocketConfig.connectionTtlSeconds() - ); - - // 레벨 파라미터가 있으면 설정 - String level = queryParams.get("level"); - if (level != null && !level.isEmpty()) { - connection.setTargetLevel(level.toUpperCase()); - } - - connectionRepository.save(connection); - - logger.info("Speaking connection established: connectionId={}, userId={}, level={}", - connectionId, userId, connection.getTargetLevel()); - return WebSocketEventUtil.ok("Connected"); - - } catch (Exception e) { - logger.error("Error handling connect: {}", e.getMessage(), e); - return WebSocketEventUtil.serverError("Internal server error"); - } - } -} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingDisconnectHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingDisconnectHandler.java index bd46d3b4..e69de29b 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingDisconnectHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingDisconnectHandler.java @@ -1,44 +0,0 @@ -package com.mzc.secondproject.serverless.domain.speaking.handler.websocket; - -import com.amazonaws.services.lambda.runtime.Context; -import com.amazonaws.services.lambda.runtime.RequestHandler; -import com.mzc.secondproject.serverless.common.util.WebSocketEventUtil; -import com.mzc.secondproject.serverless.domain.speaking.repository.SpeakingConnectionRepository; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.Map; - -/** - * Speaking WebSocket $disconnect 핸들러 - * 연결 해제 시 DynamoDB에서 연결 정보 삭제 - */ -public class SpeakingDisconnectHandler implements RequestHandler, Map> { - - private static final Logger logger = LoggerFactory.getLogger(SpeakingDisconnectHandler.class); - - private final SpeakingConnectionRepository connectionRepository; - - public SpeakingDisconnectHandler() { - this.connectionRepository = new SpeakingConnectionRepository(); - } - - @Override - public Map handleRequest(Map event, Context context) { - logger.info("Speaking WebSocket disconnect event"); - - try { - String connectionId = WebSocketEventUtil.extractConnectionId(event); - - // 연결 정보 삭제 - connectionRepository.delete(connectionId); - - logger.info("Speaking connection closed: connectionId={}", connectionId); - return WebSocketEventUtil.ok("Disconnected"); - - } catch (Exception e) { - logger.error("Error handling disconnect: {}", e.getMessage(), e); - return WebSocketEventUtil.serverError("Internal server error"); - } - } -} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingHandler.java new file mode 100644 index 00000000..69375925 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingHandler.java @@ -0,0 +1,157 @@ +package com.mzc.secondproject.serverless.domain.speaking.handler.websocket; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.mzc.secondproject.serverless.common.util.JwtUtil; +import com.mzc.secondproject.serverless.domain.speaking.service.SpeakingService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Map; +import java.util.Optional; + +/** + * Speaking API 핸들러 + * + * POST /api/speaking/chat - 대화 (음성 또는 텍스트) + * POST /api/speaking/reset - 대화 초기화 + */ +public class SpeakingHandler implements RequestHandler { + + private static final Logger logger = LoggerFactory.getLogger(SpeakingHandler.class); + private static final Gson gson = new GsonBuilder().create(); + + private static final Map CORS_HEADERS = Map.of( + "Content-Type", "application/json", + "Access-Control-Allow-Origin", "*", + "Access-Control-Allow-Headers", "Content-Type,Authorization", + "Access-Control-Allow-Methods", "POST,OPTIONS" + ); + + private final SpeakingService speakingService; + + public SpeakingHandler() { + this.speakingService = new SpeakingService(); + } + + @Override + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent event, Context context) { + logger.info("Speaking API request received"); + + // OPTIONS 요청 처리 (CORS preflight) + if ("OPTIONS".equalsIgnoreCase(event.getHttpMethod())) { + return response(200, Map.of("message", "OK")); + } + + try { + // JWT 토큰 검증 + String authHeader = event.getHeaders().get("Authorization"); + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + return response(401, Map.of("error", "Authorization header is required")); + } + + String token = authHeader.substring(7); + if (!JwtUtil.isValid(token)) { + return response(401, Map.of("error", "Invalid or expired token")); + } + + Optional userIdOpt = JwtUtil.extractUserId(token); + if (userIdOpt.isEmpty()) { + return response(401, Map.of("error", "Invalid token")); + } + + String userId = userIdOpt.get(); + String path = event.getPath(); + String body = event.getBody(); + + logger.info("Processing request: path={}, userId={}", path, userId); + + // 라우팅 + if (path.endsWith("/chat")) { + return handleChat(userId, body); + } else if (path.endsWith("/reset")) { + return handleReset(userId, body); + } else { + return response(404, Map.of("error", "Not found")); + } + + } catch (Exception e) { + logger.error("Error processing request: {}", e.getMessage(), e); + return response(500, Map.of("error", "Internal server error: " + e.getMessage())); + } + } + + /** + * 대화 처리 (음성 또는 텍스트) + */ + private APIGatewayProxyResponseEvent handleChat(String userId, String body) { + if (body == null || body.isEmpty()) { + return response(400, Map.of("error", "Request body is required")); + } + + JsonObject request = JsonParser.parseString(body).getAsJsonObject(); + + String sessionId = request.has("sessionId") ? request.get("sessionId").getAsString() : null; + String level = request.has("level") ? request.get("level").getAsString() : "INTERMEDIATE"; + String audio = request.has("audio") ? request.get("audio").getAsString() : null; + String text = request.has("text") ? request.get("text").getAsString() : null; + + SpeakingService.SpeakingResponse result; + + if (audio != null && !audio.isEmpty()) { + // 음성 입력 처리 + logger.info("Processing voice input"); + result = speakingService.processVoiceInput(sessionId, userId, audio, level); + } else if (text != null && !text.trim().isEmpty()) { + // 텍스트 입력 처리 + logger.info("Processing text input: {}", text); + result = speakingService.processTextInput(sessionId, userId, text.trim(), level); + } else { + return response(400, Map.of("error", "Either 'audio' or 'text' is required")); + } + + return response(200, Map.of( + "sessionId", result.sessionId(), + "userTranscript", result.userTranscript(), + "aiText", result.aiText(), + "aiAudioUrl", result.aiAudioUrl(), + "confidence", result.confidence() + )); + } + + /** + * 대화 초기화 + */ + private APIGatewayProxyResponseEvent handleReset(String userId, String body) { + if (body == null || body.isEmpty()) { + return response(400, Map.of("error", "Request body is required")); + } + + JsonObject request = JsonParser.parseString(body).getAsJsonObject(); + String sessionId = request.has("sessionId") ? request.get("sessionId").getAsString() : null; + + if (sessionId == null || sessionId.isEmpty()) { + return response(400, Map.of("error", "sessionId is required")); + } + + speakingService.resetConversation(sessionId); + + return response(200, Map.of( + "message", "Conversation reset successfully", + "sessionId", sessionId + )); + } + + private APIGatewayProxyResponseEvent response(int statusCode, Map body) { + return new APIGatewayProxyResponseEvent() + .withStatusCode(statusCode) + .withHeaders(CORS_HEADERS) + .withBody(gson.toJson(body)); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingMessageHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingMessageHandler.java index 24ecfc3c..e69de29b 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingMessageHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingMessageHandler.java @@ -1,217 +0,0 @@ -package com.mzc.secondproject.serverless.domain.speaking.handler.websocket; - -import com.amazonaws.services.lambda.runtime.Context; -import com.amazonaws.services.lambda.runtime.RequestHandler; -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; -import com.mzc.secondproject.serverless.common.util.WebSocketEventUtil; -import com.mzc.secondproject.serverless.domain.speaking.repository.SpeakingConnectionRepository; -import com.mzc.secondproject.serverless.domain.speaking.service.SpeakingService; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import software.amazon.awssdk.core.SdkBytes; -import software.amazon.awssdk.services.apigatewaymanagementapi.ApiGatewayManagementApiClient; -import software.amazon.awssdk.services.apigatewaymanagementapi.model.PostToConnectionRequest; - -import java.net.URI; -import java.util.Map; - -/** - * Speaking WebSocket 메시지 핸들러 - *

- * 지원하는 action: - * - speak: 음성 입력 처리 (audio base64) - * - text: 텍스트 입력 처리 - * - setLevel: 레벨 변경 - * - reset: 대화 히스토리 초기화 - */ -public class SpeakingMessageHandler implements RequestHandler, Map> { - - private static final Logger logger = LoggerFactory.getLogger(SpeakingMessageHandler.class); - private static final Gson gson = new GsonBuilder().create(); - - private final SpeakingService speakingService; - private final SpeakingConnectionRepository connectionRepository; - - public SpeakingMessageHandler() { - this.speakingService = new SpeakingService(); - this.connectionRepository = new SpeakingConnectionRepository(); - } - - @Override - public Map handleRequest(Map event, Context context) { - logger.info("Speaking message event received"); - - String connectionId = null; - String endpoint = null; - - try { - connectionId = WebSocketEventUtil.extractConnectionId(event); - endpoint = WebSocketEventUtil.extractWebSocketEndpoint(event); - - // 연결 정보 확인 - if (connectionRepository.findByConnectionId(connectionId).isEmpty()) { - logger.warn("Connection not found: {}", connectionId); - return sendError(connectionId, endpoint, "Unauthorized - please reconnect"); - } - - // 요청 바디 파싱 - String body = (String) event.get("body"); - if (body == null || body.isEmpty()) { - return sendError(connectionId, endpoint, "Message body is required"); - } - - JsonObject request = JsonParser.parseString(body).getAsJsonObject(); - String action = request.has("action") ? request.get("action").getAsString() : "speak"; - - logger.info("Processing action: {} for connectionId: {}", action, connectionId); - - // 액션별 처리 - switch (action) { - case "speak" -> handleSpeak(connectionId, endpoint, request); - case "text" -> handleText(connectionId, endpoint, request); - case "setLevel" -> handleSetLevel(connectionId, endpoint, request); - case "reset" -> handleReset(connectionId, endpoint); - default -> sendError(connectionId, endpoint, "Unknown action: " + action); - } - - return WebSocketEventUtil.ok("Processed"); - - } catch (Exception e) { - logger.error("Error processing message: {}", e.getMessage(), e); - if (connectionId != null && endpoint != null) { - sendError(connectionId, endpoint, "Processing error: " + e.getMessage()); - } - return WebSocketEventUtil.serverError("Internal server error"); - } - } - - /** - * 음성 입력 처리 - */ - private void handleSpeak(String connectionId, String endpoint, JsonObject request) { - // 시작 이벤트 전송 - sendToConnection(connectionId, endpoint, Map.of( - "type", "start", - "message", "Processing your voice..." - )); - - // 음성 데이터 추출 - String audioBase64 = request.has("audio") ? request.get("audio").getAsString() : null; - if (audioBase64 == null || audioBase64.isEmpty()) { - sendError(connectionId, endpoint, "audio data is required for speak action"); - return; - } - - // 음성 처리 - SpeakingService.SpeakingResponse response = speakingService.processVoiceInput( - connectionId, audioBase64 - ); - - // 결과 전송 - sendToConnection(connectionId, endpoint, Map.of( - "type", "complete", - "userTranscript", response.userTranscript(), - "aiText", response.aiText(), - "aiAudioUrl", response.aiAudioUrl(), - "confidence", response.confidence() - )); - } - - /** - * 텍스트 입력 처리 - */ - private void handleText(String connectionId, String endpoint, JsonObject request) { - String text = request.has("text") ? request.get("text").getAsString() : null; - if (text == null || text.trim().isEmpty()) { - sendError(connectionId, endpoint, "text is required for text action"); - return; - } - - // 시작 이벤트 전송 - sendToConnection(connectionId, endpoint, Map.of( - "type", "start", - "message", "Processing your message..." - )); - - // 텍스트 처리 - SpeakingService.SpeakingResponse response = speakingService.processTextInput( - connectionId, text.trim() - ); - - // 결과 전송 - sendToConnection(connectionId, endpoint, Map.of( - "type", "complete", - "userTranscript", response.userTranscript(), - "aiText", response.aiText(), - "aiAudioUrl", response.aiAudioUrl(), - "confidence", response.confidence() - )); - } - - /** - * 레벨 변경 처리 - */ - private void handleSetLevel(String connectionId, String endpoint, JsonObject request) { - String level = request.has("level") ? request.get("level").getAsString() : null; - if (level == null || level.isEmpty()) { - sendError(connectionId, endpoint, "level is required"); - return; - } - - speakingService.updateLevel(connectionId, level); - - sendToConnection(connectionId, endpoint, Map.of( - "type", "levelChanged", - "level", level.toUpperCase() - )); - } - - /** - * 대화 초기화 처리 - */ - private void handleReset(String connectionId, String endpoint) { - speakingService.resetConversation(connectionId); - - sendToConnection(connectionId, endpoint, Map.of( - "type", "reset", - "message", "Conversation has been reset. Let's start fresh!" - )); - } - - /** - * WebSocket으로 메시지 전송 - */ - private void sendToConnection(String connectionId, String endpoint, Map data) { - try { - ApiGatewayManagementApiClient apiClient = ApiGatewayManagementApiClient.builder() - .endpointOverride(URI.create(endpoint)) - .build(); - - String message = gson.toJson(data); - - apiClient.postToConnection(PostToConnectionRequest.builder() - .connectionId(connectionId) - .data(SdkBytes.fromUtf8String(message)) - .build()); - - logger.debug("Message sent to {}: {}", connectionId, data.get("type")); - - } catch (Exception e) { - logger.error("Failed to send message to {}: {}", connectionId, e.getMessage()); - } - } - - /** - * 에러 메시지 전송 - */ - private Map sendError(String connectionId, String endpoint, String errorMessage) { - sendToConnection(connectionId, endpoint, Map.of( - "type", "error", - "message", errorMessage - )); - return WebSocketEventUtil.ok("Error sent"); - } -} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/SpeakingConnection.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/SpeakingConnection.java deleted file mode 100644 index 133e7773..00000000 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/SpeakingConnection.java +++ /dev/null @@ -1,84 +0,0 @@ -package com.mzc.secondproject.serverless.domain.speaking.model; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.*; - -/** - * Speaking WebSocket 연결 정보 - * connectionId ↔ userId 매핑 + 대화 히스토리 저장 - */ -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -@DynamoDbBean -public class SpeakingConnection { - - // DynamoDB Key Prefixes - public static final String PK_PREFIX = "SPEAKING_CONN#"; - public static final String SK_METADATA = "METADATA"; - public static final String GSI1PK_PREFIX = "SPEAKING_USER#"; - public static final String GSI1SK_PREFIX = "CONN#"; - - private String pk; // SPEAKING_CONN#{connectionId} - private String sk; // METADATA - private String gsi1pk; // SPEAKING_USER#{userId} - private String gsi1sk; // CONN#{connectionId} - - private String connectionId; - private String userId; - private String connectedAt; - private Long ttl; // 자동 삭제용 - - // Speaking 전용 필드 - private String conversationHistory; // 대화 히스토리 (JSON) - private String targetLevel; // 목표 레벨 (BEGINNER, INTERMEDIATE, ADVANCED) - - /** - * 연결 정보 생성 팩토리 메서드 - */ - public static SpeakingConnection create(String connectionId, String userId, long ttlSeconds) { - String now = java.time.Instant.now().toString(); - long ttl = java.time.Instant.now().plusSeconds(ttlSeconds).getEpochSecond(); - - return SpeakingConnection.builder() - .pk(PK_PREFIX + connectionId) - .sk(SK_METADATA) - .gsi1pk(GSI1PK_PREFIX + userId) - .gsi1sk(GSI1SK_PREFIX + connectionId) - .connectionId(connectionId) - .userId(userId) - .connectedAt(now) - .ttl(ttl) - .conversationHistory("[]") // 빈 배열로 초기화 - .targetLevel("INTERMEDIATE") // 기본값 - .build(); - } - - @DynamoDbPartitionKey - @DynamoDbAttribute("PK") - public String getPk() { - return pk; - } - - @DynamoDbSortKey - @DynamoDbAttribute("SK") - public String getSk() { - return sk; - } - - @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") - @DynamoDbAttribute("GSI1PK") - public String getGsi1pk() { - return gsi1pk; - } - - @DynamoDbSecondarySortKey(indexNames = "GSI1") - @DynamoDbAttribute("GSI1SK") - public String getGsi1sk() { - return gsi1sk; - } -} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/SpeakingSession.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/SpeakingSession.java new file mode 100644 index 00000000..07956b2f --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/SpeakingSession.java @@ -0,0 +1,96 @@ +package com.mzc.secondproject.serverless.domain.speaking.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.*; + +/** + * Speaking WebSocket 연결 정보 + * connectionId ↔ userId 매핑 + 대화 히스토리 저장 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DynamoDbBean +public class SpeakingSession { + + // DynamoDB Key Prefixes + public static final String PK_PREFIX = "SPEAKING_SESSION#"; + public static final String SK_METADATA = "METADATA"; + public static final String GSI1PK_PREFIX = "SPEAKING_USER#"; + public static final String GSI1SK_PREFIX = "SESSION#"; + + private String pk; // SPEAKING_SESSION#{sessionId} + private String sk; // METADATA + private String gsi1pk; // SPEAKING_USER#{userId} + private String gsi1sk; // SESSION#{sessionId} + + private String sessionId; + private String userId; + private String createdAt; + private String updatedAt; + private Long ttl; // 자동 삭제용 (24시간) + + // Speaking 전용 필드 + private String conversationHistory; // 대화 히스토리 (JSON) + private String targetLevel; // 목표 레벨 (BEGINNER, INTERMEDIATE, ADVANCED) + + /** + * 세션 생성 팩토리 메서드 + */ + public static SpeakingSession create(String sessionId, String userId, String level) { + String now = java.time.Instant.now().toString(); + // 24시간 후 자동 삭제 + long ttl = java.time.Instant.now().plusSeconds(86400).getEpochSecond(); + + return SpeakingSession.builder() + .pk(PK_PREFIX + sessionId) + .sk(SK_METADATA) + .gsi1pk(GSI1PK_PREFIX + userId) + .gsi1sk(GSI1SK_PREFIX + sessionId) + .sessionId(sessionId) + .userId(userId) + .createdAt(now) + .updatedAt(now) + .ttl(ttl) + .conversationHistory("[]") + .targetLevel(level != null ? level.toUpperCase() : "INTERMEDIATE") + .build(); + } + + /** + * 업데이트 시간 갱신 + */ + public void touch() { + this.updatedAt = java.time.Instant.now().toString(); + // TTL 연장 (24시간) + this.ttl = java.time.Instant.now().plusSeconds(86400).getEpochSecond(); + } + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { + return pk; + } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { + return sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1PK") + public String getGsi1pk() { + return gsi1pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1SK") + public String getGsi1sk() { + return gsi1sk; + } +} \ No newline at end of file diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java index bbb74d7c..e69de29b 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java @@ -1,73 +0,0 @@ -package com.mzc.secondproject.serverless.domain.speaking.repository; - -import com.mzc.secondproject.serverless.common.config.AwsClients; -import com.mzc.secondproject.serverless.common.config.EnvConfig; -import com.mzc.secondproject.serverless.domain.speaking.model.SpeakingConnection; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; -import software.amazon.awssdk.enhanced.dynamodb.Key; -import software.amazon.awssdk.enhanced.dynamodb.TableSchema; - -import java.util.Optional; - -/** - * Speaking WebSocket 연결 정보 Repository - */ -public class SpeakingConnectionRepository { - - private static final Logger logger = LoggerFactory.getLogger(SpeakingConnectionRepository.class); - private static final String TABLE_NAME = EnvConfig.getRequired("CHAT_TABLE_NAME"); - - private final DynamoDbTable table; - - public SpeakingConnectionRepository() { - this.table = AwsClients.dynamoDbEnhanced().table( - TABLE_NAME, - TableSchema.fromBean(SpeakingConnection.class) - ); - } - - /** - * 연결 정보 저장 - */ - public void save(SpeakingConnection connection) { - table.putItem(connection); - logger.debug("Speaking connection saved: connectionId={}, userId={}", - connection.getConnectionId(), connection.getUserId()); - } - - /** - * connectionId로 연결 정보 조회 - */ - public Optional findByConnectionId(String connectionId) { - Key key = Key.builder() - .partitionValue(SpeakingConnection.PK_PREFIX + connectionId) - .sortValue(SpeakingConnection.SK_METADATA) - .build(); - - SpeakingConnection connection = table.getItem(key); - return Optional.ofNullable(connection); - } - - /** - * 연결 정보 업데이트 (대화 히스토리 등) - */ - public void update(SpeakingConnection connection) { - table.putItem(connection); - logger.debug("Speaking connection updated: connectionId={}", connection.getConnectionId()); - } - - /** - * 연결 정보 삭제 - */ - public void delete(String connectionId) { - Key key = Key.builder() - .partitionValue(SpeakingConnection.PK_PREFIX + connectionId) - .sortValue(SpeakingConnection.SK_METADATA) - .build(); - - table.deleteItem(key); - logger.info("Speaking connection deleted: connectionId={}", connectionId); - } -} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingSessionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingSessionRepository.java new file mode 100644 index 00000000..fed1cd66 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingSessionRepository.java @@ -0,0 +1,74 @@ +package com.mzc.secondproject.serverless.domain.speaking.repository; + +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.config.EnvConfig; +import com.mzc.secondproject.serverless.domain.speaking.model.SpeakingSession; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; +import software.amazon.awssdk.enhanced.dynamodb.Key; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; + +import java.util.Optional; + +/** + * Speaking WebSocket 연결 정보 Repository + */ +public class SpeakingSessionRepository { + + private static final Logger logger = LoggerFactory.getLogger(SpeakingSessionRepository.class); + private static final String TABLE_NAME = EnvConfig.getRequired("CHAT_TABLE_NAME"); + + private final DynamoDbTable table; + + public SpeakingSessionRepository() { + this.table = AwsClients.dynamoDbEnhanced().table( + TABLE_NAME, + TableSchema.fromBean(SpeakingSession.class) + ); + } + + /** + * 연결 정보 저장 + */ + public void save(SpeakingSession session) { + table.putItem(session); + logger.debug("Speaking session saved: sessionId={}, userId={}", + session.getSessionId(), session.getUserId()); + } + + /** + * sessionId로 연결 정보 조회 + */ + public Optional findBySessionId(String sessionId) { + Key key = Key.builder() + .partitionValue(SpeakingSession.PK_PREFIX + sessionId) + .sortValue(SpeakingSession.SK_METADATA) + .build(); + + SpeakingSession session = table.getItem(key); + return Optional.ofNullable(session); + } + + /** + * 연결 정보 업데이트 (대화 히스토리 등) + */ + public void update(SpeakingSession session) { + session.touch(); // 업데이트 시간 및 TTL 갱신 + table.putItem(session); + logger.debug("Speaking session updated: sessionId={}", session.getSessionId()); + } + + /** + * 연결 정보 삭제 + */ + public void delete(String sessionId) { + Key key = Key.builder() + .partitionValue(SpeakingSession.PK_PREFIX + sessionId) + .sortValue(SpeakingSession.SK_METADATA) + .build(); + + table.deleteItem(key); + logger.info("Speaking session deleted: sessionId={}", sessionId); + } +} \ No newline at end of file diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/service/SpeakingService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/service/SpeakingService.java index 7c428ddc..010c4afe 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/service/SpeakingService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/service/SpeakingService.java @@ -5,236 +5,264 @@ import com.mzc.secondproject.serverless.common.config.EnvConfig; import com.mzc.secondproject.serverless.common.service.PollyService; import com.mzc.secondproject.serverless.domain.opic.service.TranscribeProxyService; -import com.mzc.secondproject.serverless.domain.speaking.model.SpeakingConnection; -import com.mzc.secondproject.serverless.domain.speaking.repository.SpeakingConnectionRepository; +import com.mzc.secondproject.serverless.domain.speaking.model.SpeakingSession; +import com.mzc.secondproject.serverless.domain.speaking.repository.SpeakingSessionRepository; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; import software.amazon.awssdk.core.SdkBytes; import software.amazon.awssdk.services.bedrockruntime.model.InvokeModelRequest; import software.amazon.awssdk.services.bedrockruntime.model.InvokeModelResponse; + import java.util.ArrayList; import java.util.List; +import java.util.UUID; /** * AI와 대화하기 서비스 * 음성 입력 → STT → Bedrock → TTS → 음성 출력 */ public class SpeakingService { - - private static final Logger logger = LoggerFactory.getLogger(SpeakingService.class); - private static final Gson gson = new GsonBuilder().create(); - - private static final String MODEL_ID = "anthropic.claude-3-haiku-20240307-v1:0"; - private static final int MAX_TOKENS = 500; - private static final int MAX_HISTORY_SIZE = 10; // 최근 10턴만 유지 - - private final TranscribeProxyService transcribeService; - private final PollyService pollyService; - private final SpeakingConnectionRepository connectionRepository; - - public SpeakingService() { - this.transcribeService = new TranscribeProxyService(); - this.pollyService = new PollyService( - EnvConfig.getRequired("BUCKET_NAME"), - "speaking/voice/" - ); - this.connectionRepository = new SpeakingConnectionRepository(); - } - - /** - * 음성 입력 처리 (전체 플로우) - */ - public SpeakingResponse processVoiceInput(String connectionId, String audioBase64) { - logger.info("Processing voice input for connectionId: {}", connectionId); - - // 연결 정보 조회 - SpeakingConnection connection = connectionRepository.findByConnectionId(connectionId) - .orElseThrow(() -> new RuntimeException("Connection not found: " + connectionId)); - - String targetLevel = connection.getTargetLevel(); - - // STT: 음성 → 텍스트 (Transcribe Proxy 사용) - logger.info("Step 1: Transcribing audio..."); - TranscribeProxyService.TranscribeResult sttResult = transcribeService.transcribe( - audioBase64, - connectionId, - "en-US" - ); - String userText = sttResult.transcript(); - logger.info("Transcription complete: {} (confidence: {})", userText, sttResult.confidence()); - - // 대화 히스토리 로드 - List history = parseHistory(connection.getConversationHistory()); - - // Bedrock: AI 응답 생성 - logger.info("Step 2: Generating AI response..."); - String aiResponse = generateAiResponse(userText, history, targetLevel); - logger.info("AI response generated: {}", aiResponse); - - // 히스토리 업데이트 (최근 N턴만 유지) - history.add(new Message("user", userText)); - history.add(new Message("assistant", aiResponse)); - if (history.size() > MAX_HISTORY_SIZE * 2) { - history = new ArrayList<>(history.subList(history.size() - MAX_HISTORY_SIZE * 2, history.size())); - } - connection.setConversationHistory(toJson(history)); - connectionRepository.update(connection); - - // TTS: 텍스트 → 음성 (Polly 사용) - logger.info("Step 3: Synthesizing speech..."); - String audioId = connectionId + "_" + System.currentTimeMillis(); - PollyService.VoiceSynthesisResult ttsResult = pollyService.synthesizeSpeech( - audioId, - aiResponse, - "FEMALE" - ); - logger.info("Speech synthesis complete: cached={}", ttsResult.isCached()); - - return new SpeakingResponse( - userText, - aiResponse, - ttsResult.getAudioUrl(), - sttResult.confidence() - ); - } - - /** - * 텍스트 입력 처리 (음성 없이 텍스트만) - */ - public SpeakingResponse processTextInput(String connectionId, String userText) { - logger.info("Processing text input for connectionId: {}", connectionId); - - // 연결 정보 조회 - SpeakingConnection connection = connectionRepository.findByConnectionId(connectionId) - .orElseThrow(() -> new RuntimeException("Connection not found: " + connectionId)); - - String targetLevel = connection.getTargetLevel(); - - // 대화 히스토리 로드 - List history = parseHistory(connection.getConversationHistory()); - - // AI 응답 생성 - String aiResponse = generateAiResponse(userText, history, targetLevel); - - // 히스토리 업데이트 - history.add(new Message("user", userText)); - history.add(new Message("assistant", aiResponse)); - if (history.size() > MAX_HISTORY_SIZE * 2) { - history = new ArrayList<>(history.subList(history.size() - MAX_HISTORY_SIZE * 2, history.size())); - } - connection.setConversationHistory(toJson(history)); - connectionRepository.update(connection); - - // TTS 생성 - String audioId = connectionId + "_" + System.currentTimeMillis(); - PollyService.VoiceSynthesisResult ttsResult = pollyService.synthesizeSpeech( - audioId, aiResponse, "FEMALE" - ); - - return new SpeakingResponse(userText, aiResponse, ttsResult.getAudioUrl(), 1.0); - } - - /** - * 레벨 변경 - */ - public void updateLevel(String connectionId, String level) { - SpeakingConnection connection = connectionRepository.findByConnectionId(connectionId) - .orElseThrow(() -> new RuntimeException("Connection not found: " + connectionId)); - - connection.setTargetLevel(level.toUpperCase()); - connectionRepository.update(connection); - logger.info("Level updated for connectionId {}: {}", connectionId, level); - } - - /** - * 대화 히스토리 초기화 - */ - public void resetConversation(String connectionId) { - SpeakingConnection connection = connectionRepository.findByConnectionId(connectionId) - .orElseThrow(() -> new RuntimeException("Connection not found: " + connectionId)); - - connection.setConversationHistory("[]"); - connectionRepository.update(connection); - logger.info("Conversation reset for connectionId: {}", connectionId); - } - - - /** - * Bedrock Claude 호출하여 AI 응답 생성 - */ - private String generateAiResponse(String userText, List history, String targetLevel) { - String systemPrompt = buildSystemPrompt(targetLevel); - - JsonObject requestBody = new JsonObject(); - requestBody.addProperty("anthropic_version", "bedrock-2023-05-31"); - requestBody.addProperty("max_tokens", MAX_TOKENS); - requestBody.addProperty("system", systemPrompt); - - // 메시지 배열 구성 - JsonArray messages = new JsonArray(); - - // 기존 히스토리 추가 - for (Message msg : history) { - JsonObject m = new JsonObject(); - m.addProperty("role", msg.role()); - m.addProperty("content", msg.content()); - messages.add(m); - } - - // 현재 사용자 입력 추가 - JsonObject userMsg = new JsonObject(); - userMsg.addProperty("role", "user"); - userMsg.addProperty("content", userText); - messages.add(userMsg); - - requestBody.add("messages", messages); - - // Bedrock 호출 - InvokeModelResponse response = AwsClients.bedrock().invokeModel( - InvokeModelRequest.builder() - .modelId(MODEL_ID) - .contentType("application/json") - .body(SdkBytes.fromUtf8String(requestBody.toString())) - .build() - ); - - // 응답 파싱 - JsonObject result = JsonParser.parseString( - response.body().asUtf8String() - ).getAsJsonObject(); - - return result.getAsJsonArray("content") - .get(0).getAsJsonObject() - .get("text").getAsString(); - } - - /** - * 레벨별 시스템 프롬프트 생성 - */ - private String buildSystemPrompt(String targetLevel) { - String levelGuidance = switch (targetLevel.toUpperCase()) { - case "BEGINNER" -> """ + + private static final Logger logger = LoggerFactory.getLogger(SpeakingService.class); + private static final Gson gson = new GsonBuilder().create(); + + private static final String MODEL_ID = "anthropic.claude-3-haiku-20240307-v1:0"; + private static final int MAX_TOKENS = 500; + private static final int MAX_HISTORY_SIZE = 10; // 최근 10턴만 유지 + + private final TranscribeProxyService transcribeService; + private final PollyService pollyService; + private final SpeakingSessionRepository sessionRepository; + + /** + * 세션 생성 또는 조회 + */ + public SpeakingSession getOrCreateSession(String sessionId, String userId, String level) { + if (sessionId != null && !sessionId.isEmpty()) { + return sessionRepository.findBySessionId(sessionId) + .orElseGet(() -> createNewSession(userId, level)); + } + return createNewSession(userId, level); + } + + /** + * 새 세션 생성 + */ + private SpeakingSession createNewSession(String userId, String level) { + String newSessionId = UUID.randomUUID().toString(); + SpeakingSession session = SpeakingSession.create(newSessionId, userId, level); + sessionRepository.save(session); + logger.info("New speaking session created: sessionId={}, userId={}", newSessionId, userId); + return session; + } + + public SpeakingService() { + this.transcribeService = new TranscribeProxyService(); + this.pollyService = new PollyService( + EnvConfig.getRequired("BUCKET_NAME"), + "speaking/voice/" + ); + this.sessionRepository = new SpeakingSessionRepository(); + } + + /** + * 음성 입력 처리 (전체 플로우) + */ + public SpeakingResponse processVoiceInput(String sessionId, String userId, String audioBase64, String level) { + logger.info("Processing voice input for sessionId: {}", sessionId); + + // 세션 조회 또는 생성 + SpeakingSession session = getOrCreateSession(sessionId, userId, level); + + String targetLevel = session.getTargetLevel(); + + // STT: 음성 → 텍스트 (Transcribe Proxy 사용) + logger.info("Step 1: Transcribing audio..."); + TranscribeProxyService.TranscribeResult sttResult = transcribeService.transcribe( + audioBase64, + sessionId, + "en-US" + ); + String userText = sttResult.transcript(); + logger.info("Transcription complete: {} (confidence: {})", userText, sttResult.confidence()); + + // 대화 히스토리 로드 + List history = parseHistory(session.getConversationHistory()); + + // Bedrock: AI 응답 생성 + logger.info("Step 2: Generating AI response..."); + String aiResponse = generateAiResponse(userText, history, targetLevel); + logger.info("AI response generated: {}", aiResponse); + + // 히스토리 업데이트 (최근 N턴만 유지) + history.add(new Message("user", userText)); + history.add(new Message("assistant", aiResponse)); + if (history.size() > MAX_HISTORY_SIZE * 2) { + history = new ArrayList<>(history.subList(history.size() - MAX_HISTORY_SIZE * 2, history.size())); + } + session.setConversationHistory(toJson(history)); + sessionRepository.update(session); + + // TTS: 텍스트 → 음성 (Polly 사용) + logger.info("Step 3: Synthesizing speech..."); + String audioId = session.getSessionId() + "_" + System.currentTimeMillis(); + PollyService.VoiceSynthesisResult ttsResult = pollyService.synthesizeSpeech( + audioId, + aiResponse, + "FEMALE" + ); + logger.info("Speech synthesis complete: cached={}", ttsResult.isCached()); + + return new SpeakingResponse( + session.getSessionId(), + userText, + aiResponse, + ttsResult.getAudioUrl(), + sttResult.confidence() + ); + } + + /** + * 텍스트 입력 처리 (음성 없이 텍스트만) + */ + public SpeakingResponse processTextInput(String sessionId, String userId, String userText, String level){ + logger.info("Processing text input for sessionId: {}", sessionId); + + // 세션 조회 또는 생성 + SpeakingSession session = getOrCreateSession(sessionId, userId, level); + + // 대화 히스토리 로드 + List history = parseHistory(session.getConversationHistory()); + + // AI 응답 생성 + String aiResponse = generateAiResponse(userText, history, session.getTargetLevel()); + + // 히스토리 업데이트 + history.add(new Message("user", userText)); + history.add(new Message("assistant", aiResponse)); + if (history.size() > MAX_HISTORY_SIZE * 2) { + history = new ArrayList<>(history.subList(history.size() - MAX_HISTORY_SIZE * 2, history.size())); + } + session.setConversationHistory(toJson(history)); + sessionRepository.update(session); + + // TTS 생성 + String audioId = session.getSessionId() + "_" + System.currentTimeMillis(); + PollyService.VoiceSynthesisResult ttsResult = pollyService.synthesizeSpeech( + audioId, aiResponse, "FEMALE" + ); + + return new SpeakingResponse( + session.getSessionId(), + userText, + aiResponse, + ttsResult.getAudioUrl(), + 1.0 + ); + } + + /** + * 레벨 변경 + */ + public void updateLevel(String sessionId, String level) { + SpeakingSession session = sessionRepository.findBySessionId(sessionId) + .orElseThrow(() -> new RuntimeException("session not found: " + sessionId)); + + session.setTargetLevel(level.toUpperCase()); + sessionRepository.update(session); + logger.info("Level updated for sessionId {}: {}", sessionId, level); + } + + /** + * 대화 히스토리 초기화 + */ + public void resetConversation(String sessionId) { + SpeakingSession session = sessionRepository.findBySessionId(sessionId) + .orElseThrow(() -> new RuntimeException("session not found: " + sessionId)); + + session.setConversationHistory("[]"); + sessionRepository.update(session); + logger.info("Conversation reset for sessionId: {}", sessionId); + } + + + /** + * Bedrock Claude 호출하여 AI 응답 생성 + */ + private String generateAiResponse(String userText, List history, String targetLevel) { + String systemPrompt = buildSystemPrompt(targetLevel); + + JsonObject requestBody = new JsonObject(); + requestBody.addProperty("anthropic_version", "bedrock-2023-05-31"); + requestBody.addProperty("max_tokens", MAX_TOKENS); + requestBody.addProperty("system", systemPrompt); + + // 메시지 배열 구성 + JsonArray messages = new JsonArray(); + + // 기존 히스토리 추가 + for (Message msg : history) { + JsonObject m = new JsonObject(); + m.addProperty("role", msg.role()); + m.addProperty("content", msg.content()); + messages.add(m); + } + + // 현재 사용자 입력 추가 + JsonObject userMsg = new JsonObject(); + userMsg.addProperty("role", "user"); + userMsg.addProperty("content", userText); + messages.add(userMsg); + + requestBody.add("messages", messages); + + // Bedrock 호출 + InvokeModelResponse response = AwsClients.bedrock().invokeModel( + InvokeModelRequest.builder() + .modelId(MODEL_ID) + .contentType("application/json") + .body(SdkBytes.fromUtf8String(requestBody.toString())) + .build() + ); + + // 응답 파싱 + JsonObject result = JsonParser.parseString( + response.body().asUtf8String() + ).getAsJsonObject(); + + return result.getAsJsonArray("content") + .get(0).getAsJsonObject() + .get("text").getAsString(); + } + + /** + * 레벨별 시스템 프롬프트 생성 + */ + private String buildSystemPrompt(String targetLevel) { + String levelGuidance = switch (targetLevel.toUpperCase()) { + case "BEGINNER" -> """ - Use simple vocabulary and short sentences - Speak slowly and clearly - Use basic grammar structures - Provide Korean translations for difficult words in parentheses """; - case "ADVANCED" -> """ + case "ADVANCED" -> """ - Use sophisticated vocabulary and complex sentences - Include idiomatic expressions and phrasal verbs - Discuss abstract concepts naturally - Challenge the user with nuanced topics """; - default -> """ + default -> """ - Use moderate vocabulary appropriate for intermediate learners - Mix simple and compound sentences - Introduce useful expressions gradually - Balance challenge with accessibility """; - }; - - return String.format(""" + }; + + return String.format(""" You are a friendly English conversation partner for Korean learners. Your name is "Amy" and you're an American English teacher living in Seoul. @@ -258,61 +286,60 @@ private String buildSystemPrompt(String targetLevel) { Remember: Your goal is to make the user feel comfortable practicing English! """, targetLevel, levelGuidance); - } - - /** - * 히스토리 JSON 파싱 - */ - private List parseHistory(String historyJson) { - List history = new ArrayList<>(); - - if (historyJson == null || historyJson.isEmpty() || historyJson.equals("[]")) { - return history; - } - - try { - JsonArray array = JsonParser.parseString(historyJson).getAsJsonArray(); - for (JsonElement el : array) { - JsonObject obj = el.getAsJsonObject(); - history.add(new Message( - obj.get("role").getAsString(), - obj.get("content").getAsString() - )); - } - } catch (Exception e) { - logger.warn("Failed to parse history, starting fresh: {}", e.getMessage()); - } - - return history; - } - - /** - * 히스토리 JSON 변환 - */ - private String toJson(List history) { - JsonArray array = new JsonArray(); - for (Message msg : history) { - JsonObject obj = new JsonObject(); - obj.addProperty("role", msg.role()); - obj.addProperty("content", msg.content()); - array.add(obj); - } - return array.toString(); - } - - // ==================== Inner Classes ==================== - - private record Message(String role, String content) { - } - - /** - * Speaking 응답 DTO - */ - public record SpeakingResponse( - String userTranscript, // 사용자가 말한 내용 (STT 결과) - String aiText, // AI 응답 텍스트 - String aiAudioUrl, // AI 응답 음성 URL (Polly) - double confidence // STT 신뢰도comp - ) { - } -} + } + + /** + * 히스토리 JSON 파싱 + */ + private List parseHistory(String historyJson) { + List history = new ArrayList<>(); + + if (historyJson == null || historyJson.isEmpty() || historyJson.equals("[]")) { + return history; + } + + try { + JsonArray array = JsonParser.parseString(historyJson).getAsJsonArray(); + for (JsonElement el : array) { + JsonObject obj = el.getAsJsonObject(); + history.add(new Message( + obj.get("role").getAsString(), + obj.get("content").getAsString() + )); + } + } catch (Exception e) { + logger.warn("Failed to parse history, starting fresh: {}", e.getMessage()); + } + + return history; + } + + /** + * 히스토리 JSON 변환 + */ + private String toJson(List history) { + JsonArray array = new JsonArray(); + for (Message msg : history) { + JsonObject obj = new JsonObject(); + obj.addProperty("role", msg.role()); + obj.addProperty("content", msg.content()); + array.add(obj); + } + return array.toString(); + } + + // ==================== Inner Classes ==================== + + private record Message(String role, String content) {} + + /** + * Speaking 응답 DTO + */ + public record SpeakingResponse( + String sessionId, // 세션 ID (다음 요청에 사용) + String userTranscript, // 사용자가 말한 내용 (STT 결과) + String aiText, // AI 응답 텍스트 + String aiAudioUrl, // AI 응답 음성 URL (Polly) + double confidence // STT 신뢰도comp + ) {} +} \ No newline at end of file From ad4b59e25edea9343b4fae7afeda0f4edbd0f1ec Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 14:52:00 +0900 Subject: [PATCH 455/528] fix: revert buildspec.yml to build-only for CloudFormation deploy stage --- ServerlessFunction/buildspec.yml | 26 +++++++++----------------- 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/ServerlessFunction/buildspec.yml b/ServerlessFunction/buildspec.yml index 0779bea5..49ed2a4f 100644 --- a/ServerlessFunction/buildspec.yml +++ b/ServerlessFunction/buildspec.yml @@ -4,8 +4,6 @@ env: variables: SAM_CLI_TELEMETRY: 0 GRADLE_OPTS: "-Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.caching=true" - ENVIRONMENT: prod - STACK_NAME: group2-englishstudy-prod phases: install: @@ -25,26 +23,20 @@ phases: build: commands: - - echo "Building SAM application for $ENVIRONMENT..." + - echo "Building SAM application..." - cd $CODEBUILD_SRC_DIR/ServerlessFunction - sam build --parallel --cached - - echo "Build completed" + - echo "Packaging SAM application..." + - sam package --s3-bucket group2-englishstudy-pipeline-artifacts --s3-prefix sam-packages --output-template-file packaged-template.yaml post_build: commands: - - echo "Deploying to $ENVIRONMENT environment..." - - cd $CODEBUILD_SRC_DIR/ServerlessFunction - - | - sam deploy \ - --stack-name $STACK_NAME \ - --s3-bucket group2-englishstudy-pipeline-artifacts \ - --s3-prefix sam-deploy/prod \ - --region ap-northeast-2 \ - --capabilities CAPABILITY_IAM CAPABILITY_AUTO_EXPAND \ - --no-confirm-changeset \ - --no-fail-on-empty-changeset \ - --parameter-overrides Environment=$ENVIRONMENT - - echo "Deployment completed on $(date)" + - echo "Build completed on $(date)" + +artifacts: + files: + - packaged-template.yaml + base-directory: ServerlessFunction cache: paths: From bd2b70d0bb783b422983b5571ce4466dbac68b68 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 14:52:00 +0900 Subject: [PATCH 456/528] fix: revert buildspec.yml to build-only for CloudFormation deploy stage --- ServerlessFunction/buildspec.yml | 26 +++++++++----------------- 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/ServerlessFunction/buildspec.yml b/ServerlessFunction/buildspec.yml index 0779bea5..49ed2a4f 100644 --- a/ServerlessFunction/buildspec.yml +++ b/ServerlessFunction/buildspec.yml @@ -4,8 +4,6 @@ env: variables: SAM_CLI_TELEMETRY: 0 GRADLE_OPTS: "-Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.caching=true" - ENVIRONMENT: prod - STACK_NAME: group2-englishstudy-prod phases: install: @@ -25,26 +23,20 @@ phases: build: commands: - - echo "Building SAM application for $ENVIRONMENT..." + - echo "Building SAM application..." - cd $CODEBUILD_SRC_DIR/ServerlessFunction - sam build --parallel --cached - - echo "Build completed" + - echo "Packaging SAM application..." + - sam package --s3-bucket group2-englishstudy-pipeline-artifacts --s3-prefix sam-packages --output-template-file packaged-template.yaml post_build: commands: - - echo "Deploying to $ENVIRONMENT environment..." - - cd $CODEBUILD_SRC_DIR/ServerlessFunction - - | - sam deploy \ - --stack-name $STACK_NAME \ - --s3-bucket group2-englishstudy-pipeline-artifacts \ - --s3-prefix sam-deploy/prod \ - --region ap-northeast-2 \ - --capabilities CAPABILITY_IAM CAPABILITY_AUTO_EXPAND \ - --no-confirm-changeset \ - --no-fail-on-empty-changeset \ - --parameter-overrides Environment=$ENVIRONMENT - - echo "Deployment completed on $(date)" + - echo "Build completed on $(date)" + +artifacts: + files: + - packaged-template.yaml + base-directory: ServerlessFunction cache: paths: From 79e348012bc656bc8494d07c3a57d49de5986a63 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 14:58:26 +0900 Subject: [PATCH 457/528] fix: update all API Gateway StageName to use Environment parameter --- ServerlessFunction/template.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index c7e28a37..c8adc32c 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -219,7 +219,7 @@ Resources: Type: AWS::ApiGatewayV2::Stage Properties: ApiId: !Ref WebSocketApi - StageName: dev + StageName: !Ref Environment AutoDeploy: true # WebSocket Connect Route @@ -1280,7 +1280,7 @@ Resources: Type: AWS::ApiGatewayV2::Stage Properties: ApiId: !Ref GrammarWebSocketApi - StageName: dev + StageName: !Ref Environment AutoDeploy: true # Grammar WebSocket Connect Route From 5158788cdada0bb5eeecb1527d0d23913f147445 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 15:05:44 +0900 Subject: [PATCH 458/528] fix: correct VocabularyTable and ContentBucket references in NewsFunction --- ServerlessFunction/template.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index c8adc32c..d33ea541 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -1774,9 +1774,9 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref NewsTable - DynamoDBCrudPolicy: - TableName: !Ref VocabularyTable + TableName: !Ref VocabTable - S3CrudPolicy: - BucketName: !Ref ContentBucket + BucketName: !Sub "${AWS::StackName}" - Statement: - Effect: Allow Action: From 9f3a67216eaee2ca638fa37b9316265d9a509cd3 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 15:05:44 +0900 Subject: [PATCH 459/528] fix: correct VocabularyTable and ContentBucket references in NewsFunction --- ServerlessFunction/template.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index c8adc32c..d33ea541 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -1774,9 +1774,9 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref NewsTable - DynamoDBCrudPolicy: - TableName: !Ref VocabularyTable + TableName: !Ref VocabTable - S3CrudPolicy: - BucketName: !Ref ContentBucket + BucketName: !Sub "${AWS::StackName}" - Statement: - Effect: Allow Action: From 7e6756b6a8513eb0dfba6e5b07806d20e96dc54c Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 15:12:18 +0900 Subject: [PATCH 460/528] fix: add stack name prefix to GameScheduleGroup and daily-stats schedule to avoid conflicts --- ServerlessFunction/template.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index d33ea541..3a625aa2 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -364,7 +364,7 @@ Resources: - scheduler:CreateSchedule - scheduler:DeleteSchedule - scheduler:GetSchedule - Resource: !Sub "arn:aws:scheduler:${AWS::Region}:${AWS::AccountId}:schedule/game-auto-close/*" + Resource: !Sub "arn:aws:scheduler:${AWS::Region}:${AWS::AccountId}:schedule/${AWS::StackName}-game-auto-close/*" - Statement: - Effect: Allow Action: @@ -524,7 +524,7 @@ Resources: - scheduler:CreateSchedule - scheduler:DeleteSchedule - scheduler:GetSchedule - Resource: !Sub "arn:aws:scheduler:${AWS::Region}:${AWS::AccountId}:schedule/game-auto-close/*" + Resource: !Sub "arn:aws:scheduler:${AWS::Region}:${AWS::AccountId}:schedule/${AWS::StackName}-game-auto-close/*" - Statement: - Effect: Allow Action: @@ -614,7 +614,7 @@ Resources: GameScheduleGroup: Type: AWS::Scheduler::ScheduleGroup Properties: - Name: game-auto-close + Name: !Sub "${AWS::StackName}-game-auto-close" ChatMessageFunction: Type: AWS::Serverless::Function @@ -1441,7 +1441,7 @@ Resources: Type: Schedule Properties: Schedule: cron(0 15 * * ? *) # UTC 15:00 = KST 00:00 (자정) - Name: daily-stats-aggregation + Name: !Sub "${AWS::StackName}-daily-stats-aggregation" Description: Daily word learning stats aggregation Enabled: true From cf6c7ec84faf85be4c16b0a2a3a124fa915b0ef8 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 15:12:18 +0900 Subject: [PATCH 461/528] fix: add stack name prefix to GameScheduleGroup and daily-stats schedule to avoid conflicts --- ServerlessFunction/template.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index d33ea541..3a625aa2 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -364,7 +364,7 @@ Resources: - scheduler:CreateSchedule - scheduler:DeleteSchedule - scheduler:GetSchedule - Resource: !Sub "arn:aws:scheduler:${AWS::Region}:${AWS::AccountId}:schedule/game-auto-close/*" + Resource: !Sub "arn:aws:scheduler:${AWS::Region}:${AWS::AccountId}:schedule/${AWS::StackName}-game-auto-close/*" - Statement: - Effect: Allow Action: @@ -524,7 +524,7 @@ Resources: - scheduler:CreateSchedule - scheduler:DeleteSchedule - scheduler:GetSchedule - Resource: !Sub "arn:aws:scheduler:${AWS::Region}:${AWS::AccountId}:schedule/game-auto-close/*" + Resource: !Sub "arn:aws:scheduler:${AWS::Region}:${AWS::AccountId}:schedule/${AWS::StackName}-game-auto-close/*" - Statement: - Effect: Allow Action: @@ -614,7 +614,7 @@ Resources: GameScheduleGroup: Type: AWS::Scheduler::ScheduleGroup Properties: - Name: game-auto-close + Name: !Sub "${AWS::StackName}-game-auto-close" ChatMessageFunction: Type: AWS::Serverless::Function @@ -1441,7 +1441,7 @@ Resources: Type: Schedule Properties: Schedule: cron(0 15 * * ? *) # UTC 15:00 = KST 00:00 (자정) - Name: daily-stats-aggregation + Name: !Sub "${AWS::StackName}-daily-stats-aggregation" Description: Daily word learning stats aggregation Enabled: true From 6930140637eb38e24d8c017bbd110e0cb4701f0e Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 15:17:51 +0900 Subject: [PATCH 462/528] feat: add support for existing Cognito User Pool reuse across environments --- ServerlessFunction/template.yaml | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 3a625aa2..b28f1981 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -12,6 +12,19 @@ Parameters: - prod Description: Deployment environment + ExistingCognitoUserPoolId: + Type: String + Default: "" + Description: Existing Cognito User Pool ID (leave empty to create new) + + ExistingCognitoClientId: + Type: String + Default: "" + Description: Existing Cognito User Pool Client ID (leave empty to create new) + +Conditions: + CreateNewCognito: !Equals [!Ref ExistingCognitoUserPoolId, ""] + Globals: Function: Timeout: 30 @@ -46,8 +59,8 @@ Resources: CognitoUserPool: Type: AWS::Cognito::UserPool + Condition: CreateNewCognito DeletionPolicy: Retain - # UpdateReplacePolicy: Retain Properties: UserPoolName: !Sub "${AWS::StackName}-userpool" UsernameAttributes: @@ -86,6 +99,7 @@ Resources: # Cognito에게 Lambda 호출 권한 부여 PreSignUpPermission: Type: AWS::Lambda::Permission + Condition: CreateNewCognito Properties: Action: lambda:InvokeFunction FunctionName: !Ref PreSignUpFunction @@ -94,6 +108,7 @@ Resources: PostConfirmationPermission: Type: AWS::Lambda::Permission + Condition: CreateNewCognito Properties: Action: lambda:InvokeFunction FunctionName: !GetAtt PostConfirmationFunction.Arn @@ -132,9 +147,10 @@ Resources: CognitoUserPoolClient: Type: AWS::Cognito::UserPoolClient + Condition: CreateNewCognito Properties: ClientName: !Sub "${AWS::StackName}-client" - UserPoolId: !Ref CognitoUserPool + UserPoolId: !If [CreateNewCognito, !Ref CognitoUserPool, !Ref ExistingCognitoUserPoolId] GenerateSecret: false ExplicitAuthFlows: - ALLOW_USER_SRP_AUTH @@ -2037,11 +2053,11 @@ Outputs: CognitoUserPoolId: Description: Cognito User Pool ID - Value: !Ref CognitoUserPool + Value: !If [CreateNewCognito, !Ref CognitoUserPool, !Ref ExistingCognitoUserPoolId] CognitoUserPoolClientId: Description: Cognito User Pool Client ID - Value: !Ref CognitoUserPoolClient + Value: !If [CreateNewCognito, !Ref CognitoUserPoolClient, !Ref ExistingCognitoClientId] OPIcTableName: Description: OPIc DynamoDB Table Name From 9804d39fb2434211a6a185d09d8bb4daeca9f168 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 15:23:51 +0900 Subject: [PATCH 463/528] fix: add conditional Cognito ARN reference in API Gateway Authorizer --- ServerlessFunction/template.yaml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index b28f1981..1a212411 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -216,7 +216,10 @@ Resources: DefaultAuthorizer: CognitoAuthorizer Authorizers: CognitoAuthorizer: - UserPoolArn: !GetAtt CognitoUserPool.Arn + UserPoolArn: !If + - CreateNewCognito + - !GetAtt CognitoUserPool.Arn + - !Sub "arn:aws:cognito-idp:${AWS::Region}:${AWS::AccountId}:userpool/${ExistingCognitoUserPoolId}" Identity: Header: Authorization From 13e0827893723dbbe44390cdd6f0506e007f1cdc Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 15:32:22 +0900 Subject: [PATCH 464/528] fix: remove Cognito resources completely, use existing Cognito only --- ServerlessFunction/template.yaml | 89 +++----------------------------- 1 file changed, 6 insertions(+), 83 deletions(-) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 1a212411..53dc1248 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -19,11 +19,7 @@ Parameters: ExistingCognitoClientId: Type: String - Default: "" - Description: Existing Cognito User Pool Client ID (leave empty to create new) - -Conditions: - CreateNewCognito: !Equals [!Ref ExistingCognitoUserPoolId, ""] + Description: Existing Cognito User Pool Client ID Globals: Function: @@ -54,67 +50,10 @@ Globals: Resources: ############################################# - # Cognito User Pool + # Cognito - Using Existing User Pool + # (Cognito resources are managed in group2-englishstudy-chatting stack) ############################################# - CognitoUserPool: - Type: AWS::Cognito::UserPool - Condition: CreateNewCognito - DeletionPolicy: Retain - Properties: - UserPoolName: !Sub "${AWS::StackName}-userpool" - UsernameAttributes: - - email - AutoVerifiedAttributes: - - email - Policies: - PasswordPolicy: - MinimumLength: 8 - RequireLowercase: true - RequireNumbers: true - RequireSymbols: true - RequireUppercase: false - # Cognito에 저장할 사용자 정보 정의 ≈ 회원 테이블 컬럼 - Schema: - - Name: email - AttributeDataType: String - Required: true - Mutable: true - - Name: nickname - AttributeDataType: String - Required: false - Mutable: true - - Name: level - AttributeDataType: String - Required: false - Mutable: true - - Name: profileUrl - AttributeDataType: String - Required: false - Mutable: true - LambdaConfig: - PreSignUp: !GetAtt PreSignUpFunction.Arn - PostConfirmation: !GetAtt PostConfirmationFunction.Arn - - # Cognito에게 Lambda 호출 권한 부여 - PreSignUpPermission: - Type: AWS::Lambda::Permission - Condition: CreateNewCognito - Properties: - Action: lambda:InvokeFunction - FunctionName: !Ref PreSignUpFunction - Principal: cognito-idp.amazonaws.com - SourceArn: !Sub arn:aws:cognito-idp:${AWS::Region}:${AWS::AccountId}:userpool/* - - PostConfirmationPermission: - Type: AWS::Lambda::Permission - Condition: CreateNewCognito - Properties: - Action: lambda:InvokeFunction - FunctionName: !GetAtt PostConfirmationFunction.Arn - Principal: cognito-idp.amazonaws.com - SourceArn: !GetAtt CognitoUserPool.Arn - # 사용자 custom 속성들 기본값 설정 Lambda 함수 PreSignUpFunction: Type: AWS::Serverless::Function @@ -145,19 +84,6 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref UserTable - CognitoUserPoolClient: - Type: AWS::Cognito::UserPoolClient - Condition: CreateNewCognito - Properties: - ClientName: !Sub "${AWS::StackName}-client" - UserPoolId: !If [CreateNewCognito, !Ref CognitoUserPool, !Ref ExistingCognitoUserPoolId] - GenerateSecret: false - ExplicitAuthFlows: - - ALLOW_USER_SRP_AUTH - - ALLOW_REFRESH_TOKEN_AUTH - - ALLOW_USER_PASSWORD_AUTH - PreventUserExistenceErrors: ENABLED - ############################################# # API Gateway (Unified) ############################################# @@ -216,10 +142,7 @@ Resources: DefaultAuthorizer: CognitoAuthorizer Authorizers: CognitoAuthorizer: - UserPoolArn: !If - - CreateNewCognito - - !GetAtt CognitoUserPool.Arn - - !Sub "arn:aws:cognito-idp:${AWS::Region}:${AWS::AccountId}:userpool/${ExistingCognitoUserPoolId}" + UserPoolArn: !Sub "arn:aws:cognito-idp:${AWS::Region}:${AWS::AccountId}:userpool/${ExistingCognitoUserPoolId}" Identity: Header: Authorization @@ -2056,11 +1979,11 @@ Outputs: CognitoUserPoolId: Description: Cognito User Pool ID - Value: !If [CreateNewCognito, !Ref CognitoUserPool, !Ref ExistingCognitoUserPoolId] + Value: !Ref ExistingCognitoUserPoolId CognitoUserPoolClientId: Description: Cognito User Pool Client ID - Value: !If [CreateNewCognito, !Ref CognitoUserPoolClient, !Ref ExistingCognitoClientId] + Value: !Ref ExistingCognitoClientId OPIcTableName: Description: OPIc DynamoDB Table Name From 4d0db0a41b58e93c2865f1eb0545ee3c3ba6e5a0 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 15:32:22 +0900 Subject: [PATCH 465/528] fix: remove Cognito resources completely, use existing Cognito only --- ServerlessFunction/template.yaml | 89 +++----------------------------- 1 file changed, 6 insertions(+), 83 deletions(-) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 1a212411..53dc1248 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -19,11 +19,7 @@ Parameters: ExistingCognitoClientId: Type: String - Default: "" - Description: Existing Cognito User Pool Client ID (leave empty to create new) - -Conditions: - CreateNewCognito: !Equals [!Ref ExistingCognitoUserPoolId, ""] + Description: Existing Cognito User Pool Client ID Globals: Function: @@ -54,67 +50,10 @@ Globals: Resources: ############################################# - # Cognito User Pool + # Cognito - Using Existing User Pool + # (Cognito resources are managed in group2-englishstudy-chatting stack) ############################################# - CognitoUserPool: - Type: AWS::Cognito::UserPool - Condition: CreateNewCognito - DeletionPolicy: Retain - Properties: - UserPoolName: !Sub "${AWS::StackName}-userpool" - UsernameAttributes: - - email - AutoVerifiedAttributes: - - email - Policies: - PasswordPolicy: - MinimumLength: 8 - RequireLowercase: true - RequireNumbers: true - RequireSymbols: true - RequireUppercase: false - # Cognito에 저장할 사용자 정보 정의 ≈ 회원 테이블 컬럼 - Schema: - - Name: email - AttributeDataType: String - Required: true - Mutable: true - - Name: nickname - AttributeDataType: String - Required: false - Mutable: true - - Name: level - AttributeDataType: String - Required: false - Mutable: true - - Name: profileUrl - AttributeDataType: String - Required: false - Mutable: true - LambdaConfig: - PreSignUp: !GetAtt PreSignUpFunction.Arn - PostConfirmation: !GetAtt PostConfirmationFunction.Arn - - # Cognito에게 Lambda 호출 권한 부여 - PreSignUpPermission: - Type: AWS::Lambda::Permission - Condition: CreateNewCognito - Properties: - Action: lambda:InvokeFunction - FunctionName: !Ref PreSignUpFunction - Principal: cognito-idp.amazonaws.com - SourceArn: !Sub arn:aws:cognito-idp:${AWS::Region}:${AWS::AccountId}:userpool/* - - PostConfirmationPermission: - Type: AWS::Lambda::Permission - Condition: CreateNewCognito - Properties: - Action: lambda:InvokeFunction - FunctionName: !GetAtt PostConfirmationFunction.Arn - Principal: cognito-idp.amazonaws.com - SourceArn: !GetAtt CognitoUserPool.Arn - # 사용자 custom 속성들 기본값 설정 Lambda 함수 PreSignUpFunction: Type: AWS::Serverless::Function @@ -145,19 +84,6 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref UserTable - CognitoUserPoolClient: - Type: AWS::Cognito::UserPoolClient - Condition: CreateNewCognito - Properties: - ClientName: !Sub "${AWS::StackName}-client" - UserPoolId: !If [CreateNewCognito, !Ref CognitoUserPool, !Ref ExistingCognitoUserPoolId] - GenerateSecret: false - ExplicitAuthFlows: - - ALLOW_USER_SRP_AUTH - - ALLOW_REFRESH_TOKEN_AUTH - - ALLOW_USER_PASSWORD_AUTH - PreventUserExistenceErrors: ENABLED - ############################################# # API Gateway (Unified) ############################################# @@ -216,10 +142,7 @@ Resources: DefaultAuthorizer: CognitoAuthorizer Authorizers: CognitoAuthorizer: - UserPoolArn: !If - - CreateNewCognito - - !GetAtt CognitoUserPool.Arn - - !Sub "arn:aws:cognito-idp:${AWS::Region}:${AWS::AccountId}:userpool/${ExistingCognitoUserPoolId}" + UserPoolArn: !Sub "arn:aws:cognito-idp:${AWS::Region}:${AWS::AccountId}:userpool/${ExistingCognitoUserPoolId}" Identity: Header: Authorization @@ -2056,11 +1979,11 @@ Outputs: CognitoUserPoolId: Description: Cognito User Pool ID - Value: !If [CreateNewCognito, !Ref CognitoUserPool, !Ref ExistingCognitoUserPoolId] + Value: !Ref ExistingCognitoUserPoolId CognitoUserPoolClientId: Description: Cognito User Pool Client ID - Value: !If [CreateNewCognito, !Ref CognitoUserPoolClient, !Ref ExistingCognitoClientId] + Value: !Ref ExistingCognitoClientId OPIcTableName: Description: OPIc DynamoDB Table Name From 6c4cc89cf3234f7fa3c2de871f17e652c924daf3 Mon Sep 17 00:00:00 2001 From: hye-inA Date: Thu, 22 Jan 2026 15:52:31 +0900 Subject: [PATCH 466/528] =?UTF-8?q?feat=20:=20speaking=20rest=20API=20?= =?UTF-8?q?=EB=9E=8C=EB=8B=A4=20=ED=95=A8=EC=88=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ServerlessFunction/template.yaml | 56 ++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 488fc843..0f64417e 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -1433,6 +1433,62 @@ Resources: Description: Daily word learning stats aggregation Enabled: true + ############################################# + # Speaking REST API (AI와 대화하기) + ############################################# + + SpeakingFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: group2-englishstudy-speaking-handler + CodeUri: . + Handler: com.mzc.secondproject.serverless.domain.speaking.handler.SpeakingHandler::handleRequest + Description: Handle Speaking AI conversation (REST API) + Timeout: 120 + MemorySize: 1024 + SnapStart: + ApplyOn: PublishedVersions + Environment: + Variables: + TRANSCRIBE_API_KEY: "/opic/transcribe-proxy-api-key" + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref ChatTable + - S3CrudPolicy: + BucketName: group2-englishstudy + - Statement: + - Effect: Allow + Action: + - bedrock:InvokeModel + Resource: "*" + - Statement: + - Effect: Allow + Action: + - polly:SynthesizeSpeech + Resource: "*" + - Statement: + - Effect: Allow + Action: + - ssm:GetParameter + Resource: !Sub "arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/opic/*" + Events: + SpeakingChat: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /api/speaking/chat + Method: POST + Auth: + Authorizer: CognitoAuthorizer + SpeakingReset: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /api/speaking/reset + Method: POST + Auth: + Authorizer: CognitoAuthorizer + ############################################# # OPIc Lambda Functions ############################################# From 4d91cc89b8dd2c25fbee45fa2ae4efa8e056fd53 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 16:02:18 +0900 Subject: [PATCH 467/528] feat: add S3 bucket resource and fix environment-specific endpoints - Add ContentBucket S3 resource for content storage - Replace hardcoded /dev with ${Environment} in all WebSocket endpoints - Update Output URLs to use dynamic environment stage Co-Authored-By: Claude Opus 4.5 --- ServerlessFunction/template.yaml | 53 ++++++++++++++++++++++++-------- 1 file changed, 40 insertions(+), 13 deletions(-) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 53dc1248..b3fa4c8d 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -228,7 +228,7 @@ Resources: Environment: Variables: WEBSOCKET_CONNECTION_TTL_SECONDS: "600" - WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -257,7 +257,7 @@ Resources: ApplyOn: PublishedVersions Environment: Variables: - WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -286,7 +286,7 @@ Resources: ApplyOn: PublishedVersions Environment: Variables: - WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" GAME_AUTO_CLOSE_LAMBDA_ARN: !GetAtt GameAutoCloseFunction.Arn SCHEDULER_ROLE_ARN: !GetAtt GameSchedulerRole.Arn Policies: @@ -374,7 +374,7 @@ Resources: ApplyOn: PublishedVersions Environment: Variables: - WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -446,7 +446,7 @@ Resources: ApplyOn: PublishedVersions Environment: Variables: - WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" GAME_AUTO_CLOSE_LAMBDA_ARN: !GetAtt GameAutoCloseFunction.Arn SCHEDULER_ROLE_ARN: !GetAtt GameSchedulerRole.Arn Policies: @@ -520,7 +520,7 @@ Resources: ApplyOn: PublishedVersions Environment: Variables: - WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -1283,7 +1283,7 @@ Resources: Description: Handle Grammar WebSocket $connect with JWT auth Environment: Variables: - WEBSOCKET_ENDPOINT: !Sub "https://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" + WEBSOCKET_ENDPOINT: !Sub "https://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -1310,7 +1310,7 @@ Resources: Description: Handle Grammar WebSocket $disconnect Environment: Variables: - WEBSOCKET_ENDPOINT: !Sub "https://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" + WEBSOCKET_ENDPOINT: !Sub "https://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -1339,7 +1339,7 @@ Resources: MemorySize: 1024 Environment: Variables: - GRAMMAR_WEBSOCKET_ENDPOINT: !Sub "https://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" + GRAMMAR_WEBSOCKET_ENDPOINT: !Sub "https://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -1872,6 +1872,33 @@ Resources: AttributeName: ttl Enabled: true + ############################################# + # S3 Bucket for Content Storage + ############################################# + + ContentBucket: + Type: AWS::S3::Bucket + Properties: + BucketName: !Sub "${AWS::StackName}" + CorsConfiguration: + CorsRules: + - AllowedHeaders: + - "*" + AllowedMethods: + - GET + - PUT + - POST + - DELETE + - HEAD + AllowedOrigins: + - "*" + MaxAge: 3600 + PublicAccessBlockConfiguration: + BlockPublicAcls: false + BlockPublicPolicy: false + IgnorePublicAcls: false + RestrictPublicBuckets: false + ############################################# # SNS / SQS for Async Statistics Processing ############################################# @@ -1955,15 +1982,15 @@ Resources: Outputs: ApiUrl: Description: Unified API Gateway endpoint URL - Value: !Sub 'https://${MainApi}.execute-api.${AWS::Region}.amazonaws.com/dev/' + Value: !Sub 'https://${MainApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}/' WebSocketUrl: Description: WebSocket API Gateway endpoint URL - Value: !Sub 'wss://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev' + Value: !Sub 'wss://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}' GrammarWebSocketUrl: Description: Grammar Streaming WebSocket API endpoint URL - Value: !Sub 'wss://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev' + Value: !Sub 'wss://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}' ChatTableName: Description: Chat DynamoDB Table Name @@ -1975,7 +2002,7 @@ Outputs: BucketName: Description: S3 Bucket Name - Value: !Sub "${AWS::StackName}" + Value: !Ref ContentBucket CognitoUserPoolId: Description: Cognito User Pool ID From 025669074b9504fead42679a4af52a256b64b802 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 16:34:35 +0900 Subject: [PATCH 468/528] fix: add stack name prefix to news-collection schedule name Prevent EventBridge rule name conflicts across environments Co-Authored-By: Claude Opus 4.5 --- ServerlessFunction/template.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index b3fa4c8d..48eb1cb1 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -1699,7 +1699,7 @@ Resources: Type: Schedule Properties: Schedule: cron(0 9 * * ? *) - Name: news-collection-daily-schedule + Name: !Sub "${AWS::StackName}-news-collection-daily-schedule" Description: 매일 18시 KST (09:00 UTC)에 뉴스 수집 Enabled: true From c084f22361aaa0b3dc51e3ccfd1673c0d3ef4c7c Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 16:42:57 +0900 Subject: [PATCH 469/528] fix: add X-Requested-With and Accept headers to CORS config Enable additional headers for CloudFront CORS compatibility Co-Authored-By: Claude Opus 4.5 --- ServerlessFunction/template.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 48eb1cb1..0a393a94 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -95,7 +95,7 @@ Resources: StageName: !Ref Environment Cors: AllowMethods: "'GET,POST,PUT,DELETE,OPTIONS'" - AllowHeaders: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" + AllowHeaders: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key,X-Requested-With,Accept'" AllowOrigin: "'*'" AllowCredentials: false GatewayResponses: From e7e4b852e88fdabd76d515420d0b62efa2f859f9 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 16:49:40 +0900 Subject: [PATCH 470/528] fix: use environment variable for S3 bucket URLs Replace hardcoded bucket name with BUCKET_NAME env var for multi-env support: - PreSignUpHandler: dynamic default profile URL - PostConfirmationHandler: dynamic default profile URL - UserService: dynamic default profile URL - BadgeType: dynamic badge image base URL Co-Authored-By: Claude Opus 4.5 --- .../domain/badge/enums/BadgeType.java | 10 ++++-- .../user/handler/PostConfirmationHandler.java | 8 ++++- .../domain/user/handler/PreSignUpHandler.java | 31 ++++++++++++------- .../domain/user/service/UserService.java | 7 ++++- 4 files changed, 40 insertions(+), 16 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/enums/BadgeType.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/enums/BadgeType.java index b34466ed..f9d32794 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/enums/BadgeType.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/enums/BadgeType.java @@ -32,8 +32,14 @@ public enum BadgeType { // 특별 MASTER("학습 마스터", "모든 업적을 달성했습니다", "master.png", "ALL_BADGES", 1); - - private static final String BASE_URL = "https://group2-englishstudy.s3.ap-northeast-2.amazonaws.com/badges/"; + + private static final String BUCKET_NAME = System.getenv("BUCKET_NAME"); + private static final String BASE_URL = getBaseUrl(); + + private static String getBaseUrl() { + String bucket = BUCKET_NAME != null ? BUCKET_NAME : "group2-englishstudy"; + return String.format("https://%s.s3.ap-northeast-2.amazonaws.com/badges/", bucket); + } private final String name; private final String description; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PostConfirmationHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PostConfirmationHandler.java index 97bd6638..74a601db 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PostConfirmationHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PostConfirmationHandler.java @@ -19,7 +19,13 @@ public class PostConfirmationHandler implements RequestHandler { private static final Logger logger = LoggerFactory.getLogger(PostConfirmationHandler.class); - private static final String DEFAULT_PROFILE_URL = "https://group2-englishstudy.s3.amazonaws.com/profile/default.png"; + private static final String BUCKET_NAME = System.getenv("BUCKET_NAME"); + private static final String DEFAULT_PROFILE_URL = getDefaultProfileUrl(); + + private static String getDefaultProfileUrl() { + String bucket = BUCKET_NAME != null ? BUCKET_NAME : "group2-englishstudy"; + return String.format("https://%s.s3.amazonaws.com/profile/default.png", bucket); + } private final UserRepository userRepository; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PreSignUpHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PreSignUpHandler.java index d881615b..c8551e61 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PreSignUpHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PreSignUpHandler.java @@ -11,38 +11,45 @@ public class PreSignUpHandler implements RequestHandler, Map> { private static final Logger logger = LoggerFactory.getLogger(PreSignUpHandler.class); - private static final String DEFAULT_PROFILE_URL = System.getenv("DEFAULT_PROFILE_URL"); - + private static final String BUCKET_NAME = System.getenv("BUCKET_NAME"); + private static final String DEFAULT_PROFILE_URL = getDefaultProfileUrl(); + + private static String getDefaultProfileUrl() { + String envUrl = System.getenv("DEFAULT_PROFILE_URL"); + if (envUrl != null && !envUrl.isEmpty()) { + return envUrl; + } + String bucket = BUCKET_NAME != null ? BUCKET_NAME : "group2-englishstudy"; + return String.format("https://%s.s3.amazonaws.com/profile/default.png", bucket); + } + @Override public Map handleRequest(Map input, Context context) { - + try { @SuppressWarnings("unchecked") Map request = (Map) input.get("request"); - + @SuppressWarnings("unchecked") Map userAttributes = (Map) request.get("userAttributes"); - + String nickname = userAttributes.get("nickname"); if (nickname == null || nickname.trim().isEmpty()) { String defaultNickname = UUID.randomUUID().toString().substring(0, 6).toUpperCase() + "님"; userAttributes.put("nickname", defaultNickname); logger.info("nickname 기본값: {}", defaultNickname); } - + String level = userAttributes.get("custom:level"); if (level == null || level.trim().isEmpty()) { userAttributes.put("custom:level", "BEGINNER"); logger.info("level 선택 기본값: BEGINNER"); } - + String profileUrl = userAttributes.get("custom:profileUrl"); if (profileUrl == null || profileUrl.trim().isEmpty()) { - String defaultUrl = DEFAULT_PROFILE_URL != null - ? DEFAULT_PROFILE_URL - : "https://group2-englishstudy.s3.amazonaws.com/profile/default.png"; - userAttributes.put("custom:profileUrl", defaultUrl); - logger.info("프로필 이미지 기본값: {}", defaultUrl); + userAttributes.put("custom:profileUrl", DEFAULT_PROFILE_URL); + logger.info("프로필 이미지 기본값: {}", DEFAULT_PROFILE_URL); } return input; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/service/UserService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/service/UserService.java index 0c5c99c6..2421f118 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/service/UserService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/service/UserService.java @@ -22,7 +22,12 @@ public class UserService { private static final Logger logger = LoggerFactory.getLogger(UserService.class); private static final String BUCKET_NAME = System.getenv("PROFILE_BUCKET_NAME"); - private static final String DEFAULT_PROFILE_URL = "https://group2-englishstudy.s3.amazonaws.com/profile/default.png"; + private static final String DEFAULT_PROFILE_URL = getDefaultProfileUrl(); + + private static String getDefaultProfileUrl() { + String bucket = BUCKET_NAME != null ? BUCKET_NAME : "group2-englishstudy"; + return String.format("https://%s.s3.amazonaws.com/profile/default.png", bucket); + } private static final List VALID_LEVELS = Arrays.asList("BEGINNER", "INTERMEDIATE", "ADVANCED"); private static final List VALID_IMAGE_TYPES = Arrays.asList("image/jpeg", "image/png", "image/gif", "image/webp"); From 5eda0db9215feeb1e10afd2a2b4e079cad72aa4b Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Thu, 22 Jan 2026 17:07:29 +0900 Subject: [PATCH 471/528] fix: disable authorizer for CORS preflight requests Add AddDefaultAuthorizerToCorsPreflight: false to prevent Cognito Authorizer from blocking OPTIONS requests Co-Authored-By: Claude Opus 4.5 --- ServerlessFunction/template.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 0a393a94..ba797ea6 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -140,6 +140,7 @@ Resources: application/json: '{"message": "Token expired", "statusCode": 401}' Auth: DefaultAuthorizer: CognitoAuthorizer + AddDefaultAuthorizerToCorsPreflight: false Authorizers: CognitoAuthorizer: UserPoolArn: !Sub "arn:aws:cognito-idp:${AWS::Region}:${AWS::AccountId}:userpool/${ExistingCognitoUserPoolId}" From 143c0ff6449662f21cbc3b4080e8539f71e6218d Mon Sep 17 00:00:00 2001 From: hyein Heo <128613248+hye-inA@users.noreply.github.com> Date: Thu, 22 Jan 2026 16:53:04 +0900 Subject: [PATCH 472/528] =?UTF-8?q?feat=20:=20speaking=20REST=20API=20?= =?UTF-8?q?=EB=9E=8C=EB=8B=A4=20=ED=95=A8=EC=88=98=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=20(#491)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feature : OPIc 세션관리 + 답변 처리 파이프라인 구현 (#413) * feat : OPIc 질문 & 세션 관련 dto 생성 * refactor : @DynamoDbBean 주석 추가 * feat : 주제 + 소주제 + 레벨로 오픽 질문 조회 추가 * feat : OPIc 세션, 질문, 답변 종합 handler 구현 * feat : 오픽 주제, 소주제별 seed 데이터 추가 * refactor : Transcribe API KEY 환경변수명 수정 * refactor : Bedrock에 사용하는 클로드 모델 변경 * refactor : S3 Key 대신 Proxy에서 요청하는 Base64 값으로 변환 * feat: GAME_START, ROUND_END 메시지에 serverTime 추가 - broadcastGameStart(): serverTime, roundDuration 필드 추가 - broadcastRoundEnd(): serverTime, roundStartTime, roundDuration 필드 추가 - GameService.endRound(): data에 roundStartTime, roundDuration 포함 - 타이머 동기화 버그 수정을 위한 서버 시간 제공 * feat: WebSocketMessageHelper 유틸리티 클래스 추가 - domain 필드 포함 메시지 생성 헬퍼 - DOMAIN_CHAT, DOMAIN_GAME 상수 정의 - buildChatMessage(), buildGameMessage() 메서드 * feat: 모든 WebSocket 메시지에 domain 필드 추가 - ScoreUpdateMessage에 domain 필드 추가 - broadcastGameStart에 domain:"game" 추가 - broadcastRoundEnd에 domain:"game" 추가 - broadcastCorrectAnswerMessage에 domain:"game" 추가 - handleCommandResult 시스템 메시지에 domain:"game" 추가 - handleRegularMessage 채팅 메시지에 domain:"chat" 추가 Closes #426, #427 * feat: GameSession 모델 클래스 생성 - DynamoDB Enhanced Client 어노테이션 적용 - GSI1: roomId로 활성 게임 세션 조회 가능 - 게임 상태 관리용 헬퍼 메서드 포함 Closes #428 * feat: GameSessionRepository 구현 - 기본 CRUD (save, findById, delete) - roomId로 활성/전체 게임 세션 조회 - 상태, 라운드, 점수 업데이트 메서드 - 정답자 추가, 힌트 사용, 게임 종료 처리 Closes #429 * refactor: ChatRoom에서 게임 필드 분리 - 게임 관련 필드 제거 (gameStatus, currentRound 등) - activeGameSessionId 필드 추가 - 게임 상태는 GameSession으로 분리됨 Note: 의존 코드 수정은 #431에서 진행 Closes #430 * refactor: GameSession 기반으로 전체 게임 로직 리팩토링 - GameService: ChatRoom 대신 GameSession 사용 - GameStatsService: GameSession 매개변수로 변경 - CommandService: GameSession 기반 점수 조회 - GameHandler: GameSession 기반 REST API - WebSocketMessageHandler: GameSession 기반 브로드캐스트 - GameStatusResponse, ScoreboardResponse: GameSession 매개변수 Closes #431 * feat: GameSessionHandler Lambda 및 게임 세션 API 구현 - POST /rooms/{roomId}/games - 게임 세션 생성 - GET /games/{gameSessionId} - 게임 상태 조회 (재접속용) - POST /games/{gameSessionId}/start - 게임 시작 - POST /games/{gameSessionId}/stop - 게임 종료 모든 응답에 serverTime 포함 (타이머 동기화) 출제자에게만 currentWord 포함 Closes #432, #433 * feat: 게임 시작 7분 후 자동 종료 기능 구현 - GameConfig에 gameTimeLimit() 메서드 추가 (기본값: 420초) - GameSchedulerClient 유틸리티 클래스 생성 (EventBridge Scheduler 연동) - GameService에 스케줄 생성/취소 로직 추가 - GameAutoCloseHandler Lambda 함수 생성 - template.yaml에 Lambda, IAM Role, Schedule Group 추가 Closes #417 * fix : 메모리 증가 및 Lambda 응답 제한 시간 Cognito 트리거 제한시간과 동일하게 수정 (#439) * feat: 캐치마인드 게임 방 분리 기능 구현 (#455) - Room 타입 분리 (CHAT/GAME) - RoomType, RoomStatus enum 추가 - ChatRoom 모델에 type, gameType, gameSettings, status, hostId 필드 추가 - GameSettings 모델 추가 - 방 생성/조회 API 수정 - CreateRoomRequest에 type, gameType, gameSettings 필드 추가 - 방 목록 조회 시 type, gameType, status 필터 지원 - 게임 시작 조건 검증 - GAME 타입 방에서만 게임 시작 가능 (GAME_007 에러) - 게임 재시작 API 구현 (#452) - POST /rooms/{roomId}/game/restart 엔드포인트 추가 - 방장만 재시작 가능 (GAME_009 에러) - 게임 진행 중 재시작 불가 (GAME_008 에러) - 방장 변경 로직 구현 (#453) - 방장 퇴장 시 다음 멤버에게 자동 이전 - 모든 멤버 퇴장 시 방 자동 삭제 - WebSocket 메시지 타입 추가 (#454) - ROOM_STATUS_CHANGE, HOST_CHANGE 메시지 타입 추가 - WebSocketMessageHelper에 빌더 메서드 추가 - 테스트 추가 - RoomType, RoomStatus enum 테스트 - GameSettings 모델 테스트 - ChattingErrorCode 테스트 업데이트 Related: #440, #441, #442, #443, #444, #445, #446 Closes: #447, #448, #449, #450, #451, #452, #453, #454 * feat: 참가자 닉네임 및 방장 변경 WebSocket 알림 구현 (#456) - RoomParticipant DTO 추가 (userId, nickname, isHost) - ChatRoomQueryService에 닉네임 조회 메서드 추가 - getParticipantsWithNicknames(): 참가자 목록 + 닉네임 - getHostNickname(): 방장 닉네임 조회 - ChatRoomHandler.getRoom() 응답에 participants, hostNickname 추가 - ChatRoomCommandService.leaveRoom()에서 방장 변경 시 WebSocket 브로드캐스트 Related: #440 * fix: ChatRoomFunction에 UserTable DynamoDB 권한 추가 - 참가자/방장 닉네임 조회를 위한 UserTable 읽기 권한 추가 - DynamoDBReadPolicy로 UserTable 접근 허용 Fixes #457 * fix: GameSettings에 @DynamoDbBean 어노테이션 추가 - DynamoDB Enhanced Client가 중첩 객체를 직렬화/역직렬화할 수 있도록 수정 - ChatRoom 저장/조회 시 GameSettings 변환 오류 해결 Fixes #457 * fix: ChatRoomFunction에 WEBSOCKET_ENDPOINT 환경변수 및 권한 추가 - leaveRoom에서 WebSocketBroadcaster 사용을 위한 환경변수 추가 - execute-api:ManageConnections 권한 추가 * fix: WebSocket Lambda 함수들에 WEBSOCKET_ENDPOINT 환경변수 추가 - WebSocketConnectFunction에 WEBSOCKET_ENDPOINT 추가 - WebSocketDisconnectFunction에 WEBSOCKET_ENDPOINT 추가 - execute-api:ManageConnections 권한 추가 * fix: Grammar WebSocket Lambda 환경 변수 및 권한 추가 - GrammarStreamingConnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - GrammarStreamingDisconnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - 두 함수에 execute-api:ManageConnections 권한 추가 * feat: GSI1SK 확장성 있는 재설계 및 DB 레벨 필터링 - 기존: {level}#{createdAt} - 신규: {type}#{gameType}#{status}#{level}#{createdAt} - 전체 방: GSI1PK = "ROOMS" - 게임방만: begins_with(GSI1SK, "GAME#") - 캐치마인드만: begins_with(GSI1SK, "GAME#CATCHMIND#") - 대기중 캐치마인드: begins_with(GSI1SK, "GAME#CATCHMIND#WAITING#") - ChatRoomCommandService.java: 방 생성 시 새 GSI1SK 포맷 적용 - ChatRoomRepository.java: findByFilters() 메서드 추가, updateStatus() 메서드 추가 - ChatRoomQueryService.java: 메모리 필터링 제거, DB 레벨 필터링으로 변경 - GameService.java: 게임 시작/종료 시 방 상태 업데이트 (GSI1SK 포함) - scripts/migrate-gsi1sk.sh: 기존 데이터 마이그레이션 스크립트 - 37개 기존 방 마이그레이션 완료 * fix: increase stats query limit to 100 days - Adjust maximum allowable limit for recent stats query from 30 to 100 days to support extended data range responses. * feat: improve game round and connection management logic - Added handling for game round timeout (`ROUND_TIMEOUT`) in WebSocketMessageHandler. - Enhanced game start broadcast to include `currentWord` for the drawer only. - Updated ConnectionRepository to remove duplicate user connections in the same room. - Added `currentWordEnglish` to GameSession for better answer verification. - Normalized ChatRoom levels to match database storage format. - Updated template.yaml to include DynamoDBReadPolicy for VocabTable. * refactor: 코드 정리 및 미사용 클래스 제거 (#459) * refactor: remove unused WebSocketResponseUtil * refactor: add AutoCloseable to WebSocketBroadcaster * refactor: remove unused TestService * refactor: remove unused WordService * refactor: remove unused DailyStudyService * refactor: remove unused UserWordService * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * fix: resolve N+1 query in StatsService * refactor(all): DI 패턴 및 전략 패턴 적용 (#461) - Repository, Service, Handler에 DI 생성자 추가 (테스트 용이성) - BadgeService에 Strategy 패턴 적용 (뱃지 조건 검증 로직 분리) * refactor: relocate and restructure seed data files - Moved `question-homes.json` and `words.json` from subdirectories to `seed` folder. - Updated file paths for better organization and clarity. * chore: seed 데이터 폴더 구조 정리 * feat: add CI/CD pipeline configuration for CodePipeline * fix: add SNS topic policy and DependsOn for notification rule * fix: correct paths in buildspec.yml for CodeBuild * fix: remove hardcoded JAVA_HOME, use runtime default * fix: add gradle wrapper for CI/CD build * fix: use single line sam package command with hardcoded bucket * fix: use existing stack name group2-englishstudy-chatting * fix: add missing WEBSOCKET_ENDPOINT env var to WebSocket connect functions * docs: update FRONTEND-API-GUIDE with new RoomType/RoomStatus structure * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix: update WebSocketDisconnectHandler to use GameSession model - Remove references to deleted ChatRoom game fields - Use GameSessionRepository to finish active game sessions - Use ChatRoomRepository.updateStatus() for room state management * perf: optimize CI/CD build time - Add pip cache for SAM CLI dependencies - Enable Gradle parallel builds and build cache - Add SAM build cache (.aws-sam) - Use sam build --parallel --cached - Skip SAM CLI install if already cached - Remove unnecessary 'clean' to leverage cache * feat: add custom CodeBuild Docker image with pre-installed tools - Dockerfile with Java 21 + SAM CLI + Gradle pre-installed - build-and-push.sh script for ECR deployment - Updated buildspec.yml for custom image (removes SAM CLI install) - Expected build time reduction: ~30-40 seconds * feature : AI 영어 회화 연습 기능 (#468) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix: remove typo in SpeakingConnectionRepository * fix : 오타 수정 * chore: trigger build test with custom Docker image * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * fix: add CORS headers to API Gateway error responses (#479) API Gateway의 인증 실패 등 에러 응답에 CORS 헤더가 누락되어 CloudFront를 통한 프론트엔드 요청이 차단되는 문제 수정 - UNAUTHORIZED (401) 응답에 CORS 헤더 추가 - ACCESS_DENIED (403) 응답에 CORS 헤더 추가 - DEFAULT_4XX/5XX 응답에 CORS 헤더 추가 - EXPIRED_TOKEN 응답에 CORS 헤더 추가 * feat : speaking rest API 람다 함수 추가 --------- Co-authored-by: ddingjoo --- .../speaking/service/SpeakingService.java | 2 +- ServerlessFunction/template.yaml | 56 +++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/service/SpeakingService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/service/SpeakingService.java index 010c4afe..3dffac92 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/service/SpeakingService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/service/SpeakingService.java @@ -342,4 +342,4 @@ public record SpeakingResponse( String aiAudioUrl, // AI 응답 음성 URL (Polly) double confidence // STT 신뢰도comp ) {} -} \ No newline at end of file +} diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index ba797ea6..fdd9351f 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -1388,6 +1388,62 @@ Resources: Description: Daily word learning stats aggregation Enabled: true + ############################################# + # Speaking REST API (AI와 대화하기) + ############################################# + + SpeakingFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: group2-englishstudy-speaking-handler + CodeUri: . + Handler: com.mzc.secondproject.serverless.domain.speaking.handler.SpeakingHandler::handleRequest + Description: Handle Speaking AI conversation (REST API) + Timeout: 120 + MemorySize: 1024 + SnapStart: + ApplyOn: PublishedVersions + Environment: + Variables: + TRANSCRIBE_API_KEY: "/opic/transcribe-proxy-api-key" + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref ChatTable + - S3CrudPolicy: + BucketName: group2-englishstudy + - Statement: + - Effect: Allow + Action: + - bedrock:InvokeModel + Resource: "*" + - Statement: + - Effect: Allow + Action: + - polly:SynthesizeSpeech + Resource: "*" + - Statement: + - Effect: Allow + Action: + - ssm:GetParameter + Resource: !Sub "arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/opic/*" + Events: + SpeakingChat: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /api/speaking/chat + Method: POST + Auth: + Authorizer: CognitoAuthorizer + SpeakingReset: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /api/speaking/reset + Method: POST + Auth: + Authorizer: CognitoAuthorizer + ############################################# # OPIc Lambda Functions ############################################# From 158dcee69ca2c82ab0577e124ca29bf310ed6132 Mon Sep 17 00:00:00 2001 From: hye-inA Date: Thu, 22 Jan 2026 21:57:47 +0900 Subject: [PATCH 473/528] =?UTF-8?q?refactor=20:=20speaking=20service=20?= =?UTF-8?q?=EC=9E=AC=EC=82=AC=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../serverless/domain/speaking/service/SpeakingService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/service/SpeakingService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/service/SpeakingService.java index 010c4afe..3dffac92 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/service/SpeakingService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/service/SpeakingService.java @@ -342,4 +342,4 @@ public record SpeakingResponse( String aiAudioUrl, // AI 응답 음성 URL (Polly) double confidence // STT 신뢰도comp ) {} -} \ No newline at end of file +} From 2cc5db062dd6dce9008886e5b701c9d205192619 Mon Sep 17 00:00:00 2001 From: hye-inA Date: Thu, 22 Jan 2026 22:28:40 +0900 Subject: [PATCH 474/528] =?UTF-8?q?refactor=20:=20AI=20=EC=98=81=EC=96=B4?= =?UTF-8?q?=20=ED=9A=8C=ED=99=94=20=EC=97=B0=EC=8A=B5=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../websocket/SpeakingConnectHandler.java | 0 .../websocket/SpeakingDisconnectHandler.java | 0 .../websocket/SpeakingMessageHandler.java | 0 .../SpeakingConnectionRepository.java | 0 .../repository/SpeakingSessionRepository.java | 2 +- ServerlessFunction/template.yaml | 45 +++++++++++++++++-- 6 files changed, 42 insertions(+), 5 deletions(-) delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingConnectHandler.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingDisconnectHandler.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingMessageHandler.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingConnectHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingConnectHandler.java deleted file mode 100644 index e69de29b..00000000 diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingDisconnectHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingDisconnectHandler.java deleted file mode 100644 index e69de29b..00000000 diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingMessageHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingMessageHandler.java deleted file mode 100644 index e69de29b..00000000 diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java deleted file mode 100644 index e69de29b..00000000 diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingSessionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingSessionRepository.java index fed1cd66..a6d9e26a 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingSessionRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingSessionRepository.java @@ -17,7 +17,7 @@ public class SpeakingSessionRepository { private static final Logger logger = LoggerFactory.getLogger(SpeakingSessionRepository.class); - private static final String TABLE_NAME = EnvConfig.getRequired("CHAT_TABLE_NAME"); + private static final String TABLE_NAME = EnvConfig.getRequired("SPEAKING_TABLE_NAME"); private final DynamoDbTable table; diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index fdd9351f..d6d2ea4e 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -1395,9 +1395,9 @@ Resources: SpeakingFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-speaking-handler + FunctionName: !Sub "${AWS::StackName}-speaking-handler" CodeUri: . - Handler: com.mzc.secondproject.serverless.domain.speaking.handler.SpeakingHandler::handleRequest + Handler: com.mzc.secondproject.serverless.domain.speaking.handler.websocket.SpeakingHandler::handleRequest Description: Handle Speaking AI conversation (REST API) Timeout: 120 MemorySize: 1024 @@ -1405,12 +1405,13 @@ Resources: ApplyOn: PublishedVersions Environment: Variables: + SPEAKING_TABLE_NAME: !Ref SpeakingTable TRANSCRIBE_API_KEY: "/opic/transcribe-proxy-api-key" Policies: - DynamoDBCrudPolicy: - TableName: !Ref ChatTable + TableName: !Ref SpeakingTable - S3CrudPolicy: - BucketName: group2-englishstudy + BucketName: !Sub "${AWS::StackName}" - Statement: - Effect: Allow Action: @@ -1735,6 +1736,38 @@ Resources: AttributeName: ttl Enabled: true + SpeakingTable: + Type: AWS::DynamoDB::Table + Properties: + TableName: !Sub "${AWS::StackName}-speaking" + BillingMode: PAY_PER_REQUEST + AttributeDefinitions: + - AttributeName: PK + AttributeType: S + - AttributeName: SK + AttributeType: S + - AttributeName: GSI1PK + AttributeType: S + - AttributeName: GSI1SK + AttributeType: S + KeySchema: + - AttributeName: PK + KeyType: HASH + - AttributeName: SK + KeyType: RANGE + GlobalSecondaryIndexes: + - IndexName: GSI1 + KeySchema: + - AttributeName: GSI1PK + KeyType: HASH + - AttributeName: GSI1SK + KeyType: RANGE + Projection: + ProjectionType: ALL + TimeToLiveSpecification: + AttributeName: ttl + Enabled: true + ############################################# # News Collection Scheduled Lambda ############################################# @@ -2072,3 +2105,7 @@ Outputs: OPIcTableName: Description: OPIc DynamoDB Table Name Value: !Ref OPIcTable + + SpeakingTableName: + Description: Speaking DynamoDB Table Name + Value: !Ref SpeakingTable From 42e9d2e090c387ff9e371aa2ca4588ea19ca1c18 Mon Sep 17 00:00:00 2001 From: hyein Heo <128613248+hye-inA@users.noreply.github.com> Date: Thu, 22 Jan 2026 22:31:23 +0900 Subject: [PATCH 475/528] =?UTF-8?q?feature=20:=20test=20=EB=B2=A1=EC=97=94?= =?UTF-8?q?=EB=93=9C=20=EC=84=9C=EB=B2=84=EC=97=90=20AI=20=EB=A7=90?= =?UTF-8?q?=ED=95=98=EA=B8=B0=20=EC=97=B0=EC=8A=B5=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EB=B0=B0=ED=8F=AC=20(#492)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix : 오타 수정 * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * feat : speaking rest API 람다 함수 추가 * refactor : speaking service 재사용 * refactor : AI 영어 회화 연습 코드 리팩토링 --------- Co-authored-by: DDING JOO --- .../websocket/SpeakingConnectHandler.java | 0 .../websocket/SpeakingDisconnectHandler.java | 0 .../websocket/SpeakingMessageHandler.java | 0 .../SpeakingConnectionRepository.java | 0 .../repository/SpeakingSessionRepository.java | 2 +- .../speaking/service/SpeakingService.java | 2 +- ServerlessFunction/template.yaml | 93 +++++++++++++++++++ 7 files changed, 95 insertions(+), 2 deletions(-) delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingConnectHandler.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingDisconnectHandler.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingMessageHandler.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingConnectHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingConnectHandler.java deleted file mode 100644 index e69de29b..00000000 diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingDisconnectHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingDisconnectHandler.java deleted file mode 100644 index e69de29b..00000000 diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingMessageHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingMessageHandler.java deleted file mode 100644 index e69de29b..00000000 diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java deleted file mode 100644 index e69de29b..00000000 diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingSessionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingSessionRepository.java index fed1cd66..a6d9e26a 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingSessionRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingSessionRepository.java @@ -17,7 +17,7 @@ public class SpeakingSessionRepository { private static final Logger logger = LoggerFactory.getLogger(SpeakingSessionRepository.class); - private static final String TABLE_NAME = EnvConfig.getRequired("CHAT_TABLE_NAME"); + private static final String TABLE_NAME = EnvConfig.getRequired("SPEAKING_TABLE_NAME"); private final DynamoDbTable table; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/service/SpeakingService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/service/SpeakingService.java index 010c4afe..3dffac92 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/service/SpeakingService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/service/SpeakingService.java @@ -342,4 +342,4 @@ public record SpeakingResponse( String aiAudioUrl, // AI 응답 음성 URL (Polly) double confidence // STT 신뢰도comp ) {} -} \ No newline at end of file +} diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index ba797ea6..d6d2ea4e 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -1388,6 +1388,63 @@ Resources: Description: Daily word learning stats aggregation Enabled: true + ############################################# + # Speaking REST API (AI와 대화하기) + ############################################# + + SpeakingFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: !Sub "${AWS::StackName}-speaking-handler" + CodeUri: . + Handler: com.mzc.secondproject.serverless.domain.speaking.handler.websocket.SpeakingHandler::handleRequest + Description: Handle Speaking AI conversation (REST API) + Timeout: 120 + MemorySize: 1024 + SnapStart: + ApplyOn: PublishedVersions + Environment: + Variables: + SPEAKING_TABLE_NAME: !Ref SpeakingTable + TRANSCRIBE_API_KEY: "/opic/transcribe-proxy-api-key" + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref SpeakingTable + - S3CrudPolicy: + BucketName: !Sub "${AWS::StackName}" + - Statement: + - Effect: Allow + Action: + - bedrock:InvokeModel + Resource: "*" + - Statement: + - Effect: Allow + Action: + - polly:SynthesizeSpeech + Resource: "*" + - Statement: + - Effect: Allow + Action: + - ssm:GetParameter + Resource: !Sub "arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/opic/*" + Events: + SpeakingChat: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /api/speaking/chat + Method: POST + Auth: + Authorizer: CognitoAuthorizer + SpeakingReset: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /api/speaking/reset + Method: POST + Auth: + Authorizer: CognitoAuthorizer + ############################################# # OPIc Lambda Functions ############################################# @@ -1679,6 +1736,38 @@ Resources: AttributeName: ttl Enabled: true + SpeakingTable: + Type: AWS::DynamoDB::Table + Properties: + TableName: !Sub "${AWS::StackName}-speaking" + BillingMode: PAY_PER_REQUEST + AttributeDefinitions: + - AttributeName: PK + AttributeType: S + - AttributeName: SK + AttributeType: S + - AttributeName: GSI1PK + AttributeType: S + - AttributeName: GSI1SK + AttributeType: S + KeySchema: + - AttributeName: PK + KeyType: HASH + - AttributeName: SK + KeyType: RANGE + GlobalSecondaryIndexes: + - IndexName: GSI1 + KeySchema: + - AttributeName: GSI1PK + KeyType: HASH + - AttributeName: GSI1SK + KeyType: RANGE + Projection: + ProjectionType: ALL + TimeToLiveSpecification: + AttributeName: ttl + Enabled: true + ############################################# # News Collection Scheduled Lambda ############################################# @@ -2016,3 +2105,7 @@ Outputs: OPIcTableName: Description: OPIc DynamoDB Table Name Value: !Ref OPIcTable + + SpeakingTableName: + Description: Speaking DynamoDB Table Name + Value: !Ref SpeakingTable From 726efb7e567eff9fc8e3f942fa74bb5545e2cdad Mon Sep 17 00:00:00 2001 From: hye-inA Date: Thu, 22 Jan 2026 23:06:33 +0900 Subject: [PATCH 476/528] =?UTF-8?q?feat=20:=20handleChat=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=20JsonNull=20=EC=B2=B4=ED=81=AC=20=ED=91=B8?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../handler/websocket/SpeakingHandler.java | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingHandler.java index 69375925..c515f950 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingHandler.java @@ -148,6 +148,28 @@ private APIGatewayProxyResponseEvent handleReset(String userId, String body) { )); } + /** + * JSON에서 문자열 추출 (null 또는 JsonNull이면 null 반환) + */ + private String getStringOrNull(JsonObject json, String key) { + if (!json.has(key) || json.get(key).isJsonNull()) { + return null; + } + return json.get(key).getAsString(); + } + + /** + * JSON에서 문자열 추출 (null 또는 JsonNull이면 기본값 반환) + */ + private String getStringOrDefault(JsonObject json, String key, String defaultValue) { + if (!json.has(key) || json.get(key).isJsonNull()) { + return defaultValue; + } + String value = json.get(key).getAsString(); + return (value == null || value.isEmpty()) ? defaultValue : value; + } + + private APIGatewayProxyResponseEvent response(int statusCode, Map body) { return new APIGatewayProxyResponseEvent() .withStatusCode(statusCode) From e57b1f6ba1533491c320bd3a3798a06a2fa5c5f2 Mon Sep 17 00:00:00 2001 From: hyein Heo <128613248+hye-inA@users.noreply.github.com> Date: Thu, 22 Jan 2026 23:09:54 +0900 Subject: [PATCH 477/528] =?UTF-8?q?feature=20:=20handleChat=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=20JsonNull=20=EC=B2=B4=ED=81=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#493)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix : 오타 수정 * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * feat : speaking rest API 람다 함수 추가 * refactor : speaking service 재사용 * refactor : AI 영어 회화 연습 코드 리팩토링 * feat : handleChat 메서드 JsonNull 체크 푸가 --------- Co-authored-by: DDING JOO --- .../handler/websocket/SpeakingHandler.java | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingHandler.java index 69375925..c515f950 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingHandler.java @@ -148,6 +148,28 @@ private APIGatewayProxyResponseEvent handleReset(String userId, String body) { )); } + /** + * JSON에서 문자열 추출 (null 또는 JsonNull이면 null 반환) + */ + private String getStringOrNull(JsonObject json, String key) { + if (!json.has(key) || json.get(key).isJsonNull()) { + return null; + } + return json.get(key).getAsString(); + } + + /** + * JSON에서 문자열 추출 (null 또는 JsonNull이면 기본값 반환) + */ + private String getStringOrDefault(JsonObject json, String key, String defaultValue) { + if (!json.has(key) || json.get(key).isJsonNull()) { + return defaultValue; + } + String value = json.get(key).getAsString(); + return (value == null || value.isEmpty()) ? defaultValue : value; + } + + private APIGatewayProxyResponseEvent response(int statusCode, Map body) { return new APIGatewayProxyResponseEvent() .withStatusCode(statusCode) From f82e649d69f71d5f8c5a641ba785fbd419ccc5a0 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 23 Jan 2026 09:19:22 +0900 Subject: [PATCH 478/528] =?UTF-8?q?feat(news):=20=EB=89=B4=EC=8A=A4=20?= =?UTF-8?q?=ED=95=99=EC=8A=B5=20=EB=B0=B0=EC=A7=80=20=EC=8B=9C=EC=8A=A4?= =?UTF-8?q?=ED=85=9C=20=EA=B5=AC=ED=98=84=20(#473)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 14개 뉴스 관련 배지 추가 (읽기, 퀴즈, 단어수집, 연속학습, 마스터) - UserStats에 뉴스 통계 필드 추가 - 6개 뉴스 배지 Strategy 클래스 생성 - 뉴스 읽기/퀴즈/단어수집 시 통계 업데이트 및 배지 체크 연동 --- .../domain/badge/enums/BadgeType.java | 26 +++- .../BadgeConditionStrategyFactory.java | 7 + .../badge/strategy/NewsMasterStrategy.java | 45 ++++++ .../strategy/NewsQuizPerfectStrategy.java | 25 ++++ .../badge/strategy/NewsQuizStrategy.java | 25 ++++ .../badge/strategy/NewsReadStrategy.java | 25 ++++ .../badge/strategy/NewsStreakStrategy.java | 25 ++++ .../badge/strategy/NewsWordStrategy.java | 25 ++++ .../domain/news/handler/NewsHandler.java | 12 +- .../news/service/NewsLearningService.java | 37 ++++- .../domain/news/service/NewsQuizService.java | 31 +++- .../domain/news/service/NewsWordService.java | 43 +++++- .../domain/stats/model/UserStats.java | 10 +- .../stats/repository/UserStatsRepository.java | 134 ++++++++++++++++++ 14 files changed, 457 insertions(+), 13 deletions(-) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsMasterStrategy.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsQuizPerfectStrategy.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsQuizStrategy.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsReadStrategy.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsStreakStrategy.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsWordStrategy.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/enums/BadgeType.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/enums/BadgeType.java index f9d32794..76e857ae 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/enums/BadgeType.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/enums/BadgeType.java @@ -31,7 +31,31 @@ public enum BadgeType { PERFECT_DRAWER("완벽한 출제자", "출제 시 전원이 정답을 맞췄습니다", "perfect_drawer.png", "PERFECT_DRAWS", 1), // 특별 - MASTER("학습 마스터", "모든 업적을 달성했습니다", "master.png", "ALL_BADGES", 1); + MASTER("학습 마스터", "모든 업적을 달성했습니다", "master.png", "ALL_BADGES", 1), + + // 뉴스 - 읽기 + NEWS_FIRST_READ("뉴스 첫 발걸음", "첫 번째 뉴스 읽기 완료", "news_first_read.png", "NEWS_READ", 1), + NEWS_READ_10("뉴스 탐험가", "뉴스 10개 읽기 완료", "news_read_10.png", "NEWS_READ", 10), + NEWS_READ_50("뉴스 애호가", "뉴스 50개 읽기 완료", "news_read_50.png", "NEWS_READ", 50), + NEWS_READ_100("뉴스 전문가", "뉴스 100개 읽기 완료", "news_read_100.png", "NEWS_READ", 100), + + // 뉴스 - 퀴즈 + NEWS_QUIZ_FIRST("퀴즈 도전", "첫 뉴스 퀴즈 완료", "news_quiz_first.png", "NEWS_QUIZ", 1), + NEWS_QUIZ_PERFECT("완벽한 이해", "뉴스 퀴즈에서 만점 달성", "news_quiz_perfect.png", "NEWS_QUIZ_PERFECT", 1), + NEWS_QUIZ_10("퀴즈 탐험가", "뉴스 퀴즈 10회 완료", "news_quiz_10.png", "NEWS_QUIZ", 10), + NEWS_QUIZ_50("퀴즈 마스터", "뉴스 퀴즈 50회 완료", "news_quiz_50.png", "NEWS_QUIZ", 50), + + // 뉴스 - 단어 수집 + NEWS_WORD_10("단어 수집가", "뉴스에서 단어 10개 수집", "news_word_10.png", "NEWS_WORD", 10), + NEWS_WORD_50("단어 사냥꾼", "뉴스에서 단어 50개 수집", "news_word_50.png", "NEWS_WORD", 50), + NEWS_WORD_100("단어 전문가", "뉴스에서 단어 100개 수집", "news_word_100.png", "NEWS_WORD", 100), + + // 뉴스 - 연속 학습 + NEWS_STREAK_7("일주일 뉴스 습관", "7일 연속 뉴스 읽기", "news_streak_7.png", "NEWS_STREAK", 7), + NEWS_STREAK_30("한 달 뉴스 습관", "30일 연속 뉴스 읽기", "news_streak_30.png", "NEWS_STREAK", 30), + + // 뉴스 - 종합 + NEWS_MASTER("뉴스 마스터", "읽기100+퀴즈50+단어100 달성", "news_master.png", "NEWS_MASTER", 1); private static final String BUCKET_NAME = System.getenv("BUCKET_NAME"); private static final String BASE_URL = getBaseUrl(); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/BadgeConditionStrategyFactory.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/BadgeConditionStrategyFactory.java index 01f6ed33..ecfb1e63 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/BadgeConditionStrategyFactory.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/BadgeConditionStrategyFactory.java @@ -22,6 +22,13 @@ public class BadgeConditionStrategyFactory { register(new GamesWonStrategy()); register(new QuickGuessesStrategy()); register(new PerfectDrawsStrategy()); + // 뉴스 관련 전략 + register(new NewsReadStrategy()); + register(new NewsQuizStrategy()); + register(new NewsQuizPerfectStrategy()); + register(new NewsWordStrategy()); + register(new NewsStreakStrategy()); + register(new NewsMasterStrategy()); // 별도 로직이 필요한 카테고리 register(new NoOpStrategy("PERFECT_TEST")); register(new NoOpStrategy("ALL_BADGES")); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsMasterStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsMasterStrategy.java new file mode 100644 index 00000000..43fee824 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsMasterStrategy.java @@ -0,0 +1,45 @@ +package com.mzc.secondproject.serverless.domain.badge.strategy; + +import com.mzc.secondproject.serverless.domain.badge.enums.BadgeType; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; + +/** + * 뉴스 마스터 뱃지 조건 전략 + * 읽기 100개 + 퀴즈 50회 + 단어 100개 달성 시 획득 + */ +public class NewsMasterStrategy implements BadgeConditionStrategy { + + private static final int NEWS_READ_REQUIRED = 100; + private static final int NEWS_QUIZ_REQUIRED = 50; + private static final int NEWS_WORD_REQUIRED = 100; + + @Override + public boolean checkCondition(BadgeType type, UserStats stats) { + int newsRead = stats.getNewsRead() != null ? stats.getNewsRead() : 0; + int newsQuiz = stats.getNewsQuizCompleted() != null ? stats.getNewsQuizCompleted() : 0; + int newsWord = stats.getNewsWordsCollected() != null ? stats.getNewsWordsCollected() : 0; + + return newsRead >= NEWS_READ_REQUIRED + && newsQuiz >= NEWS_QUIZ_REQUIRED + && newsWord >= NEWS_WORD_REQUIRED; + } + + @Override + public int calculateProgress(BadgeType type, UserStats stats) { + int newsRead = stats.getNewsRead() != null ? stats.getNewsRead() : 0; + int newsQuiz = stats.getNewsQuizCompleted() != null ? stats.getNewsQuizCompleted() : 0; + int newsWord = stats.getNewsWordsCollected() != null ? stats.getNewsWordsCollected() : 0; + + // 3가지 조건의 평균 진행률 (각각 100%, 100%, 100% 기준) + int readProgress = Math.min(newsRead * 100 / NEWS_READ_REQUIRED, 100); + int quizProgress = Math.min(newsQuiz * 100 / NEWS_QUIZ_REQUIRED, 100); + int wordProgress = Math.min(newsWord * 100 / NEWS_WORD_REQUIRED, 100); + + return (readProgress + quizProgress + wordProgress) / 3; + } + + @Override + public String getCategory() { + return "NEWS_MASTER"; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsQuizPerfectStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsQuizPerfectStrategy.java new file mode 100644 index 00000000..d9790b27 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsQuizPerfectStrategy.java @@ -0,0 +1,25 @@ +package com.mzc.secondproject.serverless.domain.badge.strategy; + +import com.mzc.secondproject.serverless.domain.badge.enums.BadgeType; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; + +/** + * 뉴스 퀴즈 만점 뱃지 조건 전략 + */ +public class NewsQuizPerfectStrategy implements BadgeConditionStrategy { + + @Override + public boolean checkCondition(BadgeType type, UserStats stats) { + return stats.getNewsQuizPerfect() != null && stats.getNewsQuizPerfect() >= type.getThreshold(); + } + + @Override + public int calculateProgress(BadgeType type, UserStats stats) { + return stats.getNewsQuizPerfect() != null ? stats.getNewsQuizPerfect() : 0; + } + + @Override + public String getCategory() { + return "NEWS_QUIZ_PERFECT"; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsQuizStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsQuizStrategy.java new file mode 100644 index 00000000..4ce390d8 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsQuizStrategy.java @@ -0,0 +1,25 @@ +package com.mzc.secondproject.serverless.domain.badge.strategy; + +import com.mzc.secondproject.serverless.domain.badge.enums.BadgeType; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; + +/** + * 뉴스 퀴즈 완료 뱃지 조건 전략 + */ +public class NewsQuizStrategy implements BadgeConditionStrategy { + + @Override + public boolean checkCondition(BadgeType type, UserStats stats) { + return stats.getNewsQuizCompleted() != null && stats.getNewsQuizCompleted() >= type.getThreshold(); + } + + @Override + public int calculateProgress(BadgeType type, UserStats stats) { + return stats.getNewsQuizCompleted() != null ? stats.getNewsQuizCompleted() : 0; + } + + @Override + public String getCategory() { + return "NEWS_QUIZ"; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsReadStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsReadStrategy.java new file mode 100644 index 00000000..3e5cee34 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsReadStrategy.java @@ -0,0 +1,25 @@ +package com.mzc.secondproject.serverless.domain.badge.strategy; + +import com.mzc.secondproject.serverless.domain.badge.enums.BadgeType; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; + +/** + * 뉴스 읽기 뱃지 조건 전략 + */ +public class NewsReadStrategy implements BadgeConditionStrategy { + + @Override + public boolean checkCondition(BadgeType type, UserStats stats) { + return stats.getNewsRead() != null && stats.getNewsRead() >= type.getThreshold(); + } + + @Override + public int calculateProgress(BadgeType type, UserStats stats) { + return stats.getNewsRead() != null ? stats.getNewsRead() : 0; + } + + @Override + public String getCategory() { + return "NEWS_READ"; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsStreakStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsStreakStrategy.java new file mode 100644 index 00000000..cb5f58d0 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsStreakStrategy.java @@ -0,0 +1,25 @@ +package com.mzc.secondproject.serverless.domain.badge.strategy; + +import com.mzc.secondproject.serverless.domain.badge.enums.BadgeType; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; + +/** + * 뉴스 연속 읽기 뱃지 조건 전략 + */ +public class NewsStreakStrategy implements BadgeConditionStrategy { + + @Override + public boolean checkCondition(BadgeType type, UserStats stats) { + return stats.getNewsStreak() != null && stats.getNewsStreak() >= type.getThreshold(); + } + + @Override + public int calculateProgress(BadgeType type, UserStats stats) { + return stats.getNewsStreak() != null ? stats.getNewsStreak() : 0; + } + + @Override + public String getCategory() { + return "NEWS_STREAK"; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsWordStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsWordStrategy.java new file mode 100644 index 00000000..70c6c1a7 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsWordStrategy.java @@ -0,0 +1,25 @@ +package com.mzc.secondproject.serverless.domain.badge.strategy; + +import com.mzc.secondproject.serverless.domain.badge.enums.BadgeType; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; + +/** + * 뉴스 단어 수집 뱃지 조건 전략 + */ +public class NewsWordStrategy implements BadgeConditionStrategy { + + @Override + public boolean checkCondition(BadgeType type, UserStats stats) { + return stats.getNewsWordsCollected() != null && stats.getNewsWordsCollected() >= type.getThreshold(); + } + + @Override + public int calculateProgress(BadgeType type, UserStats stats) { + return stats.getNewsWordsCollected() != null ? stats.getNewsWordsCollected() : 0; + } + + @Override + public String getCategory() { + return "NEWS_WORD"; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java index 180bb7cb..a427051d 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java @@ -416,13 +416,19 @@ private APIGatewayProxyResponseEvent collectWord(APIGatewayProxyRequestEvent req String word = body.get("word").getAsString(); String context = body.has("context") ? body.get("context").getAsString() : ""; - NewsWordCollect collected = wordService.collectWord(userId, articleId, word, context); + NewsWordService.WordCollectResult result = wordService.collectWord(userId, articleId, word, context); - if (collected == null) { + if (result == null || result.wordCollect() == null) { return ResponseGenerator.fail(NewsErrorCode.WORD_ALREADY_COLLECTED); } - return ResponseGenerator.ok("단어 수집 성공", collected); + Map responseData = new java.util.HashMap<>(); + responseData.put("wordCollect", result.wordCollect()); + if (result.newBadges() != null && !result.newBadges().isEmpty()) { + responseData.put("newBadges", result.newBadges()); + } + + return ResponseGenerator.ok("단어 수집 성공", responseData); } /** diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java index 8eba8522..3e13f2c3 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java @@ -2,13 +2,18 @@ import com.mzc.secondproject.serverless.common.config.EnvConfig; import com.mzc.secondproject.serverless.common.service.PollyService; +import com.mzc.secondproject.serverless.domain.badge.model.UserBadge; +import com.mzc.secondproject.serverless.domain.badge.service.BadgeService; import com.mzc.secondproject.serverless.domain.news.model.NewsArticle; import com.mzc.secondproject.serverless.domain.news.model.UserNewsRecord; import com.mzc.secondproject.serverless.domain.news.repository.NewsArticleRepository; import com.mzc.secondproject.serverless.domain.news.repository.UserNewsRepository; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; +import com.mzc.secondproject.serverless.domain.stats.repository.UserStatsRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Optional; @@ -24,29 +29,38 @@ public class NewsLearningService { private final NewsArticleRepository articleRepository; private final UserNewsRepository userNewsRepository; private final PollyService pollyService; + private final UserStatsRepository userStatsRepository; + private final BadgeService badgeService; public NewsLearningService() { this.articleRepository = new NewsArticleRepository(); this.userNewsRepository = new UserNewsRepository(); this.pollyService = new PollyService(BUCKET_NAME, "news/audio/"); + this.userStatsRepository = new UserStatsRepository(); + this.badgeService = new BadgeService(); } public NewsLearningService(NewsArticleRepository articleRepository, UserNewsRepository userNewsRepository, - PollyService pollyService) { + PollyService pollyService, + UserStatsRepository userStatsRepository, + BadgeService badgeService) { this.articleRepository = articleRepository; this.userNewsRepository = userNewsRepository; this.pollyService = pollyService; + this.userStatsRepository = userStatsRepository; + this.badgeService = badgeService; } /** * 뉴스 읽기 완료 기록 + * @return 새로 획득한 배지 목록 */ - public void markAsRead(String userId, String articleId) { + public List markAsRead(String userId, String articleId) { Optional article = articleRepository.findById(articleId); if (article.isEmpty()) { logger.warn("기사를 찾을 수 없음: {}", articleId); - return; + return new ArrayList<>(); } NewsArticle a = article.get(); @@ -65,6 +79,23 @@ public void markAsRead(String userId, String articleId) { } logger.info("읽기 완료 기록: userId={}, articleId={}", userId, articleId); + + // 통계 업데이트 및 배지 체크 + List newBadges = new ArrayList<>(); + try { + UserStats updatedStats = userStatsRepository.incrementNewsReadStats(userId); + if (updatedStats != null) { + newBadges = badgeService.checkAndAwardBadges(userId, updatedStats); + if (!newBadges.isEmpty()) { + logger.info("새 배지 획득: userId={}, badges={}", userId, + newBadges.stream().map(UserBadge::getBadgeType).toList()); + } + } + } catch (Exception e) { + logger.error("통계/배지 업데이트 실패: userId={}, error={}", userId, e.getMessage()); + } + + return newBadges; } /** diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQuizService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQuizService.java index bb22fc90..da86d430 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQuizService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQuizService.java @@ -1,9 +1,13 @@ package com.mzc.secondproject.serverless.domain.news.service; +import com.mzc.secondproject.serverless.domain.badge.model.UserBadge; +import com.mzc.secondproject.serverless.domain.badge.service.BadgeService; import com.mzc.secondproject.serverless.domain.news.constants.NewsKey; import com.mzc.secondproject.serverless.domain.news.model.*; import com.mzc.secondproject.serverless.domain.news.repository.NewsArticleRepository; import com.mzc.secondproject.serverless.domain.news.repository.NewsQuizRepository; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; +import com.mzc.secondproject.serverless.domain.stats.repository.UserStatsRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -20,15 +24,22 @@ public class NewsQuizService { private final NewsArticleRepository articleRepository; private final NewsQuizRepository quizRepository; + private final UserStatsRepository userStatsRepository; + private final BadgeService badgeService; public NewsQuizService() { this.articleRepository = new NewsArticleRepository(); this.quizRepository = new NewsQuizRepository(); + this.userStatsRepository = new UserStatsRepository(); + this.badgeService = new BadgeService(); } - public NewsQuizService(NewsArticleRepository articleRepository, NewsQuizRepository quizRepository) { + public NewsQuizService(NewsArticleRepository articleRepository, NewsQuizRepository quizRepository, + UserStatsRepository userStatsRepository, BadgeService badgeService) { this.articleRepository = articleRepository; this.quizRepository = quizRepository; + this.userStatsRepository = userStatsRepository; + this.badgeService = badgeService; } /** @@ -158,12 +169,29 @@ public QuizSubmitResult submitQuiz(String userId, String articleId, List newBadges = new ArrayList<>(); + try { + boolean isPerfect = score == 100; + UserStats updatedStats = userStatsRepository.incrementNewsQuizStats(userId, isPerfect); + if (updatedStats != null) { + newBadges = badgeService.checkAndAwardBadges(userId, updatedStats); + if (!newBadges.isEmpty()) { + logger.info("새 배지 획득: userId={}, badges={}", userId, + newBadges.stream().map(UserBadge::getBadgeType).toList()); + } + } + } catch (Exception e) { + logger.error("통계/배지 업데이트 실패: userId={}, error={}", userId, e.getMessage()); + } + return QuizSubmitResult.builder() .score(score) .earnedPoints(earnedPoints) .totalPoints(totalPoints) .results(answerResults) .feedback(feedback) + .newBadges(newBadges) .build(); } @@ -259,5 +287,6 @@ public static class QuizSubmitResult { private int totalPoints; private List results; private String feedback; + private List newBadges; } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsWordService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsWordService.java index 6c3c23ec..6881c7ec 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsWordService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsWordService.java @@ -1,10 +1,14 @@ package com.mzc.secondproject.serverless.domain.news.service; +import com.mzc.secondproject.serverless.domain.badge.model.UserBadge; +import com.mzc.secondproject.serverless.domain.badge.service.BadgeService; import com.mzc.secondproject.serverless.domain.news.constants.NewsKey; import com.mzc.secondproject.serverless.domain.news.model.NewsArticle; import com.mzc.secondproject.serverless.domain.news.model.NewsWordCollect; import com.mzc.secondproject.serverless.domain.news.repository.NewsArticleRepository; import com.mzc.secondproject.serverless.domain.news.repository.NewsWordRepository; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; +import com.mzc.secondproject.serverless.domain.stats.repository.UserStatsRepository; import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; import com.mzc.secondproject.serverless.domain.vocabulary.repository.WordRepository; import com.mzc.secondproject.serverless.domain.vocabulary.service.UserWordCommandService; @@ -12,6 +16,7 @@ import org.slf4j.LoggerFactory; import java.time.Instant; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Optional; @@ -27,32 +32,42 @@ public class NewsWordService { private final NewsArticleRepository articleRepository; private final WordRepository wordRepository; private final UserWordCommandService userWordCommandService; + private final UserStatsRepository userStatsRepository; + private final BadgeService badgeService; public NewsWordService() { this.newsWordRepository = new NewsWordRepository(); this.articleRepository = new NewsArticleRepository(); this.wordRepository = new WordRepository(); this.userWordCommandService = new UserWordCommandService(); + this.userStatsRepository = new UserStatsRepository(); + this.badgeService = new BadgeService(); } public NewsWordService(NewsWordRepository newsWordRepository, NewsArticleRepository articleRepository, WordRepository wordRepository, - UserWordCommandService userWordCommandService) { + UserWordCommandService userWordCommandService, + UserStatsRepository userStatsRepository, + BadgeService badgeService) { this.newsWordRepository = newsWordRepository; this.articleRepository = articleRepository; this.wordRepository = wordRepository; this.userWordCommandService = userWordCommandService; + this.userStatsRepository = userStatsRepository; + this.badgeService = badgeService; } /** * 단어 수집 + * @return 수집 결과 (단어 정보 + 새로 획득한 배지) */ - public NewsWordCollect collectWord(String userId, String articleId, String word, String context) { + public WordCollectResult collectWord(String userId, String articleId, String word, String context) { // 이미 수집했는지 확인 if (newsWordRepository.hasCollected(userId, word, articleId)) { logger.warn("이미 수집한 단어: userId={}, word={}", userId, word); - return newsWordRepository.findByUserWordArticle(userId, word, articleId).orElse(null); + NewsWordCollect existing = newsWordRepository.findByUserWordArticle(userId, word, articleId).orElse(null); + return new WordCollectResult(existing, new ArrayList<>()); } // 기사 조회 @@ -86,9 +101,29 @@ public NewsWordCollect collectWord(String userId, String articleId, String word, newsWordRepository.save(wordCollect); logger.info("단어 수집 완료: userId={}, word={}, articleId={}", userId, word, articleId); - return wordCollect; + // 통계 업데이트 및 배지 체크 + List newBadges = new ArrayList<>(); + try { + UserStats updatedStats = userStatsRepository.incrementNewsWordStats(userId, 1); + if (updatedStats != null) { + newBadges = badgeService.checkAndAwardBadges(userId, updatedStats); + if (!newBadges.isEmpty()) { + logger.info("새 배지 획득: userId={}, badges={}", userId, + newBadges.stream().map(UserBadge::getBadgeType).toList()); + } + } + } catch (Exception e) { + logger.error("통계/배지 업데이트 실패: userId={}, error={}", userId, e.getMessage()); + } + + return new WordCollectResult(wordCollect, newBadges); } + /** + * 단어 수집 결과 + */ + public record WordCollectResult(NewsWordCollect wordCollect, List newBadges) {} + /** * 수집한 단어 삭제 */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/model/UserStats.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/model/UserStats.java index cc25634c..4905a9f2 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/model/UserStats.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/model/UserStats.java @@ -56,7 +56,15 @@ public class UserStats { private Integer totalGameScore; // 누적 게임 점수 private Integer quickGuesses; // 5초 내 정답 횟수 private Integer perfectDraws; // 전원 정답 유도 횟수 - + + // 뉴스 통계 + private Integer newsRead; // 읽은 뉴스 수 + private Integer newsQuizCompleted; // 완료한 뉴스 퀴즈 수 + private Integer newsQuizPerfect; // 뉴스 퀴즈 만점 횟수 + private Integer newsWordsCollected; // 뉴스에서 수집한 단어 수 + private Integer newsStreak; // 뉴스 연속 읽기 일수 + private String lastNewsReadDate; // 마지막 뉴스 읽은 날짜 + // 메타데이터 private String createdAt; private String updatedAt; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java index b3ad20d8..2c49d4c7 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java @@ -310,6 +310,140 @@ public void incrementGameStats(String userId, int gamesPlayed, int gamesWon, userId, gamesPlayed, gamesWon, correctGuesses); } + /** + * 뉴스 읽기 통계 Atomic 업데이트 + */ + public UserStats incrementNewsReadStats(String userId) { + String today = LocalDate.now().toString(); + String pk = StatsKey.userStatsPk(userId); + String sk = StatsKey.statsTotalSk(); + String now = Instant.now().toString(); + + // 먼저 현재 통계 조회 (streak 계산용) + UserStats currentStats = findTotalStats(userId).orElse(null); + String lastNewsReadDate = currentStats != null ? currentStats.getLastNewsReadDate() : null; + + // 연속 읽기 계산 + int currentStreak = 1; + if (lastNewsReadDate != null) { + LocalDate lastDate = LocalDate.parse(lastNewsReadDate); + LocalDate todayDate = LocalDate.now(); + if (lastDate.equals(todayDate.minusDays(1))) { + // 어제 읽었으면 streak 증가 + currentStreak = (currentStats.getNewsStreak() != null ? currentStats.getNewsStreak() : 0) + 1; + } else if (lastDate.equals(todayDate)) { + // 오늘 이미 읽었으면 streak 유지 + currentStreak = currentStats.getNewsStreak() != null ? currentStats.getNewsStreak() : 1; + } + // 그 외의 경우는 streak 1로 초기화 + } + + Map key = new HashMap<>(); + key.put("PK", AttributeValue.builder().s(pk).build()); + key.put("SK", AttributeValue.builder().s(sk).build()); + + Map values = new HashMap<>(); + values.put(":one", AttributeValue.builder().n("1").build()); + values.put(":zero", AttributeValue.builder().n("0").build()); + values.put(":streak", AttributeValue.builder().n(String.valueOf(currentStreak)).build()); + values.put(":today", AttributeValue.builder().s(today).build()); + values.put(":now", AttributeValue.builder().s(now).build()); + + String updateExpression = "SET " + + "newsRead = if_not_exists(newsRead, :zero) + :one, " + + "newsStreak = :streak, " + + "lastNewsReadDate = :today, " + + "updatedAt = :now, " + + "createdAt = if_not_exists(createdAt, :now)"; + + UpdateItemRequest request = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(key) + .updateExpression(updateExpression) + .expressionAttributeValues(values) + .returnValues(software.amazon.awssdk.services.dynamodb.model.ReturnValue.ALL_NEW) + .build(); + + AwsClients.dynamoDb().updateItem(request); + logger.info("Incremented news read stats: userId={}, streak={}", userId, currentStreak); + + return findTotalStats(userId).orElse(null); + } + + /** + * 뉴스 퀴즈 통계 Atomic 업데이트 + */ + public UserStats incrementNewsQuizStats(String userId, boolean isPerfect) { + String pk = StatsKey.userStatsPk(userId); + String sk = StatsKey.statsTotalSk(); + String now = Instant.now().toString(); + + Map key = new HashMap<>(); + key.put("PK", AttributeValue.builder().s(pk).build()); + key.put("SK", AttributeValue.builder().s(sk).build()); + + Map values = new HashMap<>(); + values.put(":one", AttributeValue.builder().n("1").build()); + values.put(":zero", AttributeValue.builder().n("0").build()); + values.put(":perfect", AttributeValue.builder().n(isPerfect ? "1" : "0").build()); + values.put(":now", AttributeValue.builder().s(now).build()); + + String updateExpression = "SET " + + "newsQuizCompleted = if_not_exists(newsQuizCompleted, :zero) + :one, " + + "newsQuizPerfect = if_not_exists(newsQuizPerfect, :zero) + :perfect, " + + "updatedAt = :now, " + + "createdAt = if_not_exists(createdAt, :now)"; + + UpdateItemRequest request = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(key) + .updateExpression(updateExpression) + .expressionAttributeValues(values) + .returnValues(software.amazon.awssdk.services.dynamodb.model.ReturnValue.ALL_NEW) + .build(); + + AwsClients.dynamoDb().updateItem(request); + logger.info("Incremented news quiz stats: userId={}, isPerfect={}", userId, isPerfect); + + return findTotalStats(userId).orElse(null); + } + + /** + * 뉴스 단어 수집 통계 Atomic 업데이트 + */ + public UserStats incrementNewsWordStats(String userId, int wordCount) { + String pk = StatsKey.userStatsPk(userId); + String sk = StatsKey.statsTotalSk(); + String now = Instant.now().toString(); + + Map key = new HashMap<>(); + key.put("PK", AttributeValue.builder().s(pk).build()); + key.put("SK", AttributeValue.builder().s(sk).build()); + + Map values = new HashMap<>(); + values.put(":count", AttributeValue.builder().n(String.valueOf(wordCount)).build()); + values.put(":zero", AttributeValue.builder().n("0").build()); + values.put(":now", AttributeValue.builder().s(now).build()); + + String updateExpression = "SET " + + "newsWordsCollected = if_not_exists(newsWordsCollected, :zero) + :count, " + + "updatedAt = :now, " + + "createdAt = if_not_exists(createdAt, :now)"; + + UpdateItemRequest request = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(key) + .updateExpression(updateExpression) + .expressionAttributeValues(values) + .returnValues(software.amazon.awssdk.services.dynamodb.model.ReturnValue.ALL_NEW) + .build(); + + AwsClients.dynamoDb().updateItem(request); + logger.info("Incremented news word stats: userId={}, wordCount={}", userId, wordCount); + + return findTotalStats(userId).orElse(null); + } + /** * 현재 연도-주차 반환 (예: 2026-W02) */ From a6662e0cfc46c6e12f20a89f0df285ff282160bc Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 23 Jan 2026 09:23:10 +0900 Subject: [PATCH 479/528] fix: add PATCH method to CORS AllowMethods --- ServerlessFunction/template.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index ba797ea6..3509353e 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -94,7 +94,7 @@ Resources: Name: !Sub "${AWS::StackName}-api" StageName: !Ref Environment Cors: - AllowMethods: "'GET,POST,PUT,DELETE,OPTIONS'" + AllowMethods: "'GET,POST,PUT,PATCH,DELETE,OPTIONS'" AllowHeaders: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key,X-Requested-With,Accept'" AllowOrigin: "'*'" AllowCredentials: false From ac6e311c95d2afdb8668314c6dc0a3fbbc4d5799 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 23 Jan 2026 09:35:02 +0900 Subject: [PATCH 480/528] =?UTF-8?q?test:=20BadgeType=20=EA=B0=9C=EC=88=98?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=88=98=EC=A0=95=20(15=20->?= =?UTF-8?q?=2029)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../serverless/domain/badge/enums/BadgeTypeSpec.groovy | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/badge/enums/BadgeTypeSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/badge/enums/BadgeTypeSpec.groovy index 19a64976..6fd08457 100644 --- a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/badge/enums/BadgeTypeSpec.groovy +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/badge/enums/BadgeTypeSpec.groovy @@ -111,8 +111,8 @@ class BadgeTypeSpec extends Specification { } def "모든 BadgeType 개수 확인"() { - expect: "15개의 뱃지 타입 존재" - BadgeType.values().length == 15 + expect: "29개의 뱃지 타입 존재 (기본 15 + 뉴스 14)" + BadgeType.values().length == 29 } def "모든 뱃지의 imageUrl이 S3 URL 형식"() { From e0c7651ad607f9bbe8d6e2c54d45a672fbe631c4 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 23 Jan 2026 09:42:56 +0900 Subject: [PATCH 481/528] =?UTF-8?q?fix:=20CORS=20PATCH=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ServerlessFunction/template.yaml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 7d8711f4..7211a2c2 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -94,7 +94,7 @@ Resources: Name: !Sub "${AWS::StackName}-api" StageName: !Ref Environment Cors: - AllowMethods: "'GET,POST,PUT,DELETE,OPTIONS'" + AllowMethods: "'GET,POST,PUT,PATCH,DELETE,OPTIONS'" AllowHeaders: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key,X-Requested-With,Accept'" AllowOrigin: "'*'" AllowCredentials: false @@ -105,7 +105,7 @@ Resources: Headers: Access-Control-Allow-Origin: "'*'" Access-Control-Allow-Headers: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" - Access-Control-Allow-Methods: "'GET,POST,PUT,DELETE,OPTIONS'" + Access-Control-Allow-Methods: "'GET,POST,PUT,PATCH,DELETE,OPTIONS'" ResponseTemplates: application/json: '{"message": "Unauthorized", "statusCode": 401}' ACCESS_DENIED: @@ -114,7 +114,7 @@ Resources: Headers: Access-Control-Allow-Origin: "'*'" Access-Control-Allow-Headers: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" - Access-Control-Allow-Methods: "'GET,POST,PUT,DELETE,OPTIONS'" + Access-Control-Allow-Methods: "'GET,POST,PUT,PATCH,DELETE,OPTIONS'" ResponseTemplates: application/json: '{"message": "Access Denied", "statusCode": 403}' DEFAULT_4XX: @@ -122,20 +122,20 @@ Resources: Headers: Access-Control-Allow-Origin: "'*'" Access-Control-Allow-Headers: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" - Access-Control-Allow-Methods: "'GET,POST,PUT,DELETE,OPTIONS'" + Access-Control-Allow-Methods: "'GET,POST,PUT,PATCH,DELETE,OPTIONS'" DEFAULT_5XX: ResponseParameters: Headers: Access-Control-Allow-Origin: "'*'" Access-Control-Allow-Headers: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" - Access-Control-Allow-Methods: "'GET,POST,PUT,DELETE,OPTIONS'" + Access-Control-Allow-Methods: "'GET,POST,PUT,PATCH,DELETE,OPTIONS'" EXPIRED_TOKEN: StatusCode: 401 ResponseParameters: Headers: Access-Control-Allow-Origin: "'*'" Access-Control-Allow-Headers: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" - Access-Control-Allow-Methods: "'GET,POST,PUT,DELETE,OPTIONS'" + Access-Control-Allow-Methods: "'GET,POST,PUT,PATCH,DELETE,OPTIONS'" ResponseTemplates: application/json: '{"message": "Token expired", "statusCode": 401}' Auth: From 4229984c503682e42d2f0b454a7c56063e2249e8 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 23 Jan 2026 09:47:05 +0900 Subject: [PATCH 482/528] =?UTF-8?q?docs:=20=EB=89=B4=EC=8A=A4=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=ED=94=84=EB=A1=A0=ED=8A=B8=EC=97=94=EB=93=9C=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99=20=EA=B0=80=EC=9D=B4=EB=93=9C=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/news-frontend-guide.md | 916 ++++++++++++++++++++++++++++++++++++ 1 file changed, 916 insertions(+) create mode 100644 docs/news-frontend-guide.md diff --git a/docs/news-frontend-guide.md b/docs/news-frontend-guide.md new file mode 100644 index 00000000..650c27cc --- /dev/null +++ b/docs/news-frontend-guide.md @@ -0,0 +1,916 @@ +# 뉴스 영어 학습 기능 - 프론트엔드 연동 가이드 + +## 목차 +1. [기능 개요](#기능-개요) +2. [주요 기능](#주요-기능) +3. [API 명세](#api-명세) +4. [데이터 모델](#데이터-모델) +5. [UI/UX 가이드](#uiux-가이드) +6. [화면 구성 제안](#화면-구성-제안) +7. [에러 코드](#에러-코드) + +--- + +## 기능 개요 + +### 프로젝트 소개 +**뉴스 영어 학습**은 실제 영어 뉴스를 활용한 맞춤형 영어 학습 서비스입니다. + +- **실시간 뉴스 수집**: BBC, VOA 등 해외 뉴스 매체에서 매일 새로운 기사 자동 수집 +- **AI 분석**: Amazon Bedrock을 활용한 난이도 분석, 키워드 추출, 퀴즈 생성 +- **맞춤형 학습**: 사용자 레벨(BEGINNER/INTERMEDIATE/ADVANCED)에 맞는 뉴스 추천 +- **TTS 지원**: Amazon Polly를 통한 원어민 발음 듣기 +- **뱃지 시스템**: 학습 활동에 따른 뱃지 획득 + +### 기술 스택 +- Backend: AWS Lambda (Java 21), DynamoDB +- AI: Amazon Bedrock (Claude) +- TTS: Amazon Polly +- 뉴스 수집: EventBridge 스케줄러 (매일 자동 수집) + +--- + +## 주요 기능 + +### 1. 뉴스 목록/상세 조회 +- 오늘의 뉴스 목록 +- 난이도별/카테고리별 필터링 +- 사용자 레벨 맞춤 추천 +- 무한 스크롤 페이지네이션 + +### 2. 뉴스 학습 +- 기사 읽기 완료 기록 +- 북마크 기능 +- TTS 오디오 재생 + +### 3. 뉴스 퀴즈 +- 기사별 5문제 자동 생성 퀴즈 +- 퀴즈 유형: 독해력(COMPREHENSION), 단어 매칭(WORD_MATCH), 빈칸 채우기(FILL_BLANK) +- 점수 및 기록 관리 + +### 4. 단어 수집 +- 기사 내 단어 수집 +- 문맥과 함께 저장 +- Vocabulary 시스템 연동 + +### 5. 학습 통계 +- 읽은 기사 수 +- 퀴즈 완료/정답률 +- 수집 단어 수 +- 연속 학습 일수 (스트릭) + +### 6. 뱃지 시스템 +14가지 뉴스 관련 뱃지: +- 읽기: 첫 읽기, 10개, 50개, 100개 기사 읽기 +- 퀴즈: 첫 퀴즈, 만점, 10회, 50회 완료 +- 단어: 10개, 50개, 100개 수집 +- 스트릭: 7일, 30일 연속 학습 +- 마스터: 전체 뉴스 기능 마스터 + +--- + +## API 명세 + +### Base URL +``` +Test: https://xgepjbg2c9.execute-api.ap-northeast-2.amazonaws.com/test +Prod: https://xgepjbg2c9.execute-api.ap-northeast-2.amazonaws.com/prod +``` + +### 인증 +모든 API는 Cognito JWT 토큰 필요 +``` +Authorization: Bearer {accessToken} +``` + +--- + +### 1. 뉴스 목록 조회 + +#### GET /news +뉴스 목록 조회 (필터링 지원) + +**Query Parameters:** +| 파라미터 | 타입 | 필수 | 설명 | +|---------|------|------|------| +| level | string | X | BEGINNER, INTERMEDIATE, ADVANCED | +| category | string | X | TECH, BUSINESS, SPORTS, ENTERTAINMENT, WORLD, CULTURE, SCIENCE | +| limit | number | X | 조회 개수 (기본 10, 최대 50) | +| cursor | string | X | 페이지네이션 커서 | + +**Response:** +```json +{ + "statusCode": 200, + "message": "뉴스 목록 조회 성공", + "data": { + "articles": [ + { + "articleId": "1eb9b924", + "title": "EU suspends approval of US trade deal", + "summary": "The move follows renewed tensions...", + "source": "BBC", + "imageUrl": "https://ichef.bbci.co.uk/...", + "category": "WORLD", + "level": "INTERMEDIATE", + "cefrLevel": "B1", + "publishedAt": "2026-01-21T23:41:10Z", + "readCount": 150, + "keywords": [ + { "word": "suspend", "meaning": "중단하다", "level": "INTERMEDIATE" }, + { "word": "tension", "meaning": "긴장", "level": "BEGINNER" } + ] + } + ], + "nextCursor": "eyJQSyI6Ik5FV1MjMjAyNi0wMS0yMiIsIlNLIjoiQVJUSUNMRSMxZWI5YjkyNCJ9", + "hasMore": true, + "count": 10 + } +} +``` + +--- + +#### GET /news/today +오늘의 뉴스 조회 + +**Query Parameters:** limit, cursor + +--- + +#### GET /news/recommended +사용자 레벨 맞춤 뉴스 추천 + +**Query Parameters:** limit, cursor + +--- + +### 2. 뉴스 상세 조회 + +#### GET /news/{articleId} +기사 상세 정보 조회 + +**Response:** +```json +{ + "statusCode": 200, + "message": "뉴스 조회 성공", + "data": { + "articleId": "1eb9b924", + "title": "EU suspends approval of US trade deal", + "summary": "The move follows renewed tensions between the US and EU...", + "originalUrl": "https://www.bbc.com/news/articles/...", + "source": "BBC", + "imageUrl": "https://ichef.bbci.co.uk/...", + "category": "WORLD", + "level": "INTERMEDIATE", + "cefrLevel": "B1", + "keywords": [ + { + "word": "suspend", + "meaning": "중단하다", + "level": "INTERMEDIATE", + "position": 1 + } + ], + "highlightWords": ["diplomatic", "negotiate", "tariff"], + "quiz": [ + { + "questionId": "q1", + "type": "COMPREHENSION", + "question": "What is the main reason for EU's decision?", + "options": ["Trade tensions", "Climate change", "Immigration", "Technology"], + "points": 20 + } + ], + "publishedAt": "2026-01-21T23:41:10Z", + "readCount": 150 + } +} +``` + +--- + +### 3. 학습 기록 + +#### POST /news/{articleId}/read +읽기 완료 기록 + +**Response:** +```json +{ + "statusCode": 200, + "message": "읽기 완료 기록 성공", + "data": { + "articleId": "1eb9b924", + "newBadges": [ + { + "type": "NEWS_FIRST_READ", + "name": "뉴스 첫 발걸음", + "description": "첫 번째 뉴스 읽기 완료", + "imageUrl": "https://..." + } + ] + } +} +``` + +--- + +#### POST /news/{articleId}/bookmark +북마크 토글 + +**Response:** +```json +{ + "statusCode": 200, + "message": "북마크 추가 성공", + "data": { + "articleId": "1eb9b924", + "bookmarked": true + } +} +``` + +--- + +#### GET /news/bookmarks +북마크 목록 조회 + +**Query Parameters:** limit + +**Response:** +```json +{ + "statusCode": 200, + "message": "북마크 목록 조회 성공", + "data": { + "bookmarks": [ + { + "articleId": "1eb9b924", + "articleTitle": "EU suspends approval...", + "articleLevel": "INTERMEDIATE", + "articleCategory": "WORLD", + "createdAt": "2026-01-22T10:30:00Z" + } + ], + "count": 5 + } +} +``` + +--- + +### 4. TTS 오디오 + +#### GET /news/{articleId}/audio +기사 TTS 오디오 URL 조회 + +**Query Parameters:** +| 파라미터 | 타입 | 필수 | 설명 | +|---------|------|------|------| +| voice | string | X | Polly 음성 (기본: Joanna) | + +**사용 가능한 음성:** +- 미국: Joanna (여), Matthew (남), Ivy (아동) +- 영국: Amy (여), Brian (남) + +**Response:** +```json +{ + "statusCode": 200, + "message": "TTS 오디오 URL 조회 성공", + "data": { + "audioUrl": "https://s3.ap-northeast-2.amazonaws.com/..." + } +} +``` + +--- + +### 5. 퀴즈 + +#### GET /news/{articleId}/quiz +퀴즈 문제 조회 + +**Response:** +```json +{ + "statusCode": 200, + "message": "퀴즈 조회 성공", + "data": { + "articleId": "1eb9b924", + "articleTitle": "EU suspends approval...", + "questions": [ + { + "questionId": "q1", + "type": "COMPREHENSION", + "question": "What is the main reason for EU's decision?", + "options": ["Trade tensions", "Climate change", "Immigration", "Technology"], + "points": 20 + }, + { + "questionId": "q2", + "type": "WORD_MATCH", + "question": "Select the correct meaning of 'suspend'", + "options": ["시작하다", "중단하다", "계속하다", "완료하다"], + "points": 20 + }, + { + "questionId": "q3", + "type": "FILL_BLANK", + "question": "The EU _____ the approval of the trade deal.", + "options": ["suspended", "continued", "started", "finished"], + "points": 20 + } + ], + "totalPoints": 100, + "previousAttempt": null + } +} +``` + +--- + +#### POST /news/{articleId}/quiz +퀴즈 제출 + +**Request Body:** +```json +{ + "answers": [ + { "questionId": "q1", "answer": "Trade tensions" }, + { "questionId": "q2", "answer": "중단하다" }, + { "questionId": "q3", "answer": "suspended" } + ], + "timeTaken": 120 +} +``` + +**Response:** +```json +{ + "statusCode": 200, + "message": "퀴즈 제출 성공", + "data": { + "score": 80, + "totalPoints": 100, + "earnedPoints": 80, + "results": [ + { "questionId": "q1", "correct": true, "correctAnswer": "Trade tensions" }, + { "questionId": "q2", "correct": true, "correctAnswer": "중단하다" }, + { "questionId": "q3", "correct": false, "correctAnswer": "suspended", "userAnswer": "continued" } + ], + "newBadges": [ + { + "type": "NEWS_QUIZ_FIRST", + "name": "퀴즈 도전자", + "description": "첫 뉴스 퀴즈 완료" + } + ] + } +} +``` + +--- + +#### GET /news/quiz/history +퀴즈 기록 조회 + +**Response:** +```json +{ + "statusCode": 200, + "message": "퀴즈 기록 조회 성공", + "data": { + "history": [ + { + "articleId": "1eb9b924", + "articleTitle": "EU suspends approval...", + "score": 80, + "totalPoints": 100, + "submittedAt": "2026-01-22T11:00:00Z" + } + ], + "stats": { + "totalQuizzes": 15, + "averageScore": 78, + "perfectScores": 3 + }, + "count": 10 + } +} +``` + +--- + +### 6. 단어 수집 + +#### POST /news/{articleId}/words +단어 수집 + +**Request Body:** +```json +{ + "word": "suspend", + "context": "The EU suspended the approval of the trade deal." +} +``` + +**Response:** +```json +{ + "statusCode": 200, + "message": "단어 수집 성공", + "data": { + "wordCollect": { + "word": "suspend", + "meaning": "중단하다", + "pronunciation": "/səˈspend/", + "context": "The EU suspended the approval of the trade deal.", + "articleId": "1eb9b924", + "articleTitle": "EU suspends approval...", + "collectedAt": "2026-01-22T11:30:00Z" + }, + "newBadges": [] + } +} +``` + +--- + +#### GET /news/words +수집 단어 목록 조회 + +**Response:** +```json +{ + "statusCode": 200, + "message": "수집 단어 목록 조회 성공", + "data": { + "words": [ + { + "word": "suspend", + "meaning": "중단하다", + "pronunciation": "/səˈspend/", + "context": "The EU suspended...", + "articleTitle": "EU suspends...", + "collectedAt": "2026-01-22T11:30:00Z", + "syncedToVocab": false + } + ], + "stats": { + "totalWords": 25, + "syncedToVocab": 10 + }, + "count": 25 + } +} +``` + +--- + +#### DELETE /news/{articleId}/words/{word} +수집 단어 삭제 + +--- + +#### POST /news/words/{word}/sync +Vocabulary 연동 + +**Request Body:** +```json +{ + "articleId": "1eb9b924" +} +``` + +**Response:** +```json +{ + "statusCode": 200, + "message": "Vocabulary 연동 성공", + "data": { + "word": "suspend", + "synced": true + } +} +``` + +--- + +### 7. 학습 통계 + +#### GET /news/stats +학습 통계 조회 + +**Response:** +```json +{ + "statusCode": 200, + "message": "뉴스 학습 통계 조회 성공", + "data": { + "totalRead": 45, + "todayRead": 3, + "totalQuizzes": 30, + "averageQuizScore": 78, + "perfectQuizzes": 5, + "totalWordsCollected": 125, + "currentStreak": 7, + "longestStreak": 14, + "bookmarkCount": 12, + "lastReadDate": "2026-01-22" + } +} +``` + +--- + +## 데이터 모델 + +### NewsArticle (뉴스 기사) +| 필드 | 타입 | 설명 | +|-----|------|------| +| articleId | string | 기사 고유 ID | +| title | string | 제목 | +| summary | string | AI 생성 3줄 요약 | +| originalUrl | string | 원문 링크 | +| source | string | 출처 (BBC, VOA 등) | +| imageUrl | string | 썸네일 이미지 | +| category | string | 카테고리 | +| level | string | 난이도 | +| cefrLevel | string | CEFR 레벨 (A1-C2) | +| keywords | KeywordInfo[] | 핵심 단어 | +| highlightWords | string[] | 강조 단어 | +| quiz | QuizQuestion[] | 퀴즈 문제 | +| publishedAt | string | 발행일 | +| readCount | number | 조회수 | + +### KeywordInfo (키워드 정보) +| 필드 | 타입 | 설명 | +|-----|------|------| +| word | string | 영어 단어 | +| meaning | string | 한국어 뜻 | +| level | string | 난이도 | +| position | number | 기사 내 위치 | + +### QuizQuestion (퀴즈 문제) +| 필드 | 타입 | 설명 | +|-----|------|------| +| questionId | string | 문제 ID | +| type | string | COMPREHENSION, WORD_MATCH, FILL_BLANK | +| question | string | 문제 내용 | +| options | string[] | 선택지 | +| points | number | 배점 | + +### NewsWordCollect (수집 단어) +| 필드 | 타입 | 설명 | +|-----|------|------| +| word | string | 단어 | +| meaning | string | 뜻 | +| pronunciation | string | 발음 기호 | +| context | string | 문맥 문장 | +| articleId | string | 출처 기사 ID | +| articleTitle | string | 출처 기사 제목 | +| syncedToVocab | boolean | Vocabulary 연동 여부 | + +--- + +## UI/UX 가이드 + +### 1. 색상 팔레트 제안 + +#### 난이도별 색상 +```css +/* BEGINNER - 녹색 계열 (쉬움) */ +--level-beginner: #10B981; +--level-beginner-bg: #D1FAE5; + +/* INTERMEDIATE - 파란 계열 (보통) */ +--level-intermediate: #3B82F6; +--level-intermediate-bg: #DBEAFE; + +/* ADVANCED - 보라 계열 (어려움) */ +--level-advanced: #8B5CF6; +--level-advanced-bg: #EDE9FE; +``` + +#### 카테고리별 색상 +```css +--category-tech: #6366F1; /* 기술 */ +--category-business: #F59E0B; /* 비즈니스 */ +--category-sports: #EF4444; /* 스포츠 */ +--category-entertainment: #EC4899; /* 엔터테인먼트 */ +--category-world: #14B8A6; /* 세계 */ +--category-culture: #F97316; /* 문화 */ +--category-science: #06B6D4; /* 과학 */ +``` + +### 2. 아이콘 가이드 + +| 기능 | 추천 아이콘 | +|-----|-----------| +| 뉴스 | newspaper, article | +| 읽기 완료 | check-circle, book-open | +| 북마크 | bookmark, heart | +| 오디오 | volume-2, headphones | +| 퀴즈 | help-circle, clipboard-check | +| 단어 수집 | plus-circle, collection | +| 통계 | bar-chart, trending-up | +| 뱃지 | award, medal | + +### 3. 애니메이션 제안 + +```css +/* 뱃지 획득 애니메이션 */ +@keyframes badge-unlock { + 0% { transform: scale(0) rotate(-180deg); opacity: 0; } + 50% { transform: scale(1.2) rotate(10deg); } + 100% { transform: scale(1) rotate(0); opacity: 1; } +} + +/* 단어 수집 애니메이션 */ +@keyframes word-collect { + 0% { transform: translateY(0); } + 50% { transform: translateY(-10px); } + 100% { transform: translateY(0); } +} + +/* 퀴즈 정답 피드백 */ +@keyframes correct-answer { + 0%, 100% { background-color: transparent; } + 50% { background-color: rgba(16, 185, 129, 0.2); } +} +``` + +--- + +## 화면 구성 제안 + +### 1. 뉴스 목록 화면 (NewsListPage) + +``` +┌─────────────────────────────────────┐ +│ [필터] 레벨 ▼ 카테고리 ▼ [검색] │ +├─────────────────────────────────────┤ +│ ┌─────────────────────────────┐ │ +│ │ [이미지] │ │ +│ │ ─────────────────────────── │ │ +│ │ [TECH] [INTERMEDIATE] │ │ +│ │ EU suspends approval of... │ │ +│ │ BBC • 2시간 전 • 👁 150 │ │ +│ │ [📖 읽기] [🔖 저장] │ │ +│ └─────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────┐ │ +│ │ [다음 카드...] │ │ +│ └─────────────────────────────┘ │ +│ │ +│ [더 보기...] │ +└─────────────────────────────────────┘ +``` + +**구현 포인트:** +- 무한 스크롤 또는 "더 보기" 버튼 +- 카드 형태로 이미지, 제목, 메타정보 표시 +- 난이도 뱃지 색상으로 직관적 구분 +- 읽은 기사는 시각적으로 구분 (예: 투명도) + +--- + +### 2. 뉴스 상세 화면 (NewsDetailPage) + +``` +┌─────────────────────────────────────┐ +│ [← 뒤로] [🔖] [🔊] │ +├─────────────────────────────────────┤ +│ │ +│ [WORLD] [B1 - INTERMEDIATE] │ +│ │ +│ EU suspends approval of │ +│ US trade deal │ +│ │ +│ BBC • 2026.01.21 │ +│ │ +│ ┌─────────────────────────────┐ │ +│ │ [기사 이미지] │ │ +│ └─────────────────────────────┘ │ +│ │ +│ 📝 요약 │ +│ The move follows renewed tensions │ +│ between the US and EU, as Donald │ +│ Trump pushes to acquire Greenland. │ +│ │ +│ ───────────────────────────────── │ +│ │ +│ 📚 핵심 단어 │ +│ ┌───────┬───────┬───────┐ │ +│ │suspend│tension│acquire│ │ +│ │중단하다│ 긴장 │획득하다│ │ +│ │ [+] │ [+] │ [+] │ │ +│ └───────┴───────┴───────┘ │ +│ │ +│ ───────────────────────────────── │ +│ │ +│ [📝 퀴즈 풀기] [📰 원문 보기] │ +│ │ +│ [✓ 읽기 완료] │ +│ │ +└─────────────────────────────────────┘ +``` + +**구현 포인트:** +- 상단에 북마크, 오디오 버튼 고정 +- 핵심 단어는 탭으로 수집 가능 +- 어려운 단어(highlightWords)는 형광펜 효과 +- 하단에 퀴즈, 원문 링크 버튼 + +--- + +### 3. 퀴즈 화면 (QuizPage) + +``` +┌─────────────────────────────────────┐ +│ [← 종료] 1/5 ⏱ 01:30 │ +├─────────────────────────────────────┤ +│ │ +│ ████████░░░░░░░░░░░░ 20/100점 │ +│ │ +│ ───────────────────────────────── │ +│ │ +│ Q1. 독해력 문제 │ +│ │ +│ What is the main reason for │ +│ EU's decision? │ +│ │ +│ ┌─────────────────────────────┐ │ +│ │ A. Trade tensions │ │ +│ └─────────────────────────────┘ │ +│ ┌─────────────────────────────┐ │ +│ │ B. Climate change │ │ +│ └─────────────────────────────┘ │ +│ ┌─────────────────────────────┐ │ +│ │ C. Immigration │ │ +│ └─────────────────────────────┘ │ +│ ┌─────────────────────────────┐ │ +│ │ D. Technology │ │ +│ └─────────────────────────────┘ │ +│ │ +│ [다음 →] │ +│ │ +└─────────────────────────────────────┘ +``` + +**구현 포인트:** +- 상단 진행률 표시 (문제 번호, 타이머) +- 점수 프로그레스바 +- 선택지 터치 영역 충분히 크게 +- 정답/오답 즉시 피드백 (색상 변경) + +--- + +### 4. 퀴즈 결과 화면 (QuizResultPage) + +``` +┌─────────────────────────────────────┐ +│ │ +│ 🎉 퀴즈 완료! │ +│ │ +│ ┌───────────────┐ │ +│ │ │ │ +│ │ 80 │ │ +│ │ /100 │ │ +│ │ │ │ +│ └───────────────┘ │ +│ │ +│ 정답 4개 / 오답 1개 │ +│ 소요시간: 2분 30초 │ +│ │ +│ ───────────────────────────────── │ +│ │ +│ 🏆 새로운 뱃지 획득! │ +│ ┌─────────────────────────────┐ │ +│ │ [뱃지 이미지] │ │ +│ │ 퀴즈 도전자 │ │ +│ │ 첫 뉴스 퀴즈 완료! │ │ +│ └─────────────────────────────┘ │ +│ │ +│ ───────────────────────────────── │ +│ │ +│ 📋 문제별 결과 │ +│ Q1. ✅ Trade tensions │ +│ Q2. ✅ 중단하다 │ +│ Q3. ❌ continued → suspended │ +│ ... │ +│ │ +│ [다시 풀기] [목록으로] │ +│ │ +└─────────────────────────────────────┘ +``` + +--- + +### 5. 단어장 화면 (WordCollectionPage) + +``` +┌─────────────────────────────────────┐ +│ 수집한 단어 25개 │ +├─────────────────────────────────────┤ +│ [전체] [미연동] [연동완료] │ +├─────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────────┐ │ +│ │ suspend /səˈspend/ │ │ +│ │ 중단하다 │ │ +│ │ ─────────────────────────── │ │ +│ │ "The EU suspended the..." │ │ +│ │ 📰 EU suspends approval... │ │ +│ │ ─────────────────────────── │ │ +│ │ [🔗 Vocab 연동] [🗑 삭제] │ │ +│ └─────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────┐ │ +│ │ tension /ˈtenʃən/ │ │ +│ │ 긴장 │ │ +│ │ ✅ Vocabulary 연동됨 │ │ +│ └─────────────────────────────┘ │ +│ │ +└─────────────────────────────────────┘ +``` + +--- + +### 6. 학습 통계 화면 (StatsPage) + +``` +┌─────────────────────────────────────┐ +│ 📊 나의 뉴스 학습 │ +├─────────────────────────────────────┤ +│ │ +│ 🔥 7일 연속 학습 중! │ +│ │ +│ ┌─────────┬─────────┬─────────┐ │ +│ │ 읽기 │ 퀴즈 │ 단어 │ │ +│ │ 45 │ 30 │ 125 │ │ +│ │ 개 │ 회 │ 개 │ │ +│ └─────────┴─────────┴─────────┘ │ +│ │ +│ ───────────────────────────────── │ +│ │ +│ 📈 이번 주 활동 │ +│ ┌─────────────────────────────┐ │ +│ │ [주간 차트 - 막대그래프] │ │ +│ │ 월 화 수 목 금 토 일 │ │ +│ └─────────────────────────────┘ │ +│ │ +│ ───────────────────────────────── │ +│ │ +│ 🏆 획득한 뱃지 (5/14) │ +│ [🥇] [🥇] [🥇] [🥇] [🥇] │ +│ [🔒] [🔒] [🔒] [🔒] [🔒] │ +│ ... │ +│ │ +└─────────────────────────────────────┘ +``` + +--- + +## 에러 코드 + +| 코드 | 메시지 | 설명 | +|-----|--------|------| +| NEWS_001 | 기사를 찾을 수 없습니다 | articleId가 유효하지 않음 | +| NEWS_002 | 인증이 필요합니다 | JWT 토큰 없음/만료 | +| NEWS_003 | 퀴즈를 찾을 수 없습니다 | 해당 기사에 퀴즈 없음 | +| NEWS_004 | 이미 퀴즈를 제출했습니다 | 중복 제출 시도 | +| NEWS_005 | 이미 수집한 단어입니다 | 중복 수집 시도 | +| NEWS_006 | 수집하지 않은 단어입니다 | 존재하지 않는 단어 조회 | + +--- + +## 추가 구현 고려사항 + +### 1. 오프라인 지원 +- 읽은 기사 로컬 캐싱 +- 수집 단어 오프라인 저장 후 동기화 + +### 2. 푸시 알림 +- 새 뉴스 알림 +- 학습 리마인더 +- 스트릭 유지 알림 + +### 3. 공유 기능 +- 기사 공유 +- 퀴즈 점수 공유 +- 뱃지 획득 공유 + +### 4. 접근성 +- 스크린 리더 지원 +- 폰트 크기 조절 +- 고대비 모드 + +--- + +## 문의 + +백엔드 관련 문의는 이슈로 등록해주세요. From 0b1fb4ca1001206db21c6310527160830eeabb7a Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 23 Jan 2026 10:04:06 +0900 Subject: [PATCH 483/528] =?UTF-8?q?fix:=20NewsCollectionFunction=EC=97=90?= =?UTF-8?q?=20Bedrock,=20Comprehend=20=EA=B6=8C=ED=95=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ServerlessFunction/template.yaml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 7211a2c2..16f1e3dd 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -1784,6 +1784,16 @@ Resources: Policies: - DynamoDBCrudPolicy: TableName: !Ref NewsTable + - Statement: + - Effect: Allow + Action: + - bedrock:InvokeModel + Resource: "*" + - Statement: + - Effect: Allow + Action: + - comprehend:DetectKeyPhrases + Resource: "*" Events: DailySchedule: Type: Schedule From ee6bab9be62ff3446c1d241d7d4615661c40e762 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 23 Jan 2026 10:21:35 +0900 Subject: [PATCH 484/528] fix: add null check for collectWord request body - Add INVALID_REQUEST error code to NewsErrorCode - Check body and word field before accessing in collectWord() - Prevents NullPointerException when request body is malformed --- .../serverless/domain/news/exception/NewsErrorCode.java | 3 +++ .../serverless/domain/news/handler/NewsHandler.java | 6 +++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/exception/NewsErrorCode.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/exception/NewsErrorCode.java index 58197f0e..cb253701 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/exception/NewsErrorCode.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/exception/NewsErrorCode.java @@ -8,6 +8,9 @@ */ public enum NewsErrorCode implements DomainErrorCode { + // 일반 에러 + INVALID_REQUEST("COMMON_001", "유효하지 않은 요청입니다", 400), + // 인증 관련 에러 UNAUTHORIZED("AUTH_001", "인증이 필요합니다", 401), diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java index a427051d..999bb786 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java @@ -413,8 +413,12 @@ private APIGatewayProxyResponseEvent collectWord(APIGatewayProxyRequestEvent req String articleId = request.getPathParameters().get("articleId"); JsonObject body = gson.fromJson(request.getBody(), JsonObject.class); + if (body == null || !body.has("word") || body.get("word").isJsonNull()) { + return ResponseGenerator.fail(NewsErrorCode.INVALID_REQUEST); + } String word = body.get("word").getAsString(); - String context = body.has("context") ? body.get("context").getAsString() : ""; + String context = body.has("context") && !body.get("context").isJsonNull() + ? body.get("context").getAsString() : ""; NewsWordService.WordCollectResult result = wordService.collectWord(userId, articleId, word, context); From 565cdcd83fb1c8f021ea367de7b0bf2477e8b19a Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 23 Jan 2026 10:45:34 +0900 Subject: [PATCH 485/528] feat: enhance stats API and bookmark response for frontend - Add DAILY stats update for news read/quiz/word collection - Add /stats/dashboard endpoint with frontend-requested format - today: wordsLearned, newsRead, quizzesTaken, wordsTotal - overall: totalWordsLearned, totalNewsRead, averageAccuracy, streaks - weeklyProgress: last 7 days with date/wordsLearned/newsRead - Add news-related fields to all stats API responses - Fix bookmark API to include full article details (title, summary, etc.) --- .../domain/news/handler/NewsHandler.java | 2 +- .../news/service/NewsLearningService.java | 28 +++- .../stats/handler/UserStatsHandler.java | 98 ++++++++++- .../stats/repository/UserStatsRepository.java | 154 ++++++++++++++---- 4 files changed, 239 insertions(+), 43 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java index 999bb786..86a30590 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java @@ -231,7 +231,7 @@ private APIGatewayProxyResponseEvent getBookmarks(APIGatewayProxyRequestEvent re if (params == null) params = new HashMap<>(); int limit = parseLimit(params.get("limit")); - List bookmarks = learningService.getUserBookmarks(userId, limit); + List> bookmarks = learningService.getUserBookmarks(userId, limit); Map response = new HashMap<>(); response.put("bookmarks", bookmarks); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java index 3e13f2c3..c46e4fc6 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java @@ -136,10 +136,32 @@ public boolean isBookmarked(String userId, String articleId) { } /** - * 사용자 북마크 목록 조회 + * 사용자 북마크 목록 조회 (기사 정보 포함) */ - public List getUserBookmarks(String userId, int limit) { - return userNewsRepository.getUserBookmarks(userId, limit); + public List> getUserBookmarks(String userId, int limit) { + List bookmarks = userNewsRepository.getUserBookmarks(userId, limit); + List> result = new ArrayList<>(); + + for (UserNewsRecord bookmark : bookmarks) { + Optional articleOpt = articleRepository.findById(bookmark.getArticleId()); + if (articleOpt.isPresent()) { + NewsArticle article = articleOpt.get(); + Map bookmarkWithArticle = new java.util.HashMap<>(); + bookmarkWithArticle.put("articleId", article.getArticleId()); + bookmarkWithArticle.put("title", article.getTitle()); + bookmarkWithArticle.put("summary", article.getSummary()); + bookmarkWithArticle.put("source", article.getSource()); + bookmarkWithArticle.put("publishedAt", article.getPublishedAt()); + bookmarkWithArticle.put("keywords", article.getKeywords()); + bookmarkWithArticle.put("highlightWords", article.getHighlightWords()); + bookmarkWithArticle.put("category", article.getCategory()); + bookmarkWithArticle.put("level", article.getLevel()); + bookmarkWithArticle.put("imageUrl", article.getImageUrl()); + bookmarkWithArticle.put("bookmarkedAt", bookmark.getCreatedAt()); + result.add(bookmarkWithArticle); + } + } + return result; } /** diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java index 637151be..5a2ba9c0 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java @@ -49,6 +49,7 @@ public UserStatsHandler(UserStatsRepository statsRepository, DailyStudyRepositor private HandlerRouter initRouter() { return new HandlerRouter().addRoutes( + Route.getAuth("/stats/dashboard", this::getDashboardStats), Route.getAuth("/stats/daily", this::getDailyStats), Route.getAuth("/stats/weekly", this::getWeeklyStats), Route.getAuth("/stats/monthly", this::getMonthlyStats), @@ -62,7 +63,88 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent re logger.info("Received request: {} {}", request.getHttpMethod(), request.getPath()); return router.route(request); } - + + /** + * 대시보드용 통합 통계 조회 (프론트엔드 요청 형식) + * GET /stats/dashboard + */ + private APIGatewayProxyResponseEvent getDashboardStats(APIGatewayProxyRequestEvent request, String userId) { + String today = LocalDate.now().toString(); + + // 오늘 통계 조회 + Optional dailyStats = statsRepository.findDailyStats(userId, today); + // 전체 통계 조회 + Optional totalStats = statsRepository.findTotalStats(userId); + // 최근 7일 히스토리 조회 + PaginatedResult weekHistory = statsRepository.findRecentDailyStats(userId, 7, null); + // 오늘 학습 목표 조회 + Optional dailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today); + + Map response = new HashMap<>(); + + // today 섹션 + Map todaySection = new HashMap<>(); + if (dailyStats.isPresent()) { + UserStats ds = dailyStats.get(); + todaySection.put("wordsLearned", ds.getNewWordsLearned() != null ? ds.getNewWordsLearned() : 0); + todaySection.put("newsRead", ds.getNewsRead() != null ? ds.getNewsRead() : 0); + todaySection.put("quizzesTaken", (ds.getTestsCompleted() != null ? ds.getTestsCompleted() : 0) + + (ds.getNewsQuizCompleted() != null ? ds.getNewsQuizCompleted() : 0)); + } else { + todaySection.put("wordsLearned", 0); + todaySection.put("newsRead", 0); + todaySection.put("quizzesTaken", 0); + } + todaySection.put("wordsTotal", dailyStudy.map(ds -> ds.getTotalWords() != null ? ds.getTotalWords() : 25).orElse(25)); + response.put("today", todaySection); + + // overall 섹션 + Map overallSection = new HashMap<>(); + if (totalStats.isPresent()) { + UserStats ts = totalStats.get(); + overallSection.put("totalWordsLearned", ts.getNewWordsLearned() != null ? ts.getNewWordsLearned() : 0); + overallSection.put("totalNewsRead", ts.getNewsRead() != null ? ts.getNewsRead() : 0); + overallSection.put("totalQuizzes", (ts.getTestsCompleted() != null ? ts.getTestsCompleted() : 0) + + (ts.getNewsQuizCompleted() != null ? ts.getNewsQuizCompleted() : 0)); + overallSection.put("averageAccuracy", calculateSuccessRate(ts)); + overallSection.put("currentStreak", ts.getCurrentStreak() != null ? ts.getCurrentStreak() : 0); + overallSection.put("longestStreak", ts.getLongestStreak() != null ? ts.getLongestStreak() : 0); + overallSection.put("lastStudyDate", ts.getLastStudyDate()); + } else { + overallSection.put("totalWordsLearned", 0); + overallSection.put("totalNewsRead", 0); + overallSection.put("totalQuizzes", 0); + overallSection.put("averageAccuracy", 0.0); + overallSection.put("currentStreak", 0); + overallSection.put("longestStreak", 0); + overallSection.put("lastStudyDate", null); + } + // totalStudyDays 계산 (최근 히스토리에서 실제 학습한 날 수) + overallSection.put("totalStudyDays", weekHistory.items().size()); + response.put("overall", overallSection); + + // weeklyProgress 섹션 + List> weeklyProgress = weekHistory.items().stream() + .map(stats -> { + Map dayStats = new HashMap<>(); + dayStats.put("date", stats.getPeriod()); + dayStats.put("wordsLearned", stats.getNewWordsLearned() != null ? stats.getNewWordsLearned() : 0); + dayStats.put("newsRead", stats.getNewsRead() != null ? stats.getNewsRead() : 0); + return dayStats; + }) + .collect(Collectors.toList()); + response.put("weeklyProgress", weeklyProgress); + + // levelDistribution (현재 미구현 - 향후 추가 가능) + Map levelDistribution = new HashMap<>(); + levelDistribution.put("beginner", 0); + levelDistribution.put("intermediate", 0); + levelDistribution.put("advanced", 0); + response.put("levelDistribution", levelDistribution); + + return ResponseGenerator.ok("학습 통계 조회 성공", response); + } + /** * 오늘의 통계 조회 */ @@ -172,7 +254,7 @@ private Map buildStatsResponse(Optional stats, String Map response = new HashMap<>(); response.put("periodType", periodType); response.put("period", period); - + if (stats.isPresent()) { UserStats s = stats.get(); response.put("testsCompleted", s.getTestsCompleted() != null ? s.getTestsCompleted() : 0); @@ -182,6 +264,11 @@ private Map buildStatsResponse(Optional stats, String response.put("successRate", calculateSuccessRate(s)); response.put("newWordsLearned", s.getNewWordsLearned() != null ? s.getNewWordsLearned() : 0); response.put("wordsReviewed", s.getWordsReviewed() != null ? s.getWordsReviewed() : 0); + // 뉴스 관련 통계 + response.put("newsRead", s.getNewsRead() != null ? s.getNewsRead() : 0); + response.put("newsQuizCompleted", s.getNewsQuizCompleted() != null ? s.getNewsQuizCompleted() : 0); + response.put("newsQuizPerfect", s.getNewsQuizPerfect() != null ? s.getNewsQuizPerfect() : 0); + response.put("newsWordsCollected", s.getNewsWordsCollected() != null ? s.getNewsWordsCollected() : 0); } else { response.put("testsCompleted", 0); response.put("questionsAnswered", 0); @@ -190,8 +277,13 @@ private Map buildStatsResponse(Optional stats, String response.put("successRate", 0.0); response.put("newWordsLearned", 0); response.put("wordsReviewed", 0); + // 뉴스 관련 통계 + response.put("newsRead", 0); + response.put("newsQuizCompleted", 0); + response.put("newsQuizPerfect", 0); + response.put("newsWordsCollected", 0); } - + return response; } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java index 2c49d4c7..4ab46228 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java @@ -311,12 +311,11 @@ public void incrementGameStats(String userId, int gamesPlayed, int gamesWon, } /** - * 뉴스 읽기 통계 Atomic 업데이트 + * 뉴스 읽기 통계 Atomic 업데이트 (TOTAL + DAILY) */ public UserStats incrementNewsReadStats(String userId) { String today = LocalDate.now().toString(); String pk = StatsKey.userStatsPk(userId); - String sk = StatsKey.statsTotalSk(); String now = Instant.now().toString(); // 먼저 현재 통계 조회 (streak 계산용) @@ -338,10 +337,6 @@ public UserStats incrementNewsReadStats(String userId) { // 그 외의 경우는 streak 1로 초기화 } - Map key = new HashMap<>(); - key.put("PK", AttributeValue.builder().s(pk).build()); - key.put("SK", AttributeValue.builder().s(sk).build()); - Map values = new HashMap<>(); values.put(":one", AttributeValue.builder().n("1").build()); values.put(":zero", AttributeValue.builder().n("0").build()); @@ -349,97 +344,184 @@ public UserStats incrementNewsReadStats(String userId) { values.put(":today", AttributeValue.builder().s(today).build()); values.put(":now", AttributeValue.builder().s(now).build()); - String updateExpression = "SET " + + // 1. TOTAL 통계 업데이트 + Map totalKey = new HashMap<>(); + totalKey.put("PK", AttributeValue.builder().s(pk).build()); + totalKey.put("SK", AttributeValue.builder().s(StatsKey.statsTotalSk()).build()); + + String totalUpdateExpression = "SET " + "newsRead = if_not_exists(newsRead, :zero) + :one, " + "newsStreak = :streak, " + "lastNewsReadDate = :today, " + "updatedAt = :now, " + "createdAt = if_not_exists(createdAt, :now)"; - UpdateItemRequest request = UpdateItemRequest.builder() + UpdateItemRequest totalRequest = UpdateItemRequest.builder() .tableName(TABLE_NAME) - .key(key) - .updateExpression(updateExpression) + .key(totalKey) + .updateExpression(totalUpdateExpression) .expressionAttributeValues(values) .returnValues(software.amazon.awssdk.services.dynamodb.model.ReturnValue.ALL_NEW) .build(); - AwsClients.dynamoDb().updateItem(request); - logger.info("Incremented news read stats: userId={}, streak={}", userId, currentStreak); + AwsClients.dynamoDb().updateItem(totalRequest); + + // 2. DAILY 통계 업데이트 + Map dailyKey = new HashMap<>(); + dailyKey.put("PK", AttributeValue.builder().s(pk).build()); + dailyKey.put("SK", AttributeValue.builder().s(StatsKey.statsDailySk(today)).build()); + + Map dailyValues = new HashMap<>(); + dailyValues.put(":one", AttributeValue.builder().n("1").build()); + dailyValues.put(":zero", AttributeValue.builder().n("0").build()); + dailyValues.put(":now", AttributeValue.builder().s(now).build()); + dailyValues.put(":today", AttributeValue.builder().s(today).build()); + + String dailyUpdateExpression = "SET " + + "newsRead = if_not_exists(newsRead, :zero) + :one, " + + "updatedAt = :now, " + + "createdAt = if_not_exists(createdAt, :now), " + + "period = if_not_exists(period, :today)"; + + UpdateItemRequest dailyRequest = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(dailyKey) + .updateExpression(dailyUpdateExpression) + .expressionAttributeValues(dailyValues) + .build(); + + AwsClients.dynamoDb().updateItem(dailyRequest); + logger.info("Incremented news read stats (TOTAL + DAILY): userId={}, streak={}", userId, currentStreak); return findTotalStats(userId).orElse(null); } /** - * 뉴스 퀴즈 통계 Atomic 업데이트 + * 뉴스 퀴즈 통계 Atomic 업데이트 (TOTAL + DAILY) */ public UserStats incrementNewsQuizStats(String userId, boolean isPerfect) { + String today = LocalDate.now().toString(); String pk = StatsKey.userStatsPk(userId); - String sk = StatsKey.statsTotalSk(); String now = Instant.now().toString(); - Map key = new HashMap<>(); - key.put("PK", AttributeValue.builder().s(pk).build()); - key.put("SK", AttributeValue.builder().s(sk).build()); - Map values = new HashMap<>(); values.put(":one", AttributeValue.builder().n("1").build()); values.put(":zero", AttributeValue.builder().n("0").build()); values.put(":perfect", AttributeValue.builder().n(isPerfect ? "1" : "0").build()); values.put(":now", AttributeValue.builder().s(now).build()); - String updateExpression = "SET " + + // 1. TOTAL 통계 업데이트 + Map totalKey = new HashMap<>(); + totalKey.put("PK", AttributeValue.builder().s(pk).build()); + totalKey.put("SK", AttributeValue.builder().s(StatsKey.statsTotalSk()).build()); + + String totalUpdateExpression = "SET " + "newsQuizCompleted = if_not_exists(newsQuizCompleted, :zero) + :one, " + "newsQuizPerfect = if_not_exists(newsQuizPerfect, :zero) + :perfect, " + "updatedAt = :now, " + "createdAt = if_not_exists(createdAt, :now)"; - UpdateItemRequest request = UpdateItemRequest.builder() + UpdateItemRequest totalRequest = UpdateItemRequest.builder() .tableName(TABLE_NAME) - .key(key) - .updateExpression(updateExpression) + .key(totalKey) + .updateExpression(totalUpdateExpression) .expressionAttributeValues(values) .returnValues(software.amazon.awssdk.services.dynamodb.model.ReturnValue.ALL_NEW) .build(); - AwsClients.dynamoDb().updateItem(request); - logger.info("Incremented news quiz stats: userId={}, isPerfect={}", userId, isPerfect); + AwsClients.dynamoDb().updateItem(totalRequest); + + // 2. DAILY 통계 업데이트 + Map dailyKey = new HashMap<>(); + dailyKey.put("PK", AttributeValue.builder().s(pk).build()); + dailyKey.put("SK", AttributeValue.builder().s(StatsKey.statsDailySk(today)).build()); + + Map dailyValues = new HashMap<>(); + dailyValues.put(":one", AttributeValue.builder().n("1").build()); + dailyValues.put(":zero", AttributeValue.builder().n("0").build()); + dailyValues.put(":perfect", AttributeValue.builder().n(isPerfect ? "1" : "0").build()); + dailyValues.put(":now", AttributeValue.builder().s(now).build()); + dailyValues.put(":today", AttributeValue.builder().s(today).build()); + + String dailyUpdateExpression = "SET " + + "newsQuizCompleted = if_not_exists(newsQuizCompleted, :zero) + :one, " + + "newsQuizPerfect = if_not_exists(newsQuizPerfect, :zero) + :perfect, " + + "updatedAt = :now, " + + "createdAt = if_not_exists(createdAt, :now), " + + "period = if_not_exists(period, :today)"; + + UpdateItemRequest dailyRequest = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(dailyKey) + .updateExpression(dailyUpdateExpression) + .expressionAttributeValues(dailyValues) + .build(); + + AwsClients.dynamoDb().updateItem(dailyRequest); + logger.info("Incremented news quiz stats (TOTAL + DAILY): userId={}, isPerfect={}", userId, isPerfect); return findTotalStats(userId).orElse(null); } /** - * 뉴스 단어 수집 통계 Atomic 업데이트 + * 뉴스 단어 수집 통계 Atomic 업데이트 (TOTAL + DAILY) */ public UserStats incrementNewsWordStats(String userId, int wordCount) { + String today = LocalDate.now().toString(); String pk = StatsKey.userStatsPk(userId); - String sk = StatsKey.statsTotalSk(); String now = Instant.now().toString(); - Map key = new HashMap<>(); - key.put("PK", AttributeValue.builder().s(pk).build()); - key.put("SK", AttributeValue.builder().s(sk).build()); - Map values = new HashMap<>(); values.put(":count", AttributeValue.builder().n(String.valueOf(wordCount)).build()); values.put(":zero", AttributeValue.builder().n("0").build()); values.put(":now", AttributeValue.builder().s(now).build()); - String updateExpression = "SET " + + // 1. TOTAL 통계 업데이트 + Map totalKey = new HashMap<>(); + totalKey.put("PK", AttributeValue.builder().s(pk).build()); + totalKey.put("SK", AttributeValue.builder().s(StatsKey.statsTotalSk()).build()); + + String totalUpdateExpression = "SET " + "newsWordsCollected = if_not_exists(newsWordsCollected, :zero) + :count, " + "updatedAt = :now, " + "createdAt = if_not_exists(createdAt, :now)"; - UpdateItemRequest request = UpdateItemRequest.builder() + UpdateItemRequest totalRequest = UpdateItemRequest.builder() .tableName(TABLE_NAME) - .key(key) - .updateExpression(updateExpression) + .key(totalKey) + .updateExpression(totalUpdateExpression) .expressionAttributeValues(values) .returnValues(software.amazon.awssdk.services.dynamodb.model.ReturnValue.ALL_NEW) .build(); - AwsClients.dynamoDb().updateItem(request); - logger.info("Incremented news word stats: userId={}, wordCount={}", userId, wordCount); + AwsClients.dynamoDb().updateItem(totalRequest); + + // 2. DAILY 통계 업데이트 + Map dailyKey = new HashMap<>(); + dailyKey.put("PK", AttributeValue.builder().s(pk).build()); + dailyKey.put("SK", AttributeValue.builder().s(StatsKey.statsDailySk(today)).build()); + + Map dailyValues = new HashMap<>(); + dailyValues.put(":count", AttributeValue.builder().n(String.valueOf(wordCount)).build()); + dailyValues.put(":zero", AttributeValue.builder().n("0").build()); + dailyValues.put(":now", AttributeValue.builder().s(now).build()); + dailyValues.put(":today", AttributeValue.builder().s(today).build()); + + String dailyUpdateExpression = "SET " + + "newsWordsCollected = if_not_exists(newsWordsCollected, :zero) + :count, " + + "updatedAt = :now, " + + "createdAt = if_not_exists(createdAt, :now), " + + "period = if_not_exists(period, :today)"; + + UpdateItemRequest dailyRequest = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(dailyKey) + .updateExpression(dailyUpdateExpression) + .expressionAttributeValues(dailyValues) + .build(); + + AwsClients.dynamoDb().updateItem(dailyRequest); + logger.info("Incremented news word stats (TOTAL + DAILY): userId={}, wordCount={}", userId, wordCount); return findTotalStats(userId).orElse(null); } From 1a8e4f03e022b8f8f299907177b37578a4e367ff Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 23 Jan 2026 10:53:30 +0900 Subject: [PATCH 486/528] feat: add category classification to news AI analysis - Add category field to AnalysisResult record - Update Bedrock prompt to classify articles into categories (WORLD, POLITICS, BUSINESS, TECH, SCIENCE, HEALTH, SPORTS, ENTERTAINMENT, LIFESTYLE) - Parse and set category from AI response - Set GSI2 (CATEGORY#) index when category is available --- .../news/service/NewsAnalysisService.java | 17 +- docs/CATCHMIND_ARCHITECTURE_SOLUTION.md | 521 ------- docs/CICD-IMPLEMENTATION-QNA.md | 421 ------ docs/FRONTEND-API-GUIDE.md | 365 ----- docs/MIDTERM-REPORT.md | 439 ------ docs/domain-reports/BADGE-DOMAIN-REPORT.md | 681 --------- docs/domain-reports/CHATTING-DOMAIN-REPORT.md | 434 ------ docs/domain-reports/COMMON-MODULE-REPORT.md | 1228 ----------------- docs/domain-reports/GRAMMAR-DOMAIN-REPORT.md | 465 ------- docs/domain-reports/STATS-DOMAIN-REPORT.md | 379 ----- .../VOCABULARY-DOMAIN-REPORT.md | 504 ------- docs/news-frontend-guide.md | 916 ------------ 12 files changed, 12 insertions(+), 6358 deletions(-) delete mode 100644 docs/CATCHMIND_ARCHITECTURE_SOLUTION.md delete mode 100644 docs/CICD-IMPLEMENTATION-QNA.md delete mode 100644 docs/FRONTEND-API-GUIDE.md delete mode 100644 docs/MIDTERM-REPORT.md delete mode 100644 docs/domain-reports/BADGE-DOMAIN-REPORT.md delete mode 100644 docs/domain-reports/CHATTING-DOMAIN-REPORT.md delete mode 100644 docs/domain-reports/COMMON-MODULE-REPORT.md delete mode 100644 docs/domain-reports/GRAMMAR-DOMAIN-REPORT.md delete mode 100644 docs/domain-reports/STATS-DOMAIN-REPORT.md delete mode 100644 docs/domain-reports/VOCABULARY-DOMAIN-REPORT.md delete mode 100644 docs/news-frontend-guide.md diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java index af23fc5b..d09070ed 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java @@ -62,13 +62,16 @@ public NewsArticle analyzeArticle(NewsArticle article) { List keywords = extractKeywords(content); article.setKeywords(keywords); - // 3. 3줄 요약 + 퀴즈 생성 (Bedrock - 한 번에 처리) + // 3. 3줄 요약 + 퀴즈 + 카테고리 생성 (Bedrock - 한 번에 처리) AnalysisResult result = generateSummaryAndQuiz(content, cefrLevel); if (result.summary() != null) { article.setSummary(result.summary()); } article.setQuiz(result.quiz()); article.setHighlightWords(result.highlightWords()); + if (result.category() != null) { + article.setCategory(result.category()); + } // 4. GSI 키 설정 article.setGsi1pk("LEVEL#" + article.getLevel()); @@ -173,7 +176,7 @@ private List extractKeywords(String content) { } /** - * 요약 + 퀴즈 생성 (Bedrock) + * 요약 + 퀴즈 + 카테고리 생성 (Bedrock) */ private AnalysisResult generateSummaryAndQuiz(String content, String cefrLevel) { String systemPrompt = """ @@ -183,6 +186,7 @@ private AnalysisResult generateSummaryAndQuiz(String content, String cefrLevel) { "summary": "3-line summary in English (each line separated by newline)", "highlightWords": ["word1", "word2", "word3"], + "category": "WORLD", "quiz": [ { "questionId": "q1", @@ -211,6 +215,7 @@ private AnalysisResult generateSummaryAndQuiz(String content, String cefrLevel) ] } + For category, choose EXACTLY ONE from: WORLD, POLITICS, BUSINESS, TECH, SCIENCE, HEALTH, SPORTS, ENTERTAINMENT, LIFESTYLE Create exactly 3 quiz questions. highlightWords should contain 3-5 difficult words for learners. Adjust difficulty based on CEFR level: """ + cefrLevel; @@ -222,7 +227,7 @@ private AnalysisResult generateSummaryAndQuiz(String content, String cefrLevel) return parseAnalysisResult(response); } catch (Exception e) { logger.error("요약/퀴즈 생성 실패", e); - return new AnalysisResult(null, new ArrayList<>(), new ArrayList<>()); + return new AnalysisResult(null, new ArrayList<>(), new ArrayList<>(), null); } } @@ -268,6 +273,7 @@ private AnalysisResult parseAnalysisResult(String response) { JsonObject json = gson.fromJson(jsonStr, JsonObject.class); String summary = json.has("summary") ? json.get("summary").getAsString() : null; + String category = json.has("category") ? json.get("category").getAsString().toUpperCase() : "WORLD"; List highlightWords = new ArrayList<>(); if (json.has("highlightWords")) { @@ -293,7 +299,7 @@ private AnalysisResult parseAnalysisResult(String response) { }); } - return new AnalysisResult(summary, highlightWords, quiz); + return new AnalysisResult(summary, highlightWords, quiz, category); } private String extractJson(String response) { @@ -316,6 +322,7 @@ private String truncate(String text, int maxLength) { private record AnalysisResult( String summary, List highlightWords, - List quiz + List quiz, + String category ) {} } diff --git a/docs/CATCHMIND_ARCHITECTURE_SOLUTION.md b/docs/CATCHMIND_ARCHITECTURE_SOLUTION.md deleted file mode 100644 index e4c22aa4..00000000 --- a/docs/CATCHMIND_ARCHITECTURE_SOLUTION.md +++ /dev/null @@ -1,521 +0,0 @@ -# 채팅방 / 캐치마인드 게임 분리 - 종합 솔루션 - -## 1. 현재 문제점 분석 - -### 1.1 백엔드 현황 - -``` -ChatRoom.java (현재 - 혼합 모델) -├── 채팅 필드 -│ ├── roomId, name, description -│ ├── memberIds, currentMembers -│ └── lastMessageAt -│ -└── 게임 필드 (여기에 섞여있음) - ├── gameStatus, gameStartedBy - ├── currentRound, totalRounds - ├── currentDrawerId, currentWord - ├── roundStartTime, roundTimeLimit ← serverTime 없음! - ├── scores, streaks - └── correctGuessers -``` - -**문제점:** - -1. `roundStartTime`만 전송, `serverTime` 누락 → 클라이언트 타이머 동기화 불가 -2. 게임 세션이 채팅방에 종속 → 게임 상태 독립 관리 불가 -3. 재접속 시 게임 상태 복구 어려움 -4. 게임 종료 후 상태 정리 복잡 - -### 1.2 WebSocket 메시지 현황 - -```java -// WebSocketMessageHandler.java - 현재 구조 -handleRequest() { - switch (messageType) { - case "DRAWING", "DRAWING_CLEAR" -> handleDrawingMessage() // 게임 - default -> handleRegularMessage() { - // 1. 슬래시 명령어 처리 (/start, /stop, /score...) - // 2. 게임 중 정답 체크 - // 3. 일반 채팅 메시지 - } - } -} -``` - -**문제점:** - -- 채팅/게임 구분 없이 모든 메시지가 동일 핸들러에서 처리 -- 메시지에 `domain` 필드 없음 - ---- - -## 2. 최적 솔루션 - -### 2.1 아키텍처 개요 - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ WebSocket (단일 엔드포인트 유지) │ -│ │ -│ ┌──────────────────────┐ ┌────────────────────────────────┐ │ -│ │ domain: "chat" │ │ domain: "game" │ │ -│ │ │ │ │ │ -│ │ • TEXT │ │ • GAME_START / GAME_END │ │ -│ │ • USER_JOIN │ │ • ROUND_START / ROUND_END │ │ -│ │ • USER_LEAVE │ │ • DRAWING / DRAWING_CLEAR │ │ -│ │ • SYSTEM │ │ • GUESS / CORRECT_ANSWER │ │ -│ │ │ │ • SCORE_UPDATE / HINT │ │ -│ └──────────────────────┘ └────────────────────────────────┘ │ -│ │ -│ GameSession (별도 모델) │ -│ ├── gameSessionId │ -│ ├── roomId (연결용) │ -│ ├── status, currentRound │ -│ ├── roundStartTime + serverTime ← 핵심! │ -│ └── scores, players │ -└─────────────────────────────────────────────────────────────────┘ -``` - -### 2.2 핵심 변경사항 - -| 구분 | 현재 | 변경 후 | -|-----|----------------------|---------------------------------| -| 모델 | `ChatRoom`에 게임 필드 포함 | `ChatRoom` + `GameSession` 분리 | -| 타이머 | `roundStartTime`만 전송 | `roundStartTime` + `serverTime` | -| 메시지 | `messageType`만 존재 | `domain` + `messageType` | -| API | 채팅방 API만 존재 | 게임 세션 API 추가 | - ---- - -## 3. 백엔드 변경사항 - -### 3.1 Phase 1: 타이머 버그 수정 (즉시) - -**변경 파일:** `WebSocketMessageHandler.java` - -```java -// GAME_START 메시지에 serverTime 추가 -private void broadcastGameStart(...) { - Map message = new HashMap<>(); - // ... 기존 코드 ... - - message.put("roundStartTime", gameResult.room().getRoundStartTime()); - message.put("serverTime", System.currentTimeMillis()); // 추가! - message.put("roundDuration", gameResult.room().getRoundTimeLimit()); // 명확한 이름 - - // ... -} - -// ROUND_END → ROUND_START 메시지에도 동일하게 추가 -private void broadcastRoundEnd(...) { - // ... - messageData.put("roundStartTime", room.getRoundStartTime()); - messageData.put("serverTime", System.currentTimeMillis()); // 추가! - messageData.put("roundDuration", room.getRoundTimeLimit()); - // ... -} -``` - -**예상 작업량:** 30분 - -### 3.2 Phase 2: 메시지 구조 개선 (1일) - -**변경 파일:** `WebSocketMessageHandler.java`, 모든 브로드캐스트 메서드 - -```java -// 모든 메시지에 domain 필드 추가 -private Map createMessage(String domain, String messageType, Object data) { - Map message = new HashMap<>(); - message.put("domain", domain); // "chat" 또는 "game" - message.put("messageType", messageType); - message.put("data", data); - message.put("timestamp", System.currentTimeMillis()); - return message; -} - -// 채팅 메시지 -createMessage("chat", "TEXT", chatData); -createMessage("chat", "USER_JOIN", joinData); - -// 게임 메시지 -createMessage("game", "GAME_START", gameStartData); -createMessage("game", "ROUND_START", roundStartData); -createMessage("game", "DRAWING", drawingData); -``` - -### 3.3 Phase 3: 게임 세션 분리 (1주) - -#### 3.3.1 새 모델: GameSession.java - -```java -@DynamoDbBean -public class GameSession { - private String pk; // GAME#{gameSessionId} - private String sk; // METADATA - private String gsi1pk; // ROOM#{roomId} - private String gsi1sk; // GAME#{createdAt} - - // 게임 식별 - private String gameSessionId; - private String roomId; // 연결된 채팅방 - private String gameType; // "catchmind" - - // 게임 상태 - private String status; // WAITING, PLAYING, FINISHED - private String startedBy; - private Long startedAt; - private Long endedAt; - - // 라운드 정보 - private Integer currentRound; - private Integer totalRounds; - private String currentDrawerId; - private String currentWordId; - private String currentWord; - private Long roundStartTime; - private Integer roundDuration; - - // 점수 - private Map scores; - private Map streaks; - private List players; - private List drawerOrder; - - // 자동 종료 - private Long gameEndScheduledAt; - private String scheduleRuleArn; - - // TTL - private Long ttl; -} -``` - -#### 3.3.2 ChatRoom에서 게임 필드 제거 - -```java -@DynamoDbBean -public class ChatRoom { - // 채팅 필드만 유지 - private String roomId; - private String name; - private String description; - private String level; - private Integer currentMembers; - private Integer maxMembers; - private Boolean isPrivate; - private String password; - private String createdBy; - private String createdAt; - private String lastMessageAt; - private List memberIds; - - // 게임 연결 (참조만) - private String activeGameSessionId; // 현재 진행중인 게임 세션 ID - - // 게임 필드 모두 제거! - // - gameStatus, gameStartedBy, currentRound... 전부 GameSession으로 이동 -} -``` - -#### 3.3.3 게임 세션 API - -``` -# 게임 세션 생성 -POST /api/chat/rooms/{roomId}/games -Request: -{ - "gameType": "catchmind", - "settings": { - "totalRounds": 5, - "roundDuration": 60 - } -} - -Response: -{ - "gameSessionId": "game-abc123", - "roomId": "room-xyz", - "status": "WAITING", - "createdAt": "2024-01-20T10:00:00Z" -} - -# 게임 상태 조회 (재접속 시 필수!) -GET /api/games/{gameSessionId} - -Response: -{ - "gameSessionId": "game-abc123", - "roomId": "room-xyz", - "status": "PLAYING", - "currentRound": 2, - "totalRounds": 5, - "currentDrawerId": "user123", - "roundStartTime": 1705744800000, - "serverTime": 1705744830000, // 핵심! - "roundDuration": 60, - "scores": { - "user1": 150, - "user2": 120 - }, - "players": ["user1", "user2", "user3"] -} - -# 게임 시작 (기존 /start 명령어 대체) -POST /api/games/{gameSessionId}/start - -# 게임 종료 -POST /api/games/{gameSessionId}/stop -``` - ---- - -## 4. 프론트엔드 변경사항 - -### 4.1 Phase 1: 타이머 버그 수정 (즉시) - -```javascript -// useTimer.js - 독립적인 타이머 훅 -export function useTimer(roundStartTime, roundDuration, serverTime) { - const [remainingTime, setRemainingTime] = useState(roundDuration); - - useEffect(() => { - if (!roundStartTime || !roundDuration) return; - - // 서버-클라이언트 시간 차이 보정 - const timeOffset = serverTime ? (Date.now() - serverTime) : 0; - - const interval = setInterval(() => { - const adjustedNow = Date.now() - timeOffset; - const elapsed = Math.floor((adjustedNow - roundStartTime) / 1000); - const remaining = Math.max(0, roundDuration - elapsed); - setRemainingTime(remaining); - - if (remaining <= 0) { - clearInterval(interval); - } - }, 100); - - return () => clearInterval(interval); - }, [roundStartTime, roundDuration, serverTime]); - - return remainingTime; -} -``` - -### 4.2 Phase 2: 메시지 핸들러 분리 - -```javascript -// WebSocket 메시지 핸들러 -onMessage(event) { - const message = JSON.parse(event.data); - - switch (message.domain) { - case 'chat': - this.handleChatMessage(message); - break; - case 'game': - this.handleGameMessage(message); - break; - } -} - -handleChatMessage(message) { - switch (message.messageType) { - case 'TEXT': // 채팅 메시지 - case 'USER_JOIN': - case 'USER_LEAVE': - case 'SYSTEM': - } -} - -handleGameMessage(message) { - switch (message.messageType) { - case 'GAME_START': - case 'ROUND_START': - case 'DRAWING': - case 'CORRECT_ANSWER': - case 'SCORE_UPDATE': - } -} -``` - -### 4.3 Phase 3: 훅 분리 - -``` -src/domains/ -├── chat/ -│ ├── hooks/ -│ │ └── useChatWebSocket.js # 채팅만 처리 -│ └── components/ -│ ├── ChatMessages.jsx -│ └── ChatInput.jsx -│ -├── catchmind/ -│ ├── hooks/ -│ │ ├── useGameWebSocket.js # 게임만 처리 -│ │ ├── useGameState.js -│ │ └── useTimer.js -│ └── components/ -│ ├── DrawingCanvas.jsx -│ ├── ScoreBoard.jsx -│ └── Timer.jsx -│ -└── freetalk/ - └── pages/ - └── FreeTalkPage.jsx # chat + catchmind 조합 -``` - ---- - -## 5. 메시지 스펙 (최종) - -### 5.1 공통 메시지 구조 - -```json -{ - "domain": "chat" | "game", - "messageType": "...", - "data": { ... }, - "timestamp": 1705744800000 -} -``` - -### 5.2 채팅 메시지 - -| Type | 방향 | data 필드 | -|--------------|-----|-----------------------------------------------| -| `TEXT` | 양방향 | `messageId`, `userId`, `content`, `createdAt` | -| `USER_JOIN` | S→C | `userId`, `memberCount` | -| `USER_LEAVE` | S→C | `userId`, `memberCount` | -| `SYSTEM` | S→C | `content` | - -### 5.3 게임 메시지 - -| Type | 방향 | data 필드 | -|------------------|-----|---------------------------------------------------------------------------------------------------------------| -| `GAME_START` | S→C | `gameSessionId`, `totalRounds`, `currentDrawerId`, `roundStartTime`, `serverTime`, `roundDuration`, `players` | -| `GAME_END` | S→C | `gameSessionId`, `reason`, `finalScores`, `winner` | -| `ROUND_START` | S→C | `currentRound`, `currentDrawerId`, `roundStartTime`, `serverTime`, `roundDuration`, `currentWord`(출제자만) | -| `ROUND_END` | S→C | `currentRound`, `answer`, `scores` | -| `DRAWING` | 양방향 | `drawingData` | -| `DRAWING_CLEAR` | 양방향 | - | -| `GUESS` | C→S | `content` | -| `CORRECT_ANSWER` | S→C | `userId`, `score`, `elapsedTime` | -| `SCORE_UPDATE` | S→C | `scores`, `currentRound`, `totalRounds` | -| `HINT` | S→C | `hint` | - -### 5.4 ROUND_START 상세 (핵심!) - -```json -{ - "domain": "game", - "messageType": "ROUND_START", - "data": { - "gameSessionId": "game-abc123", - "currentRound": 2, - "totalRounds": 5, - "currentDrawerId": "user123", - "roundStartTime": 1705744800000, - "serverTime": 1705744800500, - "roundDuration": 60, - "currentWord": { - "wordId": "word-1", - "word": "apple" - } - }, - "timestamp": 1705744800500 -} -``` - -**중요:** `currentWord`는 출제자에게만 전송! - ---- - -## 6. 구현 일정 - -``` -Week 1: 긴급 버그 수정 -├── [BE] serverTime 필드 추가 (0.5일) -├── [FE] useTimer 훅 수정 (0.5일) -├── [BE] 메시지에 domain 필드 추가 (1일) -└── [FE] 메시지 핸들러 domain 분기 (0.5일) - -Week 2: 게임 세션 분리 (BE) -├── [BE] GameSession 모델 생성 -├── [BE] GameSessionRepository 구현 -├── [BE] GameService 리팩토링 -└── [BE] 게임 세션 API 구현 - -Week 3: 프론트엔드 리팩토링 -├── [FE] useChatWebSocket 분리 -├── [FE] useGameWebSocket 신규 -├── [FE] 컴포넌트 분리 -└── [FE/BE] 통합 테스트 - -Week 4: 안정화 및 추가 기능 -├── [BE] 게임 자동 종료 (7분) - Issue #417 -├── [BE] 재접속 시 게임 상태 복구 -└── [FE/BE] E2E 테스트 -``` - ---- - -## 7. 기대 효과 - -| 항목 | 현재 | 개선 후 | -|---------|-------------|------------------| -| 타이머 정확도 | 클라이언트 시계 의존 | 서버 시간 기준 동기화 | -| 재접속 | 게임 상태 유실 | 완전 복구 가능 | -| 테스트 | 채팅/게임 분리 불가 | 독립 테스트 가능 | -| 확장성 | 새 게임 추가 어려움 | gameType으로 확장 용이 | -| 유지보수 | 책임 혼재 | 명확한 책임 분리 | - ---- - -## 8. 즉시 적용 (백엔드 변경 전 프론트엔드 임시 조치) - -```javascript -// 백엔드 변경 전까지 프론트엔드에서 적용 가능한 임시 코드 - -onRoundStart: (data) => { - const roundData = data.data || data; - const now = Date.now(); - - // serverTime이 없으면 클라이언트 시간 사용 (임시) - const serverTime = roundData.serverTime || now; - let roundStartTime = roundData.roundStartTime || now; - - // roundStartTime이 미래 시간이면 현재로 보정 - if (roundStartTime > now + 1000) { - console.warn('Invalid roundStartTime, using current time'); - roundStartTime = now; - } - - setGameState((prev) => ({ - ...prev, - currentRound: roundData.currentRound, - currentDrawerId: roundData.currentDrawerId, - roundStartTime: roundStartTime, - serverTime: serverTime, - roundDuration: roundData.roundDuration || roundData.roundTimeLimit || 60, - })); -} -``` - ---- - -## 9. 결론 - -**우선순위:** - -1. **즉시 (이번 주)**: `serverTime` 추가 + `domain` 필드 추가 -2. **단기 (2주)**: GameSession 모델 분리 + API 구현 -3. **중기 (3-4주)**: FE/BE 완전 분리 + 자동 종료 + 재접속 복구 - -**핵심 원칙:** - -- 단일 WebSocket 엔드포인트 유지 (비용/복잡도) -- `domain` 필드로 채팅/게임 구분 -- `serverTime`으로 정확한 타이머 동기화 -- GameSession 독립 모델로 상태 관리 명확화 diff --git a/docs/CICD-IMPLEMENTATION-QNA.md b/docs/CICD-IMPLEMENTATION-QNA.md deleted file mode 100644 index e00c5a11..00000000 --- a/docs/CICD-IMPLEMENTATION-QNA.md +++ /dev/null @@ -1,421 +0,0 @@ -# CI/CD 파이프라인 구현 설명 및 면접 Q&A - -## 1. CI/CD 아키텍처 개요 - -``` -┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ -│ GitHub │───▶│ CodePipeline│───▶│ CodeBuild │───▶│CloudFormation│ -│ (Source) │ │ (Pipeline) │ │ (Build) │ │ (Deploy) │ -└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ - │ │ │ │ - │ ▼ ▼ ▼ - │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ - │ │ SNS │ │ S3 │ │ Lambda │ - │ │(Notification)│ │ (Artifacts) │ │ Functions │ - │ └─────────────┘ └─────────────┘ └─────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────┐ -│ prod 브랜치 Push/Merge │ -└─────────────────────────────────────────────────────────────────────┘ -``` - -## 2. 구성 요소 상세 설명 - -### 2.1 Source Stage (GitHub) - -- **트리거**: prod 브랜치에 Push 또는 PR Merge 시 자동 실행 -- **연결 방식**: AWS CodeConnections (구 CodeStar Connections) -- **아티팩트**: 소스 코드를 ZIP으로 압축하여 다음 스테이지로 전달 - -### 2.2 Build Stage (CodeBuild) - -- **런타임**: Amazon Linux 2, Java Corretto 21 -- **빌드 단계**: - 1. **Install**: SAM CLI 설치 - 2. **Pre-build**: Gradle 테스트 실행 (`./gradlew clean test`) - 3. **Build**: SAM build & package - 4. **Post-build**: 완료 로그 -- **캐싱**: Gradle 캐시를 S3에 저장하여 빌드 시간 단축 -- **리포트**: JUnit 테스트 결과, JaCoCo 코드 커버리지 리포트 - -### 2.3 Deploy Stage (CloudFormation) - -- **배포 방식**: CloudFormation CREATE_UPDATE -- **템플릿**: SAM으로 패키징된 `packaged-template.yaml` -- **기능**: CAPABILITY_IAM, CAPABILITY_AUTO_EXPAND - -### 2.4 Notification (SNS) - -- **이벤트**: 파이프라인 시작, 성공, 실패 시 이메일 알림 -- **구현**: CodeStar Notifications + SNS Topic - -## 3. 주요 파일 구조 - -``` -BE_Repository/ -├── cicd/ -│ └── pipeline.yaml # CloudFormation 파이프라인 템플릿 -├── ServerlessFunction/ -│ ├── buildspec.yml # CodeBuild 빌드 명세 -│ ├── samconfig.toml # SAM 배포 설정 -│ └── template.yaml # SAM 애플리케이션 템플릿 -``` - -## 4. IAM 역할 구성 - -| 역할 | 목적 | 주요 권한 | -|--------------------|---------------------|----------------------------------------| -| PipelineRole | CodePipeline 서비스 역할 | S3, CodeBuild, CloudFormation, SNS | -| CodeBuildRole | CodeBuild 서비스 역할 | S3, CloudWatch Logs, CodeBuild Reports | -| CloudFormationRole | 리소스 배포 역할 | AdministratorAccess (SAM 리소스 생성용) | - ---- - -## 5. 면접 예상 질문 및 답변 - -### Q1. CI/CD 파이프라인을 구축한 이유는 무엇인가요? - -**A1:** -수동 배포의 문제점을 해결하기 위해 CI/CD를 도입했습니다. - -1. **일관성**: 수동 배포 시 발생할 수 있는 휴먼 에러 방지 -2. **자동화**: 코드 푸시만으로 테스트-빌드-배포가 자동 실행 -3. **품질 보장**: 테스트 실패 시 배포가 중단되어 결함 있는 코드가 프로덕션에 배포되는 것을 방지 -4. **추적성**: 모든 배포 이력이 CodePipeline에 기록되어 문제 발생 시 원인 추적 용이 -5. **속도**: 반복적인 배포 작업 시간을 단축하여 개발 생산성 향상 - ---- - -### Q2. GitHub과 AWS CodePipeline을 어떻게 연동했나요? - -**A2:** -AWS CodeConnections(구 CodeStar Connections)를 사용하여 연동했습니다. - -```yaml -# pipeline.yaml의 Source Stage 설정 -- Name: Source - Actions: - - Name: GitHub - ActionTypeId: - Category: Source - Owner: AWS - Provider: CodeStarSourceConnection - Version: '1' - Configuration: - ConnectionArn: !Ref GitHubConnectionArn - FullRepositoryId: "Language-Study-Prooject/BE_Repository" - BranchName: "prod" - DetectChanges: true -``` - -**연동 과정:** - -1. AWS Console에서 CodeConnections 생성 -2. GitHub OAuth 앱 승인 -3. Connection ARN을 파이프라인에 설정 -4. `DetectChanges: true`로 설정하여 자동 트리거 활성화 - ---- - -### Q3. CodeBuild의 buildspec.yml에서 각 phase의 역할은 무엇인가요? - -**A3:** - -```yaml -phases: - install: # 빌드 환경 설정 - runtime-versions: - java: corretto21 - commands: - - pip3 install aws-sam-cli - - pre_build: # 테스트 실행 (품질 게이트) - commands: - - cd ServerlessFunction - - ./gradlew clean test - - build: # 실제 빌드 및 패키징 - commands: - - sam build - - sam package --s3-bucket ... --output-template-file packaged-template.yaml - - post_build: # 후처리 (로깅, 정리) - commands: - - echo "Build completed" -``` - -- **install**: 빌드에 필요한 런타임과 도구 설치 -- **pre_build**: 테스트 실행 - 실패 시 빌드 중단 (품질 게이트 역할) -- **build**: SAM 애플리케이션 빌드 및 S3에 패키징 -- **post_build**: 완료 로그 기록, 정리 작업 - ---- - -### Q4. 테스트가 실패하면 배포가 어떻게 되나요? - -**A4:** -테스트 실패 시 배포가 자동으로 중단됩니다. - -**작동 원리:** - -1. `pre_build` 단계에서 `./gradlew clean test` 실행 -2. 테스트 실패 시 Gradle이 exit code 1 반환 -3. CodeBuild가 비정상 종료로 판단하여 빌드 실패 처리 -4. CodePipeline의 Build Stage가 실패 상태가 됨 -5. Deploy Stage로 진행되지 않음 -6. SNS를 통해 실패 알림 이메일 발송 - -``` -Pipeline Flow: -Source ──▶ Build (테스트 실패) ──✗ Deploy - │ - ▼ - SNS 알림 발송 -``` - ---- - -### Q5. SAM과 CloudFormation의 관계는 무엇인가요? - -**A5:** -SAM(Serverless Application Model)은 CloudFormation의 확장입니다. - -**관계:** - -- SAM 템플릿은 CloudFormation 템플릿의 상위 집합 -- `sam build`/`sam package` 실행 시 SAM 템플릿이 표준 CloudFormation 템플릿으로 변환 -- 변환된 템플릿(`packaged-template.yaml`)을 CloudFormation이 배포 - -**SAM의 장점:** - -1. 간결한 문법: `AWS::Serverless::Function`으로 Lambda + API Gateway + IAM 역할 한번에 정의 -2. 로컬 테스트: `sam local invoke`로 Lambda 로컬 실행 가능 -3. 자동 패키징: 코드를 S3에 업로드하고 참조 자동 생성 - -```yaml -# SAM 템플릿 (간결) -Type: AWS::Serverless::Function -Properties: - Handler: handler.main - Runtime: java21 - Events: - Api: - Type: Api - Properties: - Path: /hello - Method: get - -# 변환된 CloudFormation (복잡) -# Lambda Function + API Gateway + IAM Role + Permission 등 여러 리소스로 확장 -``` - ---- - -### Q6. 배포 중 롤백은 어떻게 처리되나요? - -**A6:** -CloudFormation의 기본 롤백 기능을 활용합니다. - -**설정:** - -```yaml -# samconfig.toml -disable_rollback = false # 롤백 활성화 -``` - -**롤백 시나리오:** - -1. **배포 실패 시**: CloudFormation이 자동으로 이전 상태로 롤백 -2. **Lambda 오류 시**: - - 현재는 기본 롤백만 사용 - - 추가로 Canary/Linear 배포 설정 가능 (AWS CodeDeploy 연동) - -```yaml -# 점진적 배포 예시 (선택적 구현) -DeploymentPreference: - Type: Canary10Percent5Minutes # 10%에 5분간 배포 후 문제없으면 전체 배포 -``` - ---- - -### Q7. 파이프라인의 아티팩트는 어떻게 관리되나요? - -**A7:** -S3 버킷을 사용하여 아티팩트를 관리합니다. - -```yaml -ArtifactBucket: - Type: AWS::S3::Bucket - Properties: - BucketName: group2-englishstudy-pipeline-artifacts - VersioningConfiguration: - Status: Enabled # 버전 관리 활성화 - BucketEncryption: - ServerSideEncryptionConfiguration: - - ServerSideEncryptionByDefault: - SSEAlgorithm: AES256 # 암호화 -``` - -**아티팩트 종류:** - -1. **SourceArtifact**: GitHub에서 가져온 소스 코드 ZIP -2. **BuildArtifact**: 빌드된 `packaged-template.yaml` -3. **Cache**: Gradle 캐시 (빌드 시간 단축용) - ---- - -### Q8. 파이프라인 알림은 어떻게 구현했나요? - -**A8:** -AWS CodeStar Notifications와 SNS를 연동하여 구현했습니다. - -```yaml -# SNS Topic 생성 -NotificationTopic: - Type: AWS::SNS::Topic - Properties: - TopicName: cicd-pipeline-notifications - -# 이메일 구독 -EmailSubscription: - Type: AWS::SNS::Subscription - Properties: - TopicArn: !Ref NotificationTopic - Protocol: email - Endpoint: !Ref NotificationEmail - -# 알림 규칙 -PipelineNotificationRule: - Type: AWS::CodeStarNotifications::NotificationRule - Properties: - EventTypeIds: - - codepipeline-pipeline-pipeline-execution-started - - codepipeline-pipeline-pipeline-execution-succeeded - - codepipeline-pipeline-pipeline-execution-failed - Targets: - - TargetType: SNS - TargetAddress: !Ref NotificationTopic -``` - ---- - -### Q9. CI/CD 구축 중 겪은 문제와 해결 방법은? - -**A9:** - -**문제 1: Gradle Wrapper를 찾을 수 없음** - -- 원인: `.gitignore`에서 `gradle/` 폴더 전체가 제외됨 -- 해결: `.gitignore` 수정하여 `!gradle/wrapper/` 예외 추가 - -**문제 2: JAVA_HOME 환경 변수 오류** - -- 원인: CodeBuild에서 JAVA_HOME을 수동 설정했으나 경로 불일치 -- 해결: `runtime-versions: java: corretto21`만 사용하고 JAVA_HOME 수동 설정 제거 - -**문제 3: SAM package S3 버킷 참조 오류** - -- 원인: 환경 변수를 사용한 멀티라인 명령어에서 변수 치환 실패 -- 해결: 단일 라인으로 버킷 이름 직접 지정 - -**문제 4: Lambda 환경 변수 누락** - -- 원인: WebSocket Connect 함수에 `WEBSOCKET_ENDPOINT` 환경 변수 미설정 -- 해결: `template.yaml`에 환경 변수 추가 - ---- - -### Q10. 현재 CI/CD의 개선점이 있다면? - -**A10:** - -1. **테스트 커버리지 게이트** - - 현재: 테스트 실행만 함 - - 개선: 커버리지 80% 미만 시 빌드 실패 설정 - -2. **점진적 배포 (Canary/Blue-Green)** - - 현재: 전체 교체 배포 - - 개선: Lambda Alias + CodeDeploy로 Canary 배포 구현 - -3. **다중 환경 지원** - - 현재: prod 단일 환경 - - 개선: dev, staging, prod 분리 및 승인 단계 추가 - -4. **보안 스캔** - - 개선: 의존성 취약점 스캔 (OWASP Dependency-Check) 추가 - -5. **성능 테스트** - - 개선: 배포 전 부하 테스트 단계 추가 - ---- - -### Q11. IaC(Infrastructure as Code)를 사용한 이유는? - -**A11:** -파이프라인 자체도 CloudFormation 템플릿(`pipeline.yaml`)으로 정의했습니다. - -**장점:** - -1. **버전 관리**: 인프라 변경 이력을 Git으로 추적 -2. **재현성**: 동일한 파이프라인을 다른 프로젝트/계정에 쉽게 복제 -3. **리뷰 가능**: 인프라 변경도 코드 리뷰 프로세스 적용 -4. **자동화**: 수동 콘솔 작업 없이 `aws cloudformation deploy`로 생성/업데이트 -5. **문서화**: 템플릿 자체가 인프라 문서 역할 - ---- - -### Q12. CodeBuild와 Jenkins의 차이점은? - -**A12:** - -| 항목 | CodeBuild | Jenkins | -|--------|---------------|----------------------| -| 관리 | 완전 관리형 (서버리스) | 자체 서버 운영 필요 | -| 비용 | 빌드 시간 기반 과금 | 서버 운영 비용 | -| 확장성 | 자동 확장 | 수동 확장 필요 | -| AWS 통합 | 네이티브 통합 | 플러그인 필요 | -| 커스터마이징 | buildspec.yml | Jenkinsfile (Groovy) | -| 플러그인 | 제한적 | 풍부한 생태계 | - -**선택 이유:** - -- AWS 서비스 중심 아키텍처에서 네이티브 통합의 이점 -- 서버 관리 부담 없음 -- SAM/CloudFormation과의 원활한 연동 - ---- - -## 6. 핵심 용어 정리 - -| 용어 | 설명 | -|-------------------------------------|------------------------------------------------| -| CI (Continuous Integration) | 코드 변경을 자주 통합하고 자동 테스트하는 방식 | -| CD (Continuous Delivery/Deployment) | 자동으로 프로덕션까지 배포하는 방식 | -| Pipeline | 소스-빌드-배포로 이어지는 자동화된 워크플로우 | -| Artifact | 빌드 결과물 (패키징된 코드, 템플릿 등) | -| buildspec.yml | CodeBuild의 빌드 명세 파일 | -| SAM | Serverless Application Model - 서버리스 앱 정의 프레임워크 | -| IaC | Infrastructure as Code - 코드로 인프라 관리 | - ---- - -## 7. 참고 명령어 - -```bash -# 파이프라인 생성 -aws cloudformation deploy \ - --template-file cicd/pipeline.yaml \ - --stack-name group2-cicd-pipeline \ - --capabilities CAPABILITY_NAMED_IAM \ - --parameter-overrides NotificationEmail=your@email.com - -# 파이프라인 상태 확인 -aws codepipeline get-pipeline-state --name group2-englishstudy-pipeline - -# 수동 파이프라인 실행 -aws codepipeline start-pipeline-execution --name group2-englishstudy-pipeline - -# 빌드 로그 확인 -aws logs tail /aws/codebuild/group2-englishstudy-build --follow -``` diff --git a/docs/FRONTEND-API-GUIDE.md b/docs/FRONTEND-API-GUIDE.md deleted file mode 100644 index 697d406a..00000000 --- a/docs/FRONTEND-API-GUIDE.md +++ /dev/null @@ -1,365 +0,0 @@ -# 프론트엔드 전달사항 - 채팅/게임 API 가이드 - -## 1. 아키텍처 구조 (업데이트됨) - -### 채팅방과 게임방 분리 - -``` -RoomType enum -├── CHAT ("chat") - 일반 채팅방 -└── GAME ("game") - 게임방 (캐치마인드 등) - -RoomStatus enum -├── WAITING ("waiting") - 대기 중 -├── PLAYING ("playing") - 게임 진행 중 -└── FINISHED ("finished") - 종료됨 -``` - -### GSI1SK 인덱스 설계 - -``` -GSI1PK: "ROOMS" (고정) -GSI1SK: {type}#{gameType}#{status}#{level}#{createdAt} - -예시: -- CHAT#-#WAITING#beginner#2026-01-22T10:00:00Z (일반 채팅방) -- GAME#CATCHMIND#WAITING#intermediate#2026-01-22T10:00:00Z (대기중 게임방) -- GAME#CATCHMIND#PLAYING#advanced#2026-01-22T10:00:00Z (진행중 게임방) -``` - -**핵심**: DB 레벨에서 `type`, `gameType`, `status`, `level` 조합으로 필터링 가능 - ---- - -## 2. 방 타입 (RoomType) - -| 타입 | 코드 | 설명 | -|--------|--------|---------------| -| `CHAT` | `chat` | 일반 채팅방 | -| `GAME` | `game` | 게임방 (캐치마인드 등) | - ---- - -## 3. 방 상태 (RoomStatus) - -| 상태 | 코드 | 설명 | 게임 시작 가능 | -|------------|------------|---------|:--------:| -| `WAITING` | `waiting` | 대기 중 | O | -| `PLAYING` | `playing` | 게임 진행 중 | X | -| `FINISHED` | `finished` | 게임 종료됨 | O | - ---- - -## 4. REST API 엔드포인트 - -### 채팅방 API (`/api/chat/rooms`) - -| Method | Endpoint | 설명 | -|--------|-------------------------|---------------------| -| POST | `/rooms` | 채팅방/게임방 생성 | -| GET | `/rooms` | 방 목록 조회 (필터 지원) | -| GET | `/rooms/{roomId}` | 방 상세 조회 | -| POST | `/rooms/{roomId}/join` | 방 입장 (roomToken 발급) | -| POST | `/rooms/{roomId}/leave` | 방 퇴장 | -| DELETE | `/rooms/{roomId}` | 방 삭제 (방장만) | - -### 게임 API (`/api/game`) - -| Method | Endpoint | 설명 | -|--------|-------------------------------|----------| -| POST | `/rooms/{roomId}/game/start` | 게임 시작 | -| POST | `/rooms/{roomId}/game/stop` | 게임 중단 | -| GET | `/rooms/{roomId}/game/status` | 게임 상태 조회 | -| GET | `/rooms/{roomId}/game/scores` | 점수판 조회 | - ---- - -## 5. 방 목록 조회 쿼리 파라미터 (업데이트됨) - -``` -GET /api/chat/rooms?type=GAME&gameType=CATCHMIND&status=WAITING&level=intermediate&limit=10&cursor=xxx -``` - -| 파라미터 | 타입 | 설명 | 예시 | -|------------|--------|----------------------|----------------------------------------| -| `type` | string | 방 타입 필터 | `CHAT`, `GAME` | -| `gameType` | string | 게임 타입 | `CATCHMIND` | -| `status` | string | 상태 필터 | `WAITING`, `PLAYING`, `FINISHED` | -| `level` | string | 난이도 필터 | `beginner`, `intermediate`, `advanced` | -| `limit` | number | 조회 개수 (기본 10, 최대 20) | | -| `cursor` | string | 페이지네이션 커서 | | - -### 필터 조합 예시 - -```bash -# 대기 중인 게임방만 -GET /api/chat/rooms?type=GAME&status=WAITING - -# 캐치마인드 게임방만 -GET /api/chat/rooms?type=GAME&gameType=CATCHMIND - -# 초급 난이도 채팅방 -GET /api/chat/rooms?type=CHAT&level=beginner - -# 진행 중인 고급 게임방 -GET /api/chat/rooms?type=GAME&status=PLAYING&level=advanced -``` - -### 응답 예시 - -```json -{ - "success": true, - "message": "Rooms retrieved", - "data": { - "rooms": [ - { - "roomId": "abc-123", - "name": "초보자 영어 스터디", - "type": "GAME", - "gameType": "CATCHMIND", - "status": "WAITING", - "level": "beginner", - "currentMembers": 3, - "maxMembers": 6, - "currentRound": 0, - "totalRounds": 5, - "createdAt": "2026-01-22T10:00:00Z" - } - ], - "nextCursor": "eyJQSyI6Ik...", - "hasMore": true - } -} -``` - ---- - -## 6. 방 생성 요청 (업데이트됨) - -### 채팅방 생성 - -```json -{ - "name": "영어 스터디 채팅방", - "type": "CHAT", - "level": "beginner", - "maxMembers": 6, - "description": "초보자를 위한 영어 채팅방" -} -``` - -### 게임방 생성 - -```json -{ - "name": "캐치마인드 게임", - "type": "GAME", - "gameType": "CATCHMIND", - "level": "intermediate", - "maxMembers": 8, - "description": "영어 단어 맞추기 게임" -} -``` - ---- - -## 7. 프론트엔드에서 방 타입 구분 - -### 방법 1: API 필터 사용 (권장) - -```javascript -// 게임방만 조회 -const gameRooms = await fetch('/api/chat/rooms?type=GAME'); - -// 대기 중인 게임방만 -const waitingGames = await fetch('/api/chat/rooms?type=GAME&status=WAITING'); - -// 채팅방만 -const chatRooms = await fetch('/api/chat/rooms?type=CHAT'); -``` - -### 방법 2: 전체 조회 후 클라이언트 필터링 - -```javascript -const allRooms = await fetchRooms(); - -// 게임방만 -const gameRooms = allRooms.filter(room => room.type === 'GAME'); - -// 채팅방만 -const chatRooms = allRooms.filter(room => room.type === 'CHAT'); - -// 대기 중인 방만 -const waitingRooms = allRooms.filter(room => room.status === 'WAITING'); -``` - ---- - -## 8. WebSocket 연결 - -### 채팅/게임 WebSocket - -``` -wss://t378dif43l.execute-api.ap-northeast-2.amazonaws.com/dev?roomToken={roomToken} -``` - -### Grammar WebSocket - -``` -wss://ltrccmteo8.execute-api.ap-northeast-2.amazonaws.com/dev?token={jwtToken} -``` - -### 연결 순서 - -1. `POST /rooms/{roomId}/join` → `roomToken` 발급 -2. WebSocket 연결 시 `roomToken` 쿼리 파라미터로 전달 - ---- - -## 9. WebSocket 메시지 타입 (messageType) - -| 코드 | 타입 | 설명 | -|------------------|--------|---------------| -| `MSG` | 일반 메시지 | 일반 채팅 메시지 | -| `VOICE` | 음성 메시지 | 음성 채팅 | -| `JOIN` | 입장 알림 | 사용자 입장 | -| `LEAVE` | 퇴장 알림 | 사용자 퇴장 | -| `GAME_START` | 게임 시작 | 게임 시작 알림 | -| `GAME_END` | 게임 종료 | 게임 종료 + 최종 순위 | -| `ROUND_START` | 라운드 시작 | 새 라운드 시작 | -| `ROUND_END` | 라운드 종료 | 정답 공개 | -| `ANSWER_CORRECT` | 정답 | 정답 맞춤 | -| `HINT` | 힌트 | 힌트 제공 | -| `SKIP` | 스킵 | 라운드 스킵 | -| `SYSTEM` | 시스템 | 시스템 메시지 | - ---- - -## 10. 게임 명령어 (WebSocket) - -채팅 메시지로 게임 명령어 전송: - -| 명령어 | 설명 | 권한 | -|----------|--------|-----------------| -| `/start` | 게임 시작 | 방장 (2명 이상 접속 시) | -| `/stop` | 게임 중단 | 방장 또는 게임 시작자 | -| `/skip` | 라운드 스킵 | 누구나 | -| `/hint` | 힌트 제공 | 출제자만 | -| `/score` | 점수 확인 | 누구나 | - ---- - -## 11. 게임 시작 응답 예시 - -```json -{ - "messageId": "uuid", - "roomId": "abc-123", - "userId": "SYSTEM", - "content": "게임 시작!\n총 5 라운드\n\n라운드 1 시작!\n출제자: user-456", - "messageType": "GAME_START", - "createdAt": "2026-01-22T10:00:00Z", - "serverTime": "2026-01-22T10:00:00Z", - "domain": "GAME", - "type": "GAME", - "status": "PLAYING", - "currentRound": 1, - "totalRounds": 5, - "currentDrawerId": "user-456", - "drawerOrder": ["user-456", "user-789", "user-123"] -} -``` - ---- - -## 12. 정답 체크 로직 - -- **한국어** 또는 **영어** 둘 다 정답으로 인정 -- 대소문자 구분 없음 -- 공백 무시 - -### 점수 계산 - -``` -기본 점수: 10점 -시간 보너스: (제한시간 - 경과시간) * 0.5 -연속 정답 보너스: 연속정답수 * 2 - -총점 = 기본점수 + 시간보너스 + 연속정답보너스 -``` - ---- - -## 13. 게임 설정 - -| 설정 | 기본값 | 환경변수 | -|--------------|----------|---------------------------------| -| 총 라운드 수 | 5 | `GAME_TOTAL_ROUNDS` | -| 라운드 제한 시간(초) | 60 | `GAME_ROUND_TIME_LIMIT` | -| 빠른 정답 기준(ms) | 5000 | `GAME_QUICK_GUESS_THRESHOLD_MS` | -| 게임 전체 제한(초) | 420 (7분) | `GAME_TIME_LIMIT_SECONDS` | - ---- - -## 14. 주의사항 - -1. **roomToken은 한 번만 사용**: 재연결 시 새로 발급 필요 -2. **WebSocket 연결 실패 시**: `POST /rooms/{roomId}/join`으로 새 토큰 발급 -3. **게임 중 퇴장**: 자동으로 다음 출제자로 넘어감 (2명 미만 시 게임 종료) -4. **출제자는 정답 입력 불가**: 본인이 출제자일 때 채팅해도 정답 체크 안됨 -5. **방 타입 변경 불가**: 생성 시 지정한 type은 변경 불가 - ---- - -## 15. 에러 코드 - -| 코드 | HTTP | 설명 | -|--------------|------|--------------| -| `ROOM_001` | 404 | 채팅방 없음 | -| `ROOM_002` | 409 | 채팅방 이미 존재 | -| `ROOM_003` | 400 | 채팅방 인원 초과 | -| `ROOM_004` | 400 | 채팅방 종료됨 | -| `ROOM_005` | 401 | 비밀번호 틀림 | -| `ROOM_006` | 403 | 방장 권한 없음 | -| `MEMBER_001` | 403 | 채팅방 멤버 아님 | -| `MEMBER_002` | 409 | 이미 참여 중 | -| `GAME_001` | 400 | 게임 시작 실패 | -| `GAME_002` | 400 | 게임 중단 실패 | -| `GAME_003` | 400 | 게임 진행 중 아님 | -| `GAME_004` | 409 | 게임 이미 진행 중 | -| `GAME_005` | 403 | 게임 시작자 아님 | -| `GAME_006` | 404 | 게임 없음 | -| `GAME_007` | 400 | 채팅방에서 게임 불가 | -| `GAME_008` | 400 | 게임 재시작 불가 | -| `GAME_009` | 403 | 방장만 게임 시작 가능 | - ---- - -## 16. UI 구현 가이드 - -### 탭 구조 (권장) - -``` -[전체] [채팅방] [게임방] -``` - -### 게임방 상태 표시 - -``` -대기 중 (WAITING) → 초록색 뱃지 "참여 가능" -진행 중 (PLAYING) → 빨간색 뱃지 "게임 중" -종료됨 (FINISHED) → 회색 뱃지 "종료" -``` - -### 게임방 카드 정보 - -``` -┌─────────────────────────────┐ -│ 캐치마인드 - 영어 단어 맞추기 │ -│ [게임방] [intermediate] │ -│ │ -│ 👥 3/8명 🎮 대기 중 │ -│ 🕐 2026-01-22 10:00 │ -└─────────────────────────────┘ -``` diff --git a/docs/MIDTERM-REPORT.md b/docs/MIDTERM-REPORT.md deleted file mode 100644 index 9a6bb1d1..00000000 --- a/docs/MIDTERM-REPORT.md +++ /dev/null @@ -1,439 +0,0 @@ -# 영어 학습 플랫폼 백엔드 최종 성과 보고서 - -## 프로젝트 개요 - -| 항목 | 내용 | -|-------|--------------------------------------------------------------------------| -| 프로젝트명 | 영어 회화 학습 플랫폼 (MZC 2nd Project) | -| 담당 영역 | Vocabulary, Chatting, Grammar, Badge, Stats, Common | -| 기술 스택 | Java 21, AWS Lambda, DynamoDB, API Gateway WebSocket, Bedrock, Polly, S3 | -| 배포 환경 | AWS SAM, CloudFormation | - ---- - -## 1. 전체 시스템 아키텍처 - -```mermaid -flowchart TB - subgraph Client["클라이언트"] - WEB[Web App] - end - - subgraph Gateway["API Gateway"] - REST[REST API] - WS[WebSocket API] - GRAMMAR_WS[Grammar WebSocket] - end - - subgraph Lambda["AWS Lambda - 도메인별 핸들러"] - direction TB - VOCAB[Vocabulary
단어/일일학습/테스트] - CHAT[Chatting
실시간 채팅/게임] - GRAMMAR[Grammar
문법 체크/스트리밍] - STATS[Stats
통계 집계] - BADGE[Badge
배지 시스템] - USER[User
사용자 관리] - end - - subgraph AI["AI Services"] - BEDROCK[AWS Bedrock
Claude 3.5 Sonnet] - POLLY[AWS Polly
TTS] - end - - subgraph Data["Data Layer"] - DYNAMO_VOCAB[(DynamoDB
Vocab Table)] - DYNAMO_CHAT[(DynamoDB
Chat Table)] - S3[(S3
음성/뱃지 이미지)] - STREAMS[DynamoDB Streams] - end - - WEB --> REST - WEB --> WS - WEB --> GRAMMAR_WS - REST --> VOCAB - REST --> CHAT - REST --> GRAMMAR - REST --> BADGE - REST --> STATS - REST --> USER - WS --> CHAT - GRAMMAR_WS --> GRAMMAR - VOCAB --> DYNAMO_VOCAB - VOCAB --> POLLY - VOCAB --> S3 - CHAT --> DYNAMO_CHAT - CHAT --> BEDROCK - GRAMMAR --> DYNAMO_VOCAB - GRAMMAR --> BEDROCK - STATS --> DYNAMO_VOCAB - BADGE --> DYNAMO_VOCAB - BADGE --> S3 - STREAMS -->|이벤트 트리거| STATS - STATS -->|배지 부여| BADGE -``` - ---- - -## 2. 주요 기능 구현 - -### 2.1 Vocabulary Domain (단어 학습) - -#### 2.1.1 일일 학습 시스템 (Daily Study) - -```mermaid -flowchart LR - subgraph DailyStudy["일일 학습 흐름"] - A[오늘의 단어 조회] --> B{기존 학습 존재?} - B -->|Yes| C[기존 학습 반환] - B -->|No| D[새 단어 50개 + 복습 5개 생성] - D --> E[학습 진행] - E --> F[단어별 학습 완료 처리] - F --> G{50개 완료?} - G -->|Yes| H[isCompleted = true] - end -``` - -**주요 기능:** - -- 레벨별 신규 단어 50개 + 복습 단어 5개 자동 선정 -- 학습 진행도 실시간 추적 (learnedCount/totalWords) -- 일일 학습 완료 시 isCompleted 플래그 설정 - -#### 2.1.2 SM-2 Spaced Repetition 알고리즘 - -```mermaid -stateDiagram-v2 - [*] --> NEW: 단어 추가 - NEW --> LEARNING: 첫 학습 - LEARNING --> LEARNING: 오답 - LEARNING --> REVIEWING: 2회 연속 정답 - REVIEWING --> LEARNING: 오답 - REVIEWING --> MASTERED: 5회 연속 정답 - MASTERED --> LEARNING: 오답 - MASTERED --> MASTERED: 정답 유지 -``` - -**구현 특징:** - -- State 패턴으로 학습 상태 전이 관리 -- easeFactor 동적 조정 (1.3 ~ 2.5) -- 복습 간격 자동 계산 (1일 → 6일 → interval * easeFactor) - -#### 2.1.3 TTS 음성 생성 - -- AWS Polly 연동 (남성/여성 음성) -- S3 캐싱으로 중복 생성 방지 -- 단어 + 예문 음성 생성 - ---- - -### 2.2 Chatting Domain (실시간 채팅 & 게임) - -#### 2.2.1 WebSocket 채팅 - -```mermaid -sequenceDiagram - participant Client - participant REST as REST API - participant WS as WebSocket API - participant DB as DynamoDB - Note over Client, DB: Phase 1: 방 입장 토큰 발급 - Client ->> REST: POST /rooms/{id}/join - REST ->> DB: RoomToken 저장 (TTL: 5분) - REST -->> Client: roomToken 반환 - Note over Client, DB: Phase 2: WebSocket 연결 - Client ->> WS: $connect?roomToken={token} - WS ->> DB: 토큰 검증 + Connection 저장 - WS -->> Client: 연결 성공 - Note over Client, DB: Phase 3: 메시지 송수신 - Client ->> WS: sendmessage (채팅) - WS ->> DB: 메시지 저장 + 브로드캐스트 -``` - -**주요 기능:** - -- RoomToken 기반 인증 (TTL 5분) -- BCrypt 비밀방 암호화 -- 슬래시 명령어 시스템 (/member, /game, /skip, /hint 등) -- Connection 자동 정리 (TTL + 실패 시 삭제) - -#### 2.2.2 캐치마인드 게임 - -```mermaid -flowchart TB - subgraph Game["캐치마인드 게임 흐름"] - START["#47;game 명령어"] --> INIT["게임 초기화
출제 순서 셔플"] - INIT --> ROUND[라운드 시작
출제자 + 단어 선정] - ROUND --> DRAW[출제자 그림 그리기] - DRAW --> GUESS[참가자 정답 입력] - GUESS --> CHECK{정답?} - CHECK -->|Yes| SCORE[점수 계산
시간보너스 + 연속정답보너스] - CHECK -->|No| GUESS - SCORE --> ALLCORRECT{전원 정답?} - ALLCORRECT -->|Yes| NEXTROUND - ALLCORRECT -->|No| TIMEOUT{시간 초과?} - TIMEOUT -->|Yes| NEXTROUND[다음 라운드] - TIMEOUT -->|No| GUESS - NEXTROUND --> LASTROUND{마지막 라운드?} - LASTROUND -->|Yes| END[게임 종료
순위 발표] - LASTROUND -->|No| ROUND - end -``` - -**점수 계산:** - -``` -점수 = 기본점수(10) + 시간보너스((60-경과초)*0.5) + 연속정답보너스(streak*2) -출제자 보너스 = 정답자당 5점 -``` - -**주요 기능:** - -- 실시간 점수 브로드캐스트 -- 연속 정답 스트릭 시스템 -- 접속자 변동 시 출제자 자동 재선정 -- 라운드별 순위 표시 - ---- - -### 2.3 Grammar Domain (문법 체크) - -#### 2.3.1 AI 스트리밍 응답 - -```mermaid -sequenceDiagram - participant Client - participant WS as Grammar WebSocket - participant Handler as GrammarStreamingHandler - participant Bedrock as AWS Bedrock - Client ->> WS: 문법 체크 요청 - WS ->> Handler: Lambda 호출 - Handler ->> Bedrock: 스트리밍 요청 (Claude 3.5 Sonnet) - - loop 청크 단위 응답 - Bedrock -->> Handler: 텍스트 청크 - Handler -->> WS: 실시간 전송 - WS -->> Client: 즉시 표시 - end - - Handler -->> Client: [DONE] 완료 - Handler ->> DB: 피드백 저장 -``` - -**주요 기능:** - -- Claude 3.5 Sonnet 모델 사용 -- 스트리밍으로 체감 대기 시간 80% 감소 -- 레벨별 맞춤 프롬프트 (BEGINNER: 한국어 번역 포함) -- 대화 히스토리 저장으로 문맥 유지 -- 피드백 영구 저장 (DynamoDB) - ---- - -### 2.4 Stats Domain (학습 통계) - -```mermaid -flowchart LR - subgraph StatsTypes["통계 유형"] - DAILY["일별 통계
#47;stats#47;daily"] - WEEKLY["주별 통계
#47;stats#47;weekly"] - MONTHLY["월별 통계
#47;stats#47;monthly"] - TOTAL["전체 통계
#47;stats#47;total"] - HISTORY["히스토리
#47;stats#47;history"] - end -``` - -**통계 항목:** - -| 필드 | 설명 | -|-------------------|-------------| -| testsCompleted | 완료한 테스트 수 | -| questionsAnswered | 답변한 문제 수 | -| correctAnswers | 정답 수 | -| incorrectAnswers | 오답 수 | -| successRate | 정답률 (%) | -| newWordsLearned | 새로 학습한 단어 수 | -| wordsReviewed | 복습한 단어 수 | -| currentStreak | 현재 연속 학습일 | -| longestStreak | 최장 연속 학습일 | -| gamesPlayed | 참여한 게임 수 | -| gamesWon | 1등 횟수 | -| totalGameScore | 누적 게임 점수 | - -**DynamoDB Streams 기반 비동기 집계:** - -- 테스트 결과 저장 시 자동 트리거 -- API 응답과 분리되어 응답 속도 향상 - ---- - -### 2.5 Badge Domain (배지 시스템) - -```mermaid -flowchart TB - subgraph BadgeSystem["배지 시스템"] - TRIGGER[통계 업데이트] --> CHECK[배지 조건 체크] - CHECK --> AWARD{조건 달성?} - AWARD -->|Yes| SAVE[배지 부여 + 저장] - AWARD -->|No| END[종료] - SAVE --> NOTIFY[프론트엔드 조회] - end -``` - -**배지 종류:** - -| Badge Type | 이름 | 조건 | -|----------------------|---------|------------| -| FIRST_STEP | 첫 걸음 | 첫 학습 완료 | -| STREAK_3, 7, 30 | 연속 학습 | N일 연속 학습 | -| WORDS_100, 500, 1000 | 단어 학습 | N개 단어 학습 | -| PERFECT_SCORE | 완벽주의자 | 테스트 만점 | -| ACCURACY_90 | 정확도 달인 | 전체 정확도 90% | -| GAME_FIRST_PLAY | 첫 게임 | 첫 게임 참여 | -| GAME_10_WINS | 게임 10승 | 10번 1등 | -| QUICK_GUESSER | 번개 정답 | 5초 내 정답 | -| PERFECT_DRAWER | 완벽한 출제자 | 전원 정답 유도 | - -**기술적 특징:** - -- S3 Presigned URL로 배지 이미지 제공 (1시간 유효) -- 획득/미획득 배지 + 진행도 표시 - ---- - -## 3. 기술적 성과 - -### 3.1 아키텍처 패턴 - -| 패턴 | 적용 영역 | 효과 | -|------------------|----------|----------------------------| -| **CQRS** | 전 도메인 | 읽기/쓰기 책임 분리, 테스트 용이성 | -| **State** | 단어 학습 상태 | 복잡한 조건문 제거, 확장성 | -| **Factory** | AI 서비스 | 서비스 교체 용이 (Claude ↔ Llama) | -| **Event-Driven** | 통계/배지 | 느슨한 결합, 비동기 처리 | - -### 3.2 DynamoDB 설계 - -**Single Table Design:** - -- Vocab Table: 단어, 사용자단어, 테스트, 일일학습, 통계, 배지, 문법 -- Chat Table: 채팅방, 메시지, 연결, 게임라운드 - -**GSI 구성:** - -| GSI | 용도 | -|------|---------------------| -| GSI1 | 레벨별 단어 조회, 복습 예정 단어 | -| GSI2 | 카테고리별 단어, 상태별 사용자단어 | -| GSI3 | 북마크 단어 조회 | - -### 3.3 보안 - -- Cognito 인증 (idToken) -- WebSocket RoomToken 인증 (TTL 5분) -- BCrypt 비밀방 암호화 -- S3 Presigned URL (배지 이미지) - -### 3.4 성능 최적화 - -| 최적화 | 효과 | -|--------------------------|-------------------------| -| TTS S3 캐싱 | Polly API 호출 90% 절감 | -| 배치 처리 | 최대 100개 단어 일괄 처리 | -| Strongly Consistent Read | 데이터 정합성 보장 | -| DynamoDB Streams | 비동기 통계 집계로 응답 속도 50% 향상 | -| AI 스트리밍 | 체감 대기 시간 80% 감소 | - ---- - -## 4. API 엔드포인트 요약 - -### REST API (https://gc8l9ijhzc.execute-api.ap-northeast-2.amazonaws.com/dev) - -| Method | Path | 설명 | -|--------|-------------------------------------|-----------| -| GET | /vocab/words | 단어 목록 조회 | -| POST | /vocab/words | 단어 등록 | -| GET | /vocab/daily | 오늘의 학습 단어 | -| POST | /vocab/daily/words/{wordId}/learned | 단어 학습 완료 | -| POST | /vocab/tests | 테스트 생성 | -| POST | /vocab/tests/{testId}/submit | 테스트 제출 | -| GET | /stats/daily | 일별 통계 | -| GET | /stats/weekly | 주별 통계 | -| GET | /stats/monthly | 월별 통계 | -| GET | /stats/total | 전체 통계 | -| GET | /stats/history?limit=100 | 통계 히스토리 | -| GET | /badges | 전체 배지 목록 | -| GET | /badges/earned | 획득한 배지 | -| GET | /rooms | 채팅방 목록 | -| POST | /rooms | 채팅방 생성 | -| POST | /rooms/{roomId}/join | 채팅방 입장 | -| POST | /grammar/check | 문법 체크 | - -### WebSocket API - -| Endpoint | 설명 | -|---------------------------------------------------------------|---------| -| wss://t378dif43l.execute-api.ap-northeast-2.amazonaws.com/dev | 채팅/게임 | -| wss://ltrccmteo8.execute-api.ap-northeast-2.amazonaws.com/dev | 문법 스트리밍 | - ---- - -## 5. 프로젝트 구조 - -``` -ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/ -├── common/ # 공통 모듈 -│ ├── config/ # AWS 클라이언트 (싱글톤) -│ ├── router/ # HandlerRouter, Route -│ ├── exception/ # 예외 처리 체계 -│ ├── dto/ # PaginatedResult, ErrorInfo -│ └── util/ # ResponseGenerator, CursorUtil -│ -├── domain/ -│ ├── vocabulary/ # 단어 학습 도메인 -│ │ ├── handler/ # Word, UserWord, Test, DailyStudy 핸들러 -│ │ ├── service/ # CQRS 서비스 (Command/Query) -│ │ ├── repository/ # DynamoDB 레포지토리 -│ │ ├── model/ # Word, UserWord, TestResult, DailyStudy -│ │ └── state/ # NEW, LEARNING, REVIEWING, MASTERED -│ │ -│ ├── chatting/ # 채팅 도메인 -│ │ ├── handler/ # REST + WebSocket 핸들러 -│ │ ├── service/ # ChatRoom, Game, Command 서비스 -│ │ └── model/ # ChatRoom, Connection, GameRound -│ │ -│ ├── grammar/ # 문법 체크 도메인 -│ │ ├── handler/ # REST + 스트리밍 핸들러 -│ │ ├── service/ # GrammarCheck, Conversation 서비스 -│ │ └── factory/ # BedrockGrammarCheckFactory -│ │ -│ ├── stats/ # 통계 도메인 -│ │ ├── handler/ # UserStats, Streams 핸들러 -│ │ └── repository/ # UserStatsRepository -│ │ -│ └── badge/ # 배지 도메인 -│ ├── handler/ # BadgeHandler -│ └── service/ # BadgeService -``` - ---- - -## 6. 성과 요약 - -| 카테고리 | 성과 | -|------------------|------------------------------------| -| **Lambda 함수** | 26개 | -| **API 엔드포인트** | REST 40+, WebSocket 2 | -| **DynamoDB 테이블** | 2개 (Single Table Design) | -| **GSI** | 5개 | -| **아키텍처 패턴** | CQRS, State, Factory, Event-Driven | -| **AI 연동** | Bedrock Claude 3.5 Sonnet (문법/대화) | -| **TTS** | AWS Polly (남성/여성 음성) | -| **실시간 통신** | WebSocket (채팅/게임/문법 스트리밍) | -| **인증** | Cognito + RoomToken | - ---- - -**작성일:** 2026-01-16 -**팀:** MZC 2nd Project Team / SMJ diff --git a/docs/domain-reports/BADGE-DOMAIN-REPORT.md b/docs/domain-reports/BADGE-DOMAIN-REPORT.md deleted file mode 100644 index 4cd58215..00000000 --- a/docs/domain-reports/BADGE-DOMAIN-REPORT.md +++ /dev/null @@ -1,681 +0,0 @@ -# Badge Domain 세부 보고서 - -## 1. 개요 - -Badge 도메인은 사용자의 학습 성취도에 따라 배지를 자동으로 부여하는 시스템입니다. 이벤트 기반 아키텍처를 통해 Stats, Vocabulary, Chatting 도메인과 연동되어 실시간으로 배지를 체크하고 -부여합니다. - ---- - -## 2. 전체 아키텍처 - -```mermaid -flowchart TB - subgraph Triggers["트리거 소스"] - TEST[테스트 완료
DynamoDB Streams] - WORD[단어 학습
Write-through] - GAME[게임 종료
Service Method] - end - - subgraph Processing["Badge 처리"] - CHECK[BadgeService
조건 체크] - AWARD[배지 부여] - end - - subgraph Storage["저장소"] - DDB[(DynamoDB
UserBadge)] - S3[(S3
배지 이미지)] - end - - subgraph Query["조회"] - API[BadgeHandler
REST API] - PRESIGN[S3 Presigned URL] - end - - TEST --> CHECK - WORD --> CHECK - GAME --> CHECK - CHECK --> AWARD - AWARD --> DDB - DDB --> API - S3 --> PRESIGN - PRESIGN --> API -``` - ---- - -## 3. 배지 종류 - -### 3.1 배지 카테고리 - -```mermaid -mindmap - root((배지 시스템)) - 학습 - FIRST_STEP[첫 걸음] - WORDS_100[단어 수집가] - WORDS_500[단어 전문가] - WORDS_1000[단어 마스터] - 연속학습 - STREAK_3[3일 연속] - STREAK_7[7일 연속] - STREAK_30[30일 연속] - 테스트 - PERFECT_SCORE[완벽주의자] - TEST_10[테스트 도전자] - ACCURACY_90[정확도 달인] - 게임 - GAME_FIRST[첫 게임] - GAME_10_WINS[10승 달성] - QUICK_GUESSER[번개 정답] - PERFECT_DRAWER[완벽한 출제자] - 최종 - MASTER[학습 마스터] -``` - -### 3.2 배지 상세 - -| Badge Type | 이름 | 설명 | 카테고리 | 조건 | -|-----------------|-----------|--------------------|-----------------|-----------------------| -| FIRST_STEP | 첫 걸음 | 첫 학습을 완료했습니다 | FIRST_STUDY | testsCompleted >= 1 | -| STREAK_3 | 3일 연속 학습 | 3일 연속으로 학습했습니다 | STREAK | currentStreak >= 3 | -| STREAK_7 | 일주일 연속 학습 | 7일 연속으로 학습했습니다 | STREAK | currentStreak >= 7 | -| STREAK_30 | 한 달 연속 학습 | 30일 연속으로 학습했습니다 | STREAK | currentStreak >= 30 | -| WORDS_100 | 단어 수집가 | 100개의 단어를 학습했습니다 | WORDS_LEARNED | totalWords >= 100 | -| WORDS_500 | 단어 전문가 | 500개의 단어를 학습했습니다 | WORDS_LEARNED | totalWords >= 500 | -| WORDS_1000 | 단어 마스터 | 1000개의 단어를 학습했습니다 | WORDS_LEARNED | totalWords >= 1000 | -| PERFECT_SCORE | 완벽주의자 | 테스트에서 만점을 받았습니다 | PERFECT_TEST | incorrectAnswers == 0 | -| TEST_10 | 테스트 도전자 | 10회의 테스트를 완료했습니다 | TESTS_COMPLETED | testsCompleted >= 10 | -| ACCURACY_90 | 정확도 달인 | 전체 정확도 90%를 달성했습니다 | ACCURACY | successRate >= 90 | -| GAME_FIRST_PLAY | 첫 게임 | 첫 게임에 참여했습니다 | GAMES_PLAYED | gamesPlayed >= 1 | -| GAME_10_WINS | 게임 10승 | 게임에서 10번 1등을 했습니다 | GAMES_WON | gamesWon >= 10 | -| QUICK_GUESSER | 번개 정답 | 5초 내에 정답을 맞췄습니다 | QUICK_GUESSES | quickGuesses >= 1 | -| PERFECT_DRAWER | 완벽한 출제자 | 출제 시 전원이 정답을 맞췄습니다 | PERFECT_DRAWS | perfectDraws >= 1 | -| MASTER | 학습 마스터 | 모든 업적을 달성했습니다 | ALL_BADGES | 모든 배지 획득 | - ---- - -## 4. 배지 부여 흐름 - -### 4.1 테스트 완료 시 - -```mermaid -sequenceDiagram - participant Test as TestResult - participant Streams as DynamoDB Streams - participant Handler as StatsStreamHandler - participant Stats as UserStats - participant Badge as BadgeService - participant DB as DynamoDB - Test ->> Streams: INSERT 이벤트 - Streams ->> Handler: 트리거 - Handler ->> Stats: incrementTestStats() - Handler ->> Stats: updateStudyStreak() - Note over Handler: 만점 체크 - alt 정답 > 0 && 오답 == 0 - Handler ->> Badge: awardBadge("PERFECT_SCORE") - Badge ->> DB: UserBadge 저장 - end - - Handler ->> Stats: findTotalStats() - Stats -->> Handler: UserStats - Handler ->> Badge: checkAndAwardBadges() - Badge ->> Badge: 각 배지 조건 체크 - Badge ->> DB: 획득 배지 저장 -``` - -### 4.2 단어 학습 시 - -```mermaid -sequenceDiagram - participant API as DailyStudyHandler - participant Service as DailyStudyCommandService - participant Stats as UserStatsRepository - participant Badge as BadgeService - participant DB as DynamoDB - API ->> Service: markWordLearned() - Service ->> Stats: incrementWordsLearned() - Note over Service: 배지 체크 (WORDS_xxx) - Service ->> Stats: findTotalStats() - Stats -->> Service: UserStats - Service ->> Badge: checkAndAwardBadges() - Badge ->> Badge: WORDS_100, 500, 1000 체크 - Badge ->> DB: 획득 배지 저장 -``` - -### 4.3 게임 종료 시 - -```mermaid -sequenceDiagram - participant Game as GameService - participant Stats as GameStatsService - participant Repo as UserStatsRepository - participant Badge as BadgeService - participant DB as DynamoDB - Game ->> Stats: updateGameStats(room) - - loop 각 참가자 - Stats ->> Stats: 점수 집계 - Note over Stats: correctGuesses
quickGuesses (5초 이내)
perfectDraws - Stats ->> Repo: incrementGameStats() - Stats ->> Repo: findTotalStats() - Repo -->> Stats: UserStats - Stats ->> Badge: checkAndAwardBadges() - Badge ->> Badge: GAME_xxx 배지 체크 - Badge ->> DB: 획득 배지 저장 - end -``` - ---- - -## 5. 배지 조건 체크 로직 - -### 5.1 카테고리별 조건 - -```mermaid -flowchart TB - START[checkAndAwardBadges] --> LOOP{모든 BadgeType 순회} - LOOP --> EARNED{이미 획득?} - EARNED -->|Yes| SKIP[건너뛰기] - EARNED -->|No| CHECK[조건 체크] - CHECK --> SWITCH{카테고리} - SWITCH -->|FIRST_STUDY| FS[testsCompleted >= 1] - SWITCH -->|STREAK| ST[currentStreak >= threshold] - SWITCH -->|WORDS_LEARNED| WL[totalWords >= threshold] - SWITCH -->|PERFECT_TEST| PT[별도 처리] - SWITCH -->|TESTS_COMPLETED| TC[testsCompleted >= threshold] - SWITCH -->|ACCURACY| AC[successRate >= threshold] - SWITCH -->|GAMES_PLAYED| GP[gamesPlayed >= threshold] - SWITCH -->|GAMES_WON| GW[gamesWon >= threshold] - SWITCH -->|QUICK_GUESSES| QG[quickGuesses >= threshold] - SWITCH -->|PERFECT_DRAWS| PD[perfectDraws >= threshold] - SWITCH -->|ALL_BADGES| AB[모든 배지 획득 체크] - FS --> RESULT{조건 충족?} - ST --> RESULT - WL --> RESULT - TC --> RESULT - AC --> RESULT - GP --> RESULT - GW --> RESULT - QG --> RESULT - PD --> RESULT - RESULT -->|Yes| AWARD[배지 부여] - RESULT -->|No| SKIP - AWARD --> LOOP - SKIP --> LOOP -``` - -### 5.2 Switch Expression 패턴 - -```java -private boolean checkBadgeCondition(BadgeType type, UserStats stats) { - return switch (type.getCategory()) { - case "FIRST_STUDY" -> stats.getTestsCompleted() != null && stats.getTestsCompleted() >= 1; - - case "STREAK" -> stats.getCurrentStreak() != null && - stats.getCurrentStreak() >= type.getThreshold(); - - case "WORDS_LEARNED" -> { - int total = (stats.getNewWordsLearned() != null ? stats.getNewWordsLearned() : 0) - + (stats.getWordsReviewed() != null ? stats.getWordsReviewed() : 0); - yield total >= type.getThreshold(); - } - - case "ACCURACY" -> { - if (stats.getQuestionsAnswered() == null || stats.getQuestionsAnswered() == 0) - yield false; - double accuracy = (stats.getCorrectAnswers() * 100.0) / stats.getQuestionsAnswered(); - yield accuracy >= type.getThreshold(); - } - - case "TESTS_COMPLETED" -> stats.getTestsCompleted() != null && - stats.getTestsCompleted() >= type.getThreshold(); - - case "GAMES_PLAYED" -> stats.getGamesPlayed() != null && - stats.getGamesPlayed() >= type.getThreshold(); - - case "GAMES_WON" -> stats.getGamesWon() != null && - stats.getGamesWon() >= type.getThreshold(); - - case "QUICK_GUESSES" -> stats.getQuickGuesses() != null && - stats.getQuickGuesses() >= type.getThreshold(); - - case "PERFECT_DRAWS" -> stats.getPerfectDraws() != null && - stats.getPerfectDraws() >= type.getThreshold(); - - case "PERFECT_TEST" -> false; // 별도 처리 (StatsStreamHandler) - case "ALL_BADGES" -> false; // 특수 로직 필요 - - default -> false; - }; -} -``` - ---- - -## 6. API 엔드포인트 - -### 6.1 REST API - -| Method | Endpoint | 설명 | 응답 | -|--------|----------------|----------------|-------------| -| GET | /badges | 전체 배지 목록 + 진행도 | BadgeInfo[] | -| GET | /badges/earned | 획득한 배지만 조회 | UserBadge[] | - -### 6.2 전체 배지 조회 응답 - -```json -{ - "message": "Badges retrieved", - "data": { - "badges": [ - { - "badgeType": "FIRST_STEP", - "name": "첫 걸음", - "description": "첫 학습을 완료했습니다", - "imageUrl": "https://...presigned.../badges/first_step.png", - "category": "FIRST_STUDY", - "threshold": 1, - "progress": 1, - "earned": true, - "earnedAt": "2026-01-16T10:30:45.123Z" - }, - { - "badgeType": "WORDS_100", - "name": "단어 수집가", - "description": "100개의 단어를 학습했습니다", - "imageUrl": "https://...presigned.../badges/words_100.png", - "category": "WORDS_LEARNED", - "threshold": 100, - "progress": 45, - "earned": false, - "earnedAt": null - } - ], - "totalCount": 16, - "earnedCount": 8 - } -} -``` - -### 6.3 획득 배지 조회 응답 - -```json -{ - "message": "Earned badges retrieved", - "data": { - "badges": [ - { - "badgeType": "FIRST_STEP", - "name": "첫 걸음", - "description": "첫 학습을 완료했습니다", - "imageUrl": "https://...presigned.../badges/first_step.png", - "category": "FIRST_STUDY", - "threshold": 1, - "progress": 1, - "earnedAt": "2026-01-16T10:30:45.123Z" - } - ], - "count": 8 - } -} -``` - ---- - -## 7. 데이터 모델 - -### 7.1 UserBadge - -```java - -@DynamoDbBean -public class UserBadge { - // 기본 키 - String pk; // USER#{userId}#BADGE - String sk; // BADGE#{badgeType} - - // GSI (전체 배지 조회) - String gsi1pk; // BADGE#ALL - String gsi1sk; // EARNED#{earnedAt} - - // 메타데이터 - String odUserId; - String badgeType; // BadgeType enum 이름 - String name; - String description; - String imageUrl; - String category; - Integer threshold; - Integer progress; // 획득 시점 진행도 - - // 타임스탬프 - String earnedAt; - String createdAt; -} -``` - -### 7.2 DynamoDB 키 구조 - -| 필드 | 패턴 | 예시 | -|--------|---------------------|-----------------------------| -| PK | USER#{userId}#BADGE | USER#abc123#BADGE | -| SK | BADGE#{badgeType} | BADGE#STREAK_7 | -| GSI1PK | BADGE#ALL | BADGE#ALL | -| GSI1SK | EARNED#{earnedAt} | EARNED#2026-01-16T10:30:45Z | - -### 7.3 BadgeType Enum - -```java -public enum BadgeType { - FIRST_STEP("첫 걸음", "첫 학습을 완료했습니다", - "FIRST_STUDY", 1, "first_step.png"), - STREAK_3("3일 연속 학습", "3일 연속으로 학습했습니다", - "STREAK", 3, "streak_3.png"), - STREAK_7("일주일 연속 학습", "7일 연속으로 학습했습니다", - "STREAK", 7, "streak_7.png"), - // ... 생략 - MASTER("학습 마스터", "모든 업적을 달성했습니다", - "ALL_BADGES", 1, "master.png"); - - private final String name; - private final String description; - private final String category; - private final int threshold; - private final String imageFile; -} -``` - ---- - -## 8. 진행도 계산 - -### 8.1 카테고리별 진행도 - -```mermaid -flowchart TB - subgraph Progress["진행도 계산"] - FIRST["FIRST_STUDY
testsCompleted >= 1 ? 1 : 0"] - STREAK["STREAK
currentStreak"] - WORDS["WORDS_LEARNED
newWords + reviewed"] - TESTS["TESTS_COMPLETED
testsCompleted"] - ACC["ACCURACY
successRate (%)"] - GAMES["GAMES_PLAYED
gamesPlayed"] - WINS["GAMES_WON
gamesWon"] - QUICK["QUICK_GUESSES
quickGuesses"] - PERFECT["PERFECT_DRAWS
perfectDraws"] - end -``` - -### 8.2 calculateProgress 메서드 - -```java -private int calculateProgress(BadgeType type, UserStats stats) { - return switch (type.getCategory()) { - case "FIRST_STUDY" -> (stats.getTestsCompleted() != null && stats.getTestsCompleted() >= 1) ? 1 : 0; - - case "STREAK" -> stats.getCurrentStreak() != null ? stats.getCurrentStreak() : 0; - - case "WORDS_LEARNED" -> { - int newWords = stats.getNewWordsLearned() != null ? stats.getNewWordsLearned() : 0; - int reviewed = stats.getWordsReviewed() != null ? stats.getWordsReviewed() : 0; - yield newWords + reviewed; - } - - case "TESTS_COMPLETED" -> stats.getTestsCompleted() != null ? stats.getTestsCompleted() : 0; - - case "ACCURACY" -> { - if (stats.getQuestionsAnswered() == null || stats.getQuestionsAnswered() == 0) - yield 0; - yield (int) ((stats.getCorrectAnswers() * 100.0) / stats.getQuestionsAnswered()); - } - - case "GAMES_PLAYED" -> stats.getGamesPlayed() != null ? stats.getGamesPlayed() : 0; - - case "GAMES_WON" -> stats.getGamesWon() != null ? stats.getGamesWon() : 0; - - case "QUICK_GUESSES" -> stats.getQuickGuesses() != null ? stats.getQuickGuesses() : 0; - - case "PERFECT_DRAWS" -> stats.getPerfectDraws() != null ? stats.getPerfectDraws() : 0; - - default -> 0; - }; -} -``` - ---- - -## 9. 멱등성 보장 - -### 9.1 중복 부여 방지 흐름 - -```mermaid -flowchart TB - START[checkAndAwardBadges] --> LOOP[배지 타입 순회] - LOOP --> CHECK{hasBadge?} - CHECK -->|이미 있음| SKIP[건너뛰기] - CHECK -->|없음| CONDITION{조건 충족?} - CONDITION -->|Yes| CREATE[배지 생성] - CONDITION -->|No| SKIP - CREATE --> SAVE[DynamoDB 저장] - SAVE --> LOOP - SKIP --> LOOP -``` - -### 9.2 구현 코드 - -```java -public List checkAndAwardBadges(String userId, UserStats stats) { - List newBadges = new ArrayList<>(); - String now = Instant.now().toString(); - - for (BadgeType type : BadgeType.values()) { - // 1. 이미 획득한 배지는 건너뛰기 - if (badgeRepository.hasBadge(userId, type.name())) { - continue; - } - - // 2. 조건 체크 - if (checkBadgeCondition(type, stats)) { - // 3. 배지 생성 및 저장 - UserBadge badge = createBadge(userId, type, now); - badgeRepository.save(badge); - newBadges.add(badge); - } - } - - return newBadges; -} -``` - ---- - -## 10. S3 이미지 연동 - -### 10.1 Presigned URL 생성 - -```mermaid -flowchart LR - REQ[배지 조회] --> SERVICE[BadgeService] - SERVICE --> PRESIGN[S3PresignUtil] - PRESIGN --> CACHE{캐시 확인} - CACHE -->|있음| RETURN[URL 반환] - CACHE -->|없음| GENERATE[Presigned URL 생성] - GENERATE --> SAVE[캐시 저장] - SAVE --> RETURN -``` - -### 10.2 이미지 URL 생성 - -```java -// S3PresignUtil.java -public static String getBadgeImageUrl(String imageFile) { - return getPresignedUrl("badges/" + imageFile); -} - -// BadgeService - 배지 생성 시 -private UserBadge createBadge(String userId, BadgeType type, String now) { - return UserBadge.builder() - .pk(BadgeKey.userBadgePk(userId)) - .sk(BadgeKey.badgeSk(type.name())) - .gsi1pk(BadgeKey.BADGE_ALL) - .gsi1sk(BadgeKey.earnedSk(now)) - .odUserId(userId) - .badgeType(type.name()) - .name(type.getName()) - .description(type.getDescription()) - .imageUrl(S3PresignUtil.getBadgeImageUrl(type.getImageFile())) - .category(type.getCategory()) - .threshold(type.getThreshold()) - .earnedAt(now) - .createdAt(now) - .build(); -} -``` - -### 10.3 S3 버킷 구조 - -``` -s3://group2-englishstudy/ -└── badges/ - ├── first_step.png - ├── streak_3.png - ├── streak_7.png - ├── streak_30.png - ├── words_100.png - ├── words_500.png - ├── words_1000.png - ├── perfect_score.png - ├── test_10.png - ├── accuracy_90.png - ├── game_first.png - ├── game_10_wins.png - ├── quick_guesser.png - ├── perfect_drawer.png - └── master.png -``` - ---- - -## 11. Stats 도메인 연동 - -### 11.1 연동 포인트 - -```mermaid -flowchart TB - subgraph Stats["Stats 도메인"] - STREAM[StatsStreamHandler] - DAILY[DailyStudyCommandService] - GAME[GameStatsService] - REPO[UserStatsRepository] - end - - subgraph Badge["Badge 도메인"] - SERVICE[BadgeService] - BADGEREPO[BadgeRepository] - end - - STREAM -->|checkAndAwardBadges| SERVICE - DAILY -->|checkWordsBadge| SERVICE - GAME -->|checkAndAwardBadges| SERVICE - SERVICE -->|hasBadge, save| BADGEREPO - SERVICE -->|findTotalStats| REPO -``` - -### 11.2 UserStats 필드와 배지 매핑 - -| UserStats 필드 | 배지 | -|------------------------------------|----------------------------------| -| testsCompleted | FIRST_STEP, TEST_10 | -| currentStreak | STREAK_3, STREAK_7, STREAK_30 | -| newWordsLearned + wordsReviewed | WORDS_100, WORDS_500, WORDS_1000 | -| correctAnswers / questionsAnswered | ACCURACY_90 | -| gamesPlayed | GAME_FIRST_PLAY | -| gamesWon | GAME_10_WINS | -| quickGuesses | QUICK_GUESSER | -| perfectDraws | PERFECT_DRAWER | - ---- - -## 12. 파일 구조 - -``` -domain/badge/ -├── enums/ -│ └── BadgeType.java # 16가지 배지 정의 -├── constants/ -│ └── BadgeKey.java # DynamoDB 키 생성 -├── model/ -│ └── UserBadge.java # 배지 엔티티 -├── repository/ -│ └── BadgeRepository.java # CRUD 연산 -├── service/ -│ └── BadgeService.java # 조건 체크, 배지 부여 -└── handler/ - └── BadgeHandler.java # REST API - -연동 파일: -├── domain/stats/handler/StatsStreamHandler.java -├── domain/vocabulary/service/DailyStudyCommandService.java -└── domain/chatting/service/GameStatsService.java -``` - ---- - -## 13. 기술 스택 - -- **Runtime:** AWS Lambda (Java 21) -- **Database:** DynamoDB (Single Table Design) -- **Storage:** S3 (배지 이미지) -- **Event:** DynamoDB Streams, Write-through, Service Method -- **Pattern:** Event-driven, Idempotent, Switch Expression -- **Java 21 Features:** Enhanced Switch, Yield Statement - ---- - -## 14. 배지 획득 시나리오 - -### 14.1 시나리오 예시 - -```mermaid -flowchart LR - subgraph Day1["1일차"] - A1[테스트 완료] --> B1["FIRST_STEP 획득"] - end - - subgraph Day3["3일차"] - A3[3일 연속 학습] --> B3["STREAK_3 획득"] - end - - subgraph Day7["7일차"] - A7[7일 연속 학습] --> B7["STREAK_7 획득"] - A7_2[100단어 학습] --> B7_2["WORDS_100 획득"] - end - - subgraph Game["게임"] - G1[5초 내 정답] --> G2["QUICK_GUESSER 획득"] - G3[10회 1등] --> G4["GAME_10_WINS 획득"] - end -``` - -### 14.2 특수 배지 획득 조건 - -**PERFECT_SCORE (완벽주의자):** - -- 테스트 제출 시 오답 0개이면 즉시 부여 -- StatsStreamHandler에서 별도 처리 - -**QUICK_GUESSER (번개 정답):** - -- 게임 중 5초(5000ms) 이내 정답 시 -- GameStatsService에서 quickGuesses 카운트 - -**PERFECT_DRAWER (완벽한 출제자):** - -- 출제 시 모든 참가자가 정답을 맞춘 경우 -- 라운드 종료 시 endReason == "ALL_CORRECT"이면 카운트 - -**MASTER (학습 마스터):** - -- 다른 모든 배지를 획득한 경우 -- 특수 로직으로 모든 배지 보유 여부 확인 diff --git a/docs/domain-reports/CHATTING-DOMAIN-REPORT.md b/docs/domain-reports/CHATTING-DOMAIN-REPORT.md deleted file mode 100644 index c27eb552..00000000 --- a/docs/domain-reports/CHATTING-DOMAIN-REPORT.md +++ /dev/null @@ -1,434 +0,0 @@ -# Chatting Domain 세부 보고서 - -## 1. 개요 - -Chatting 도메인은 실시간 채팅과 캐치마인드 게임 기능을 제공하는 WebSocket 기반 시스템입니다. AWS API Gateway WebSocket과 Lambda를 활용하여 실시간 양방향 통신을 구현했습니다. - ---- - -## 2. 전체 아키텍처 - -```mermaid -flowchart TB - subgraph Client["클라이언트"] - APP[Mobile/Web App] - end - - subgraph Gateway["API Gateway"] - REST[REST API] - WS[WebSocket API] - end - - subgraph Lambda["Lambda Handlers"] - direction TB - ROOM[ChatRoomHandler] - MSG[ChatMessageHandler] - GAME[GameHandler] - VOICE[ChatVoiceHandler] - CONNECT[WebSocketConnectHandler] - DISCONNECT[WebSocketDisconnectHandler] - MESSAGE[WebSocketMessageHandler] - end - - subgraph Storage["데이터 저장소"] - DDB[(DynamoDB)] - S3[(S3 - 음성 캐시)] - end - - APP --> REST - APP <--> WS - REST --> ROOM - REST --> MSG - REST --> GAME - REST --> VOICE - WS --> CONNECT - WS --> DISCONNECT - WS --> MESSAGE - ROOM --> DDB - MSG --> DDB - GAME --> DDB - MESSAGE --> DDB - VOICE --> S3 -``` - ---- - -## 3. 채팅방 시스템 - -### 3.1 채팅방 입장 흐름 - -```mermaid -sequenceDiagram - participant Client - participant REST as REST API - participant WS as WebSocket API - participant DB as DynamoDB - Note over Client, DB: Phase 1 - 방 입장 및 토큰 발급 - Client ->> REST: POST /rooms/{roomId}/join - REST ->> DB: 비밀번호 검증 (비밀방인 경우) - REST ->> DB: RoomToken 저장 (TTL 5분) - REST -->> Client: roomToken 반환 - Note over Client, DB: Phase 2 - WebSocket 연결 - Client ->> WS: $connect?roomToken={token} - WS ->> DB: 토큰 검증 - WS ->> DB: Connection 저장 (TTL 10분) - WS -->> Client: 연결 성공 - Note over Client, DB: Phase 3 - 실시간 메시지 - Client ->> WS: sendMessage (채팅) - WS ->> DB: 메시지 저장 - WS -->> Client: 브로드캐스트 (같은 방 전체) -``` - -### 3.2 REST API 엔드포인트 - -| Method | Endpoint | 설명 | 인증 | -|--------|-------------------------------|---------------------------|----| -| POST | /chat/rooms | 채팅방 생성 | O | -| GET | /chat/rooms | 채팅방 목록 (level, joined 필터) | O | -| GET | /chat/rooms/{roomId} | 채팅방 상세 | O | -| POST | /chat/rooms/{roomId}/join | 채팅방 입장 (토큰 발급) | O | -| POST | /chat/rooms/{roomId}/leave | 채팅방 퇴장 | O | -| DELETE | /chat/rooms/{roomId} | 채팅방 삭제 (방장만) | O | -| GET | /chat/rooms/{roomId}/messages | 메시지 히스토리 | O | - -### 3.3 WebSocket 이벤트 - -| Route | 설명 | Payload | -|-------------|------------|------------------------------------------| -| $connect | 연결 (토큰 검증) | ?roomToken={token} | -| $disconnect | 연결 해제 | - | -| sendMessage | 메시지 전송 | { roomId, userId, content, messageType } | - ---- - -## 4. 캐치마인드 게임 시스템 - -### 4.1 게임 흐름 - -```mermaid -flowchart TB - subgraph GameFlow["캐치마인드 게임 흐름"] - START["/game 명령어"] --> INIT["게임 초기화
출제자 순서 셔플"] - INIT --> ROUND["라운드 시작
출제자 + 단어 선정"] - ROUND --> DRAW["출제자 그림 그리기
(DRAWING 메시지)"] - DRAW --> GUESS["참가자 정답 입력"] - GUESS --> CHECK{정답?} - CHECK -->|Yes| SCORE["점수 계산
시간보너스 + 연속보너스"] - CHECK -->|No| GUESS - SCORE --> ALLCORRECT{전원 정답?} - ALLCORRECT -->|Yes| NEXTROUND - ALLCORRECT -->|No| TIMEOUT{시간 초과?} - TIMEOUT -->|Yes| NEXTROUND["다음 라운드"] - TIMEOUT -->|No| GUESS - NEXTROUND --> LASTROUND{마지막 라운드?} - LASTROUND -->|Yes| END["게임 종료
순위 발표"] - LASTROUND -->|No| ROUND - end -``` - -### 4.2 게임 API - -| Method | Endpoint | 설명 | -|--------|----------------------------------|-------------| -| POST | /chat/rooms/{roomId}/game/start | 게임 시작 (방장만) | -| POST | /chat/rooms/{roomId}/game/stop | 게임 중지 | -| GET | /chat/rooms/{roomId}/game/status | 게임 상태 조회 | -| GET | /chat/rooms/{roomId}/game/scores | 점수판 조회 | - -### 4.3 슬래시 명령어 - -| 명령어 | 설명 | 사용 가능 | -|---------|----------------|--------| -| /start | 게임 시작 | 방장 | -| /stop | 게임 중지 | 방장/시작자 | -| /score | 점수판 보기 | 전체 | -| /member | 접속자 수 | 전체 | -| /hint | 힌트 제공 (첫글자○○○) | 출제자 | -| /skip | 라운드 스킵 | 출제자 | -| /help | 명령어 도움말 | 전체 | - -### 4.4 점수 계산 공식 - -``` -점수 = 기본점수(10) + 시간보너스 + 연속보너스 + 출제자보너스 - -- 시간보너스: (60 - 경과초) × 0.5 -- 연속보너스: streak × 2 -- 출제자보너스: 정답자당 5점 -``` - -**예시:** - -- 30초에 정답 + 연속 3회: 10 + 15 + 6 = 31점 -- 출제자가 3명 맞출 경우: 5 × 3 = 15점 - -### 4.5 게임 상태 - -```mermaid -stateDiagram-v2 - [*] --> NONE: 대기 - NONE --> PLAYING: /start 명령어 - PLAYING --> ROUND_END: 시간초과/전원정답 - ROUND_END --> PLAYING: 다음 라운드 - ROUND_END --> FINISHED: 마지막 라운드 - PLAYING --> FINISHED: /stop 명령어 - FINISHED --> [*]: 게임 종료 -``` - ---- - -## 5. WebSocket 메시지 타입 - -### 5.1 채팅 메시지 - -| Type | 설명 | 저장 | -|-------------|-------|----| -| TEXT | 일반 채팅 | O | -| IMAGE | 이미지 | O | -| VOICE | 음성 | O | -| AI_RESPONSE | AI 응답 | O | - -### 5.2 게임 메시지 - -| Type | 설명 | 저장 | -|----------------|--------------|----| -| DRAWING | 그림 데이터 (실시간) | X | -| DRAWING_CLEAR | 그림 지우기 | X | -| GUESS | 오답 추측 | X | -| CORRECT_ANSWER | 정답 알림 | X | -| SCORE_UPDATE | 점수 갱신 | X | -| GAME_START | 게임 시작 | X | -| ROUND_START | 라운드 시작 | X | -| ROUND_END | 라운드 종료 | X | -| GAME_END | 게임 종료 | X | -| HINT | 힌트 | X | - -### 5.3 실시간 점수 업데이트 메시지 - -```json -{ - "messageType": "SCORE_UPDATE", - "roomId": "uuid", - "scorerId": "user123", - "scoreGained": 25, - "ranking": [ - { - "rank": 1, - "userId": "user123", - "score": 85, - "change": 25 - }, - { - "rank": 2, - "userId": "user456", - "score": 60, - "change": 0 - } - ], - "currentRound": 3, - "totalRounds": 5 -} -``` - ---- - -## 6. 데이터 모델 - -### 6.1 ChatRoom - -```java - -@DynamoDbBean -public class ChatRoom { - // 기본 정보 - String roomId, name, description; - String level; // beginner, intermediate, advanced - Integer currentMembers, maxMembers; - Boolean isPrivate; - String password; // BCrypt 암호화 - String createdBy; // 방장 - List memberIds; - - // 게임 상태 - String gameStatus; // NONE, PLAYING, ROUND_END, FINISHED - Integer currentRound, totalRounds; - String currentDrawerId, currentWord; - Long roundStartTime; - Integer roundTimeLimit; // 60초 - List drawerOrder; - Map scores; - Map streaks; - List correctGuessers; - Boolean hintUsed; -} -``` - -**DynamoDB Keys:** - -- PK: `ROOM#{roomId}` | SK: `METADATA` -- GSI1: `ROOMS` | `{level}#{createdAt}` (레벨별 최신순) - -### 6.2 Connection - -```java - -@DynamoDbBean -public class Connection { - String connectionId; // API Gateway 연결 ID - String userId; - String roomId; - Long ttl; // 10분 (자동 삭제) -} -``` - -**DynamoDB Keys:** - -- PK: `CONN#{connectionId}` | SK: `METADATA` -- GSI1: `ROOM#{roomId}` | `CONN#{connectionId}` (방별 연결) -- GSI2: `USER#{userId}` | `CONN#{connectionId}` (사용자별 연결) - -### 6.3 GameRound - -```java - -@DynamoDbBean -public class GameRound { - Integer roundNumber; - String drawerId, word, wordEnglish; - List correctGuessers; - Map guessTimes; // 정답까지 걸린 시간 - Map roundScores; - Long startTime, endTime; - String endReason; // TIME_UP, ALL_CORRECT, SKIP - Long ttl; // 7일 -} -``` - -### 6.4 RoomToken - -```java - -@DynamoDbBean -public class RoomToken { - String token; // UUID - String roomId; - String userId; - Long ttl; // 5분 -} -``` - ---- - -## 7. 서비스 레이어 - -### 7.1 CQRS 패턴 - -| Service | 역할 | -|------------------------|----------------------| -| ChatRoomCommandService | 채팅방 생성, 입장, 퇴장, 삭제 | -| ChatRoomQueryService | 채팅방 조회, 목록 | -| GameService | 게임 시작, 정답 체크, 라운드 종료 | -| GameStatsService | 게임 종료 후 통계, 배지 처리 | -| CommandService | 슬래시 명령어 처리 | -| RoomTokenService | 토큰 발급 및 검증 | - -### 7.2 게임 정답 체크 로직 - -```mermaid -flowchart TB - INPUT[정답 입력] --> NORMALIZE["정규화
(소문자, 공백제거)"] - NORMALIZE --> VALIDATE{유효성 검사} - VALIDATE -->|게임 미진행| REJECT1[거부: 게임 없음] - VALIDATE -->|출제자 본인| REJECT2[거부: 출제자] - VALIDATE -->|이미 정답| REJECT3[거부: 중복] - VALIDATE -->|통과| COMPARE{정답 비교} - COMPARE -->|일치| CORRECT["정답 처리
점수 계산"] - COMPARE -->|불일치| WRONG["오답 처리
GUESS 메시지 전송"] - CORRECT --> BROADCAST["브로드캐스트
CORRECT_ANSWER + SCORE_UPDATE"] - WRONG --> GUESSBROADCAST["브로드캐스트
GUESS 메시지"] - BROADCAST --> ALLCHECK{전원 정답?} - ALLCHECK -->|Yes| ROUNDEND[라운드 자동 종료] - ALLCHECK -->|No| CONTINUE[게임 계속] -``` - ---- - -## 8. 브로드캐스트 시스템 - -### 8.1 WebSocketBroadcaster - -```java -public class WebSocketBroadcaster { - public List broadcast( - List connections, - String payload - ) { - // 1. 같은 방 모든 연결에 메시지 전송 - // 2. 실패한 연결 ID 반환 (Stale 정리용) - } -} -``` - -### 8.2 브로드캐스트 유형 - -| 유형 | 대상 | 예시 | -|--------|--------|-----------| -| 전체 | 방 전체 | 채팅, 정답 알림 | -| 본인 제외 | 발신자 제외 | 그림 데이터 | -| 출제자 전용 | 출제자만 | 단어 정보 | - ---- - -## 9. 파일 구조 - -``` -domain/chatting/ -├── handler/ -│ ├── ChatRoomHandler.java -│ ├── ChatMessageHandler.java -│ ├── ChatVoiceHandler.java -│ ├── GameHandler.java -│ └── websocket/ -│ ├── WebSocketConnectHandler.java -│ ├── WebSocketDisconnectHandler.java -│ └── WebSocketMessageHandler.java -├── service/ -│ ├── ChatRoomCommandService.java -│ ├── ChatRoomQueryService.java -│ ├── ChatMessageService.java -│ ├── GameService.java -│ ├── GameStatsService.java -│ ├── CommandService.java -│ └── RoomTokenService.java -├── repository/ -│ ├── ChatRoomRepository.java -│ ├── ChatMessageRepository.java -│ ├── ConnectionRepository.java -│ ├── GameRoundRepository.java -│ └── RoomTokenRepository.java -├── model/ -│ ├── ChatRoom.java -│ ├── ChatMessage.java -│ ├── Connection.java -│ ├── GameRound.java -│ └── RoomToken.java -├── dto/ -│ ├── request/ -│ └── response/ -│ └── ScoreUpdateMessage.java -└── enums/ - ├── GameStatus.java - └── MessageType.java -``` - ---- - -## 10. 기술 스택 - -- **Runtime:** AWS Lambda (Java 21) -- **API:** API Gateway REST + WebSocket -- **Database:** DynamoDB (Single Table Design) -- **Auth:** Cognito + RoomToken -- **Encryption:** BCrypt (비밀방 암호) -- **TTS:** AWS Polly + S3 캐시 -- **Pattern:** CQRS, Repository, Factory diff --git a/docs/domain-reports/COMMON-MODULE-REPORT.md b/docs/domain-reports/COMMON-MODULE-REPORT.md deleted file mode 100644 index aefe6d08..00000000 --- a/docs/domain-reports/COMMON-MODULE-REPORT.md +++ /dev/null @@ -1,1228 +0,0 @@ -# Common Module 세부 보고서 - -## 1. 개요 - -Common 모듈은 모든 도메인에서 공유하는 유틸리티, 설정, 예외 처리, 라우팅 등을 제공하는 핵심 인프라 모듈입니다. Java 21의 최신 기능(Records, Sealed Interface, Pattern -Matching)을 적극 활용하여 타입 안전성과 코드 간결성을 확보했습니다. - ---- - -## 2. 전체 패키지 구조 - -```mermaid -flowchart TB -subgraph Common["common/"] -CONFIG[config/] -CONST[constants/] -DTO[dto/] -ENUM[enums/] -EXCEPTION[exception/] -ROUTER[router/] -SERVICE[service/] -UTIL[util/] -VALIDATION[validation/] -end - -subgraph ConfigFiles["config/"] -AC[AwsClients.java] -WSC[WebSocketConfig.java] -RTC[RoomTokenConfig.java] -SC[StudyConfig.java] -end - -subgraph DtoFiles["dto/"] -AR[ApiResponse.java] -EI[ErrorInfo.java] -PR[PaginatedResult.java] -end - -subgraph ExceptionFiles["exception/"] -SE[ServerlessException.java] -EC[ErrorCode.java] -CEC[CommonErrorCode.java] -CE[CommonException.java] -end - -subgraph RouterFiles["router/"] -HR[HandlerRouter.java] -RT[Route.java] -AH[AuthenticatedHandler.java] -end - -CONFIG --> ConfigFiles -DTO --> DtoFiles -EXCEPTION --> ExceptionFiles -ROUTER --> RouterFiles -``` - ---- - -## 3. Handler 라우팅 시스템 - -### 3.1 HandlerRouter 아키텍처 - -```mermaid -flowchart TB - subgraph Request["요청 처리 흐름"] - REQ[APIGatewayProxyRequestEvent] --> ROUTER[HandlerRouter] - ROUTER --> MATCH{라우트 매칭} - MATCH -->|매칭 성공| VALIDATE[파라미터 검증] - MATCH -->|매칭 실패| NF404[404 Not Found] - VALIDATE --> EXECUTE[핸들러 실행] - EXECUTE --> RESPONSE[APIGatewayProxyResponseEvent] - end - - subgraph ErrorHandling["예외 처리"] - EXECUTE -->|ServerlessException| ERR1[ErrorCode 기반 응답] - EXECUTE -->|IllegalArgumentException| ERR2[400 Bad Request] - EXECUTE -->|IllegalStateException| ERR3[409 Conflict] - EXECUTE -->|SecurityException| ERR4[403 Forbidden] - EXECUTE -->|기타 예외| ERR5[500 Internal Error] - end -``` - -### 3.2 Route 정의 (Java 21 Record) - -```java -// Route.java - Java 21 Record 활용 -public record Route( - String method, // HTTP 메서드 - String pathPattern, // 경로 패턴 (e.g., "/rooms/{roomId}") - Function handler, - List requiredPathParams, // 필수 경로 파라미터 - List requiredQueryParams // 필수 쿼리 파라미터 - ) { - // 경로 파라미터 자동 추출: {roomId} → roomId - private static final Pattern PATH_PARAM_PATTERN = - Pattern.compile("\\{([^}]+)}"); -} -``` - -### 3.3 Route 팩토리 메서드 - -```mermaid -flowchart LR - subgraph BasicRoutes["기본 라우트"] - GET["Route.get()"] - POST["Route.post()"] - PUT["Route.put()"] - DELETE["Route.delete()"] - PATCH["Route.patch()"] - end - - subgraph AuthRoutes["인증 라우트"] - GETAUTH["Route.getAuth()"] - POSTAUTH["Route.postAuth()"] - PUTAUTH["Route.putAuth()"] - DELETEAUTH["Route.deleteAuth()"] - PATCHAUTH["Route.patchAuth()"] - end - - BasicRoutes -->|" + Cognito 인증 "| AuthRoutes -``` - -### 3.4 사용 예시 - -```java -// Handler에서 라우터 초기화 -private HandlerRouter initRouter() { - return new HandlerRouter().addRoutes( - // 인증 필요 라우트 (Cognito userId 자동 추출) - Route.postAuth("/grammar/check", this::checkGrammar), - Route.getAuth("/grammar/sessions/{sessionId}", this::getSessionDetail), - Route.deleteAuth("/grammar/sessions/{sessionId}", this::deleteSession), - - // 쿼리 파라미터 검증 - Route.getAuth("/rooms", this::getRooms) - .requireQueryParams("level") - ); -} - -// Lambda 핸들러 메서드 -@Override -public APIGatewayProxyResponseEvent handleRequest( - APIGatewayProxyRequestEvent request, Context context) { - return router.route(request); -} -``` - -### 3.5 AuthenticatedHandler 인터페이스 - -```java -// 함수형 인터페이스 - Cognito 인증 요청 처리 -@FunctionalInterface -public interface AuthenticatedHandler { - APIGatewayProxyResponseEvent handle( - APIGatewayProxyRequestEvent request, - String userId // Cognito sub claim에서 자동 추출 - ); -} - -// 사용 예시 - 람다 표현식으로 간결하게 -Route. - -postAuth("/rooms",(request, userId) ->{ -CreateRoomRequest dto = parseBody(request, CreateRoomRequest.class); -ChatRoom room = roomService.createRoom(userId, dto); - return ResponseGenerator. - -created("Room created",room); -}); -``` - ---- - -## 4. 예외 처리 시스템 - -### 4.1 ErrorCode 계층 구조 (Sealed Interface) - -```mermaid -flowchart TB - subgraph SealedHierarchy["Java 21 Sealed Interface 계층"] - EC[/"ErrorCode
(sealed interface)"/] - EC -->|permits| CEC["CommonErrorCode
(enum)"] - EC -->|permits| DEC[/"DomainErrorCode
(non-sealed interface)"/] - DEC --> VEC["VocabularyErrorCode"] - DEC --> CHEC["ChattingErrorCode"] - DEC --> GEC["GrammarErrorCode"] - DEC --> SEC["StatsErrorCode"] - DEC --> BEC["BadgeErrorCode"] - end -``` - -### 4.2 CommonErrorCode 정의 - -```java -public enum CommonErrorCode implements ErrorCode { - // 인증/인가 (AUTH_xxx) - UNAUTHORIZED("AUTH_001", "인증이 필요합니다", 401), - FORBIDDEN("AUTH_002", "접근 권한이 없습니다", 403), - INVALID_TOKEN("AUTH_003", "유효하지 않은 토큰입니다", 401), - TOKEN_EXPIRED("AUTH_004", "토큰이 만료되었습니다", 401), - - // 검증 (VALIDATION_xxx) - INVALID_INPUT("VALIDATION_001", "잘못된 입력입니다", 400), - REQUIRED_FIELD_MISSING("VALIDATION_002", "필수 필드가 누락되었습니다", 400), - INVALID_FORMAT("VALIDATION_003", "형식이 올바르지 않습니다", 400), - VALUE_OUT_OF_RANGE("VALIDATION_004", "값이 허용 범위를 벗어났습니다", 400), - - // 리소스 (RESOURCE_xxx) - RESOURCE_NOT_FOUND("RESOURCE_001", "리소스를 찾을 수 없습니다", 404), - RESOURCE_ALREADY_EXISTS("RESOURCE_002", "이미 존재하는 리소스입니다", 409), - METHOD_NOT_ALLOWED("RESOURCE_003", "허용되지 않는 메서드입니다", 405), - - // 시스템 (SYSTEM_xxx) - INTERNAL_SERVER_ERROR("SYSTEM_001", "내부 서버 오류가 발생했습니다", 500), - DATABASE_ERROR("SYSTEM_002", "데이터베이스 오류가 발생했습니다", 500), - EXTERNAL_API_ERROR("SYSTEM_003", "외부 API 호출 오류가 발생했습니다", 502), - SERVICE_UNAVAILABLE("SYSTEM_004", "서비스를 일시적으로 사용할 수 없습니다", 503); - - private final String code; - private final String message; - private final int statusCode; -} -``` - -### 4.3 예외 생성 팩토리 패턴 - -```mermaid -flowchart LR - subgraph FactoryMethods["CommonException 팩토리 메서드"] - AUTH["인증 오류"] - VALID["검증 오류"] - RES["리소스 오류"] - SYS["시스템 오류"] - end - - AUTH --> UNAUTH["unauthorized()"] - AUTH --> FORBID["forbidden()"] - AUTH --> TOKEN["invalidToken()"] - VALID --> INPUT["invalidInput(msg)"] - VALID --> MISS["requiredFieldMissing(field)"] - VALID --> FMT["invalidFormat(field)"] - RES --> NF["notFound(resource, id)"] - RES --> EXIST["alreadyExists(resource)"] - SYS --> INTERN["internalError(cause)"] - SYS --> DB["databaseError(cause)"] - SYS --> EXT["externalApiError(api, cause)"] -``` - -### 4.4 예외 사용 예시 - -```java -// 가독성 높은 예외 생성 -throw CommonException.notFound("User","user123"); -// → "User (ID: user123)를 찾을 수 없습니다", 404 - -throw CommonException. - -invalidInput("Email format is invalid"); -// → 400 INVALID_INPUT with custom message - -throw CommonException. - -alreadyExists("ChatRoom","room456"); -// → "ChatRoom (ID: room456)가 이미 존재합니다", 409 - -// 상세 컨텍스트 추가 (메서드 체이닝) -throw CommonException. - -internalError(cause) - . - -addDetail("operation","database_query") - . - -addDetail("table","users"); -``` - ---- - -## 5. AWS 클라이언트 관리 - -### 5.1 Singleton 패턴 (Cold Start 최적화) - -```mermaid -flowchart TB - subgraph ColdStart["Lambda Cold Start 최적화"] - INIT["Lambda 컨테이너 초기화
(1회)"] - STATIC["static final 클라이언트 생성"] - REUSE["요청마다 재사용"] - end - - INIT --> STATIC - STATIC --> REUSE - REUSE -->|" 다음 요청 "| REUSE -``` - -### 5.2 AwsClients.java 구조 - -```java -public final class AwsClients { - // DynamoDB (Enhanced Client 포함) - private static final DynamoDbClient DYNAMO_DB_CLIENT = - DynamoDbClient.builder().build(); - private static final DynamoDbEnhancedClient DYNAMO_DB_ENHANCED_CLIENT = - DynamoDbEnhancedClient.builder() - .dynamoDbClient(DYNAMO_DB_CLIENT) - .build(); - - // S3 (Presigner 포함) - private static final S3Client S3_CLIENT = S3Client.builder().build(); - private static final S3Presigner S3_PRESIGNER = S3Presigner.builder().build(); - - // AI/ML 서비스 - private static final PollyClient POLLY_CLIENT = PollyClient.builder().build(); - private static final BedrockRuntimeClient BEDROCK_CLIENT = - BedrockRuntimeClient.builder().build(); - private static final BedrockRuntimeAsyncClient BEDROCK_ASYNC_CLIENT = - BedrockRuntimeAsyncClient.builder().build(); - private static final ComprehendClient COMPREHEND_CLIENT = - ComprehendClient.builder().build(); - - // SNS - private static final SnsClient SNS_CLIENT = SnsClient.builder().build(); - - // 팩토리 메서드 - public static DynamoDbClient dynamoDb() { - return DYNAMO_DB_CLIENT; - } - - public static DynamoDbEnhancedClient dynamoDbEnhanced() { - return DYNAMO_DB_ENHANCED_CLIENT; - } - - public static S3Client s3() { - return S3_CLIENT; - } - - public static S3Presigner s3Presigner() { - return S3_PRESIGNER; - } - - public static PollyClient polly() { - return POLLY_CLIENT; - } - - public static BedrockRuntimeClient bedrock() { - return BEDROCK_CLIENT; - } - - public static BedrockRuntimeAsyncClient bedrockAsync() { - return BEDROCK_ASYNC_CLIENT; - } - - public static ComprehendClient comprehend() { - return COMPREHEND_CLIENT; - } - - public static SnsClient sns() { - return SNS_CLIENT; - } -} -``` - -### 5.3 사용 예시 - -```java -// Service에서 사용 -public class PollyService { - public VoiceSynthesisResult synthesizeSpeech(String id, String text, String voice) { - SynthesizeSpeechRequest request = SynthesizeSpeechRequest.builder() - .text(text) - .voiceId(VoiceId.MATTHEW) - .engine("neural") - .outputFormat(OutputFormat.MP3) - .build(); - - // Singleton 클라이언트 사용 - InputStream audioStream = AwsClients.polly().synthesizeSpeech(request); - AwsClients.s3().putObject(putRequest, RequestBody.fromInputStream(audioStream, -1)); - - return new VoiceSynthesisResult(s3Key, presignedUrl, false); - } -} -``` - ---- - -## 6. DTO 패턴 (Java 21 Records) - -### 6.1 ApiResponse (제네릭 응답 래퍼) - -```java -// 불변 데이터 클래스 - Java 21 Record -public record ApiResponse( - boolean isSuccess, - String message, - T data, - String error - ) { - // 성공 응답 팩토리 - public static ApiResponse ok(String message, T data) { - return new ApiResponse<>(true, message, data, null); - } - - public static ApiResponse ok(T data) { - return new ApiResponse<>(true, null, data, null); - } - - // 실패 응답 팩토리 - public static ApiResponse fail(String errorMessage) { - return new ApiResponse<>(false, null, null, errorMessage); - } -} -``` - -**JSON 응답 예시:** - -```json -{ - "isSuccess": true, - "message": "Grammar checked successfully", - "data": { - "correctedSentence": "I am a student", - "score": 85, - "errors": [ - ... - ] - }, - "error": null -} -``` - -### 6.2 ErrorInfo (RFC 7807 준수) - -```java -// Problem Details for HTTP APIs (RFC 7807) -public record ErrorInfo( - String code, // e.g., "VOCABULARY.WORD_001" - String message, // e.g., "단어를 찾을 수 없습니다" - int status, // e.g., 404 - Map details // Optional context - ) { - public static ErrorInfo from(ErrorCode errorCode) { ...} - - public static ErrorInfo from(ServerlessException ex) { ...} - - public boolean isClientError() { - return status >= 400 && status < 500; - } - - public boolean isServerError() { - return status >= 500 && status < 600; - } -} -``` - -**JSON 에러 응답 예시:** - -```json -{ - "code": "VOCABULARY.WORD_001", - "message": "단어를 찾을 수 없습니다", - "status": 404, - "details": { - "wordId": "abc-123", - "userId": "user456" - } -} -``` - -### 6.3 PaginatedResult (커서 페이지네이션) - -```java -public record PaginatedResult( - List items, - String nextCursor // Base64 인코딩된 DynamoDB lastEvaluatedKey -) { - public boolean hasMore() { - return nextCursor != null; - } -} -``` - ---- - -## 7. 페이지네이션 유틸리티 - -### 7.1 CursorUtil 동작 흐름 - -```mermaid -sequenceDiagram - participant Client - participant Handler - participant CursorUtil - participant DynamoDB - Note over Client, DynamoDB: 첫 페이지 요청 - Client ->> Handler: GET /items?limit=10 - Handler ->> CursorUtil: decode(null) → null - Handler ->> DynamoDB: Query (exclusiveStartKey=null) - DynamoDB -->> Handler: items + lastEvaluatedKey - Handler ->> CursorUtil: encode(lastEvaluatedKey) - CursorUtil -->> Handler: "dXNlcklkPXVzZXIxMjM..." - Handler -->> Client: {"items": [...], "nextCursor": "dXNlcklkPXVzZXIxMjM..."} - Note over Client, DynamoDB: 다음 페이지 요청 - Client ->> Handler: GET /items?cursor=dXNlcklkPXVzZXIxMjM... - Handler ->> CursorUtil: decode("dXNlcklkPXVzZXIxMjM...") - CursorUtil -->> Handler: {"userId": "user123", ...} - Handler ->> DynamoDB: Query (exclusiveStartKey={...}) - DynamoDB -->> Handler: items + lastEvaluatedKey -``` - -### 7.2 CursorUtil 구현 - -```java -public class CursorUtil { - // DynamoDB lastEvaluatedKey → Base64 문자열 - public static String encode(Map lastEvaluatedKey) { - if (lastEvaluatedKey == null || lastEvaluatedKey.isEmpty()) { - return null; - } - - StringBuilder sb = new StringBuilder(); - for (Map.Entry entry : lastEvaluatedKey.entrySet()) { - if (sb.length() > 0) sb.append("|"); - sb.append(entry.getKey()).append("=").append(entry.getValue().s()); - } - - return Base64.getUrlEncoder().encodeToString(sb.toString().getBytes()); - } - - // Base64 문자열 → DynamoDB exclusiveStartKey - public static Map decode(String cursor) { - if (cursor == null || cursor.isEmpty()) { - return null; - } - - String decoded = new String(Base64.getUrlDecoder().decode(cursor)); - Map startKey = new HashMap<>(); - - for (String pair : decoded.split("\\|")) { - String[] kv = pair.split("=", 2); - if (kv.length == 2) { - startKey.put(kv[0], AttributeValue.builder().s(kv[1]).build()); - } - } - - return startKey; - } -} -``` - ---- - -## 8. 인증 유틸리티 - -### 8.1 Cognito 인증 흐름 - -```mermaid -flowchart TB - subgraph CognitoAuth["Cognito 인증 흐름"] - REQ[요청] --> AUTH[API Gateway Authorizer] - AUTH --> CLAIMS[JWT Claims 추출] - CLAIMS --> INJECT["requestContext.authorizer.claims"] - end - - subgraph CognitoUtil["CognitoUtil 추출"] - INJECT --> EXTRACT[extractUserId] - EXTRACT --> SUB["claims.sub → userId"] - end -``` - -### 8.2 CognitoUtil.java - -```java -public class CognitoUtil { - // 기본 userId 추출 (sub claim) - public static String extractUserId(APIGatewayProxyRequestEvent request) { - Map authorizer = request.getRequestContext().getAuthorizer(); - if (authorizer == null) return null; - - Map claims = (Map) authorizer.get("claims"); - return claims != null ? claims.get("sub") : null; - } - - // 선택적 claim 추출 - public static Optional extractEmail(APIGatewayProxyRequestEvent request) { - return extractClaim(request, "email"); - } - - public static Optional extractNickname(APIGatewayProxyRequestEvent request) { - return extractClaim(request, "custom:nickname"); - } - - public static Optional extractClaim( - APIGatewayProxyRequestEvent request, String claimName) { - // ... claim 추출 로직 - } - - // 사용자 접근 권한 검증 - public static boolean validateUserAccess( - APIGatewayProxyRequestEvent request, String pathUserId) { - String tokenUserId = extractUserId(request); - return tokenUserId != null && tokenUserId.equals(pathUserId); - } -} -``` - -### 8.3 JwtUtil.java (WebSocket용) - -```java -// WebSocket 연결 시 직접 JWT 파싱 (Authorizer 미사용) -public final class JwtUtil { - public static Optional extractUserId(String token) { - // Bearer 제거 - if (token.startsWith("Bearer ")) { - token = token.substring(7); - } - - // JWT payload 추출 (헤더.페이로드.시그니처) - String[] parts = token.split("\\."); - if (parts.length != 3) return Optional.empty(); - - // Base64 URL 디코딩 - String payload = new String(Base64.getUrlDecoder().decode(parts[1])); - Map claims = gson.fromJson(payload, Map.class); - - return Optional.ofNullable((String) claims.get("sub")); - } - - public static boolean isExpired(String token) { - // exp claim 확인 - } -} -``` - ---- - -## 9. HTTP 응답 생성 - -### 9.1 ResponseGenerator.java - -```java -public class ResponseGenerator { - private static final Gson GSON = new GsonBuilder() - .setDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'") - .create(); - - private static final Map CORS_HEADERS = Map.of( - "Content-Type", "application/json", - "Access-Control-Allow-Origin", "*", - "Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS", - "Access-Control-Allow-Headers", "Content-Type,Authorization" - ); - - // 성공 응답 - public static APIGatewayProxyResponseEvent ok(String message, T data) { - return buildResponse(200, ApiResponse.ok(message, data)); - } - - public static APIGatewayProxyResponseEvent created(String message, T data) { - return buildResponse(201, ApiResponse.ok(message, data)); - } - - public static APIGatewayProxyResponseEvent noContent() { - return buildResponse(204, null); - } - - // 에러 응답 - public static APIGatewayProxyResponseEvent fail(ErrorCode errorCode) { - return buildResponse(errorCode.getStatusCode(), ErrorInfo.from(errorCode)); - } - - public static APIGatewayProxyResponseEvent badRequest(String message) { - return fail(CommonErrorCode.INVALID_INPUT, message); - } - - public static APIGatewayProxyResponseEvent notFound(String message) { - return fail(CommonErrorCode.RESOURCE_NOT_FOUND, message); - } - - // ... 기타 편의 메서드 - - private static APIGatewayProxyResponseEvent buildResponse(int statusCode, Object body) { - return new APIGatewayProxyResponseEvent() - .withStatusCode(statusCode) - .withHeaders(new HashMap<>(CORS_HEADERS)) - .withBody(body != null ? GSON.toJson(body) : null); - } - - public static Gson gson() { - return GSON; - } -} -``` - ---- - -## 10. Bean Validation - -### 10.1 BeanValidator 패턴 - -```mermaid -flowchart TB - REQ[요청 수신] --> PARSE[JSON 파싱 → DTO] -PARSE --> VALIDATE[BeanValidator.validateAndExecute] -VALIDATE --> CHECK{검증 통과?} -CHECK -->|Yes|HANDLER[핸들러 로직 실행] -CHECK -->|No|ERR400[400 Bad Request] -HANDLER --> RESPONSE[정상 응답] -``` - -### 10.2 BeanValidator.java - -```java -public final class BeanValidator { - private static final Validator VALIDATOR; - - static { - ValidatorFactory factory = Validation.buildDefaultValidatorFactory(); - VALIDATOR = factory.getValidator(); - } - - // 검증 + 실행 통합 패턴 - public static APIGatewayProxyResponseEvent validateAndExecute( - T object, - Function handler) { - - Optional error = validate(object); - if (error.isPresent()) { - return ResponseGenerator.badRequest(error.get()); - } - - return handler.apply(object); - } - - public static Optional validate(T object) { - Set> violations = VALIDATOR.validate(object); - if (violations.isEmpty()) { - return Optional.empty(); - } - - String message = violations.stream() - .map(ConstraintViolation::getMessage) - .collect(Collectors.joining(", ")); - - return Optional.of(message); - } -} -``` - -### 10.3 DTO 검증 예시 - -```java -// 요청 DTO -public class CreateRoomRequest { - @NotEmpty(message = "방 이름은 필수입니다") - private String roomName; - - @NotNull(message = "난이도는 필수입니다") - private String difficulty; - - @Min(value = 2, message = "최소 2명 이상이어야 합니다") - @Max(value = 10, message = "최대 10명까지 가능합니다") - private int maxMembers; -} - -// Handler에서 사용 -private APIGatewayProxyResponseEvent createRoom( - APIGatewayProxyRequestEvent request, String userId) { - - CreateRoomRequest req = ResponseGenerator.gson() - .fromJson(request.getBody(), CreateRoomRequest.class); - - return BeanValidator.validateAndExecute(req, dto -> { - // 검증 통과 시에만 실행됨 - ChatRoom room = roomService.createRoom(userId, dto); - return ResponseGenerator.created("방이 생성되었습니다", room); - }); -} -``` - ---- - -## 11. WebSocket 유틸리티 - -### 11.1 브로드캐스트 흐름 - -```mermaid -sequenceDiagram - participant Service - participant Broadcaster as WebSocketBroadcaster - participant APIGW as API Gateway - participant Clients as WebSocket Clients - Service ->> Broadcaster: broadcast(connections, message) - - loop 각 연결에 전송 - Broadcaster ->> APIGW: postToConnection(connectionId, data) - alt 성공 - APIGW -->> Clients: 메시지 전달 - else 연결 끊김 (410 Gone) - APIGW -->> Broadcaster: GoneException - Broadcaster ->> Broadcaster: failedIds에 추가 - end - end - - Broadcaster -->> Service: failedConnectionIds 반환 - Service ->> Service: Stale 연결 정리 -``` - -### 11.2 WebSocketBroadcaster.java - -```java -public class WebSocketBroadcaster { - private final ApiGatewayManagementApiClient apiClient; - - public WebSocketBroadcaster() { - String endpoint = WebSocketConfig.websocketEndpoint(); - this.apiClient = ApiGatewayManagementApiClient.builder() - .endpointOverride(URI.create(endpoint)) - .build(); - } - - // 단일 연결에 전송 - public boolean sendToConnection(String connectionId, String message) { - try { - apiClient.postToConnection(PostToConnectionRequest.builder() - .connectionId(connectionId) - .data(SdkBytes.fromUtf8String(message)) - .build()); - return true; - } catch (GoneException e) { - // 연결이 이미 끊김 - return false; - } - } - - // 다수 연결에 브로드캐스트 - public List broadcast(List connections, String message) { - List failedIds = new ArrayList<>(); - - for (Connection conn : connections) { - if (!sendToConnection(conn.getConnectionId(), message)) { - failedIds.add(conn.getConnectionId()); - } - } - - return failedIds; // 실패한 연결 ID 반환 (정리용) - } -} -``` - -### 11.3 WebSocket 응답 유틸리티 - -```java -public final class WebSocketResponseUtil { - public static Map ok(String message) { - return response(200, message); - } - - public static Map unauthorized(String message) { - return response(401, message); - } - - public static Map badRequest(String message) { - return response(400, message); - } - - private static Map response(int statusCode, String body) { - return Map.of( - "statusCode", statusCode, - "body", body - ); - } -} -``` - ---- - -## 12. S3 Presigned URL - -### 12.1 S3PresignUtil.java - -```java -public class S3PresignUtil { - private static final Duration DEFAULT_DURATION = Duration.ofHours(24); - private static final String BUCKET_NAME = System.getenv("S3_BUCKET_NAME"); - - // 내부 캐시 (Java 21 Record) - private record CachedUrl(String url, long expiresAt) { - boolean isExpired() { - // 1시간 버퍼 두고 만료 체크 - return System.currentTimeMillis() > (expiresAt - 3600_000); - } - } - - private static final Map URL_CACHE = new ConcurrentHashMap<>(); - - public static String getPresignedUrl(String key) { - return getPresignedUrl(key, DEFAULT_DURATION); - } - - public static String getPresignedUrl(String key, Duration duration) { - CachedUrl cached = URL_CACHE.get(key); - if (cached != null && !cached.isExpired()) { - return cached.url(); - } - - GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() - .signatureDuration(duration) - .getObjectRequest(r -> r.bucket(BUCKET_NAME).key(key)) - .build(); - - String url = AwsClients.s3Presigner() - .presignGetObject(presignRequest) - .url() - .toString(); - - URL_CACHE.put(key, new CachedUrl(url, - System.currentTimeMillis() + duration.toMillis())); - - return url; - } - - // 배지 이미지 URL 생성 편의 메서드 - public static String getBadgeImageUrl(String imageFile) { - return getPresignedUrl("badges/" + imageFile); - } -} -``` - ---- - -## 13. AWS 서비스 래퍼 - -### 13.1 PollyService (TTS + S3 캐시) - -```mermaid -flowchart TB - REQ[음성 합성 요청] --> CHECK{S3 캐시 확인} - CHECK -->|캐시 있음| PRESIGN[Presigned URL 생성] - CHECK -->|캐시 없음| SYNTH[Polly 음성 합성] - SYNTH --> SAVE[S3 저장] - SAVE --> PRESIGN - PRESIGN --> RETURN[URL 반환] -``` - -```java -public class PollyService { - public VoiceSynthesisResult synthesizeSpeech(String id, String text, String voice) { - String s3Key = generateS3Key(id, voice); - - // 캐시 확인 - if (existsInS3(s3Key)) { - return new VoiceSynthesisResult(s3Key, getPresignedUrl(s3Key), true); - } - - // Polly 음성 합성 - VoiceId voiceId = "MALE".equalsIgnoreCase(voice) ? VoiceId.MATTHEW : VoiceId.JOANNA; - - SynthesizeSpeechRequest request = SynthesizeSpeechRequest.builder() - .text(text) - .voiceId(voiceId) - .engine("neural") // Neural 음성 (고품질) - .outputFormat(OutputFormat.MP3) - .build(); - - InputStream audioStream = AwsClients.polly().synthesizeSpeech(request); - - // S3 저장 - AwsClients.s3().putObject( - PutObjectRequest.builder() - .bucket(bucketName) - .key(s3Key) - .contentType("audio/mpeg") - .build(), - RequestBody.fromInputStream(audioStream, -1) - ); - - return new VoiceSynthesisResult(s3Key, getPresignedUrl(s3Key), false); - } - - public String generateS3Key(String id, String voice) { - String suffix = "MALE".equalsIgnoreCase(voice) ? "male" : "female"; - return s3KeyPrefix + id + "_" + suffix + ".mp3"; - } -} -``` - -### 13.2 ComprehendService (NLP 분석) - -```java -public class ComprehendService { - public ComprehendAnalysis analyze(String text) { - // 감정 분석 - DetectSentimentResponse sentiment = AwsClients.comprehend() - .detectSentiment(DetectSentimentRequest.builder() - .text(text) - .languageCode("en") - .build()); - - // 구문 분석 (품사 태깅) - DetectSyntaxResponse syntax = AwsClients.comprehend() - .detectSyntax(DetectSyntaxRequest.builder() - .text(text) - .languageCode("en") - .build()); - - // 핵심 구문 추출 - DetectKeyPhrasesResponse keyPhrases = AwsClients.comprehend() - .detectKeyPhrases(DetectKeyPhrasesRequest.builder() - .text(text) - .languageCode("en") - .build()); - - // 문장 복잡도 계산 - String complexity = calculateComplexity(syntax.syntaxTokens()); - - return ComprehendAnalysis.builder() - .sentiment(sentiment.sentimentAsString()) - .syntax(mapTokens(syntax.syntaxTokens())) - .keyPhrases(mapKeyPhrases(keyPhrases.keyPhrases())) - .complexity(complexity) - .build(); - } - - private String calculateComplexity(List tokens) { - Set uniquePOS = tokens.stream() - .map(t -> t.partOfSpeech().tagAsString()) - .collect(Collectors.toSet()); - - if (uniquePOS.size() <= 3 && tokens.size() <= 5) return "BEGINNER"; - if (uniquePOS.size() <= 5 && tokens.size() <= 10) return "INTERMEDIATE"; - return "ADVANCED"; - } -} -``` - ---- - -## 14. 설정 클래스 - -### 14.1 StudyConfig (학습 알고리즘 상수) - -```java -public final class StudyConfig { - // SM-2 알고리즘 상수 - public static final int INITIAL_INTERVAL_DAYS = 1; - public static final double DEFAULT_EASE_FACTOR = 2.5; - public static final double MIN_EASE_FACTOR = 1.3; - public static final int INITIAL_REPETITIONS = 0; - - // 테스트 설정 - public static final int DEFAULT_WORD_COUNT = 20; - public static final int DAILY_TEST_WORD_COUNT = 10; - - // 복습 주기 (일) - public static final int[] REVIEW_INTERVALS = {1, 3, 7, 14, 30}; - - // 상태 기본값 - public static final String DEFAULT_WORD_STATUS = "NEW"; - public static final String DEFAULT_DIFFICULTY = "NORMAL"; - - // 오류 제한 - public static final int MAX_WRONG_COUNT = 3; -} -``` - -### 14.2 DynamoDbKey (키 패턴 상수) - -```java -public final class DynamoDbKey { - // 기본 키 - public static final String PK = "PK"; - public static final String SK = "SK"; - - // GSI 키 - public static final String GSI1_PK = "GSI1PK"; - public static final String GSI1_SK = "GSI1SK"; - public static final String GSI2_PK = "GSI2PK"; - public static final String GSI2_SK = "GSI2SK"; - - // GSI 이름 - public static final String GSI1 = "GSI1"; - public static final String GSI2 = "GSI2"; - - // 공통 접두사 - public static final String USER = "USER#"; - public static final String METADATA = "METADATA"; - - // 헬퍼 메서드 - public static String userPk(String userId) { - return USER + userId; // "USER#user-123" - } -} -``` - ---- - -## 15. Java 21 기능 활용 - -### 15.1 Records 활용 - -| 클래스 | 용도 | -|-----------------|----------------| -| ApiResponse | 제네릭 API 응답 래퍼 | -| ErrorInfo | RFC 7807 에러 응답 | -| PaginatedResult | 페이지네이션 결과 | -| Route | HTTP 라우트 정의 | -| RouteEntry | 라우터 내부 매칭 | -| CachedUrl | S3 URL 캐시 | - -### 15.2 Sealed Interface 활용 - -```mermaid -flowchart TB - subgraph SealedPattern["Sealed Interface 패턴"] - EC[/"sealed interface ErrorCode
permits CommonErrorCode, DomainErrorCode"/] - CEC["final enum CommonErrorCode
implements ErrorCode"] - DEC[/"non-sealed interface DomainErrorCode
extends ErrorCode"/] - EC --> CEC - EC --> DEC - end -``` - -### 15.3 Pattern Matching 활용 - -```java -// instanceof 패턴 매칭 -String code = errorCode instanceof DomainErrorCode domainCode - ? domainCode.getFullCode() // "VOCABULARY.WORD_001" - : errorCode.getCode(); // "AUTH_001" - -// switch 표현식 (Enhanced) -return switch(type. - -getCategory()){ - case"FIRST_STUDY"->stats. - -getTestsCompleted() >=1; - case"STREAK"->stats. - -getCurrentStreak() >=type. - -getThreshold(); - case"ACCURACY"->{ -double accuracy = (double) stats.getCorrectAnswers() / stats.getQuestionsAnswered() * 100; -yield accuracy >=type. - -getThreshold(); - } -default ->false; - }; -``` - ---- - -## 16. 디자인 패턴 요약 - -| 패턴 | 적용 위치 | 목적 | -|----------------------|------------------------|-------------------| -| **Singleton** | AwsClients | AWS SDK 클라이언트 재사용 | -| **Factory Method** | Route, CommonException | 객체 생성 캡슐화 | -| **Strategy** | AuthenticatedHandler | 요청 처리 전략 분리 | -| **Router** | HandlerRouter | HTTP 요청 라우팅 | -| **Builder** | ComprehendAnalysis | 복잡한 객체 생성 | -| **Template Method** | BeanValidator | 검증-실행 흐름 템플릿 | -| **Sealed Interface** | ErrorCode 계층 | 구현 제한 | -| **Data Class** | Records | 불변 데이터 전송 | - ---- - -## 17. 파일 구조 - -``` -common/ -├── config/ -│ ├── AwsClients.java # AWS SDK 클라이언트 싱글톤 -│ ├── WebSocketConfig.java # WebSocket 설정 -│ ├── RoomTokenConfig.java # 방 토큰 TTL 설정 -│ └── StudyConfig.java # 학습 알고리즘 상수 -├── constants/ -│ └── DynamoDbKey.java # DynamoDB 키 패턴 -├── dto/ -│ ├── ApiResponse.java # 제네릭 응답 래퍼 (Record) -│ ├── ErrorInfo.java # RFC 7807 에러 (Record) -│ └── PaginatedResult.java # 페이지네이션 (Record) -├── enums/ -│ ├── Difficulty.java # EASY, NORMAL, HARD -│ └── StudyLevel.java # BEGINNER, INTERMEDIATE, ADVANCED -├── exception/ -│ ├── ServerlessException.java # 기본 예외 클래스 -│ ├── ErrorCode.java # Sealed Interface -│ ├── CommonErrorCode.java # 공통 에러 코드 -│ ├── DomainErrorCode.java # 도메인 에러 인터페이스 -│ └── CommonException.java # 예외 팩토리 -├── router/ -│ ├── HandlerRouter.java # HTTP 라우터 -│ ├── Route.java # 라우트 정의 (Record) -│ └── AuthenticatedHandler.java # 인증 핸들러 인터페이스 -├── service/ -│ ├── PollyService.java # TTS + S3 캐시 -│ └── ComprehendService.java # NLP 분석 -├── util/ -│ ├── ResponseGenerator.java # HTTP 응답 빌더 -│ ├── CursorUtil.java # 커서 페이지네이션 -│ ├── CognitoUtil.java # Cognito 인증 추출 -│ ├── JwtUtil.java # JWT 직접 파싱 -│ ├── WebSocketBroadcaster.java # WebSocket 브로드캐스트 -│ ├── WebSocketEventUtil.java # WebSocket 이벤트 추출 -│ ├── WebSocketResponseUtil.java # WebSocket 응답 빌더 -│ └── S3PresignUtil.java # Presigned URL 생성 -└── validation/ - └── BeanValidator.java # Bean Validation 유틸 -``` - ---- - -## 18. 기술 스택 - -- **Runtime:** AWS Lambda (Java 21) -- **Build:** Gradle -- **AWS SDK:** AWS SDK for Java v2 -- **Validation:** Jakarta Bean Validation -- **JSON:** Gson -- **Pattern:** Singleton, Factory, Strategy, Router, Builder, Sealed Interface -- **Java 21 Features:** Records, Sealed Interface, Pattern Matching, Enhanced Switch diff --git a/docs/domain-reports/GRAMMAR-DOMAIN-REPORT.md b/docs/domain-reports/GRAMMAR-DOMAIN-REPORT.md deleted file mode 100644 index 5015a011..00000000 --- a/docs/domain-reports/GRAMMAR-DOMAIN-REPORT.md +++ /dev/null @@ -1,465 +0,0 @@ -# Grammar Domain 세부 보고서 - -## 1. 개요 - -Grammar 도메인은 AWS Bedrock(Claude 3 Haiku)을 활용한 AI 기반 영어 문법 체크 시스템입니다. REST API와 WebSocket 스트리밍을 통해 실시간 문법 교정 및 대화형 학습을 -제공합니다. - ---- - -## 2. 전체 아키텍처 - -```mermaid -flowchart TB - subgraph Client["클라이언트"] - APP[Mobile/Web App] - end - - subgraph Gateway["API Gateway"] - REST[REST API] - WS[Grammar WebSocket] - end - - subgraph Lambda["Lambda Handlers"] - HANDLER[GrammarHandler] - CONNECT[StreamingConnectHandler] - DISCONNECT[StreamingDisconnectHandler] - STREAM[StreamingHandler] - end - - subgraph AI["AWS AI 서비스"] - BEDROCK[Bedrock
Claude 3 Haiku] - COMPREHEND[Comprehend
언어 분석] - end - - subgraph Storage["저장소"] - DDB[(DynamoDB)] - end - - APP --> REST - APP <--> WS - REST --> HANDLER - WS --> CONNECT - WS --> DISCONNECT - WS --> STREAM - HANDLER --> BEDROCK - HANDLER --> COMPREHEND - STREAM --> BEDROCK - HANDLER --> DDB - STREAM --> DDB -``` - ---- - -## 3. 문법 체크 흐름 - -### 3.1 동기식 문법 체크 - -```mermaid -sequenceDiagram - participant Client - participant Handler as GrammarHandler - participant Service as GrammarCheckService - participant Bedrock as AWS Bedrock - participant DB as DynamoDB - Client ->> Handler: POST /grammar/check - Handler ->> Service: checkGrammar(sentence, level) - Service ->> Bedrock: Claude API 호출 - Bedrock -->> Service: JSON 응답 - Service -->> Handler: GrammarCheckResponse - Handler -->> Client: 문법 교정 결과 -``` - -### 3.2 스트리밍 대화 - -```mermaid -sequenceDiagram - participant Client - participant WS as WebSocket - participant Handler as StreamingHandler - participant Service as ConversationService - participant Bedrock as AWS Bedrock - Client ->> WS: $connect?token={jwt} - WS -->> Client: 연결 성공 - Client ->> WS: 메시지 전송 - WS ->> Handler: $default 라우트 - Handler ->> Service: chatStreaming() - Service -->> Client: StartEvent (sessionId) - - loop 토큰 단위 스트리밍 - Bedrock -->> Service: 텍스트 청크 - Service -->> Client: TokenEvent - end - - Service -->> Client: CompleteEvent (전체 응답) -``` - ---- - -## 4. API 엔드포인트 - -### 4.1 REST API - -| Method | Endpoint | 설명 | -|--------|-------------------------------|---------------| -| POST | /grammar/check | 문법 체크 (단일 문장) | -| POST | /grammar/conversation | 대화형 문법 학습 | -| GET | /grammar/sessions | 대화 세션 목록 | -| GET | /grammar/sessions/{sessionId} | 세션 상세 | -| DELETE | /grammar/sessions/{sessionId} | 세션 삭제 | - -### 4.2 WebSocket API - -| Route | 설명 | -|-------------|-------------| -| $connect | JWT 토큰으로 연결 | -| $disconnect | 연결 해제 | -| $default | 스트리밍 메시지 처리 | - ---- - -## 5. 레벨별 문법 체크 - -### 5.1 학습 레벨 - -| 레벨 | 설명 | 피드백 스타일 | -|--------------|----|--------------------| -| BEGINNER | 초급 | 한국어 번역 + 쉬운 설명 | -| INTERMEDIATE | 중급 | 영어 위주 설명 | -| ADVANCED | 고급 | 상세한 문법 규칙 + 스타일 제안 | - -### 5.2 오류 유형 - -```mermaid -mindmap - root((문법 오류)) - 시제 - VERB_TENSE - 동사 시제 오류 - 일치 - SUBJECT_VERB_AGREEMENT - 주어-동사 일치 - 품사 - ARTICLE - 관사 오류 - PREPOSITION - 전치사 오류 - PRONOUN - 대명사 오류 - 구조 - WORD_ORDER - 어순 오류 - SENTENCE_STRUCTURE - 문장 구조 - 기타 - SPELLING - 철자 - PUNCTUATION - 구두점 - WORD_CHOICE - 어휘 선택 -``` - ---- - -## 6. 응답 포맷 - -### 6.1 문법 체크 응답 - -```json -{ - "originalSentence": "I goed to school yesterday", - "correctedSentence": "I went to school yesterday", - "score": 70, - "isCorrect": false, - "errors": [ - { - "type": "VERB_TENSE", - "original": "goed", - "corrected": "went", - "explanation": "'go'의 과거형은 'went'입니다 (불규칙 동사)", - "startIndex": 2, - "endIndex": 6 - } - ], - "feedback": "과거 시제를 잘 사용하려고 노력했네요! 불규칙 동사를 조금 더 연습해보세요." -} -``` - -### 6.2 대화 응답 - -```json -{ - "sessionId": "uuid", - "grammarCheck": { - /* 위와 동일 */ - }, - "aiResponse": "Great job! Your sentence structure is correct. Let's practice more complex sentences.", - "conversationTip": "Try using 'had gone' for past perfect tense." -} -``` - -### 6.3 스트리밍 이벤트 - -```json -// StartEvent -{ - "type": "start", - "sessionId": "uuid" -} - -// TokenEvent (실시간) -{ - "type": "token", - "token": "Great " -} -{ - "type": "token", - "token": "job!" -} - -// CompleteEvent (완료) -{ - "type": "complete", - "sessionId": "uuid", - "grammarCheck": { - ... - }, - "aiResponse": "...", - "conversationTip": "..." -} - -// ErrorEvent (오류 시) -{ - "type": "error", - "message": "..." -} -``` - ---- - -## 7. AWS Bedrock 통합 - -### 7.1 Claude 3 Haiku 설정 - -```java -public class BedrockGrammarCheckFactory { - private static final String MODEL_ID = "anthropic.claude-3-haiku-20240307-v1:0"; - private static final int MAX_TOKENS = 2048; - private static final String API_VERSION = "bedrock-2023-05-31"; -} -``` - -### 7.2 프롬프트 구조 - -**시스템 프롬프트 (초급):** - -``` -You are a friendly English grammar tutor for Korean speakers. -- Use simple English with Korean translations -- Be encouraging and supportive -- Explain grammar rules clearly -``` - -**사용자 프롬프트:** - -``` -Please check the grammar of this sentence: "{sentence}" - -Return JSON: -{ - "correctedSentence": "...", - "score": 0-100, - "isCorrect": boolean, - "errors": [...], - "feedback": "..." -} -``` - -### 7.3 스트리밍 응답 파싱 - -``` -[RESPONSE] -AI의 자연스러운 대화 응답 -[/RESPONSE] - -[GRAMMAR] -{ JSON 형식의 문법 체크 결과 } -[/GRAMMAR] - -[TIP] -학습 팁 -[/TIP] -``` - ---- - -## 8. 데이터 모델 - -### 8.1 GrammarSession - -```java - -@DynamoDbBean -public class GrammarSession { - String sessionId; - String userId; - String level; // BEGINNER, INTERMEDIATE, ADVANCED - String topic; // "Conversation Practice" - Integer messageCount; - String lastMessage; // 마지막 메시지 (100자 제한) - String createdAt; - String updatedAt; - Long ttl; // 30일 -} -``` - -**DynamoDB Keys:** - -- PK: `GSESSION#{userId}` | SK: `SESSION#{sessionId}` -- GSI1: `GSESSION#ALL` | `UPDATED#{timestamp}` (최신순 정렬) - -### 8.2 GrammarMessage - -```java - -@DynamoDbBean -public class GrammarMessage { - String messageId; - String sessionId; - String userId; - String role; // USER, ASSISTANT - String content; // 원본 메시지 - String correctedContent; // 교정된 메시지 (USER만) - String errorsJson; // 오류 목록 JSON - Integer grammarScore; - String feedback; - Boolean isCorrect; - Long ttl; // 30일 -} -``` - -**DynamoDB Keys:** - -- PK: `GSESSION#{userId}` | SK: `MSG#{timestamp}#{messageId}` -- GSI1: `GSESSION#{sessionId}` | `MSG#{timestamp}` - -### 8.3 GrammarConnection (WebSocket) - -```java - -@DynamoDbBean -public class GrammarConnection { - String connectionId; // API Gateway 연결 ID - String userId; // JWT에서 추출 - String connectedAt; - Long ttl; // 연결 타임아웃 -} -``` - ---- - -## 9. AWS Comprehend 분석 (선택적) - -```mermaid -flowchart LR - INPUT[입력 문장] --> SENTIMENT[감정 분석] - INPUT --> SYNTAX[구문 분석] - INPUT --> KEYPHRASE[핵심 구문] - INPUT --> LANGUAGE[언어 감지] - SENTIMENT --> OUTPUT[분석 결과] - SYNTAX --> OUTPUT - KEYPHRASE --> OUTPUT - LANGUAGE --> OUTPUT -``` - -**분석 항목:** - -- 감정: POSITIVE, NEGATIVE, NEUTRAL, MIXED -- 품사 태깅: NOUN, VERB, ADJ 등 -- 핵심 구문 추출 -- 문장 복잡도 추정 - ---- - -## 10. 서비스 레이어 - -### 10.1 서비스 구성 - -| Service | 역할 | -|----------------------------|----------------| -| GrammarCheckService | 단일 문장 문법 체크 | -| GrammarConversationService | 대화형 학습 + 스트리밍 | -| GrammarSessionQueryService | 세션 조회, 삭제 | -| BedrockGrammarCheckFactory | Bedrock API 호출 | - -### 10.2 대화 히스토리 관리 - -```java -// 최근 10개 메시지만 컨텍스트로 유지 -private static final int MAX_HISTORY_MESSAGES = 10; - -// 대화 히스토리 빌드 -String buildConversationHistory(String sessionId) { - // 최근 메시지 조회 - // USER: 내용 / ASSISTANT: 내용 형식으로 포맷 -} -``` - ---- - -## 11. 파일 구조 - -``` -domain/grammar/ -├── handler/ -│ ├── GrammarHandler.java -│ └── websocket/ -│ ├── GrammarStreamingConnectHandler.java -│ ├── GrammarStreamingDisconnectHandler.java -│ └── GrammarStreamingHandler.java -├── service/ -│ ├── GrammarCheckService.java -│ ├── GrammarConversationService.java -│ └── GrammarSessionQueryService.java -├── factory/ -│ ├── GrammarCheckFactory.java (interface) -│ └── BedrockGrammarCheckFactory.java -├── repository/ -│ ├── GrammarSessionRepository.java -│ └── GrammarConnectionRepository.java -├── model/ -│ ├── GrammarSession.java -│ ├── GrammarMessage.java -│ └── GrammarConnection.java -├── dto/ -│ ├── request/ -│ │ ├── GrammarCheckRequest.java -│ │ └── ConversationRequest.java -│ └── response/ -│ ├── GrammarCheckResponse.java -│ ├── ConversationResponse.java -│ ├── GrammarError.java -│ └── ComprehendAnalysis.java -├── streaming/ -│ ├── StreamingCallback.java -│ ├── StreamingEvent.java (sealed interface) -│ └── StreamingRequest.java -├── enums/ -│ ├── GrammarLevel.java -│ └── GrammarErrorType.java -└── constants/ - └── GrammarKey.java -``` - ---- - -## 12. 기술 스택 - -- **Runtime:** AWS Lambda (Java 21) -- **API:** API Gateway REST + WebSocket -- **AI:** AWS Bedrock (Claude 3 Haiku) -- **NLP:** AWS Comprehend (선택적) -- **Database:** DynamoDB -- **Auth:** JWT (Cognito) -- **Pattern:** Factory, Callback, Sealed Interface (Java 17+) diff --git a/docs/domain-reports/STATS-DOMAIN-REPORT.md b/docs/domain-reports/STATS-DOMAIN-REPORT.md deleted file mode 100644 index 3ca3d3ff..00000000 --- a/docs/domain-reports/STATS-DOMAIN-REPORT.md +++ /dev/null @@ -1,379 +0,0 @@ -# Stats Domain 세부 보고서 - -## 1. 개요 - -Stats 도메인은 사용자의 학습 활동을 추적하고 통계를 집계하는 시스템입니다. DynamoDB Streams와 EventBridge를 활용한 이벤트 기반 아키텍처로 실시간 통계 업데이트를 제공합니다. - ---- - -## 2. 전체 아키텍처 - -```mermaid -flowchart TB - subgraph Triggers["트리거"] - TEST[테스트 완료] - DAILY[일일 학습] - GAME[게임 종료] - SCHEDULE[스케줄러
매일 자정] - end - - subgraph Processing["처리"] - STREAM[StatsStreamHandler
DynamoDB Streams] - SERVICE[StatsService
Write-through] - SCHEDULED[ScheduledStatsHandler
EventBridge] - end - - subgraph Storage["저장소"] - DDB[(DynamoDB
UserStats)] - end - - subgraph Query["조회"] - API[UserStatsHandler
REST API] - end - - TEST --> STREAM - DAILY --> SERVICE - GAME --> SERVICE - SCHEDULE --> SCHEDULED - STREAM --> DDB - SERVICE --> DDB - SCHEDULED --> DDB - DDB --> API -``` - ---- - -## 3. 통계 집계 방식 - -### 3.1 집계 레벨 - -```mermaid -flowchart LR - subgraph Levels["통계 집계 레벨"] - DAILY["일별
DAILY#2026-01-16"] - WEEKLY["주별
WEEKLY#2026-W03"] - MONTHLY["월별
MONTHLY#2026-01"] - TOTAL["전체
TOTAL"] - end - - EVENT[이벤트 발생] --> DAILY - EVENT --> WEEKLY - EVENT --> MONTHLY - EVENT --> TOTAL -``` - -### 3.2 Atomic Counter 패턴 - -```java -// 모든 레벨에 동시 업데이트 (원자적) -UpdateExpression: -SET correctAnswers = if_not_exists(correctAnswers, 0) + :correct, -incorrectAnswers = - -if_not_exists(incorrectAnswers, 0) +:incorrect, -testsCompleted = - -if_not_exists(testsCompleted, 0) +1, -updatedAt =:now -``` - ---- - -## 4. 이벤트 기반 통계 업데이트 - -### 4.1 DynamoDB Streams 처리 - -```mermaid -sequenceDiagram - participant Test as TestResult 저장 - participant Stream as DynamoDB Streams - participant Handler as StatsStreamHandler - participant DB as UserStats - Test ->> Stream: INSERT 이벤트 - Stream ->> Handler: 트리거 - Handler ->> Handler: PK/SK 패턴 확인
(TEST#userId, RESULT#timestamp) - Handler ->> DB: incrementTestStats() - Handler ->> DB: updateStudyStreak() - Handler ->> Handler: checkAndAwardBadges() -``` - -### 4.2 Write-through 패턴 - -```mermaid -sequenceDiagram - participant API as DailyStudyHandler - participant Service as StatsService - participant DB as UserStats - Note over API, DB: 단어 학습 완료 시 - API ->> Service: recordWordsLearned() - Service ->> DB: incrementWordsLearned()
(DAILY, WEEKLY, MONTHLY, TOTAL) - Service ->> DB: updateStudyStreak() -``` - ---- - -## 5. API 엔드포인트 - -### 5.1 통계 조회 API - -| Method | Endpoint | 설명 | 파라미터 | -|--------|----------------|---------|------------------| -| GET | /stats/daily | 일별 통계 | ?date=YYYY-MM-DD | -| GET | /stats/weekly | 주별 통계 | ?week=YYYY-Www | -| GET | /stats/monthly | 월별 통계 | ?month=YYYY-MM | -| GET | /stats/total | 전체 통계 | - | -| GET | /stats/history | 일별 히스토리 | ?cursor, ?limit | - -### 5.2 응답 예시 - -```json -{ - "periodType": "DAILY", - "period": "2026-01-16", - "testsCompleted": 3, - "questionsAnswered": 45, - "correctAnswers": 38, - "incorrectAnswers": 7, - "successRate": 84.44, - "newWordsLearned": 50, - "wordsReviewed": 5 -} -``` - -**전체 통계 추가 필드:** - -```json -{ - "currentStreak": 7, - "longestStreak": 14, - "lastStudyDate": "2026-01-16", - "gamesPlayed": 10, - "gamesWon": 3, - "totalGameScore": 450 -} -``` - ---- - -## 6. 연속 학습 (Streak) 시스템 - -### 6.1 스트릭 계산 로직 - -```mermaid -flowchart TB - START[학습 활동 발생] --> CHECK{lastStudyDate
확인} - CHECK -->|null| NEW["currentStreak = 1
longestStreak = 1"] - CHECK -->|오늘| SAME[변경 없음
이미 오늘 학습] - CHECK -->|어제| INCREMENT["currentStreak++
longestStreak = max()"] - CHECK -->|2일+ 전| RESET["currentStreak = 1
longestStreak 유지"] - NEW --> UPDATE[DB 업데이트] - INCREMENT --> UPDATE - RESET --> UPDATE -``` - -### 6.2 스트릭 리셋 (스케줄러) - -```java -// EventBridge: 매일 자정 실행 -@Scheduled -public void resetStreaks() { - String yesterday = LocalDate.now().minusDays(1).toString(); - // lastStudyDate != yesterday인 사용자의 스트릭 리셋 - // 비용 최적화로 클라이언트 측 계산 권장 -} -``` - ---- - -## 7. 데이터 모델 - -### 7.1 UserStats - -```java - -@DynamoDbBean -public class UserStats { - // 키 - String pk; // USER#{userId}#STATS - String sk; // DAILY#{date} | WEEKLY#{week} | MONTHLY#{month} | TOTAL - - // 메타데이터 - String userId; - String periodType; // DAILY, WEEKLY, MONTHLY, TOTAL - String period; // 2026-01-16, 2026-W03, 2026-01, TOTAL - - // 테스트 통계 - Integer testsCompleted; - Integer questionsAnswered; - Integer correctAnswers; - Integer incorrectAnswers; - Double successRate; - - // 학습 통계 - Integer newWordsLearned; - Integer wordsReviewed; - Integer wordsMastered; - - // 스트릭 (TOTAL만) - Integer currentStreak; - Integer longestStreak; - String lastStudyDate; - - // 게임 통계 (TOTAL만) - Integer gamesPlayed; - Integer gamesWon; - Integer correctGuesses; - Integer totalGameScore; - Integer quickGuesses; // 5초 이내 정답 - Integer perfectDraws; // 전원 정답 유도 - - // 타임스탬프 - String createdAt; - String updatedAt; -} -``` - -### 7.2 DynamoDB 키 구조 - -| 필드 | 패턴 | 예시 | -|---------|------------------------|-------------------| -| PK | USER#{userId}#STATS | USER#abc123#STATS | -| SK (일별) | DAILY#{date} | DAILY#2026-01-16 | -| SK (주별) | WEEKLY#{year}-W{week} | WEEKLY#2026-W03 | -| SK (월별) | MONTHLY#{year}-{month} | MONTHLY#2026-01 | -| SK (전체) | TOTAL | TOTAL | - ---- - -## 8. 통계 메트릭 - -### 8.1 테스트 메트릭 - -| 메트릭 | 설명 | 업데이트 시점 | -|-------------------|----------|---------| -| testsCompleted | 완료 테스트 수 | 테스트 제출 | -| questionsAnswered | 총 문제 수 | 테스트 제출 | -| correctAnswers | 정답 수 | 테스트 제출 | -| incorrectAnswers | 오답 수 | 테스트 제출 | -| successRate | 정답률 (%) | 조회 시 계산 | - -### 8.2 학습 메트릭 - -| 메트릭 | 설명 | 업데이트 시점 | -|-----------------|----------|---------| -| newWordsLearned | 신규 학습 단어 | 일일학습 완료 | -| wordsReviewed | 복습 단어 | 일일학습 완료 | -| wordsMastered | 마스터 단어 | 상태 변경 시 | - -### 8.3 게임 메트릭 - -| 메트릭 | 설명 | 업데이트 시점 | -|----------------|----------|---------| -| gamesPlayed | 참여 게임 수 | 게임 종료 | -| gamesWon | 1등 횟수 | 게임 종료 | -| correctGuesses | 정답 횟수 | 게임 종료 | -| totalGameScore | 누적 점수 | 게임 종료 | -| quickGuesses | 5초 내 정답 | 게임 종료 | -| perfectDraws | 전원 정답 유도 | 게임 종료 | - ---- - -## 9. 히스토리 조회 - -### 9.1 페이지네이션 - -```mermaid -flowchart LR - REQUEST["GET /stats/history
?limit=7&cursor=..."] - QUERY["Query
PK = USER#id#STATS
SK begins_with DAILY#
scanIndexForward = false"] - ENRICH["DailyStudy 조회
isCompleted 추가"] - RESPONSE["PaginatedResult
items, nextCursor, hasMore"] - REQUEST --> QUERY --> ENRICH --> RESPONSE -``` - -### 9.2 응답 구조 - -```json -{ - "history": [ - { - "period": "2026-01-16", - "testsCompleted": 2, - "questionsAnswered": 30, - "correctAnswers": 25, - "incorrectAnswers": 5, - "successRate": 83.33, - "newWordsLearned": 50, - "wordsReviewed": 5, - "isCompleted": true - } - ], - "nextCursor": "base64encoded...", - "hasMore": true -} -``` - ---- - -## 10. 배지 연동 - -### 10.1 자동 배지 체크 - -```mermaid -flowchart TB - STREAM[StatsStreamHandler] --> CHECK[배지 조건 체크] - CHECK --> PERFECT{만점 테스트?} - PERFECT -->|Yes| BADGE1[PERFECT_SCORE 배지] - CHECK --> STATS[전체 통계 조회] - STATS --> BADGESERVICE[BadgeService.checkAndAwardBadges] - BADGESERVICE --> AWARD[조건 충족 배지 부여] -``` - -### 10.2 배지 조건 예시 - -| 배지 | 조건 | 통계 필드 | -|--------------|----------|----------------------| -| STREAK_7 | 7일 연속 학습 | currentStreak >= 7 | -| ACCURACY_90 | 정확도 90% | successRate >= 90 | -| TEST_10 | 10회 테스트 | testsCompleted >= 10 | -| GAME_10_WINS | 10번 1등 | gamesWon >= 10 | - ---- - -## 11. 파일 구조 - -``` -domain/stats/ -├── handler/ -│ ├── UserStatsHandler.java (REST API) -│ ├── StatsStreamHandler.java (DynamoDB Streams) -│ └── ScheduledStatsHandler.java (EventBridge) -├── service/ -│ └── StatsService.java -├── repository/ -│ └── UserStatsRepository.java -├── model/ -│ └── UserStats.java -└── constants/ - └── StatsKey.java -``` - ---- - -## 12. 성능 최적화 - -| 최적화 | 기법 | 효과 | -|--------------------------|------------------|-------------------| -| 원자적 업데이트 | UpdateExpression | Race condition 방지 | -| 비동기 처리 | DynamoDB Streams | API 응답 속도 향상 | -| Cursor 페이지네이션 | lastEvaluatedKey | 대용량 히스토리 처리 | -| Strongly Consistent Read | 히스토리 조회 | 데이터 정합성 | - ---- - -## 13. 기술 스택 - -- **Runtime:** AWS Lambda (Java 21) -- **Database:** DynamoDB (Single Table Design) -- **Event:** DynamoDB Streams, EventBridge -- **Pattern:** Atomic Counter, Write-through, Event-driven diff --git a/docs/domain-reports/VOCABULARY-DOMAIN-REPORT.md b/docs/domain-reports/VOCABULARY-DOMAIN-REPORT.md deleted file mode 100644 index 7ee2c90e..00000000 --- a/docs/domain-reports/VOCABULARY-DOMAIN-REPORT.md +++ /dev/null @@ -1,504 +0,0 @@ -# Vocabulary Domain 세부 보고서 - -## 1. 개요 - -Vocabulary 도메인은 AWS Lambda와 DynamoDB를 기반으로 한 영어 단어 학습 시스템입니다. SM-2 Spaced Repetition 알고리즘과 CQRS 패턴을 적용하여 과학적이고 효율적인 단어 -암기를 지원합니다. - ---- - -## 2. 전체 아키텍처 - -```mermaid -flowchart TB - subgraph Client["클라이언트"] - APP[Mobile/Web App] - end - - subgraph Gateway["API Gateway"] - REST[REST API
HTTP] - end - - subgraph Lambda["Lambda Handlers"] - direction TB - WORD[WordHandler] - USERWORD[UserWordHandler] - DAILY[DailyStudyHandler] - TEST[TestHandler] - GROUP[WordGroupHandler] - VOICE[VoiceHandler] - STATS[StatisticsHandler
SQS Consumer] - end - - subgraph Services["서비스 레이어 (CQRS)"] - direction TB - CMD[Command Services
쓰기 작업] - QUERY[Query Services
읽기 작업] - end - - subgraph External["외부 서비스"] - POLLY[AWS Polly
TTS] - SNS[AWS SNS] - SQS[AWS SQS] - S3[(S3
음성 캐시)] - end - - subgraph Storage["데이터 저장소"] - DDB[(DynamoDB)] - end - - APP --> REST - REST --> WORD & USERWORD & DAILY & TEST & GROUP & VOICE - WORD & USERWORD & DAILY & TEST & GROUP --> CMD & QUERY - CMD & QUERY --> DDB - VOICE --> POLLY --> S3 - TEST --> SNS --> SQS --> STATS - STATS --> DDB -``` - ---- - -## 3. 일일 학습 시스템 - -### 3.1 일일 학습 흐름 - -```mermaid -flowchart TB - subgraph DailyStudyFlow["일일 학습 흐름"] - START[GET /vocab/daily] --> CHECK{기존 학습
존재?} - CHECK -->|Yes| RETURN[기존 학습 반환] - CHECK -->|No| CREATE[새 학습 생성] - CREATE --> REVIEW["복습 단어 5개 선정
(nextReviewAt <= today)"] - REVIEW --> NEW["신규 단어 50개 선정
(미학습 + 해당 레벨)"] - NEW --> SAVE[DailyStudy 저장] - SAVE --> RETURN - RETURN --> LEARN[학습 진행] - LEARN --> MARK["POST .../learned
단어별 학습 완료"] - MARK --> PROGRESS{50개 완료?} - PROGRESS -->|No| LEARN - PROGRESS -->|Yes| COMPLETE["isCompleted = true
배지 체크"] - end -``` - -### 3.2 Daily Study API - -| Method | Endpoint | 설명 | -|--------|-------------------------------------|-----------------| -| GET | /vocab/daily | 오늘의 학습 단어 조회/생성 | -| POST | /vocab/daily/words/{wordId}/learned | 단어 학습 완료 처리 | - -### 3.3 응답 예시 - -```json -{ - "userId": "user123", - "date": "2026-01-16", - "newWordIds": [ - "word1", - "word2", - ... - ], - "reviewWordIds": [ - "word51", - "word52", - ... - ], - "learnedWordIds": [], - "totalWords": 55, - "learnedCount": 0, - "isCompleted": false, - "progress": { - "percentage": 0, - "learned": 0, - "total": 55 - } -} -``` - ---- - -## 4. SM-2 Spaced Repetition 알고리즘 - -### 4.1 학습 상태 전이 - -```mermaid -stateDiagram-v2 - [*] --> NEW: 단어 추가 - NEW --> LEARNING: 첫 학습 - LEARNING --> LEARNING: 오답 - LEARNING --> REVIEWING: 2회 연속 정답 - REVIEWING --> LEARNING: 오답 - REVIEWING --> MASTERED: 5회 연속 정답 - MASTERED --> LEARNING: 오답 - MASTERED --> MASTERED: 정답 유지 -``` - -### 4.2 상태별 로직 - -| 상태 | 조건 | 정답 시 | 오답 시 | -|---------------|-------------|-----------------------------|---------------------------| -| **NEW** | 신규 단어 | LEARNING, rep=1, interval=1 | LEARNING, easeFactor-=0.2 | -| **LEARNING** | rep < 2 | rep++, interval 계산 | rep=0, interval=1 | -| **REVIEWING** | 2 ≤ rep < 5 | rep++, interval 증가 | rep=0, LEARNING | -| **MASTERED** | rep ≥ 5 | interval 증가, 유지 | rep=0, REVIEWING | - -### 4.3 복습 간격 계산 - -```mermaid -flowchart LR - REP1["rep = 1
interval = 1일"] - REP2["rep = 2
interval = 6일"] - REP3["rep >= 3
interval = interval × easeFactor"] - REP1 --> REP2 --> REP3 -``` - -**핵심 변수:** - -- `repetitions`: 연속 정답 횟수 (0~∞) -- `interval`: 복습 간격 (일 단위) -- `easeFactor`: 난이도 계수 (1.3~2.5, 기본 2.5) -- `nextReviewAt`: 다음 복습 예정일 - ---- - -## 5. 테스트 시스템 - -### 5.1 테스트 흐름 - -```mermaid -sequenceDiagram - participant Client - participant Handler as TestHandler - participant Service as TestCommandService - participant DB as DynamoDB - participant SNS as AWS SNS - Client ->> Handler: POST /vocab/tests/start - Handler ->> Service: startTest(userId, testType) - Service ->> DB: 오늘의 학습 단어 조회 - Service ->> Service: 4지선다 문제 생성 - Service -->> Client: 문제 목록 반환 - Note over Client: 사용자 답변 입력 - Client ->> Handler: POST /vocab/tests/submit - Handler ->> Service: submitTest(answers) - Service ->> DB: 결과 저장 - Service ->> SNS: 결과 발행 (비동기) - Service -->> Client: 테스트 결과 - Note over SNS, DB: 비동기 통계 처리 - SNS ->> DB: 통계 업데이트 -``` - -### 5.2 문제 생성 알고리즘 - -```mermaid -flowchart TB - START[문제 생성 시작] --> WORDS[일일 학습 단어 로드] - WORDS --> GROUP[레벨별 그룹화] - GROUP --> LOOP[각 단어마다] - LOOP --> CORRECT["정답 = 해당 단어의
한국어 뜻"] - CORRECT --> DIST["오답 3개 선정
(동일 레벨 단어)"] - DIST --> SHUFFLE[4개 보기 셔플] - SHUFFLE --> NEXT{다음 단어?} - NEXT -->|Yes| LOOP - NEXT -->|No| RETURN[문제 목록 반환] -``` - -### 5.3 Test API - -| Method | Endpoint | 설명 | -|--------|-------------------------------|------------| -| POST | /vocab/tests/start | 테스트 시작 | -| POST | /vocab/tests/submit | 테스트 제출 | -| GET | /vocab/tests/results | 테스트 결과 목록 | -| GET | /vocab/tests/results/{testId} | 테스트 상세 결과 | -| GET | /vocab/tests/tested-words | 최근 테스트된 단어 | - ---- - -## 6. 단어 관리 시스템 - -### 6.1 Word API - -| Method | Endpoint | 설명 | -|--------|------------------------|----------------------------| -| GET | /vocab/words | 단어 목록 (level, category 필터) | -| POST | /vocab/words | 단어 등록 | -| GET | /vocab/words/{wordId} | 단어 상세 | -| PUT | /vocab/words/{wordId} | 단어 수정 | -| DELETE | /vocab/words/{wordId} | 단어 삭제 | -| GET | /vocab/words/search | 키워드 검색 | -| POST | /vocab/words/batch | 배치 등록 (최대 100개) | -| POST | /vocab/words/batch/get | 배치 조회 | - -### 6.2 User Word API - -| Method | Endpoint | 설명 | -|--------|-----------------------------------|-------------| -| GET | /vocab/user-words | 사용자 단어 목록 | -| GET | /vocab/user-words/{wordId} | 사용자 단어 상세 | -| PUT | /vocab/user-words/{wordId} | 정답/오답 기록 | -| PATCH | /vocab/user-words/{wordId}/tag | 북마크, 난이도 설정 | -| PATCH | /vocab/user-words/{wordId}/status | 상태 수동 변경 | -| GET | /vocab/wrong-answers | 오답 단어 목록 | - -### 6.3 Word Group API - -| Method | Endpoint | 설명 | -|--------|----------------------------------------|--------| -| POST | /vocab/groups | 단어장 생성 | -| GET | /vocab/groups | 단어장 목록 | -| GET | /vocab/groups/{groupId} | 단어장 상세 | -| PUT | /vocab/groups/{groupId} | 단어장 수정 | -| DELETE | /vocab/groups/{groupId} | 단어장 삭제 | -| POST | /vocab/groups/{groupId}/words/{wordId} | 단어 추가 | -| DELETE | /vocab/groups/{groupId}/words/{wordId} | 단어 제거 | - ---- - -## 7. TTS 음성 합성 - -### 7.1 음성 생성 흐름 - -```mermaid -flowchart TB - REQUEST["POST /vocab/synthesize
{wordId, voice, type}"] - CHECK{S3 캐시
존재?} - REQUEST --> CHECK - CHECK -->|Yes| PRESIGN[Presigned URL 생성] - CHECK -->|No| POLLY[AWS Polly 호출] - POLLY --> SAVE[S3 저장] - SAVE --> PRESIGN - PRESIGN --> RESPONSE[URL 반환] -``` - -### 7.2 Voice API - -```json -// Request -{ - "wordId": "uuid", - "voice": "MALE", - // MALE | FEMALE - "type": "WORD" - // WORD | EXAMPLE -} - -// Response -{ - "url": "https://s3...presigned-url", - "expiresIn": 3600 -} -``` - ---- - -## 8. 데이터 모델 - -### 8.1 Word - -```java - -@DynamoDbBean -public class Word { - String wordId; // UUID - String english; // 영어 단어 - String korean; // 한국어 뜻 - String example; // 예문 - String level; // BEGINNER | INTERMEDIATE | ADVANCED - String category; // DAILY | BUSINESS | ACADEMIC | TRAVEL | TECHNOLOGY - String maleVoiceKey; // S3 음성 키 - String femaleVoiceKey; - String maleExampleVoiceKey; - String femaleExampleVoiceKey; -} -``` - -**DynamoDB Keys:** - -| Key | 패턴 | 용도 | -|--------|---------------------|----------| -| PK | WORD#{wordId} | 기본 조회 | -| SK | METADATA | - | -| GSI1PK | LEVEL#{level} | 레벨별 조회 | -| GSI2PK | CATEGORY#{category} | 카테고리별 조회 | - -### 8.2 UserWord - -```java - -@DynamoDbBean -public class UserWord { - String userId; - String wordId; - String status; // NEW | LEARNING | REVIEWING | MASTERED - - // SM-2 알고리즘 필드 - Integer interval; // 복습 간격 (일) - Double easeFactor; // 난이도 계수 (1.3~2.5) - Integer repetitions; // 연속 정답 횟수 - String nextReviewAt; // 다음 복습일 (YYYY-MM-DD) - - // 통계 - Integer correctCount; // 누적 정답 - Integer incorrectCount; // 누적 오답 - - // 사용자 설정 - Boolean bookmarked; // 북마크 - Boolean favorite; // 즐겨찾기 - String difficulty; // EASY | NORMAL | HARD -} -``` - -**DynamoDB Keys:** - -| Key | 패턴 | 용도 | -|--------|--------------------------|--------------| -| PK | USER#{userId} | 기본 조회 | -| SK | WORD#{wordId} | - | -| GSI1PK | USER#{userId}#REVIEW | 복습 예정 단어 | -| GSI1SK | DATE#{nextReviewAt} | - | -| GSI2PK | USER#{userId}#STATUS | 상태별 조회 | -| GSI2SK | STATUS#{status} | - | -| GSI3PK | USER#{userId}#BOOKMARKED | 북마크 (Sparse) | - -### 8.3 DailyStudy - -```java - -@DynamoDbBean -public class DailyStudy { - String userId; - String date; // YYYY-MM-DD - List newWordIds; // 신규 단어 50개 - List reviewWordIds; // 복습 단어 5개 - List learnedWordIds; // 학습 완료 단어 - Integer totalWords; // 총 단어 수 (55) - Integer learnedCount; // 학습 완료 수 - Boolean isCompleted; // 완료 여부 -} -``` - -### 8.4 TestResult - -```java - -@DynamoDbBean -public class TestResult { - String testId; - String userId; - String testType; // DAILY | WEEKLY | CUSTOM - Integer totalQuestions; - Integer correctAnswers; - Integer incorrectAnswers; - Double successRate; - List testedWordIds; - List incorrectWordIds; - String startedAt; - String completedAt; -} -``` - ---- - -## 9. 서비스 아키텍처 (CQRS) - -### 9.1 Command Services (쓰기) - -```mermaid -flowchart TB - subgraph Commands["Command Services"] - WC[WordCommandService
단어 생성/수정/삭제] - UC[UserWordCommandService
학습 상태 업데이트] - DC[DailyStudyCommandService
일일 학습 관리] - TC[TestCommandService
테스트 생성/제출] - GC[WordGroupCommandService
단어장 관리] - end -``` - -### 9.2 Query Services (읽기) - -```mermaid -flowchart TB - subgraph Queries["Query Services"] - WQ[WordQueryService
단어 조회/검색] - UQ[UserWordQueryService
학습 현황 조회] - DQ[DailyStudyQueryService
일일 학습 조회] - TQ[TestQueryService
테스트 결과 조회] - end -``` - ---- - -## 10. 성능 최적화 - -| 최적화 | 기법 | 효과 | -|---------------------|------------------------|-----------------| -| N+1 방지 | BatchGetItem (100개 단위) | DB 호출 90% 감소 | -| TTS 캐싱 | S3 + Presigned URL | Polly 호출 90% 절감 | -| 페이지네이션 | Cursor 기반 (Base64) | 대용량 데이터 처리 | -| Sparse Index | GSI3 (북마크 전용) | 인덱스 크기 최소화 | -| 비동기 통계 | SNS/SQS | API 응답 속도 향상 | -| Strongly Consistent | DailyStudy 조회 | 데이터 정합성 | - ---- - -## 11. 파일 구조 - -``` -domain/vocabulary/ -├── handler/ -│ ├── WordHandler.java -│ ├── UserWordHandler.java -│ ├── DailyStudyHandler.java -│ ├── TestHandler.java -│ ├── WordGroupHandler.java -│ ├── VoiceHandler.java -│ ├── StatsHandler.java -│ └── StatisticsHandler.java (SQS) -├── service/ -│ ├── WordCommandService.java -│ ├── WordQueryService.java -│ ├── UserWordCommandService.java -│ ├── UserWordQueryService.java -│ ├── TestCommandService.java -│ ├── TestQueryService.java -│ ├── DailyStudyCommandService.java -│ ├── DailyStudyQueryService.java -│ ├── WordGroupCommandService.java -│ ├── StatsService.java -│ └── StatisticsService.java -├── repository/ -│ ├── WordRepository.java -│ ├── UserWordRepository.java -│ ├── DailyStudyRepository.java -│ ├── TestResultRepository.java -│ └── WordGroupRepository.java -├── model/ -│ ├── Word.java -│ ├── UserWord.java -│ ├── DailyStudy.java -│ ├── TestResult.java -│ └── WordGroup.java -├── state/ -│ ├── WordState.java (interface) -│ ├── NewState.java -│ ├── LearningState.java -│ ├── ReviewingState.java -│ ├── MasteredState.java -│ ├── SpacedRepetitionContext.java -│ └── WordStateFactory.java -└── enums/ - ├── WordStatus.java - ├── WordCategory.java - └── TestType.java -``` - ---- - -## 12. 기술 스택 - -- **Runtime:** AWS Lambda (Java 21) -- **Database:** DynamoDB (Single Table Design) -- **TTS:** AWS Polly (남성/여성 음성) -- **Storage:** S3 (음성 캐시) -- **Messaging:** SNS/SQS (비동기 통계) -- **Pattern:** CQRS, State, Repository, Factory diff --git a/docs/news-frontend-guide.md b/docs/news-frontend-guide.md deleted file mode 100644 index 650c27cc..00000000 --- a/docs/news-frontend-guide.md +++ /dev/null @@ -1,916 +0,0 @@ -# 뉴스 영어 학습 기능 - 프론트엔드 연동 가이드 - -## 목차 -1. [기능 개요](#기능-개요) -2. [주요 기능](#주요-기능) -3. [API 명세](#api-명세) -4. [데이터 모델](#데이터-모델) -5. [UI/UX 가이드](#uiux-가이드) -6. [화면 구성 제안](#화면-구성-제안) -7. [에러 코드](#에러-코드) - ---- - -## 기능 개요 - -### 프로젝트 소개 -**뉴스 영어 학습**은 실제 영어 뉴스를 활용한 맞춤형 영어 학습 서비스입니다. - -- **실시간 뉴스 수집**: BBC, VOA 등 해외 뉴스 매체에서 매일 새로운 기사 자동 수집 -- **AI 분석**: Amazon Bedrock을 활용한 난이도 분석, 키워드 추출, 퀴즈 생성 -- **맞춤형 학습**: 사용자 레벨(BEGINNER/INTERMEDIATE/ADVANCED)에 맞는 뉴스 추천 -- **TTS 지원**: Amazon Polly를 통한 원어민 발음 듣기 -- **뱃지 시스템**: 학습 활동에 따른 뱃지 획득 - -### 기술 스택 -- Backend: AWS Lambda (Java 21), DynamoDB -- AI: Amazon Bedrock (Claude) -- TTS: Amazon Polly -- 뉴스 수집: EventBridge 스케줄러 (매일 자동 수집) - ---- - -## 주요 기능 - -### 1. 뉴스 목록/상세 조회 -- 오늘의 뉴스 목록 -- 난이도별/카테고리별 필터링 -- 사용자 레벨 맞춤 추천 -- 무한 스크롤 페이지네이션 - -### 2. 뉴스 학습 -- 기사 읽기 완료 기록 -- 북마크 기능 -- TTS 오디오 재생 - -### 3. 뉴스 퀴즈 -- 기사별 5문제 자동 생성 퀴즈 -- 퀴즈 유형: 독해력(COMPREHENSION), 단어 매칭(WORD_MATCH), 빈칸 채우기(FILL_BLANK) -- 점수 및 기록 관리 - -### 4. 단어 수집 -- 기사 내 단어 수집 -- 문맥과 함께 저장 -- Vocabulary 시스템 연동 - -### 5. 학습 통계 -- 읽은 기사 수 -- 퀴즈 완료/정답률 -- 수집 단어 수 -- 연속 학습 일수 (스트릭) - -### 6. 뱃지 시스템 -14가지 뉴스 관련 뱃지: -- 읽기: 첫 읽기, 10개, 50개, 100개 기사 읽기 -- 퀴즈: 첫 퀴즈, 만점, 10회, 50회 완료 -- 단어: 10개, 50개, 100개 수집 -- 스트릭: 7일, 30일 연속 학습 -- 마스터: 전체 뉴스 기능 마스터 - ---- - -## API 명세 - -### Base URL -``` -Test: https://xgepjbg2c9.execute-api.ap-northeast-2.amazonaws.com/test -Prod: https://xgepjbg2c9.execute-api.ap-northeast-2.amazonaws.com/prod -``` - -### 인증 -모든 API는 Cognito JWT 토큰 필요 -``` -Authorization: Bearer {accessToken} -``` - ---- - -### 1. 뉴스 목록 조회 - -#### GET /news -뉴스 목록 조회 (필터링 지원) - -**Query Parameters:** -| 파라미터 | 타입 | 필수 | 설명 | -|---------|------|------|------| -| level | string | X | BEGINNER, INTERMEDIATE, ADVANCED | -| category | string | X | TECH, BUSINESS, SPORTS, ENTERTAINMENT, WORLD, CULTURE, SCIENCE | -| limit | number | X | 조회 개수 (기본 10, 최대 50) | -| cursor | string | X | 페이지네이션 커서 | - -**Response:** -```json -{ - "statusCode": 200, - "message": "뉴스 목록 조회 성공", - "data": { - "articles": [ - { - "articleId": "1eb9b924", - "title": "EU suspends approval of US trade deal", - "summary": "The move follows renewed tensions...", - "source": "BBC", - "imageUrl": "https://ichef.bbci.co.uk/...", - "category": "WORLD", - "level": "INTERMEDIATE", - "cefrLevel": "B1", - "publishedAt": "2026-01-21T23:41:10Z", - "readCount": 150, - "keywords": [ - { "word": "suspend", "meaning": "중단하다", "level": "INTERMEDIATE" }, - { "word": "tension", "meaning": "긴장", "level": "BEGINNER" } - ] - } - ], - "nextCursor": "eyJQSyI6Ik5FV1MjMjAyNi0wMS0yMiIsIlNLIjoiQVJUSUNMRSMxZWI5YjkyNCJ9", - "hasMore": true, - "count": 10 - } -} -``` - ---- - -#### GET /news/today -오늘의 뉴스 조회 - -**Query Parameters:** limit, cursor - ---- - -#### GET /news/recommended -사용자 레벨 맞춤 뉴스 추천 - -**Query Parameters:** limit, cursor - ---- - -### 2. 뉴스 상세 조회 - -#### GET /news/{articleId} -기사 상세 정보 조회 - -**Response:** -```json -{ - "statusCode": 200, - "message": "뉴스 조회 성공", - "data": { - "articleId": "1eb9b924", - "title": "EU suspends approval of US trade deal", - "summary": "The move follows renewed tensions between the US and EU...", - "originalUrl": "https://www.bbc.com/news/articles/...", - "source": "BBC", - "imageUrl": "https://ichef.bbci.co.uk/...", - "category": "WORLD", - "level": "INTERMEDIATE", - "cefrLevel": "B1", - "keywords": [ - { - "word": "suspend", - "meaning": "중단하다", - "level": "INTERMEDIATE", - "position": 1 - } - ], - "highlightWords": ["diplomatic", "negotiate", "tariff"], - "quiz": [ - { - "questionId": "q1", - "type": "COMPREHENSION", - "question": "What is the main reason for EU's decision?", - "options": ["Trade tensions", "Climate change", "Immigration", "Technology"], - "points": 20 - } - ], - "publishedAt": "2026-01-21T23:41:10Z", - "readCount": 150 - } -} -``` - ---- - -### 3. 학습 기록 - -#### POST /news/{articleId}/read -읽기 완료 기록 - -**Response:** -```json -{ - "statusCode": 200, - "message": "읽기 완료 기록 성공", - "data": { - "articleId": "1eb9b924", - "newBadges": [ - { - "type": "NEWS_FIRST_READ", - "name": "뉴스 첫 발걸음", - "description": "첫 번째 뉴스 읽기 완료", - "imageUrl": "https://..." - } - ] - } -} -``` - ---- - -#### POST /news/{articleId}/bookmark -북마크 토글 - -**Response:** -```json -{ - "statusCode": 200, - "message": "북마크 추가 성공", - "data": { - "articleId": "1eb9b924", - "bookmarked": true - } -} -``` - ---- - -#### GET /news/bookmarks -북마크 목록 조회 - -**Query Parameters:** limit - -**Response:** -```json -{ - "statusCode": 200, - "message": "북마크 목록 조회 성공", - "data": { - "bookmarks": [ - { - "articleId": "1eb9b924", - "articleTitle": "EU suspends approval...", - "articleLevel": "INTERMEDIATE", - "articleCategory": "WORLD", - "createdAt": "2026-01-22T10:30:00Z" - } - ], - "count": 5 - } -} -``` - ---- - -### 4. TTS 오디오 - -#### GET /news/{articleId}/audio -기사 TTS 오디오 URL 조회 - -**Query Parameters:** -| 파라미터 | 타입 | 필수 | 설명 | -|---------|------|------|------| -| voice | string | X | Polly 음성 (기본: Joanna) | - -**사용 가능한 음성:** -- 미국: Joanna (여), Matthew (남), Ivy (아동) -- 영국: Amy (여), Brian (남) - -**Response:** -```json -{ - "statusCode": 200, - "message": "TTS 오디오 URL 조회 성공", - "data": { - "audioUrl": "https://s3.ap-northeast-2.amazonaws.com/..." - } -} -``` - ---- - -### 5. 퀴즈 - -#### GET /news/{articleId}/quiz -퀴즈 문제 조회 - -**Response:** -```json -{ - "statusCode": 200, - "message": "퀴즈 조회 성공", - "data": { - "articleId": "1eb9b924", - "articleTitle": "EU suspends approval...", - "questions": [ - { - "questionId": "q1", - "type": "COMPREHENSION", - "question": "What is the main reason for EU's decision?", - "options": ["Trade tensions", "Climate change", "Immigration", "Technology"], - "points": 20 - }, - { - "questionId": "q2", - "type": "WORD_MATCH", - "question": "Select the correct meaning of 'suspend'", - "options": ["시작하다", "중단하다", "계속하다", "완료하다"], - "points": 20 - }, - { - "questionId": "q3", - "type": "FILL_BLANK", - "question": "The EU _____ the approval of the trade deal.", - "options": ["suspended", "continued", "started", "finished"], - "points": 20 - } - ], - "totalPoints": 100, - "previousAttempt": null - } -} -``` - ---- - -#### POST /news/{articleId}/quiz -퀴즈 제출 - -**Request Body:** -```json -{ - "answers": [ - { "questionId": "q1", "answer": "Trade tensions" }, - { "questionId": "q2", "answer": "중단하다" }, - { "questionId": "q3", "answer": "suspended" } - ], - "timeTaken": 120 -} -``` - -**Response:** -```json -{ - "statusCode": 200, - "message": "퀴즈 제출 성공", - "data": { - "score": 80, - "totalPoints": 100, - "earnedPoints": 80, - "results": [ - { "questionId": "q1", "correct": true, "correctAnswer": "Trade tensions" }, - { "questionId": "q2", "correct": true, "correctAnswer": "중단하다" }, - { "questionId": "q3", "correct": false, "correctAnswer": "suspended", "userAnswer": "continued" } - ], - "newBadges": [ - { - "type": "NEWS_QUIZ_FIRST", - "name": "퀴즈 도전자", - "description": "첫 뉴스 퀴즈 완료" - } - ] - } -} -``` - ---- - -#### GET /news/quiz/history -퀴즈 기록 조회 - -**Response:** -```json -{ - "statusCode": 200, - "message": "퀴즈 기록 조회 성공", - "data": { - "history": [ - { - "articleId": "1eb9b924", - "articleTitle": "EU suspends approval...", - "score": 80, - "totalPoints": 100, - "submittedAt": "2026-01-22T11:00:00Z" - } - ], - "stats": { - "totalQuizzes": 15, - "averageScore": 78, - "perfectScores": 3 - }, - "count": 10 - } -} -``` - ---- - -### 6. 단어 수집 - -#### POST /news/{articleId}/words -단어 수집 - -**Request Body:** -```json -{ - "word": "suspend", - "context": "The EU suspended the approval of the trade deal." -} -``` - -**Response:** -```json -{ - "statusCode": 200, - "message": "단어 수집 성공", - "data": { - "wordCollect": { - "word": "suspend", - "meaning": "중단하다", - "pronunciation": "/səˈspend/", - "context": "The EU suspended the approval of the trade deal.", - "articleId": "1eb9b924", - "articleTitle": "EU suspends approval...", - "collectedAt": "2026-01-22T11:30:00Z" - }, - "newBadges": [] - } -} -``` - ---- - -#### GET /news/words -수집 단어 목록 조회 - -**Response:** -```json -{ - "statusCode": 200, - "message": "수집 단어 목록 조회 성공", - "data": { - "words": [ - { - "word": "suspend", - "meaning": "중단하다", - "pronunciation": "/səˈspend/", - "context": "The EU suspended...", - "articleTitle": "EU suspends...", - "collectedAt": "2026-01-22T11:30:00Z", - "syncedToVocab": false - } - ], - "stats": { - "totalWords": 25, - "syncedToVocab": 10 - }, - "count": 25 - } -} -``` - ---- - -#### DELETE /news/{articleId}/words/{word} -수집 단어 삭제 - ---- - -#### POST /news/words/{word}/sync -Vocabulary 연동 - -**Request Body:** -```json -{ - "articleId": "1eb9b924" -} -``` - -**Response:** -```json -{ - "statusCode": 200, - "message": "Vocabulary 연동 성공", - "data": { - "word": "suspend", - "synced": true - } -} -``` - ---- - -### 7. 학습 통계 - -#### GET /news/stats -학습 통계 조회 - -**Response:** -```json -{ - "statusCode": 200, - "message": "뉴스 학습 통계 조회 성공", - "data": { - "totalRead": 45, - "todayRead": 3, - "totalQuizzes": 30, - "averageQuizScore": 78, - "perfectQuizzes": 5, - "totalWordsCollected": 125, - "currentStreak": 7, - "longestStreak": 14, - "bookmarkCount": 12, - "lastReadDate": "2026-01-22" - } -} -``` - ---- - -## 데이터 모델 - -### NewsArticle (뉴스 기사) -| 필드 | 타입 | 설명 | -|-----|------|------| -| articleId | string | 기사 고유 ID | -| title | string | 제목 | -| summary | string | AI 생성 3줄 요약 | -| originalUrl | string | 원문 링크 | -| source | string | 출처 (BBC, VOA 등) | -| imageUrl | string | 썸네일 이미지 | -| category | string | 카테고리 | -| level | string | 난이도 | -| cefrLevel | string | CEFR 레벨 (A1-C2) | -| keywords | KeywordInfo[] | 핵심 단어 | -| highlightWords | string[] | 강조 단어 | -| quiz | QuizQuestion[] | 퀴즈 문제 | -| publishedAt | string | 발행일 | -| readCount | number | 조회수 | - -### KeywordInfo (키워드 정보) -| 필드 | 타입 | 설명 | -|-----|------|------| -| word | string | 영어 단어 | -| meaning | string | 한국어 뜻 | -| level | string | 난이도 | -| position | number | 기사 내 위치 | - -### QuizQuestion (퀴즈 문제) -| 필드 | 타입 | 설명 | -|-----|------|------| -| questionId | string | 문제 ID | -| type | string | COMPREHENSION, WORD_MATCH, FILL_BLANK | -| question | string | 문제 내용 | -| options | string[] | 선택지 | -| points | number | 배점 | - -### NewsWordCollect (수집 단어) -| 필드 | 타입 | 설명 | -|-----|------|------| -| word | string | 단어 | -| meaning | string | 뜻 | -| pronunciation | string | 발음 기호 | -| context | string | 문맥 문장 | -| articleId | string | 출처 기사 ID | -| articleTitle | string | 출처 기사 제목 | -| syncedToVocab | boolean | Vocabulary 연동 여부 | - ---- - -## UI/UX 가이드 - -### 1. 색상 팔레트 제안 - -#### 난이도별 색상 -```css -/* BEGINNER - 녹색 계열 (쉬움) */ ---level-beginner: #10B981; ---level-beginner-bg: #D1FAE5; - -/* INTERMEDIATE - 파란 계열 (보통) */ ---level-intermediate: #3B82F6; ---level-intermediate-bg: #DBEAFE; - -/* ADVANCED - 보라 계열 (어려움) */ ---level-advanced: #8B5CF6; ---level-advanced-bg: #EDE9FE; -``` - -#### 카테고리별 색상 -```css ---category-tech: #6366F1; /* 기술 */ ---category-business: #F59E0B; /* 비즈니스 */ ---category-sports: #EF4444; /* 스포츠 */ ---category-entertainment: #EC4899; /* 엔터테인먼트 */ ---category-world: #14B8A6; /* 세계 */ ---category-culture: #F97316; /* 문화 */ ---category-science: #06B6D4; /* 과학 */ -``` - -### 2. 아이콘 가이드 - -| 기능 | 추천 아이콘 | -|-----|-----------| -| 뉴스 | newspaper, article | -| 읽기 완료 | check-circle, book-open | -| 북마크 | bookmark, heart | -| 오디오 | volume-2, headphones | -| 퀴즈 | help-circle, clipboard-check | -| 단어 수집 | plus-circle, collection | -| 통계 | bar-chart, trending-up | -| 뱃지 | award, medal | - -### 3. 애니메이션 제안 - -```css -/* 뱃지 획득 애니메이션 */ -@keyframes badge-unlock { - 0% { transform: scale(0) rotate(-180deg); opacity: 0; } - 50% { transform: scale(1.2) rotate(10deg); } - 100% { transform: scale(1) rotate(0); opacity: 1; } -} - -/* 단어 수집 애니메이션 */ -@keyframes word-collect { - 0% { transform: translateY(0); } - 50% { transform: translateY(-10px); } - 100% { transform: translateY(0); } -} - -/* 퀴즈 정답 피드백 */ -@keyframes correct-answer { - 0%, 100% { background-color: transparent; } - 50% { background-color: rgba(16, 185, 129, 0.2); } -} -``` - ---- - -## 화면 구성 제안 - -### 1. 뉴스 목록 화면 (NewsListPage) - -``` -┌─────────────────────────────────────┐ -│ [필터] 레벨 ▼ 카테고리 ▼ [검색] │ -├─────────────────────────────────────┤ -│ ┌─────────────────────────────┐ │ -│ │ [이미지] │ │ -│ │ ─────────────────────────── │ │ -│ │ [TECH] [INTERMEDIATE] │ │ -│ │ EU suspends approval of... │ │ -│ │ BBC • 2시간 전 • 👁 150 │ │ -│ │ [📖 읽기] [🔖 저장] │ │ -│ └─────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────┐ │ -│ │ [다음 카드...] │ │ -│ └─────────────────────────────┘ │ -│ │ -│ [더 보기...] │ -└─────────────────────────────────────┘ -``` - -**구현 포인트:** -- 무한 스크롤 또는 "더 보기" 버튼 -- 카드 형태로 이미지, 제목, 메타정보 표시 -- 난이도 뱃지 색상으로 직관적 구분 -- 읽은 기사는 시각적으로 구분 (예: 투명도) - ---- - -### 2. 뉴스 상세 화면 (NewsDetailPage) - -``` -┌─────────────────────────────────────┐ -│ [← 뒤로] [🔖] [🔊] │ -├─────────────────────────────────────┤ -│ │ -│ [WORLD] [B1 - INTERMEDIATE] │ -│ │ -│ EU suspends approval of │ -│ US trade deal │ -│ │ -│ BBC • 2026.01.21 │ -│ │ -│ ┌─────────────────────────────┐ │ -│ │ [기사 이미지] │ │ -│ └─────────────────────────────┘ │ -│ │ -│ 📝 요약 │ -│ The move follows renewed tensions │ -│ between the US and EU, as Donald │ -│ Trump pushes to acquire Greenland. │ -│ │ -│ ───────────────────────────────── │ -│ │ -│ 📚 핵심 단어 │ -│ ┌───────┬───────┬───────┐ │ -│ │suspend│tension│acquire│ │ -│ │중단하다│ 긴장 │획득하다│ │ -│ │ [+] │ [+] │ [+] │ │ -│ └───────┴───────┴───────┘ │ -│ │ -│ ───────────────────────────────── │ -│ │ -│ [📝 퀴즈 풀기] [📰 원문 보기] │ -│ │ -│ [✓ 읽기 완료] │ -│ │ -└─────────────────────────────────────┘ -``` - -**구현 포인트:** -- 상단에 북마크, 오디오 버튼 고정 -- 핵심 단어는 탭으로 수집 가능 -- 어려운 단어(highlightWords)는 형광펜 효과 -- 하단에 퀴즈, 원문 링크 버튼 - ---- - -### 3. 퀴즈 화면 (QuizPage) - -``` -┌─────────────────────────────────────┐ -│ [← 종료] 1/5 ⏱ 01:30 │ -├─────────────────────────────────────┤ -│ │ -│ ████████░░░░░░░░░░░░ 20/100점 │ -│ │ -│ ───────────────────────────────── │ -│ │ -│ Q1. 독해력 문제 │ -│ │ -│ What is the main reason for │ -│ EU's decision? │ -│ │ -│ ┌─────────────────────────────┐ │ -│ │ A. Trade tensions │ │ -│ └─────────────────────────────┘ │ -│ ┌─────────────────────────────┐ │ -│ │ B. Climate change │ │ -│ └─────────────────────────────┘ │ -│ ┌─────────────────────────────┐ │ -│ │ C. Immigration │ │ -│ └─────────────────────────────┘ │ -│ ┌─────────────────────────────┐ │ -│ │ D. Technology │ │ -│ └─────────────────────────────┘ │ -│ │ -│ [다음 →] │ -│ │ -└─────────────────────────────────────┘ -``` - -**구현 포인트:** -- 상단 진행률 표시 (문제 번호, 타이머) -- 점수 프로그레스바 -- 선택지 터치 영역 충분히 크게 -- 정답/오답 즉시 피드백 (색상 변경) - ---- - -### 4. 퀴즈 결과 화면 (QuizResultPage) - -``` -┌─────────────────────────────────────┐ -│ │ -│ 🎉 퀴즈 완료! │ -│ │ -│ ┌───────────────┐ │ -│ │ │ │ -│ │ 80 │ │ -│ │ /100 │ │ -│ │ │ │ -│ └───────────────┘ │ -│ │ -│ 정답 4개 / 오답 1개 │ -│ 소요시간: 2분 30초 │ -│ │ -│ ───────────────────────────────── │ -│ │ -│ 🏆 새로운 뱃지 획득! │ -│ ┌─────────────────────────────┐ │ -│ │ [뱃지 이미지] │ │ -│ │ 퀴즈 도전자 │ │ -│ │ 첫 뉴스 퀴즈 완료! │ │ -│ └─────────────────────────────┘ │ -│ │ -│ ───────────────────────────────── │ -│ │ -│ 📋 문제별 결과 │ -│ Q1. ✅ Trade tensions │ -│ Q2. ✅ 중단하다 │ -│ Q3. ❌ continued → suspended │ -│ ... │ -│ │ -│ [다시 풀기] [목록으로] │ -│ │ -└─────────────────────────────────────┘ -``` - ---- - -### 5. 단어장 화면 (WordCollectionPage) - -``` -┌─────────────────────────────────────┐ -│ 수집한 단어 25개 │ -├─────────────────────────────────────┤ -│ [전체] [미연동] [연동완료] │ -├─────────────────────────────────────┤ -│ │ -│ ┌─────────────────────────────┐ │ -│ │ suspend /səˈspend/ │ │ -│ │ 중단하다 │ │ -│ │ ─────────────────────────── │ │ -│ │ "The EU suspended the..." │ │ -│ │ 📰 EU suspends approval... │ │ -│ │ ─────────────────────────── │ │ -│ │ [🔗 Vocab 연동] [🗑 삭제] │ │ -│ └─────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────┐ │ -│ │ tension /ˈtenʃən/ │ │ -│ │ 긴장 │ │ -│ │ ✅ Vocabulary 연동됨 │ │ -│ └─────────────────────────────┘ │ -│ │ -└─────────────────────────────────────┘ -``` - ---- - -### 6. 학습 통계 화면 (StatsPage) - -``` -┌─────────────────────────────────────┐ -│ 📊 나의 뉴스 학습 │ -├─────────────────────────────────────┤ -│ │ -│ 🔥 7일 연속 학습 중! │ -│ │ -│ ┌─────────┬─────────┬─────────┐ │ -│ │ 읽기 │ 퀴즈 │ 단어 │ │ -│ │ 45 │ 30 │ 125 │ │ -│ │ 개 │ 회 │ 개 │ │ -│ └─────────┴─────────┴─────────┘ │ -│ │ -│ ───────────────────────────────── │ -│ │ -│ 📈 이번 주 활동 │ -│ ┌─────────────────────────────┐ │ -│ │ [주간 차트 - 막대그래프] │ │ -│ │ 월 화 수 목 금 토 일 │ │ -│ └─────────────────────────────┘ │ -│ │ -│ ───────────────────────────────── │ -│ │ -│ 🏆 획득한 뱃지 (5/14) │ -│ [🥇] [🥇] [🥇] [🥇] [🥇] │ -│ [🔒] [🔒] [🔒] [🔒] [🔒] │ -│ ... │ -│ │ -└─────────────────────────────────────┘ -``` - ---- - -## 에러 코드 - -| 코드 | 메시지 | 설명 | -|-----|--------|------| -| NEWS_001 | 기사를 찾을 수 없습니다 | articleId가 유효하지 않음 | -| NEWS_002 | 인증이 필요합니다 | JWT 토큰 없음/만료 | -| NEWS_003 | 퀴즈를 찾을 수 없습니다 | 해당 기사에 퀴즈 없음 | -| NEWS_004 | 이미 퀴즈를 제출했습니다 | 중복 제출 시도 | -| NEWS_005 | 이미 수집한 단어입니다 | 중복 수집 시도 | -| NEWS_006 | 수집하지 않은 단어입니다 | 존재하지 않는 단어 조회 | - ---- - -## 추가 구현 고려사항 - -### 1. 오프라인 지원 -- 읽은 기사 로컬 캐싱 -- 수집 단어 오프라인 저장 후 동기화 - -### 2. 푸시 알림 -- 새 뉴스 알림 -- 학습 리마인더 -- 스트릭 유지 알림 - -### 3. 공유 기능 -- 기사 공유 -- 퀴즈 점수 공유 -- 뱃지 획득 공유 - -### 4. 접근성 -- 스크린 리더 지원 -- 폰트 크기 조절 -- 고대비 모드 - ---- - -## 문의 - -백엔드 관련 문의는 이슈로 등록해주세요. From 92f526e5e480732809d0f38fe7c6ba36f6321893 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 23 Jan 2026 10:54:55 +0900 Subject: [PATCH 487/528] fix: add /stats/dashboard endpoint to template.yaml --- ServerlessFunction/template.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 16f1e3dd..6efea7aa 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -1055,6 +1055,14 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref VocabTable Events: + GetDashboardStats: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /stats/dashboard + Method: GET + Auth: + Authorizer: CognitoAuthorizer GetDailyStats: Type: Api Properties: From ce71f18f780324bc7455e31c42077c2a420e7ac2 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 23 Jan 2026 10:58:32 +0900 Subject: [PATCH 488/528] fix: filter by ARTICLE# prefix in findById to avoid returning UserNewsRecord --- .../domain/news/repository/NewsArticleRepository.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsArticleRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsArticleRepository.java index 28ca35cc..4aeb217d 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsArticleRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsArticleRepository.java @@ -72,8 +72,9 @@ public Optional findByDateAndId(String date, String articleId) { */ public Optional findById(String articleId) { Expression filterExpression = Expression.builder() - .expression("articleId = :articleId") + .expression("articleId = :articleId AND begins_with(SK, :skPrefix)") .putExpressionValue(":articleId", AttributeValue.builder().s(articleId).build()) + .putExpressionValue(":skPrefix", AttributeValue.builder().s("ARTICLE#").build()) .build(); ScanEnhancedRequest request = ScanEnhancedRequest.builder() From 09afe65157e56787033cbab13bd317e519736a32 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 23 Jan 2026 11:02:51 +0900 Subject: [PATCH 489/528] docs: add News API troubleshooting guide --- .../NEWS-API-TROUBLESHOOTING.md | 226 ++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 docs/troubleshooting/NEWS-API-TROUBLESHOOTING.md diff --git a/docs/troubleshooting/NEWS-API-TROUBLESHOOTING.md b/docs/troubleshooting/NEWS-API-TROUBLESHOOTING.md new file mode 100644 index 00000000..4b6e3ba7 --- /dev/null +++ b/docs/troubleshooting/NEWS-API-TROUBLESHOOTING.md @@ -0,0 +1,226 @@ +# News API 트러블슈팅 가이드 + +## 개요 +2026-01-23 뉴스 기능 프론트엔드 연동 과정에서 발생한 이슈들과 해결 방법을 정리합니다. + +--- + +## 1. GET /news/{articleId} 응답이 기사가 아닌 읽기 기록 반환 + +### 증상 +```javascript +// 예상 응답 +{ articleId: "e644d491", title: "...", summary: "...", ... } + +// 실제 응답 +{ pk: "USER#64983d3c-...#NEWS", sk: "READ#e644d491", articleId: "e644d491" } +``` + +### 원인 +`NewsArticleRepository.findById()`가 테이블 전체를 스캔하면서 `articleId`만 필터링했습니다. +뉴스 테이블에는 기사(`ARTICLE#`)와 사용자 기록(`READ#`, `BOOKMARK#`)이 함께 저장되어 있어서, +`UserNewsRecord`가 먼저 매칭되어 반환되었습니다. + +### 해결 +`findById`에서 SK가 `ARTICLE#`로 시작하는 것만 필터링하도록 수정: + +```java +// Before +Expression filterExpression = Expression.builder() + .expression("articleId = :articleId") + .putExpressionValue(":articleId", AttributeValue.builder().s(articleId).build()) + .build(); + +// After +Expression filterExpression = Expression.builder() + .expression("articleId = :articleId AND begins_with(SK, :skPrefix)") + .putExpressionValue(":articleId", AttributeValue.builder().s(articleId).build()) + .putExpressionValue(":skPrefix", AttributeValue.builder().s("ARTICLE#").build()) + .build(); +``` + +### 파일 +- `NewsArticleRepository.java` - `findById()` 메서드 + +--- + +## 2. 기사에 category 필드 누락 + +### 증상 +```json +{ + "articleId": "2b4e42f9", + "title": "...", + "category": null // 누락 +} +``` + +### 원인 +`NewsAnalysisService`에서 Bedrock AI 분석 시 category 분류 로직이 없었습니다. + +### 해결 +1. Bedrock 프롬프트에 category 분류 요청 추가 +2. `AnalysisResult` 레코드에 category 필드 추가 +3. 파싱 및 저장 로직 추가 + +```java +// 프롬프트에 추가 +"category": "WORLD", +... +For category, choose EXACTLY ONE from: WORLD, POLITICS, BUSINESS, TECH, SCIENCE, HEALTH, SPORTS, ENTERTAINMENT, LIFESTYLE +``` + +### 파일 +- `NewsAnalysisService.java` - `generateSummaryAndQuiz()`, `parseAnalysisResult()`, `AnalysisResult` 레코드 + +### 주의 +기존 기사에는 category가 없으므로, **기사 삭제 후 재수집** 필요 + +--- + +## 3. /stats/dashboard CORS 에러 + +### 증상 +``` +Access to fetch at '.../stats/dashboard' has been blocked by CORS policy: +Response to preflight request doesn't pass access control check +``` + +### 원인 +새로 추가한 `/stats/dashboard` 엔드포인트가 `template.yaml`에 정의되지 않았습니다. + +### 해결 +`template.yaml`의 `UserStatsFunction` Events에 엔드포인트 추가: + +```yaml +Events: + GetDashboardStats: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /stats/dashboard + Method: GET + Auth: + Authorizer: CognitoAuthorizer +``` + +### 파일 +- `template.yaml` - UserStatsFunction Events + +--- + +## 4. 북마크 API가 기사 정보 없이 반환 + +### 증상 +```json +// GET /news/bookmarks 응답 +{ + "bookmarks": [ + { "pk": "USER#...", "sk": "BOOKMARK#...", "articleId": "..." } + ] +} +``` + +### 원인 +`NewsLearningService.getUserBookmarks()`가 북마크 레코드만 반환하고 기사 정보를 조회하지 않았습니다. + +### 해결 +북마크 레코드에서 articleId로 기사 정보를 조회하여 함께 반환: + +```java +public List> getUserBookmarks(String userId, int limit) { + List bookmarks = userNewsRepository.getUserBookmarks(userId, limit); + List> result = new ArrayList<>(); + + for (UserNewsRecord bookmark : bookmarks) { + Optional articleOpt = articleRepository.findById(bookmark.getArticleId()); + if (articleOpt.isPresent()) { + NewsArticle article = articleOpt.get(); + Map bookmarkWithArticle = new HashMap<>(); + bookmarkWithArticle.put("articleId", article.getArticleId()); + bookmarkWithArticle.put("title", article.getTitle()); + bookmarkWithArticle.put("summary", article.getSummary()); + // ... 기타 필드 + result.add(bookmarkWithArticle); + } + } + return result; +} +``` + +### 파일 +- `NewsLearningService.java` - `getUserBookmarks()` +- `NewsHandler.java` - `getBookmarks()` + +--- + +## 5. POST /news/{articleId}/words 500 에러 + +### 증상 +``` +java.lang.NullPointerException: Cannot invoke "JsonElement.getAsString()" +because the return value of "JsonObject.get(String)" is null +at NewsHandler.collectWord(NewsHandler.java:416) +``` + +### 원인 +요청 body에 `word` 필드가 없거나 null일 때 검증 없이 바로 접근했습니다. + +### 해결 +null 체크 추가 및 `INVALID_REQUEST` 에러 코드 정의: + +```java +JsonObject body = gson.fromJson(request.getBody(), JsonObject.class); +if (body == null || !body.has("word") || body.get("word").isJsonNull()) { + return ResponseGenerator.fail(NewsErrorCode.INVALID_REQUEST); +} +``` + +### 파일 +- `NewsHandler.java` - `collectWord()` +- `NewsErrorCode.java` - `INVALID_REQUEST` 추가 + +--- + +## 6. DAILY 통계에 뉴스 관련 필드 누락 + +### 증상 +- TOTAL 통계: `newsRead: 5` ✅ +- DAILY 통계: `newsRead` 필드 없음 ❌ + +### 원인 +`incrementNewsReadStats()` 등의 메서드가 TOTAL 통계만 업데이트하고 DAILY 통계는 업데이트하지 않았습니다. + +### 해결 +각 뉴스 통계 업데이트 메서드에서 DAILY 통계도 함께 업데이트: + +```java +// TOTAL 업데이트 후 DAILY도 업데이트 +Map dailyKey = new HashMap<>(); +dailyKey.put("PK", AttributeValue.builder().s(pk).build()); +dailyKey.put("SK", AttributeValue.builder().s(StatsKey.statsDailySk(today)).build()); +// ... DAILY 업데이트 로직 +``` + +### 파일 +- `UserStatsRepository.java` - `incrementNewsReadStats()`, `incrementNewsQuizStats()`, `incrementNewsWordStats()` + +--- + +## 체크리스트 + +새로운 API 엔드포인트 추가 시: +- [ ] Handler에 라우트 추가 +- [ ] `template.yaml`에 Events 추가 +- [ ] CORS 설정 확인 + +DynamoDB 단일 테이블 설계 주의: +- [ ] 쿼리 시 PK/SK 패턴 명확히 구분 +- [ ] Scan 사용 시 적절한 필터 표현식 사용 + +통계 업데이트 시: +- [ ] TOTAL과 DAILY 모두 업데이트 + +API 요청 처리 시: +- [ ] 요청 body null 체크 +- [ ] 필수 필드 존재 여부 검증 From aad76c877b1c3a31f898f38c58f83eae60502625 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 23 Jan 2026 11:52:22 +0900 Subject: [PATCH 490/528] feat: add Cognito authorizer to News API and enhance keyword extraction - Set CognitoAuthorizer for all News API endpoints in template.yaml - Update Bedrock AI to extract keywords with meanings and examples - Add fallback to Comprehend for keyword extraction when Bedrock fails - Modify KeywordInfo model to include example field - Adjust AI prompt to include keyword extraction with examples - Update AnalysisResult to store keywords and parse them from AI response --- .../domain/news/model/KeywordInfo.java | 5 +- .../news/service/NewsAnalysisService.java | 49 ++++++++++++++----- ServerlessFunction/template.yaml | 24 +++++++++ 3 files changed, 64 insertions(+), 14 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/KeywordInfo.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/KeywordInfo.java index 81f1e1f5..cd5b4b44 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/KeywordInfo.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/KeywordInfo.java @@ -8,7 +8,7 @@ /** * 뉴스 기사 내 키워드 정보 - * 단어, 뜻, 난이도, 위치 정보를 포함 + * 단어, 뜻, 예문, 난이도, 위치 정보를 포함 */ @Data @Builder @@ -18,7 +18,8 @@ public class KeywordInfo { private String word; // 영어 단어 - private String meaning; // 한국어 뜻 + private String meaning; // 영어 뜻 (간단한 정의) + private String example; // 기사에서 발췌한 예문 private String level; // 단어 난이도 (BEGINNER, INTERMEDIATE, ADVANCED) private Integer position; // 기사 내 위치 (문장 번호 또는 단어 인덱스) } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java index d09070ed..83a4fc30 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java @@ -58,11 +58,7 @@ public NewsArticle analyzeArticle(NewsArticle article) { article.setCefrLevel(cefrLevel); article.setLevel(mapCefrToLevel(cefrLevel)); - // 2. 핵심 단어 추출 (Comprehend) - List keywords = extractKeywords(content); - article.setKeywords(keywords); - - // 3. 3줄 요약 + 퀴즈 + 카테고리 생성 (Bedrock - 한 번에 처리) + // 2. 3줄 요약 + 퀴즈 + 카테고리 + 키워드 생성 (Bedrock - 한 번에 처리) AnalysisResult result = generateSummaryAndQuiz(content, cefrLevel); if (result.summary() != null) { article.setSummary(result.summary()); @@ -73,6 +69,15 @@ public NewsArticle analyzeArticle(NewsArticle article) { article.setCategory(result.category()); } + // 3. 키워드 설정 (Bedrock AI에서 추출한 키워드 사용) + if (result.keywords() != null && !result.keywords().isEmpty()) { + article.setKeywords(result.keywords()); + } else { + // Bedrock 키워드 추출 실패 시 Comprehend 폴백 + List fallbackKeywords = extractKeywords(content); + article.setKeywords(fallbackKeywords); + } + // 4. GSI 키 설정 article.setGsi1pk("LEVEL#" + article.getLevel()); article.setGsi1sk(article.getPublishedAt()); @@ -176,7 +181,7 @@ private List extractKeywords(String content) { } /** - * 요약 + 퀴즈 + 카테고리 생성 (Bedrock) + * 요약 + 퀴즈 + 카테고리 + 키워드 생성 (Bedrock) */ private AnalysisResult generateSummaryAndQuiz(String content, String cefrLevel) { String systemPrompt = """ @@ -185,6 +190,10 @@ private AnalysisResult generateSummaryAndQuiz(String content, String cefrLevel) Respond in this exact JSON format: { "summary": "3-line summary in English (each line separated by newline)", + "keywords": [ + {"word": "economy", "meaning": "the system of trade and industry", "example": "The economy is growing steadily."}, + {"word": "policy", "meaning": "a plan of action adopted by government", "example": "The new policy affects all citizens."} + ], "highlightWords": ["word1", "word2", "word3"], "category": "WORLD", "quiz": [ @@ -215,10 +224,12 @@ private AnalysisResult generateSummaryAndQuiz(String content, String cefrLevel) ] } - For category, choose EXACTLY ONE from: WORLD, POLITICS, BUSINESS, TECH, SCIENCE, HEALTH, SPORTS, ENTERTAINMENT, LIFESTYLE - Create exactly 3 quiz questions. - highlightWords should contain 3-5 difficult words for learners. - Adjust difficulty based on CEFR level: """ + cefrLevel; + IMPORTANT: + - keywords: Extract 5-8 important vocabulary words from the article. Include word, meaning (simple definition), and example sentence from the article. + - highlightWords: 3-5 difficult words that learners should pay attention to (just the words, no definitions). + - category: Choose EXACTLY ONE from: WORLD, POLITICS, BUSINESS, TECH, SCIENCE, HEALTH, SPORTS, ENTERTAINMENT, LIFESTYLE + - Create exactly 3 quiz questions. + - Adjust difficulty based on CEFR level: """ + cefrLevel; String userPrompt = "Create learning materials for this article:\n\n" + truncate(content, 1500); @@ -227,7 +238,7 @@ private AnalysisResult generateSummaryAndQuiz(String content, String cefrLevel) return parseAnalysisResult(response); } catch (Exception e) { logger.error("요약/퀴즈 생성 실패", e); - return new AnalysisResult(null, new ArrayList<>(), new ArrayList<>(), null); + return new AnalysisResult(null, new ArrayList<>(), new ArrayList<>(), new ArrayList<>(), null); } } @@ -275,6 +286,19 @@ private AnalysisResult parseAnalysisResult(String response) { String summary = json.has("summary") ? json.get("summary").getAsString() : null; String category = json.has("category") ? json.get("category").getAsString().toUpperCase() : "WORLD"; + // keywords 파싱 + List keywords = new ArrayList<>(); + if (json.has("keywords")) { + json.getAsJsonArray("keywords").forEach(e -> { + JsonObject k = e.getAsJsonObject(); + keywords.add(KeywordInfo.builder() + .word(k.has("word") ? k.get("word").getAsString() : "") + .meaning(k.has("meaning") ? k.get("meaning").getAsString() : "") + .example(k.has("example") ? k.get("example").getAsString() : "") + .build()); + }); + } + List highlightWords = new ArrayList<>(); if (json.has("highlightWords")) { json.getAsJsonArray("highlightWords").forEach(e -> highlightWords.add(e.getAsString())); @@ -299,7 +323,7 @@ private AnalysisResult parseAnalysisResult(String response) { }); } - return new AnalysisResult(summary, highlightWords, quiz, category); + return new AnalysisResult(summary, keywords, highlightWords, quiz, category); } private String extractJson(String response) { @@ -321,6 +345,7 @@ private String truncate(String text, int maxLength) { */ private record AnalysisResult( String summary, + List keywords, List highlightWords, List quiz, String category diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 6efea7aa..97fe03e6 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -1857,48 +1857,64 @@ Resources: RestApiId: !Ref MainApi Path: /news/stats Method: GET + Auth: + Authorizer: CognitoAuthorizer GetBookmarks: Type: Api Properties: RestApiId: !Ref MainApi Path: /news/bookmarks Method: GET + Auth: + Authorizer: CognitoAuthorizer GetUserWords: Type: Api Properties: RestApiId: !Ref MainApi Path: /news/words Method: GET + Auth: + Authorizer: CognitoAuthorizer SyncWordToVocab: Type: Api Properties: RestApiId: !Ref MainApi Path: /news/words/{word}/sync Method: POST + Auth: + Authorizer: CognitoAuthorizer CollectWord: Type: Api Properties: RestApiId: !Ref MainApi Path: /news/{articleId}/words Method: POST + Auth: + Authorizer: CognitoAuthorizer DeleteWord: Type: Api Properties: RestApiId: !Ref MainApi Path: /news/{articleId}/words/{word} Method: DELETE + Auth: + Authorizer: CognitoAuthorizer GetWordDetail: Type: Api Properties: RestApiId: !Ref MainApi Path: /news/{articleId}/words/{word} Method: GET + Auth: + Authorizer: CognitoAuthorizer GetQuizHistory: Type: Api Properties: RestApiId: !Ref MainApi Path: /news/quiz/history Method: GET + Auth: + Authorizer: CognitoAuthorizer GetQuiz: Type: Api Properties: @@ -1911,24 +1927,32 @@ Resources: RestApiId: !Ref MainApi Path: /news/{articleId}/quiz Method: POST + Auth: + Authorizer: CognitoAuthorizer MarkAsRead: Type: Api Properties: RestApiId: !Ref MainApi Path: /news/{articleId}/read Method: POST + Auth: + Authorizer: CognitoAuthorizer ToggleBookmark: Type: Api Properties: RestApiId: !Ref MainApi Path: /news/{articleId}/bookmark Method: POST + Auth: + Authorizer: CognitoAuthorizer GetAudio: Type: Api Properties: RestApiId: !Ref MainApi Path: /news/{articleId}/audio Method: GET + Auth: + Authorizer: CognitoAuthorizer GetNewsDetail: Type: Api Properties: From fd42d9be8254db81715a86c41c63f20b71d241cf Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 23 Jan 2026 12:17:35 +0900 Subject: [PATCH 491/528] Revert "Merge branch 'test' into prod" This reverts commit c1a958eac6799d5838a76e4078ae304bcdb62278, reversing changes made to a6662e0cfc46c6e12f20a89f0df285ff282160bc. --- .../domain/badge/enums/BadgeType.java | 26 +- .../BadgeConditionStrategyFactory.java | 7 - .../badge/strategy/NewsMasterStrategy.java | 45 - .../strategy/NewsQuizPerfectStrategy.java | 25 - .../badge/strategy/NewsQuizStrategy.java | 25 - .../badge/strategy/NewsReadStrategy.java | 25 - .../badge/strategy/NewsStreakStrategy.java | 25 - .../badge/strategy/NewsWordStrategy.java | 25 - .../domain/news/exception/NewsErrorCode.java | 3 - .../domain/news/handler/NewsHandler.java | 20 +- .../domain/news/model/KeywordInfo.java | 5 +- .../repository/NewsArticleRepository.java | 3 +- .../news/service/NewsAnalysisService.java | 56 +- .../news/service/NewsLearningService.java | 65 +- .../domain/news/service/NewsQuizService.java | 31 +- .../domain/news/service/NewsWordService.java | 43 +- .../websocket/SpeakingConnectHandler.java | 0 .../websocket/SpeakingDisconnectHandler.java | 0 .../handler/websocket/SpeakingHandler.java | 22 - .../websocket/SpeakingMessageHandler.java | 0 .../SpeakingConnectionRepository.java | 0 .../repository/SpeakingSessionRepository.java | 2 +- .../speaking/service/SpeakingService.java | 2 +- .../stats/handler/UserStatsHandler.java | 98 +- .../domain/stats/model/UserStats.java | 10 +- .../stats/repository/UserStatsRepository.java | 216 --- .../domain/badge/enums/BadgeTypeSpec.groovy | 4 +- ServerlessFunction/template.yaml | 145 +- docs/CATCHMIND_ARCHITECTURE_SOLUTION.md | 521 +++++++ docs/CICD-IMPLEMENTATION-QNA.md | 421 ++++++ docs/FRONTEND-API-GUIDE.md | 365 +++++ docs/MIDTERM-REPORT.md | 439 ++++++ docs/domain-reports/BADGE-DOMAIN-REPORT.md | 681 +++++++++ docs/domain-reports/CHATTING-DOMAIN-REPORT.md | 434 ++++++ docs/domain-reports/COMMON-MODULE-REPORT.md | 1228 +++++++++++++++++ docs/domain-reports/GRAMMAR-DOMAIN-REPORT.md | 465 +++++++ docs/domain-reports/STATS-DOMAIN-REPORT.md | 379 +++++ .../VOCABULARY-DOMAIN-REPORT.md | 504 +++++++ .../NEWS-API-TROUBLESHOOTING.md | 226 --- 39 files changed, 5482 insertions(+), 1109 deletions(-) delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsMasterStrategy.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsQuizPerfectStrategy.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsQuizStrategy.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsReadStrategy.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsStreakStrategy.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsWordStrategy.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingConnectHandler.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingDisconnectHandler.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingMessageHandler.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java create mode 100644 docs/CATCHMIND_ARCHITECTURE_SOLUTION.md create mode 100644 docs/CICD-IMPLEMENTATION-QNA.md create mode 100644 docs/FRONTEND-API-GUIDE.md create mode 100644 docs/MIDTERM-REPORT.md create mode 100644 docs/domain-reports/BADGE-DOMAIN-REPORT.md create mode 100644 docs/domain-reports/CHATTING-DOMAIN-REPORT.md create mode 100644 docs/domain-reports/COMMON-MODULE-REPORT.md create mode 100644 docs/domain-reports/GRAMMAR-DOMAIN-REPORT.md create mode 100644 docs/domain-reports/STATS-DOMAIN-REPORT.md create mode 100644 docs/domain-reports/VOCABULARY-DOMAIN-REPORT.md delete mode 100644 docs/troubleshooting/NEWS-API-TROUBLESHOOTING.md diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/enums/BadgeType.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/enums/BadgeType.java index 76e857ae..f9d32794 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/enums/BadgeType.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/enums/BadgeType.java @@ -31,31 +31,7 @@ public enum BadgeType { PERFECT_DRAWER("완벽한 출제자", "출제 시 전원이 정답을 맞췄습니다", "perfect_drawer.png", "PERFECT_DRAWS", 1), // 특별 - MASTER("학습 마스터", "모든 업적을 달성했습니다", "master.png", "ALL_BADGES", 1), - - // 뉴스 - 읽기 - NEWS_FIRST_READ("뉴스 첫 발걸음", "첫 번째 뉴스 읽기 완료", "news_first_read.png", "NEWS_READ", 1), - NEWS_READ_10("뉴스 탐험가", "뉴스 10개 읽기 완료", "news_read_10.png", "NEWS_READ", 10), - NEWS_READ_50("뉴스 애호가", "뉴스 50개 읽기 완료", "news_read_50.png", "NEWS_READ", 50), - NEWS_READ_100("뉴스 전문가", "뉴스 100개 읽기 완료", "news_read_100.png", "NEWS_READ", 100), - - // 뉴스 - 퀴즈 - NEWS_QUIZ_FIRST("퀴즈 도전", "첫 뉴스 퀴즈 완료", "news_quiz_first.png", "NEWS_QUIZ", 1), - NEWS_QUIZ_PERFECT("완벽한 이해", "뉴스 퀴즈에서 만점 달성", "news_quiz_perfect.png", "NEWS_QUIZ_PERFECT", 1), - NEWS_QUIZ_10("퀴즈 탐험가", "뉴스 퀴즈 10회 완료", "news_quiz_10.png", "NEWS_QUIZ", 10), - NEWS_QUIZ_50("퀴즈 마스터", "뉴스 퀴즈 50회 완료", "news_quiz_50.png", "NEWS_QUIZ", 50), - - // 뉴스 - 단어 수집 - NEWS_WORD_10("단어 수집가", "뉴스에서 단어 10개 수집", "news_word_10.png", "NEWS_WORD", 10), - NEWS_WORD_50("단어 사냥꾼", "뉴스에서 단어 50개 수집", "news_word_50.png", "NEWS_WORD", 50), - NEWS_WORD_100("단어 전문가", "뉴스에서 단어 100개 수집", "news_word_100.png", "NEWS_WORD", 100), - - // 뉴스 - 연속 학습 - NEWS_STREAK_7("일주일 뉴스 습관", "7일 연속 뉴스 읽기", "news_streak_7.png", "NEWS_STREAK", 7), - NEWS_STREAK_30("한 달 뉴스 습관", "30일 연속 뉴스 읽기", "news_streak_30.png", "NEWS_STREAK", 30), - - // 뉴스 - 종합 - NEWS_MASTER("뉴스 마스터", "읽기100+퀴즈50+단어100 달성", "news_master.png", "NEWS_MASTER", 1); + MASTER("학습 마스터", "모든 업적을 달성했습니다", "master.png", "ALL_BADGES", 1); private static final String BUCKET_NAME = System.getenv("BUCKET_NAME"); private static final String BASE_URL = getBaseUrl(); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/BadgeConditionStrategyFactory.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/BadgeConditionStrategyFactory.java index ecfb1e63..01f6ed33 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/BadgeConditionStrategyFactory.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/BadgeConditionStrategyFactory.java @@ -22,13 +22,6 @@ public class BadgeConditionStrategyFactory { register(new GamesWonStrategy()); register(new QuickGuessesStrategy()); register(new PerfectDrawsStrategy()); - // 뉴스 관련 전략 - register(new NewsReadStrategy()); - register(new NewsQuizStrategy()); - register(new NewsQuizPerfectStrategy()); - register(new NewsWordStrategy()); - register(new NewsStreakStrategy()); - register(new NewsMasterStrategy()); // 별도 로직이 필요한 카테고리 register(new NoOpStrategy("PERFECT_TEST")); register(new NoOpStrategy("ALL_BADGES")); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsMasterStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsMasterStrategy.java deleted file mode 100644 index 43fee824..00000000 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsMasterStrategy.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.mzc.secondproject.serverless.domain.badge.strategy; - -import com.mzc.secondproject.serverless.domain.badge.enums.BadgeType; -import com.mzc.secondproject.serverless.domain.stats.model.UserStats; - -/** - * 뉴스 마스터 뱃지 조건 전략 - * 읽기 100개 + 퀴즈 50회 + 단어 100개 달성 시 획득 - */ -public class NewsMasterStrategy implements BadgeConditionStrategy { - - private static final int NEWS_READ_REQUIRED = 100; - private static final int NEWS_QUIZ_REQUIRED = 50; - private static final int NEWS_WORD_REQUIRED = 100; - - @Override - public boolean checkCondition(BadgeType type, UserStats stats) { - int newsRead = stats.getNewsRead() != null ? stats.getNewsRead() : 0; - int newsQuiz = stats.getNewsQuizCompleted() != null ? stats.getNewsQuizCompleted() : 0; - int newsWord = stats.getNewsWordsCollected() != null ? stats.getNewsWordsCollected() : 0; - - return newsRead >= NEWS_READ_REQUIRED - && newsQuiz >= NEWS_QUIZ_REQUIRED - && newsWord >= NEWS_WORD_REQUIRED; - } - - @Override - public int calculateProgress(BadgeType type, UserStats stats) { - int newsRead = stats.getNewsRead() != null ? stats.getNewsRead() : 0; - int newsQuiz = stats.getNewsQuizCompleted() != null ? stats.getNewsQuizCompleted() : 0; - int newsWord = stats.getNewsWordsCollected() != null ? stats.getNewsWordsCollected() : 0; - - // 3가지 조건의 평균 진행률 (각각 100%, 100%, 100% 기준) - int readProgress = Math.min(newsRead * 100 / NEWS_READ_REQUIRED, 100); - int quizProgress = Math.min(newsQuiz * 100 / NEWS_QUIZ_REQUIRED, 100); - int wordProgress = Math.min(newsWord * 100 / NEWS_WORD_REQUIRED, 100); - - return (readProgress + quizProgress + wordProgress) / 3; - } - - @Override - public String getCategory() { - return "NEWS_MASTER"; - } -} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsQuizPerfectStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsQuizPerfectStrategy.java deleted file mode 100644 index d9790b27..00000000 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsQuizPerfectStrategy.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.mzc.secondproject.serverless.domain.badge.strategy; - -import com.mzc.secondproject.serverless.domain.badge.enums.BadgeType; -import com.mzc.secondproject.serverless.domain.stats.model.UserStats; - -/** - * 뉴스 퀴즈 만점 뱃지 조건 전략 - */ -public class NewsQuizPerfectStrategy implements BadgeConditionStrategy { - - @Override - public boolean checkCondition(BadgeType type, UserStats stats) { - return stats.getNewsQuizPerfect() != null && stats.getNewsQuizPerfect() >= type.getThreshold(); - } - - @Override - public int calculateProgress(BadgeType type, UserStats stats) { - return stats.getNewsQuizPerfect() != null ? stats.getNewsQuizPerfect() : 0; - } - - @Override - public String getCategory() { - return "NEWS_QUIZ_PERFECT"; - } -} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsQuizStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsQuizStrategy.java deleted file mode 100644 index 4ce390d8..00000000 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsQuizStrategy.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.mzc.secondproject.serverless.domain.badge.strategy; - -import com.mzc.secondproject.serverless.domain.badge.enums.BadgeType; -import com.mzc.secondproject.serverless.domain.stats.model.UserStats; - -/** - * 뉴스 퀴즈 완료 뱃지 조건 전략 - */ -public class NewsQuizStrategy implements BadgeConditionStrategy { - - @Override - public boolean checkCondition(BadgeType type, UserStats stats) { - return stats.getNewsQuizCompleted() != null && stats.getNewsQuizCompleted() >= type.getThreshold(); - } - - @Override - public int calculateProgress(BadgeType type, UserStats stats) { - return stats.getNewsQuizCompleted() != null ? stats.getNewsQuizCompleted() : 0; - } - - @Override - public String getCategory() { - return "NEWS_QUIZ"; - } -} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsReadStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsReadStrategy.java deleted file mode 100644 index 3e5cee34..00000000 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsReadStrategy.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.mzc.secondproject.serverless.domain.badge.strategy; - -import com.mzc.secondproject.serverless.domain.badge.enums.BadgeType; -import com.mzc.secondproject.serverless.domain.stats.model.UserStats; - -/** - * 뉴스 읽기 뱃지 조건 전략 - */ -public class NewsReadStrategy implements BadgeConditionStrategy { - - @Override - public boolean checkCondition(BadgeType type, UserStats stats) { - return stats.getNewsRead() != null && stats.getNewsRead() >= type.getThreshold(); - } - - @Override - public int calculateProgress(BadgeType type, UserStats stats) { - return stats.getNewsRead() != null ? stats.getNewsRead() : 0; - } - - @Override - public String getCategory() { - return "NEWS_READ"; - } -} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsStreakStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsStreakStrategy.java deleted file mode 100644 index cb5f58d0..00000000 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsStreakStrategy.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.mzc.secondproject.serverless.domain.badge.strategy; - -import com.mzc.secondproject.serverless.domain.badge.enums.BadgeType; -import com.mzc.secondproject.serverless.domain.stats.model.UserStats; - -/** - * 뉴스 연속 읽기 뱃지 조건 전략 - */ -public class NewsStreakStrategy implements BadgeConditionStrategy { - - @Override - public boolean checkCondition(BadgeType type, UserStats stats) { - return stats.getNewsStreak() != null && stats.getNewsStreak() >= type.getThreshold(); - } - - @Override - public int calculateProgress(BadgeType type, UserStats stats) { - return stats.getNewsStreak() != null ? stats.getNewsStreak() : 0; - } - - @Override - public String getCategory() { - return "NEWS_STREAK"; - } -} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsWordStrategy.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsWordStrategy.java deleted file mode 100644 index 70c6c1a7..00000000 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/strategy/NewsWordStrategy.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.mzc.secondproject.serverless.domain.badge.strategy; - -import com.mzc.secondproject.serverless.domain.badge.enums.BadgeType; -import com.mzc.secondproject.serverless.domain.stats.model.UserStats; - -/** - * 뉴스 단어 수집 뱃지 조건 전략 - */ -public class NewsWordStrategy implements BadgeConditionStrategy { - - @Override - public boolean checkCondition(BadgeType type, UserStats stats) { - return stats.getNewsWordsCollected() != null && stats.getNewsWordsCollected() >= type.getThreshold(); - } - - @Override - public int calculateProgress(BadgeType type, UserStats stats) { - return stats.getNewsWordsCollected() != null ? stats.getNewsWordsCollected() : 0; - } - - @Override - public String getCategory() { - return "NEWS_WORD"; - } -} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/exception/NewsErrorCode.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/exception/NewsErrorCode.java index cb253701..58197f0e 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/exception/NewsErrorCode.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/exception/NewsErrorCode.java @@ -8,9 +8,6 @@ */ public enum NewsErrorCode implements DomainErrorCode { - // 일반 에러 - INVALID_REQUEST("COMMON_001", "유효하지 않은 요청입니다", 400), - // 인증 관련 에러 UNAUTHORIZED("AUTH_001", "인증이 필요합니다", 401), diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java index 86a30590..180bb7cb 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java @@ -231,7 +231,7 @@ private APIGatewayProxyResponseEvent getBookmarks(APIGatewayProxyRequestEvent re if (params == null) params = new HashMap<>(); int limit = parseLimit(params.get("limit")); - List> bookmarks = learningService.getUserBookmarks(userId, limit); + List bookmarks = learningService.getUserBookmarks(userId, limit); Map response = new HashMap<>(); response.put("bookmarks", bookmarks); @@ -413,26 +413,16 @@ private APIGatewayProxyResponseEvent collectWord(APIGatewayProxyRequestEvent req String articleId = request.getPathParameters().get("articleId"); JsonObject body = gson.fromJson(request.getBody(), JsonObject.class); - if (body == null || !body.has("word") || body.get("word").isJsonNull()) { - return ResponseGenerator.fail(NewsErrorCode.INVALID_REQUEST); - } String word = body.get("word").getAsString(); - String context = body.has("context") && !body.get("context").isJsonNull() - ? body.get("context").getAsString() : ""; + String context = body.has("context") ? body.get("context").getAsString() : ""; - NewsWordService.WordCollectResult result = wordService.collectWord(userId, articleId, word, context); + NewsWordCollect collected = wordService.collectWord(userId, articleId, word, context); - if (result == null || result.wordCollect() == null) { + if (collected == null) { return ResponseGenerator.fail(NewsErrorCode.WORD_ALREADY_COLLECTED); } - Map responseData = new java.util.HashMap<>(); - responseData.put("wordCollect", result.wordCollect()); - if (result.newBadges() != null && !result.newBadges().isEmpty()) { - responseData.put("newBadges", result.newBadges()); - } - - return ResponseGenerator.ok("단어 수집 성공", responseData); + return ResponseGenerator.ok("단어 수집 성공", collected); } /** diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/KeywordInfo.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/KeywordInfo.java index cd5b4b44..81f1e1f5 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/KeywordInfo.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/KeywordInfo.java @@ -8,7 +8,7 @@ /** * 뉴스 기사 내 키워드 정보 - * 단어, 뜻, 예문, 난이도, 위치 정보를 포함 + * 단어, 뜻, 난이도, 위치 정보를 포함 */ @Data @Builder @@ -18,8 +18,7 @@ public class KeywordInfo { private String word; // 영어 단어 - private String meaning; // 영어 뜻 (간단한 정의) - private String example; // 기사에서 발췌한 예문 + private String meaning; // 한국어 뜻 private String level; // 단어 난이도 (BEGINNER, INTERMEDIATE, ADVANCED) private Integer position; // 기사 내 위치 (문장 번호 또는 단어 인덱스) } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsArticleRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsArticleRepository.java index 4aeb217d..28ca35cc 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsArticleRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsArticleRepository.java @@ -72,9 +72,8 @@ public Optional findByDateAndId(String date, String articleId) { */ public Optional findById(String articleId) { Expression filterExpression = Expression.builder() - .expression("articleId = :articleId AND begins_with(SK, :skPrefix)") + .expression("articleId = :articleId") .putExpressionValue(":articleId", AttributeValue.builder().s(articleId).build()) - .putExpressionValue(":skPrefix", AttributeValue.builder().s("ARTICLE#").build()) .build(); ScanEnhancedRequest request = ScanEnhancedRequest.builder() diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java index 83a4fc30..af23fc5b 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java @@ -58,25 +58,17 @@ public NewsArticle analyzeArticle(NewsArticle article) { article.setCefrLevel(cefrLevel); article.setLevel(mapCefrToLevel(cefrLevel)); - // 2. 3줄 요약 + 퀴즈 + 카테고리 + 키워드 생성 (Bedrock - 한 번에 처리) + // 2. 핵심 단어 추출 (Comprehend) + List keywords = extractKeywords(content); + article.setKeywords(keywords); + + // 3. 3줄 요약 + 퀴즈 생성 (Bedrock - 한 번에 처리) AnalysisResult result = generateSummaryAndQuiz(content, cefrLevel); if (result.summary() != null) { article.setSummary(result.summary()); } article.setQuiz(result.quiz()); article.setHighlightWords(result.highlightWords()); - if (result.category() != null) { - article.setCategory(result.category()); - } - - // 3. 키워드 설정 (Bedrock AI에서 추출한 키워드 사용) - if (result.keywords() != null && !result.keywords().isEmpty()) { - article.setKeywords(result.keywords()); - } else { - // Bedrock 키워드 추출 실패 시 Comprehend 폴백 - List fallbackKeywords = extractKeywords(content); - article.setKeywords(fallbackKeywords); - } // 4. GSI 키 설정 article.setGsi1pk("LEVEL#" + article.getLevel()); @@ -181,7 +173,7 @@ private List extractKeywords(String content) { } /** - * 요약 + 퀴즈 + 카테고리 + 키워드 생성 (Bedrock) + * 요약 + 퀴즈 생성 (Bedrock) */ private AnalysisResult generateSummaryAndQuiz(String content, String cefrLevel) { String systemPrompt = """ @@ -190,12 +182,7 @@ private AnalysisResult generateSummaryAndQuiz(String content, String cefrLevel) Respond in this exact JSON format: { "summary": "3-line summary in English (each line separated by newline)", - "keywords": [ - {"word": "economy", "meaning": "the system of trade and industry", "example": "The economy is growing steadily."}, - {"word": "policy", "meaning": "a plan of action adopted by government", "example": "The new policy affects all citizens."} - ], "highlightWords": ["word1", "word2", "word3"], - "category": "WORLD", "quiz": [ { "questionId": "q1", @@ -224,12 +211,9 @@ private AnalysisResult generateSummaryAndQuiz(String content, String cefrLevel) ] } - IMPORTANT: - - keywords: Extract 5-8 important vocabulary words from the article. Include word, meaning (simple definition), and example sentence from the article. - - highlightWords: 3-5 difficult words that learners should pay attention to (just the words, no definitions). - - category: Choose EXACTLY ONE from: WORLD, POLITICS, BUSINESS, TECH, SCIENCE, HEALTH, SPORTS, ENTERTAINMENT, LIFESTYLE - - Create exactly 3 quiz questions. - - Adjust difficulty based on CEFR level: """ + cefrLevel; + Create exactly 3 quiz questions. + highlightWords should contain 3-5 difficult words for learners. + Adjust difficulty based on CEFR level: """ + cefrLevel; String userPrompt = "Create learning materials for this article:\n\n" + truncate(content, 1500); @@ -238,7 +222,7 @@ private AnalysisResult generateSummaryAndQuiz(String content, String cefrLevel) return parseAnalysisResult(response); } catch (Exception e) { logger.error("요약/퀴즈 생성 실패", e); - return new AnalysisResult(null, new ArrayList<>(), new ArrayList<>(), new ArrayList<>(), null); + return new AnalysisResult(null, new ArrayList<>(), new ArrayList<>()); } } @@ -284,20 +268,6 @@ private AnalysisResult parseAnalysisResult(String response) { JsonObject json = gson.fromJson(jsonStr, JsonObject.class); String summary = json.has("summary") ? json.get("summary").getAsString() : null; - String category = json.has("category") ? json.get("category").getAsString().toUpperCase() : "WORLD"; - - // keywords 파싱 - List keywords = new ArrayList<>(); - if (json.has("keywords")) { - json.getAsJsonArray("keywords").forEach(e -> { - JsonObject k = e.getAsJsonObject(); - keywords.add(KeywordInfo.builder() - .word(k.has("word") ? k.get("word").getAsString() : "") - .meaning(k.has("meaning") ? k.get("meaning").getAsString() : "") - .example(k.has("example") ? k.get("example").getAsString() : "") - .build()); - }); - } List highlightWords = new ArrayList<>(); if (json.has("highlightWords")) { @@ -323,7 +293,7 @@ private AnalysisResult parseAnalysisResult(String response) { }); } - return new AnalysisResult(summary, keywords, highlightWords, quiz, category); + return new AnalysisResult(summary, highlightWords, quiz); } private String extractJson(String response) { @@ -345,9 +315,7 @@ private String truncate(String text, int maxLength) { */ private record AnalysisResult( String summary, - List keywords, List highlightWords, - List quiz, - String category + List quiz ) {} } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java index c46e4fc6..8eba8522 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java @@ -2,18 +2,13 @@ import com.mzc.secondproject.serverless.common.config.EnvConfig; import com.mzc.secondproject.serverless.common.service.PollyService; -import com.mzc.secondproject.serverless.domain.badge.model.UserBadge; -import com.mzc.secondproject.serverless.domain.badge.service.BadgeService; import com.mzc.secondproject.serverless.domain.news.model.NewsArticle; import com.mzc.secondproject.serverless.domain.news.model.UserNewsRecord; import com.mzc.secondproject.serverless.domain.news.repository.NewsArticleRepository; import com.mzc.secondproject.serverless.domain.news.repository.UserNewsRepository; -import com.mzc.secondproject.serverless.domain.stats.model.UserStats; -import com.mzc.secondproject.serverless.domain.stats.repository.UserStatsRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Optional; @@ -29,38 +24,29 @@ public class NewsLearningService { private final NewsArticleRepository articleRepository; private final UserNewsRepository userNewsRepository; private final PollyService pollyService; - private final UserStatsRepository userStatsRepository; - private final BadgeService badgeService; public NewsLearningService() { this.articleRepository = new NewsArticleRepository(); this.userNewsRepository = new UserNewsRepository(); this.pollyService = new PollyService(BUCKET_NAME, "news/audio/"); - this.userStatsRepository = new UserStatsRepository(); - this.badgeService = new BadgeService(); } public NewsLearningService(NewsArticleRepository articleRepository, UserNewsRepository userNewsRepository, - PollyService pollyService, - UserStatsRepository userStatsRepository, - BadgeService badgeService) { + PollyService pollyService) { this.articleRepository = articleRepository; this.userNewsRepository = userNewsRepository; this.pollyService = pollyService; - this.userStatsRepository = userStatsRepository; - this.badgeService = badgeService; } /** * 뉴스 읽기 완료 기록 - * @return 새로 획득한 배지 목록 */ - public List markAsRead(String userId, String articleId) { + public void markAsRead(String userId, String articleId) { Optional article = articleRepository.findById(articleId); if (article.isEmpty()) { logger.warn("기사를 찾을 수 없음: {}", articleId); - return new ArrayList<>(); + return; } NewsArticle a = article.get(); @@ -79,23 +65,6 @@ public List markAsRead(String userId, String articleId) { } logger.info("읽기 완료 기록: userId={}, articleId={}", userId, articleId); - - // 통계 업데이트 및 배지 체크 - List newBadges = new ArrayList<>(); - try { - UserStats updatedStats = userStatsRepository.incrementNewsReadStats(userId); - if (updatedStats != null) { - newBadges = badgeService.checkAndAwardBadges(userId, updatedStats); - if (!newBadges.isEmpty()) { - logger.info("새 배지 획득: userId={}, badges={}", userId, - newBadges.stream().map(UserBadge::getBadgeType).toList()); - } - } - } catch (Exception e) { - logger.error("통계/배지 업데이트 실패: userId={}, error={}", userId, e.getMessage()); - } - - return newBadges; } /** @@ -136,32 +105,10 @@ public boolean isBookmarked(String userId, String articleId) { } /** - * 사용자 북마크 목록 조회 (기사 정보 포함) + * 사용자 북마크 목록 조회 */ - public List> getUserBookmarks(String userId, int limit) { - List bookmarks = userNewsRepository.getUserBookmarks(userId, limit); - List> result = new ArrayList<>(); - - for (UserNewsRecord bookmark : bookmarks) { - Optional articleOpt = articleRepository.findById(bookmark.getArticleId()); - if (articleOpt.isPresent()) { - NewsArticle article = articleOpt.get(); - Map bookmarkWithArticle = new java.util.HashMap<>(); - bookmarkWithArticle.put("articleId", article.getArticleId()); - bookmarkWithArticle.put("title", article.getTitle()); - bookmarkWithArticle.put("summary", article.getSummary()); - bookmarkWithArticle.put("source", article.getSource()); - bookmarkWithArticle.put("publishedAt", article.getPublishedAt()); - bookmarkWithArticle.put("keywords", article.getKeywords()); - bookmarkWithArticle.put("highlightWords", article.getHighlightWords()); - bookmarkWithArticle.put("category", article.getCategory()); - bookmarkWithArticle.put("level", article.getLevel()); - bookmarkWithArticle.put("imageUrl", article.getImageUrl()); - bookmarkWithArticle.put("bookmarkedAt", bookmark.getCreatedAt()); - result.add(bookmarkWithArticle); - } - } - return result; + public List getUserBookmarks(String userId, int limit) { + return userNewsRepository.getUserBookmarks(userId, limit); } /** diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQuizService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQuizService.java index da86d430..bb22fc90 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQuizService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQuizService.java @@ -1,13 +1,9 @@ package com.mzc.secondproject.serverless.domain.news.service; -import com.mzc.secondproject.serverless.domain.badge.model.UserBadge; -import com.mzc.secondproject.serverless.domain.badge.service.BadgeService; import com.mzc.secondproject.serverless.domain.news.constants.NewsKey; import com.mzc.secondproject.serverless.domain.news.model.*; import com.mzc.secondproject.serverless.domain.news.repository.NewsArticleRepository; import com.mzc.secondproject.serverless.domain.news.repository.NewsQuizRepository; -import com.mzc.secondproject.serverless.domain.stats.model.UserStats; -import com.mzc.secondproject.serverless.domain.stats.repository.UserStatsRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -24,22 +20,15 @@ public class NewsQuizService { private final NewsArticleRepository articleRepository; private final NewsQuizRepository quizRepository; - private final UserStatsRepository userStatsRepository; - private final BadgeService badgeService; public NewsQuizService() { this.articleRepository = new NewsArticleRepository(); this.quizRepository = new NewsQuizRepository(); - this.userStatsRepository = new UserStatsRepository(); - this.badgeService = new BadgeService(); } - public NewsQuizService(NewsArticleRepository articleRepository, NewsQuizRepository quizRepository, - UserStatsRepository userStatsRepository, BadgeService badgeService) { + public NewsQuizService(NewsArticleRepository articleRepository, NewsQuizRepository quizRepository) { this.articleRepository = articleRepository; this.quizRepository = quizRepository; - this.userStatsRepository = userStatsRepository; - this.badgeService = badgeService; } /** @@ -169,29 +158,12 @@ public QuizSubmitResult submitQuiz(String userId, String articleId, List newBadges = new ArrayList<>(); - try { - boolean isPerfect = score == 100; - UserStats updatedStats = userStatsRepository.incrementNewsQuizStats(userId, isPerfect); - if (updatedStats != null) { - newBadges = badgeService.checkAndAwardBadges(userId, updatedStats); - if (!newBadges.isEmpty()) { - logger.info("새 배지 획득: userId={}, badges={}", userId, - newBadges.stream().map(UserBadge::getBadgeType).toList()); - } - } - } catch (Exception e) { - logger.error("통계/배지 업데이트 실패: userId={}, error={}", userId, e.getMessage()); - } - return QuizSubmitResult.builder() .score(score) .earnedPoints(earnedPoints) .totalPoints(totalPoints) .results(answerResults) .feedback(feedback) - .newBadges(newBadges) .build(); } @@ -287,6 +259,5 @@ public static class QuizSubmitResult { private int totalPoints; private List results; private String feedback; - private List newBadges; } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsWordService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsWordService.java index 6881c7ec..6c3c23ec 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsWordService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsWordService.java @@ -1,14 +1,10 @@ package com.mzc.secondproject.serverless.domain.news.service; -import com.mzc.secondproject.serverless.domain.badge.model.UserBadge; -import com.mzc.secondproject.serverless.domain.badge.service.BadgeService; import com.mzc.secondproject.serverless.domain.news.constants.NewsKey; import com.mzc.secondproject.serverless.domain.news.model.NewsArticle; import com.mzc.secondproject.serverless.domain.news.model.NewsWordCollect; import com.mzc.secondproject.serverless.domain.news.repository.NewsArticleRepository; import com.mzc.secondproject.serverless.domain.news.repository.NewsWordRepository; -import com.mzc.secondproject.serverless.domain.stats.model.UserStats; -import com.mzc.secondproject.serverless.domain.stats.repository.UserStatsRepository; import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; import com.mzc.secondproject.serverless.domain.vocabulary.repository.WordRepository; import com.mzc.secondproject.serverless.domain.vocabulary.service.UserWordCommandService; @@ -16,7 +12,6 @@ import org.slf4j.LoggerFactory; import java.time.Instant; -import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Optional; @@ -32,42 +27,32 @@ public class NewsWordService { private final NewsArticleRepository articleRepository; private final WordRepository wordRepository; private final UserWordCommandService userWordCommandService; - private final UserStatsRepository userStatsRepository; - private final BadgeService badgeService; public NewsWordService() { this.newsWordRepository = new NewsWordRepository(); this.articleRepository = new NewsArticleRepository(); this.wordRepository = new WordRepository(); this.userWordCommandService = new UserWordCommandService(); - this.userStatsRepository = new UserStatsRepository(); - this.badgeService = new BadgeService(); } public NewsWordService(NewsWordRepository newsWordRepository, NewsArticleRepository articleRepository, WordRepository wordRepository, - UserWordCommandService userWordCommandService, - UserStatsRepository userStatsRepository, - BadgeService badgeService) { + UserWordCommandService userWordCommandService) { this.newsWordRepository = newsWordRepository; this.articleRepository = articleRepository; this.wordRepository = wordRepository; this.userWordCommandService = userWordCommandService; - this.userStatsRepository = userStatsRepository; - this.badgeService = badgeService; } /** * 단어 수집 - * @return 수집 결과 (단어 정보 + 새로 획득한 배지) */ - public WordCollectResult collectWord(String userId, String articleId, String word, String context) { + public NewsWordCollect collectWord(String userId, String articleId, String word, String context) { // 이미 수집했는지 확인 if (newsWordRepository.hasCollected(userId, word, articleId)) { logger.warn("이미 수집한 단어: userId={}, word={}", userId, word); - NewsWordCollect existing = newsWordRepository.findByUserWordArticle(userId, word, articleId).orElse(null); - return new WordCollectResult(existing, new ArrayList<>()); + return newsWordRepository.findByUserWordArticle(userId, word, articleId).orElse(null); } // 기사 조회 @@ -101,29 +86,9 @@ public WordCollectResult collectWord(String userId, String articleId, String wor newsWordRepository.save(wordCollect); logger.info("단어 수집 완료: userId={}, word={}, articleId={}", userId, word, articleId); - // 통계 업데이트 및 배지 체크 - List newBadges = new ArrayList<>(); - try { - UserStats updatedStats = userStatsRepository.incrementNewsWordStats(userId, 1); - if (updatedStats != null) { - newBadges = badgeService.checkAndAwardBadges(userId, updatedStats); - if (!newBadges.isEmpty()) { - logger.info("새 배지 획득: userId={}, badges={}", userId, - newBadges.stream().map(UserBadge::getBadgeType).toList()); - } - } - } catch (Exception e) { - logger.error("통계/배지 업데이트 실패: userId={}, error={}", userId, e.getMessage()); - } - - return new WordCollectResult(wordCollect, newBadges); + return wordCollect; } - /** - * 단어 수집 결과 - */ - public record WordCollectResult(NewsWordCollect wordCollect, List newBadges) {} - /** * 수집한 단어 삭제 */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingConnectHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingConnectHandler.java new file mode 100644 index 00000000..e69de29b diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingDisconnectHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingDisconnectHandler.java new file mode 100644 index 00000000..e69de29b diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingHandler.java index c515f950..69375925 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingHandler.java @@ -148,28 +148,6 @@ private APIGatewayProxyResponseEvent handleReset(String userId, String body) { )); } - /** - * JSON에서 문자열 추출 (null 또는 JsonNull이면 null 반환) - */ - private String getStringOrNull(JsonObject json, String key) { - if (!json.has(key) || json.get(key).isJsonNull()) { - return null; - } - return json.get(key).getAsString(); - } - - /** - * JSON에서 문자열 추출 (null 또는 JsonNull이면 기본값 반환) - */ - private String getStringOrDefault(JsonObject json, String key, String defaultValue) { - if (!json.has(key) || json.get(key).isJsonNull()) { - return defaultValue; - } - String value = json.get(key).getAsString(); - return (value == null || value.isEmpty()) ? defaultValue : value; - } - - private APIGatewayProxyResponseEvent response(int statusCode, Map body) { return new APIGatewayProxyResponseEvent() .withStatusCode(statusCode) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingMessageHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingMessageHandler.java new file mode 100644 index 00000000..e69de29b diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java new file mode 100644 index 00000000..e69de29b diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingSessionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingSessionRepository.java index a6d9e26a..fed1cd66 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingSessionRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingSessionRepository.java @@ -17,7 +17,7 @@ public class SpeakingSessionRepository { private static final Logger logger = LoggerFactory.getLogger(SpeakingSessionRepository.class); - private static final String TABLE_NAME = EnvConfig.getRequired("SPEAKING_TABLE_NAME"); + private static final String TABLE_NAME = EnvConfig.getRequired("CHAT_TABLE_NAME"); private final DynamoDbTable table; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/service/SpeakingService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/service/SpeakingService.java index 3dffac92..010c4afe 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/service/SpeakingService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/service/SpeakingService.java @@ -342,4 +342,4 @@ public record SpeakingResponse( String aiAudioUrl, // AI 응답 음성 URL (Polly) double confidence // STT 신뢰도comp ) {} -} +} \ No newline at end of file diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java index 5a2ba9c0..637151be 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java @@ -49,7 +49,6 @@ public UserStatsHandler(UserStatsRepository statsRepository, DailyStudyRepositor private HandlerRouter initRouter() { return new HandlerRouter().addRoutes( - Route.getAuth("/stats/dashboard", this::getDashboardStats), Route.getAuth("/stats/daily", this::getDailyStats), Route.getAuth("/stats/weekly", this::getWeeklyStats), Route.getAuth("/stats/monthly", this::getMonthlyStats), @@ -63,88 +62,7 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent re logger.info("Received request: {} {}", request.getHttpMethod(), request.getPath()); return router.route(request); } - - /** - * 대시보드용 통합 통계 조회 (프론트엔드 요청 형식) - * GET /stats/dashboard - */ - private APIGatewayProxyResponseEvent getDashboardStats(APIGatewayProxyRequestEvent request, String userId) { - String today = LocalDate.now().toString(); - - // 오늘 통계 조회 - Optional dailyStats = statsRepository.findDailyStats(userId, today); - // 전체 통계 조회 - Optional totalStats = statsRepository.findTotalStats(userId); - // 최근 7일 히스토리 조회 - PaginatedResult weekHistory = statsRepository.findRecentDailyStats(userId, 7, null); - // 오늘 학습 목표 조회 - Optional dailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today); - - Map response = new HashMap<>(); - - // today 섹션 - Map todaySection = new HashMap<>(); - if (dailyStats.isPresent()) { - UserStats ds = dailyStats.get(); - todaySection.put("wordsLearned", ds.getNewWordsLearned() != null ? ds.getNewWordsLearned() : 0); - todaySection.put("newsRead", ds.getNewsRead() != null ? ds.getNewsRead() : 0); - todaySection.put("quizzesTaken", (ds.getTestsCompleted() != null ? ds.getTestsCompleted() : 0) + - (ds.getNewsQuizCompleted() != null ? ds.getNewsQuizCompleted() : 0)); - } else { - todaySection.put("wordsLearned", 0); - todaySection.put("newsRead", 0); - todaySection.put("quizzesTaken", 0); - } - todaySection.put("wordsTotal", dailyStudy.map(ds -> ds.getTotalWords() != null ? ds.getTotalWords() : 25).orElse(25)); - response.put("today", todaySection); - - // overall 섹션 - Map overallSection = new HashMap<>(); - if (totalStats.isPresent()) { - UserStats ts = totalStats.get(); - overallSection.put("totalWordsLearned", ts.getNewWordsLearned() != null ? ts.getNewWordsLearned() : 0); - overallSection.put("totalNewsRead", ts.getNewsRead() != null ? ts.getNewsRead() : 0); - overallSection.put("totalQuizzes", (ts.getTestsCompleted() != null ? ts.getTestsCompleted() : 0) + - (ts.getNewsQuizCompleted() != null ? ts.getNewsQuizCompleted() : 0)); - overallSection.put("averageAccuracy", calculateSuccessRate(ts)); - overallSection.put("currentStreak", ts.getCurrentStreak() != null ? ts.getCurrentStreak() : 0); - overallSection.put("longestStreak", ts.getLongestStreak() != null ? ts.getLongestStreak() : 0); - overallSection.put("lastStudyDate", ts.getLastStudyDate()); - } else { - overallSection.put("totalWordsLearned", 0); - overallSection.put("totalNewsRead", 0); - overallSection.put("totalQuizzes", 0); - overallSection.put("averageAccuracy", 0.0); - overallSection.put("currentStreak", 0); - overallSection.put("longestStreak", 0); - overallSection.put("lastStudyDate", null); - } - // totalStudyDays 계산 (최근 히스토리에서 실제 학습한 날 수) - overallSection.put("totalStudyDays", weekHistory.items().size()); - response.put("overall", overallSection); - - // weeklyProgress 섹션 - List> weeklyProgress = weekHistory.items().stream() - .map(stats -> { - Map dayStats = new HashMap<>(); - dayStats.put("date", stats.getPeriod()); - dayStats.put("wordsLearned", stats.getNewWordsLearned() != null ? stats.getNewWordsLearned() : 0); - dayStats.put("newsRead", stats.getNewsRead() != null ? stats.getNewsRead() : 0); - return dayStats; - }) - .collect(Collectors.toList()); - response.put("weeklyProgress", weeklyProgress); - - // levelDistribution (현재 미구현 - 향후 추가 가능) - Map levelDistribution = new HashMap<>(); - levelDistribution.put("beginner", 0); - levelDistribution.put("intermediate", 0); - levelDistribution.put("advanced", 0); - response.put("levelDistribution", levelDistribution); - - return ResponseGenerator.ok("학습 통계 조회 성공", response); - } - + /** * 오늘의 통계 조회 */ @@ -254,7 +172,7 @@ private Map buildStatsResponse(Optional stats, String Map response = new HashMap<>(); response.put("periodType", periodType); response.put("period", period); - + if (stats.isPresent()) { UserStats s = stats.get(); response.put("testsCompleted", s.getTestsCompleted() != null ? s.getTestsCompleted() : 0); @@ -264,11 +182,6 @@ private Map buildStatsResponse(Optional stats, String response.put("successRate", calculateSuccessRate(s)); response.put("newWordsLearned", s.getNewWordsLearned() != null ? s.getNewWordsLearned() : 0); response.put("wordsReviewed", s.getWordsReviewed() != null ? s.getWordsReviewed() : 0); - // 뉴스 관련 통계 - response.put("newsRead", s.getNewsRead() != null ? s.getNewsRead() : 0); - response.put("newsQuizCompleted", s.getNewsQuizCompleted() != null ? s.getNewsQuizCompleted() : 0); - response.put("newsQuizPerfect", s.getNewsQuizPerfect() != null ? s.getNewsQuizPerfect() : 0); - response.put("newsWordsCollected", s.getNewsWordsCollected() != null ? s.getNewsWordsCollected() : 0); } else { response.put("testsCompleted", 0); response.put("questionsAnswered", 0); @@ -277,13 +190,8 @@ private Map buildStatsResponse(Optional stats, String response.put("successRate", 0.0); response.put("newWordsLearned", 0); response.put("wordsReviewed", 0); - // 뉴스 관련 통계 - response.put("newsRead", 0); - response.put("newsQuizCompleted", 0); - response.put("newsQuizPerfect", 0); - response.put("newsWordsCollected", 0); } - + return response; } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/model/UserStats.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/model/UserStats.java index 4905a9f2..cc25634c 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/model/UserStats.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/model/UserStats.java @@ -56,15 +56,7 @@ public class UserStats { private Integer totalGameScore; // 누적 게임 점수 private Integer quickGuesses; // 5초 내 정답 횟수 private Integer perfectDraws; // 전원 정답 유도 횟수 - - // 뉴스 통계 - private Integer newsRead; // 읽은 뉴스 수 - private Integer newsQuizCompleted; // 완료한 뉴스 퀴즈 수 - private Integer newsQuizPerfect; // 뉴스 퀴즈 만점 횟수 - private Integer newsWordsCollected; // 뉴스에서 수집한 단어 수 - private Integer newsStreak; // 뉴스 연속 읽기 일수 - private String lastNewsReadDate; // 마지막 뉴스 읽은 날짜 - + // 메타데이터 private String createdAt; private String updatedAt; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java index 4ab46228..b3ad20d8 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java @@ -310,222 +310,6 @@ public void incrementGameStats(String userId, int gamesPlayed, int gamesWon, userId, gamesPlayed, gamesWon, correctGuesses); } - /** - * 뉴스 읽기 통계 Atomic 업데이트 (TOTAL + DAILY) - */ - public UserStats incrementNewsReadStats(String userId) { - String today = LocalDate.now().toString(); - String pk = StatsKey.userStatsPk(userId); - String now = Instant.now().toString(); - - // 먼저 현재 통계 조회 (streak 계산용) - UserStats currentStats = findTotalStats(userId).orElse(null); - String lastNewsReadDate = currentStats != null ? currentStats.getLastNewsReadDate() : null; - - // 연속 읽기 계산 - int currentStreak = 1; - if (lastNewsReadDate != null) { - LocalDate lastDate = LocalDate.parse(lastNewsReadDate); - LocalDate todayDate = LocalDate.now(); - if (lastDate.equals(todayDate.minusDays(1))) { - // 어제 읽었으면 streak 증가 - currentStreak = (currentStats.getNewsStreak() != null ? currentStats.getNewsStreak() : 0) + 1; - } else if (lastDate.equals(todayDate)) { - // 오늘 이미 읽었으면 streak 유지 - currentStreak = currentStats.getNewsStreak() != null ? currentStats.getNewsStreak() : 1; - } - // 그 외의 경우는 streak 1로 초기화 - } - - Map values = new HashMap<>(); - values.put(":one", AttributeValue.builder().n("1").build()); - values.put(":zero", AttributeValue.builder().n("0").build()); - values.put(":streak", AttributeValue.builder().n(String.valueOf(currentStreak)).build()); - values.put(":today", AttributeValue.builder().s(today).build()); - values.put(":now", AttributeValue.builder().s(now).build()); - - // 1. TOTAL 통계 업데이트 - Map totalKey = new HashMap<>(); - totalKey.put("PK", AttributeValue.builder().s(pk).build()); - totalKey.put("SK", AttributeValue.builder().s(StatsKey.statsTotalSk()).build()); - - String totalUpdateExpression = "SET " + - "newsRead = if_not_exists(newsRead, :zero) + :one, " + - "newsStreak = :streak, " + - "lastNewsReadDate = :today, " + - "updatedAt = :now, " + - "createdAt = if_not_exists(createdAt, :now)"; - - UpdateItemRequest totalRequest = UpdateItemRequest.builder() - .tableName(TABLE_NAME) - .key(totalKey) - .updateExpression(totalUpdateExpression) - .expressionAttributeValues(values) - .returnValues(software.amazon.awssdk.services.dynamodb.model.ReturnValue.ALL_NEW) - .build(); - - AwsClients.dynamoDb().updateItem(totalRequest); - - // 2. DAILY 통계 업데이트 - Map dailyKey = new HashMap<>(); - dailyKey.put("PK", AttributeValue.builder().s(pk).build()); - dailyKey.put("SK", AttributeValue.builder().s(StatsKey.statsDailySk(today)).build()); - - Map dailyValues = new HashMap<>(); - dailyValues.put(":one", AttributeValue.builder().n("1").build()); - dailyValues.put(":zero", AttributeValue.builder().n("0").build()); - dailyValues.put(":now", AttributeValue.builder().s(now).build()); - dailyValues.put(":today", AttributeValue.builder().s(today).build()); - - String dailyUpdateExpression = "SET " + - "newsRead = if_not_exists(newsRead, :zero) + :one, " + - "updatedAt = :now, " + - "createdAt = if_not_exists(createdAt, :now), " + - "period = if_not_exists(period, :today)"; - - UpdateItemRequest dailyRequest = UpdateItemRequest.builder() - .tableName(TABLE_NAME) - .key(dailyKey) - .updateExpression(dailyUpdateExpression) - .expressionAttributeValues(dailyValues) - .build(); - - AwsClients.dynamoDb().updateItem(dailyRequest); - logger.info("Incremented news read stats (TOTAL + DAILY): userId={}, streak={}", userId, currentStreak); - - return findTotalStats(userId).orElse(null); - } - - /** - * 뉴스 퀴즈 통계 Atomic 업데이트 (TOTAL + DAILY) - */ - public UserStats incrementNewsQuizStats(String userId, boolean isPerfect) { - String today = LocalDate.now().toString(); - String pk = StatsKey.userStatsPk(userId); - String now = Instant.now().toString(); - - Map values = new HashMap<>(); - values.put(":one", AttributeValue.builder().n("1").build()); - values.put(":zero", AttributeValue.builder().n("0").build()); - values.put(":perfect", AttributeValue.builder().n(isPerfect ? "1" : "0").build()); - values.put(":now", AttributeValue.builder().s(now).build()); - - // 1. TOTAL 통계 업데이트 - Map totalKey = new HashMap<>(); - totalKey.put("PK", AttributeValue.builder().s(pk).build()); - totalKey.put("SK", AttributeValue.builder().s(StatsKey.statsTotalSk()).build()); - - String totalUpdateExpression = "SET " + - "newsQuizCompleted = if_not_exists(newsQuizCompleted, :zero) + :one, " + - "newsQuizPerfect = if_not_exists(newsQuizPerfect, :zero) + :perfect, " + - "updatedAt = :now, " + - "createdAt = if_not_exists(createdAt, :now)"; - - UpdateItemRequest totalRequest = UpdateItemRequest.builder() - .tableName(TABLE_NAME) - .key(totalKey) - .updateExpression(totalUpdateExpression) - .expressionAttributeValues(values) - .returnValues(software.amazon.awssdk.services.dynamodb.model.ReturnValue.ALL_NEW) - .build(); - - AwsClients.dynamoDb().updateItem(totalRequest); - - // 2. DAILY 통계 업데이트 - Map dailyKey = new HashMap<>(); - dailyKey.put("PK", AttributeValue.builder().s(pk).build()); - dailyKey.put("SK", AttributeValue.builder().s(StatsKey.statsDailySk(today)).build()); - - Map dailyValues = new HashMap<>(); - dailyValues.put(":one", AttributeValue.builder().n("1").build()); - dailyValues.put(":zero", AttributeValue.builder().n("0").build()); - dailyValues.put(":perfect", AttributeValue.builder().n(isPerfect ? "1" : "0").build()); - dailyValues.put(":now", AttributeValue.builder().s(now).build()); - dailyValues.put(":today", AttributeValue.builder().s(today).build()); - - String dailyUpdateExpression = "SET " + - "newsQuizCompleted = if_not_exists(newsQuizCompleted, :zero) + :one, " + - "newsQuizPerfect = if_not_exists(newsQuizPerfect, :zero) + :perfect, " + - "updatedAt = :now, " + - "createdAt = if_not_exists(createdAt, :now), " + - "period = if_not_exists(period, :today)"; - - UpdateItemRequest dailyRequest = UpdateItemRequest.builder() - .tableName(TABLE_NAME) - .key(dailyKey) - .updateExpression(dailyUpdateExpression) - .expressionAttributeValues(dailyValues) - .build(); - - AwsClients.dynamoDb().updateItem(dailyRequest); - logger.info("Incremented news quiz stats (TOTAL + DAILY): userId={}, isPerfect={}", userId, isPerfect); - - return findTotalStats(userId).orElse(null); - } - - /** - * 뉴스 단어 수집 통계 Atomic 업데이트 (TOTAL + DAILY) - */ - public UserStats incrementNewsWordStats(String userId, int wordCount) { - String today = LocalDate.now().toString(); - String pk = StatsKey.userStatsPk(userId); - String now = Instant.now().toString(); - - Map values = new HashMap<>(); - values.put(":count", AttributeValue.builder().n(String.valueOf(wordCount)).build()); - values.put(":zero", AttributeValue.builder().n("0").build()); - values.put(":now", AttributeValue.builder().s(now).build()); - - // 1. TOTAL 통계 업데이트 - Map totalKey = new HashMap<>(); - totalKey.put("PK", AttributeValue.builder().s(pk).build()); - totalKey.put("SK", AttributeValue.builder().s(StatsKey.statsTotalSk()).build()); - - String totalUpdateExpression = "SET " + - "newsWordsCollected = if_not_exists(newsWordsCollected, :zero) + :count, " + - "updatedAt = :now, " + - "createdAt = if_not_exists(createdAt, :now)"; - - UpdateItemRequest totalRequest = UpdateItemRequest.builder() - .tableName(TABLE_NAME) - .key(totalKey) - .updateExpression(totalUpdateExpression) - .expressionAttributeValues(values) - .returnValues(software.amazon.awssdk.services.dynamodb.model.ReturnValue.ALL_NEW) - .build(); - - AwsClients.dynamoDb().updateItem(totalRequest); - - // 2. DAILY 통계 업데이트 - Map dailyKey = new HashMap<>(); - dailyKey.put("PK", AttributeValue.builder().s(pk).build()); - dailyKey.put("SK", AttributeValue.builder().s(StatsKey.statsDailySk(today)).build()); - - Map dailyValues = new HashMap<>(); - dailyValues.put(":count", AttributeValue.builder().n(String.valueOf(wordCount)).build()); - dailyValues.put(":zero", AttributeValue.builder().n("0").build()); - dailyValues.put(":now", AttributeValue.builder().s(now).build()); - dailyValues.put(":today", AttributeValue.builder().s(today).build()); - - String dailyUpdateExpression = "SET " + - "newsWordsCollected = if_not_exists(newsWordsCollected, :zero) + :count, " + - "updatedAt = :now, " + - "createdAt = if_not_exists(createdAt, :now), " + - "period = if_not_exists(period, :today)"; - - UpdateItemRequest dailyRequest = UpdateItemRequest.builder() - .tableName(TABLE_NAME) - .key(dailyKey) - .updateExpression(dailyUpdateExpression) - .expressionAttributeValues(dailyValues) - .build(); - - AwsClients.dynamoDb().updateItem(dailyRequest); - logger.info("Incremented news word stats (TOTAL + DAILY): userId={}, wordCount={}", userId, wordCount); - - return findTotalStats(userId).orElse(null); - } - /** * 현재 연도-주차 반환 (예: 2026-W02) */ diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/badge/enums/BadgeTypeSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/badge/enums/BadgeTypeSpec.groovy index 6fd08457..19a64976 100644 --- a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/badge/enums/BadgeTypeSpec.groovy +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/badge/enums/BadgeTypeSpec.groovy @@ -111,8 +111,8 @@ class BadgeTypeSpec extends Specification { } def "모든 BadgeType 개수 확인"() { - expect: "29개의 뱃지 타입 존재 (기본 15 + 뉴스 14)" - BadgeType.values().length == 29 + expect: "15개의 뱃지 타입 존재" + BadgeType.values().length == 15 } def "모든 뱃지의 imageUrl이 S3 URL 형식"() { diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 97fe03e6..3509353e 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -105,7 +105,7 @@ Resources: Headers: Access-Control-Allow-Origin: "'*'" Access-Control-Allow-Headers: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" - Access-Control-Allow-Methods: "'GET,POST,PUT,PATCH,DELETE,OPTIONS'" + Access-Control-Allow-Methods: "'GET,POST,PUT,DELETE,OPTIONS'" ResponseTemplates: application/json: '{"message": "Unauthorized", "statusCode": 401}' ACCESS_DENIED: @@ -114,7 +114,7 @@ Resources: Headers: Access-Control-Allow-Origin: "'*'" Access-Control-Allow-Headers: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" - Access-Control-Allow-Methods: "'GET,POST,PUT,PATCH,DELETE,OPTIONS'" + Access-Control-Allow-Methods: "'GET,POST,PUT,DELETE,OPTIONS'" ResponseTemplates: application/json: '{"message": "Access Denied", "statusCode": 403}' DEFAULT_4XX: @@ -122,20 +122,20 @@ Resources: Headers: Access-Control-Allow-Origin: "'*'" Access-Control-Allow-Headers: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" - Access-Control-Allow-Methods: "'GET,POST,PUT,PATCH,DELETE,OPTIONS'" + Access-Control-Allow-Methods: "'GET,POST,PUT,DELETE,OPTIONS'" DEFAULT_5XX: ResponseParameters: Headers: Access-Control-Allow-Origin: "'*'" Access-Control-Allow-Headers: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" - Access-Control-Allow-Methods: "'GET,POST,PUT,PATCH,DELETE,OPTIONS'" + Access-Control-Allow-Methods: "'GET,POST,PUT,DELETE,OPTIONS'" EXPIRED_TOKEN: StatusCode: 401 ResponseParameters: Headers: Access-Control-Allow-Origin: "'*'" Access-Control-Allow-Headers: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" - Access-Control-Allow-Methods: "'GET,POST,PUT,PATCH,DELETE,OPTIONS'" + Access-Control-Allow-Methods: "'GET,POST,PUT,DELETE,OPTIONS'" ResponseTemplates: application/json: '{"message": "Token expired", "statusCode": 401}' Auth: @@ -1055,14 +1055,6 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref VocabTable Events: - GetDashboardStats: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /stats/dashboard - Method: GET - Auth: - Authorizer: CognitoAuthorizer GetDailyStats: Type: Api Properties: @@ -1396,63 +1388,6 @@ Resources: Description: Daily word learning stats aggregation Enabled: true - ############################################# - # Speaking REST API (AI와 대화하기) - ############################################# - - SpeakingFunction: - Type: AWS::Serverless::Function - Properties: - FunctionName: !Sub "${AWS::StackName}-speaking-handler" - CodeUri: . - Handler: com.mzc.secondproject.serverless.domain.speaking.handler.SpeakingHandler::handleRequest - Description: Handle Speaking AI conversation (REST API) - Timeout: 120 - MemorySize: 1024 - SnapStart: - ApplyOn: PublishedVersions - Environment: - Variables: - SPEAKING_TABLE_NAME: !Ref SpeakingTable - TRANSCRIBE_API_KEY: "/opic/transcribe-proxy-api-key" - Policies: - - DynamoDBCrudPolicy: - TableName: !Ref SpeakingTable - - S3CrudPolicy: - BucketName: !Ref ContentBucket - - Statement: - - Effect: Allow - Action: - - bedrock:InvokeModel - Resource: "*" - - Statement: - - Effect: Allow - Action: - - polly:SynthesizeSpeech - Resource: "*" - - Statement: - - Effect: Allow - Action: - - ssm:GetParameter - Resource: !Sub "arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/opic/*" - Events: - SpeakingChat: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /api/speaking/chat - Method: POST - Auth: - Authorizer: CognitoAuthorizer - SpeakingReset: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /api/speaking/reset - Method: POST - Auth: - Authorizer: CognitoAuthorizer - ############################################# # OPIc Lambda Functions ############################################# @@ -1744,38 +1679,6 @@ Resources: AttributeName: ttl Enabled: true - SpeakingTable: - Type: AWS::DynamoDB::Table - Properties: - TableName: !Sub "${AWS::StackName}-speaking" - BillingMode: PAY_PER_REQUEST - AttributeDefinitions: - - AttributeName: PK - AttributeType: S - - AttributeName: SK - AttributeType: S - - AttributeName: GSI1PK - AttributeType: S - - AttributeName: GSI1SK - AttributeType: S - KeySchema: - - AttributeName: PK - KeyType: HASH - - AttributeName: SK - KeyType: RANGE - GlobalSecondaryIndexes: - - IndexName: GSI1 - KeySchema: - - AttributeName: GSI1PK - KeyType: HASH - - AttributeName: GSI1SK - KeyType: RANGE - Projection: - ProjectionType: ALL - TimeToLiveSpecification: - AttributeName: ttl - Enabled: true - ############################################# # News Collection Scheduled Lambda ############################################# @@ -1792,16 +1695,6 @@ Resources: Policies: - DynamoDBCrudPolicy: TableName: !Ref NewsTable - - Statement: - - Effect: Allow - Action: - - bedrock:InvokeModel - Resource: "*" - - Statement: - - Effect: Allow - Action: - - comprehend:DetectKeyPhrases - Resource: "*" Events: DailySchedule: Type: Schedule @@ -1857,64 +1750,48 @@ Resources: RestApiId: !Ref MainApi Path: /news/stats Method: GET - Auth: - Authorizer: CognitoAuthorizer GetBookmarks: Type: Api Properties: RestApiId: !Ref MainApi Path: /news/bookmarks Method: GET - Auth: - Authorizer: CognitoAuthorizer GetUserWords: Type: Api Properties: RestApiId: !Ref MainApi Path: /news/words Method: GET - Auth: - Authorizer: CognitoAuthorizer SyncWordToVocab: Type: Api Properties: RestApiId: !Ref MainApi Path: /news/words/{word}/sync Method: POST - Auth: - Authorizer: CognitoAuthorizer CollectWord: Type: Api Properties: RestApiId: !Ref MainApi Path: /news/{articleId}/words Method: POST - Auth: - Authorizer: CognitoAuthorizer DeleteWord: Type: Api Properties: RestApiId: !Ref MainApi Path: /news/{articleId}/words/{word} Method: DELETE - Auth: - Authorizer: CognitoAuthorizer GetWordDetail: Type: Api Properties: RestApiId: !Ref MainApi Path: /news/{articleId}/words/{word} Method: GET - Auth: - Authorizer: CognitoAuthorizer GetQuizHistory: Type: Api Properties: RestApiId: !Ref MainApi Path: /news/quiz/history Method: GET - Auth: - Authorizer: CognitoAuthorizer GetQuiz: Type: Api Properties: @@ -1927,32 +1804,24 @@ Resources: RestApiId: !Ref MainApi Path: /news/{articleId}/quiz Method: POST - Auth: - Authorizer: CognitoAuthorizer MarkAsRead: Type: Api Properties: RestApiId: !Ref MainApi Path: /news/{articleId}/read Method: POST - Auth: - Authorizer: CognitoAuthorizer ToggleBookmark: Type: Api Properties: RestApiId: !Ref MainApi Path: /news/{articleId}/bookmark Method: POST - Auth: - Authorizer: CognitoAuthorizer GetAudio: Type: Api Properties: RestApiId: !Ref MainApi Path: /news/{articleId}/audio Method: GET - Auth: - Authorizer: CognitoAuthorizer GetNewsDetail: Type: Api Properties: @@ -2147,7 +2016,3 @@ Outputs: OPIcTableName: Description: OPIc DynamoDB Table Name Value: !Ref OPIcTable - - SpeakingTableName: - Description: Speaking DynamoDB Table Name - Value: !Ref SpeakingTable diff --git a/docs/CATCHMIND_ARCHITECTURE_SOLUTION.md b/docs/CATCHMIND_ARCHITECTURE_SOLUTION.md new file mode 100644 index 00000000..e4c22aa4 --- /dev/null +++ b/docs/CATCHMIND_ARCHITECTURE_SOLUTION.md @@ -0,0 +1,521 @@ +# 채팅방 / 캐치마인드 게임 분리 - 종합 솔루션 + +## 1. 현재 문제점 분석 + +### 1.1 백엔드 현황 + +``` +ChatRoom.java (현재 - 혼합 모델) +├── 채팅 필드 +│ ├── roomId, name, description +│ ├── memberIds, currentMembers +│ └── lastMessageAt +│ +└── 게임 필드 (여기에 섞여있음) + ├── gameStatus, gameStartedBy + ├── currentRound, totalRounds + ├── currentDrawerId, currentWord + ├── roundStartTime, roundTimeLimit ← serverTime 없음! + ├── scores, streaks + └── correctGuessers +``` + +**문제점:** + +1. `roundStartTime`만 전송, `serverTime` 누락 → 클라이언트 타이머 동기화 불가 +2. 게임 세션이 채팅방에 종속 → 게임 상태 독립 관리 불가 +3. 재접속 시 게임 상태 복구 어려움 +4. 게임 종료 후 상태 정리 복잡 + +### 1.2 WebSocket 메시지 현황 + +```java +// WebSocketMessageHandler.java - 현재 구조 +handleRequest() { + switch (messageType) { + case "DRAWING", "DRAWING_CLEAR" -> handleDrawingMessage() // 게임 + default -> handleRegularMessage() { + // 1. 슬래시 명령어 처리 (/start, /stop, /score...) + // 2. 게임 중 정답 체크 + // 3. 일반 채팅 메시지 + } + } +} +``` + +**문제점:** + +- 채팅/게임 구분 없이 모든 메시지가 동일 핸들러에서 처리 +- 메시지에 `domain` 필드 없음 + +--- + +## 2. 최적 솔루션 + +### 2.1 아키텍처 개요 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ WebSocket (단일 엔드포인트 유지) │ +│ │ +│ ┌──────────────────────┐ ┌────────────────────────────────┐ │ +│ │ domain: "chat" │ │ domain: "game" │ │ +│ │ │ │ │ │ +│ │ • TEXT │ │ • GAME_START / GAME_END │ │ +│ │ • USER_JOIN │ │ • ROUND_START / ROUND_END │ │ +│ │ • USER_LEAVE │ │ • DRAWING / DRAWING_CLEAR │ │ +│ │ • SYSTEM │ │ • GUESS / CORRECT_ANSWER │ │ +│ │ │ │ • SCORE_UPDATE / HINT │ │ +│ └──────────────────────┘ └────────────────────────────────┘ │ +│ │ +│ GameSession (별도 모델) │ +│ ├── gameSessionId │ +│ ├── roomId (연결용) │ +│ ├── status, currentRound │ +│ ├── roundStartTime + serverTime ← 핵심! │ +│ └── scores, players │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 2.2 핵심 변경사항 + +| 구분 | 현재 | 변경 후 | +|-----|----------------------|---------------------------------| +| 모델 | `ChatRoom`에 게임 필드 포함 | `ChatRoom` + `GameSession` 분리 | +| 타이머 | `roundStartTime`만 전송 | `roundStartTime` + `serverTime` | +| 메시지 | `messageType`만 존재 | `domain` + `messageType` | +| API | 채팅방 API만 존재 | 게임 세션 API 추가 | + +--- + +## 3. 백엔드 변경사항 + +### 3.1 Phase 1: 타이머 버그 수정 (즉시) + +**변경 파일:** `WebSocketMessageHandler.java` + +```java +// GAME_START 메시지에 serverTime 추가 +private void broadcastGameStart(...) { + Map message = new HashMap<>(); + // ... 기존 코드 ... + + message.put("roundStartTime", gameResult.room().getRoundStartTime()); + message.put("serverTime", System.currentTimeMillis()); // 추가! + message.put("roundDuration", gameResult.room().getRoundTimeLimit()); // 명확한 이름 + + // ... +} + +// ROUND_END → ROUND_START 메시지에도 동일하게 추가 +private void broadcastRoundEnd(...) { + // ... + messageData.put("roundStartTime", room.getRoundStartTime()); + messageData.put("serverTime", System.currentTimeMillis()); // 추가! + messageData.put("roundDuration", room.getRoundTimeLimit()); + // ... +} +``` + +**예상 작업량:** 30분 + +### 3.2 Phase 2: 메시지 구조 개선 (1일) + +**변경 파일:** `WebSocketMessageHandler.java`, 모든 브로드캐스트 메서드 + +```java +// 모든 메시지에 domain 필드 추가 +private Map createMessage(String domain, String messageType, Object data) { + Map message = new HashMap<>(); + message.put("domain", domain); // "chat" 또는 "game" + message.put("messageType", messageType); + message.put("data", data); + message.put("timestamp", System.currentTimeMillis()); + return message; +} + +// 채팅 메시지 +createMessage("chat", "TEXT", chatData); +createMessage("chat", "USER_JOIN", joinData); + +// 게임 메시지 +createMessage("game", "GAME_START", gameStartData); +createMessage("game", "ROUND_START", roundStartData); +createMessage("game", "DRAWING", drawingData); +``` + +### 3.3 Phase 3: 게임 세션 분리 (1주) + +#### 3.3.1 새 모델: GameSession.java + +```java +@DynamoDbBean +public class GameSession { + private String pk; // GAME#{gameSessionId} + private String sk; // METADATA + private String gsi1pk; // ROOM#{roomId} + private String gsi1sk; // GAME#{createdAt} + + // 게임 식별 + private String gameSessionId; + private String roomId; // 연결된 채팅방 + private String gameType; // "catchmind" + + // 게임 상태 + private String status; // WAITING, PLAYING, FINISHED + private String startedBy; + private Long startedAt; + private Long endedAt; + + // 라운드 정보 + private Integer currentRound; + private Integer totalRounds; + private String currentDrawerId; + private String currentWordId; + private String currentWord; + private Long roundStartTime; + private Integer roundDuration; + + // 점수 + private Map scores; + private Map streaks; + private List players; + private List drawerOrder; + + // 자동 종료 + private Long gameEndScheduledAt; + private String scheduleRuleArn; + + // TTL + private Long ttl; +} +``` + +#### 3.3.2 ChatRoom에서 게임 필드 제거 + +```java +@DynamoDbBean +public class ChatRoom { + // 채팅 필드만 유지 + private String roomId; + private String name; + private String description; + private String level; + private Integer currentMembers; + private Integer maxMembers; + private Boolean isPrivate; + private String password; + private String createdBy; + private String createdAt; + private String lastMessageAt; + private List memberIds; + + // 게임 연결 (참조만) + private String activeGameSessionId; // 현재 진행중인 게임 세션 ID + + // 게임 필드 모두 제거! + // - gameStatus, gameStartedBy, currentRound... 전부 GameSession으로 이동 +} +``` + +#### 3.3.3 게임 세션 API + +``` +# 게임 세션 생성 +POST /api/chat/rooms/{roomId}/games +Request: +{ + "gameType": "catchmind", + "settings": { + "totalRounds": 5, + "roundDuration": 60 + } +} + +Response: +{ + "gameSessionId": "game-abc123", + "roomId": "room-xyz", + "status": "WAITING", + "createdAt": "2024-01-20T10:00:00Z" +} + +# 게임 상태 조회 (재접속 시 필수!) +GET /api/games/{gameSessionId} + +Response: +{ + "gameSessionId": "game-abc123", + "roomId": "room-xyz", + "status": "PLAYING", + "currentRound": 2, + "totalRounds": 5, + "currentDrawerId": "user123", + "roundStartTime": 1705744800000, + "serverTime": 1705744830000, // 핵심! + "roundDuration": 60, + "scores": { + "user1": 150, + "user2": 120 + }, + "players": ["user1", "user2", "user3"] +} + +# 게임 시작 (기존 /start 명령어 대체) +POST /api/games/{gameSessionId}/start + +# 게임 종료 +POST /api/games/{gameSessionId}/stop +``` + +--- + +## 4. 프론트엔드 변경사항 + +### 4.1 Phase 1: 타이머 버그 수정 (즉시) + +```javascript +// useTimer.js - 독립적인 타이머 훅 +export function useTimer(roundStartTime, roundDuration, serverTime) { + const [remainingTime, setRemainingTime] = useState(roundDuration); + + useEffect(() => { + if (!roundStartTime || !roundDuration) return; + + // 서버-클라이언트 시간 차이 보정 + const timeOffset = serverTime ? (Date.now() - serverTime) : 0; + + const interval = setInterval(() => { + const adjustedNow = Date.now() - timeOffset; + const elapsed = Math.floor((adjustedNow - roundStartTime) / 1000); + const remaining = Math.max(0, roundDuration - elapsed); + setRemainingTime(remaining); + + if (remaining <= 0) { + clearInterval(interval); + } + }, 100); + + return () => clearInterval(interval); + }, [roundStartTime, roundDuration, serverTime]); + + return remainingTime; +} +``` + +### 4.2 Phase 2: 메시지 핸들러 분리 + +```javascript +// WebSocket 메시지 핸들러 +onMessage(event) { + const message = JSON.parse(event.data); + + switch (message.domain) { + case 'chat': + this.handleChatMessage(message); + break; + case 'game': + this.handleGameMessage(message); + break; + } +} + +handleChatMessage(message) { + switch (message.messageType) { + case 'TEXT': // 채팅 메시지 + case 'USER_JOIN': + case 'USER_LEAVE': + case 'SYSTEM': + } +} + +handleGameMessage(message) { + switch (message.messageType) { + case 'GAME_START': + case 'ROUND_START': + case 'DRAWING': + case 'CORRECT_ANSWER': + case 'SCORE_UPDATE': + } +} +``` + +### 4.3 Phase 3: 훅 분리 + +``` +src/domains/ +├── chat/ +│ ├── hooks/ +│ │ └── useChatWebSocket.js # 채팅만 처리 +│ └── components/ +│ ├── ChatMessages.jsx +│ └── ChatInput.jsx +│ +├── catchmind/ +│ ├── hooks/ +│ │ ├── useGameWebSocket.js # 게임만 처리 +│ │ ├── useGameState.js +│ │ └── useTimer.js +│ └── components/ +│ ├── DrawingCanvas.jsx +│ ├── ScoreBoard.jsx +│ └── Timer.jsx +│ +└── freetalk/ + └── pages/ + └── FreeTalkPage.jsx # chat + catchmind 조합 +``` + +--- + +## 5. 메시지 스펙 (최종) + +### 5.1 공통 메시지 구조 + +```json +{ + "domain": "chat" | "game", + "messageType": "...", + "data": { ... }, + "timestamp": 1705744800000 +} +``` + +### 5.2 채팅 메시지 + +| Type | 방향 | data 필드 | +|--------------|-----|-----------------------------------------------| +| `TEXT` | 양방향 | `messageId`, `userId`, `content`, `createdAt` | +| `USER_JOIN` | S→C | `userId`, `memberCount` | +| `USER_LEAVE` | S→C | `userId`, `memberCount` | +| `SYSTEM` | S→C | `content` | + +### 5.3 게임 메시지 + +| Type | 방향 | data 필드 | +|------------------|-----|---------------------------------------------------------------------------------------------------------------| +| `GAME_START` | S→C | `gameSessionId`, `totalRounds`, `currentDrawerId`, `roundStartTime`, `serverTime`, `roundDuration`, `players` | +| `GAME_END` | S→C | `gameSessionId`, `reason`, `finalScores`, `winner` | +| `ROUND_START` | S→C | `currentRound`, `currentDrawerId`, `roundStartTime`, `serverTime`, `roundDuration`, `currentWord`(출제자만) | +| `ROUND_END` | S→C | `currentRound`, `answer`, `scores` | +| `DRAWING` | 양방향 | `drawingData` | +| `DRAWING_CLEAR` | 양방향 | - | +| `GUESS` | C→S | `content` | +| `CORRECT_ANSWER` | S→C | `userId`, `score`, `elapsedTime` | +| `SCORE_UPDATE` | S→C | `scores`, `currentRound`, `totalRounds` | +| `HINT` | S→C | `hint` | + +### 5.4 ROUND_START 상세 (핵심!) + +```json +{ + "domain": "game", + "messageType": "ROUND_START", + "data": { + "gameSessionId": "game-abc123", + "currentRound": 2, + "totalRounds": 5, + "currentDrawerId": "user123", + "roundStartTime": 1705744800000, + "serverTime": 1705744800500, + "roundDuration": 60, + "currentWord": { + "wordId": "word-1", + "word": "apple" + } + }, + "timestamp": 1705744800500 +} +``` + +**중요:** `currentWord`는 출제자에게만 전송! + +--- + +## 6. 구현 일정 + +``` +Week 1: 긴급 버그 수정 +├── [BE] serverTime 필드 추가 (0.5일) +├── [FE] useTimer 훅 수정 (0.5일) +├── [BE] 메시지에 domain 필드 추가 (1일) +└── [FE] 메시지 핸들러 domain 분기 (0.5일) + +Week 2: 게임 세션 분리 (BE) +├── [BE] GameSession 모델 생성 +├── [BE] GameSessionRepository 구현 +├── [BE] GameService 리팩토링 +└── [BE] 게임 세션 API 구현 + +Week 3: 프론트엔드 리팩토링 +├── [FE] useChatWebSocket 분리 +├── [FE] useGameWebSocket 신규 +├── [FE] 컴포넌트 분리 +└── [FE/BE] 통합 테스트 + +Week 4: 안정화 및 추가 기능 +├── [BE] 게임 자동 종료 (7분) - Issue #417 +├── [BE] 재접속 시 게임 상태 복구 +└── [FE/BE] E2E 테스트 +``` + +--- + +## 7. 기대 효과 + +| 항목 | 현재 | 개선 후 | +|---------|-------------|------------------| +| 타이머 정확도 | 클라이언트 시계 의존 | 서버 시간 기준 동기화 | +| 재접속 | 게임 상태 유실 | 완전 복구 가능 | +| 테스트 | 채팅/게임 분리 불가 | 독립 테스트 가능 | +| 확장성 | 새 게임 추가 어려움 | gameType으로 확장 용이 | +| 유지보수 | 책임 혼재 | 명확한 책임 분리 | + +--- + +## 8. 즉시 적용 (백엔드 변경 전 프론트엔드 임시 조치) + +```javascript +// 백엔드 변경 전까지 프론트엔드에서 적용 가능한 임시 코드 + +onRoundStart: (data) => { + const roundData = data.data || data; + const now = Date.now(); + + // serverTime이 없으면 클라이언트 시간 사용 (임시) + const serverTime = roundData.serverTime || now; + let roundStartTime = roundData.roundStartTime || now; + + // roundStartTime이 미래 시간이면 현재로 보정 + if (roundStartTime > now + 1000) { + console.warn('Invalid roundStartTime, using current time'); + roundStartTime = now; + } + + setGameState((prev) => ({ + ...prev, + currentRound: roundData.currentRound, + currentDrawerId: roundData.currentDrawerId, + roundStartTime: roundStartTime, + serverTime: serverTime, + roundDuration: roundData.roundDuration || roundData.roundTimeLimit || 60, + })); +} +``` + +--- + +## 9. 결론 + +**우선순위:** + +1. **즉시 (이번 주)**: `serverTime` 추가 + `domain` 필드 추가 +2. **단기 (2주)**: GameSession 모델 분리 + API 구현 +3. **중기 (3-4주)**: FE/BE 완전 분리 + 자동 종료 + 재접속 복구 + +**핵심 원칙:** + +- 단일 WebSocket 엔드포인트 유지 (비용/복잡도) +- `domain` 필드로 채팅/게임 구분 +- `serverTime`으로 정확한 타이머 동기화 +- GameSession 독립 모델로 상태 관리 명확화 diff --git a/docs/CICD-IMPLEMENTATION-QNA.md b/docs/CICD-IMPLEMENTATION-QNA.md new file mode 100644 index 00000000..e00c5a11 --- /dev/null +++ b/docs/CICD-IMPLEMENTATION-QNA.md @@ -0,0 +1,421 @@ +# CI/CD 파이프라인 구현 설명 및 면접 Q&A + +## 1. CI/CD 아키텍처 개요 + +``` +┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ +│ GitHub │───▶│ CodePipeline│───▶│ CodeBuild │───▶│CloudFormation│ +│ (Source) │ │ (Pipeline) │ │ (Build) │ │ (Deploy) │ +└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ + │ │ │ │ + │ ▼ ▼ ▼ + │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ + │ │ SNS │ │ S3 │ │ Lambda │ + │ │(Notification)│ │ (Artifacts) │ │ Functions │ + │ └─────────────┘ └─────────────┘ └─────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ prod 브랜치 Push/Merge │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +## 2. 구성 요소 상세 설명 + +### 2.1 Source Stage (GitHub) + +- **트리거**: prod 브랜치에 Push 또는 PR Merge 시 자동 실행 +- **연결 방식**: AWS CodeConnections (구 CodeStar Connections) +- **아티팩트**: 소스 코드를 ZIP으로 압축하여 다음 스테이지로 전달 + +### 2.2 Build Stage (CodeBuild) + +- **런타임**: Amazon Linux 2, Java Corretto 21 +- **빌드 단계**: + 1. **Install**: SAM CLI 설치 + 2. **Pre-build**: Gradle 테스트 실행 (`./gradlew clean test`) + 3. **Build**: SAM build & package + 4. **Post-build**: 완료 로그 +- **캐싱**: Gradle 캐시를 S3에 저장하여 빌드 시간 단축 +- **리포트**: JUnit 테스트 결과, JaCoCo 코드 커버리지 리포트 + +### 2.3 Deploy Stage (CloudFormation) + +- **배포 방식**: CloudFormation CREATE_UPDATE +- **템플릿**: SAM으로 패키징된 `packaged-template.yaml` +- **기능**: CAPABILITY_IAM, CAPABILITY_AUTO_EXPAND + +### 2.4 Notification (SNS) + +- **이벤트**: 파이프라인 시작, 성공, 실패 시 이메일 알림 +- **구현**: CodeStar Notifications + SNS Topic + +## 3. 주요 파일 구조 + +``` +BE_Repository/ +├── cicd/ +│ └── pipeline.yaml # CloudFormation 파이프라인 템플릿 +├── ServerlessFunction/ +│ ├── buildspec.yml # CodeBuild 빌드 명세 +│ ├── samconfig.toml # SAM 배포 설정 +│ └── template.yaml # SAM 애플리케이션 템플릿 +``` + +## 4. IAM 역할 구성 + +| 역할 | 목적 | 주요 권한 | +|--------------------|---------------------|----------------------------------------| +| PipelineRole | CodePipeline 서비스 역할 | S3, CodeBuild, CloudFormation, SNS | +| CodeBuildRole | CodeBuild 서비스 역할 | S3, CloudWatch Logs, CodeBuild Reports | +| CloudFormationRole | 리소스 배포 역할 | AdministratorAccess (SAM 리소스 생성용) | + +--- + +## 5. 면접 예상 질문 및 답변 + +### Q1. CI/CD 파이프라인을 구축한 이유는 무엇인가요? + +**A1:** +수동 배포의 문제점을 해결하기 위해 CI/CD를 도입했습니다. + +1. **일관성**: 수동 배포 시 발생할 수 있는 휴먼 에러 방지 +2. **자동화**: 코드 푸시만으로 테스트-빌드-배포가 자동 실행 +3. **품질 보장**: 테스트 실패 시 배포가 중단되어 결함 있는 코드가 프로덕션에 배포되는 것을 방지 +4. **추적성**: 모든 배포 이력이 CodePipeline에 기록되어 문제 발생 시 원인 추적 용이 +5. **속도**: 반복적인 배포 작업 시간을 단축하여 개발 생산성 향상 + +--- + +### Q2. GitHub과 AWS CodePipeline을 어떻게 연동했나요? + +**A2:** +AWS CodeConnections(구 CodeStar Connections)를 사용하여 연동했습니다. + +```yaml +# pipeline.yaml의 Source Stage 설정 +- Name: Source + Actions: + - Name: GitHub + ActionTypeId: + Category: Source + Owner: AWS + Provider: CodeStarSourceConnection + Version: '1' + Configuration: + ConnectionArn: !Ref GitHubConnectionArn + FullRepositoryId: "Language-Study-Prooject/BE_Repository" + BranchName: "prod" + DetectChanges: true +``` + +**연동 과정:** + +1. AWS Console에서 CodeConnections 생성 +2. GitHub OAuth 앱 승인 +3. Connection ARN을 파이프라인에 설정 +4. `DetectChanges: true`로 설정하여 자동 트리거 활성화 + +--- + +### Q3. CodeBuild의 buildspec.yml에서 각 phase의 역할은 무엇인가요? + +**A3:** + +```yaml +phases: + install: # 빌드 환경 설정 + runtime-versions: + java: corretto21 + commands: + - pip3 install aws-sam-cli + + pre_build: # 테스트 실행 (품질 게이트) + commands: + - cd ServerlessFunction + - ./gradlew clean test + + build: # 실제 빌드 및 패키징 + commands: + - sam build + - sam package --s3-bucket ... --output-template-file packaged-template.yaml + + post_build: # 후처리 (로깅, 정리) + commands: + - echo "Build completed" +``` + +- **install**: 빌드에 필요한 런타임과 도구 설치 +- **pre_build**: 테스트 실행 - 실패 시 빌드 중단 (품질 게이트 역할) +- **build**: SAM 애플리케이션 빌드 및 S3에 패키징 +- **post_build**: 완료 로그 기록, 정리 작업 + +--- + +### Q4. 테스트가 실패하면 배포가 어떻게 되나요? + +**A4:** +테스트 실패 시 배포가 자동으로 중단됩니다. + +**작동 원리:** + +1. `pre_build` 단계에서 `./gradlew clean test` 실행 +2. 테스트 실패 시 Gradle이 exit code 1 반환 +3. CodeBuild가 비정상 종료로 판단하여 빌드 실패 처리 +4. CodePipeline의 Build Stage가 실패 상태가 됨 +5. Deploy Stage로 진행되지 않음 +6. SNS를 통해 실패 알림 이메일 발송 + +``` +Pipeline Flow: +Source ──▶ Build (테스트 실패) ──✗ Deploy + │ + ▼ + SNS 알림 발송 +``` + +--- + +### Q5. SAM과 CloudFormation의 관계는 무엇인가요? + +**A5:** +SAM(Serverless Application Model)은 CloudFormation의 확장입니다. + +**관계:** + +- SAM 템플릿은 CloudFormation 템플릿의 상위 집합 +- `sam build`/`sam package` 실행 시 SAM 템플릿이 표준 CloudFormation 템플릿으로 변환 +- 변환된 템플릿(`packaged-template.yaml`)을 CloudFormation이 배포 + +**SAM의 장점:** + +1. 간결한 문법: `AWS::Serverless::Function`으로 Lambda + API Gateway + IAM 역할 한번에 정의 +2. 로컬 테스트: `sam local invoke`로 Lambda 로컬 실행 가능 +3. 자동 패키징: 코드를 S3에 업로드하고 참조 자동 생성 + +```yaml +# SAM 템플릿 (간결) +Type: AWS::Serverless::Function +Properties: + Handler: handler.main + Runtime: java21 + Events: + Api: + Type: Api + Properties: + Path: /hello + Method: get + +# 변환된 CloudFormation (복잡) +# Lambda Function + API Gateway + IAM Role + Permission 등 여러 리소스로 확장 +``` + +--- + +### Q6. 배포 중 롤백은 어떻게 처리되나요? + +**A6:** +CloudFormation의 기본 롤백 기능을 활용합니다. + +**설정:** + +```yaml +# samconfig.toml +disable_rollback = false # 롤백 활성화 +``` + +**롤백 시나리오:** + +1. **배포 실패 시**: CloudFormation이 자동으로 이전 상태로 롤백 +2. **Lambda 오류 시**: + - 현재는 기본 롤백만 사용 + - 추가로 Canary/Linear 배포 설정 가능 (AWS CodeDeploy 연동) + +```yaml +# 점진적 배포 예시 (선택적 구현) +DeploymentPreference: + Type: Canary10Percent5Minutes # 10%에 5분간 배포 후 문제없으면 전체 배포 +``` + +--- + +### Q7. 파이프라인의 아티팩트는 어떻게 관리되나요? + +**A7:** +S3 버킷을 사용하여 아티팩트를 관리합니다. + +```yaml +ArtifactBucket: + Type: AWS::S3::Bucket + Properties: + BucketName: group2-englishstudy-pipeline-artifacts + VersioningConfiguration: + Status: Enabled # 버전 관리 활성화 + BucketEncryption: + ServerSideEncryptionConfiguration: + - ServerSideEncryptionByDefault: + SSEAlgorithm: AES256 # 암호화 +``` + +**아티팩트 종류:** + +1. **SourceArtifact**: GitHub에서 가져온 소스 코드 ZIP +2. **BuildArtifact**: 빌드된 `packaged-template.yaml` +3. **Cache**: Gradle 캐시 (빌드 시간 단축용) + +--- + +### Q8. 파이프라인 알림은 어떻게 구현했나요? + +**A8:** +AWS CodeStar Notifications와 SNS를 연동하여 구현했습니다. + +```yaml +# SNS Topic 생성 +NotificationTopic: + Type: AWS::SNS::Topic + Properties: + TopicName: cicd-pipeline-notifications + +# 이메일 구독 +EmailSubscription: + Type: AWS::SNS::Subscription + Properties: + TopicArn: !Ref NotificationTopic + Protocol: email + Endpoint: !Ref NotificationEmail + +# 알림 규칙 +PipelineNotificationRule: + Type: AWS::CodeStarNotifications::NotificationRule + Properties: + EventTypeIds: + - codepipeline-pipeline-pipeline-execution-started + - codepipeline-pipeline-pipeline-execution-succeeded + - codepipeline-pipeline-pipeline-execution-failed + Targets: + - TargetType: SNS + TargetAddress: !Ref NotificationTopic +``` + +--- + +### Q9. CI/CD 구축 중 겪은 문제와 해결 방법은? + +**A9:** + +**문제 1: Gradle Wrapper를 찾을 수 없음** + +- 원인: `.gitignore`에서 `gradle/` 폴더 전체가 제외됨 +- 해결: `.gitignore` 수정하여 `!gradle/wrapper/` 예외 추가 + +**문제 2: JAVA_HOME 환경 변수 오류** + +- 원인: CodeBuild에서 JAVA_HOME을 수동 설정했으나 경로 불일치 +- 해결: `runtime-versions: java: corretto21`만 사용하고 JAVA_HOME 수동 설정 제거 + +**문제 3: SAM package S3 버킷 참조 오류** + +- 원인: 환경 변수를 사용한 멀티라인 명령어에서 변수 치환 실패 +- 해결: 단일 라인으로 버킷 이름 직접 지정 + +**문제 4: Lambda 환경 변수 누락** + +- 원인: WebSocket Connect 함수에 `WEBSOCKET_ENDPOINT` 환경 변수 미설정 +- 해결: `template.yaml`에 환경 변수 추가 + +--- + +### Q10. 현재 CI/CD의 개선점이 있다면? + +**A10:** + +1. **테스트 커버리지 게이트** + - 현재: 테스트 실행만 함 + - 개선: 커버리지 80% 미만 시 빌드 실패 설정 + +2. **점진적 배포 (Canary/Blue-Green)** + - 현재: 전체 교체 배포 + - 개선: Lambda Alias + CodeDeploy로 Canary 배포 구현 + +3. **다중 환경 지원** + - 현재: prod 단일 환경 + - 개선: dev, staging, prod 분리 및 승인 단계 추가 + +4. **보안 스캔** + - 개선: 의존성 취약점 스캔 (OWASP Dependency-Check) 추가 + +5. **성능 테스트** + - 개선: 배포 전 부하 테스트 단계 추가 + +--- + +### Q11. IaC(Infrastructure as Code)를 사용한 이유는? + +**A11:** +파이프라인 자체도 CloudFormation 템플릿(`pipeline.yaml`)으로 정의했습니다. + +**장점:** + +1. **버전 관리**: 인프라 변경 이력을 Git으로 추적 +2. **재현성**: 동일한 파이프라인을 다른 프로젝트/계정에 쉽게 복제 +3. **리뷰 가능**: 인프라 변경도 코드 리뷰 프로세스 적용 +4. **자동화**: 수동 콘솔 작업 없이 `aws cloudformation deploy`로 생성/업데이트 +5. **문서화**: 템플릿 자체가 인프라 문서 역할 + +--- + +### Q12. CodeBuild와 Jenkins의 차이점은? + +**A12:** + +| 항목 | CodeBuild | Jenkins | +|--------|---------------|----------------------| +| 관리 | 완전 관리형 (서버리스) | 자체 서버 운영 필요 | +| 비용 | 빌드 시간 기반 과금 | 서버 운영 비용 | +| 확장성 | 자동 확장 | 수동 확장 필요 | +| AWS 통합 | 네이티브 통합 | 플러그인 필요 | +| 커스터마이징 | buildspec.yml | Jenkinsfile (Groovy) | +| 플러그인 | 제한적 | 풍부한 생태계 | + +**선택 이유:** + +- AWS 서비스 중심 아키텍처에서 네이티브 통합의 이점 +- 서버 관리 부담 없음 +- SAM/CloudFormation과의 원활한 연동 + +--- + +## 6. 핵심 용어 정리 + +| 용어 | 설명 | +|-------------------------------------|------------------------------------------------| +| CI (Continuous Integration) | 코드 변경을 자주 통합하고 자동 테스트하는 방식 | +| CD (Continuous Delivery/Deployment) | 자동으로 프로덕션까지 배포하는 방식 | +| Pipeline | 소스-빌드-배포로 이어지는 자동화된 워크플로우 | +| Artifact | 빌드 결과물 (패키징된 코드, 템플릿 등) | +| buildspec.yml | CodeBuild의 빌드 명세 파일 | +| SAM | Serverless Application Model - 서버리스 앱 정의 프레임워크 | +| IaC | Infrastructure as Code - 코드로 인프라 관리 | + +--- + +## 7. 참고 명령어 + +```bash +# 파이프라인 생성 +aws cloudformation deploy \ + --template-file cicd/pipeline.yaml \ + --stack-name group2-cicd-pipeline \ + --capabilities CAPABILITY_NAMED_IAM \ + --parameter-overrides NotificationEmail=your@email.com + +# 파이프라인 상태 확인 +aws codepipeline get-pipeline-state --name group2-englishstudy-pipeline + +# 수동 파이프라인 실행 +aws codepipeline start-pipeline-execution --name group2-englishstudy-pipeline + +# 빌드 로그 확인 +aws logs tail /aws/codebuild/group2-englishstudy-build --follow +``` diff --git a/docs/FRONTEND-API-GUIDE.md b/docs/FRONTEND-API-GUIDE.md new file mode 100644 index 00000000..697d406a --- /dev/null +++ b/docs/FRONTEND-API-GUIDE.md @@ -0,0 +1,365 @@ +# 프론트엔드 전달사항 - 채팅/게임 API 가이드 + +## 1. 아키텍처 구조 (업데이트됨) + +### 채팅방과 게임방 분리 + +``` +RoomType enum +├── CHAT ("chat") - 일반 채팅방 +└── GAME ("game") - 게임방 (캐치마인드 등) + +RoomStatus enum +├── WAITING ("waiting") - 대기 중 +├── PLAYING ("playing") - 게임 진행 중 +└── FINISHED ("finished") - 종료됨 +``` + +### GSI1SK 인덱스 설계 + +``` +GSI1PK: "ROOMS" (고정) +GSI1SK: {type}#{gameType}#{status}#{level}#{createdAt} + +예시: +- CHAT#-#WAITING#beginner#2026-01-22T10:00:00Z (일반 채팅방) +- GAME#CATCHMIND#WAITING#intermediate#2026-01-22T10:00:00Z (대기중 게임방) +- GAME#CATCHMIND#PLAYING#advanced#2026-01-22T10:00:00Z (진행중 게임방) +``` + +**핵심**: DB 레벨에서 `type`, `gameType`, `status`, `level` 조합으로 필터링 가능 + +--- + +## 2. 방 타입 (RoomType) + +| 타입 | 코드 | 설명 | +|--------|--------|---------------| +| `CHAT` | `chat` | 일반 채팅방 | +| `GAME` | `game` | 게임방 (캐치마인드 등) | + +--- + +## 3. 방 상태 (RoomStatus) + +| 상태 | 코드 | 설명 | 게임 시작 가능 | +|------------|------------|---------|:--------:| +| `WAITING` | `waiting` | 대기 중 | O | +| `PLAYING` | `playing` | 게임 진행 중 | X | +| `FINISHED` | `finished` | 게임 종료됨 | O | + +--- + +## 4. REST API 엔드포인트 + +### 채팅방 API (`/api/chat/rooms`) + +| Method | Endpoint | 설명 | +|--------|-------------------------|---------------------| +| POST | `/rooms` | 채팅방/게임방 생성 | +| GET | `/rooms` | 방 목록 조회 (필터 지원) | +| GET | `/rooms/{roomId}` | 방 상세 조회 | +| POST | `/rooms/{roomId}/join` | 방 입장 (roomToken 발급) | +| POST | `/rooms/{roomId}/leave` | 방 퇴장 | +| DELETE | `/rooms/{roomId}` | 방 삭제 (방장만) | + +### 게임 API (`/api/game`) + +| Method | Endpoint | 설명 | +|--------|-------------------------------|----------| +| POST | `/rooms/{roomId}/game/start` | 게임 시작 | +| POST | `/rooms/{roomId}/game/stop` | 게임 중단 | +| GET | `/rooms/{roomId}/game/status` | 게임 상태 조회 | +| GET | `/rooms/{roomId}/game/scores` | 점수판 조회 | + +--- + +## 5. 방 목록 조회 쿼리 파라미터 (업데이트됨) + +``` +GET /api/chat/rooms?type=GAME&gameType=CATCHMIND&status=WAITING&level=intermediate&limit=10&cursor=xxx +``` + +| 파라미터 | 타입 | 설명 | 예시 | +|------------|--------|----------------------|----------------------------------------| +| `type` | string | 방 타입 필터 | `CHAT`, `GAME` | +| `gameType` | string | 게임 타입 | `CATCHMIND` | +| `status` | string | 상태 필터 | `WAITING`, `PLAYING`, `FINISHED` | +| `level` | string | 난이도 필터 | `beginner`, `intermediate`, `advanced` | +| `limit` | number | 조회 개수 (기본 10, 최대 20) | | +| `cursor` | string | 페이지네이션 커서 | | + +### 필터 조합 예시 + +```bash +# 대기 중인 게임방만 +GET /api/chat/rooms?type=GAME&status=WAITING + +# 캐치마인드 게임방만 +GET /api/chat/rooms?type=GAME&gameType=CATCHMIND + +# 초급 난이도 채팅방 +GET /api/chat/rooms?type=CHAT&level=beginner + +# 진행 중인 고급 게임방 +GET /api/chat/rooms?type=GAME&status=PLAYING&level=advanced +``` + +### 응답 예시 + +```json +{ + "success": true, + "message": "Rooms retrieved", + "data": { + "rooms": [ + { + "roomId": "abc-123", + "name": "초보자 영어 스터디", + "type": "GAME", + "gameType": "CATCHMIND", + "status": "WAITING", + "level": "beginner", + "currentMembers": 3, + "maxMembers": 6, + "currentRound": 0, + "totalRounds": 5, + "createdAt": "2026-01-22T10:00:00Z" + } + ], + "nextCursor": "eyJQSyI6Ik...", + "hasMore": true + } +} +``` + +--- + +## 6. 방 생성 요청 (업데이트됨) + +### 채팅방 생성 + +```json +{ + "name": "영어 스터디 채팅방", + "type": "CHAT", + "level": "beginner", + "maxMembers": 6, + "description": "초보자를 위한 영어 채팅방" +} +``` + +### 게임방 생성 + +```json +{ + "name": "캐치마인드 게임", + "type": "GAME", + "gameType": "CATCHMIND", + "level": "intermediate", + "maxMembers": 8, + "description": "영어 단어 맞추기 게임" +} +``` + +--- + +## 7. 프론트엔드에서 방 타입 구분 + +### 방법 1: API 필터 사용 (권장) + +```javascript +// 게임방만 조회 +const gameRooms = await fetch('/api/chat/rooms?type=GAME'); + +// 대기 중인 게임방만 +const waitingGames = await fetch('/api/chat/rooms?type=GAME&status=WAITING'); + +// 채팅방만 +const chatRooms = await fetch('/api/chat/rooms?type=CHAT'); +``` + +### 방법 2: 전체 조회 후 클라이언트 필터링 + +```javascript +const allRooms = await fetchRooms(); + +// 게임방만 +const gameRooms = allRooms.filter(room => room.type === 'GAME'); + +// 채팅방만 +const chatRooms = allRooms.filter(room => room.type === 'CHAT'); + +// 대기 중인 방만 +const waitingRooms = allRooms.filter(room => room.status === 'WAITING'); +``` + +--- + +## 8. WebSocket 연결 + +### 채팅/게임 WebSocket + +``` +wss://t378dif43l.execute-api.ap-northeast-2.amazonaws.com/dev?roomToken={roomToken} +``` + +### Grammar WebSocket + +``` +wss://ltrccmteo8.execute-api.ap-northeast-2.amazonaws.com/dev?token={jwtToken} +``` + +### 연결 순서 + +1. `POST /rooms/{roomId}/join` → `roomToken` 발급 +2. WebSocket 연결 시 `roomToken` 쿼리 파라미터로 전달 + +--- + +## 9. WebSocket 메시지 타입 (messageType) + +| 코드 | 타입 | 설명 | +|------------------|--------|---------------| +| `MSG` | 일반 메시지 | 일반 채팅 메시지 | +| `VOICE` | 음성 메시지 | 음성 채팅 | +| `JOIN` | 입장 알림 | 사용자 입장 | +| `LEAVE` | 퇴장 알림 | 사용자 퇴장 | +| `GAME_START` | 게임 시작 | 게임 시작 알림 | +| `GAME_END` | 게임 종료 | 게임 종료 + 최종 순위 | +| `ROUND_START` | 라운드 시작 | 새 라운드 시작 | +| `ROUND_END` | 라운드 종료 | 정답 공개 | +| `ANSWER_CORRECT` | 정답 | 정답 맞춤 | +| `HINT` | 힌트 | 힌트 제공 | +| `SKIP` | 스킵 | 라운드 스킵 | +| `SYSTEM` | 시스템 | 시스템 메시지 | + +--- + +## 10. 게임 명령어 (WebSocket) + +채팅 메시지로 게임 명령어 전송: + +| 명령어 | 설명 | 권한 | +|----------|--------|-----------------| +| `/start` | 게임 시작 | 방장 (2명 이상 접속 시) | +| `/stop` | 게임 중단 | 방장 또는 게임 시작자 | +| `/skip` | 라운드 스킵 | 누구나 | +| `/hint` | 힌트 제공 | 출제자만 | +| `/score` | 점수 확인 | 누구나 | + +--- + +## 11. 게임 시작 응답 예시 + +```json +{ + "messageId": "uuid", + "roomId": "abc-123", + "userId": "SYSTEM", + "content": "게임 시작!\n총 5 라운드\n\n라운드 1 시작!\n출제자: user-456", + "messageType": "GAME_START", + "createdAt": "2026-01-22T10:00:00Z", + "serverTime": "2026-01-22T10:00:00Z", + "domain": "GAME", + "type": "GAME", + "status": "PLAYING", + "currentRound": 1, + "totalRounds": 5, + "currentDrawerId": "user-456", + "drawerOrder": ["user-456", "user-789", "user-123"] +} +``` + +--- + +## 12. 정답 체크 로직 + +- **한국어** 또는 **영어** 둘 다 정답으로 인정 +- 대소문자 구분 없음 +- 공백 무시 + +### 점수 계산 + +``` +기본 점수: 10점 +시간 보너스: (제한시간 - 경과시간) * 0.5 +연속 정답 보너스: 연속정답수 * 2 + +총점 = 기본점수 + 시간보너스 + 연속정답보너스 +``` + +--- + +## 13. 게임 설정 + +| 설정 | 기본값 | 환경변수 | +|--------------|----------|---------------------------------| +| 총 라운드 수 | 5 | `GAME_TOTAL_ROUNDS` | +| 라운드 제한 시간(초) | 60 | `GAME_ROUND_TIME_LIMIT` | +| 빠른 정답 기준(ms) | 5000 | `GAME_QUICK_GUESS_THRESHOLD_MS` | +| 게임 전체 제한(초) | 420 (7분) | `GAME_TIME_LIMIT_SECONDS` | + +--- + +## 14. 주의사항 + +1. **roomToken은 한 번만 사용**: 재연결 시 새로 발급 필요 +2. **WebSocket 연결 실패 시**: `POST /rooms/{roomId}/join`으로 새 토큰 발급 +3. **게임 중 퇴장**: 자동으로 다음 출제자로 넘어감 (2명 미만 시 게임 종료) +4. **출제자는 정답 입력 불가**: 본인이 출제자일 때 채팅해도 정답 체크 안됨 +5. **방 타입 변경 불가**: 생성 시 지정한 type은 변경 불가 + +--- + +## 15. 에러 코드 + +| 코드 | HTTP | 설명 | +|--------------|------|--------------| +| `ROOM_001` | 404 | 채팅방 없음 | +| `ROOM_002` | 409 | 채팅방 이미 존재 | +| `ROOM_003` | 400 | 채팅방 인원 초과 | +| `ROOM_004` | 400 | 채팅방 종료됨 | +| `ROOM_005` | 401 | 비밀번호 틀림 | +| `ROOM_006` | 403 | 방장 권한 없음 | +| `MEMBER_001` | 403 | 채팅방 멤버 아님 | +| `MEMBER_002` | 409 | 이미 참여 중 | +| `GAME_001` | 400 | 게임 시작 실패 | +| `GAME_002` | 400 | 게임 중단 실패 | +| `GAME_003` | 400 | 게임 진행 중 아님 | +| `GAME_004` | 409 | 게임 이미 진행 중 | +| `GAME_005` | 403 | 게임 시작자 아님 | +| `GAME_006` | 404 | 게임 없음 | +| `GAME_007` | 400 | 채팅방에서 게임 불가 | +| `GAME_008` | 400 | 게임 재시작 불가 | +| `GAME_009` | 403 | 방장만 게임 시작 가능 | + +--- + +## 16. UI 구현 가이드 + +### 탭 구조 (권장) + +``` +[전체] [채팅방] [게임방] +``` + +### 게임방 상태 표시 + +``` +대기 중 (WAITING) → 초록색 뱃지 "참여 가능" +진행 중 (PLAYING) → 빨간색 뱃지 "게임 중" +종료됨 (FINISHED) → 회색 뱃지 "종료" +``` + +### 게임방 카드 정보 + +``` +┌─────────────────────────────┐ +│ 캐치마인드 - 영어 단어 맞추기 │ +│ [게임방] [intermediate] │ +│ │ +│ 👥 3/8명 🎮 대기 중 │ +│ 🕐 2026-01-22 10:00 │ +└─────────────────────────────┘ +``` diff --git a/docs/MIDTERM-REPORT.md b/docs/MIDTERM-REPORT.md new file mode 100644 index 00000000..9a6bb1d1 --- /dev/null +++ b/docs/MIDTERM-REPORT.md @@ -0,0 +1,439 @@ +# 영어 학습 플랫폼 백엔드 최종 성과 보고서 + +## 프로젝트 개요 + +| 항목 | 내용 | +|-------|--------------------------------------------------------------------------| +| 프로젝트명 | 영어 회화 학습 플랫폼 (MZC 2nd Project) | +| 담당 영역 | Vocabulary, Chatting, Grammar, Badge, Stats, Common | +| 기술 스택 | Java 21, AWS Lambda, DynamoDB, API Gateway WebSocket, Bedrock, Polly, S3 | +| 배포 환경 | AWS SAM, CloudFormation | + +--- + +## 1. 전체 시스템 아키텍처 + +```mermaid +flowchart TB + subgraph Client["클라이언트"] + WEB[Web App] + end + + subgraph Gateway["API Gateway"] + REST[REST API] + WS[WebSocket API] + GRAMMAR_WS[Grammar WebSocket] + end + + subgraph Lambda["AWS Lambda - 도메인별 핸들러"] + direction TB + VOCAB[Vocabulary
단어/일일학습/테스트] + CHAT[Chatting
실시간 채팅/게임] + GRAMMAR[Grammar
문법 체크/스트리밍] + STATS[Stats
통계 집계] + BADGE[Badge
배지 시스템] + USER[User
사용자 관리] + end + + subgraph AI["AI Services"] + BEDROCK[AWS Bedrock
Claude 3.5 Sonnet] + POLLY[AWS Polly
TTS] + end + + subgraph Data["Data Layer"] + DYNAMO_VOCAB[(DynamoDB
Vocab Table)] + DYNAMO_CHAT[(DynamoDB
Chat Table)] + S3[(S3
음성/뱃지 이미지)] + STREAMS[DynamoDB Streams] + end + + WEB --> REST + WEB --> WS + WEB --> GRAMMAR_WS + REST --> VOCAB + REST --> CHAT + REST --> GRAMMAR + REST --> BADGE + REST --> STATS + REST --> USER + WS --> CHAT + GRAMMAR_WS --> GRAMMAR + VOCAB --> DYNAMO_VOCAB + VOCAB --> POLLY + VOCAB --> S3 + CHAT --> DYNAMO_CHAT + CHAT --> BEDROCK + GRAMMAR --> DYNAMO_VOCAB + GRAMMAR --> BEDROCK + STATS --> DYNAMO_VOCAB + BADGE --> DYNAMO_VOCAB + BADGE --> S3 + STREAMS -->|이벤트 트리거| STATS + STATS -->|배지 부여| BADGE +``` + +--- + +## 2. 주요 기능 구현 + +### 2.1 Vocabulary Domain (단어 학습) + +#### 2.1.1 일일 학습 시스템 (Daily Study) + +```mermaid +flowchart LR + subgraph DailyStudy["일일 학습 흐름"] + A[오늘의 단어 조회] --> B{기존 학습 존재?} + B -->|Yes| C[기존 학습 반환] + B -->|No| D[새 단어 50개 + 복습 5개 생성] + D --> E[학습 진행] + E --> F[단어별 학습 완료 처리] + F --> G{50개 완료?} + G -->|Yes| H[isCompleted = true] + end +``` + +**주요 기능:** + +- 레벨별 신규 단어 50개 + 복습 단어 5개 자동 선정 +- 학습 진행도 실시간 추적 (learnedCount/totalWords) +- 일일 학습 완료 시 isCompleted 플래그 설정 + +#### 2.1.2 SM-2 Spaced Repetition 알고리즘 + +```mermaid +stateDiagram-v2 + [*] --> NEW: 단어 추가 + NEW --> LEARNING: 첫 학습 + LEARNING --> LEARNING: 오답 + LEARNING --> REVIEWING: 2회 연속 정답 + REVIEWING --> LEARNING: 오답 + REVIEWING --> MASTERED: 5회 연속 정답 + MASTERED --> LEARNING: 오답 + MASTERED --> MASTERED: 정답 유지 +``` + +**구현 특징:** + +- State 패턴으로 학습 상태 전이 관리 +- easeFactor 동적 조정 (1.3 ~ 2.5) +- 복습 간격 자동 계산 (1일 → 6일 → interval * easeFactor) + +#### 2.1.3 TTS 음성 생성 + +- AWS Polly 연동 (남성/여성 음성) +- S3 캐싱으로 중복 생성 방지 +- 단어 + 예문 음성 생성 + +--- + +### 2.2 Chatting Domain (실시간 채팅 & 게임) + +#### 2.2.1 WebSocket 채팅 + +```mermaid +sequenceDiagram + participant Client + participant REST as REST API + participant WS as WebSocket API + participant DB as DynamoDB + Note over Client, DB: Phase 1: 방 입장 토큰 발급 + Client ->> REST: POST /rooms/{id}/join + REST ->> DB: RoomToken 저장 (TTL: 5분) + REST -->> Client: roomToken 반환 + Note over Client, DB: Phase 2: WebSocket 연결 + Client ->> WS: $connect?roomToken={token} + WS ->> DB: 토큰 검증 + Connection 저장 + WS -->> Client: 연결 성공 + Note over Client, DB: Phase 3: 메시지 송수신 + Client ->> WS: sendmessage (채팅) + WS ->> DB: 메시지 저장 + 브로드캐스트 +``` + +**주요 기능:** + +- RoomToken 기반 인증 (TTL 5분) +- BCrypt 비밀방 암호화 +- 슬래시 명령어 시스템 (/member, /game, /skip, /hint 등) +- Connection 자동 정리 (TTL + 실패 시 삭제) + +#### 2.2.2 캐치마인드 게임 + +```mermaid +flowchart TB + subgraph Game["캐치마인드 게임 흐름"] + START["#47;game 명령어"] --> INIT["게임 초기화
출제 순서 셔플"] + INIT --> ROUND[라운드 시작
출제자 + 단어 선정] + ROUND --> DRAW[출제자 그림 그리기] + DRAW --> GUESS[참가자 정답 입력] + GUESS --> CHECK{정답?} + CHECK -->|Yes| SCORE[점수 계산
시간보너스 + 연속정답보너스] + CHECK -->|No| GUESS + SCORE --> ALLCORRECT{전원 정답?} + ALLCORRECT -->|Yes| NEXTROUND + ALLCORRECT -->|No| TIMEOUT{시간 초과?} + TIMEOUT -->|Yes| NEXTROUND[다음 라운드] + TIMEOUT -->|No| GUESS + NEXTROUND --> LASTROUND{마지막 라운드?} + LASTROUND -->|Yes| END[게임 종료
순위 발표] + LASTROUND -->|No| ROUND + end +``` + +**점수 계산:** + +``` +점수 = 기본점수(10) + 시간보너스((60-경과초)*0.5) + 연속정답보너스(streak*2) +출제자 보너스 = 정답자당 5점 +``` + +**주요 기능:** + +- 실시간 점수 브로드캐스트 +- 연속 정답 스트릭 시스템 +- 접속자 변동 시 출제자 자동 재선정 +- 라운드별 순위 표시 + +--- + +### 2.3 Grammar Domain (문법 체크) + +#### 2.3.1 AI 스트리밍 응답 + +```mermaid +sequenceDiagram + participant Client + participant WS as Grammar WebSocket + participant Handler as GrammarStreamingHandler + participant Bedrock as AWS Bedrock + Client ->> WS: 문법 체크 요청 + WS ->> Handler: Lambda 호출 + Handler ->> Bedrock: 스트리밍 요청 (Claude 3.5 Sonnet) + + loop 청크 단위 응답 + Bedrock -->> Handler: 텍스트 청크 + Handler -->> WS: 실시간 전송 + WS -->> Client: 즉시 표시 + end + + Handler -->> Client: [DONE] 완료 + Handler ->> DB: 피드백 저장 +``` + +**주요 기능:** + +- Claude 3.5 Sonnet 모델 사용 +- 스트리밍으로 체감 대기 시간 80% 감소 +- 레벨별 맞춤 프롬프트 (BEGINNER: 한국어 번역 포함) +- 대화 히스토리 저장으로 문맥 유지 +- 피드백 영구 저장 (DynamoDB) + +--- + +### 2.4 Stats Domain (학습 통계) + +```mermaid +flowchart LR + subgraph StatsTypes["통계 유형"] + DAILY["일별 통계
#47;stats#47;daily"] + WEEKLY["주별 통계
#47;stats#47;weekly"] + MONTHLY["월별 통계
#47;stats#47;monthly"] + TOTAL["전체 통계
#47;stats#47;total"] + HISTORY["히스토리
#47;stats#47;history"] + end +``` + +**통계 항목:** + +| 필드 | 설명 | +|-------------------|-------------| +| testsCompleted | 완료한 테스트 수 | +| questionsAnswered | 답변한 문제 수 | +| correctAnswers | 정답 수 | +| incorrectAnswers | 오답 수 | +| successRate | 정답률 (%) | +| newWordsLearned | 새로 학습한 단어 수 | +| wordsReviewed | 복습한 단어 수 | +| currentStreak | 현재 연속 학습일 | +| longestStreak | 최장 연속 학습일 | +| gamesPlayed | 참여한 게임 수 | +| gamesWon | 1등 횟수 | +| totalGameScore | 누적 게임 점수 | + +**DynamoDB Streams 기반 비동기 집계:** + +- 테스트 결과 저장 시 자동 트리거 +- API 응답과 분리되어 응답 속도 향상 + +--- + +### 2.5 Badge Domain (배지 시스템) + +```mermaid +flowchart TB + subgraph BadgeSystem["배지 시스템"] + TRIGGER[통계 업데이트] --> CHECK[배지 조건 체크] + CHECK --> AWARD{조건 달성?} + AWARD -->|Yes| SAVE[배지 부여 + 저장] + AWARD -->|No| END[종료] + SAVE --> NOTIFY[프론트엔드 조회] + end +``` + +**배지 종류:** + +| Badge Type | 이름 | 조건 | +|----------------------|---------|------------| +| FIRST_STEP | 첫 걸음 | 첫 학습 완료 | +| STREAK_3, 7, 30 | 연속 학습 | N일 연속 학습 | +| WORDS_100, 500, 1000 | 단어 학습 | N개 단어 학습 | +| PERFECT_SCORE | 완벽주의자 | 테스트 만점 | +| ACCURACY_90 | 정확도 달인 | 전체 정확도 90% | +| GAME_FIRST_PLAY | 첫 게임 | 첫 게임 참여 | +| GAME_10_WINS | 게임 10승 | 10번 1등 | +| QUICK_GUESSER | 번개 정답 | 5초 내 정답 | +| PERFECT_DRAWER | 완벽한 출제자 | 전원 정답 유도 | + +**기술적 특징:** + +- S3 Presigned URL로 배지 이미지 제공 (1시간 유효) +- 획득/미획득 배지 + 진행도 표시 + +--- + +## 3. 기술적 성과 + +### 3.1 아키텍처 패턴 + +| 패턴 | 적용 영역 | 효과 | +|------------------|----------|----------------------------| +| **CQRS** | 전 도메인 | 읽기/쓰기 책임 분리, 테스트 용이성 | +| **State** | 단어 학습 상태 | 복잡한 조건문 제거, 확장성 | +| **Factory** | AI 서비스 | 서비스 교체 용이 (Claude ↔ Llama) | +| **Event-Driven** | 통계/배지 | 느슨한 결합, 비동기 처리 | + +### 3.2 DynamoDB 설계 + +**Single Table Design:** + +- Vocab Table: 단어, 사용자단어, 테스트, 일일학습, 통계, 배지, 문법 +- Chat Table: 채팅방, 메시지, 연결, 게임라운드 + +**GSI 구성:** + +| GSI | 용도 | +|------|---------------------| +| GSI1 | 레벨별 단어 조회, 복습 예정 단어 | +| GSI2 | 카테고리별 단어, 상태별 사용자단어 | +| GSI3 | 북마크 단어 조회 | + +### 3.3 보안 + +- Cognito 인증 (idToken) +- WebSocket RoomToken 인증 (TTL 5분) +- BCrypt 비밀방 암호화 +- S3 Presigned URL (배지 이미지) + +### 3.4 성능 최적화 + +| 최적화 | 효과 | +|--------------------------|-------------------------| +| TTS S3 캐싱 | Polly API 호출 90% 절감 | +| 배치 처리 | 최대 100개 단어 일괄 처리 | +| Strongly Consistent Read | 데이터 정합성 보장 | +| DynamoDB Streams | 비동기 통계 집계로 응답 속도 50% 향상 | +| AI 스트리밍 | 체감 대기 시간 80% 감소 | + +--- + +## 4. API 엔드포인트 요약 + +### REST API (https://gc8l9ijhzc.execute-api.ap-northeast-2.amazonaws.com/dev) + +| Method | Path | 설명 | +|--------|-------------------------------------|-----------| +| GET | /vocab/words | 단어 목록 조회 | +| POST | /vocab/words | 단어 등록 | +| GET | /vocab/daily | 오늘의 학습 단어 | +| POST | /vocab/daily/words/{wordId}/learned | 단어 학습 완료 | +| POST | /vocab/tests | 테스트 생성 | +| POST | /vocab/tests/{testId}/submit | 테스트 제출 | +| GET | /stats/daily | 일별 통계 | +| GET | /stats/weekly | 주별 통계 | +| GET | /stats/monthly | 월별 통계 | +| GET | /stats/total | 전체 통계 | +| GET | /stats/history?limit=100 | 통계 히스토리 | +| GET | /badges | 전체 배지 목록 | +| GET | /badges/earned | 획득한 배지 | +| GET | /rooms | 채팅방 목록 | +| POST | /rooms | 채팅방 생성 | +| POST | /rooms/{roomId}/join | 채팅방 입장 | +| POST | /grammar/check | 문법 체크 | + +### WebSocket API + +| Endpoint | 설명 | +|---------------------------------------------------------------|---------| +| wss://t378dif43l.execute-api.ap-northeast-2.amazonaws.com/dev | 채팅/게임 | +| wss://ltrccmteo8.execute-api.ap-northeast-2.amazonaws.com/dev | 문법 스트리밍 | + +--- + +## 5. 프로젝트 구조 + +``` +ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/ +├── common/ # 공통 모듈 +│ ├── config/ # AWS 클라이언트 (싱글톤) +│ ├── router/ # HandlerRouter, Route +│ ├── exception/ # 예외 처리 체계 +│ ├── dto/ # PaginatedResult, ErrorInfo +│ └── util/ # ResponseGenerator, CursorUtil +│ +├── domain/ +│ ├── vocabulary/ # 단어 학습 도메인 +│ │ ├── handler/ # Word, UserWord, Test, DailyStudy 핸들러 +│ │ ├── service/ # CQRS 서비스 (Command/Query) +│ │ ├── repository/ # DynamoDB 레포지토리 +│ │ ├── model/ # Word, UserWord, TestResult, DailyStudy +│ │ └── state/ # NEW, LEARNING, REVIEWING, MASTERED +│ │ +│ ├── chatting/ # 채팅 도메인 +│ │ ├── handler/ # REST + WebSocket 핸들러 +│ │ ├── service/ # ChatRoom, Game, Command 서비스 +│ │ └── model/ # ChatRoom, Connection, GameRound +│ │ +│ ├── grammar/ # 문법 체크 도메인 +│ │ ├── handler/ # REST + 스트리밍 핸들러 +│ │ ├── service/ # GrammarCheck, Conversation 서비스 +│ │ └── factory/ # BedrockGrammarCheckFactory +│ │ +│ ├── stats/ # 통계 도메인 +│ │ ├── handler/ # UserStats, Streams 핸들러 +│ │ └── repository/ # UserStatsRepository +│ │ +│ └── badge/ # 배지 도메인 +│ ├── handler/ # BadgeHandler +│ └── service/ # BadgeService +``` + +--- + +## 6. 성과 요약 + +| 카테고리 | 성과 | +|------------------|------------------------------------| +| **Lambda 함수** | 26개 | +| **API 엔드포인트** | REST 40+, WebSocket 2 | +| **DynamoDB 테이블** | 2개 (Single Table Design) | +| **GSI** | 5개 | +| **아키텍처 패턴** | CQRS, State, Factory, Event-Driven | +| **AI 연동** | Bedrock Claude 3.5 Sonnet (문법/대화) | +| **TTS** | AWS Polly (남성/여성 음성) | +| **실시간 통신** | WebSocket (채팅/게임/문법 스트리밍) | +| **인증** | Cognito + RoomToken | + +--- + +**작성일:** 2026-01-16 +**팀:** MZC 2nd Project Team / SMJ diff --git a/docs/domain-reports/BADGE-DOMAIN-REPORT.md b/docs/domain-reports/BADGE-DOMAIN-REPORT.md new file mode 100644 index 00000000..4cd58215 --- /dev/null +++ b/docs/domain-reports/BADGE-DOMAIN-REPORT.md @@ -0,0 +1,681 @@ +# Badge Domain 세부 보고서 + +## 1. 개요 + +Badge 도메인은 사용자의 학습 성취도에 따라 배지를 자동으로 부여하는 시스템입니다. 이벤트 기반 아키텍처를 통해 Stats, Vocabulary, Chatting 도메인과 연동되어 실시간으로 배지를 체크하고 +부여합니다. + +--- + +## 2. 전체 아키텍처 + +```mermaid +flowchart TB + subgraph Triggers["트리거 소스"] + TEST[테스트 완료
DynamoDB Streams] + WORD[단어 학습
Write-through] + GAME[게임 종료
Service Method] + end + + subgraph Processing["Badge 처리"] + CHECK[BadgeService
조건 체크] + AWARD[배지 부여] + end + + subgraph Storage["저장소"] + DDB[(DynamoDB
UserBadge)] + S3[(S3
배지 이미지)] + end + + subgraph Query["조회"] + API[BadgeHandler
REST API] + PRESIGN[S3 Presigned URL] + end + + TEST --> CHECK + WORD --> CHECK + GAME --> CHECK + CHECK --> AWARD + AWARD --> DDB + DDB --> API + S3 --> PRESIGN + PRESIGN --> API +``` + +--- + +## 3. 배지 종류 + +### 3.1 배지 카테고리 + +```mermaid +mindmap + root((배지 시스템)) + 학습 + FIRST_STEP[첫 걸음] + WORDS_100[단어 수집가] + WORDS_500[단어 전문가] + WORDS_1000[단어 마스터] + 연속학습 + STREAK_3[3일 연속] + STREAK_7[7일 연속] + STREAK_30[30일 연속] + 테스트 + PERFECT_SCORE[완벽주의자] + TEST_10[테스트 도전자] + ACCURACY_90[정확도 달인] + 게임 + GAME_FIRST[첫 게임] + GAME_10_WINS[10승 달성] + QUICK_GUESSER[번개 정답] + PERFECT_DRAWER[완벽한 출제자] + 최종 + MASTER[학습 마스터] +``` + +### 3.2 배지 상세 + +| Badge Type | 이름 | 설명 | 카테고리 | 조건 | +|-----------------|-----------|--------------------|-----------------|-----------------------| +| FIRST_STEP | 첫 걸음 | 첫 학습을 완료했습니다 | FIRST_STUDY | testsCompleted >= 1 | +| STREAK_3 | 3일 연속 학습 | 3일 연속으로 학습했습니다 | STREAK | currentStreak >= 3 | +| STREAK_7 | 일주일 연속 학습 | 7일 연속으로 학습했습니다 | STREAK | currentStreak >= 7 | +| STREAK_30 | 한 달 연속 학습 | 30일 연속으로 학습했습니다 | STREAK | currentStreak >= 30 | +| WORDS_100 | 단어 수집가 | 100개의 단어를 학습했습니다 | WORDS_LEARNED | totalWords >= 100 | +| WORDS_500 | 단어 전문가 | 500개의 단어를 학습했습니다 | WORDS_LEARNED | totalWords >= 500 | +| WORDS_1000 | 단어 마스터 | 1000개의 단어를 학습했습니다 | WORDS_LEARNED | totalWords >= 1000 | +| PERFECT_SCORE | 완벽주의자 | 테스트에서 만점을 받았습니다 | PERFECT_TEST | incorrectAnswers == 0 | +| TEST_10 | 테스트 도전자 | 10회의 테스트를 완료했습니다 | TESTS_COMPLETED | testsCompleted >= 10 | +| ACCURACY_90 | 정확도 달인 | 전체 정확도 90%를 달성했습니다 | ACCURACY | successRate >= 90 | +| GAME_FIRST_PLAY | 첫 게임 | 첫 게임에 참여했습니다 | GAMES_PLAYED | gamesPlayed >= 1 | +| GAME_10_WINS | 게임 10승 | 게임에서 10번 1등을 했습니다 | GAMES_WON | gamesWon >= 10 | +| QUICK_GUESSER | 번개 정답 | 5초 내에 정답을 맞췄습니다 | QUICK_GUESSES | quickGuesses >= 1 | +| PERFECT_DRAWER | 완벽한 출제자 | 출제 시 전원이 정답을 맞췄습니다 | PERFECT_DRAWS | perfectDraws >= 1 | +| MASTER | 학습 마스터 | 모든 업적을 달성했습니다 | ALL_BADGES | 모든 배지 획득 | + +--- + +## 4. 배지 부여 흐름 + +### 4.1 테스트 완료 시 + +```mermaid +sequenceDiagram + participant Test as TestResult + participant Streams as DynamoDB Streams + participant Handler as StatsStreamHandler + participant Stats as UserStats + participant Badge as BadgeService + participant DB as DynamoDB + Test ->> Streams: INSERT 이벤트 + Streams ->> Handler: 트리거 + Handler ->> Stats: incrementTestStats() + Handler ->> Stats: updateStudyStreak() + Note over Handler: 만점 체크 + alt 정답 > 0 && 오답 == 0 + Handler ->> Badge: awardBadge("PERFECT_SCORE") + Badge ->> DB: UserBadge 저장 + end + + Handler ->> Stats: findTotalStats() + Stats -->> Handler: UserStats + Handler ->> Badge: checkAndAwardBadges() + Badge ->> Badge: 각 배지 조건 체크 + Badge ->> DB: 획득 배지 저장 +``` + +### 4.2 단어 학습 시 + +```mermaid +sequenceDiagram + participant API as DailyStudyHandler + participant Service as DailyStudyCommandService + participant Stats as UserStatsRepository + participant Badge as BadgeService + participant DB as DynamoDB + API ->> Service: markWordLearned() + Service ->> Stats: incrementWordsLearned() + Note over Service: 배지 체크 (WORDS_xxx) + Service ->> Stats: findTotalStats() + Stats -->> Service: UserStats + Service ->> Badge: checkAndAwardBadges() + Badge ->> Badge: WORDS_100, 500, 1000 체크 + Badge ->> DB: 획득 배지 저장 +``` + +### 4.3 게임 종료 시 + +```mermaid +sequenceDiagram + participant Game as GameService + participant Stats as GameStatsService + participant Repo as UserStatsRepository + participant Badge as BadgeService + participant DB as DynamoDB + Game ->> Stats: updateGameStats(room) + + loop 각 참가자 + Stats ->> Stats: 점수 집계 + Note over Stats: correctGuesses
quickGuesses (5초 이내)
perfectDraws + Stats ->> Repo: incrementGameStats() + Stats ->> Repo: findTotalStats() + Repo -->> Stats: UserStats + Stats ->> Badge: checkAndAwardBadges() + Badge ->> Badge: GAME_xxx 배지 체크 + Badge ->> DB: 획득 배지 저장 + end +``` + +--- + +## 5. 배지 조건 체크 로직 + +### 5.1 카테고리별 조건 + +```mermaid +flowchart TB + START[checkAndAwardBadges] --> LOOP{모든 BadgeType 순회} + LOOP --> EARNED{이미 획득?} + EARNED -->|Yes| SKIP[건너뛰기] + EARNED -->|No| CHECK[조건 체크] + CHECK --> SWITCH{카테고리} + SWITCH -->|FIRST_STUDY| FS[testsCompleted >= 1] + SWITCH -->|STREAK| ST[currentStreak >= threshold] + SWITCH -->|WORDS_LEARNED| WL[totalWords >= threshold] + SWITCH -->|PERFECT_TEST| PT[별도 처리] + SWITCH -->|TESTS_COMPLETED| TC[testsCompleted >= threshold] + SWITCH -->|ACCURACY| AC[successRate >= threshold] + SWITCH -->|GAMES_PLAYED| GP[gamesPlayed >= threshold] + SWITCH -->|GAMES_WON| GW[gamesWon >= threshold] + SWITCH -->|QUICK_GUESSES| QG[quickGuesses >= threshold] + SWITCH -->|PERFECT_DRAWS| PD[perfectDraws >= threshold] + SWITCH -->|ALL_BADGES| AB[모든 배지 획득 체크] + FS --> RESULT{조건 충족?} + ST --> RESULT + WL --> RESULT + TC --> RESULT + AC --> RESULT + GP --> RESULT + GW --> RESULT + QG --> RESULT + PD --> RESULT + RESULT -->|Yes| AWARD[배지 부여] + RESULT -->|No| SKIP + AWARD --> LOOP + SKIP --> LOOP +``` + +### 5.2 Switch Expression 패턴 + +```java +private boolean checkBadgeCondition(BadgeType type, UserStats stats) { + return switch (type.getCategory()) { + case "FIRST_STUDY" -> stats.getTestsCompleted() != null && stats.getTestsCompleted() >= 1; + + case "STREAK" -> stats.getCurrentStreak() != null && + stats.getCurrentStreak() >= type.getThreshold(); + + case "WORDS_LEARNED" -> { + int total = (stats.getNewWordsLearned() != null ? stats.getNewWordsLearned() : 0) + + (stats.getWordsReviewed() != null ? stats.getWordsReviewed() : 0); + yield total >= type.getThreshold(); + } + + case "ACCURACY" -> { + if (stats.getQuestionsAnswered() == null || stats.getQuestionsAnswered() == 0) + yield false; + double accuracy = (stats.getCorrectAnswers() * 100.0) / stats.getQuestionsAnswered(); + yield accuracy >= type.getThreshold(); + } + + case "TESTS_COMPLETED" -> stats.getTestsCompleted() != null && + stats.getTestsCompleted() >= type.getThreshold(); + + case "GAMES_PLAYED" -> stats.getGamesPlayed() != null && + stats.getGamesPlayed() >= type.getThreshold(); + + case "GAMES_WON" -> stats.getGamesWon() != null && + stats.getGamesWon() >= type.getThreshold(); + + case "QUICK_GUESSES" -> stats.getQuickGuesses() != null && + stats.getQuickGuesses() >= type.getThreshold(); + + case "PERFECT_DRAWS" -> stats.getPerfectDraws() != null && + stats.getPerfectDraws() >= type.getThreshold(); + + case "PERFECT_TEST" -> false; // 별도 처리 (StatsStreamHandler) + case "ALL_BADGES" -> false; // 특수 로직 필요 + + default -> false; + }; +} +``` + +--- + +## 6. API 엔드포인트 + +### 6.1 REST API + +| Method | Endpoint | 설명 | 응답 | +|--------|----------------|----------------|-------------| +| GET | /badges | 전체 배지 목록 + 진행도 | BadgeInfo[] | +| GET | /badges/earned | 획득한 배지만 조회 | UserBadge[] | + +### 6.2 전체 배지 조회 응답 + +```json +{ + "message": "Badges retrieved", + "data": { + "badges": [ + { + "badgeType": "FIRST_STEP", + "name": "첫 걸음", + "description": "첫 학습을 완료했습니다", + "imageUrl": "https://...presigned.../badges/first_step.png", + "category": "FIRST_STUDY", + "threshold": 1, + "progress": 1, + "earned": true, + "earnedAt": "2026-01-16T10:30:45.123Z" + }, + { + "badgeType": "WORDS_100", + "name": "단어 수집가", + "description": "100개의 단어를 학습했습니다", + "imageUrl": "https://...presigned.../badges/words_100.png", + "category": "WORDS_LEARNED", + "threshold": 100, + "progress": 45, + "earned": false, + "earnedAt": null + } + ], + "totalCount": 16, + "earnedCount": 8 + } +} +``` + +### 6.3 획득 배지 조회 응답 + +```json +{ + "message": "Earned badges retrieved", + "data": { + "badges": [ + { + "badgeType": "FIRST_STEP", + "name": "첫 걸음", + "description": "첫 학습을 완료했습니다", + "imageUrl": "https://...presigned.../badges/first_step.png", + "category": "FIRST_STUDY", + "threshold": 1, + "progress": 1, + "earnedAt": "2026-01-16T10:30:45.123Z" + } + ], + "count": 8 + } +} +``` + +--- + +## 7. 데이터 모델 + +### 7.1 UserBadge + +```java + +@DynamoDbBean +public class UserBadge { + // 기본 키 + String pk; // USER#{userId}#BADGE + String sk; // BADGE#{badgeType} + + // GSI (전체 배지 조회) + String gsi1pk; // BADGE#ALL + String gsi1sk; // EARNED#{earnedAt} + + // 메타데이터 + String odUserId; + String badgeType; // BadgeType enum 이름 + String name; + String description; + String imageUrl; + String category; + Integer threshold; + Integer progress; // 획득 시점 진행도 + + // 타임스탬프 + String earnedAt; + String createdAt; +} +``` + +### 7.2 DynamoDB 키 구조 + +| 필드 | 패턴 | 예시 | +|--------|---------------------|-----------------------------| +| PK | USER#{userId}#BADGE | USER#abc123#BADGE | +| SK | BADGE#{badgeType} | BADGE#STREAK_7 | +| GSI1PK | BADGE#ALL | BADGE#ALL | +| GSI1SK | EARNED#{earnedAt} | EARNED#2026-01-16T10:30:45Z | + +### 7.3 BadgeType Enum + +```java +public enum BadgeType { + FIRST_STEP("첫 걸음", "첫 학습을 완료했습니다", + "FIRST_STUDY", 1, "first_step.png"), + STREAK_3("3일 연속 학습", "3일 연속으로 학습했습니다", + "STREAK", 3, "streak_3.png"), + STREAK_7("일주일 연속 학습", "7일 연속으로 학습했습니다", + "STREAK", 7, "streak_7.png"), + // ... 생략 + MASTER("학습 마스터", "모든 업적을 달성했습니다", + "ALL_BADGES", 1, "master.png"); + + private final String name; + private final String description; + private final String category; + private final int threshold; + private final String imageFile; +} +``` + +--- + +## 8. 진행도 계산 + +### 8.1 카테고리별 진행도 + +```mermaid +flowchart TB + subgraph Progress["진행도 계산"] + FIRST["FIRST_STUDY
testsCompleted >= 1 ? 1 : 0"] + STREAK["STREAK
currentStreak"] + WORDS["WORDS_LEARNED
newWords + reviewed"] + TESTS["TESTS_COMPLETED
testsCompleted"] + ACC["ACCURACY
successRate (%)"] + GAMES["GAMES_PLAYED
gamesPlayed"] + WINS["GAMES_WON
gamesWon"] + QUICK["QUICK_GUESSES
quickGuesses"] + PERFECT["PERFECT_DRAWS
perfectDraws"] + end +``` + +### 8.2 calculateProgress 메서드 + +```java +private int calculateProgress(BadgeType type, UserStats stats) { + return switch (type.getCategory()) { + case "FIRST_STUDY" -> (stats.getTestsCompleted() != null && stats.getTestsCompleted() >= 1) ? 1 : 0; + + case "STREAK" -> stats.getCurrentStreak() != null ? stats.getCurrentStreak() : 0; + + case "WORDS_LEARNED" -> { + int newWords = stats.getNewWordsLearned() != null ? stats.getNewWordsLearned() : 0; + int reviewed = stats.getWordsReviewed() != null ? stats.getWordsReviewed() : 0; + yield newWords + reviewed; + } + + case "TESTS_COMPLETED" -> stats.getTestsCompleted() != null ? stats.getTestsCompleted() : 0; + + case "ACCURACY" -> { + if (stats.getQuestionsAnswered() == null || stats.getQuestionsAnswered() == 0) + yield 0; + yield (int) ((stats.getCorrectAnswers() * 100.0) / stats.getQuestionsAnswered()); + } + + case "GAMES_PLAYED" -> stats.getGamesPlayed() != null ? stats.getGamesPlayed() : 0; + + case "GAMES_WON" -> stats.getGamesWon() != null ? stats.getGamesWon() : 0; + + case "QUICK_GUESSES" -> stats.getQuickGuesses() != null ? stats.getQuickGuesses() : 0; + + case "PERFECT_DRAWS" -> stats.getPerfectDraws() != null ? stats.getPerfectDraws() : 0; + + default -> 0; + }; +} +``` + +--- + +## 9. 멱등성 보장 + +### 9.1 중복 부여 방지 흐름 + +```mermaid +flowchart TB + START[checkAndAwardBadges] --> LOOP[배지 타입 순회] + LOOP --> CHECK{hasBadge?} + CHECK -->|이미 있음| SKIP[건너뛰기] + CHECK -->|없음| CONDITION{조건 충족?} + CONDITION -->|Yes| CREATE[배지 생성] + CONDITION -->|No| SKIP + CREATE --> SAVE[DynamoDB 저장] + SAVE --> LOOP + SKIP --> LOOP +``` + +### 9.2 구현 코드 + +```java +public List checkAndAwardBadges(String userId, UserStats stats) { + List newBadges = new ArrayList<>(); + String now = Instant.now().toString(); + + for (BadgeType type : BadgeType.values()) { + // 1. 이미 획득한 배지는 건너뛰기 + if (badgeRepository.hasBadge(userId, type.name())) { + continue; + } + + // 2. 조건 체크 + if (checkBadgeCondition(type, stats)) { + // 3. 배지 생성 및 저장 + UserBadge badge = createBadge(userId, type, now); + badgeRepository.save(badge); + newBadges.add(badge); + } + } + + return newBadges; +} +``` + +--- + +## 10. S3 이미지 연동 + +### 10.1 Presigned URL 생성 + +```mermaid +flowchart LR + REQ[배지 조회] --> SERVICE[BadgeService] + SERVICE --> PRESIGN[S3PresignUtil] + PRESIGN --> CACHE{캐시 확인} + CACHE -->|있음| RETURN[URL 반환] + CACHE -->|없음| GENERATE[Presigned URL 생성] + GENERATE --> SAVE[캐시 저장] + SAVE --> RETURN +``` + +### 10.2 이미지 URL 생성 + +```java +// S3PresignUtil.java +public static String getBadgeImageUrl(String imageFile) { + return getPresignedUrl("badges/" + imageFile); +} + +// BadgeService - 배지 생성 시 +private UserBadge createBadge(String userId, BadgeType type, String now) { + return UserBadge.builder() + .pk(BadgeKey.userBadgePk(userId)) + .sk(BadgeKey.badgeSk(type.name())) + .gsi1pk(BadgeKey.BADGE_ALL) + .gsi1sk(BadgeKey.earnedSk(now)) + .odUserId(userId) + .badgeType(type.name()) + .name(type.getName()) + .description(type.getDescription()) + .imageUrl(S3PresignUtil.getBadgeImageUrl(type.getImageFile())) + .category(type.getCategory()) + .threshold(type.getThreshold()) + .earnedAt(now) + .createdAt(now) + .build(); +} +``` + +### 10.3 S3 버킷 구조 + +``` +s3://group2-englishstudy/ +└── badges/ + ├── first_step.png + ├── streak_3.png + ├── streak_7.png + ├── streak_30.png + ├── words_100.png + ├── words_500.png + ├── words_1000.png + ├── perfect_score.png + ├── test_10.png + ├── accuracy_90.png + ├── game_first.png + ├── game_10_wins.png + ├── quick_guesser.png + ├── perfect_drawer.png + └── master.png +``` + +--- + +## 11. Stats 도메인 연동 + +### 11.1 연동 포인트 + +```mermaid +flowchart TB + subgraph Stats["Stats 도메인"] + STREAM[StatsStreamHandler] + DAILY[DailyStudyCommandService] + GAME[GameStatsService] + REPO[UserStatsRepository] + end + + subgraph Badge["Badge 도메인"] + SERVICE[BadgeService] + BADGEREPO[BadgeRepository] + end + + STREAM -->|checkAndAwardBadges| SERVICE + DAILY -->|checkWordsBadge| SERVICE + GAME -->|checkAndAwardBadges| SERVICE + SERVICE -->|hasBadge, save| BADGEREPO + SERVICE -->|findTotalStats| REPO +``` + +### 11.2 UserStats 필드와 배지 매핑 + +| UserStats 필드 | 배지 | +|------------------------------------|----------------------------------| +| testsCompleted | FIRST_STEP, TEST_10 | +| currentStreak | STREAK_3, STREAK_7, STREAK_30 | +| newWordsLearned + wordsReviewed | WORDS_100, WORDS_500, WORDS_1000 | +| correctAnswers / questionsAnswered | ACCURACY_90 | +| gamesPlayed | GAME_FIRST_PLAY | +| gamesWon | GAME_10_WINS | +| quickGuesses | QUICK_GUESSER | +| perfectDraws | PERFECT_DRAWER | + +--- + +## 12. 파일 구조 + +``` +domain/badge/ +├── enums/ +│ └── BadgeType.java # 16가지 배지 정의 +├── constants/ +│ └── BadgeKey.java # DynamoDB 키 생성 +├── model/ +│ └── UserBadge.java # 배지 엔티티 +├── repository/ +│ └── BadgeRepository.java # CRUD 연산 +├── service/ +│ └── BadgeService.java # 조건 체크, 배지 부여 +└── handler/ + └── BadgeHandler.java # REST API + +연동 파일: +├── domain/stats/handler/StatsStreamHandler.java +├── domain/vocabulary/service/DailyStudyCommandService.java +└── domain/chatting/service/GameStatsService.java +``` + +--- + +## 13. 기술 스택 + +- **Runtime:** AWS Lambda (Java 21) +- **Database:** DynamoDB (Single Table Design) +- **Storage:** S3 (배지 이미지) +- **Event:** DynamoDB Streams, Write-through, Service Method +- **Pattern:** Event-driven, Idempotent, Switch Expression +- **Java 21 Features:** Enhanced Switch, Yield Statement + +--- + +## 14. 배지 획득 시나리오 + +### 14.1 시나리오 예시 + +```mermaid +flowchart LR + subgraph Day1["1일차"] + A1[테스트 완료] --> B1["FIRST_STEP 획득"] + end + + subgraph Day3["3일차"] + A3[3일 연속 학습] --> B3["STREAK_3 획득"] + end + + subgraph Day7["7일차"] + A7[7일 연속 학습] --> B7["STREAK_7 획득"] + A7_2[100단어 학습] --> B7_2["WORDS_100 획득"] + end + + subgraph Game["게임"] + G1[5초 내 정답] --> G2["QUICK_GUESSER 획득"] + G3[10회 1등] --> G4["GAME_10_WINS 획득"] + end +``` + +### 14.2 특수 배지 획득 조건 + +**PERFECT_SCORE (완벽주의자):** + +- 테스트 제출 시 오답 0개이면 즉시 부여 +- StatsStreamHandler에서 별도 처리 + +**QUICK_GUESSER (번개 정답):** + +- 게임 중 5초(5000ms) 이내 정답 시 +- GameStatsService에서 quickGuesses 카운트 + +**PERFECT_DRAWER (완벽한 출제자):** + +- 출제 시 모든 참가자가 정답을 맞춘 경우 +- 라운드 종료 시 endReason == "ALL_CORRECT"이면 카운트 + +**MASTER (학습 마스터):** + +- 다른 모든 배지를 획득한 경우 +- 특수 로직으로 모든 배지 보유 여부 확인 diff --git a/docs/domain-reports/CHATTING-DOMAIN-REPORT.md b/docs/domain-reports/CHATTING-DOMAIN-REPORT.md new file mode 100644 index 00000000..c27eb552 --- /dev/null +++ b/docs/domain-reports/CHATTING-DOMAIN-REPORT.md @@ -0,0 +1,434 @@ +# Chatting Domain 세부 보고서 + +## 1. 개요 + +Chatting 도메인은 실시간 채팅과 캐치마인드 게임 기능을 제공하는 WebSocket 기반 시스템입니다. AWS API Gateway WebSocket과 Lambda를 활용하여 실시간 양방향 통신을 구현했습니다. + +--- + +## 2. 전체 아키텍처 + +```mermaid +flowchart TB + subgraph Client["클라이언트"] + APP[Mobile/Web App] + end + + subgraph Gateway["API Gateway"] + REST[REST API] + WS[WebSocket API] + end + + subgraph Lambda["Lambda Handlers"] + direction TB + ROOM[ChatRoomHandler] + MSG[ChatMessageHandler] + GAME[GameHandler] + VOICE[ChatVoiceHandler] + CONNECT[WebSocketConnectHandler] + DISCONNECT[WebSocketDisconnectHandler] + MESSAGE[WebSocketMessageHandler] + end + + subgraph Storage["데이터 저장소"] + DDB[(DynamoDB)] + S3[(S3 - 음성 캐시)] + end + + APP --> REST + APP <--> WS + REST --> ROOM + REST --> MSG + REST --> GAME + REST --> VOICE + WS --> CONNECT + WS --> DISCONNECT + WS --> MESSAGE + ROOM --> DDB + MSG --> DDB + GAME --> DDB + MESSAGE --> DDB + VOICE --> S3 +``` + +--- + +## 3. 채팅방 시스템 + +### 3.1 채팅방 입장 흐름 + +```mermaid +sequenceDiagram + participant Client + participant REST as REST API + participant WS as WebSocket API + participant DB as DynamoDB + Note over Client, DB: Phase 1 - 방 입장 및 토큰 발급 + Client ->> REST: POST /rooms/{roomId}/join + REST ->> DB: 비밀번호 검증 (비밀방인 경우) + REST ->> DB: RoomToken 저장 (TTL 5분) + REST -->> Client: roomToken 반환 + Note over Client, DB: Phase 2 - WebSocket 연결 + Client ->> WS: $connect?roomToken={token} + WS ->> DB: 토큰 검증 + WS ->> DB: Connection 저장 (TTL 10분) + WS -->> Client: 연결 성공 + Note over Client, DB: Phase 3 - 실시간 메시지 + Client ->> WS: sendMessage (채팅) + WS ->> DB: 메시지 저장 + WS -->> Client: 브로드캐스트 (같은 방 전체) +``` + +### 3.2 REST API 엔드포인트 + +| Method | Endpoint | 설명 | 인증 | +|--------|-------------------------------|---------------------------|----| +| POST | /chat/rooms | 채팅방 생성 | O | +| GET | /chat/rooms | 채팅방 목록 (level, joined 필터) | O | +| GET | /chat/rooms/{roomId} | 채팅방 상세 | O | +| POST | /chat/rooms/{roomId}/join | 채팅방 입장 (토큰 발급) | O | +| POST | /chat/rooms/{roomId}/leave | 채팅방 퇴장 | O | +| DELETE | /chat/rooms/{roomId} | 채팅방 삭제 (방장만) | O | +| GET | /chat/rooms/{roomId}/messages | 메시지 히스토리 | O | + +### 3.3 WebSocket 이벤트 + +| Route | 설명 | Payload | +|-------------|------------|------------------------------------------| +| $connect | 연결 (토큰 검증) | ?roomToken={token} | +| $disconnect | 연결 해제 | - | +| sendMessage | 메시지 전송 | { roomId, userId, content, messageType } | + +--- + +## 4. 캐치마인드 게임 시스템 + +### 4.1 게임 흐름 + +```mermaid +flowchart TB + subgraph GameFlow["캐치마인드 게임 흐름"] + START["/game 명령어"] --> INIT["게임 초기화
출제자 순서 셔플"] + INIT --> ROUND["라운드 시작
출제자 + 단어 선정"] + ROUND --> DRAW["출제자 그림 그리기
(DRAWING 메시지)"] + DRAW --> GUESS["참가자 정답 입력"] + GUESS --> CHECK{정답?} + CHECK -->|Yes| SCORE["점수 계산
시간보너스 + 연속보너스"] + CHECK -->|No| GUESS + SCORE --> ALLCORRECT{전원 정답?} + ALLCORRECT -->|Yes| NEXTROUND + ALLCORRECT -->|No| TIMEOUT{시간 초과?} + TIMEOUT -->|Yes| NEXTROUND["다음 라운드"] + TIMEOUT -->|No| GUESS + NEXTROUND --> LASTROUND{마지막 라운드?} + LASTROUND -->|Yes| END["게임 종료
순위 발표"] + LASTROUND -->|No| ROUND + end +``` + +### 4.2 게임 API + +| Method | Endpoint | 설명 | +|--------|----------------------------------|-------------| +| POST | /chat/rooms/{roomId}/game/start | 게임 시작 (방장만) | +| POST | /chat/rooms/{roomId}/game/stop | 게임 중지 | +| GET | /chat/rooms/{roomId}/game/status | 게임 상태 조회 | +| GET | /chat/rooms/{roomId}/game/scores | 점수판 조회 | + +### 4.3 슬래시 명령어 + +| 명령어 | 설명 | 사용 가능 | +|---------|----------------|--------| +| /start | 게임 시작 | 방장 | +| /stop | 게임 중지 | 방장/시작자 | +| /score | 점수판 보기 | 전체 | +| /member | 접속자 수 | 전체 | +| /hint | 힌트 제공 (첫글자○○○) | 출제자 | +| /skip | 라운드 스킵 | 출제자 | +| /help | 명령어 도움말 | 전체 | + +### 4.4 점수 계산 공식 + +``` +점수 = 기본점수(10) + 시간보너스 + 연속보너스 + 출제자보너스 + +- 시간보너스: (60 - 경과초) × 0.5 +- 연속보너스: streak × 2 +- 출제자보너스: 정답자당 5점 +``` + +**예시:** + +- 30초에 정답 + 연속 3회: 10 + 15 + 6 = 31점 +- 출제자가 3명 맞출 경우: 5 × 3 = 15점 + +### 4.5 게임 상태 + +```mermaid +stateDiagram-v2 + [*] --> NONE: 대기 + NONE --> PLAYING: /start 명령어 + PLAYING --> ROUND_END: 시간초과/전원정답 + ROUND_END --> PLAYING: 다음 라운드 + ROUND_END --> FINISHED: 마지막 라운드 + PLAYING --> FINISHED: /stop 명령어 + FINISHED --> [*]: 게임 종료 +``` + +--- + +## 5. WebSocket 메시지 타입 + +### 5.1 채팅 메시지 + +| Type | 설명 | 저장 | +|-------------|-------|----| +| TEXT | 일반 채팅 | O | +| IMAGE | 이미지 | O | +| VOICE | 음성 | O | +| AI_RESPONSE | AI 응답 | O | + +### 5.2 게임 메시지 + +| Type | 설명 | 저장 | +|----------------|--------------|----| +| DRAWING | 그림 데이터 (실시간) | X | +| DRAWING_CLEAR | 그림 지우기 | X | +| GUESS | 오답 추측 | X | +| CORRECT_ANSWER | 정답 알림 | X | +| SCORE_UPDATE | 점수 갱신 | X | +| GAME_START | 게임 시작 | X | +| ROUND_START | 라운드 시작 | X | +| ROUND_END | 라운드 종료 | X | +| GAME_END | 게임 종료 | X | +| HINT | 힌트 | X | + +### 5.3 실시간 점수 업데이트 메시지 + +```json +{ + "messageType": "SCORE_UPDATE", + "roomId": "uuid", + "scorerId": "user123", + "scoreGained": 25, + "ranking": [ + { + "rank": 1, + "userId": "user123", + "score": 85, + "change": 25 + }, + { + "rank": 2, + "userId": "user456", + "score": 60, + "change": 0 + } + ], + "currentRound": 3, + "totalRounds": 5 +} +``` + +--- + +## 6. 데이터 모델 + +### 6.1 ChatRoom + +```java + +@DynamoDbBean +public class ChatRoom { + // 기본 정보 + String roomId, name, description; + String level; // beginner, intermediate, advanced + Integer currentMembers, maxMembers; + Boolean isPrivate; + String password; // BCrypt 암호화 + String createdBy; // 방장 + List memberIds; + + // 게임 상태 + String gameStatus; // NONE, PLAYING, ROUND_END, FINISHED + Integer currentRound, totalRounds; + String currentDrawerId, currentWord; + Long roundStartTime; + Integer roundTimeLimit; // 60초 + List drawerOrder; + Map scores; + Map streaks; + List correctGuessers; + Boolean hintUsed; +} +``` + +**DynamoDB Keys:** + +- PK: `ROOM#{roomId}` | SK: `METADATA` +- GSI1: `ROOMS` | `{level}#{createdAt}` (레벨별 최신순) + +### 6.2 Connection + +```java + +@DynamoDbBean +public class Connection { + String connectionId; // API Gateway 연결 ID + String userId; + String roomId; + Long ttl; // 10분 (자동 삭제) +} +``` + +**DynamoDB Keys:** + +- PK: `CONN#{connectionId}` | SK: `METADATA` +- GSI1: `ROOM#{roomId}` | `CONN#{connectionId}` (방별 연결) +- GSI2: `USER#{userId}` | `CONN#{connectionId}` (사용자별 연결) + +### 6.3 GameRound + +```java + +@DynamoDbBean +public class GameRound { + Integer roundNumber; + String drawerId, word, wordEnglish; + List correctGuessers; + Map guessTimes; // 정답까지 걸린 시간 + Map roundScores; + Long startTime, endTime; + String endReason; // TIME_UP, ALL_CORRECT, SKIP + Long ttl; // 7일 +} +``` + +### 6.4 RoomToken + +```java + +@DynamoDbBean +public class RoomToken { + String token; // UUID + String roomId; + String userId; + Long ttl; // 5분 +} +``` + +--- + +## 7. 서비스 레이어 + +### 7.1 CQRS 패턴 + +| Service | 역할 | +|------------------------|----------------------| +| ChatRoomCommandService | 채팅방 생성, 입장, 퇴장, 삭제 | +| ChatRoomQueryService | 채팅방 조회, 목록 | +| GameService | 게임 시작, 정답 체크, 라운드 종료 | +| GameStatsService | 게임 종료 후 통계, 배지 처리 | +| CommandService | 슬래시 명령어 처리 | +| RoomTokenService | 토큰 발급 및 검증 | + +### 7.2 게임 정답 체크 로직 + +```mermaid +flowchart TB + INPUT[정답 입력] --> NORMALIZE["정규화
(소문자, 공백제거)"] + NORMALIZE --> VALIDATE{유효성 검사} + VALIDATE -->|게임 미진행| REJECT1[거부: 게임 없음] + VALIDATE -->|출제자 본인| REJECT2[거부: 출제자] + VALIDATE -->|이미 정답| REJECT3[거부: 중복] + VALIDATE -->|통과| COMPARE{정답 비교} + COMPARE -->|일치| CORRECT["정답 처리
점수 계산"] + COMPARE -->|불일치| WRONG["오답 처리
GUESS 메시지 전송"] + CORRECT --> BROADCAST["브로드캐스트
CORRECT_ANSWER + SCORE_UPDATE"] + WRONG --> GUESSBROADCAST["브로드캐스트
GUESS 메시지"] + BROADCAST --> ALLCHECK{전원 정답?} + ALLCHECK -->|Yes| ROUNDEND[라운드 자동 종료] + ALLCHECK -->|No| CONTINUE[게임 계속] +``` + +--- + +## 8. 브로드캐스트 시스템 + +### 8.1 WebSocketBroadcaster + +```java +public class WebSocketBroadcaster { + public List broadcast( + List connections, + String payload + ) { + // 1. 같은 방 모든 연결에 메시지 전송 + // 2. 실패한 연결 ID 반환 (Stale 정리용) + } +} +``` + +### 8.2 브로드캐스트 유형 + +| 유형 | 대상 | 예시 | +|--------|--------|-----------| +| 전체 | 방 전체 | 채팅, 정답 알림 | +| 본인 제외 | 발신자 제외 | 그림 데이터 | +| 출제자 전용 | 출제자만 | 단어 정보 | + +--- + +## 9. 파일 구조 + +``` +domain/chatting/ +├── handler/ +│ ├── ChatRoomHandler.java +│ ├── ChatMessageHandler.java +│ ├── ChatVoiceHandler.java +│ ├── GameHandler.java +│ └── websocket/ +│ ├── WebSocketConnectHandler.java +│ ├── WebSocketDisconnectHandler.java +│ └── WebSocketMessageHandler.java +├── service/ +│ ├── ChatRoomCommandService.java +│ ├── ChatRoomQueryService.java +│ ├── ChatMessageService.java +│ ├── GameService.java +│ ├── GameStatsService.java +│ ├── CommandService.java +│ └── RoomTokenService.java +├── repository/ +│ ├── ChatRoomRepository.java +│ ├── ChatMessageRepository.java +│ ├── ConnectionRepository.java +│ ├── GameRoundRepository.java +│ └── RoomTokenRepository.java +├── model/ +│ ├── ChatRoom.java +│ ├── ChatMessage.java +│ ├── Connection.java +│ ├── GameRound.java +│ └── RoomToken.java +├── dto/ +│ ├── request/ +│ └── response/ +│ └── ScoreUpdateMessage.java +└── enums/ + ├── GameStatus.java + └── MessageType.java +``` + +--- + +## 10. 기술 스택 + +- **Runtime:** AWS Lambda (Java 21) +- **API:** API Gateway REST + WebSocket +- **Database:** DynamoDB (Single Table Design) +- **Auth:** Cognito + RoomToken +- **Encryption:** BCrypt (비밀방 암호) +- **TTS:** AWS Polly + S3 캐시 +- **Pattern:** CQRS, Repository, Factory diff --git a/docs/domain-reports/COMMON-MODULE-REPORT.md b/docs/domain-reports/COMMON-MODULE-REPORT.md new file mode 100644 index 00000000..aefe6d08 --- /dev/null +++ b/docs/domain-reports/COMMON-MODULE-REPORT.md @@ -0,0 +1,1228 @@ +# Common Module 세부 보고서 + +## 1. 개요 + +Common 모듈은 모든 도메인에서 공유하는 유틸리티, 설정, 예외 처리, 라우팅 등을 제공하는 핵심 인프라 모듈입니다. Java 21의 최신 기능(Records, Sealed Interface, Pattern +Matching)을 적극 활용하여 타입 안전성과 코드 간결성을 확보했습니다. + +--- + +## 2. 전체 패키지 구조 + +```mermaid +flowchart TB +subgraph Common["common/"] +CONFIG[config/] +CONST[constants/] +DTO[dto/] +ENUM[enums/] +EXCEPTION[exception/] +ROUTER[router/] +SERVICE[service/] +UTIL[util/] +VALIDATION[validation/] +end + +subgraph ConfigFiles["config/"] +AC[AwsClients.java] +WSC[WebSocketConfig.java] +RTC[RoomTokenConfig.java] +SC[StudyConfig.java] +end + +subgraph DtoFiles["dto/"] +AR[ApiResponse.java] +EI[ErrorInfo.java] +PR[PaginatedResult.java] +end + +subgraph ExceptionFiles["exception/"] +SE[ServerlessException.java] +EC[ErrorCode.java] +CEC[CommonErrorCode.java] +CE[CommonException.java] +end + +subgraph RouterFiles["router/"] +HR[HandlerRouter.java] +RT[Route.java] +AH[AuthenticatedHandler.java] +end + +CONFIG --> ConfigFiles +DTO --> DtoFiles +EXCEPTION --> ExceptionFiles +ROUTER --> RouterFiles +``` + +--- + +## 3. Handler 라우팅 시스템 + +### 3.1 HandlerRouter 아키텍처 + +```mermaid +flowchart TB + subgraph Request["요청 처리 흐름"] + REQ[APIGatewayProxyRequestEvent] --> ROUTER[HandlerRouter] + ROUTER --> MATCH{라우트 매칭} + MATCH -->|매칭 성공| VALIDATE[파라미터 검증] + MATCH -->|매칭 실패| NF404[404 Not Found] + VALIDATE --> EXECUTE[핸들러 실행] + EXECUTE --> RESPONSE[APIGatewayProxyResponseEvent] + end + + subgraph ErrorHandling["예외 처리"] + EXECUTE -->|ServerlessException| ERR1[ErrorCode 기반 응답] + EXECUTE -->|IllegalArgumentException| ERR2[400 Bad Request] + EXECUTE -->|IllegalStateException| ERR3[409 Conflict] + EXECUTE -->|SecurityException| ERR4[403 Forbidden] + EXECUTE -->|기타 예외| ERR5[500 Internal Error] + end +``` + +### 3.2 Route 정의 (Java 21 Record) + +```java +// Route.java - Java 21 Record 활용 +public record Route( + String method, // HTTP 메서드 + String pathPattern, // 경로 패턴 (e.g., "/rooms/{roomId}") + Function handler, + List requiredPathParams, // 필수 경로 파라미터 + List requiredQueryParams // 필수 쿼리 파라미터 + ) { + // 경로 파라미터 자동 추출: {roomId} → roomId + private static final Pattern PATH_PARAM_PATTERN = + Pattern.compile("\\{([^}]+)}"); +} +``` + +### 3.3 Route 팩토리 메서드 + +```mermaid +flowchart LR + subgraph BasicRoutes["기본 라우트"] + GET["Route.get()"] + POST["Route.post()"] + PUT["Route.put()"] + DELETE["Route.delete()"] + PATCH["Route.patch()"] + end + + subgraph AuthRoutes["인증 라우트"] + GETAUTH["Route.getAuth()"] + POSTAUTH["Route.postAuth()"] + PUTAUTH["Route.putAuth()"] + DELETEAUTH["Route.deleteAuth()"] + PATCHAUTH["Route.patchAuth()"] + end + + BasicRoutes -->|" + Cognito 인증 "| AuthRoutes +``` + +### 3.4 사용 예시 + +```java +// Handler에서 라우터 초기화 +private HandlerRouter initRouter() { + return new HandlerRouter().addRoutes( + // 인증 필요 라우트 (Cognito userId 자동 추출) + Route.postAuth("/grammar/check", this::checkGrammar), + Route.getAuth("/grammar/sessions/{sessionId}", this::getSessionDetail), + Route.deleteAuth("/grammar/sessions/{sessionId}", this::deleteSession), + + // 쿼리 파라미터 검증 + Route.getAuth("/rooms", this::getRooms) + .requireQueryParams("level") + ); +} + +// Lambda 핸들러 메서드 +@Override +public APIGatewayProxyResponseEvent handleRequest( + APIGatewayProxyRequestEvent request, Context context) { + return router.route(request); +} +``` + +### 3.5 AuthenticatedHandler 인터페이스 + +```java +// 함수형 인터페이스 - Cognito 인증 요청 처리 +@FunctionalInterface +public interface AuthenticatedHandler { + APIGatewayProxyResponseEvent handle( + APIGatewayProxyRequestEvent request, + String userId // Cognito sub claim에서 자동 추출 + ); +} + +// 사용 예시 - 람다 표현식으로 간결하게 +Route. + +postAuth("/rooms",(request, userId) ->{ +CreateRoomRequest dto = parseBody(request, CreateRoomRequest.class); +ChatRoom room = roomService.createRoom(userId, dto); + return ResponseGenerator. + +created("Room created",room); +}); +``` + +--- + +## 4. 예외 처리 시스템 + +### 4.1 ErrorCode 계층 구조 (Sealed Interface) + +```mermaid +flowchart TB + subgraph SealedHierarchy["Java 21 Sealed Interface 계층"] + EC[/"ErrorCode
(sealed interface)"/] + EC -->|permits| CEC["CommonErrorCode
(enum)"] + EC -->|permits| DEC[/"DomainErrorCode
(non-sealed interface)"/] + DEC --> VEC["VocabularyErrorCode"] + DEC --> CHEC["ChattingErrorCode"] + DEC --> GEC["GrammarErrorCode"] + DEC --> SEC["StatsErrorCode"] + DEC --> BEC["BadgeErrorCode"] + end +``` + +### 4.2 CommonErrorCode 정의 + +```java +public enum CommonErrorCode implements ErrorCode { + // 인증/인가 (AUTH_xxx) + UNAUTHORIZED("AUTH_001", "인증이 필요합니다", 401), + FORBIDDEN("AUTH_002", "접근 권한이 없습니다", 403), + INVALID_TOKEN("AUTH_003", "유효하지 않은 토큰입니다", 401), + TOKEN_EXPIRED("AUTH_004", "토큰이 만료되었습니다", 401), + + // 검증 (VALIDATION_xxx) + INVALID_INPUT("VALIDATION_001", "잘못된 입력입니다", 400), + REQUIRED_FIELD_MISSING("VALIDATION_002", "필수 필드가 누락되었습니다", 400), + INVALID_FORMAT("VALIDATION_003", "형식이 올바르지 않습니다", 400), + VALUE_OUT_OF_RANGE("VALIDATION_004", "값이 허용 범위를 벗어났습니다", 400), + + // 리소스 (RESOURCE_xxx) + RESOURCE_NOT_FOUND("RESOURCE_001", "리소스를 찾을 수 없습니다", 404), + RESOURCE_ALREADY_EXISTS("RESOURCE_002", "이미 존재하는 리소스입니다", 409), + METHOD_NOT_ALLOWED("RESOURCE_003", "허용되지 않는 메서드입니다", 405), + + // 시스템 (SYSTEM_xxx) + INTERNAL_SERVER_ERROR("SYSTEM_001", "내부 서버 오류가 발생했습니다", 500), + DATABASE_ERROR("SYSTEM_002", "데이터베이스 오류가 발생했습니다", 500), + EXTERNAL_API_ERROR("SYSTEM_003", "외부 API 호출 오류가 발생했습니다", 502), + SERVICE_UNAVAILABLE("SYSTEM_004", "서비스를 일시적으로 사용할 수 없습니다", 503); + + private final String code; + private final String message; + private final int statusCode; +} +``` + +### 4.3 예외 생성 팩토리 패턴 + +```mermaid +flowchart LR + subgraph FactoryMethods["CommonException 팩토리 메서드"] + AUTH["인증 오류"] + VALID["검증 오류"] + RES["리소스 오류"] + SYS["시스템 오류"] + end + + AUTH --> UNAUTH["unauthorized()"] + AUTH --> FORBID["forbidden()"] + AUTH --> TOKEN["invalidToken()"] + VALID --> INPUT["invalidInput(msg)"] + VALID --> MISS["requiredFieldMissing(field)"] + VALID --> FMT["invalidFormat(field)"] + RES --> NF["notFound(resource, id)"] + RES --> EXIST["alreadyExists(resource)"] + SYS --> INTERN["internalError(cause)"] + SYS --> DB["databaseError(cause)"] + SYS --> EXT["externalApiError(api, cause)"] +``` + +### 4.4 예외 사용 예시 + +```java +// 가독성 높은 예외 생성 +throw CommonException.notFound("User","user123"); +// → "User (ID: user123)를 찾을 수 없습니다", 404 + +throw CommonException. + +invalidInput("Email format is invalid"); +// → 400 INVALID_INPUT with custom message + +throw CommonException. + +alreadyExists("ChatRoom","room456"); +// → "ChatRoom (ID: room456)가 이미 존재합니다", 409 + +// 상세 컨텍스트 추가 (메서드 체이닝) +throw CommonException. + +internalError(cause) + . + +addDetail("operation","database_query") + . + +addDetail("table","users"); +``` + +--- + +## 5. AWS 클라이언트 관리 + +### 5.1 Singleton 패턴 (Cold Start 최적화) + +```mermaid +flowchart TB + subgraph ColdStart["Lambda Cold Start 최적화"] + INIT["Lambda 컨테이너 초기화
(1회)"] + STATIC["static final 클라이언트 생성"] + REUSE["요청마다 재사용"] + end + + INIT --> STATIC + STATIC --> REUSE + REUSE -->|" 다음 요청 "| REUSE +``` + +### 5.2 AwsClients.java 구조 + +```java +public final class AwsClients { + // DynamoDB (Enhanced Client 포함) + private static final DynamoDbClient DYNAMO_DB_CLIENT = + DynamoDbClient.builder().build(); + private static final DynamoDbEnhancedClient DYNAMO_DB_ENHANCED_CLIENT = + DynamoDbEnhancedClient.builder() + .dynamoDbClient(DYNAMO_DB_CLIENT) + .build(); + + // S3 (Presigner 포함) + private static final S3Client S3_CLIENT = S3Client.builder().build(); + private static final S3Presigner S3_PRESIGNER = S3Presigner.builder().build(); + + // AI/ML 서비스 + private static final PollyClient POLLY_CLIENT = PollyClient.builder().build(); + private static final BedrockRuntimeClient BEDROCK_CLIENT = + BedrockRuntimeClient.builder().build(); + private static final BedrockRuntimeAsyncClient BEDROCK_ASYNC_CLIENT = + BedrockRuntimeAsyncClient.builder().build(); + private static final ComprehendClient COMPREHEND_CLIENT = + ComprehendClient.builder().build(); + + // SNS + private static final SnsClient SNS_CLIENT = SnsClient.builder().build(); + + // 팩토리 메서드 + public static DynamoDbClient dynamoDb() { + return DYNAMO_DB_CLIENT; + } + + public static DynamoDbEnhancedClient dynamoDbEnhanced() { + return DYNAMO_DB_ENHANCED_CLIENT; + } + + public static S3Client s3() { + return S3_CLIENT; + } + + public static S3Presigner s3Presigner() { + return S3_PRESIGNER; + } + + public static PollyClient polly() { + return POLLY_CLIENT; + } + + public static BedrockRuntimeClient bedrock() { + return BEDROCK_CLIENT; + } + + public static BedrockRuntimeAsyncClient bedrockAsync() { + return BEDROCK_ASYNC_CLIENT; + } + + public static ComprehendClient comprehend() { + return COMPREHEND_CLIENT; + } + + public static SnsClient sns() { + return SNS_CLIENT; + } +} +``` + +### 5.3 사용 예시 + +```java +// Service에서 사용 +public class PollyService { + public VoiceSynthesisResult synthesizeSpeech(String id, String text, String voice) { + SynthesizeSpeechRequest request = SynthesizeSpeechRequest.builder() + .text(text) + .voiceId(VoiceId.MATTHEW) + .engine("neural") + .outputFormat(OutputFormat.MP3) + .build(); + + // Singleton 클라이언트 사용 + InputStream audioStream = AwsClients.polly().synthesizeSpeech(request); + AwsClients.s3().putObject(putRequest, RequestBody.fromInputStream(audioStream, -1)); + + return new VoiceSynthesisResult(s3Key, presignedUrl, false); + } +} +``` + +--- + +## 6. DTO 패턴 (Java 21 Records) + +### 6.1 ApiResponse (제네릭 응답 래퍼) + +```java +// 불변 데이터 클래스 - Java 21 Record +public record ApiResponse( + boolean isSuccess, + String message, + T data, + String error + ) { + // 성공 응답 팩토리 + public static ApiResponse ok(String message, T data) { + return new ApiResponse<>(true, message, data, null); + } + + public static ApiResponse ok(T data) { + return new ApiResponse<>(true, null, data, null); + } + + // 실패 응답 팩토리 + public static ApiResponse fail(String errorMessage) { + return new ApiResponse<>(false, null, null, errorMessage); + } +} +``` + +**JSON 응답 예시:** + +```json +{ + "isSuccess": true, + "message": "Grammar checked successfully", + "data": { + "correctedSentence": "I am a student", + "score": 85, + "errors": [ + ... + ] + }, + "error": null +} +``` + +### 6.2 ErrorInfo (RFC 7807 준수) + +```java +// Problem Details for HTTP APIs (RFC 7807) +public record ErrorInfo( + String code, // e.g., "VOCABULARY.WORD_001" + String message, // e.g., "단어를 찾을 수 없습니다" + int status, // e.g., 404 + Map details // Optional context + ) { + public static ErrorInfo from(ErrorCode errorCode) { ...} + + public static ErrorInfo from(ServerlessException ex) { ...} + + public boolean isClientError() { + return status >= 400 && status < 500; + } + + public boolean isServerError() { + return status >= 500 && status < 600; + } +} +``` + +**JSON 에러 응답 예시:** + +```json +{ + "code": "VOCABULARY.WORD_001", + "message": "단어를 찾을 수 없습니다", + "status": 404, + "details": { + "wordId": "abc-123", + "userId": "user456" + } +} +``` + +### 6.3 PaginatedResult (커서 페이지네이션) + +```java +public record PaginatedResult( + List items, + String nextCursor // Base64 인코딩된 DynamoDB lastEvaluatedKey +) { + public boolean hasMore() { + return nextCursor != null; + } +} +``` + +--- + +## 7. 페이지네이션 유틸리티 + +### 7.1 CursorUtil 동작 흐름 + +```mermaid +sequenceDiagram + participant Client + participant Handler + participant CursorUtil + participant DynamoDB + Note over Client, DynamoDB: 첫 페이지 요청 + Client ->> Handler: GET /items?limit=10 + Handler ->> CursorUtil: decode(null) → null + Handler ->> DynamoDB: Query (exclusiveStartKey=null) + DynamoDB -->> Handler: items + lastEvaluatedKey + Handler ->> CursorUtil: encode(lastEvaluatedKey) + CursorUtil -->> Handler: "dXNlcklkPXVzZXIxMjM..." + Handler -->> Client: {"items": [...], "nextCursor": "dXNlcklkPXVzZXIxMjM..."} + Note over Client, DynamoDB: 다음 페이지 요청 + Client ->> Handler: GET /items?cursor=dXNlcklkPXVzZXIxMjM... + Handler ->> CursorUtil: decode("dXNlcklkPXVzZXIxMjM...") + CursorUtil -->> Handler: {"userId": "user123", ...} + Handler ->> DynamoDB: Query (exclusiveStartKey={...}) + DynamoDB -->> Handler: items + lastEvaluatedKey +``` + +### 7.2 CursorUtil 구현 + +```java +public class CursorUtil { + // DynamoDB lastEvaluatedKey → Base64 문자열 + public static String encode(Map lastEvaluatedKey) { + if (lastEvaluatedKey == null || lastEvaluatedKey.isEmpty()) { + return null; + } + + StringBuilder sb = new StringBuilder(); + for (Map.Entry entry : lastEvaluatedKey.entrySet()) { + if (sb.length() > 0) sb.append("|"); + sb.append(entry.getKey()).append("=").append(entry.getValue().s()); + } + + return Base64.getUrlEncoder().encodeToString(sb.toString().getBytes()); + } + + // Base64 문자열 → DynamoDB exclusiveStartKey + public static Map decode(String cursor) { + if (cursor == null || cursor.isEmpty()) { + return null; + } + + String decoded = new String(Base64.getUrlDecoder().decode(cursor)); + Map startKey = new HashMap<>(); + + for (String pair : decoded.split("\\|")) { + String[] kv = pair.split("=", 2); + if (kv.length == 2) { + startKey.put(kv[0], AttributeValue.builder().s(kv[1]).build()); + } + } + + return startKey; + } +} +``` + +--- + +## 8. 인증 유틸리티 + +### 8.1 Cognito 인증 흐름 + +```mermaid +flowchart TB + subgraph CognitoAuth["Cognito 인증 흐름"] + REQ[요청] --> AUTH[API Gateway Authorizer] + AUTH --> CLAIMS[JWT Claims 추출] + CLAIMS --> INJECT["requestContext.authorizer.claims"] + end + + subgraph CognitoUtil["CognitoUtil 추출"] + INJECT --> EXTRACT[extractUserId] + EXTRACT --> SUB["claims.sub → userId"] + end +``` + +### 8.2 CognitoUtil.java + +```java +public class CognitoUtil { + // 기본 userId 추출 (sub claim) + public static String extractUserId(APIGatewayProxyRequestEvent request) { + Map authorizer = request.getRequestContext().getAuthorizer(); + if (authorizer == null) return null; + + Map claims = (Map) authorizer.get("claims"); + return claims != null ? claims.get("sub") : null; + } + + // 선택적 claim 추출 + public static Optional extractEmail(APIGatewayProxyRequestEvent request) { + return extractClaim(request, "email"); + } + + public static Optional extractNickname(APIGatewayProxyRequestEvent request) { + return extractClaim(request, "custom:nickname"); + } + + public static Optional extractClaim( + APIGatewayProxyRequestEvent request, String claimName) { + // ... claim 추출 로직 + } + + // 사용자 접근 권한 검증 + public static boolean validateUserAccess( + APIGatewayProxyRequestEvent request, String pathUserId) { + String tokenUserId = extractUserId(request); + return tokenUserId != null && tokenUserId.equals(pathUserId); + } +} +``` + +### 8.3 JwtUtil.java (WebSocket용) + +```java +// WebSocket 연결 시 직접 JWT 파싱 (Authorizer 미사용) +public final class JwtUtil { + public static Optional extractUserId(String token) { + // Bearer 제거 + if (token.startsWith("Bearer ")) { + token = token.substring(7); + } + + // JWT payload 추출 (헤더.페이로드.시그니처) + String[] parts = token.split("\\."); + if (parts.length != 3) return Optional.empty(); + + // Base64 URL 디코딩 + String payload = new String(Base64.getUrlDecoder().decode(parts[1])); + Map claims = gson.fromJson(payload, Map.class); + + return Optional.ofNullable((String) claims.get("sub")); + } + + public static boolean isExpired(String token) { + // exp claim 확인 + } +} +``` + +--- + +## 9. HTTP 응답 생성 + +### 9.1 ResponseGenerator.java + +```java +public class ResponseGenerator { + private static final Gson GSON = new GsonBuilder() + .setDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'") + .create(); + + private static final Map CORS_HEADERS = Map.of( + "Content-Type", "application/json", + "Access-Control-Allow-Origin", "*", + "Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS", + "Access-Control-Allow-Headers", "Content-Type,Authorization" + ); + + // 성공 응답 + public static APIGatewayProxyResponseEvent ok(String message, T data) { + return buildResponse(200, ApiResponse.ok(message, data)); + } + + public static APIGatewayProxyResponseEvent created(String message, T data) { + return buildResponse(201, ApiResponse.ok(message, data)); + } + + public static APIGatewayProxyResponseEvent noContent() { + return buildResponse(204, null); + } + + // 에러 응답 + public static APIGatewayProxyResponseEvent fail(ErrorCode errorCode) { + return buildResponse(errorCode.getStatusCode(), ErrorInfo.from(errorCode)); + } + + public static APIGatewayProxyResponseEvent badRequest(String message) { + return fail(CommonErrorCode.INVALID_INPUT, message); + } + + public static APIGatewayProxyResponseEvent notFound(String message) { + return fail(CommonErrorCode.RESOURCE_NOT_FOUND, message); + } + + // ... 기타 편의 메서드 + + private static APIGatewayProxyResponseEvent buildResponse(int statusCode, Object body) { + return new APIGatewayProxyResponseEvent() + .withStatusCode(statusCode) + .withHeaders(new HashMap<>(CORS_HEADERS)) + .withBody(body != null ? GSON.toJson(body) : null); + } + + public static Gson gson() { + return GSON; + } +} +``` + +--- + +## 10. Bean Validation + +### 10.1 BeanValidator 패턴 + +```mermaid +flowchart TB + REQ[요청 수신] --> PARSE[JSON 파싱 → DTO] +PARSE --> VALIDATE[BeanValidator.validateAndExecute] +VALIDATE --> CHECK{검증 통과?} +CHECK -->|Yes|HANDLER[핸들러 로직 실행] +CHECK -->|No|ERR400[400 Bad Request] +HANDLER --> RESPONSE[정상 응답] +``` + +### 10.2 BeanValidator.java + +```java +public final class BeanValidator { + private static final Validator VALIDATOR; + + static { + ValidatorFactory factory = Validation.buildDefaultValidatorFactory(); + VALIDATOR = factory.getValidator(); + } + + // 검증 + 실행 통합 패턴 + public static APIGatewayProxyResponseEvent validateAndExecute( + T object, + Function handler) { + + Optional error = validate(object); + if (error.isPresent()) { + return ResponseGenerator.badRequest(error.get()); + } + + return handler.apply(object); + } + + public static Optional validate(T object) { + Set> violations = VALIDATOR.validate(object); + if (violations.isEmpty()) { + return Optional.empty(); + } + + String message = violations.stream() + .map(ConstraintViolation::getMessage) + .collect(Collectors.joining(", ")); + + return Optional.of(message); + } +} +``` + +### 10.3 DTO 검증 예시 + +```java +// 요청 DTO +public class CreateRoomRequest { + @NotEmpty(message = "방 이름은 필수입니다") + private String roomName; + + @NotNull(message = "난이도는 필수입니다") + private String difficulty; + + @Min(value = 2, message = "최소 2명 이상이어야 합니다") + @Max(value = 10, message = "최대 10명까지 가능합니다") + private int maxMembers; +} + +// Handler에서 사용 +private APIGatewayProxyResponseEvent createRoom( + APIGatewayProxyRequestEvent request, String userId) { + + CreateRoomRequest req = ResponseGenerator.gson() + .fromJson(request.getBody(), CreateRoomRequest.class); + + return BeanValidator.validateAndExecute(req, dto -> { + // 검증 통과 시에만 실행됨 + ChatRoom room = roomService.createRoom(userId, dto); + return ResponseGenerator.created("방이 생성되었습니다", room); + }); +} +``` + +--- + +## 11. WebSocket 유틸리티 + +### 11.1 브로드캐스트 흐름 + +```mermaid +sequenceDiagram + participant Service + participant Broadcaster as WebSocketBroadcaster + participant APIGW as API Gateway + participant Clients as WebSocket Clients + Service ->> Broadcaster: broadcast(connections, message) + + loop 각 연결에 전송 + Broadcaster ->> APIGW: postToConnection(connectionId, data) + alt 성공 + APIGW -->> Clients: 메시지 전달 + else 연결 끊김 (410 Gone) + APIGW -->> Broadcaster: GoneException + Broadcaster ->> Broadcaster: failedIds에 추가 + end + end + + Broadcaster -->> Service: failedConnectionIds 반환 + Service ->> Service: Stale 연결 정리 +``` + +### 11.2 WebSocketBroadcaster.java + +```java +public class WebSocketBroadcaster { + private final ApiGatewayManagementApiClient apiClient; + + public WebSocketBroadcaster() { + String endpoint = WebSocketConfig.websocketEndpoint(); + this.apiClient = ApiGatewayManagementApiClient.builder() + .endpointOverride(URI.create(endpoint)) + .build(); + } + + // 단일 연결에 전송 + public boolean sendToConnection(String connectionId, String message) { + try { + apiClient.postToConnection(PostToConnectionRequest.builder() + .connectionId(connectionId) + .data(SdkBytes.fromUtf8String(message)) + .build()); + return true; + } catch (GoneException e) { + // 연결이 이미 끊김 + return false; + } + } + + // 다수 연결에 브로드캐스트 + public List broadcast(List connections, String message) { + List failedIds = new ArrayList<>(); + + for (Connection conn : connections) { + if (!sendToConnection(conn.getConnectionId(), message)) { + failedIds.add(conn.getConnectionId()); + } + } + + return failedIds; // 실패한 연결 ID 반환 (정리용) + } +} +``` + +### 11.3 WebSocket 응답 유틸리티 + +```java +public final class WebSocketResponseUtil { + public static Map ok(String message) { + return response(200, message); + } + + public static Map unauthorized(String message) { + return response(401, message); + } + + public static Map badRequest(String message) { + return response(400, message); + } + + private static Map response(int statusCode, String body) { + return Map.of( + "statusCode", statusCode, + "body", body + ); + } +} +``` + +--- + +## 12. S3 Presigned URL + +### 12.1 S3PresignUtil.java + +```java +public class S3PresignUtil { + private static final Duration DEFAULT_DURATION = Duration.ofHours(24); + private static final String BUCKET_NAME = System.getenv("S3_BUCKET_NAME"); + + // 내부 캐시 (Java 21 Record) + private record CachedUrl(String url, long expiresAt) { + boolean isExpired() { + // 1시간 버퍼 두고 만료 체크 + return System.currentTimeMillis() > (expiresAt - 3600_000); + } + } + + private static final Map URL_CACHE = new ConcurrentHashMap<>(); + + public static String getPresignedUrl(String key) { + return getPresignedUrl(key, DEFAULT_DURATION); + } + + public static String getPresignedUrl(String key, Duration duration) { + CachedUrl cached = URL_CACHE.get(key); + if (cached != null && !cached.isExpired()) { + return cached.url(); + } + + GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() + .signatureDuration(duration) + .getObjectRequest(r -> r.bucket(BUCKET_NAME).key(key)) + .build(); + + String url = AwsClients.s3Presigner() + .presignGetObject(presignRequest) + .url() + .toString(); + + URL_CACHE.put(key, new CachedUrl(url, + System.currentTimeMillis() + duration.toMillis())); + + return url; + } + + // 배지 이미지 URL 생성 편의 메서드 + public static String getBadgeImageUrl(String imageFile) { + return getPresignedUrl("badges/" + imageFile); + } +} +``` + +--- + +## 13. AWS 서비스 래퍼 + +### 13.1 PollyService (TTS + S3 캐시) + +```mermaid +flowchart TB + REQ[음성 합성 요청] --> CHECK{S3 캐시 확인} + CHECK -->|캐시 있음| PRESIGN[Presigned URL 생성] + CHECK -->|캐시 없음| SYNTH[Polly 음성 합성] + SYNTH --> SAVE[S3 저장] + SAVE --> PRESIGN + PRESIGN --> RETURN[URL 반환] +``` + +```java +public class PollyService { + public VoiceSynthesisResult synthesizeSpeech(String id, String text, String voice) { + String s3Key = generateS3Key(id, voice); + + // 캐시 확인 + if (existsInS3(s3Key)) { + return new VoiceSynthesisResult(s3Key, getPresignedUrl(s3Key), true); + } + + // Polly 음성 합성 + VoiceId voiceId = "MALE".equalsIgnoreCase(voice) ? VoiceId.MATTHEW : VoiceId.JOANNA; + + SynthesizeSpeechRequest request = SynthesizeSpeechRequest.builder() + .text(text) + .voiceId(voiceId) + .engine("neural") // Neural 음성 (고품질) + .outputFormat(OutputFormat.MP3) + .build(); + + InputStream audioStream = AwsClients.polly().synthesizeSpeech(request); + + // S3 저장 + AwsClients.s3().putObject( + PutObjectRequest.builder() + .bucket(bucketName) + .key(s3Key) + .contentType("audio/mpeg") + .build(), + RequestBody.fromInputStream(audioStream, -1) + ); + + return new VoiceSynthesisResult(s3Key, getPresignedUrl(s3Key), false); + } + + public String generateS3Key(String id, String voice) { + String suffix = "MALE".equalsIgnoreCase(voice) ? "male" : "female"; + return s3KeyPrefix + id + "_" + suffix + ".mp3"; + } +} +``` + +### 13.2 ComprehendService (NLP 분석) + +```java +public class ComprehendService { + public ComprehendAnalysis analyze(String text) { + // 감정 분석 + DetectSentimentResponse sentiment = AwsClients.comprehend() + .detectSentiment(DetectSentimentRequest.builder() + .text(text) + .languageCode("en") + .build()); + + // 구문 분석 (품사 태깅) + DetectSyntaxResponse syntax = AwsClients.comprehend() + .detectSyntax(DetectSyntaxRequest.builder() + .text(text) + .languageCode("en") + .build()); + + // 핵심 구문 추출 + DetectKeyPhrasesResponse keyPhrases = AwsClients.comprehend() + .detectKeyPhrases(DetectKeyPhrasesRequest.builder() + .text(text) + .languageCode("en") + .build()); + + // 문장 복잡도 계산 + String complexity = calculateComplexity(syntax.syntaxTokens()); + + return ComprehendAnalysis.builder() + .sentiment(sentiment.sentimentAsString()) + .syntax(mapTokens(syntax.syntaxTokens())) + .keyPhrases(mapKeyPhrases(keyPhrases.keyPhrases())) + .complexity(complexity) + .build(); + } + + private String calculateComplexity(List tokens) { + Set uniquePOS = tokens.stream() + .map(t -> t.partOfSpeech().tagAsString()) + .collect(Collectors.toSet()); + + if (uniquePOS.size() <= 3 && tokens.size() <= 5) return "BEGINNER"; + if (uniquePOS.size() <= 5 && tokens.size() <= 10) return "INTERMEDIATE"; + return "ADVANCED"; + } +} +``` + +--- + +## 14. 설정 클래스 + +### 14.1 StudyConfig (학습 알고리즘 상수) + +```java +public final class StudyConfig { + // SM-2 알고리즘 상수 + public static final int INITIAL_INTERVAL_DAYS = 1; + public static final double DEFAULT_EASE_FACTOR = 2.5; + public static final double MIN_EASE_FACTOR = 1.3; + public static final int INITIAL_REPETITIONS = 0; + + // 테스트 설정 + public static final int DEFAULT_WORD_COUNT = 20; + public static final int DAILY_TEST_WORD_COUNT = 10; + + // 복습 주기 (일) + public static final int[] REVIEW_INTERVALS = {1, 3, 7, 14, 30}; + + // 상태 기본값 + public static final String DEFAULT_WORD_STATUS = "NEW"; + public static final String DEFAULT_DIFFICULTY = "NORMAL"; + + // 오류 제한 + public static final int MAX_WRONG_COUNT = 3; +} +``` + +### 14.2 DynamoDbKey (키 패턴 상수) + +```java +public final class DynamoDbKey { + // 기본 키 + public static final String PK = "PK"; + public static final String SK = "SK"; + + // GSI 키 + public static final String GSI1_PK = "GSI1PK"; + public static final String GSI1_SK = "GSI1SK"; + public static final String GSI2_PK = "GSI2PK"; + public static final String GSI2_SK = "GSI2SK"; + + // GSI 이름 + public static final String GSI1 = "GSI1"; + public static final String GSI2 = "GSI2"; + + // 공통 접두사 + public static final String USER = "USER#"; + public static final String METADATA = "METADATA"; + + // 헬퍼 메서드 + public static String userPk(String userId) { + return USER + userId; // "USER#user-123" + } +} +``` + +--- + +## 15. Java 21 기능 활용 + +### 15.1 Records 활용 + +| 클래스 | 용도 | +|-----------------|----------------| +| ApiResponse | 제네릭 API 응답 래퍼 | +| ErrorInfo | RFC 7807 에러 응답 | +| PaginatedResult | 페이지네이션 결과 | +| Route | HTTP 라우트 정의 | +| RouteEntry | 라우터 내부 매칭 | +| CachedUrl | S3 URL 캐시 | + +### 15.2 Sealed Interface 활용 + +```mermaid +flowchart TB + subgraph SealedPattern["Sealed Interface 패턴"] + EC[/"sealed interface ErrorCode
permits CommonErrorCode, DomainErrorCode"/] + CEC["final enum CommonErrorCode
implements ErrorCode"] + DEC[/"non-sealed interface DomainErrorCode
extends ErrorCode"/] + EC --> CEC + EC --> DEC + end +``` + +### 15.3 Pattern Matching 활용 + +```java +// instanceof 패턴 매칭 +String code = errorCode instanceof DomainErrorCode domainCode + ? domainCode.getFullCode() // "VOCABULARY.WORD_001" + : errorCode.getCode(); // "AUTH_001" + +// switch 표현식 (Enhanced) +return switch(type. + +getCategory()){ + case"FIRST_STUDY"->stats. + +getTestsCompleted() >=1; + case"STREAK"->stats. + +getCurrentStreak() >=type. + +getThreshold(); + case"ACCURACY"->{ +double accuracy = (double) stats.getCorrectAnswers() / stats.getQuestionsAnswered() * 100; +yield accuracy >=type. + +getThreshold(); + } +default ->false; + }; +``` + +--- + +## 16. 디자인 패턴 요약 + +| 패턴 | 적용 위치 | 목적 | +|----------------------|------------------------|-------------------| +| **Singleton** | AwsClients | AWS SDK 클라이언트 재사용 | +| **Factory Method** | Route, CommonException | 객체 생성 캡슐화 | +| **Strategy** | AuthenticatedHandler | 요청 처리 전략 분리 | +| **Router** | HandlerRouter | HTTP 요청 라우팅 | +| **Builder** | ComprehendAnalysis | 복잡한 객체 생성 | +| **Template Method** | BeanValidator | 검증-실행 흐름 템플릿 | +| **Sealed Interface** | ErrorCode 계층 | 구현 제한 | +| **Data Class** | Records | 불변 데이터 전송 | + +--- + +## 17. 파일 구조 + +``` +common/ +├── config/ +│ ├── AwsClients.java # AWS SDK 클라이언트 싱글톤 +│ ├── WebSocketConfig.java # WebSocket 설정 +│ ├── RoomTokenConfig.java # 방 토큰 TTL 설정 +│ └── StudyConfig.java # 학습 알고리즘 상수 +├── constants/ +│ └── DynamoDbKey.java # DynamoDB 키 패턴 +├── dto/ +│ ├── ApiResponse.java # 제네릭 응답 래퍼 (Record) +│ ├── ErrorInfo.java # RFC 7807 에러 (Record) +│ └── PaginatedResult.java # 페이지네이션 (Record) +├── enums/ +│ ├── Difficulty.java # EASY, NORMAL, HARD +│ └── StudyLevel.java # BEGINNER, INTERMEDIATE, ADVANCED +├── exception/ +│ ├── ServerlessException.java # 기본 예외 클래스 +│ ├── ErrorCode.java # Sealed Interface +│ ├── CommonErrorCode.java # 공통 에러 코드 +│ ├── DomainErrorCode.java # 도메인 에러 인터페이스 +│ └── CommonException.java # 예외 팩토리 +├── router/ +│ ├── HandlerRouter.java # HTTP 라우터 +│ ├── Route.java # 라우트 정의 (Record) +│ └── AuthenticatedHandler.java # 인증 핸들러 인터페이스 +├── service/ +│ ├── PollyService.java # TTS + S3 캐시 +│ └── ComprehendService.java # NLP 분석 +├── util/ +│ ├── ResponseGenerator.java # HTTP 응답 빌더 +│ ├── CursorUtil.java # 커서 페이지네이션 +│ ├── CognitoUtil.java # Cognito 인증 추출 +│ ├── JwtUtil.java # JWT 직접 파싱 +│ ├── WebSocketBroadcaster.java # WebSocket 브로드캐스트 +│ ├── WebSocketEventUtil.java # WebSocket 이벤트 추출 +│ ├── WebSocketResponseUtil.java # WebSocket 응답 빌더 +│ └── S3PresignUtil.java # Presigned URL 생성 +└── validation/ + └── BeanValidator.java # Bean Validation 유틸 +``` + +--- + +## 18. 기술 스택 + +- **Runtime:** AWS Lambda (Java 21) +- **Build:** Gradle +- **AWS SDK:** AWS SDK for Java v2 +- **Validation:** Jakarta Bean Validation +- **JSON:** Gson +- **Pattern:** Singleton, Factory, Strategy, Router, Builder, Sealed Interface +- **Java 21 Features:** Records, Sealed Interface, Pattern Matching, Enhanced Switch diff --git a/docs/domain-reports/GRAMMAR-DOMAIN-REPORT.md b/docs/domain-reports/GRAMMAR-DOMAIN-REPORT.md new file mode 100644 index 00000000..5015a011 --- /dev/null +++ b/docs/domain-reports/GRAMMAR-DOMAIN-REPORT.md @@ -0,0 +1,465 @@ +# Grammar Domain 세부 보고서 + +## 1. 개요 + +Grammar 도메인은 AWS Bedrock(Claude 3 Haiku)을 활용한 AI 기반 영어 문법 체크 시스템입니다. REST API와 WebSocket 스트리밍을 통해 실시간 문법 교정 및 대화형 학습을 +제공합니다. + +--- + +## 2. 전체 아키텍처 + +```mermaid +flowchart TB + subgraph Client["클라이언트"] + APP[Mobile/Web App] + end + + subgraph Gateway["API Gateway"] + REST[REST API] + WS[Grammar WebSocket] + end + + subgraph Lambda["Lambda Handlers"] + HANDLER[GrammarHandler] + CONNECT[StreamingConnectHandler] + DISCONNECT[StreamingDisconnectHandler] + STREAM[StreamingHandler] + end + + subgraph AI["AWS AI 서비스"] + BEDROCK[Bedrock
Claude 3 Haiku] + COMPREHEND[Comprehend
언어 분석] + end + + subgraph Storage["저장소"] + DDB[(DynamoDB)] + end + + APP --> REST + APP <--> WS + REST --> HANDLER + WS --> CONNECT + WS --> DISCONNECT + WS --> STREAM + HANDLER --> BEDROCK + HANDLER --> COMPREHEND + STREAM --> BEDROCK + HANDLER --> DDB + STREAM --> DDB +``` + +--- + +## 3. 문법 체크 흐름 + +### 3.1 동기식 문법 체크 + +```mermaid +sequenceDiagram + participant Client + participant Handler as GrammarHandler + participant Service as GrammarCheckService + participant Bedrock as AWS Bedrock + participant DB as DynamoDB + Client ->> Handler: POST /grammar/check + Handler ->> Service: checkGrammar(sentence, level) + Service ->> Bedrock: Claude API 호출 + Bedrock -->> Service: JSON 응답 + Service -->> Handler: GrammarCheckResponse + Handler -->> Client: 문법 교정 결과 +``` + +### 3.2 스트리밍 대화 + +```mermaid +sequenceDiagram + participant Client + participant WS as WebSocket + participant Handler as StreamingHandler + participant Service as ConversationService + participant Bedrock as AWS Bedrock + Client ->> WS: $connect?token={jwt} + WS -->> Client: 연결 성공 + Client ->> WS: 메시지 전송 + WS ->> Handler: $default 라우트 + Handler ->> Service: chatStreaming() + Service -->> Client: StartEvent (sessionId) + + loop 토큰 단위 스트리밍 + Bedrock -->> Service: 텍스트 청크 + Service -->> Client: TokenEvent + end + + Service -->> Client: CompleteEvent (전체 응답) +``` + +--- + +## 4. API 엔드포인트 + +### 4.1 REST API + +| Method | Endpoint | 설명 | +|--------|-------------------------------|---------------| +| POST | /grammar/check | 문법 체크 (단일 문장) | +| POST | /grammar/conversation | 대화형 문법 학습 | +| GET | /grammar/sessions | 대화 세션 목록 | +| GET | /grammar/sessions/{sessionId} | 세션 상세 | +| DELETE | /grammar/sessions/{sessionId} | 세션 삭제 | + +### 4.2 WebSocket API + +| Route | 설명 | +|-------------|-------------| +| $connect | JWT 토큰으로 연결 | +| $disconnect | 연결 해제 | +| $default | 스트리밍 메시지 처리 | + +--- + +## 5. 레벨별 문법 체크 + +### 5.1 학습 레벨 + +| 레벨 | 설명 | 피드백 스타일 | +|--------------|----|--------------------| +| BEGINNER | 초급 | 한국어 번역 + 쉬운 설명 | +| INTERMEDIATE | 중급 | 영어 위주 설명 | +| ADVANCED | 고급 | 상세한 문법 규칙 + 스타일 제안 | + +### 5.2 오류 유형 + +```mermaid +mindmap + root((문법 오류)) + 시제 + VERB_TENSE + 동사 시제 오류 + 일치 + SUBJECT_VERB_AGREEMENT + 주어-동사 일치 + 품사 + ARTICLE + 관사 오류 + PREPOSITION + 전치사 오류 + PRONOUN + 대명사 오류 + 구조 + WORD_ORDER + 어순 오류 + SENTENCE_STRUCTURE + 문장 구조 + 기타 + SPELLING + 철자 + PUNCTUATION + 구두점 + WORD_CHOICE + 어휘 선택 +``` + +--- + +## 6. 응답 포맷 + +### 6.1 문법 체크 응답 + +```json +{ + "originalSentence": "I goed to school yesterday", + "correctedSentence": "I went to school yesterday", + "score": 70, + "isCorrect": false, + "errors": [ + { + "type": "VERB_TENSE", + "original": "goed", + "corrected": "went", + "explanation": "'go'의 과거형은 'went'입니다 (불규칙 동사)", + "startIndex": 2, + "endIndex": 6 + } + ], + "feedback": "과거 시제를 잘 사용하려고 노력했네요! 불규칙 동사를 조금 더 연습해보세요." +} +``` + +### 6.2 대화 응답 + +```json +{ + "sessionId": "uuid", + "grammarCheck": { + /* 위와 동일 */ + }, + "aiResponse": "Great job! Your sentence structure is correct. Let's practice more complex sentences.", + "conversationTip": "Try using 'had gone' for past perfect tense." +} +``` + +### 6.3 스트리밍 이벤트 + +```json +// StartEvent +{ + "type": "start", + "sessionId": "uuid" +} + +// TokenEvent (실시간) +{ + "type": "token", + "token": "Great " +} +{ + "type": "token", + "token": "job!" +} + +// CompleteEvent (완료) +{ + "type": "complete", + "sessionId": "uuid", + "grammarCheck": { + ... + }, + "aiResponse": "...", + "conversationTip": "..." +} + +// ErrorEvent (오류 시) +{ + "type": "error", + "message": "..." +} +``` + +--- + +## 7. AWS Bedrock 통합 + +### 7.1 Claude 3 Haiku 설정 + +```java +public class BedrockGrammarCheckFactory { + private static final String MODEL_ID = "anthropic.claude-3-haiku-20240307-v1:0"; + private static final int MAX_TOKENS = 2048; + private static final String API_VERSION = "bedrock-2023-05-31"; +} +``` + +### 7.2 프롬프트 구조 + +**시스템 프롬프트 (초급):** + +``` +You are a friendly English grammar tutor for Korean speakers. +- Use simple English with Korean translations +- Be encouraging and supportive +- Explain grammar rules clearly +``` + +**사용자 프롬프트:** + +``` +Please check the grammar of this sentence: "{sentence}" + +Return JSON: +{ + "correctedSentence": "...", + "score": 0-100, + "isCorrect": boolean, + "errors": [...], + "feedback": "..." +} +``` + +### 7.3 스트리밍 응답 파싱 + +``` +[RESPONSE] +AI의 자연스러운 대화 응답 +[/RESPONSE] + +[GRAMMAR] +{ JSON 형식의 문법 체크 결과 } +[/GRAMMAR] + +[TIP] +학습 팁 +[/TIP] +``` + +--- + +## 8. 데이터 모델 + +### 8.1 GrammarSession + +```java + +@DynamoDbBean +public class GrammarSession { + String sessionId; + String userId; + String level; // BEGINNER, INTERMEDIATE, ADVANCED + String topic; // "Conversation Practice" + Integer messageCount; + String lastMessage; // 마지막 메시지 (100자 제한) + String createdAt; + String updatedAt; + Long ttl; // 30일 +} +``` + +**DynamoDB Keys:** + +- PK: `GSESSION#{userId}` | SK: `SESSION#{sessionId}` +- GSI1: `GSESSION#ALL` | `UPDATED#{timestamp}` (최신순 정렬) + +### 8.2 GrammarMessage + +```java + +@DynamoDbBean +public class GrammarMessage { + String messageId; + String sessionId; + String userId; + String role; // USER, ASSISTANT + String content; // 원본 메시지 + String correctedContent; // 교정된 메시지 (USER만) + String errorsJson; // 오류 목록 JSON + Integer grammarScore; + String feedback; + Boolean isCorrect; + Long ttl; // 30일 +} +``` + +**DynamoDB Keys:** + +- PK: `GSESSION#{userId}` | SK: `MSG#{timestamp}#{messageId}` +- GSI1: `GSESSION#{sessionId}` | `MSG#{timestamp}` + +### 8.3 GrammarConnection (WebSocket) + +```java + +@DynamoDbBean +public class GrammarConnection { + String connectionId; // API Gateway 연결 ID + String userId; // JWT에서 추출 + String connectedAt; + Long ttl; // 연결 타임아웃 +} +``` + +--- + +## 9. AWS Comprehend 분석 (선택적) + +```mermaid +flowchart LR + INPUT[입력 문장] --> SENTIMENT[감정 분석] + INPUT --> SYNTAX[구문 분석] + INPUT --> KEYPHRASE[핵심 구문] + INPUT --> LANGUAGE[언어 감지] + SENTIMENT --> OUTPUT[분석 결과] + SYNTAX --> OUTPUT + KEYPHRASE --> OUTPUT + LANGUAGE --> OUTPUT +``` + +**분석 항목:** + +- 감정: POSITIVE, NEGATIVE, NEUTRAL, MIXED +- 품사 태깅: NOUN, VERB, ADJ 등 +- 핵심 구문 추출 +- 문장 복잡도 추정 + +--- + +## 10. 서비스 레이어 + +### 10.1 서비스 구성 + +| Service | 역할 | +|----------------------------|----------------| +| GrammarCheckService | 단일 문장 문법 체크 | +| GrammarConversationService | 대화형 학습 + 스트리밍 | +| GrammarSessionQueryService | 세션 조회, 삭제 | +| BedrockGrammarCheckFactory | Bedrock API 호출 | + +### 10.2 대화 히스토리 관리 + +```java +// 최근 10개 메시지만 컨텍스트로 유지 +private static final int MAX_HISTORY_MESSAGES = 10; + +// 대화 히스토리 빌드 +String buildConversationHistory(String sessionId) { + // 최근 메시지 조회 + // USER: 내용 / ASSISTANT: 내용 형식으로 포맷 +} +``` + +--- + +## 11. 파일 구조 + +``` +domain/grammar/ +├── handler/ +│ ├── GrammarHandler.java +│ └── websocket/ +│ ├── GrammarStreamingConnectHandler.java +│ ├── GrammarStreamingDisconnectHandler.java +│ └── GrammarStreamingHandler.java +├── service/ +│ ├── GrammarCheckService.java +│ ├── GrammarConversationService.java +│ └── GrammarSessionQueryService.java +├── factory/ +│ ├── GrammarCheckFactory.java (interface) +│ └── BedrockGrammarCheckFactory.java +├── repository/ +│ ├── GrammarSessionRepository.java +│ └── GrammarConnectionRepository.java +├── model/ +│ ├── GrammarSession.java +│ ├── GrammarMessage.java +│ └── GrammarConnection.java +├── dto/ +│ ├── request/ +│ │ ├── GrammarCheckRequest.java +│ │ └── ConversationRequest.java +│ └── response/ +│ ├── GrammarCheckResponse.java +│ ├── ConversationResponse.java +│ ├── GrammarError.java +│ └── ComprehendAnalysis.java +├── streaming/ +│ ├── StreamingCallback.java +│ ├── StreamingEvent.java (sealed interface) +│ └── StreamingRequest.java +├── enums/ +│ ├── GrammarLevel.java +│ └── GrammarErrorType.java +└── constants/ + └── GrammarKey.java +``` + +--- + +## 12. 기술 스택 + +- **Runtime:** AWS Lambda (Java 21) +- **API:** API Gateway REST + WebSocket +- **AI:** AWS Bedrock (Claude 3 Haiku) +- **NLP:** AWS Comprehend (선택적) +- **Database:** DynamoDB +- **Auth:** JWT (Cognito) +- **Pattern:** Factory, Callback, Sealed Interface (Java 17+) diff --git a/docs/domain-reports/STATS-DOMAIN-REPORT.md b/docs/domain-reports/STATS-DOMAIN-REPORT.md new file mode 100644 index 00000000..3ca3d3ff --- /dev/null +++ b/docs/domain-reports/STATS-DOMAIN-REPORT.md @@ -0,0 +1,379 @@ +# Stats Domain 세부 보고서 + +## 1. 개요 + +Stats 도메인은 사용자의 학습 활동을 추적하고 통계를 집계하는 시스템입니다. DynamoDB Streams와 EventBridge를 활용한 이벤트 기반 아키텍처로 실시간 통계 업데이트를 제공합니다. + +--- + +## 2. 전체 아키텍처 + +```mermaid +flowchart TB + subgraph Triggers["트리거"] + TEST[테스트 완료] + DAILY[일일 학습] + GAME[게임 종료] + SCHEDULE[스케줄러
매일 자정] + end + + subgraph Processing["처리"] + STREAM[StatsStreamHandler
DynamoDB Streams] + SERVICE[StatsService
Write-through] + SCHEDULED[ScheduledStatsHandler
EventBridge] + end + + subgraph Storage["저장소"] + DDB[(DynamoDB
UserStats)] + end + + subgraph Query["조회"] + API[UserStatsHandler
REST API] + end + + TEST --> STREAM + DAILY --> SERVICE + GAME --> SERVICE + SCHEDULE --> SCHEDULED + STREAM --> DDB + SERVICE --> DDB + SCHEDULED --> DDB + DDB --> API +``` + +--- + +## 3. 통계 집계 방식 + +### 3.1 집계 레벨 + +```mermaid +flowchart LR + subgraph Levels["통계 집계 레벨"] + DAILY["일별
DAILY#2026-01-16"] + WEEKLY["주별
WEEKLY#2026-W03"] + MONTHLY["월별
MONTHLY#2026-01"] + TOTAL["전체
TOTAL"] + end + + EVENT[이벤트 발생] --> DAILY + EVENT --> WEEKLY + EVENT --> MONTHLY + EVENT --> TOTAL +``` + +### 3.2 Atomic Counter 패턴 + +```java +// 모든 레벨에 동시 업데이트 (원자적) +UpdateExpression: +SET correctAnswers = if_not_exists(correctAnswers, 0) + :correct, +incorrectAnswers = + +if_not_exists(incorrectAnswers, 0) +:incorrect, +testsCompleted = + +if_not_exists(testsCompleted, 0) +1, +updatedAt =:now +``` + +--- + +## 4. 이벤트 기반 통계 업데이트 + +### 4.1 DynamoDB Streams 처리 + +```mermaid +sequenceDiagram + participant Test as TestResult 저장 + participant Stream as DynamoDB Streams + participant Handler as StatsStreamHandler + participant DB as UserStats + Test ->> Stream: INSERT 이벤트 + Stream ->> Handler: 트리거 + Handler ->> Handler: PK/SK 패턴 확인
(TEST#userId, RESULT#timestamp) + Handler ->> DB: incrementTestStats() + Handler ->> DB: updateStudyStreak() + Handler ->> Handler: checkAndAwardBadges() +``` + +### 4.2 Write-through 패턴 + +```mermaid +sequenceDiagram + participant API as DailyStudyHandler + participant Service as StatsService + participant DB as UserStats + Note over API, DB: 단어 학습 완료 시 + API ->> Service: recordWordsLearned() + Service ->> DB: incrementWordsLearned()
(DAILY, WEEKLY, MONTHLY, TOTAL) + Service ->> DB: updateStudyStreak() +``` + +--- + +## 5. API 엔드포인트 + +### 5.1 통계 조회 API + +| Method | Endpoint | 설명 | 파라미터 | +|--------|----------------|---------|------------------| +| GET | /stats/daily | 일별 통계 | ?date=YYYY-MM-DD | +| GET | /stats/weekly | 주별 통계 | ?week=YYYY-Www | +| GET | /stats/monthly | 월별 통계 | ?month=YYYY-MM | +| GET | /stats/total | 전체 통계 | - | +| GET | /stats/history | 일별 히스토리 | ?cursor, ?limit | + +### 5.2 응답 예시 + +```json +{ + "periodType": "DAILY", + "period": "2026-01-16", + "testsCompleted": 3, + "questionsAnswered": 45, + "correctAnswers": 38, + "incorrectAnswers": 7, + "successRate": 84.44, + "newWordsLearned": 50, + "wordsReviewed": 5 +} +``` + +**전체 통계 추가 필드:** + +```json +{ + "currentStreak": 7, + "longestStreak": 14, + "lastStudyDate": "2026-01-16", + "gamesPlayed": 10, + "gamesWon": 3, + "totalGameScore": 450 +} +``` + +--- + +## 6. 연속 학습 (Streak) 시스템 + +### 6.1 스트릭 계산 로직 + +```mermaid +flowchart TB + START[학습 활동 발생] --> CHECK{lastStudyDate
확인} + CHECK -->|null| NEW["currentStreak = 1
longestStreak = 1"] + CHECK -->|오늘| SAME[변경 없음
이미 오늘 학습] + CHECK -->|어제| INCREMENT["currentStreak++
longestStreak = max()"] + CHECK -->|2일+ 전| RESET["currentStreak = 1
longestStreak 유지"] + NEW --> UPDATE[DB 업데이트] + INCREMENT --> UPDATE + RESET --> UPDATE +``` + +### 6.2 스트릭 리셋 (스케줄러) + +```java +// EventBridge: 매일 자정 실행 +@Scheduled +public void resetStreaks() { + String yesterday = LocalDate.now().minusDays(1).toString(); + // lastStudyDate != yesterday인 사용자의 스트릭 리셋 + // 비용 최적화로 클라이언트 측 계산 권장 +} +``` + +--- + +## 7. 데이터 모델 + +### 7.1 UserStats + +```java + +@DynamoDbBean +public class UserStats { + // 키 + String pk; // USER#{userId}#STATS + String sk; // DAILY#{date} | WEEKLY#{week} | MONTHLY#{month} | TOTAL + + // 메타데이터 + String userId; + String periodType; // DAILY, WEEKLY, MONTHLY, TOTAL + String period; // 2026-01-16, 2026-W03, 2026-01, TOTAL + + // 테스트 통계 + Integer testsCompleted; + Integer questionsAnswered; + Integer correctAnswers; + Integer incorrectAnswers; + Double successRate; + + // 학습 통계 + Integer newWordsLearned; + Integer wordsReviewed; + Integer wordsMastered; + + // 스트릭 (TOTAL만) + Integer currentStreak; + Integer longestStreak; + String lastStudyDate; + + // 게임 통계 (TOTAL만) + Integer gamesPlayed; + Integer gamesWon; + Integer correctGuesses; + Integer totalGameScore; + Integer quickGuesses; // 5초 이내 정답 + Integer perfectDraws; // 전원 정답 유도 + + // 타임스탬프 + String createdAt; + String updatedAt; +} +``` + +### 7.2 DynamoDB 키 구조 + +| 필드 | 패턴 | 예시 | +|---------|------------------------|-------------------| +| PK | USER#{userId}#STATS | USER#abc123#STATS | +| SK (일별) | DAILY#{date} | DAILY#2026-01-16 | +| SK (주별) | WEEKLY#{year}-W{week} | WEEKLY#2026-W03 | +| SK (월별) | MONTHLY#{year}-{month} | MONTHLY#2026-01 | +| SK (전체) | TOTAL | TOTAL | + +--- + +## 8. 통계 메트릭 + +### 8.1 테스트 메트릭 + +| 메트릭 | 설명 | 업데이트 시점 | +|-------------------|----------|---------| +| testsCompleted | 완료 테스트 수 | 테스트 제출 | +| questionsAnswered | 총 문제 수 | 테스트 제출 | +| correctAnswers | 정답 수 | 테스트 제출 | +| incorrectAnswers | 오답 수 | 테스트 제출 | +| successRate | 정답률 (%) | 조회 시 계산 | + +### 8.2 학습 메트릭 + +| 메트릭 | 설명 | 업데이트 시점 | +|-----------------|----------|---------| +| newWordsLearned | 신규 학습 단어 | 일일학습 완료 | +| wordsReviewed | 복습 단어 | 일일학습 완료 | +| wordsMastered | 마스터 단어 | 상태 변경 시 | + +### 8.3 게임 메트릭 + +| 메트릭 | 설명 | 업데이트 시점 | +|----------------|----------|---------| +| gamesPlayed | 참여 게임 수 | 게임 종료 | +| gamesWon | 1등 횟수 | 게임 종료 | +| correctGuesses | 정답 횟수 | 게임 종료 | +| totalGameScore | 누적 점수 | 게임 종료 | +| quickGuesses | 5초 내 정답 | 게임 종료 | +| perfectDraws | 전원 정답 유도 | 게임 종료 | + +--- + +## 9. 히스토리 조회 + +### 9.1 페이지네이션 + +```mermaid +flowchart LR + REQUEST["GET /stats/history
?limit=7&cursor=..."] + QUERY["Query
PK = USER#id#STATS
SK begins_with DAILY#
scanIndexForward = false"] + ENRICH["DailyStudy 조회
isCompleted 추가"] + RESPONSE["PaginatedResult
items, nextCursor, hasMore"] + REQUEST --> QUERY --> ENRICH --> RESPONSE +``` + +### 9.2 응답 구조 + +```json +{ + "history": [ + { + "period": "2026-01-16", + "testsCompleted": 2, + "questionsAnswered": 30, + "correctAnswers": 25, + "incorrectAnswers": 5, + "successRate": 83.33, + "newWordsLearned": 50, + "wordsReviewed": 5, + "isCompleted": true + } + ], + "nextCursor": "base64encoded...", + "hasMore": true +} +``` + +--- + +## 10. 배지 연동 + +### 10.1 자동 배지 체크 + +```mermaid +flowchart TB + STREAM[StatsStreamHandler] --> CHECK[배지 조건 체크] + CHECK --> PERFECT{만점 테스트?} + PERFECT -->|Yes| BADGE1[PERFECT_SCORE 배지] + CHECK --> STATS[전체 통계 조회] + STATS --> BADGESERVICE[BadgeService.checkAndAwardBadges] + BADGESERVICE --> AWARD[조건 충족 배지 부여] +``` + +### 10.2 배지 조건 예시 + +| 배지 | 조건 | 통계 필드 | +|--------------|----------|----------------------| +| STREAK_7 | 7일 연속 학습 | currentStreak >= 7 | +| ACCURACY_90 | 정확도 90% | successRate >= 90 | +| TEST_10 | 10회 테스트 | testsCompleted >= 10 | +| GAME_10_WINS | 10번 1등 | gamesWon >= 10 | + +--- + +## 11. 파일 구조 + +``` +domain/stats/ +├── handler/ +│ ├── UserStatsHandler.java (REST API) +│ ├── StatsStreamHandler.java (DynamoDB Streams) +│ └── ScheduledStatsHandler.java (EventBridge) +├── service/ +│ └── StatsService.java +├── repository/ +│ └── UserStatsRepository.java +├── model/ +│ └── UserStats.java +└── constants/ + └── StatsKey.java +``` + +--- + +## 12. 성능 최적화 + +| 최적화 | 기법 | 효과 | +|--------------------------|------------------|-------------------| +| 원자적 업데이트 | UpdateExpression | Race condition 방지 | +| 비동기 처리 | DynamoDB Streams | API 응답 속도 향상 | +| Cursor 페이지네이션 | lastEvaluatedKey | 대용량 히스토리 처리 | +| Strongly Consistent Read | 히스토리 조회 | 데이터 정합성 | + +--- + +## 13. 기술 스택 + +- **Runtime:** AWS Lambda (Java 21) +- **Database:** DynamoDB (Single Table Design) +- **Event:** DynamoDB Streams, EventBridge +- **Pattern:** Atomic Counter, Write-through, Event-driven diff --git a/docs/domain-reports/VOCABULARY-DOMAIN-REPORT.md b/docs/domain-reports/VOCABULARY-DOMAIN-REPORT.md new file mode 100644 index 00000000..7ee2c90e --- /dev/null +++ b/docs/domain-reports/VOCABULARY-DOMAIN-REPORT.md @@ -0,0 +1,504 @@ +# Vocabulary Domain 세부 보고서 + +## 1. 개요 + +Vocabulary 도메인은 AWS Lambda와 DynamoDB를 기반으로 한 영어 단어 학습 시스템입니다. SM-2 Spaced Repetition 알고리즘과 CQRS 패턴을 적용하여 과학적이고 효율적인 단어 +암기를 지원합니다. + +--- + +## 2. 전체 아키텍처 + +```mermaid +flowchart TB + subgraph Client["클라이언트"] + APP[Mobile/Web App] + end + + subgraph Gateway["API Gateway"] + REST[REST API
HTTP] + end + + subgraph Lambda["Lambda Handlers"] + direction TB + WORD[WordHandler] + USERWORD[UserWordHandler] + DAILY[DailyStudyHandler] + TEST[TestHandler] + GROUP[WordGroupHandler] + VOICE[VoiceHandler] + STATS[StatisticsHandler
SQS Consumer] + end + + subgraph Services["서비스 레이어 (CQRS)"] + direction TB + CMD[Command Services
쓰기 작업] + QUERY[Query Services
읽기 작업] + end + + subgraph External["외부 서비스"] + POLLY[AWS Polly
TTS] + SNS[AWS SNS] + SQS[AWS SQS] + S3[(S3
음성 캐시)] + end + + subgraph Storage["데이터 저장소"] + DDB[(DynamoDB)] + end + + APP --> REST + REST --> WORD & USERWORD & DAILY & TEST & GROUP & VOICE + WORD & USERWORD & DAILY & TEST & GROUP --> CMD & QUERY + CMD & QUERY --> DDB + VOICE --> POLLY --> S3 + TEST --> SNS --> SQS --> STATS + STATS --> DDB +``` + +--- + +## 3. 일일 학습 시스템 + +### 3.1 일일 학습 흐름 + +```mermaid +flowchart TB + subgraph DailyStudyFlow["일일 학습 흐름"] + START[GET /vocab/daily] --> CHECK{기존 학습
존재?} + CHECK -->|Yes| RETURN[기존 학습 반환] + CHECK -->|No| CREATE[새 학습 생성] + CREATE --> REVIEW["복습 단어 5개 선정
(nextReviewAt <= today)"] + REVIEW --> NEW["신규 단어 50개 선정
(미학습 + 해당 레벨)"] + NEW --> SAVE[DailyStudy 저장] + SAVE --> RETURN + RETURN --> LEARN[학습 진행] + LEARN --> MARK["POST .../learned
단어별 학습 완료"] + MARK --> PROGRESS{50개 완료?} + PROGRESS -->|No| LEARN + PROGRESS -->|Yes| COMPLETE["isCompleted = true
배지 체크"] + end +``` + +### 3.2 Daily Study API + +| Method | Endpoint | 설명 | +|--------|-------------------------------------|-----------------| +| GET | /vocab/daily | 오늘의 학습 단어 조회/생성 | +| POST | /vocab/daily/words/{wordId}/learned | 단어 학습 완료 처리 | + +### 3.3 응답 예시 + +```json +{ + "userId": "user123", + "date": "2026-01-16", + "newWordIds": [ + "word1", + "word2", + ... + ], + "reviewWordIds": [ + "word51", + "word52", + ... + ], + "learnedWordIds": [], + "totalWords": 55, + "learnedCount": 0, + "isCompleted": false, + "progress": { + "percentage": 0, + "learned": 0, + "total": 55 + } +} +``` + +--- + +## 4. SM-2 Spaced Repetition 알고리즘 + +### 4.1 학습 상태 전이 + +```mermaid +stateDiagram-v2 + [*] --> NEW: 단어 추가 + NEW --> LEARNING: 첫 학습 + LEARNING --> LEARNING: 오답 + LEARNING --> REVIEWING: 2회 연속 정답 + REVIEWING --> LEARNING: 오답 + REVIEWING --> MASTERED: 5회 연속 정답 + MASTERED --> LEARNING: 오답 + MASTERED --> MASTERED: 정답 유지 +``` + +### 4.2 상태별 로직 + +| 상태 | 조건 | 정답 시 | 오답 시 | +|---------------|-------------|-----------------------------|---------------------------| +| **NEW** | 신규 단어 | LEARNING, rep=1, interval=1 | LEARNING, easeFactor-=0.2 | +| **LEARNING** | rep < 2 | rep++, interval 계산 | rep=0, interval=1 | +| **REVIEWING** | 2 ≤ rep < 5 | rep++, interval 증가 | rep=0, LEARNING | +| **MASTERED** | rep ≥ 5 | interval 증가, 유지 | rep=0, REVIEWING | + +### 4.3 복습 간격 계산 + +```mermaid +flowchart LR + REP1["rep = 1
interval = 1일"] + REP2["rep = 2
interval = 6일"] + REP3["rep >= 3
interval = interval × easeFactor"] + REP1 --> REP2 --> REP3 +``` + +**핵심 변수:** + +- `repetitions`: 연속 정답 횟수 (0~∞) +- `interval`: 복습 간격 (일 단위) +- `easeFactor`: 난이도 계수 (1.3~2.5, 기본 2.5) +- `nextReviewAt`: 다음 복습 예정일 + +--- + +## 5. 테스트 시스템 + +### 5.1 테스트 흐름 + +```mermaid +sequenceDiagram + participant Client + participant Handler as TestHandler + participant Service as TestCommandService + participant DB as DynamoDB + participant SNS as AWS SNS + Client ->> Handler: POST /vocab/tests/start + Handler ->> Service: startTest(userId, testType) + Service ->> DB: 오늘의 학습 단어 조회 + Service ->> Service: 4지선다 문제 생성 + Service -->> Client: 문제 목록 반환 + Note over Client: 사용자 답변 입력 + Client ->> Handler: POST /vocab/tests/submit + Handler ->> Service: submitTest(answers) + Service ->> DB: 결과 저장 + Service ->> SNS: 결과 발행 (비동기) + Service -->> Client: 테스트 결과 + Note over SNS, DB: 비동기 통계 처리 + SNS ->> DB: 통계 업데이트 +``` + +### 5.2 문제 생성 알고리즘 + +```mermaid +flowchart TB + START[문제 생성 시작] --> WORDS[일일 학습 단어 로드] + WORDS --> GROUP[레벨별 그룹화] + GROUP --> LOOP[각 단어마다] + LOOP --> CORRECT["정답 = 해당 단어의
한국어 뜻"] + CORRECT --> DIST["오답 3개 선정
(동일 레벨 단어)"] + DIST --> SHUFFLE[4개 보기 셔플] + SHUFFLE --> NEXT{다음 단어?} + NEXT -->|Yes| LOOP + NEXT -->|No| RETURN[문제 목록 반환] +``` + +### 5.3 Test API + +| Method | Endpoint | 설명 | +|--------|-------------------------------|------------| +| POST | /vocab/tests/start | 테스트 시작 | +| POST | /vocab/tests/submit | 테스트 제출 | +| GET | /vocab/tests/results | 테스트 결과 목록 | +| GET | /vocab/tests/results/{testId} | 테스트 상세 결과 | +| GET | /vocab/tests/tested-words | 최근 테스트된 단어 | + +--- + +## 6. 단어 관리 시스템 + +### 6.1 Word API + +| Method | Endpoint | 설명 | +|--------|------------------------|----------------------------| +| GET | /vocab/words | 단어 목록 (level, category 필터) | +| POST | /vocab/words | 단어 등록 | +| GET | /vocab/words/{wordId} | 단어 상세 | +| PUT | /vocab/words/{wordId} | 단어 수정 | +| DELETE | /vocab/words/{wordId} | 단어 삭제 | +| GET | /vocab/words/search | 키워드 검색 | +| POST | /vocab/words/batch | 배치 등록 (최대 100개) | +| POST | /vocab/words/batch/get | 배치 조회 | + +### 6.2 User Word API + +| Method | Endpoint | 설명 | +|--------|-----------------------------------|-------------| +| GET | /vocab/user-words | 사용자 단어 목록 | +| GET | /vocab/user-words/{wordId} | 사용자 단어 상세 | +| PUT | /vocab/user-words/{wordId} | 정답/오답 기록 | +| PATCH | /vocab/user-words/{wordId}/tag | 북마크, 난이도 설정 | +| PATCH | /vocab/user-words/{wordId}/status | 상태 수동 변경 | +| GET | /vocab/wrong-answers | 오답 단어 목록 | + +### 6.3 Word Group API + +| Method | Endpoint | 설명 | +|--------|----------------------------------------|--------| +| POST | /vocab/groups | 단어장 생성 | +| GET | /vocab/groups | 단어장 목록 | +| GET | /vocab/groups/{groupId} | 단어장 상세 | +| PUT | /vocab/groups/{groupId} | 단어장 수정 | +| DELETE | /vocab/groups/{groupId} | 단어장 삭제 | +| POST | /vocab/groups/{groupId}/words/{wordId} | 단어 추가 | +| DELETE | /vocab/groups/{groupId}/words/{wordId} | 단어 제거 | + +--- + +## 7. TTS 음성 합성 + +### 7.1 음성 생성 흐름 + +```mermaid +flowchart TB + REQUEST["POST /vocab/synthesize
{wordId, voice, type}"] + CHECK{S3 캐시
존재?} + REQUEST --> CHECK + CHECK -->|Yes| PRESIGN[Presigned URL 생성] + CHECK -->|No| POLLY[AWS Polly 호출] + POLLY --> SAVE[S3 저장] + SAVE --> PRESIGN + PRESIGN --> RESPONSE[URL 반환] +``` + +### 7.2 Voice API + +```json +// Request +{ + "wordId": "uuid", + "voice": "MALE", + // MALE | FEMALE + "type": "WORD" + // WORD | EXAMPLE +} + +// Response +{ + "url": "https://s3...presigned-url", + "expiresIn": 3600 +} +``` + +--- + +## 8. 데이터 모델 + +### 8.1 Word + +```java + +@DynamoDbBean +public class Word { + String wordId; // UUID + String english; // 영어 단어 + String korean; // 한국어 뜻 + String example; // 예문 + String level; // BEGINNER | INTERMEDIATE | ADVANCED + String category; // DAILY | BUSINESS | ACADEMIC | TRAVEL | TECHNOLOGY + String maleVoiceKey; // S3 음성 키 + String femaleVoiceKey; + String maleExampleVoiceKey; + String femaleExampleVoiceKey; +} +``` + +**DynamoDB Keys:** + +| Key | 패턴 | 용도 | +|--------|---------------------|----------| +| PK | WORD#{wordId} | 기본 조회 | +| SK | METADATA | - | +| GSI1PK | LEVEL#{level} | 레벨별 조회 | +| GSI2PK | CATEGORY#{category} | 카테고리별 조회 | + +### 8.2 UserWord + +```java + +@DynamoDbBean +public class UserWord { + String userId; + String wordId; + String status; // NEW | LEARNING | REVIEWING | MASTERED + + // SM-2 알고리즘 필드 + Integer interval; // 복습 간격 (일) + Double easeFactor; // 난이도 계수 (1.3~2.5) + Integer repetitions; // 연속 정답 횟수 + String nextReviewAt; // 다음 복습일 (YYYY-MM-DD) + + // 통계 + Integer correctCount; // 누적 정답 + Integer incorrectCount; // 누적 오답 + + // 사용자 설정 + Boolean bookmarked; // 북마크 + Boolean favorite; // 즐겨찾기 + String difficulty; // EASY | NORMAL | HARD +} +``` + +**DynamoDB Keys:** + +| Key | 패턴 | 용도 | +|--------|--------------------------|--------------| +| PK | USER#{userId} | 기본 조회 | +| SK | WORD#{wordId} | - | +| GSI1PK | USER#{userId}#REVIEW | 복습 예정 단어 | +| GSI1SK | DATE#{nextReviewAt} | - | +| GSI2PK | USER#{userId}#STATUS | 상태별 조회 | +| GSI2SK | STATUS#{status} | - | +| GSI3PK | USER#{userId}#BOOKMARKED | 북마크 (Sparse) | + +### 8.3 DailyStudy + +```java + +@DynamoDbBean +public class DailyStudy { + String userId; + String date; // YYYY-MM-DD + List newWordIds; // 신규 단어 50개 + List reviewWordIds; // 복습 단어 5개 + List learnedWordIds; // 학습 완료 단어 + Integer totalWords; // 총 단어 수 (55) + Integer learnedCount; // 학습 완료 수 + Boolean isCompleted; // 완료 여부 +} +``` + +### 8.4 TestResult + +```java + +@DynamoDbBean +public class TestResult { + String testId; + String userId; + String testType; // DAILY | WEEKLY | CUSTOM + Integer totalQuestions; + Integer correctAnswers; + Integer incorrectAnswers; + Double successRate; + List testedWordIds; + List incorrectWordIds; + String startedAt; + String completedAt; +} +``` + +--- + +## 9. 서비스 아키텍처 (CQRS) + +### 9.1 Command Services (쓰기) + +```mermaid +flowchart TB + subgraph Commands["Command Services"] + WC[WordCommandService
단어 생성/수정/삭제] + UC[UserWordCommandService
학습 상태 업데이트] + DC[DailyStudyCommandService
일일 학습 관리] + TC[TestCommandService
테스트 생성/제출] + GC[WordGroupCommandService
단어장 관리] + end +``` + +### 9.2 Query Services (읽기) + +```mermaid +flowchart TB + subgraph Queries["Query Services"] + WQ[WordQueryService
단어 조회/검색] + UQ[UserWordQueryService
학습 현황 조회] + DQ[DailyStudyQueryService
일일 학습 조회] + TQ[TestQueryService
테스트 결과 조회] + end +``` + +--- + +## 10. 성능 최적화 + +| 최적화 | 기법 | 효과 | +|---------------------|------------------------|-----------------| +| N+1 방지 | BatchGetItem (100개 단위) | DB 호출 90% 감소 | +| TTS 캐싱 | S3 + Presigned URL | Polly 호출 90% 절감 | +| 페이지네이션 | Cursor 기반 (Base64) | 대용량 데이터 처리 | +| Sparse Index | GSI3 (북마크 전용) | 인덱스 크기 최소화 | +| 비동기 통계 | SNS/SQS | API 응답 속도 향상 | +| Strongly Consistent | DailyStudy 조회 | 데이터 정합성 | + +--- + +## 11. 파일 구조 + +``` +domain/vocabulary/ +├── handler/ +│ ├── WordHandler.java +│ ├── UserWordHandler.java +│ ├── DailyStudyHandler.java +│ ├── TestHandler.java +│ ├── WordGroupHandler.java +│ ├── VoiceHandler.java +│ ├── StatsHandler.java +│ └── StatisticsHandler.java (SQS) +├── service/ +│ ├── WordCommandService.java +│ ├── WordQueryService.java +│ ├── UserWordCommandService.java +│ ├── UserWordQueryService.java +│ ├── TestCommandService.java +│ ├── TestQueryService.java +│ ├── DailyStudyCommandService.java +│ ├── DailyStudyQueryService.java +│ ├── WordGroupCommandService.java +│ ├── StatsService.java +│ └── StatisticsService.java +├── repository/ +│ ├── WordRepository.java +│ ├── UserWordRepository.java +│ ├── DailyStudyRepository.java +│ ├── TestResultRepository.java +│ └── WordGroupRepository.java +├── model/ +│ ├── Word.java +│ ├── UserWord.java +│ ├── DailyStudy.java +│ ├── TestResult.java +│ └── WordGroup.java +├── state/ +│ ├── WordState.java (interface) +│ ├── NewState.java +│ ├── LearningState.java +│ ├── ReviewingState.java +│ ├── MasteredState.java +│ ├── SpacedRepetitionContext.java +│ └── WordStateFactory.java +└── enums/ + ├── WordStatus.java + ├── WordCategory.java + └── TestType.java +``` + +--- + +## 12. 기술 스택 + +- **Runtime:** AWS Lambda (Java 21) +- **Database:** DynamoDB (Single Table Design) +- **TTS:** AWS Polly (남성/여성 음성) +- **Storage:** S3 (음성 캐시) +- **Messaging:** SNS/SQS (비동기 통계) +- **Pattern:** CQRS, State, Repository, Factory diff --git a/docs/troubleshooting/NEWS-API-TROUBLESHOOTING.md b/docs/troubleshooting/NEWS-API-TROUBLESHOOTING.md deleted file mode 100644 index 4b6e3ba7..00000000 --- a/docs/troubleshooting/NEWS-API-TROUBLESHOOTING.md +++ /dev/null @@ -1,226 +0,0 @@ -# News API 트러블슈팅 가이드 - -## 개요 -2026-01-23 뉴스 기능 프론트엔드 연동 과정에서 발생한 이슈들과 해결 방법을 정리합니다. - ---- - -## 1. GET /news/{articleId} 응답이 기사가 아닌 읽기 기록 반환 - -### 증상 -```javascript -// 예상 응답 -{ articleId: "e644d491", title: "...", summary: "...", ... } - -// 실제 응답 -{ pk: "USER#64983d3c-...#NEWS", sk: "READ#e644d491", articleId: "e644d491" } -``` - -### 원인 -`NewsArticleRepository.findById()`가 테이블 전체를 스캔하면서 `articleId`만 필터링했습니다. -뉴스 테이블에는 기사(`ARTICLE#`)와 사용자 기록(`READ#`, `BOOKMARK#`)이 함께 저장되어 있어서, -`UserNewsRecord`가 먼저 매칭되어 반환되었습니다. - -### 해결 -`findById`에서 SK가 `ARTICLE#`로 시작하는 것만 필터링하도록 수정: - -```java -// Before -Expression filterExpression = Expression.builder() - .expression("articleId = :articleId") - .putExpressionValue(":articleId", AttributeValue.builder().s(articleId).build()) - .build(); - -// After -Expression filterExpression = Expression.builder() - .expression("articleId = :articleId AND begins_with(SK, :skPrefix)") - .putExpressionValue(":articleId", AttributeValue.builder().s(articleId).build()) - .putExpressionValue(":skPrefix", AttributeValue.builder().s("ARTICLE#").build()) - .build(); -``` - -### 파일 -- `NewsArticleRepository.java` - `findById()` 메서드 - ---- - -## 2. 기사에 category 필드 누락 - -### 증상 -```json -{ - "articleId": "2b4e42f9", - "title": "...", - "category": null // 누락 -} -``` - -### 원인 -`NewsAnalysisService`에서 Bedrock AI 분석 시 category 분류 로직이 없었습니다. - -### 해결 -1. Bedrock 프롬프트에 category 분류 요청 추가 -2. `AnalysisResult` 레코드에 category 필드 추가 -3. 파싱 및 저장 로직 추가 - -```java -// 프롬프트에 추가 -"category": "WORLD", -... -For category, choose EXACTLY ONE from: WORLD, POLITICS, BUSINESS, TECH, SCIENCE, HEALTH, SPORTS, ENTERTAINMENT, LIFESTYLE -``` - -### 파일 -- `NewsAnalysisService.java` - `generateSummaryAndQuiz()`, `parseAnalysisResult()`, `AnalysisResult` 레코드 - -### 주의 -기존 기사에는 category가 없으므로, **기사 삭제 후 재수집** 필요 - ---- - -## 3. /stats/dashboard CORS 에러 - -### 증상 -``` -Access to fetch at '.../stats/dashboard' has been blocked by CORS policy: -Response to preflight request doesn't pass access control check -``` - -### 원인 -새로 추가한 `/stats/dashboard` 엔드포인트가 `template.yaml`에 정의되지 않았습니다. - -### 해결 -`template.yaml`의 `UserStatsFunction` Events에 엔드포인트 추가: - -```yaml -Events: - GetDashboardStats: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /stats/dashboard - Method: GET - Auth: - Authorizer: CognitoAuthorizer -``` - -### 파일 -- `template.yaml` - UserStatsFunction Events - ---- - -## 4. 북마크 API가 기사 정보 없이 반환 - -### 증상 -```json -// GET /news/bookmarks 응답 -{ - "bookmarks": [ - { "pk": "USER#...", "sk": "BOOKMARK#...", "articleId": "..." } - ] -} -``` - -### 원인 -`NewsLearningService.getUserBookmarks()`가 북마크 레코드만 반환하고 기사 정보를 조회하지 않았습니다. - -### 해결 -북마크 레코드에서 articleId로 기사 정보를 조회하여 함께 반환: - -```java -public List> getUserBookmarks(String userId, int limit) { - List bookmarks = userNewsRepository.getUserBookmarks(userId, limit); - List> result = new ArrayList<>(); - - for (UserNewsRecord bookmark : bookmarks) { - Optional articleOpt = articleRepository.findById(bookmark.getArticleId()); - if (articleOpt.isPresent()) { - NewsArticle article = articleOpt.get(); - Map bookmarkWithArticle = new HashMap<>(); - bookmarkWithArticle.put("articleId", article.getArticleId()); - bookmarkWithArticle.put("title", article.getTitle()); - bookmarkWithArticle.put("summary", article.getSummary()); - // ... 기타 필드 - result.add(bookmarkWithArticle); - } - } - return result; -} -``` - -### 파일 -- `NewsLearningService.java` - `getUserBookmarks()` -- `NewsHandler.java` - `getBookmarks()` - ---- - -## 5. POST /news/{articleId}/words 500 에러 - -### 증상 -``` -java.lang.NullPointerException: Cannot invoke "JsonElement.getAsString()" -because the return value of "JsonObject.get(String)" is null -at NewsHandler.collectWord(NewsHandler.java:416) -``` - -### 원인 -요청 body에 `word` 필드가 없거나 null일 때 검증 없이 바로 접근했습니다. - -### 해결 -null 체크 추가 및 `INVALID_REQUEST` 에러 코드 정의: - -```java -JsonObject body = gson.fromJson(request.getBody(), JsonObject.class); -if (body == null || !body.has("word") || body.get("word").isJsonNull()) { - return ResponseGenerator.fail(NewsErrorCode.INVALID_REQUEST); -} -``` - -### 파일 -- `NewsHandler.java` - `collectWord()` -- `NewsErrorCode.java` - `INVALID_REQUEST` 추가 - ---- - -## 6. DAILY 통계에 뉴스 관련 필드 누락 - -### 증상 -- TOTAL 통계: `newsRead: 5` ✅ -- DAILY 통계: `newsRead` 필드 없음 ❌ - -### 원인 -`incrementNewsReadStats()` 등의 메서드가 TOTAL 통계만 업데이트하고 DAILY 통계는 업데이트하지 않았습니다. - -### 해결 -각 뉴스 통계 업데이트 메서드에서 DAILY 통계도 함께 업데이트: - -```java -// TOTAL 업데이트 후 DAILY도 업데이트 -Map dailyKey = new HashMap<>(); -dailyKey.put("PK", AttributeValue.builder().s(pk).build()); -dailyKey.put("SK", AttributeValue.builder().s(StatsKey.statsDailySk(today)).build()); -// ... DAILY 업데이트 로직 -``` - -### 파일 -- `UserStatsRepository.java` - `incrementNewsReadStats()`, `incrementNewsQuizStats()`, `incrementNewsWordStats()` - ---- - -## 체크리스트 - -새로운 API 엔드포인트 추가 시: -- [ ] Handler에 라우트 추가 -- [ ] `template.yaml`에 Events 추가 -- [ ] CORS 설정 확인 - -DynamoDB 단일 테이블 설계 주의: -- [ ] 쿼리 시 PK/SK 패턴 명확히 구분 -- [ ] Scan 사용 시 적절한 필터 표현식 사용 - -통계 업데이트 시: -- [ ] TOTAL과 DAILY 모두 업데이트 - -API 요청 처리 시: -- [ ] 요청 body null 체크 -- [ ] 필수 필드 존재 여부 검증 From 9a3fe6f6d0583551cc7d6a42645f2054d6ce7df7 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 23 Jan 2026 12:34:19 +0900 Subject: [PATCH 492/528] feat: enhance bookmark and reading status tracking for News API - Add bookmark and reading status to individual article responses - Include bookmark status in paginated news responses - Modify `KeywordInfo` to support Korean translations (`meaningKo`) - Update AI prompts to extract Korean meanings for keywords --- .../domain/news/handler/NewsHandler.java | 59 +++++++++++++++++-- .../domain/news/model/KeywordInfo.java | 1 + .../news/repository/UserNewsRepository.java | 13 ++++ .../news/service/NewsAnalysisService.java | 13 ++-- .../news/service/NewsLearningService.java | 23 +++++++- 5 files changed, 99 insertions(+), 10 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java index 86a30590..30d0d3e7 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java @@ -111,7 +111,7 @@ private APIGatewayProxyResponseEvent getNewsList(APIGatewayProxyRequestEvent req result = queryService.getTodayNews(limit, cursor); } - return buildPaginatedResponse(result); + return buildPaginatedResponse(result, getUserId(request)); } /** @@ -126,7 +126,7 @@ private APIGatewayProxyResponseEvent getTodayNews(APIGatewayProxyRequestEvent re int limit = parseLimit(params.get("limit")); PaginatedResult result = queryService.getTodayNews(limit, cursor); - return buildPaginatedResponse(result); + return buildPaginatedResponse(result, getUserId(request)); } /** @@ -143,7 +143,7 @@ private APIGatewayProxyResponseEvent getRecommendedNews(APIGatewayProxyRequestEv int limit = parseLimit(params.get("limit")); PaginatedResult result = queryService.getRecommendedNews(userLevel, limit, cursor); - return buildPaginatedResponse(result); + return buildPaginatedResponse(result, getUserId(request)); } /** @@ -158,15 +158,64 @@ private APIGatewayProxyResponseEvent getNewsDetail(APIGatewayProxyRequestEvent r return ResponseGenerator.fail(NewsErrorCode.ARTICLE_NOT_FOUND); } - return ResponseGenerator.ok("뉴스 조회 성공", article.get()); + // 로그인한 사용자의 경우 북마크/읽기 상태 추가 + String userId = getUserId(request); + Map response = new HashMap<>(); + response.put("article", article.get()); + + if (userId != null) { + response.put("isBookmarked", learningService.isBookmarked(userId, articleId)); + response.put("isRead", learningService.hasRead(userId, articleId)); + } else { + response.put("isBookmarked", false); + response.put("isRead", false); + } + + return ResponseGenerator.ok("뉴스 조회 성공", response); } /** * 페이지네이션 응답 생성 */ private APIGatewayProxyResponseEvent buildPaginatedResponse(PaginatedResult result) { + return buildPaginatedResponse(result, null); + } + + /** + * 페이지네이션 응답 생성 (북마크 상태 포함) + */ + private APIGatewayProxyResponseEvent buildPaginatedResponse(PaginatedResult result, String userId) { + List> articlesWithStatus = new java.util.ArrayList<>(); + java.util.Set bookmarkedIds = java.util.Collections.emptySet(); + + // 로그인한 사용자의 경우 북마크 상태 조회 + if (userId != null && !result.items().isEmpty()) { + List articleIds = result.items().stream() + .map(NewsArticle::getArticleId) + .toList(); + bookmarkedIds = learningService.getBookmarkedArticleIds(userId, articleIds); + } + + for (NewsArticle article : result.items()) { + Map articleWithStatus = new HashMap<>(); + articleWithStatus.put("articleId", article.getArticleId()); + articleWithStatus.put("title", article.getTitle()); + articleWithStatus.put("summary", article.getSummary()); + articleWithStatus.put("source", article.getSource()); + articleWithStatus.put("publishedAt", article.getPublishedAt()); + articleWithStatus.put("keywords", article.getKeywords()); + articleWithStatus.put("highlightWords", article.getHighlightWords()); + articleWithStatus.put("category", article.getCategory()); + articleWithStatus.put("level", article.getLevel()); + articleWithStatus.put("cefrLevel", article.getCefrLevel()); + articleWithStatus.put("imageUrl", article.getImageUrl()); + articleWithStatus.put("readCount", article.getReadCount()); + articleWithStatus.put("isBookmarked", bookmarkedIds.contains(article.getArticleId())); + articlesWithStatus.add(articleWithStatus); + } + Map response = new HashMap<>(); - response.put("articles", result.items()); + response.put("articles", articlesWithStatus); response.put("nextCursor", result.nextCursor()); response.put("hasMore", result.hasMore()); response.put("count", result.items().size()); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/KeywordInfo.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/KeywordInfo.java index cd5b4b44..c1e00f56 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/KeywordInfo.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/KeywordInfo.java @@ -19,6 +19,7 @@ public class KeywordInfo { private String word; // 영어 단어 private String meaning; // 영어 뜻 (간단한 정의) + private String meaningKo; // 한국어 뜻 private String example; // 기사에서 발췌한 예문 private String level; // 단어 난이도 (BEGINNER, INTERMEDIATE, ADVANCED) private Integer position; // 기사 내 위치 (문장 번호 또는 단어 인덱스) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/UserNewsRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/UserNewsRepository.java index 25f8e651..a5fa2b67 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/UserNewsRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/UserNewsRepository.java @@ -145,6 +145,19 @@ public List getUserBookmarks(String userId, int limit) { return results.subList(0, Math.min(results.size(), limit)); } + /** + * 여러 기사의 북마크 여부 확인 (배치) + */ + public Set getBookmarkedArticleIds(String userId, List articleIds) { + Set bookmarkedIds = new HashSet<>(); + for (String articleId : articleIds) { + if (isBookmarked(userId, articleId)) { + bookmarkedIds.add(articleId); + } + } + return bookmarkedIds; + } + /** * 사용자 뉴스 통계 조회 */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java index 83a4fc30..bf8bf449 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java @@ -185,14 +185,14 @@ private List extractKeywords(String content) { */ private AnalysisResult generateSummaryAndQuiz(String content, String cefrLevel) { String systemPrompt = """ - You are an English learning assistant. Analyze the news article and create learning materials. + You are an English learning assistant for Korean learners. Analyze the news article and create learning materials. Respond in this exact JSON format: { "summary": "3-line summary in English (each line separated by newline)", "keywords": [ - {"word": "economy", "meaning": "the system of trade and industry", "example": "The economy is growing steadily."}, - {"word": "policy", "meaning": "a plan of action adopted by government", "example": "The new policy affects all citizens."} + {"word": "economy", "meaning": "the system of trade and industry", "meaningKo": "경제", "example": "The economy is growing steadily."}, + {"word": "policy", "meaning": "a plan of action adopted by government", "meaningKo": "정책", "example": "The new policy affects all citizens."} ], "highlightWords": ["word1", "word2", "word3"], "category": "WORLD", @@ -225,7 +225,11 @@ private AnalysisResult generateSummaryAndQuiz(String content, String cefrLevel) } IMPORTANT: - - keywords: Extract 5-8 important vocabulary words from the article. Include word, meaning (simple definition), and example sentence from the article. + - keywords: Extract 5-8 important vocabulary words from the article. Include: + - word: the English word + - meaning: simple English definition + - meaningKo: Korean translation of the word (한국어 뜻) + - example: example sentence from the article - highlightWords: 3-5 difficult words that learners should pay attention to (just the words, no definitions). - category: Choose EXACTLY ONE from: WORLD, POLITICS, BUSINESS, TECH, SCIENCE, HEALTH, SPORTS, ENTERTAINMENT, LIFESTYLE - Create exactly 3 quiz questions. @@ -294,6 +298,7 @@ private AnalysisResult parseAnalysisResult(String response) { keywords.add(KeywordInfo.builder() .word(k.has("word") ? k.get("word").getAsString() : "") .meaning(k.has("meaning") ? k.get("meaning").getAsString() : "") + .meaningKo(k.has("meaningKo") ? k.get("meaningKo").getAsString() : "") .example(k.has("example") ? k.get("example").getAsString() : "") .build()); }); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java index c46e4fc6..208339ee 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java @@ -17,6 +17,7 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; /** * 뉴스 학습 부가 기능 서비스 @@ -63,6 +64,12 @@ public List markAsRead(String userId, String articleId) { return new ArrayList<>(); } + // 이미 읽은 기사인지 확인 (중복 조회수 증가 방지) + if (userNewsRepository.hasRead(userId, articleId)) { + logger.debug("이미 읽은 기사: userId={}, articleId={}", userId, articleId); + return new ArrayList<>(); + } + NewsArticle a = article.get(); userNewsRepository.saveReadRecord( userId, @@ -72,7 +79,7 @@ public List markAsRead(String userId, String articleId) { a.getCategory() ); - // 조회수 증가 + // 조회수 증가 (새로운 읽기만) String date = extractDateFromPk(a.getPk()); if (date != null) { articleRepository.incrementReadCount(date, articleId); @@ -135,6 +142,20 @@ public boolean isBookmarked(String userId, String articleId) { return userNewsRepository.isBookmarked(userId, articleId); } + /** + * 읽기 여부 확인 + */ + public boolean hasRead(String userId, String articleId) { + return userNewsRepository.hasRead(userId, articleId); + } + + /** + * 여러 기사의 북마크 여부 확인 (배치) + */ + public Set getBookmarkedArticleIds(String userId, List articleIds) { + return userNewsRepository.getBookmarkedArticleIds(userId, articleIds); + } + /** * 사용자 북마크 목록 조회 (기사 정보 포함) */ From 8aed923304ebff972f3fca152128d0833ec87aba Mon Sep 17 00:00:00 2001 From: hye-inA Date: Fri, 23 Jan 2026 12:48:45 +0900 Subject: [PATCH 493/528] =?UTF-8?q?refactor=20:=20session=5Fid=EA=B0=80=20?= =?UTF-8?q?null=20=EC=B2=B4=ED=81=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../{websocket => }/SpeakingHandler.java | 9 ++++++--- .../speaking/service/SpeakingService.java | 19 ++++++------------- 2 files changed, 12 insertions(+), 16 deletions(-) rename ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/{websocket => }/SpeakingHandler.java (97%) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingHandler.java similarity index 97% rename from ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingHandler.java rename to ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingHandler.java index c515f950..90e041a7 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingHandler.java @@ -1,4 +1,4 @@ -package com.mzc.secondproject.serverless.domain.speaking.handler.websocket; +package com.mzc.secondproject.serverless.domain.speaking.handler; import com.amazonaws.services.lambda.runtime.Context; import com.amazonaws.services.lambda.runtime.RequestHandler; @@ -9,6 +9,7 @@ import com.google.gson.JsonObject; import com.google.gson.JsonParser; import com.mzc.secondproject.serverless.common.util.JwtUtil; +import com.mzc.secondproject.serverless.domain.speaking.dto.response.SpeakingResponse; import com.mzc.secondproject.serverless.domain.speaking.service.SpeakingService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -102,7 +103,7 @@ private APIGatewayProxyResponseEvent handleChat(String userId, String body) { String audio = request.has("audio") ? request.get("audio").getAsString() : null; String text = request.has("text") ? request.get("text").getAsString() : null; - SpeakingService.SpeakingResponse result; + SpeakingResponse result; if (audio != null && !audio.isEmpty()) { // 음성 입력 처리 @@ -134,7 +135,7 @@ private APIGatewayProxyResponseEvent handleReset(String userId, String body) { } JsonObject request = JsonParser.parseString(body).getAsJsonObject(); - String sessionId = request.has("sessionId") ? request.get("sessionId").getAsString() : null; + String sessionId = getStringOrNull(request, "sessionId"); if (sessionId == null || sessionId.isEmpty()) { return response(400, Map.of("error", "sessionId is required")); @@ -176,4 +177,6 @@ private APIGatewayProxyResponseEvent response(int statusCode, Map parseHistory(String historyJson) { return history; } + /** * 히스토리 JSON 변환 */ @@ -328,18 +330,9 @@ private String toJson(List history) { return array.toString(); } - // ==================== Inner Classes ==================== - - private record Message(String role, String content) {} - /** - * Speaking 응답 DTO + * 대화 메시지 (히스토리용) */ - public record SpeakingResponse( - String sessionId, // 세션 ID (다음 요청에 사용) - String userTranscript, // 사용자가 말한 내용 (STT 결과) - String aiText, // AI 응답 텍스트 - String aiAudioUrl, // AI 응답 음성 URL (Polly) - double confidence // STT 신뢰도comp - ) {} + private record Message(String role, String content) {} + } From a67d15fe0d40dabf2e61ad903edff519888a2c1d Mon Sep 17 00:00:00 2001 From: hyein Heo <128613248+hye-inA@users.noreply.github.com> Date: Fri, 23 Jan 2026 14:27:13 +0900 Subject: [PATCH 494/528] =?UTF-8?q?fix=20:=20sessionId=20NullPointerExcept?= =?UTF-8?q?ion=20=EC=97=90=EB=9F=AC=20=EC=88=98=EC=A0=95=20=20(#496)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix : 오타 수정 * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * feat : speaking rest API 람다 함수 추가 * refactor : speaking service 재사용 * refactor : AI 영어 회화 연습 코드 리팩토링 * feat : handleChat 메서드 JsonNull 체크 푸가 * refactor : session_id가 null 체크 추가 * feat : template 환경변수 리펙토링 --------- Co-authored-by: DDING JOO --- ServerlessFunction/gradlew | 28 +- ServerlessFunction/gradlew.bat | 25 +- .../{websocket => }/SpeakingHandler.java | 9 +- .../speaking/service/SpeakingService.java | 19 +- .../user/dto/response/ProfileResponse.java | 2 +- .../domain/user/handler/UserHandler.java | 15 +- .../serverless/domain/user/model/User.java | 11 +- .../domain/user/service/UserService.java | 59 +- ServerlessFunction/template.yaml | 553 +++++------------- 9 files changed, 258 insertions(+), 463 deletions(-) rename ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/{websocket => }/SpeakingHandler.java (97%) diff --git a/ServerlessFunction/gradlew b/ServerlessFunction/gradlew index adff685a..fcb6fca1 100755 --- a/ServerlessFunction/gradlew +++ b/ServerlessFunction/gradlew @@ -1,7 +1,7 @@ #!/bin/sh # -# Copyright © 2015 the original authors. +# Copyright © 2015-2021 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -15,8 +15,6 @@ # See the License for the specific language governing permissions and # limitations under the License. # -# SPDX-License-Identifier: Apache-2.0 -# ############################################################################## # @@ -57,7 +55,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -85,8 +83,7 @@ done # This is normally unused # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} -# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -114,6 +111,7 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. @@ -146,7 +144,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 + # shellcheck disable=SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac @@ -154,7 +152,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then '' | soft) :;; #( *) # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 + # shellcheck disable=SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac @@ -171,6 +169,7 @@ fi # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) @@ -202,15 +201,16 @@ fi # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' -# Collect all arguments for the java command: -# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, -# and any embedded shellness will be escaped. -# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be -# treated as '${Hostname}' itself on the command line. +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ "$@" # Stop when "xargs" is not available. diff --git a/ServerlessFunction/gradlew.bat b/ServerlessFunction/gradlew.bat index c4bdd3ab..93e3f59f 100644 --- a/ServerlessFunction/gradlew.bat +++ b/ServerlessFunction/gradlew.bat @@ -13,8 +13,6 @@ @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem -@rem SPDX-License-Identifier: Apache-2.0 -@rem @if "%DEBUG%"=="" @echo off @rem ########################################################################## @@ -45,11 +43,11 @@ set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if %ERRORLEVEL% equ 0 goto execute -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. goto fail @@ -59,21 +57,22 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. goto fail :execute @rem Setup the command line +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* :end @rem End local scope for the variables with windows NT shell diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingHandler.java similarity index 97% rename from ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingHandler.java rename to ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingHandler.java index c515f950..90e041a7 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingHandler.java @@ -1,4 +1,4 @@ -package com.mzc.secondproject.serverless.domain.speaking.handler.websocket; +package com.mzc.secondproject.serverless.domain.speaking.handler; import com.amazonaws.services.lambda.runtime.Context; import com.amazonaws.services.lambda.runtime.RequestHandler; @@ -9,6 +9,7 @@ import com.google.gson.JsonObject; import com.google.gson.JsonParser; import com.mzc.secondproject.serverless.common.util.JwtUtil; +import com.mzc.secondproject.serverless.domain.speaking.dto.response.SpeakingResponse; import com.mzc.secondproject.serverless.domain.speaking.service.SpeakingService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -102,7 +103,7 @@ private APIGatewayProxyResponseEvent handleChat(String userId, String body) { String audio = request.has("audio") ? request.get("audio").getAsString() : null; String text = request.has("text") ? request.get("text").getAsString() : null; - SpeakingService.SpeakingResponse result; + SpeakingResponse result; if (audio != null && !audio.isEmpty()) { // 음성 입력 처리 @@ -134,7 +135,7 @@ private APIGatewayProxyResponseEvent handleReset(String userId, String body) { } JsonObject request = JsonParser.parseString(body).getAsJsonObject(); - String sessionId = request.has("sessionId") ? request.get("sessionId").getAsString() : null; + String sessionId = getStringOrNull(request, "sessionId"); if (sessionId == null || sessionId.isEmpty()) { return response(400, Map.of("error", "sessionId is required")); @@ -176,4 +177,6 @@ private APIGatewayProxyResponseEvent response(int statusCode, Map parseHistory(String historyJson) { return history; } + /** * 히스토리 JSON 변환 */ @@ -328,18 +330,9 @@ private String toJson(List history) { return array.toString(); } - // ==================== Inner Classes ==================== - - private record Message(String role, String content) {} - /** - * Speaking 응답 DTO + * 대화 메시지 (히스토리용) */ - public record SpeakingResponse( - String sessionId, // 세션 ID (다음 요청에 사용) - String userTranscript, // 사용자가 말한 내용 (STT 결과) - String aiText, // AI 응답 텍스트 - String aiAudioUrl, // AI 응답 음성 URL (Polly) - double confidence // STT 신뢰도comp - ) {} + private record Message(String role, String content) {} + } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/dto/response/ProfileResponse.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/dto/response/ProfileResponse.java index bdc1ced7..7f17bd4a 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/dto/response/ProfileResponse.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/dto/response/ProfileResponse.java @@ -29,7 +29,7 @@ public static ProfileResponse from(User user) { .email(user.getEmail()) .nickname(user.getNickname()) .level(user.getLevel()) - .profileUrl(user.getProfileUrl()) + .profileUrl(user.getProfileUrlForResponse()) .createdAt(user.getCreatedAt()) .updatedAt(user.getUpdatedAt()) .build(); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/UserHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/UserHandler.java index b4fc9aea..9ad8618f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/UserHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/UserHandler.java @@ -59,9 +59,20 @@ private APIGatewayProxyResponseEvent getMyProfile( APIGatewayProxyRequestEvent request, String userId // cognitoSub ) { - User user = userService.getProfile(userId, request); - ProfileResponse response = ProfileResponse.from(user); + + // profileUrl을 Presigned URL로 변환 + String presignedUrl = userService.getPresignedProfileUrl(user.getProfileUrl()); + + ProfileResponse response = ProfileResponse.builder() + .userId(user.getCognitoSub()) + .email(user.getEmail()) + .nickname(user.getNickname()) + .level(user.getLevel()) + .profileUrl(presignedUrl) // Presigned URL 사용 + .createdAt(user.getCreatedAt()) + .updatedAt(user.getUpdatedAt()) + .build(); return ResponseGenerator.ok(user.getNickname() + " 환영합니다!", response); } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/model/User.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/model/User.java index 344d77e4..60dc6be5 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/model/User.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/model/User.java @@ -27,11 +27,13 @@ public class User { private String nickname; private String level; private String profileUrl; + private String profileUrlForResponse; private String createdAt; private String updatedAt; private String lastLoginAt; private Long ttl; - + + /** * 신규 사용자 생성 * - Lazy Registration 적용: 최초 프로필 조회 시 DynamoDB에 저장 @@ -114,7 +116,12 @@ public void updateProfileUrl(String newProfileUrl) { this.profileUrl = newProfileUrl; this.updatedAt = Instant.now().toString(); } - + + @DynamoDbIgnore + public String getProfileUrlForResponse() { + return profileUrlForResponse != null ? profileUrlForResponse : profileUrl; + } + public void updateLastLoginAt() { this.lastLoginAt = Instant.now().toString(); } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/service/UserService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/service/UserService.java index 2421f118..6783c42b 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/service/UserService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/service/UserService.java @@ -7,8 +7,10 @@ import com.mzc.secondproject.serverless.domain.user.repository.UserRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; import software.amazon.awssdk.services.s3.model.PutObjectRequest; import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; @@ -52,18 +54,53 @@ public UserService(UserRepository userRepository) { * @return User 객체 */ public User getProfile(String userId, APIGatewayProxyRequestEvent request) { - - return userRepository.findByCognitoSub(userId) - .map(user -> { - // 정상 DB에서 조회 완료 - user.updateLastLoginAt(); - userRepository.update(user); - return user; + + User user = userRepository.findByCognitoSub(userId) + .map(u -> { + u.updateLastLoginAt(); + userRepository.update(u); + return u; }) - .orElseGet(() -> { - // PostConfirmation 실패 대비 fallback - return createUserFromRequest(userId, request); - }); + .orElseGet(() -> createUserFromRequest(userId, request)); + + // 프로필 URL을 Presigned URL로 변환 + String presignedProfileUrl = getPresignedProfileUrl(user.getProfileUrl()); + user.setProfileUrlForResponse(presignedProfileUrl); // 응답용으로만 설정 + + return user; + } + + public String getPresignedProfileUrl(String s3Url) { + if (s3Url == null || s3Url.isEmpty()) { + return generateGetPresignedUrl("profile/default.png"); + } + String key = extractKeyFromS3Url(s3Url); + return generateGetPresignedUrl(key); + } + + private String generateGetPresignedUrl(String imageKey) { + GetObjectRequest getObjectRequest = GetObjectRequest.builder() + .bucket(BUCKET_NAME) + .key(imageKey) + .build(); + + GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() + .signatureDuration(Duration.ofHours(24)) + .getObjectRequest(getObjectRequest) + .build(); + + return s3Presigner.presignGetObject(presignRequest).url().toString(); + } + + + private String extractKeyFromS3Url(String s3Url) { + // https://group2-englishstudy.s3.amazonaws.com/profile/user123/img.png + // → profile/user123/img.png + String prefix = String.format("https://%s.s3.amazonaws.com/", BUCKET_NAME); + if (s3Url.startsWith(prefix)) { + return s3Url.substring(prefix.length()); + } + return s3Url; } /** diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 97fe03e6..0f64417e 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -2,25 +2,6 @@ AWSTemplateFormatVersion: '2010-09-09' Transform: AWS::Serverless-2016-10-31 Description: Group2 English Study - Unified API (Chatting + Vocabulary) -Parameters: - Environment: - Type: String - Default: dev - AllowedValues: - - dev - - test - - prod - Description: Deployment environment - - ExistingCognitoUserPoolId: - Type: String - Default: "" - Description: Existing Cognito User Pool ID (leave empty to create new) - - ExistingCognitoClientId: - Type: String - Description: Existing Cognito User Pool Client ID - Globals: Function: Timeout: 30 @@ -35,13 +16,11 @@ Globals: CHAT_TABLE_NAME: !Ref ChatTable VOCAB_TABLE_NAME: !Ref VocabTable OPIC_TABLE_NAME: !Ref OPIcTable - NEWS_TABLE_NAME: !Ref NewsTable - BUCKET_NAME: !Sub "${AWS::StackName}" - CHAT_BUCKET_NAME: !Sub "${AWS::StackName}" - VOCAB_BUCKET_NAME: !Sub "${AWS::StackName}" - PROFILE_BUCKET_NAME: !Sub "${AWS::StackName}" - OPIC_BUCKET_NAME: !Sub "${AWS::StackName}" - NEWS_BUCKET_NAME: !Sub "${AWS::StackName}" + BUCKET_NAME: group2-englishstudy + CHAT_BUCKET_NAME: group2-englishstudy + VOCAB_BUCKET_NAME: group2-englishstudy + PROFILE_BUCKET_NAME: group2-englishstudy + OPIC_BUCKET_NAME: group2-englishstudy AWS_REGION_NAME: !Ref AWS::Region ROOM_TOKEN_TTL_SECONDS: "300" TRANSCRIBE_PROXY_URL: "https://tfo1zm7vec.execute-api.ap-northeast-2.amazonaws.com/prod/transcribe" @@ -50,10 +29,65 @@ Globals: Resources: ############################################# - # Cognito - Using Existing User Pool - # (Cognito resources are managed in group2-englishstudy-chatting stack) + # Cognito User Pool ############################################# + CognitoUserPool: + Type: AWS::Cognito::UserPool + DeletionPolicy: Retain + # UpdateReplacePolicy: Retain + Properties: + UserPoolName: !Sub "${AWS::StackName}-userpool" + UsernameAttributes: + - email + AutoVerifiedAttributes: + - email + Policies: + PasswordPolicy: + MinimumLength: 8 + RequireLowercase: true + RequireNumbers: true + RequireSymbols: true + RequireUppercase: false + # Cognito에 저장할 사용자 정보 정의 ≈ 회원 테이블 컬럼 + Schema: + - Name: email + AttributeDataType: String + Required: true + Mutable: true + - Name: nickname + AttributeDataType: String + Required: false + Mutable: true + - Name: level + AttributeDataType: String + Required: false + Mutable: true + - Name: profileUrl + AttributeDataType: String + Required: false + Mutable: true + LambdaConfig: + PreSignUp: !GetAtt PreSignUpFunction.Arn + PostConfirmation: !GetAtt PostConfirmationFunction.Arn + + # Cognito에게 Lambda 호출 권한 부여 + PreSignUpPermission: + Type: AWS::Lambda::Permission + Properties: + Action: lambda:InvokeFunction + FunctionName: !Ref PreSignUpFunction + Principal: cognito-idp.amazonaws.com + SourceArn: !Sub arn:aws:cognito-idp:${AWS::Region}:${AWS::AccountId}:userpool/* + + PostConfirmationPermission: + Type: AWS::Lambda::Permission + Properties: + Action: lambda:InvokeFunction + FunctionName: !GetAtt PostConfirmationFunction.Arn + Principal: cognito-idp.amazonaws.com + SourceArn: !GetAtt CognitoUserPool.Arn + # 사용자 custom 속성들 기본값 설정 Lambda 함수 PreSignUpFunction: Type: AWS::Serverless::Function @@ -66,7 +100,7 @@ Resources: Timeout: 10 Environment: Variables: - DEFAULT_PROFILE_URL: !Sub "https://${AWS::StackName}.s3.amazonaws.com/profile/default.png" + DEFAULT_PROFILE_URL: https://group2-englishstudy.s3.amazonaws.com/profile/default.png # 회원가입 시점에 사용자 모든 정보가 DB에 저장 Lambda 함수 PostConfirmationFunction: @@ -84,6 +118,18 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref UserTable + CognitoUserPoolClient: + Type: AWS::Cognito::UserPoolClient + Properties: + ClientName: !Sub "${AWS::StackName}-client" + UserPoolId: !Ref CognitoUserPool + GenerateSecret: false + ExplicitAuthFlows: + - ALLOW_USER_SRP_AUTH + - ALLOW_REFRESH_TOKEN_AUTH + - ALLOW_USER_PASSWORD_AUTH + PreventUserExistenceErrors: ENABLED + ############################################# # API Gateway (Unified) ############################################# @@ -91,11 +137,11 @@ Resources: MainApi: Type: AWS::Serverless::Api Properties: - Name: !Sub "${AWS::StackName}-api" - StageName: !Ref Environment + Name: group2-englishstudy-api + StageName: dev Cors: - AllowMethods: "'GET,POST,PUT,PATCH,DELETE,OPTIONS'" - AllowHeaders: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key,X-Requested-With,Accept'" + AllowMethods: "'GET,POST,PUT,DELETE,OPTIONS'" + AllowHeaders: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" AllowOrigin: "'*'" AllowCredentials: false GatewayResponses: @@ -105,7 +151,7 @@ Resources: Headers: Access-Control-Allow-Origin: "'*'" Access-Control-Allow-Headers: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" - Access-Control-Allow-Methods: "'GET,POST,PUT,PATCH,DELETE,OPTIONS'" + Access-Control-Allow-Methods: "'GET,POST,PUT,DELETE,OPTIONS'" ResponseTemplates: application/json: '{"message": "Unauthorized", "statusCode": 401}' ACCESS_DENIED: @@ -114,7 +160,7 @@ Resources: Headers: Access-Control-Allow-Origin: "'*'" Access-Control-Allow-Headers: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" - Access-Control-Allow-Methods: "'GET,POST,PUT,PATCH,DELETE,OPTIONS'" + Access-Control-Allow-Methods: "'GET,POST,PUT,DELETE,OPTIONS'" ResponseTemplates: application/json: '{"message": "Access Denied", "statusCode": 403}' DEFAULT_4XX: @@ -122,28 +168,27 @@ Resources: Headers: Access-Control-Allow-Origin: "'*'" Access-Control-Allow-Headers: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" - Access-Control-Allow-Methods: "'GET,POST,PUT,PATCH,DELETE,OPTIONS'" + Access-Control-Allow-Methods: "'GET,POST,PUT,DELETE,OPTIONS'" DEFAULT_5XX: ResponseParameters: Headers: Access-Control-Allow-Origin: "'*'" Access-Control-Allow-Headers: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" - Access-Control-Allow-Methods: "'GET,POST,PUT,PATCH,DELETE,OPTIONS'" + Access-Control-Allow-Methods: "'GET,POST,PUT,DELETE,OPTIONS'" EXPIRED_TOKEN: StatusCode: 401 ResponseParameters: Headers: Access-Control-Allow-Origin: "'*'" Access-Control-Allow-Headers: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" - Access-Control-Allow-Methods: "'GET,POST,PUT,PATCH,DELETE,OPTIONS'" + Access-Control-Allow-Methods: "'GET,POST,PUT,DELETE,OPTIONS'" ResponseTemplates: application/json: '{"message": "Token expired", "statusCode": 401}' Auth: DefaultAuthorizer: CognitoAuthorizer - AddDefaultAuthorizerToCorsPreflight: false Authorizers: CognitoAuthorizer: - UserPoolArn: !Sub "arn:aws:cognito-idp:${AWS::Region}:${AWS::AccountId}:userpool/${ExistingCognitoUserPoolId}" + UserPoolArn: !GetAtt CognitoUserPool.Arn Identity: Header: Authorization @@ -154,7 +199,7 @@ Resources: WebSocketApi: Type: AWS::ApiGatewayV2::Api Properties: - Name: !Sub "${AWS::StackName}-websocket" + Name: group2-englishstudy-websocket ProtocolType: WEBSOCKET RouteSelectionExpression: "$request.body.action" @@ -162,7 +207,7 @@ Resources: Type: AWS::ApiGatewayV2::Stage Properties: ApiId: !Ref WebSocketApi - StageName: !Ref Environment + StageName: dev AutoDeploy: true # WebSocket Connect Route @@ -220,7 +265,7 @@ Resources: WebSocketConnectFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-ws-connect" + FunctionName: group2-englishstudy-ws-connect CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.websocket.WebSocketConnectHandler::handleRequest Description: Handle WebSocket $connect @@ -229,7 +274,7 @@ Resources: Environment: Variables: WEBSOCKET_CONNECTION_TTL_SECONDS: "600" - WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -250,7 +295,7 @@ Resources: WebSocketDisconnectFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-ws-disconnect" + FunctionName: group2-englishstudy-ws-disconnect CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.websocket.WebSocketDisconnectHandler::handleRequest Description: Handle WebSocket $disconnect @@ -258,7 +303,7 @@ Resources: ApplyOn: PublishedVersions Environment: Variables: - WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -279,7 +324,7 @@ Resources: WebSocketMessageFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-ws-message" + FunctionName: group2-englishstudy-ws-message CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.websocket.WebSocketMessageHandler::handleRequest Description: Handle WebSocket sendMessage @@ -287,7 +332,7 @@ Resources: ApplyOn: PublishedVersions Environment: Variables: - WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" GAME_AUTO_CLOSE_LAMBDA_ARN: !GetAtt GameAutoCloseFunction.Arn SCHEDULER_ROLE_ARN: !GetAtt GameSchedulerRole.Arn Policies: @@ -307,7 +352,7 @@ Resources: - scheduler:CreateSchedule - scheduler:DeleteSchedule - scheduler:GetSchedule - Resource: !Sub "arn:aws:scheduler:${AWS::Region}:${AWS::AccountId}:schedule/${AWS::StackName}-game-auto-close/*" + Resource: !Sub "arn:aws:scheduler:${AWS::Region}:${AWS::AccountId}:schedule/game-auto-close/*" - Statement: - Effect: Allow Action: @@ -339,7 +384,7 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref UserTable - S3CrudPolicy: - BucketName: !Sub "${AWS::StackName}" + BucketName: group2-englishstudy Events: GetMyProfile: Type: Api @@ -367,7 +412,7 @@ Resources: ChatRoomFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-chat-room-handler" + FunctionName: group2-englishstudy-chat-room-handler CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.ChatRoomHandler::handleRequest Description: Handle chat room CRUD operations @@ -375,7 +420,7 @@ Resources: ApplyOn: PublishedVersions Environment: Variables: - WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -439,7 +484,7 @@ Resources: GameFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-game-handler" + FunctionName: group2-englishstudy-game-handler CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.GameHandler::handleRequest Description: Handle catch-mind game operations @@ -447,7 +492,7 @@ Resources: ApplyOn: PublishedVersions Environment: Variables: - WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" GAME_AUTO_CLOSE_LAMBDA_ARN: !GetAtt GameAutoCloseFunction.Arn SCHEDULER_ROLE_ARN: !GetAtt GameSchedulerRole.Arn Policies: @@ -467,7 +512,7 @@ Resources: - scheduler:CreateSchedule - scheduler:DeleteSchedule - scheduler:GetSchedule - Resource: !Sub "arn:aws:scheduler:${AWS::Region}:${AWS::AccountId}:schedule/${AWS::StackName}-game-auto-close/*" + Resource: !Sub "arn:aws:scheduler:${AWS::Region}:${AWS::AccountId}:schedule/game-auto-close/*" - Statement: - Effect: Allow Action: @@ -511,7 +556,7 @@ Resources: GameAutoCloseFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-game-auto-close" + FunctionName: group2-englishstudy-game-auto-close CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.GameAutoCloseHandler::handleRequest Description: Auto-close game after 7 minutes @@ -521,7 +566,7 @@ Resources: ApplyOn: PublishedVersions Environment: Variables: - WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -557,12 +602,12 @@ Resources: GameScheduleGroup: Type: AWS::Scheduler::ScheduleGroup Properties: - Name: !Sub "${AWS::StackName}-game-auto-close" + Name: game-auto-close ChatMessageFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-chat-message-handler" + FunctionName: group2-englishstudy-chat-message-handler CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.ChatMessageHandler::handleRequest Description: Handle chat messages @@ -572,7 +617,7 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref ChatTable - S3CrudPolicy: - BucketName: !Sub "${AWS::StackName}" + BucketName: group2-englishstudy - Statement: - Effect: Allow Action: @@ -614,7 +659,7 @@ Resources: ChatVoiceFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-chat-voice-handler" + FunctionName: group2-englishstudy-chat-voice-handler CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.ChatVoiceHandler::handleRequest Description: Convert text to speech using Polly @@ -624,7 +669,7 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref ChatTable - S3CrudPolicy: - BucketName: !Sub "${AWS::StackName}" + BucketName: group2-englishstudy - Statement: - Effect: Allow Action: @@ -648,7 +693,7 @@ Resources: WordFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-vocab-word-handler" + FunctionName: group2-englishstudy-vocab-word-handler CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.WordHandler::handleRequest Description: Handle word CRUD operations @@ -726,7 +771,7 @@ Resources: UserWordFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-vocab-userword-handler" + FunctionName: group2-englishstudy-vocab-userword-handler CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.UserWordHandler::handleRequest Description: Handle user word learning status @@ -788,7 +833,7 @@ Resources: WordGroupFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-vocab-wordgroup-handler" + FunctionName: group2-englishstudy-vocab-wordgroup-handler CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.WordGroupHandler::handleRequest Description: Handle user custom word groups @@ -858,7 +903,7 @@ Resources: DailyStudyFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-vocab-daily-handler" + FunctionName: group2-englishstudy-vocab-daily-handler CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.DailyStudyHandler::handleRequest Description: Handle daily study word assignment @@ -888,7 +933,7 @@ Resources: TestFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-vocab-test-handler" + FunctionName: group2-englishstudy-vocab-test-handler CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.TestHandler::handleRequest Description: Handle vocabulary tests @@ -947,7 +992,7 @@ Resources: StatsFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-vocab-stats-handler" + FunctionName: group2-englishstudy-vocab-stats-handler CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.StatsHandler::handleRequest Description: Handle user learning statistics @@ -985,7 +1030,7 @@ Resources: VocabVoiceFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-vocab-voice-handler" + FunctionName: group2-englishstudy-vocab-voice-handler CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.VoiceHandler::handleRequest Description: Convert word to speech using Polly @@ -995,7 +1040,7 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref VocabTable - S3CrudPolicy: - BucketName: !Sub "${AWS::StackName}" + BucketName: group2-englishstudy - Statement: - Effect: Allow Action: @@ -1020,7 +1065,7 @@ Resources: StatsStreamFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-stats-stream-handler" + FunctionName: group2-englishstudy-stats-stream-handler CodeUri: . Handler: com.mzc.secondproject.serverless.domain.stats.handler.StatsStreamHandler::handleRequest Description: Process DynamoDB Streams for stats aggregation @@ -1045,7 +1090,7 @@ Resources: UserStatsFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-user-stats-handler" + FunctionName: group2-englishstudy-user-stats-handler CodeUri: . Handler: com.mzc.secondproject.serverless.domain.stats.handler.UserStatsHandler::handleRequest Description: Handle user learning statistics API @@ -1055,14 +1100,6 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref VocabTable Events: - GetDashboardStats: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /stats/dashboard - Method: GET - Auth: - Authorizer: CognitoAuthorizer GetDailyStats: Type: Api Properties: @@ -1108,7 +1145,7 @@ Resources: BadgeFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-badge-handler" + FunctionName: group2-englishstudy-badge-handler CodeUri: . Handler: com.mzc.secondproject.serverless.domain.badge.handler.BadgeHandler::handleRequest Description: Handle user badges and achievements @@ -1118,7 +1155,7 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref VocabTable - S3ReadPolicy: - BucketName: !Sub "${AWS::StackName}" + BucketName: group2-englishstudy Events: GetAllBadges: Type: Api @@ -1144,7 +1181,7 @@ Resources: GrammarFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-grammar-handler" + FunctionName: group2-englishstudy-grammar-handler CodeUri: . Handler: com.mzc.secondproject.serverless.domain.grammar.handler.GrammarHandler::handleRequest Description: Handle grammar check using Bedrock AI @@ -1223,7 +1260,7 @@ Resources: GrammarWebSocketApi: Type: AWS::ApiGatewayV2::Api Properties: - Name: !Sub "${AWS::StackName}-grammar-websocket" + Name: group2-englishstudy-grammar-websocket ProtocolType: WEBSOCKET RouteSelectionExpression: "$request.body.action" @@ -1231,7 +1268,7 @@ Resources: Type: AWS::ApiGatewayV2::Stage Properties: ApiId: !Ref GrammarWebSocketApi - StageName: !Ref Environment + StageName: dev AutoDeploy: true # Grammar WebSocket Connect Route @@ -1286,13 +1323,13 @@ Resources: GrammarStreamingConnectFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-grammar-ws-connect" + FunctionName: group2-grammar-ws-connect CodeUri: . Handler: com.mzc.secondproject.serverless.domain.grammar.handler.websocket.GrammarStreamingConnectHandler::handleRequest Description: Handle Grammar WebSocket $connect with JWT auth Environment: Variables: - WEBSOCKET_ENDPOINT: !Sub "https://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" + WEBSOCKET_ENDPOINT: !Sub "https://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -1313,13 +1350,13 @@ Resources: GrammarStreamingDisconnectFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-grammar-ws-disconnect" + FunctionName: group2-grammar-ws-disconnect CodeUri: . Handler: com.mzc.secondproject.serverless.domain.grammar.handler.websocket.GrammarStreamingDisconnectHandler::handleRequest Description: Handle Grammar WebSocket $disconnect Environment: Variables: - WEBSOCKET_ENDPOINT: !Sub "https://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" + WEBSOCKET_ENDPOINT: !Sub "https://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -1340,7 +1377,7 @@ Resources: GrammarStreamingFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-grammar-ws-streaming" + FunctionName: group2-grammar-ws-streaming CodeUri: . Handler: com.mzc.secondproject.serverless.domain.grammar.handler.websocket.GrammarStreamingHandler::handleRequest Description: Handle Grammar streaming with Bedrock @@ -1348,7 +1385,7 @@ Resources: MemorySize: 1024 Environment: Variables: - GRAMMAR_WEBSOCKET_ENDPOINT: !Sub "https://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" + GRAMMAR_WEBSOCKET_ENDPOINT: !Sub "https://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -1376,7 +1413,7 @@ Resources: ScheduledStatsFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-scheduled-stats" + FunctionName: group2-englishstudy-scheduled-stats CodeUri: . Handler: com.mzc.secondproject.serverless.domain.stats.handler.ScheduledStatsHandler::handleRequest Description: Daily scheduled job for word learning stats aggregation @@ -1392,7 +1429,7 @@ Resources: Type: Schedule Properties: Schedule: cron(0 15 * * ? *) # UTC 15:00 = KST 00:00 (자정) - Name: !Sub "${AWS::StackName}-daily-stats-aggregation" + Name: daily-stats-aggregation Description: Daily word learning stats aggregation Enabled: true @@ -1403,7 +1440,7 @@ Resources: SpeakingFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-speaking-handler" + FunctionName: group2-englishstudy-speaking-handler CodeUri: . Handler: com.mzc.secondproject.serverless.domain.speaking.handler.SpeakingHandler::handleRequest Description: Handle Speaking AI conversation (REST API) @@ -1413,13 +1450,12 @@ Resources: ApplyOn: PublishedVersions Environment: Variables: - SPEAKING_TABLE_NAME: !Ref SpeakingTable TRANSCRIBE_API_KEY: "/opic/transcribe-proxy-api-key" Policies: - DynamoDBCrudPolicy: - TableName: !Ref SpeakingTable + TableName: !Ref ChatTable - S3CrudPolicy: - BucketName: !Ref ContentBucket + BucketName: group2-englishstudy - Statement: - Effect: Allow Action: @@ -1460,7 +1496,7 @@ Resources: OPIcSessionFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-opic-session-handler" + FunctionName: group2-englishstudy-opic-session-handler CodeUri: . Handler: com.mzc.secondproject.serverless.domain.opic.handler.OPIcSessionHandler::handleRequest Description: Handle OPIc speaking practice sessions @@ -1475,7 +1511,7 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref OPIcTable - S3CrudPolicy: - BucketName: !Sub "${AWS::StackName}" + BucketName: group2-englishstudy - Statement: - Effect: Allow Action: @@ -1568,7 +1604,7 @@ Resources: DeletionPolicy: Retain # UpdateReplacePolicy: Retain Properties: - TableName: !Sub "${AWS::StackName}-user" + TableName: group2-englishstudy-user BillingMode: PAY_PER_REQUEST AttributeDefinitions: - AttributeName: PK @@ -1613,7 +1649,7 @@ Resources: ChatTable: Type: AWS::DynamoDB::Table Properties: - TableName: !Sub "${AWS::StackName}-chat" + TableName: group2-englishstudy-chat BillingMode: PAY_PER_REQUEST AttributeDefinitions: - AttributeName: PK @@ -1657,7 +1693,7 @@ Resources: VocabTable: Type: AWS::DynamoDB::Table Properties: - TableName: !Sub "${AWS::StackName}-vocab" + TableName: group2-englishstudy-vocab BillingMode: PAY_PER_REQUEST StreamSpecification: StreamViewType: NEW_IMAGE @@ -1715,255 +1751,7 @@ Resources: OPIcTable: Type: AWS::DynamoDB::Table Properties: - TableName: !Sub "${AWS::StackName}-opic" - BillingMode: PAY_PER_REQUEST - AttributeDefinitions: - - AttributeName: PK - AttributeType: S - - AttributeName: SK - AttributeType: S - - AttributeName: GSI1PK - AttributeType: S - - AttributeName: GSI1SK - AttributeType: S - KeySchema: - - AttributeName: PK - KeyType: HASH - - AttributeName: SK - KeyType: RANGE - GlobalSecondaryIndexes: - - IndexName: GSI1 - KeySchema: - - AttributeName: GSI1PK - KeyType: HASH - - AttributeName: GSI1SK - KeyType: RANGE - Projection: - ProjectionType: ALL - TimeToLiveSpecification: - AttributeName: ttl - Enabled: true - - SpeakingTable: - Type: AWS::DynamoDB::Table - Properties: - TableName: !Sub "${AWS::StackName}-speaking" - BillingMode: PAY_PER_REQUEST - AttributeDefinitions: - - AttributeName: PK - AttributeType: S - - AttributeName: SK - AttributeType: S - - AttributeName: GSI1PK - AttributeType: S - - AttributeName: GSI1SK - AttributeType: S - KeySchema: - - AttributeName: PK - KeyType: HASH - - AttributeName: SK - KeyType: RANGE - GlobalSecondaryIndexes: - - IndexName: GSI1 - KeySchema: - - AttributeName: GSI1PK - KeyType: HASH - - AttributeName: GSI1SK - KeyType: RANGE - Projection: - ProjectionType: ALL - TimeToLiveSpecification: - AttributeName: ttl - Enabled: true - - ############################################# - # News Collection Scheduled Lambda - ############################################# - - NewsCollectionFunction: - Type: AWS::Serverless::Function - Properties: - FunctionName: !Sub "${AWS::StackName}-news-collection" - CodeUri: . - Handler: com.mzc.secondproject.serverless.domain.news.handler.NewsCollectionHandler::handleRequest - Description: 매일 18시에 영어 뉴스를 수집하는 Lambda - MemorySize: 512 - Timeout: 300 - Policies: - - DynamoDBCrudPolicy: - TableName: !Ref NewsTable - - Statement: - - Effect: Allow - Action: - - bedrock:InvokeModel - Resource: "*" - - Statement: - - Effect: Allow - Action: - - comprehend:DetectKeyPhrases - Resource: "*" - Events: - DailySchedule: - Type: Schedule - Properties: - Schedule: cron(0 9 * * ? *) - Name: !Sub "${AWS::StackName}-news-collection-daily-schedule" - Description: 매일 18시 KST (09:00 UTC)에 뉴스 수집 - Enabled: true - - NewsFunction: - Type: AWS::Serverless::Function - Properties: - FunctionName: !Sub "${AWS::StackName}-news" - CodeUri: . - Handler: com.mzc.secondproject.serverless.domain.news.handler.NewsHandler::handleRequest - Description: 뉴스 학습 API - MemorySize: 256 - Timeout: 30 - Policies: - - DynamoDBCrudPolicy: - TableName: !Ref NewsTable - - DynamoDBCrudPolicy: - TableName: !Ref VocabTable - - S3CrudPolicy: - BucketName: !Sub "${AWS::StackName}" - - Statement: - - Effect: Allow - Action: - - polly:SynthesizeSpeech - Resource: "*" - Events: - GetNewsList: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /news - Method: GET - GetTodayNews: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /news/today - Method: GET - GetRecommendedNews: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /news/recommended - Method: GET - GetNewsStats: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /news/stats - Method: GET - Auth: - Authorizer: CognitoAuthorizer - GetBookmarks: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /news/bookmarks - Method: GET - Auth: - Authorizer: CognitoAuthorizer - GetUserWords: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /news/words - Method: GET - Auth: - Authorizer: CognitoAuthorizer - SyncWordToVocab: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /news/words/{word}/sync - Method: POST - Auth: - Authorizer: CognitoAuthorizer - CollectWord: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /news/{articleId}/words - Method: POST - Auth: - Authorizer: CognitoAuthorizer - DeleteWord: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /news/{articleId}/words/{word} - Method: DELETE - Auth: - Authorizer: CognitoAuthorizer - GetWordDetail: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /news/{articleId}/words/{word} - Method: GET - Auth: - Authorizer: CognitoAuthorizer - GetQuizHistory: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /news/quiz/history - Method: GET - Auth: - Authorizer: CognitoAuthorizer - GetQuiz: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /news/{articleId}/quiz - Method: GET - SubmitQuiz: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /news/{articleId}/quiz - Method: POST - Auth: - Authorizer: CognitoAuthorizer - MarkAsRead: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /news/{articleId}/read - Method: POST - Auth: - Authorizer: CognitoAuthorizer - ToggleBookmark: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /news/{articleId}/bookmark - Method: POST - Auth: - Authorizer: CognitoAuthorizer - GetAudio: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /news/{articleId}/audio - Method: GET - Auth: - Authorizer: CognitoAuthorizer - GetNewsDetail: - Type: Api - Properties: - RestApiId: !Ref MainApi - Path: /news/{articleId} - Method: GET - - NewsTable: - Type: AWS::DynamoDB::Table - Properties: - TableName: !Sub "${AWS::StackName}-news" + TableName: group2-englishstudy-opic BillingMode: PAY_PER_REQUEST AttributeDefinitions: - AttributeName: PK @@ -1974,10 +1762,6 @@ Resources: AttributeType: S - AttributeName: GSI1SK AttributeType: S - - AttributeName: GSI2PK - AttributeType: S - - AttributeName: GSI2SK - AttributeType: S KeySchema: - AttributeName: PK KeyType: HASH @@ -1992,45 +1776,10 @@ Resources: KeyType: RANGE Projection: ProjectionType: ALL - - IndexName: GSI2 - KeySchema: - - AttributeName: GSI2PK - KeyType: HASH - - AttributeName: GSI2SK - KeyType: RANGE - Projection: - ProjectionType: ALL TimeToLiveSpecification: AttributeName: ttl Enabled: true - ############################################# - # S3 Bucket for Content Storage - ############################################# - - ContentBucket: - Type: AWS::S3::Bucket - Properties: - BucketName: !Sub "${AWS::StackName}" - CorsConfiguration: - CorsRules: - - AllowedHeaders: - - "*" - AllowedMethods: - - GET - - PUT - - POST - - DELETE - - HEAD - AllowedOrigins: - - "*" - MaxAge: 3600 - PublicAccessBlockConfiguration: - BlockPublicAcls: false - BlockPublicPolicy: false - IgnorePublicAcls: false - RestrictPublicBuckets: false - ############################################# # SNS / SQS for Async Statistics Processing ############################################# @@ -2039,20 +1788,20 @@ Resources: TestResultTopic: Type: AWS::SNS::Topic Properties: - TopicName: !Sub "${AWS::StackName}-test-result-topic" + TopicName: group2-englishstudy-test-result-topic # SQS Dead Letter Queue - 실패한 메시지 보관 StatisticsDeadLetterQueue: Type: AWS::SQS::Queue Properties: - QueueName: !Sub "${AWS::StackName}-statistics-dlq" + QueueName: group2-englishstudy-statistics-dlq MessageRetentionPeriod: 1209600 # 14일 # SQS Queue - 통계 처리용 StatisticsQueue: Type: AWS::SQS::Queue Properties: - QueueName: !Sub "${AWS::StackName}-statistics-queue" + QueueName: group2-englishstudy-statistics-queue VisibilityTimeout: 60 RedrivePolicy: deadLetterTargetArn: !GetAtt StatisticsDeadLetterQueue.Arn @@ -2088,7 +1837,7 @@ Resources: StatisticsProcessorFunction: Type: AWS::Serverless::Function Properties: - FunctionName: !Sub "${AWS::StackName}-statistics-processor" + FunctionName: group2-englishstudy-statistics-processor CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.StatisticsHandler::handleRequest Description: Process test results and update user word statistics @@ -2114,15 +1863,15 @@ Resources: Outputs: ApiUrl: Description: Unified API Gateway endpoint URL - Value: !Sub 'https://${MainApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}/' + Value: !Sub 'https://${MainApi}.execute-api.${AWS::Region}.amazonaws.com/dev/' WebSocketUrl: Description: WebSocket API Gateway endpoint URL - Value: !Sub 'wss://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}' + Value: !Sub 'wss://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev' GrammarWebSocketUrl: Description: Grammar Streaming WebSocket API endpoint URL - Value: !Sub 'wss://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}' + Value: !Sub 'wss://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev' ChatTableName: Description: Chat DynamoDB Table Name @@ -2134,20 +1883,16 @@ Outputs: BucketName: Description: S3 Bucket Name - Value: !Ref ContentBucket + Value: group2-englishstudy CognitoUserPoolId: Description: Cognito User Pool ID - Value: !Ref ExistingCognitoUserPoolId + Value: !Ref CognitoUserPool CognitoUserPoolClientId: Description: Cognito User Pool Client ID - Value: !Ref ExistingCognitoClientId + Value: !Ref CognitoUserPoolClient OPIcTableName: Description: OPIc DynamoDB Table Name Value: !Ref OPIcTable - - SpeakingTableName: - Description: Speaking DynamoDB Table Name - Value: !Ref SpeakingTable From d3029a2badac036bf0b6dfe196c3b2be18bc1204 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 23 Jan 2026 14:46:19 +0900 Subject: [PATCH 495/528] feat: enhance bookmark and reading status tracking for News API - Add bookmark and reading status to individual article responses - Include bookmark status in paginated news responses - Modify `KeywordInfo` to support Korean translations (`meaningKo`) - Update AI prompts to extract Korean meanings for keywords --- .../domain/news/service/NewsWordService.java | 58 +- ServerlessFunction/template.yaml | 553 +++++++++++++----- 2 files changed, 457 insertions(+), 154 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsWordService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsWordService.java index 6881c7ec..7da961ce 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsWordService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsWordService.java @@ -59,7 +59,7 @@ public NewsWordService(NewsWordRepository newsWordRepository, } /** - * 단어 수집 + * 단어 수집 (자동으로 Word 테이블 + UserWord에 추가) * @return 수집 결과 (단어 정보 + 새로 획득한 배지) */ public WordCollectResult collectWord(String userId, String articleId, String word, String context) { @@ -73,12 +73,51 @@ public WordCollectResult collectWord(String userId, String articleId, String wor // 기사 조회 Optional articleOpt = articleRepository.findById(articleId); String articleTitle = articleOpt.map(NewsArticle::getTitle).orElse(""); + String articleLevel = articleOpt.map(NewsArticle::getLevel).orElse("INTERMEDIATE"); + + // 기사 키워드에서 단어 정보 추출 + String meaningKo = ""; + String meaningEn = ""; + String example = ""; + if (articleOpt.isPresent() && articleOpt.get().getKeywords() != null) { + for (var keyword : articleOpt.get().getKeywords()) { + if (keyword.getWord() != null && keyword.getWord().equalsIgnoreCase(word)) { + meaningKo = keyword.getMeaningKo() != null ? keyword.getMeaningKo() : ""; + meaningEn = keyword.getMeaning() != null ? keyword.getMeaning() : ""; + example = keyword.getExample() != null ? keyword.getExample() : ""; + break; + } + } + } // 단어 정보 조회 (Word 테이블에서) String wordId = word.toLowerCase().trim(); Optional wordOpt = wordRepository.findById(wordId); - String meaning = wordOpt.map(Word::getKorean).orElse(""); - String pronunciation = ""; + String meaning = meaningKo; + + // Word 테이블에 없으면 자동 생성 + if (wordOpt.isEmpty() && !meaningKo.isEmpty()) { + String now = Instant.now().toString(); + Word newWord = Word.builder() + .pk("WORD#" + wordId) + .sk("METADATA") + .gsi1pk("LEVEL#" + articleLevel) + .gsi1sk("WORD#" + wordId) + .gsi2pk("CATEGORY#NEWS") + .gsi2sk("WORD#" + wordId) + .wordId(wordId) + .english(word) + .korean(meaningKo) + .example(example) + .level(articleLevel) + .category("NEWS") + .createdAt(now) + .build(); + wordRepository.save(newWord); + logger.info("Word 테이블에 단어 자동 추가: wordId={}, korean={}", wordId, meaningKo); + } else if (wordOpt.isPresent()) { + meaning = wordOpt.get().getKorean(); + } String now = Instant.now().toString(); @@ -90,17 +129,26 @@ public WordCollectResult collectWord(String userId, String articleId, String wor .userId(userId) .word(word) .meaning(meaning) - .pronunciation(pronunciation) + .pronunciation("") .context(context) .articleId(articleId) .articleTitle(articleTitle) .collectedAt(now) - .syncedToVocab(false) + .syncedToVocab(true) // 자동 연동됨 + .vocabUserWordId(wordId) .build(); newsWordRepository.save(wordCollect); logger.info("단어 수집 완료: userId={}, word={}, articleId={}", userId, word, articleId); + // UserWord에 자동 추가 (NEW 상태로) + try { + userWordCommandService.updateWordStatus(userId, wordId, "NEW"); + logger.info("UserWord에 자동 추가: userId={}, wordId={}", userId, wordId); + } catch (Exception e) { + logger.warn("UserWord 추가 실패 (이미 존재할 수 있음): userId={}, wordId={}, error={}", userId, wordId, e.getMessage()); + } + // 통계 업데이트 및 배지 체크 List newBadges = new ArrayList<>(); try { diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 0f64417e..97fe03e6 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -2,6 +2,25 @@ AWSTemplateFormatVersion: '2010-09-09' Transform: AWS::Serverless-2016-10-31 Description: Group2 English Study - Unified API (Chatting + Vocabulary) +Parameters: + Environment: + Type: String + Default: dev + AllowedValues: + - dev + - test + - prod + Description: Deployment environment + + ExistingCognitoUserPoolId: + Type: String + Default: "" + Description: Existing Cognito User Pool ID (leave empty to create new) + + ExistingCognitoClientId: + Type: String + Description: Existing Cognito User Pool Client ID + Globals: Function: Timeout: 30 @@ -16,11 +35,13 @@ Globals: CHAT_TABLE_NAME: !Ref ChatTable VOCAB_TABLE_NAME: !Ref VocabTable OPIC_TABLE_NAME: !Ref OPIcTable - BUCKET_NAME: group2-englishstudy - CHAT_BUCKET_NAME: group2-englishstudy - VOCAB_BUCKET_NAME: group2-englishstudy - PROFILE_BUCKET_NAME: group2-englishstudy - OPIC_BUCKET_NAME: group2-englishstudy + NEWS_TABLE_NAME: !Ref NewsTable + BUCKET_NAME: !Sub "${AWS::StackName}" + CHAT_BUCKET_NAME: !Sub "${AWS::StackName}" + VOCAB_BUCKET_NAME: !Sub "${AWS::StackName}" + PROFILE_BUCKET_NAME: !Sub "${AWS::StackName}" + OPIC_BUCKET_NAME: !Sub "${AWS::StackName}" + NEWS_BUCKET_NAME: !Sub "${AWS::StackName}" AWS_REGION_NAME: !Ref AWS::Region ROOM_TOKEN_TTL_SECONDS: "300" TRANSCRIBE_PROXY_URL: "https://tfo1zm7vec.execute-api.ap-northeast-2.amazonaws.com/prod/transcribe" @@ -29,65 +50,10 @@ Globals: Resources: ############################################# - # Cognito User Pool + # Cognito - Using Existing User Pool + # (Cognito resources are managed in group2-englishstudy-chatting stack) ############################################# - CognitoUserPool: - Type: AWS::Cognito::UserPool - DeletionPolicy: Retain - # UpdateReplacePolicy: Retain - Properties: - UserPoolName: !Sub "${AWS::StackName}-userpool" - UsernameAttributes: - - email - AutoVerifiedAttributes: - - email - Policies: - PasswordPolicy: - MinimumLength: 8 - RequireLowercase: true - RequireNumbers: true - RequireSymbols: true - RequireUppercase: false - # Cognito에 저장할 사용자 정보 정의 ≈ 회원 테이블 컬럼 - Schema: - - Name: email - AttributeDataType: String - Required: true - Mutable: true - - Name: nickname - AttributeDataType: String - Required: false - Mutable: true - - Name: level - AttributeDataType: String - Required: false - Mutable: true - - Name: profileUrl - AttributeDataType: String - Required: false - Mutable: true - LambdaConfig: - PreSignUp: !GetAtt PreSignUpFunction.Arn - PostConfirmation: !GetAtt PostConfirmationFunction.Arn - - # Cognito에게 Lambda 호출 권한 부여 - PreSignUpPermission: - Type: AWS::Lambda::Permission - Properties: - Action: lambda:InvokeFunction - FunctionName: !Ref PreSignUpFunction - Principal: cognito-idp.amazonaws.com - SourceArn: !Sub arn:aws:cognito-idp:${AWS::Region}:${AWS::AccountId}:userpool/* - - PostConfirmationPermission: - Type: AWS::Lambda::Permission - Properties: - Action: lambda:InvokeFunction - FunctionName: !GetAtt PostConfirmationFunction.Arn - Principal: cognito-idp.amazonaws.com - SourceArn: !GetAtt CognitoUserPool.Arn - # 사용자 custom 속성들 기본값 설정 Lambda 함수 PreSignUpFunction: Type: AWS::Serverless::Function @@ -100,7 +66,7 @@ Resources: Timeout: 10 Environment: Variables: - DEFAULT_PROFILE_URL: https://group2-englishstudy.s3.amazonaws.com/profile/default.png + DEFAULT_PROFILE_URL: !Sub "https://${AWS::StackName}.s3.amazonaws.com/profile/default.png" # 회원가입 시점에 사용자 모든 정보가 DB에 저장 Lambda 함수 PostConfirmationFunction: @@ -118,18 +84,6 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref UserTable - CognitoUserPoolClient: - Type: AWS::Cognito::UserPoolClient - Properties: - ClientName: !Sub "${AWS::StackName}-client" - UserPoolId: !Ref CognitoUserPool - GenerateSecret: false - ExplicitAuthFlows: - - ALLOW_USER_SRP_AUTH - - ALLOW_REFRESH_TOKEN_AUTH - - ALLOW_USER_PASSWORD_AUTH - PreventUserExistenceErrors: ENABLED - ############################################# # API Gateway (Unified) ############################################# @@ -137,11 +91,11 @@ Resources: MainApi: Type: AWS::Serverless::Api Properties: - Name: group2-englishstudy-api - StageName: dev + Name: !Sub "${AWS::StackName}-api" + StageName: !Ref Environment Cors: - AllowMethods: "'GET,POST,PUT,DELETE,OPTIONS'" - AllowHeaders: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" + AllowMethods: "'GET,POST,PUT,PATCH,DELETE,OPTIONS'" + AllowHeaders: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key,X-Requested-With,Accept'" AllowOrigin: "'*'" AllowCredentials: false GatewayResponses: @@ -151,7 +105,7 @@ Resources: Headers: Access-Control-Allow-Origin: "'*'" Access-Control-Allow-Headers: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" - Access-Control-Allow-Methods: "'GET,POST,PUT,DELETE,OPTIONS'" + Access-Control-Allow-Methods: "'GET,POST,PUT,PATCH,DELETE,OPTIONS'" ResponseTemplates: application/json: '{"message": "Unauthorized", "statusCode": 401}' ACCESS_DENIED: @@ -160,7 +114,7 @@ Resources: Headers: Access-Control-Allow-Origin: "'*'" Access-Control-Allow-Headers: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" - Access-Control-Allow-Methods: "'GET,POST,PUT,DELETE,OPTIONS'" + Access-Control-Allow-Methods: "'GET,POST,PUT,PATCH,DELETE,OPTIONS'" ResponseTemplates: application/json: '{"message": "Access Denied", "statusCode": 403}' DEFAULT_4XX: @@ -168,27 +122,28 @@ Resources: Headers: Access-Control-Allow-Origin: "'*'" Access-Control-Allow-Headers: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" - Access-Control-Allow-Methods: "'GET,POST,PUT,DELETE,OPTIONS'" + Access-Control-Allow-Methods: "'GET,POST,PUT,PATCH,DELETE,OPTIONS'" DEFAULT_5XX: ResponseParameters: Headers: Access-Control-Allow-Origin: "'*'" Access-Control-Allow-Headers: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" - Access-Control-Allow-Methods: "'GET,POST,PUT,DELETE,OPTIONS'" + Access-Control-Allow-Methods: "'GET,POST,PUT,PATCH,DELETE,OPTIONS'" EXPIRED_TOKEN: StatusCode: 401 ResponseParameters: Headers: Access-Control-Allow-Origin: "'*'" Access-Control-Allow-Headers: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" - Access-Control-Allow-Methods: "'GET,POST,PUT,DELETE,OPTIONS'" + Access-Control-Allow-Methods: "'GET,POST,PUT,PATCH,DELETE,OPTIONS'" ResponseTemplates: application/json: '{"message": "Token expired", "statusCode": 401}' Auth: DefaultAuthorizer: CognitoAuthorizer + AddDefaultAuthorizerToCorsPreflight: false Authorizers: CognitoAuthorizer: - UserPoolArn: !GetAtt CognitoUserPool.Arn + UserPoolArn: !Sub "arn:aws:cognito-idp:${AWS::Region}:${AWS::AccountId}:userpool/${ExistingCognitoUserPoolId}" Identity: Header: Authorization @@ -199,7 +154,7 @@ Resources: WebSocketApi: Type: AWS::ApiGatewayV2::Api Properties: - Name: group2-englishstudy-websocket + Name: !Sub "${AWS::StackName}-websocket" ProtocolType: WEBSOCKET RouteSelectionExpression: "$request.body.action" @@ -207,7 +162,7 @@ Resources: Type: AWS::ApiGatewayV2::Stage Properties: ApiId: !Ref WebSocketApi - StageName: dev + StageName: !Ref Environment AutoDeploy: true # WebSocket Connect Route @@ -265,7 +220,7 @@ Resources: WebSocketConnectFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-ws-connect + FunctionName: !Sub "${AWS::StackName}-ws-connect" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.websocket.WebSocketConnectHandler::handleRequest Description: Handle WebSocket $connect @@ -274,7 +229,7 @@ Resources: Environment: Variables: WEBSOCKET_CONNECTION_TTL_SECONDS: "600" - WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -295,7 +250,7 @@ Resources: WebSocketDisconnectFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-ws-disconnect + FunctionName: !Sub "${AWS::StackName}-ws-disconnect" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.websocket.WebSocketDisconnectHandler::handleRequest Description: Handle WebSocket $disconnect @@ -303,7 +258,7 @@ Resources: ApplyOn: PublishedVersions Environment: Variables: - WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -324,7 +279,7 @@ Resources: WebSocketMessageFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-ws-message + FunctionName: !Sub "${AWS::StackName}-ws-message" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.websocket.WebSocketMessageHandler::handleRequest Description: Handle WebSocket sendMessage @@ -332,7 +287,7 @@ Resources: ApplyOn: PublishedVersions Environment: Variables: - WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" GAME_AUTO_CLOSE_LAMBDA_ARN: !GetAtt GameAutoCloseFunction.Arn SCHEDULER_ROLE_ARN: !GetAtt GameSchedulerRole.Arn Policies: @@ -352,7 +307,7 @@ Resources: - scheduler:CreateSchedule - scheduler:DeleteSchedule - scheduler:GetSchedule - Resource: !Sub "arn:aws:scheduler:${AWS::Region}:${AWS::AccountId}:schedule/game-auto-close/*" + Resource: !Sub "arn:aws:scheduler:${AWS::Region}:${AWS::AccountId}:schedule/${AWS::StackName}-game-auto-close/*" - Statement: - Effect: Allow Action: @@ -384,7 +339,7 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref UserTable - S3CrudPolicy: - BucketName: group2-englishstudy + BucketName: !Sub "${AWS::StackName}" Events: GetMyProfile: Type: Api @@ -412,7 +367,7 @@ Resources: ChatRoomFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-chat-room-handler + FunctionName: !Sub "${AWS::StackName}-chat-room-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.ChatRoomHandler::handleRequest Description: Handle chat room CRUD operations @@ -420,7 +375,7 @@ Resources: ApplyOn: PublishedVersions Environment: Variables: - WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -484,7 +439,7 @@ Resources: GameFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-game-handler + FunctionName: !Sub "${AWS::StackName}-game-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.GameHandler::handleRequest Description: Handle catch-mind game operations @@ -492,7 +447,7 @@ Resources: ApplyOn: PublishedVersions Environment: Variables: - WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" GAME_AUTO_CLOSE_LAMBDA_ARN: !GetAtt GameAutoCloseFunction.Arn SCHEDULER_ROLE_ARN: !GetAtt GameSchedulerRole.Arn Policies: @@ -512,7 +467,7 @@ Resources: - scheduler:CreateSchedule - scheduler:DeleteSchedule - scheduler:GetSchedule - Resource: !Sub "arn:aws:scheduler:${AWS::Region}:${AWS::AccountId}:schedule/game-auto-close/*" + Resource: !Sub "arn:aws:scheduler:${AWS::Region}:${AWS::AccountId}:schedule/${AWS::StackName}-game-auto-close/*" - Statement: - Effect: Allow Action: @@ -556,7 +511,7 @@ Resources: GameAutoCloseFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-game-auto-close + FunctionName: !Sub "${AWS::StackName}-game-auto-close" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.GameAutoCloseHandler::handleRequest Description: Auto-close game after 7 minutes @@ -566,7 +521,7 @@ Resources: ApplyOn: PublishedVersions Environment: Variables: - WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -602,12 +557,12 @@ Resources: GameScheduleGroup: Type: AWS::Scheduler::ScheduleGroup Properties: - Name: game-auto-close + Name: !Sub "${AWS::StackName}-game-auto-close" ChatMessageFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-chat-message-handler + FunctionName: !Sub "${AWS::StackName}-chat-message-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.ChatMessageHandler::handleRequest Description: Handle chat messages @@ -617,7 +572,7 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref ChatTable - S3CrudPolicy: - BucketName: group2-englishstudy + BucketName: !Sub "${AWS::StackName}" - Statement: - Effect: Allow Action: @@ -659,7 +614,7 @@ Resources: ChatVoiceFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-chat-voice-handler + FunctionName: !Sub "${AWS::StackName}-chat-voice-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.chatting.handler.ChatVoiceHandler::handleRequest Description: Convert text to speech using Polly @@ -669,7 +624,7 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref ChatTable - S3CrudPolicy: - BucketName: group2-englishstudy + BucketName: !Sub "${AWS::StackName}" - Statement: - Effect: Allow Action: @@ -693,7 +648,7 @@ Resources: WordFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-vocab-word-handler + FunctionName: !Sub "${AWS::StackName}-vocab-word-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.WordHandler::handleRequest Description: Handle word CRUD operations @@ -771,7 +726,7 @@ Resources: UserWordFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-vocab-userword-handler + FunctionName: !Sub "${AWS::StackName}-vocab-userword-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.UserWordHandler::handleRequest Description: Handle user word learning status @@ -833,7 +788,7 @@ Resources: WordGroupFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-vocab-wordgroup-handler + FunctionName: !Sub "${AWS::StackName}-vocab-wordgroup-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.WordGroupHandler::handleRequest Description: Handle user custom word groups @@ -903,7 +858,7 @@ Resources: DailyStudyFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-vocab-daily-handler + FunctionName: !Sub "${AWS::StackName}-vocab-daily-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.DailyStudyHandler::handleRequest Description: Handle daily study word assignment @@ -933,7 +888,7 @@ Resources: TestFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-vocab-test-handler + FunctionName: !Sub "${AWS::StackName}-vocab-test-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.TestHandler::handleRequest Description: Handle vocabulary tests @@ -992,7 +947,7 @@ Resources: StatsFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-vocab-stats-handler + FunctionName: !Sub "${AWS::StackName}-vocab-stats-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.StatsHandler::handleRequest Description: Handle user learning statistics @@ -1030,7 +985,7 @@ Resources: VocabVoiceFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-vocab-voice-handler + FunctionName: !Sub "${AWS::StackName}-vocab-voice-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.VoiceHandler::handleRequest Description: Convert word to speech using Polly @@ -1040,7 +995,7 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref VocabTable - S3CrudPolicy: - BucketName: group2-englishstudy + BucketName: !Sub "${AWS::StackName}" - Statement: - Effect: Allow Action: @@ -1065,7 +1020,7 @@ Resources: StatsStreamFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-stats-stream-handler + FunctionName: !Sub "${AWS::StackName}-stats-stream-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.stats.handler.StatsStreamHandler::handleRequest Description: Process DynamoDB Streams for stats aggregation @@ -1090,7 +1045,7 @@ Resources: UserStatsFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-user-stats-handler + FunctionName: !Sub "${AWS::StackName}-user-stats-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.stats.handler.UserStatsHandler::handleRequest Description: Handle user learning statistics API @@ -1100,6 +1055,14 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref VocabTable Events: + GetDashboardStats: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /stats/dashboard + Method: GET + Auth: + Authorizer: CognitoAuthorizer GetDailyStats: Type: Api Properties: @@ -1145,7 +1108,7 @@ Resources: BadgeFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-badge-handler + FunctionName: !Sub "${AWS::StackName}-badge-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.badge.handler.BadgeHandler::handleRequest Description: Handle user badges and achievements @@ -1155,7 +1118,7 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref VocabTable - S3ReadPolicy: - BucketName: group2-englishstudy + BucketName: !Sub "${AWS::StackName}" Events: GetAllBadges: Type: Api @@ -1181,7 +1144,7 @@ Resources: GrammarFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-grammar-handler + FunctionName: !Sub "${AWS::StackName}-grammar-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.grammar.handler.GrammarHandler::handleRequest Description: Handle grammar check using Bedrock AI @@ -1260,7 +1223,7 @@ Resources: GrammarWebSocketApi: Type: AWS::ApiGatewayV2::Api Properties: - Name: group2-englishstudy-grammar-websocket + Name: !Sub "${AWS::StackName}-grammar-websocket" ProtocolType: WEBSOCKET RouteSelectionExpression: "$request.body.action" @@ -1268,7 +1231,7 @@ Resources: Type: AWS::ApiGatewayV2::Stage Properties: ApiId: !Ref GrammarWebSocketApi - StageName: dev + StageName: !Ref Environment AutoDeploy: true # Grammar WebSocket Connect Route @@ -1323,13 +1286,13 @@ Resources: GrammarStreamingConnectFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-grammar-ws-connect + FunctionName: !Sub "${AWS::StackName}-grammar-ws-connect" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.grammar.handler.websocket.GrammarStreamingConnectHandler::handleRequest Description: Handle Grammar WebSocket $connect with JWT auth Environment: Variables: - WEBSOCKET_ENDPOINT: !Sub "https://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" + WEBSOCKET_ENDPOINT: !Sub "https://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -1350,13 +1313,13 @@ Resources: GrammarStreamingDisconnectFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-grammar-ws-disconnect + FunctionName: !Sub "${AWS::StackName}-grammar-ws-disconnect" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.grammar.handler.websocket.GrammarStreamingDisconnectHandler::handleRequest Description: Handle Grammar WebSocket $disconnect Environment: Variables: - WEBSOCKET_ENDPOINT: !Sub "https://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" + WEBSOCKET_ENDPOINT: !Sub "https://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -1377,7 +1340,7 @@ Resources: GrammarStreamingFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-grammar-ws-streaming + FunctionName: !Sub "${AWS::StackName}-grammar-ws-streaming" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.grammar.handler.websocket.GrammarStreamingHandler::handleRequest Description: Handle Grammar streaming with Bedrock @@ -1385,7 +1348,7 @@ Resources: MemorySize: 1024 Environment: Variables: - GRAMMAR_WEBSOCKET_ENDPOINT: !Sub "https://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" + GRAMMAR_WEBSOCKET_ENDPOINT: !Sub "https://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -1413,7 +1376,7 @@ Resources: ScheduledStatsFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-scheduled-stats + FunctionName: !Sub "${AWS::StackName}-scheduled-stats" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.stats.handler.ScheduledStatsHandler::handleRequest Description: Daily scheduled job for word learning stats aggregation @@ -1429,7 +1392,7 @@ Resources: Type: Schedule Properties: Schedule: cron(0 15 * * ? *) # UTC 15:00 = KST 00:00 (자정) - Name: daily-stats-aggregation + Name: !Sub "${AWS::StackName}-daily-stats-aggregation" Description: Daily word learning stats aggregation Enabled: true @@ -1440,7 +1403,7 @@ Resources: SpeakingFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-speaking-handler + FunctionName: !Sub "${AWS::StackName}-speaking-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.speaking.handler.SpeakingHandler::handleRequest Description: Handle Speaking AI conversation (REST API) @@ -1450,12 +1413,13 @@ Resources: ApplyOn: PublishedVersions Environment: Variables: + SPEAKING_TABLE_NAME: !Ref SpeakingTable TRANSCRIBE_API_KEY: "/opic/transcribe-proxy-api-key" Policies: - DynamoDBCrudPolicy: - TableName: !Ref ChatTable + TableName: !Ref SpeakingTable - S3CrudPolicy: - BucketName: group2-englishstudy + BucketName: !Ref ContentBucket - Statement: - Effect: Allow Action: @@ -1496,7 +1460,7 @@ Resources: OPIcSessionFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-opic-session-handler + FunctionName: !Sub "${AWS::StackName}-opic-session-handler" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.opic.handler.OPIcSessionHandler::handleRequest Description: Handle OPIc speaking practice sessions @@ -1511,7 +1475,7 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref OPIcTable - S3CrudPolicy: - BucketName: group2-englishstudy + BucketName: !Sub "${AWS::StackName}" - Statement: - Effect: Allow Action: @@ -1604,7 +1568,7 @@ Resources: DeletionPolicy: Retain # UpdateReplacePolicy: Retain Properties: - TableName: group2-englishstudy-user + TableName: !Sub "${AWS::StackName}-user" BillingMode: PAY_PER_REQUEST AttributeDefinitions: - AttributeName: PK @@ -1649,7 +1613,7 @@ Resources: ChatTable: Type: AWS::DynamoDB::Table Properties: - TableName: group2-englishstudy-chat + TableName: !Sub "${AWS::StackName}-chat" BillingMode: PAY_PER_REQUEST AttributeDefinitions: - AttributeName: PK @@ -1693,7 +1657,7 @@ Resources: VocabTable: Type: AWS::DynamoDB::Table Properties: - TableName: group2-englishstudy-vocab + TableName: !Sub "${AWS::StackName}-vocab" BillingMode: PAY_PER_REQUEST StreamSpecification: StreamViewType: NEW_IMAGE @@ -1751,7 +1715,255 @@ Resources: OPIcTable: Type: AWS::DynamoDB::Table Properties: - TableName: group2-englishstudy-opic + TableName: !Sub "${AWS::StackName}-opic" + BillingMode: PAY_PER_REQUEST + AttributeDefinitions: + - AttributeName: PK + AttributeType: S + - AttributeName: SK + AttributeType: S + - AttributeName: GSI1PK + AttributeType: S + - AttributeName: GSI1SK + AttributeType: S + KeySchema: + - AttributeName: PK + KeyType: HASH + - AttributeName: SK + KeyType: RANGE + GlobalSecondaryIndexes: + - IndexName: GSI1 + KeySchema: + - AttributeName: GSI1PK + KeyType: HASH + - AttributeName: GSI1SK + KeyType: RANGE + Projection: + ProjectionType: ALL + TimeToLiveSpecification: + AttributeName: ttl + Enabled: true + + SpeakingTable: + Type: AWS::DynamoDB::Table + Properties: + TableName: !Sub "${AWS::StackName}-speaking" + BillingMode: PAY_PER_REQUEST + AttributeDefinitions: + - AttributeName: PK + AttributeType: S + - AttributeName: SK + AttributeType: S + - AttributeName: GSI1PK + AttributeType: S + - AttributeName: GSI1SK + AttributeType: S + KeySchema: + - AttributeName: PK + KeyType: HASH + - AttributeName: SK + KeyType: RANGE + GlobalSecondaryIndexes: + - IndexName: GSI1 + KeySchema: + - AttributeName: GSI1PK + KeyType: HASH + - AttributeName: GSI1SK + KeyType: RANGE + Projection: + ProjectionType: ALL + TimeToLiveSpecification: + AttributeName: ttl + Enabled: true + + ############################################# + # News Collection Scheduled Lambda + ############################################# + + NewsCollectionFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: !Sub "${AWS::StackName}-news-collection" + CodeUri: . + Handler: com.mzc.secondproject.serverless.domain.news.handler.NewsCollectionHandler::handleRequest + Description: 매일 18시에 영어 뉴스를 수집하는 Lambda + MemorySize: 512 + Timeout: 300 + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref NewsTable + - Statement: + - Effect: Allow + Action: + - bedrock:InvokeModel + Resource: "*" + - Statement: + - Effect: Allow + Action: + - comprehend:DetectKeyPhrases + Resource: "*" + Events: + DailySchedule: + Type: Schedule + Properties: + Schedule: cron(0 9 * * ? *) + Name: !Sub "${AWS::StackName}-news-collection-daily-schedule" + Description: 매일 18시 KST (09:00 UTC)에 뉴스 수집 + Enabled: true + + NewsFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: !Sub "${AWS::StackName}-news" + CodeUri: . + Handler: com.mzc.secondproject.serverless.domain.news.handler.NewsHandler::handleRequest + Description: 뉴스 학습 API + MemorySize: 256 + Timeout: 30 + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref NewsTable + - DynamoDBCrudPolicy: + TableName: !Ref VocabTable + - S3CrudPolicy: + BucketName: !Sub "${AWS::StackName}" + - Statement: + - Effect: Allow + Action: + - polly:SynthesizeSpeech + Resource: "*" + Events: + GetNewsList: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news + Method: GET + GetTodayNews: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/today + Method: GET + GetRecommendedNews: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/recommended + Method: GET + GetNewsStats: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/stats + Method: GET + Auth: + Authorizer: CognitoAuthorizer + GetBookmarks: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/bookmarks + Method: GET + Auth: + Authorizer: CognitoAuthorizer + GetUserWords: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/words + Method: GET + Auth: + Authorizer: CognitoAuthorizer + SyncWordToVocab: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/words/{word}/sync + Method: POST + Auth: + Authorizer: CognitoAuthorizer + CollectWord: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/{articleId}/words + Method: POST + Auth: + Authorizer: CognitoAuthorizer + DeleteWord: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/{articleId}/words/{word} + Method: DELETE + Auth: + Authorizer: CognitoAuthorizer + GetWordDetail: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/{articleId}/words/{word} + Method: GET + Auth: + Authorizer: CognitoAuthorizer + GetQuizHistory: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/quiz/history + Method: GET + Auth: + Authorizer: CognitoAuthorizer + GetQuiz: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/{articleId}/quiz + Method: GET + SubmitQuiz: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/{articleId}/quiz + Method: POST + Auth: + Authorizer: CognitoAuthorizer + MarkAsRead: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/{articleId}/read + Method: POST + Auth: + Authorizer: CognitoAuthorizer + ToggleBookmark: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/{articleId}/bookmark + Method: POST + Auth: + Authorizer: CognitoAuthorizer + GetAudio: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/{articleId}/audio + Method: GET + Auth: + Authorizer: CognitoAuthorizer + GetNewsDetail: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /news/{articleId} + Method: GET + + NewsTable: + Type: AWS::DynamoDB::Table + Properties: + TableName: !Sub "${AWS::StackName}-news" BillingMode: PAY_PER_REQUEST AttributeDefinitions: - AttributeName: PK @@ -1762,6 +1974,10 @@ Resources: AttributeType: S - AttributeName: GSI1SK AttributeType: S + - AttributeName: GSI2PK + AttributeType: S + - AttributeName: GSI2SK + AttributeType: S KeySchema: - AttributeName: PK KeyType: HASH @@ -1776,10 +1992,45 @@ Resources: KeyType: RANGE Projection: ProjectionType: ALL + - IndexName: GSI2 + KeySchema: + - AttributeName: GSI2PK + KeyType: HASH + - AttributeName: GSI2SK + KeyType: RANGE + Projection: + ProjectionType: ALL TimeToLiveSpecification: AttributeName: ttl Enabled: true + ############################################# + # S3 Bucket for Content Storage + ############################################# + + ContentBucket: + Type: AWS::S3::Bucket + Properties: + BucketName: !Sub "${AWS::StackName}" + CorsConfiguration: + CorsRules: + - AllowedHeaders: + - "*" + AllowedMethods: + - GET + - PUT + - POST + - DELETE + - HEAD + AllowedOrigins: + - "*" + MaxAge: 3600 + PublicAccessBlockConfiguration: + BlockPublicAcls: false + BlockPublicPolicy: false + IgnorePublicAcls: false + RestrictPublicBuckets: false + ############################################# # SNS / SQS for Async Statistics Processing ############################################# @@ -1788,20 +2039,20 @@ Resources: TestResultTopic: Type: AWS::SNS::Topic Properties: - TopicName: group2-englishstudy-test-result-topic + TopicName: !Sub "${AWS::StackName}-test-result-topic" # SQS Dead Letter Queue - 실패한 메시지 보관 StatisticsDeadLetterQueue: Type: AWS::SQS::Queue Properties: - QueueName: group2-englishstudy-statistics-dlq + QueueName: !Sub "${AWS::StackName}-statistics-dlq" MessageRetentionPeriod: 1209600 # 14일 # SQS Queue - 통계 처리용 StatisticsQueue: Type: AWS::SQS::Queue Properties: - QueueName: group2-englishstudy-statistics-queue + QueueName: !Sub "${AWS::StackName}-statistics-queue" VisibilityTimeout: 60 RedrivePolicy: deadLetterTargetArn: !GetAtt StatisticsDeadLetterQueue.Arn @@ -1837,7 +2088,7 @@ Resources: StatisticsProcessorFunction: Type: AWS::Serverless::Function Properties: - FunctionName: group2-englishstudy-statistics-processor + FunctionName: !Sub "${AWS::StackName}-statistics-processor" CodeUri: . Handler: com.mzc.secondproject.serverless.domain.vocabulary.handler.StatisticsHandler::handleRequest Description: Process test results and update user word statistics @@ -1863,15 +2114,15 @@ Resources: Outputs: ApiUrl: Description: Unified API Gateway endpoint URL - Value: !Sub 'https://${MainApi}.execute-api.${AWS::Region}.amazonaws.com/dev/' + Value: !Sub 'https://${MainApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}/' WebSocketUrl: Description: WebSocket API Gateway endpoint URL - Value: !Sub 'wss://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev' + Value: !Sub 'wss://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}' GrammarWebSocketUrl: Description: Grammar Streaming WebSocket API endpoint URL - Value: !Sub 'wss://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev' + Value: !Sub 'wss://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}' ChatTableName: Description: Chat DynamoDB Table Name @@ -1883,16 +2134,20 @@ Outputs: BucketName: Description: S3 Bucket Name - Value: group2-englishstudy + Value: !Ref ContentBucket CognitoUserPoolId: Description: Cognito User Pool ID - Value: !Ref CognitoUserPool + Value: !Ref ExistingCognitoUserPoolId CognitoUserPoolClientId: Description: Cognito User Pool Client ID - Value: !Ref CognitoUserPoolClient + Value: !Ref ExistingCognitoClientId OPIcTableName: Description: OPIc DynamoDB Table Name Value: !Ref OPIcTable + + SpeakingTableName: + Description: Speaking DynamoDB Table Name + Value: !Ref SpeakingTable From 63669c76b6f63608eb640dc33c68b098f50f45d6 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 23 Jan 2026 14:53:44 +0900 Subject: [PATCH 496/528] feat: add category filtering to UserWord API with enhanced query logic - Introduce `category` filtering to `getUserWords` API - Update WordCategory enums to include "news" category - Apply category filter after enrichment with word info - Adjust query limits for bookmarked and incorrect words queries --- .../domain/vocabulary/enums/WordCategory.java | 3 +- .../vocabulary/handler/UserWordHandler.java | 9 +++--- .../service/UserWordQueryService.java | 29 ++++++++++++++----- 3 files changed, 28 insertions(+), 13 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/enums/WordCategory.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/enums/WordCategory.java index 9a65b41a..aafe1eac 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/enums/WordCategory.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/enums/WordCategory.java @@ -7,7 +7,8 @@ public enum WordCategory { BUSINESS("business", "비즈니스"), ACADEMIC("academic", "학술"), TRAVEL("travel", "여행"), - TECHNOLOGY("technology", "기술"); + TECHNOLOGY("technology", "기술"), + NEWS("news", "뉴스"); private final String code; private final String displayName; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java index 83047fa4..8cb93c80 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java @@ -66,18 +66,19 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent re private APIGatewayProxyResponseEvent getUserWords(APIGatewayProxyRequestEvent request, String userId) { Map queryParams = request.getQueryStringParameters(); - + String status = queryParams != null ? queryParams.get("status") : null; String cursor = queryParams != null ? queryParams.get("cursor") : null; String bookmarked = queryParams != null ? queryParams.get("bookmarked") : null; String incorrectOnly = queryParams != null ? queryParams.get("incorrectOnly") : null; - + String category = queryParams != null ? queryParams.get("category") : null; + int limit = 20; if (queryParams != null && queryParams.get("limit") != null) { limit = Math.min(Integer.parseInt(queryParams.get("limit")), 50); } - - UserWordQueryService.UserWordsResult result = queryService.getUserWords(userId, status, bookmarked, incorrectOnly, limit, cursor); + + UserWordQueryService.UserWordsResult result = queryService.getUserWords(userId, status, bookmarked, incorrectOnly, category, limit, cursor); Map response = new HashMap<>(); response.put("userWords", result.userWords()); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordQueryService.java index bf6920e5..6b862566 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordQueryService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordQueryService.java @@ -37,21 +37,34 @@ public UserWordQueryService(UserWordRepository userWordRepository, WordRepositor } public UserWordsResult getUserWords(String userId, String status, String bookmarked, - String incorrectOnly, int limit, String cursor) { + String incorrectOnly, String category, int limit, String cursor) { PaginatedResult userWordPage; - + if ("true".equalsIgnoreCase(bookmarked)) { - userWordPage = userWordRepository.findBookmarkedWords(userId, limit, cursor); + userWordPage = userWordRepository.findBookmarkedWords(userId, limit * 3, cursor); } else if ("true".equalsIgnoreCase(incorrectOnly)) { - userWordPage = userWordRepository.findIncorrectWords(userId, limit, cursor); + userWordPage = userWordRepository.findIncorrectWords(userId, limit * 3, cursor); } else if (status != null && !status.isEmpty()) { - userWordPage = userWordRepository.findByUserIdAndStatus(userId, status, limit, cursor); + userWordPage = userWordRepository.findByUserIdAndStatus(userId, status, limit * 3, cursor); } else { - userWordPage = userWordRepository.findByUserIdWithPagination(userId, limit, cursor); + userWordPage = userWordRepository.findByUserIdWithPagination(userId, limit * 3, cursor); } - + List> enrichedUserWords = enrichWithWordInfo(userWordPage.items()); - + + // 카테고리 필터링 (Word 테이블 조인 후 필터) + if (category != null && !category.isEmpty()) { + String upperCategory = category.toUpperCase(); + enrichedUserWords = enrichedUserWords.stream() + .filter(w -> upperCategory.equals(w.get("category"))) + .limit(limit) + .collect(Collectors.toList()); + } else { + enrichedUserWords = enrichedUserWords.stream() + .limit(limit) + .collect(Collectors.toList()); + } + return new UserWordsResult(enrichedUserWords, userWordPage.nextCursor(), userWordPage.hasMore()); } From 36cee08379dbab3457e2ba5270474c925ef328dc Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 23 Jan 2026 15:10:30 +0900 Subject: [PATCH 497/528] feat: add default value for ExistingCognitoClientId in template.yaml - Set default to an empty string to ensure compatibility with templates using this parameter without explicitly specifying a value. --- ServerlessFunction/template.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 97fe03e6..34296f1a 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -19,6 +19,7 @@ Parameters: ExistingCognitoClientId: Type: String + Default: "" Description: Existing Cognito User Pool Client ID Globals: From 2c72aaa80138c2f83692227e7aed5b4a5da18c1b Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 23 Jan 2026 15:24:12 +0900 Subject: [PATCH 498/528] =?UTF-8?q?fix:=20SpeakingHandler=20getStringOrNul?= =?UTF-8?q?l=20=EC=BB=B4=ED=8C=8C=EC=9D=BC=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../serverless/domain/speaking/handler/SpeakingHandler.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingHandler.java index c4a04370..c4166ed8 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingHandler.java @@ -135,7 +135,7 @@ private APIGatewayProxyResponseEvent handleReset(String userId, String body) { } JsonObject request = JsonParser.parseString(body).getAsJsonObject(); - String sessionId = getStringOrNull(request, "sessionId"); + String sessionId = request.has("sessionId") ? request.get("sessionId").getAsString() : null; if (sessionId == null || sessionId.isEmpty()) { return response(400, Map.of("error", "sessionId is required")); From 0e226114ed29d782cc86b2dc6ee732b221a2d42b Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 23 Jan 2026 15:24:12 +0900 Subject: [PATCH 499/528] =?UTF-8?q?fix:=20SpeakingHandler=20getStringOrNul?= =?UTF-8?q?l=20=EC=BB=B4=ED=8C=8C=EC=9D=BC=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../serverless/domain/speaking/handler/SpeakingHandler.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingHandler.java index c4a04370..c4166ed8 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingHandler.java @@ -135,7 +135,7 @@ private APIGatewayProxyResponseEvent handleReset(String userId, String body) { } JsonObject request = JsonParser.parseString(body).getAsJsonObject(); - String sessionId = getStringOrNull(request, "sessionId"); + String sessionId = request.has("sessionId") ? request.get("sessionId").getAsString() : null; if (sessionId == null || sessionId.isEmpty()) { return response(400, Map.of("error", "sessionId is required")); From 0563433e482bf95a0e3610c3e9ccf8965ca90243 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 23 Jan 2026 15:24:12 +0900 Subject: [PATCH 500/528] =?UTF-8?q?fix:=20SpeakingHandler=20getStringOrNul?= =?UTF-8?q?l=20=EC=BB=B4=ED=8C=8C=EC=9D=BC=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../serverless/domain/speaking/handler/SpeakingHandler.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingHandler.java index c4a04370..c4166ed8 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingHandler.java @@ -135,7 +135,7 @@ private APIGatewayProxyResponseEvent handleReset(String userId, String body) { } JsonObject request = JsonParser.parseString(body).getAsJsonObject(); - String sessionId = getStringOrNull(request, "sessionId"); + String sessionId = request.has("sessionId") ? request.get("sessionId").getAsString() : null; if (sessionId == null || sessionId.isEmpty()) { return response(400, Map.of("error", "sessionId is required")); From f8644dd44cc237c061fa6927c06ce030726ccd1a Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 23 Jan 2026 15:46:22 +0900 Subject: [PATCH 501/528] =?UTF-8?q?fix:=20Bedrock=20=ED=82=A4=EC=9B=8C?= =?UTF-8?q?=EB=93=9C(meaningKo=20=ED=8F=AC=ED=95=A8)=EB=A5=BC=20article?= =?UTF-8?q?=EC=97=90=20=EC=A0=80=EC=9E=A5=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../news/service/NewsAnalysisService.java | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java index f13712f0..87da58f1 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java @@ -58,11 +58,7 @@ public NewsArticle analyzeArticle(NewsArticle article) { article.setCefrLevel(cefrLevel); article.setLevel(mapCefrToLevel(cefrLevel)); - // 2. 핵심 단어 추출 (Comprehend) - List keywords = extractKeywords(content); - article.setKeywords(keywords); - - // 3. 3줄 요약 + 퀴즈 생성 (Bedrock - 한 번에 처리) + // 2. 3줄 요약 + 키워드 + 퀴즈 생성 (Bedrock - 한 번에 처리) AnalysisResult result = generateSummaryAndQuiz(content, cefrLevel); if (result.summary() != null) { article.setSummary(result.summary()); @@ -70,6 +66,15 @@ public NewsArticle analyzeArticle(NewsArticle article) { article.setQuiz(result.quiz()); article.setHighlightWords(result.highlightWords()); + // Bedrock 키워드 사용 (meaningKo 포함) + if (result.keywords() != null && !result.keywords().isEmpty()) { + article.setKeywords(result.keywords()); + } else { + // fallback: Comprehend로 키워드 추출 + List keywords = extractKeywords(content); + article.setKeywords(keywords); + } + // 4. GSI 키 설정 article.setGsi1pk("LEVEL#" + article.getLevel()); article.setGsi1sk(article.getPublishedAt()); @@ -233,7 +238,7 @@ private AnalysisResult generateSummaryAndQuiz(String content, String cefrLevel) return parseAnalysisResult(response); } catch (Exception e) { logger.error("요약/퀴즈 생성 실패", e); - return new AnalysisResult(null, new ArrayList<>(), new ArrayList<>()); + return new AnalysisResult(null, new ArrayList<>(), new ArrayList<>(), new ArrayList<>()); } } @@ -319,7 +324,7 @@ private AnalysisResult parseAnalysisResult(String response) { }); } - return new AnalysisResult(summary, highlightWords, quiz); + return new AnalysisResult(summary, keywords, highlightWords, quiz); } private String extractJson(String response) { @@ -341,6 +346,7 @@ private String truncate(String text, int maxLength) { */ private record AnalysisResult( String summary, + List keywords, List highlightWords, List quiz ) {} From badf72023b56a15410305a415eed7c3c2b1a39c8 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 23 Jan 2026 15:46:22 +0900 Subject: [PATCH 502/528] =?UTF-8?q?fix:=20Bedrock=20=ED=82=A4=EC=9B=8C?= =?UTF-8?q?=EB=93=9C(meaningKo=20=ED=8F=AC=ED=95=A8)=EB=A5=BC=20article?= =?UTF-8?q?=EC=97=90=20=EC=A0=80=EC=9E=A5=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../news/service/NewsAnalysisService.java | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java index f13712f0..87da58f1 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java @@ -58,11 +58,7 @@ public NewsArticle analyzeArticle(NewsArticle article) { article.setCefrLevel(cefrLevel); article.setLevel(mapCefrToLevel(cefrLevel)); - // 2. 핵심 단어 추출 (Comprehend) - List keywords = extractKeywords(content); - article.setKeywords(keywords); - - // 3. 3줄 요약 + 퀴즈 생성 (Bedrock - 한 번에 처리) + // 2. 3줄 요약 + 키워드 + 퀴즈 생성 (Bedrock - 한 번에 처리) AnalysisResult result = generateSummaryAndQuiz(content, cefrLevel); if (result.summary() != null) { article.setSummary(result.summary()); @@ -70,6 +66,15 @@ public NewsArticle analyzeArticle(NewsArticle article) { article.setQuiz(result.quiz()); article.setHighlightWords(result.highlightWords()); + // Bedrock 키워드 사용 (meaningKo 포함) + if (result.keywords() != null && !result.keywords().isEmpty()) { + article.setKeywords(result.keywords()); + } else { + // fallback: Comprehend로 키워드 추출 + List keywords = extractKeywords(content); + article.setKeywords(keywords); + } + // 4. GSI 키 설정 article.setGsi1pk("LEVEL#" + article.getLevel()); article.setGsi1sk(article.getPublishedAt()); @@ -233,7 +238,7 @@ private AnalysisResult generateSummaryAndQuiz(String content, String cefrLevel) return parseAnalysisResult(response); } catch (Exception e) { logger.error("요약/퀴즈 생성 실패", e); - return new AnalysisResult(null, new ArrayList<>(), new ArrayList<>()); + return new AnalysisResult(null, new ArrayList<>(), new ArrayList<>(), new ArrayList<>()); } } @@ -319,7 +324,7 @@ private AnalysisResult parseAnalysisResult(String response) { }); } - return new AnalysisResult(summary, highlightWords, quiz); + return new AnalysisResult(summary, keywords, highlightWords, quiz); } private String extractJson(String response) { @@ -341,6 +346,7 @@ private String truncate(String text, int maxLength) { */ private record AnalysisResult( String summary, + List keywords, List highlightWords, List quiz ) {} From 5f2bc1b942214309adfdde165ca8a9620a79e68d Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 23 Jan 2026 15:46:22 +0900 Subject: [PATCH 503/528] =?UTF-8?q?fix:=20Bedrock=20=ED=82=A4=EC=9B=8C?= =?UTF-8?q?=EB=93=9C(meaningKo=20=ED=8F=AC=ED=95=A8)=EB=A5=BC=20article?= =?UTF-8?q?=EC=97=90=20=EC=A0=80=EC=9E=A5=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../news/service/NewsAnalysisService.java | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java index f13712f0..87da58f1 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java @@ -58,11 +58,7 @@ public NewsArticle analyzeArticle(NewsArticle article) { article.setCefrLevel(cefrLevel); article.setLevel(mapCefrToLevel(cefrLevel)); - // 2. 핵심 단어 추출 (Comprehend) - List keywords = extractKeywords(content); - article.setKeywords(keywords); - - // 3. 3줄 요약 + 퀴즈 생성 (Bedrock - 한 번에 처리) + // 2. 3줄 요약 + 키워드 + 퀴즈 생성 (Bedrock - 한 번에 처리) AnalysisResult result = generateSummaryAndQuiz(content, cefrLevel); if (result.summary() != null) { article.setSummary(result.summary()); @@ -70,6 +66,15 @@ public NewsArticle analyzeArticle(NewsArticle article) { article.setQuiz(result.quiz()); article.setHighlightWords(result.highlightWords()); + // Bedrock 키워드 사용 (meaningKo 포함) + if (result.keywords() != null && !result.keywords().isEmpty()) { + article.setKeywords(result.keywords()); + } else { + // fallback: Comprehend로 키워드 추출 + List keywords = extractKeywords(content); + article.setKeywords(keywords); + } + // 4. GSI 키 설정 article.setGsi1pk("LEVEL#" + article.getLevel()); article.setGsi1sk(article.getPublishedAt()); @@ -233,7 +238,7 @@ private AnalysisResult generateSummaryAndQuiz(String content, String cefrLevel) return parseAnalysisResult(response); } catch (Exception e) { logger.error("요약/퀴즈 생성 실패", e); - return new AnalysisResult(null, new ArrayList<>(), new ArrayList<>()); + return new AnalysisResult(null, new ArrayList<>(), new ArrayList<>(), new ArrayList<>()); } } @@ -319,7 +324,7 @@ private AnalysisResult parseAnalysisResult(String response) { }); } - return new AnalysisResult(summary, highlightWords, quiz); + return new AnalysisResult(summary, keywords, highlightWords, quiz); } private String extractJson(String response) { @@ -341,6 +346,7 @@ private String truncate(String text, int maxLength) { */ private record AnalysisResult( String summary, + List keywords, List highlightWords, List quiz ) {} From fa533d3335ac53ad4afdf671f0ea7efe23685054 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 23 Jan 2026 15:46:22 +0900 Subject: [PATCH 504/528] =?UTF-8?q?fix:=20Bedrock=20=ED=82=A4=EC=9B=8C?= =?UTF-8?q?=EB=93=9C(meaningKo=20=ED=8F=AC=ED=95=A8)=EB=A5=BC=20article?= =?UTF-8?q?=EC=97=90=20=EC=A0=80=EC=9E=A5=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../news/service/NewsAnalysisService.java | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java index af23fc5b..5af2b554 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java @@ -58,11 +58,7 @@ public NewsArticle analyzeArticle(NewsArticle article) { article.setCefrLevel(cefrLevel); article.setLevel(mapCefrToLevel(cefrLevel)); - // 2. 핵심 단어 추출 (Comprehend) - List keywords = extractKeywords(content); - article.setKeywords(keywords); - - // 3. 3줄 요약 + 퀴즈 생성 (Bedrock - 한 번에 처리) + // 2. 3줄 요약 + 키워드 + 퀴즈 생성 (Bedrock - 한 번에 처리) AnalysisResult result = generateSummaryAndQuiz(content, cefrLevel); if (result.summary() != null) { article.setSummary(result.summary()); @@ -70,6 +66,15 @@ public NewsArticle analyzeArticle(NewsArticle article) { article.setQuiz(result.quiz()); article.setHighlightWords(result.highlightWords()); + // Bedrock 키워드 사용 (meaningKo 포함) + if (result.keywords() != null && !result.keywords().isEmpty()) { + article.setKeywords(result.keywords()); + } else { + // fallback: Comprehend로 키워드 추출 + List keywords = extractKeywords(content); + article.setKeywords(keywords); + } + // 4. GSI 키 설정 article.setGsi1pk("LEVEL#" + article.getLevel()); article.setGsi1sk(article.getPublishedAt()); @@ -222,7 +227,7 @@ private AnalysisResult generateSummaryAndQuiz(String content, String cefrLevel) return parseAnalysisResult(response); } catch (Exception e) { logger.error("요약/퀴즈 생성 실패", e); - return new AnalysisResult(null, new ArrayList<>(), new ArrayList<>()); + return new AnalysisResult(null, new ArrayList<>(), new ArrayList<>(), new ArrayList<>()); } } @@ -293,7 +298,7 @@ private AnalysisResult parseAnalysisResult(String response) { }); } - return new AnalysisResult(summary, highlightWords, quiz); + return new AnalysisResult(summary, keywords, highlightWords, quiz); } private String extractJson(String response) { @@ -315,6 +320,7 @@ private String truncate(String text, int maxLength) { */ private record AnalysisResult( String summary, + List keywords, List highlightWords, List quiz ) {} From 75acb9a22d3e0a3a44a2b98ea0383de4be4c0a22 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 23 Jan 2026 16:05:18 +0900 Subject: [PATCH 505/528] feat: add dashboard stats API and enhance news stats tracking - Introduce `/stats/dashboard` API for retrieving integrated user stats (today, overall, weekly progress, and level distribution) - Update `UserStats` model to include additional fields for news stats (newsRead, newsQuizCompleted, newsQuizPerfect, newsWordsCollected, etc.) - Add atomic updates to track news reading, quiz, and word stats in `UserStatsRepository` - Modify CloudFormation `template.yaml` to register the new `/stats/dashboard` endpoint --- .../domain/news/handler/NewsHandler.java | 2 +- .../news/service/NewsLearningService.java | 69 +++- .../stats/handler/UserStatsHandler.java | 158 +++++++-- .../domain/stats/model/UserStats.java | 26 +- .../stats/repository/UserStatsRepository.java | 314 +++++++++++++++--- ServerlessFunction/template.yaml | 8 + 6 files changed, 475 insertions(+), 102 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java index 4ffd1bab..44f9a43c 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java @@ -280,7 +280,7 @@ private APIGatewayProxyResponseEvent getBookmarks(APIGatewayProxyRequestEvent re if (params == null) params = new HashMap<>(); int limit = parseLimit(params.get("limit")); - List bookmarks = learningService.getUserBookmarks(userId, limit); + List> bookmarks = learningService.getUserBookmarks(userId, limit); Map response = new HashMap<>(); response.put("bookmarks", bookmarks); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java index f69355db..02930b1d 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java @@ -2,17 +2,18 @@ import com.mzc.secondproject.serverless.common.config.EnvConfig; import com.mzc.secondproject.serverless.common.service.PollyService; +import com.mzc.secondproject.serverless.domain.badge.model.UserBadge; +import com.mzc.secondproject.serverless.domain.badge.service.BadgeService; import com.mzc.secondproject.serverless.domain.news.model.NewsArticle; import com.mzc.secondproject.serverless.domain.news.model.UserNewsRecord; import com.mzc.secondproject.serverless.domain.news.repository.NewsArticleRepository; import com.mzc.secondproject.serverless.domain.news.repository.UserNewsRepository; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; +import com.mzc.secondproject.serverless.domain.stats.repository.UserStatsRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; +import java.util.*; /** * 뉴스 학습 부가 기능 서비스 @@ -25,35 +26,44 @@ public class NewsLearningService { private final NewsArticleRepository articleRepository; private final UserNewsRepository userNewsRepository; private final PollyService pollyService; + private final UserStatsRepository userStatsRepository; + private final BadgeService badgeService; public NewsLearningService() { this.articleRepository = new NewsArticleRepository(); this.userNewsRepository = new UserNewsRepository(); this.pollyService = new PollyService(BUCKET_NAME, "news/audio/"); + this.userStatsRepository = new UserStatsRepository(); + this.badgeService = new BadgeService(); } public NewsLearningService(NewsArticleRepository articleRepository, UserNewsRepository userNewsRepository, - PollyService pollyService) { + PollyService pollyService, + UserStatsRepository userStatsRepository, + BadgeService badgeService) { this.articleRepository = articleRepository; this.userNewsRepository = userNewsRepository; this.pollyService = pollyService; + this.userStatsRepository = userStatsRepository; + this.badgeService = badgeService; } /** * 뉴스 읽기 완료 기록 + * @return 새로 획득한 배지 목록 */ - public void markAsRead(String userId, String articleId) { + public List markAsRead(String userId, String articleId) { Optional article = articleRepository.findById(articleId); if (article.isEmpty()) { logger.warn("기사를 찾을 수 없음: {}", articleId); - return; + return new ArrayList<>(); } // 이미 읽은 기사인지 확인 (중복 조회수 증가 방지) if (userNewsRepository.hasRead(userId, articleId)) { logger.debug("이미 읽은 기사: userId={}, articleId={}", userId, articleId); - return; + return new ArrayList<>(); } NewsArticle a = article.get(); @@ -72,6 +82,23 @@ public void markAsRead(String userId, String articleId) { } logger.info("읽기 완료 기록: userId={}, articleId={}", userId, articleId); + + // 통계 업데이트 및 배지 체크 + List newBadges = new ArrayList<>(); + try { + UserStats updatedStats = userStatsRepository.incrementNewsReadStats(userId); + if (updatedStats != null) { + newBadges = badgeService.checkAndAwardBadges(userId, updatedStats); + if (!newBadges.isEmpty()) { + logger.info("새 배지 획득: userId={}, badges={}", userId, + newBadges.stream().map(UserBadge::getBadgeType).toList()); + } + } + } catch (Exception e) { + logger.error("통계/배지 업데이트 실패: userId={}, error={}", userId, e.getMessage()); + } + + return newBadges; } /** @@ -128,8 +155,30 @@ public Set getBookmarkedArticleIds(String userId, List articleId /** * 사용자 북마크 목록 조회 (기사 정보 포함) */ - public List getUserBookmarks(String userId, int limit) { - return userNewsRepository.getUserBookmarks(userId, limit); + public List> getUserBookmarks(String userId, int limit) { + List bookmarks = userNewsRepository.getUserBookmarks(userId, limit); + List> result = new ArrayList<>(); + + for (UserNewsRecord bookmark : bookmarks) { + Optional articleOpt = articleRepository.findById(bookmark.getArticleId()); + if (articleOpt.isPresent()) { + NewsArticle article = articleOpt.get(); + Map bookmarkWithArticle = new HashMap<>(); + bookmarkWithArticle.put("articleId", article.getArticleId()); + bookmarkWithArticle.put("title", article.getTitle()); + bookmarkWithArticle.put("summary", article.getSummary()); + bookmarkWithArticle.put("source", article.getSource()); + bookmarkWithArticle.put("publishedAt", article.getPublishedAt()); + bookmarkWithArticle.put("keywords", article.getKeywords()); + bookmarkWithArticle.put("highlightWords", article.getHighlightWords()); + bookmarkWithArticle.put("category", article.getCategory()); + bookmarkWithArticle.put("level", article.getLevel()); + bookmarkWithArticle.put("imageUrl", article.getImageUrl()); + bookmarkWithArticle.put("bookmarkedAt", bookmark.getCreatedAt()); + result.add(bookmarkWithArticle); + } + } + return result; } /** diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java index 637151be..66edddce 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java @@ -24,20 +24,20 @@ * 사용자 학습 통계 API Handler */ public class UserStatsHandler implements RequestHandler { - + private static final Logger logger = LoggerFactory.getLogger(UserStatsHandler.class); - + private final UserStatsRepository statsRepository; private final DailyStudyRepository dailyStudyRepository; private final HandlerRouter router; - + /** * 기본 생성자 (Lambda에서 사용) */ public UserStatsHandler() { this(new UserStatsRepository(), new DailyStudyRepository()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ @@ -46,9 +46,10 @@ public UserStatsHandler(UserStatsRepository statsRepository, DailyStudyRepositor this.dailyStudyRepository = dailyStudyRepository; this.router = initRouter(); } - + private HandlerRouter initRouter() { return new HandlerRouter().addRoutes( + Route.getAuth("/stats/dashboard", this::getDashboardStats), Route.getAuth("/stats/daily", this::getDailyStats), Route.getAuth("/stats/weekly", this::getWeeklyStats), Route.getAuth("/stats/monthly", this::getMonthlyStats), @@ -56,13 +57,94 @@ private HandlerRouter initRouter() { Route.getAuth("/stats/history", this::getStatsHistory) ); } - + @Override public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { logger.info("Received request: {} {}", request.getHttpMethod(), request.getPath()); return router.route(request); } - + + /** + * 대시보드용 통합 통계 조회 (프론트엔드 요청 형식) + * GET /stats/dashboard + */ + private APIGatewayProxyResponseEvent getDashboardStats(APIGatewayProxyRequestEvent request, String userId) { + String today = LocalDate.now().toString(); + + // 오늘 통계 조회 + Optional dailyStats = statsRepository.findDailyStats(userId, today); + // 전체 통계 조회 + Optional totalStats = statsRepository.findTotalStats(userId); + // 최근 7일 히스토리 조회 + PaginatedResult weekHistory = statsRepository.findRecentDailyStats(userId, 7, null); + // 오늘 학습 목표 조회 + Optional dailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today); + + Map response = new HashMap<>(); + + // today 섹션 + Map todaySection = new HashMap<>(); + if (dailyStats.isPresent()) { + UserStats ds = dailyStats.get(); + todaySection.put("wordsLearned", ds.getNewWordsLearned() != null ? ds.getNewWordsLearned() : 0); + todaySection.put("newsRead", ds.getNewsRead() != null ? ds.getNewsRead() : 0); + todaySection.put("quizzesTaken", (ds.getTestsCompleted() != null ? ds.getTestsCompleted() : 0) + + (ds.getNewsQuizCompleted() != null ? ds.getNewsQuizCompleted() : 0)); + } else { + todaySection.put("wordsLearned", 0); + todaySection.put("newsRead", 0); + todaySection.put("quizzesTaken", 0); + } + todaySection.put("wordsTotal", dailyStudy.map(ds -> ds.getTotalWords() != null ? ds.getTotalWords() : 25).orElse(25)); + response.put("today", todaySection); + + // overall 섹션 + Map overallSection = new HashMap<>(); + if (totalStats.isPresent()) { + UserStats ts = totalStats.get(); + overallSection.put("totalWordsLearned", ts.getNewWordsLearned() != null ? ts.getNewWordsLearned() : 0); + overallSection.put("totalNewsRead", ts.getNewsRead() != null ? ts.getNewsRead() : 0); + overallSection.put("totalQuizzes", (ts.getTestsCompleted() != null ? ts.getTestsCompleted() : 0) + + (ts.getNewsQuizCompleted() != null ? ts.getNewsQuizCompleted() : 0)); + overallSection.put("averageAccuracy", calculateSuccessRate(ts)); + overallSection.put("currentStreak", ts.getCurrentStreak() != null ? ts.getCurrentStreak() : 0); + overallSection.put("longestStreak", ts.getLongestStreak() != null ? ts.getLongestStreak() : 0); + overallSection.put("lastStudyDate", ts.getLastStudyDate()); + } else { + overallSection.put("totalWordsLearned", 0); + overallSection.put("totalNewsRead", 0); + overallSection.put("totalQuizzes", 0); + overallSection.put("averageAccuracy", 0.0); + overallSection.put("currentStreak", 0); + overallSection.put("longestStreak", 0); + overallSection.put("lastStudyDate", null); + } + // totalStudyDays 계산 (최근 히스토리에서 실제 학습한 날 수) + overallSection.put("totalStudyDays", weekHistory.items().size()); + response.put("overall", overallSection); + + // weeklyProgress 섹션 + List> weeklyProgress = weekHistory.items().stream() + .map(stats -> { + Map dayStats = new HashMap<>(); + dayStats.put("date", stats.getPeriod()); + dayStats.put("wordsLearned", stats.getNewWordsLearned() != null ? stats.getNewWordsLearned() : 0); + dayStats.put("newsRead", stats.getNewsRead() != null ? stats.getNewsRead() : 0); + return dayStats; + }) + .collect(Collectors.toList()); + response.put("weeklyProgress", weeklyProgress); + + // levelDistribution (현재 미구현 - 향후 추가 가능) + Map levelDistribution = new HashMap<>(); + levelDistribution.put("beginner", 0); + levelDistribution.put("intermediate", 0); + levelDistribution.put("advanced", 0); + response.put("levelDistribution", levelDistribution); + + return ResponseGenerator.ok("학습 통계 조회 성공", response); + } + /** * 오늘의 통계 조회 */ @@ -70,12 +152,12 @@ private APIGatewayProxyResponseEvent getDailyStats(APIGatewayProxyRequestEvent r Map queryParams = request.getQueryStringParameters(); String date = queryParams != null && queryParams.get("date") != null ? queryParams.get("date") : LocalDate.now().toString(); - + Optional stats = statsRepository.findDailyStats(userId, date); - + return ResponseGenerator.ok("Daily stats retrieved", buildStatsResponse(stats, "DAILY", date)); } - + /** * 이번 주 통계 조회 */ @@ -83,12 +165,12 @@ private APIGatewayProxyResponseEvent getWeeklyStats(APIGatewayProxyRequestEvent Map queryParams = request.getQueryStringParameters(); String yearWeek = queryParams != null && queryParams.get("week") != null ? queryParams.get("week") : getCurrentYearWeek(); - + Optional stats = statsRepository.findWeeklyStats(userId, yearWeek); - + return ResponseGenerator.ok("Weekly stats retrieved", buildStatsResponse(stats, "WEEKLY", yearWeek)); } - + /** * 이번 달 통계 조회 */ @@ -96,20 +178,20 @@ private APIGatewayProxyResponseEvent getMonthlyStats(APIGatewayProxyRequestEvent Map queryParams = request.getQueryStringParameters(); String yearMonth = queryParams != null && queryParams.get("month") != null ? queryParams.get("month") : getCurrentYearMonth(); - + Optional stats = statsRepository.findMonthlyStats(userId, yearMonth); - + return ResponseGenerator.ok("Monthly stats retrieved", buildStatsResponse(stats, "MONTHLY", yearMonth)); } - + /** * 전체 통계 조회 */ private APIGatewayProxyResponseEvent getTotalStats(APIGatewayProxyRequestEvent request, String userId) { Optional stats = statsRepository.findTotalStats(userId); - + Map response = buildStatsResponse(stats, "TOTAL", "ALL"); - + // 전체 통계에는 streak 정보 추가 if (stats.isPresent()) { UserStats s = stats.get(); @@ -121,24 +203,24 @@ private APIGatewayProxyResponseEvent getTotalStats(APIGatewayProxyRequestEvent r response.put("longestStreak", 0); response.put("lastStudyDate", null); } - + return ResponseGenerator.ok("Total stats retrieved", response); } - + /** * 최근 일별 통계 히스토리 조회 */ private APIGatewayProxyResponseEvent getStatsHistory(APIGatewayProxyRequestEvent request, String userId) { Map queryParams = request.getQueryStringParameters(); String cursor = queryParams != null ? queryParams.get("cursor") : null; - + int limit = 7; // 기본 7일 if (queryParams != null && queryParams.get("limit") != null) { limit = Math.min(Integer.parseInt(queryParams.get("limit")), 100); } - + PaginatedResult result = statsRepository.findRecentDailyStats(userId, limit, cursor); - + // 각 날짜별 isCompleted 정보 조회 및 응답 구성 List> historyWithCompletion = result.items().stream() .map(stats -> { @@ -151,28 +233,28 @@ private APIGatewayProxyResponseEvent getStatsHistory(APIGatewayProxyRequestEvent item.put("successRate", calculateSuccessRate(stats)); item.put("newWordsLearned", stats.getNewWordsLearned() != null ? stats.getNewWordsLearned() : 0); item.put("wordsReviewed", stats.getWordsReviewed() != null ? stats.getWordsReviewed() : 0); - + // DailyStudy에서 isCompleted 조회 Optional dailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, stats.getPeriod()); item.put("isCompleted", dailyStudy.map(ds -> ds.getIsCompleted() != null && ds.getIsCompleted()).orElse(false)); - + return item; }) .collect(Collectors.toList()); - + Map response = new HashMap<>(); response.put("history", historyWithCompletion); response.put("nextCursor", result.nextCursor()); response.put("hasMore", result.hasMore()); - + return ResponseGenerator.ok("Stats history retrieved", response); } - + private Map buildStatsResponse(Optional stats, String periodType, String period) { Map response = new HashMap<>(); response.put("periodType", periodType); response.put("period", period); - + if (stats.isPresent()) { UserStats s = stats.get(); response.put("testsCompleted", s.getTestsCompleted() != null ? s.getTestsCompleted() : 0); @@ -182,6 +264,11 @@ private Map buildStatsResponse(Optional stats, String response.put("successRate", calculateSuccessRate(s)); response.put("newWordsLearned", s.getNewWordsLearned() != null ? s.getNewWordsLearned() : 0); response.put("wordsReviewed", s.getWordsReviewed() != null ? s.getWordsReviewed() : 0); + // 뉴스 관련 통계 + response.put("newsRead", s.getNewsRead() != null ? s.getNewsRead() : 0); + response.put("newsQuizCompleted", s.getNewsQuizCompleted() != null ? s.getNewsQuizCompleted() : 0); + response.put("newsQuizPerfect", s.getNewsQuizPerfect() != null ? s.getNewsQuizPerfect() : 0); + response.put("newsWordsCollected", s.getNewsWordsCollected() != null ? s.getNewsWordsCollected() : 0); } else { response.put("testsCompleted", 0); response.put("questionsAnswered", 0); @@ -190,17 +277,22 @@ private Map buildStatsResponse(Optional stats, String response.put("successRate", 0.0); response.put("newWordsLearned", 0); response.put("wordsReviewed", 0); + // 뉴스 관련 통계 + response.put("newsRead", 0); + response.put("newsQuizCompleted", 0); + response.put("newsQuizPerfect", 0); + response.put("newsWordsCollected", 0); } - + return response; } - + private double calculateSuccessRate(UserStats stats) { int correct = stats.getCorrectAnswers() != null ? stats.getCorrectAnswers() : 0; int total = stats.getQuestionsAnswered() != null ? stats.getQuestionsAnswered() : 0; return total > 0 ? (correct * 100.0 / total) : 0.0; } - + private String getCurrentYearWeek() { LocalDate now = LocalDate.now(); WeekFields weekFields = WeekFields.of(Locale.getDefault()); @@ -208,7 +300,7 @@ private String getCurrentYearWeek() { int year = now.get(weekFields.weekBasedYear()); return String.format("%d-W%02d", year, week); } - + private String getCurrentYearMonth() { LocalDate now = LocalDate.now(); return String.format("%d-%02d", now.getYear(), now.getMonthValue()); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/model/UserStats.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/model/UserStats.java index cc25634c..3c268897 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/model/UserStats.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/model/UserStats.java @@ -24,31 +24,31 @@ @AllArgsConstructor @DynamoDbBean public class UserStats { - + private String pk; // USER#{userId}#STATS private String sk; // DAILY#{date} / WEEKLY#{year}-W{week} / MONTHLY#{year}-{month} / TOTAL - + private String userId; private String periodType; // DAILY, WEEKLY, MONTHLY, TOTAL private String period; // 2026-01-13, 2026-W02, 2026-01, TOTAL - + // 테스트 통계 private Integer testsCompleted; // 완료한 테스트 수 private Integer questionsAnswered; // 답변한 문제 수 private Integer correctAnswers; // 정답 수 private Integer incorrectAnswers; // 오답 수 private Double successRate; // 정답률 - + // 학습 통계 private Integer newWordsLearned; // 새로 학습한 단어 수 private Integer wordsReviewed; // 복습한 단어 수 private Integer wordsMastered; // 마스터한 단어 수 - + // Streak (연속 학습) private Integer currentStreak; // 현재 연속 학습일 private Integer longestStreak; // 최장 연속 학습일 private String lastStudyDate; // 마지막 학습일 - + // 게임 통계 private Integer gamesPlayed; // 참여한 게임 수 private Integer gamesWon; // 1등 횟수 @@ -56,17 +56,25 @@ public class UserStats { private Integer totalGameScore; // 누적 게임 점수 private Integer quickGuesses; // 5초 내 정답 횟수 private Integer perfectDraws; // 전원 정답 유도 횟수 - + + // 뉴스 통계 + private Integer newsRead; // 읽은 뉴스 수 + private Integer newsQuizCompleted; // 완료한 뉴스 퀴즈 수 + private Integer newsQuizPerfect; // 뉴스 퀴즈 만점 횟수 + private Integer newsWordsCollected; // 뉴스에서 수집한 단어 수 + private Integer newsStreak; // 뉴스 연속 읽기 일수 + private String lastNewsReadDate; // 마지막 뉴스 읽은 날짜 + // 메타데이터 private String createdAt; private String updatedAt; - + @DynamoDbPartitionKey @DynamoDbAttribute("PK") public String getPk() { return pk; } - + @DynamoDbSortKey @DynamoDbAttribute("SK") public String getSk() { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java index b3ad20d8..89e9b385 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java @@ -28,26 +28,26 @@ * Atomic Counter 패턴을 사용하여 Scan 없이 통계 업데이트 */ public class UserStatsRepository { - + private static final Logger logger = LoggerFactory.getLogger(UserStatsRepository.class); private static final String TABLE_NAME = EnvConfig.getRequired("VOCAB_TABLE_NAME"); - + private final DynamoDbTable table; - + /** * 기본 생성자 (Lambda에서 사용) */ public UserStatsRepository() { this(AwsClients.dynamoDbEnhanced()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ public UserStatsRepository(DynamoDbEnhancedClient enhancedClient) { this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(UserStats.class)); } - + /** * 특정 기간의 통계 조회 */ @@ -56,39 +56,39 @@ public Optional findByUserIdAndPeriod(String userId, String sk) { .partitionValue(StatsKey.userStatsPk(userId)) .sortValue(sk) .build(); - + UserStats stats = table.getItem(key); return Optional.ofNullable(stats); } - + /** * 일별 통계 조회 */ public Optional findDailyStats(String userId, String date) { return findByUserIdAndPeriod(userId, StatsKey.statsDailySk(date)); } - + /** * 주별 통계 조회 */ public Optional findWeeklyStats(String userId, String yearWeek) { return findByUserIdAndPeriod(userId, StatsKey.statsWeeklySk(yearWeek)); } - + /** * 월별 통계 조회 */ public Optional findMonthlyStats(String userId, String yearMonth) { return findByUserIdAndPeriod(userId, StatsKey.statsMonthlySk(yearMonth)); } - + /** * 전체 통계 조회 */ public Optional findTotalStats(String userId) { return findByUserIdAndPeriod(userId, StatsKey.statsTotalSk()); } - + /** * 최근 N일 일별 통계 조회 */ @@ -98,25 +98,25 @@ public PaginatedResult findRecentDailyStats(String userId, int limit, .partitionValue(StatsKey.userStatsPk(userId)) .sortValue(StatsKey.STATS_DAILY) .build()); - + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() .queryConditional(queryConditional) .scanIndexForward(false) // 최신순 .limit(limit); - + if (cursor != null && !cursor.isEmpty()) { Map exclusiveStartKey = CursorUtil.decode(cursor); if (exclusiveStartKey != null) { requestBuilder.exclusiveStartKey(exclusiveStartKey); } } - + Page page = table.query(requestBuilder.build()).iterator().next(); String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); - + return new PaginatedResult<>(page.items(), nextCursor); } - + /** * 테스트 결과 통계 Atomic 업데이트 * 일/주/월/전체 통계를 한 번에 업데이트 @@ -125,31 +125,31 @@ public void incrementTestStats(String userId, int correctAnswers, int incorrectA String today = LocalDate.now().toString(); String yearWeek = getYearWeek(); String yearMonth = getYearMonth(); - + List sortKeys = List.of( StatsKey.statsDailySk(today), StatsKey.statsWeeklySk(yearWeek), StatsKey.statsMonthlySk(yearMonth), StatsKey.statsTotalSk() ); - + String pk = StatsKey.userStatsPk(userId); String now = Instant.now().toString(); int totalQuestions = correctAnswers + incorrectAnswers; - + for (String sk : sortKeys) { updateTestStats(pk, sk, correctAnswers, incorrectAnswers, totalQuestions, now); } - + logger.info("Incremented test stats: userId={}, correct={}, incorrect={}", userId, correctAnswers, incorrectAnswers); } - + private void updateTestStats(String pk, String sk, int correct, int incorrect, int total, String now) { Map key = new HashMap<>(); key.put("PK", AttributeValue.builder().s(pk).build()); key.put("SK", AttributeValue.builder().s(sk).build()); - + Map values = new HashMap<>(); values.put(":correct", AttributeValue.builder().n(String.valueOf(correct)).build()); values.put(":incorrect", AttributeValue.builder().n(String.valueOf(incorrect)).build()); @@ -157,7 +157,7 @@ private void updateTestStats(String pk, String sk, int correct, int incorrect, i values.put(":one", AttributeValue.builder().n("1").build()); values.put(":zero", AttributeValue.builder().n("0").build()); values.put(":now", AttributeValue.builder().s(now).build()); - + String updateExpression = "SET " + "correctAnswers = if_not_exists(correctAnswers, :zero) + :correct, " + "incorrectAnswers = if_not_exists(incorrectAnswers, :zero) + :incorrect, " + @@ -165,17 +165,17 @@ private void updateTestStats(String pk, String sk, int correct, int incorrect, i "testsCompleted = if_not_exists(testsCompleted, :zero) + :one, " + "updatedAt = :now, " + "createdAt = if_not_exists(createdAt, :now)"; - + UpdateItemRequest request = UpdateItemRequest.builder() .tableName(TABLE_NAME) .key(key) .updateExpression(updateExpression) .expressionAttributeValues(values) .build(); - + AwsClients.dynamoDb().updateItem(request); } - + /** * 학습 완료 단어 수 Atomic 업데이트 */ @@ -183,52 +183,52 @@ public void incrementWordsLearned(String userId, int newWords, int reviewedWords String today = LocalDate.now().toString(); String yearWeek = getYearWeek(); String yearMonth = getYearMonth(); - + List sortKeys = List.of( StatsKey.statsDailySk(today), StatsKey.statsWeeklySk(yearWeek), StatsKey.statsMonthlySk(yearMonth), StatsKey.statsTotalSk() ); - + String pk = StatsKey.userStatsPk(userId); String now = Instant.now().toString(); - + for (String sk : sortKeys) { updateWordsLearned(pk, sk, newWords, reviewedWords, now); } - + logger.info("Incremented words learned: userId={}, new={}, reviewed={}", userId, newWords, reviewedWords); } - + private void updateWordsLearned(String pk, String sk, int newWords, int reviewedWords, String now) { Map key = new HashMap<>(); key.put("PK", AttributeValue.builder().s(pk).build()); key.put("SK", AttributeValue.builder().s(sk).build()); - + Map values = new HashMap<>(); values.put(":new", AttributeValue.builder().n(String.valueOf(newWords)).build()); values.put(":reviewed", AttributeValue.builder().n(String.valueOf(reviewedWords)).build()); values.put(":zero", AttributeValue.builder().n("0").build()); values.put(":now", AttributeValue.builder().s(now).build()); - + String updateExpression = "SET " + "newWordsLearned = if_not_exists(newWordsLearned, :zero) + :new, " + "wordsReviewed = if_not_exists(wordsReviewed, :zero) + :reviewed, " + "updatedAt = :now, " + "createdAt = if_not_exists(createdAt, :now)"; - + UpdateItemRequest request = UpdateItemRequest.builder() .tableName(TABLE_NAME) .key(key) .updateExpression(updateExpression) .expressionAttributeValues(values) .build(); - + AwsClients.dynamoDb().updateItem(request); } - + /** * Streak(연속 학습일) 업데이트 */ @@ -236,35 +236,35 @@ public void updateStreak(String userId, int currentStreak, int longestStreak, St String pk = StatsKey.userStatsPk(userId); String sk = StatsKey.statsTotalSk(); String now = Instant.now().toString(); - + Map key = new HashMap<>(); key.put("PK", AttributeValue.builder().s(pk).build()); key.put("SK", AttributeValue.builder().s(sk).build()); - + Map values = new HashMap<>(); values.put(":current", AttributeValue.builder().n(String.valueOf(currentStreak)).build()); values.put(":longest", AttributeValue.builder().n(String.valueOf(longestStreak)).build()); values.put(":lastDate", AttributeValue.builder().s(lastStudyDate).build()); values.put(":now", AttributeValue.builder().s(now).build()); - + String updateExpression = "SET " + "currentStreak = :current, " + "longestStreak = :longest, " + "lastStudyDate = :lastDate, " + "updatedAt = :now, " + "createdAt = if_not_exists(createdAt, :now)"; - + UpdateItemRequest request = UpdateItemRequest.builder() .tableName(TABLE_NAME) .key(key) .updateExpression(updateExpression) .expressionAttributeValues(values) .build(); - + AwsClients.dynamoDb().updateItem(request); logger.info("Updated streak: userId={}, current={}, longest={}", userId, currentStreak, longestStreak); } - + /** * 게임 통계 Atomic 업데이트 */ @@ -273,11 +273,11 @@ public void incrementGameStats(String userId, int gamesPlayed, int gamesWon, String pk = StatsKey.userStatsPk(userId); String sk = StatsKey.statsTotalSk(); String now = Instant.now().toString(); - + Map key = new HashMap<>(); key.put("PK", AttributeValue.builder().s(pk).build()); key.put("SK", AttributeValue.builder().s(sk).build()); - + Map values = new HashMap<>(); values.put(":gamesPlayed", AttributeValue.builder().n(String.valueOf(gamesPlayed)).build()); values.put(":gamesWon", AttributeValue.builder().n(String.valueOf(gamesWon)).build()); @@ -287,7 +287,7 @@ public void incrementGameStats(String userId, int gamesPlayed, int gamesWon, values.put(":perfectDraws", AttributeValue.builder().n(String.valueOf(perfectDraws)).build()); values.put(":zero", AttributeValue.builder().n("0").build()); values.put(":now", AttributeValue.builder().s(now).build()); - + String updateExpression = "SET " + "gamesPlayed = if_not_exists(gamesPlayed, :zero) + :gamesPlayed, " + "gamesWon = if_not_exists(gamesWon, :zero) + :gamesWon, " + @@ -297,19 +297,235 @@ public void incrementGameStats(String userId, int gamesPlayed, int gamesWon, "perfectDraws = if_not_exists(perfectDraws, :zero) + :perfectDraws, " + "updatedAt = :now, " + "createdAt = if_not_exists(createdAt, :now)"; - + UpdateItemRequest request = UpdateItemRequest.builder() .tableName(TABLE_NAME) .key(key) .updateExpression(updateExpression) .expressionAttributeValues(values) .build(); - + AwsClients.dynamoDb().updateItem(request); logger.info("Incremented game stats: userId={}, gamesPlayed={}, gamesWon={}, correctGuesses={}", userId, gamesPlayed, gamesWon, correctGuesses); } - + + /** + * 뉴스 읽기 통계 Atomic 업데이트 (TOTAL + DAILY) + */ + public UserStats incrementNewsReadStats(String userId) { + String today = LocalDate.now().toString(); + String pk = StatsKey.userStatsPk(userId); + String now = Instant.now().toString(); + + // 먼저 현재 통계 조회 (streak 계산용) + UserStats currentStats = findTotalStats(userId).orElse(null); + String lastNewsReadDate = currentStats != null ? currentStats.getLastNewsReadDate() : null; + + // 연속 읽기 계산 + int currentStreak = 1; + if (lastNewsReadDate != null) { + LocalDate lastDate = LocalDate.parse(lastNewsReadDate); + LocalDate todayDate = LocalDate.now(); + if (lastDate.equals(todayDate.minusDays(1))) { + // 어제 읽었으면 streak 증가 + currentStreak = (currentStats.getNewsStreak() != null ? currentStats.getNewsStreak() : 0) + 1; + } else if (lastDate.equals(todayDate)) { + // 오늘 이미 읽었으면 streak 유지 + currentStreak = currentStats.getNewsStreak() != null ? currentStats.getNewsStreak() : 1; + } + // 그 외의 경우는 streak 1로 초기화 + } + + Map values = new HashMap<>(); + values.put(":one", AttributeValue.builder().n("1").build()); + values.put(":zero", AttributeValue.builder().n("0").build()); + values.put(":streak", AttributeValue.builder().n(String.valueOf(currentStreak)).build()); + values.put(":today", AttributeValue.builder().s(today).build()); + values.put(":now", AttributeValue.builder().s(now).build()); + + // 1. TOTAL 통계 업데이트 + Map totalKey = new HashMap<>(); + totalKey.put("PK", AttributeValue.builder().s(pk).build()); + totalKey.put("SK", AttributeValue.builder().s(StatsKey.statsTotalSk()).build()); + + String totalUpdateExpression = "SET " + + "newsRead = if_not_exists(newsRead, :zero) + :one, " + + "newsStreak = :streak, " + + "lastNewsReadDate = :today, " + + "updatedAt = :now, " + + "createdAt = if_not_exists(createdAt, :now)"; + + UpdateItemRequest totalRequest = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(totalKey) + .updateExpression(totalUpdateExpression) + .expressionAttributeValues(values) + .returnValues(software.amazon.awssdk.services.dynamodb.model.ReturnValue.ALL_NEW) + .build(); + + AwsClients.dynamoDb().updateItem(totalRequest); + + // 2. DAILY 통계 업데이트 + Map dailyKey = new HashMap<>(); + dailyKey.put("PK", AttributeValue.builder().s(pk).build()); + dailyKey.put("SK", AttributeValue.builder().s(StatsKey.statsDailySk(today)).build()); + + Map dailyValues = new HashMap<>(); + dailyValues.put(":one", AttributeValue.builder().n("1").build()); + dailyValues.put(":zero", AttributeValue.builder().n("0").build()); + dailyValues.put(":now", AttributeValue.builder().s(now).build()); + dailyValues.put(":today", AttributeValue.builder().s(today).build()); + + String dailyUpdateExpression = "SET " + + "newsRead = if_not_exists(newsRead, :zero) + :one, " + + "updatedAt = :now, " + + "createdAt = if_not_exists(createdAt, :now), " + + "period = if_not_exists(period, :today)"; + + UpdateItemRequest dailyRequest = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(dailyKey) + .updateExpression(dailyUpdateExpression) + .expressionAttributeValues(dailyValues) + .build(); + + AwsClients.dynamoDb().updateItem(dailyRequest); + logger.info("Incremented news read stats (TOTAL + DAILY): userId={}, streak={}", userId, currentStreak); + + return findTotalStats(userId).orElse(null); + } + + /** + * 뉴스 퀴즈 통계 Atomic 업데이트 (TOTAL + DAILY) + */ + public UserStats incrementNewsQuizStats(String userId, boolean isPerfect) { + String today = LocalDate.now().toString(); + String pk = StatsKey.userStatsPk(userId); + String now = Instant.now().toString(); + + Map values = new HashMap<>(); + values.put(":one", AttributeValue.builder().n("1").build()); + values.put(":zero", AttributeValue.builder().n("0").build()); + values.put(":perfect", AttributeValue.builder().n(isPerfect ? "1" : "0").build()); + values.put(":now", AttributeValue.builder().s(now).build()); + + // 1. TOTAL 통계 업데이트 + Map totalKey = new HashMap<>(); + totalKey.put("PK", AttributeValue.builder().s(pk).build()); + totalKey.put("SK", AttributeValue.builder().s(StatsKey.statsTotalSk()).build()); + + String totalUpdateExpression = "SET " + + "newsQuizCompleted = if_not_exists(newsQuizCompleted, :zero) + :one, " + + "newsQuizPerfect = if_not_exists(newsQuizPerfect, :zero) + :perfect, " + + "updatedAt = :now, " + + "createdAt = if_not_exists(createdAt, :now)"; + + UpdateItemRequest totalRequest = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(totalKey) + .updateExpression(totalUpdateExpression) + .expressionAttributeValues(values) + .returnValues(software.amazon.awssdk.services.dynamodb.model.ReturnValue.ALL_NEW) + .build(); + + AwsClients.dynamoDb().updateItem(totalRequest); + + // 2. DAILY 통계 업데이트 + Map dailyKey = new HashMap<>(); + dailyKey.put("PK", AttributeValue.builder().s(pk).build()); + dailyKey.put("SK", AttributeValue.builder().s(StatsKey.statsDailySk(today)).build()); + + Map dailyValues = new HashMap<>(); + dailyValues.put(":one", AttributeValue.builder().n("1").build()); + dailyValues.put(":zero", AttributeValue.builder().n("0").build()); + dailyValues.put(":perfect", AttributeValue.builder().n(isPerfect ? "1" : "0").build()); + dailyValues.put(":now", AttributeValue.builder().s(now).build()); + dailyValues.put(":today", AttributeValue.builder().s(today).build()); + + String dailyUpdateExpression = "SET " + + "newsQuizCompleted = if_not_exists(newsQuizCompleted, :zero) + :one, " + + "newsQuizPerfect = if_not_exists(newsQuizPerfect, :zero) + :perfect, " + + "updatedAt = :now, " + + "createdAt = if_not_exists(createdAt, :now), " + + "period = if_not_exists(period, :today)"; + + UpdateItemRequest dailyRequest = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(dailyKey) + .updateExpression(dailyUpdateExpression) + .expressionAttributeValues(dailyValues) + .build(); + + AwsClients.dynamoDb().updateItem(dailyRequest); + logger.info("Incremented news quiz stats (TOTAL + DAILY): userId={}, isPerfect={}", userId, isPerfect); + + return findTotalStats(userId).orElse(null); + } + + /** + * 뉴스 단어 수집 통계 Atomic 업데이트 (TOTAL + DAILY) + */ + public UserStats incrementNewsWordStats(String userId, int wordCount) { + String today = LocalDate.now().toString(); + String pk = StatsKey.userStatsPk(userId); + String now = Instant.now().toString(); + + Map values = new HashMap<>(); + values.put(":count", AttributeValue.builder().n(String.valueOf(wordCount)).build()); + values.put(":zero", AttributeValue.builder().n("0").build()); + values.put(":now", AttributeValue.builder().s(now).build()); + + // 1. TOTAL 통계 업데이트 + Map totalKey = new HashMap<>(); + totalKey.put("PK", AttributeValue.builder().s(pk).build()); + totalKey.put("SK", AttributeValue.builder().s(StatsKey.statsTotalSk()).build()); + + String totalUpdateExpression = "SET " + + "newsWordsCollected = if_not_exists(newsWordsCollected, :zero) + :count, " + + "updatedAt = :now, " + + "createdAt = if_not_exists(createdAt, :now)"; + + UpdateItemRequest totalRequest = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(totalKey) + .updateExpression(totalUpdateExpression) + .expressionAttributeValues(values) + .returnValues(software.amazon.awssdk.services.dynamodb.model.ReturnValue.ALL_NEW) + .build(); + + AwsClients.dynamoDb().updateItem(totalRequest); + + // 2. DAILY 통계 업데이트 + Map dailyKey = new HashMap<>(); + dailyKey.put("PK", AttributeValue.builder().s(pk).build()); + dailyKey.put("SK", AttributeValue.builder().s(StatsKey.statsDailySk(today)).build()); + + Map dailyValues = new HashMap<>(); + dailyValues.put(":count", AttributeValue.builder().n(String.valueOf(wordCount)).build()); + dailyValues.put(":zero", AttributeValue.builder().n("0").build()); + dailyValues.put(":now", AttributeValue.builder().s(now).build()); + dailyValues.put(":today", AttributeValue.builder().s(today).build()); + + String dailyUpdateExpression = "SET " + + "newsWordsCollected = if_not_exists(newsWordsCollected, :zero) + :count, " + + "updatedAt = :now, " + + "createdAt = if_not_exists(createdAt, :now), " + + "period = if_not_exists(period, :today)"; + + UpdateItemRequest dailyRequest = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(dailyKey) + .updateExpression(dailyUpdateExpression) + .expressionAttributeValues(dailyValues) + .build(); + + AwsClients.dynamoDb().updateItem(dailyRequest); + logger.info("Incremented news word stats (TOTAL + DAILY): userId={}, wordCount={}", userId, wordCount); + + return findTotalStats(userId).orElse(null); + } + /** * 현재 연도-주차 반환 (예: 2026-W02) */ @@ -320,7 +536,7 @@ private String getYearWeek() { int year = now.get(weekFields.weekBasedYear()); return String.format("%d-W%02d", year, week); } - + /** * 현재 연도-월 반환 (예: 2026-01) */ diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index bb4adbd6..3c0134d7 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -1088,6 +1088,14 @@ Resources: Method: GET Auth: Authorizer: CognitoAuthorizer + GetDashboard: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /stats/dashboard + Method: GET + Auth: + Authorizer: CognitoAuthorizer GetStatsHistory: Type: Api Properties: From 11c9eed6bc588bf64dfeb905ebff46c8a9be6581 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 23 Jan 2026 16:11:39 +0900 Subject: [PATCH 506/528] feat: add dashboard stats API and enhance news stats tracking - Introduce `/stats/dashboard` API for retrieving integrated user stats (today, overall, weekly progress, and level distribution) - Update `UserStats` model to include additional fields for news stats (newsRead, newsQuizCompleted, newsQuizPerfect, newsWordsCollected, etc.) - Add atomic updates to track news reading, quiz, and word stats in `UserStatsRepository` - Modify CloudFormation `template.yaml` to register the new `/stats/dashboard` endpoint --- .../docs/NEWS_API_FRONTEND_CHANGES.md | 304 ++++ .../docs/VOCABULARY_NEWS_INTEGRATION.md | 308 +++++ docs/CATCHMIND_ARCHITECTURE_SOLUTION.md | 521 ------- docs/CICD-IMPLEMENTATION-QNA.md | 421 ------ docs/FRONTEND-API-GUIDE.md | 365 ----- docs/MIDTERM-REPORT.md | 439 ------ docs/domain-reports/BADGE-DOMAIN-REPORT.md | 681 --------- docs/domain-reports/CHATTING-DOMAIN-REPORT.md | 434 ------ docs/domain-reports/COMMON-MODULE-REPORT.md | 1228 ----------------- docs/domain-reports/GRAMMAR-DOMAIN-REPORT.md | 465 ------- docs/domain-reports/STATS-DOMAIN-REPORT.md | 379 ----- .../VOCABULARY-DOMAIN-REPORT.md | 504 ------- 12 files changed, 612 insertions(+), 5437 deletions(-) create mode 100644 ServerlessFunction/docs/NEWS_API_FRONTEND_CHANGES.md create mode 100644 ServerlessFunction/docs/VOCABULARY_NEWS_INTEGRATION.md delete mode 100644 docs/CATCHMIND_ARCHITECTURE_SOLUTION.md delete mode 100644 docs/CICD-IMPLEMENTATION-QNA.md delete mode 100644 docs/FRONTEND-API-GUIDE.md delete mode 100644 docs/MIDTERM-REPORT.md delete mode 100644 docs/domain-reports/BADGE-DOMAIN-REPORT.md delete mode 100644 docs/domain-reports/CHATTING-DOMAIN-REPORT.md delete mode 100644 docs/domain-reports/COMMON-MODULE-REPORT.md delete mode 100644 docs/domain-reports/GRAMMAR-DOMAIN-REPORT.md delete mode 100644 docs/domain-reports/STATS-DOMAIN-REPORT.md delete mode 100644 docs/domain-reports/VOCABULARY-DOMAIN-REPORT.md diff --git a/ServerlessFunction/docs/NEWS_API_FRONTEND_CHANGES.md b/ServerlessFunction/docs/NEWS_API_FRONTEND_CHANGES.md new file mode 100644 index 00000000..2d63691a --- /dev/null +++ b/ServerlessFunction/docs/NEWS_API_FRONTEND_CHANGES.md @@ -0,0 +1,304 @@ +# News API 프론트엔드 변경사항 + +> 마지막 업데이트: 2025-01-23 + +## 목차 +1. [기사 목록 조회 API 변경](#1-기사-목록-조회-api-변경) +2. [기사 상세 조회 API 변경](#2-기사-상세-조회-api-변경) +3. [키워드 필드 추가](#3-키워드-필드-추가) +4. [인증 필수 엔드포인트](#4-인증-필수-엔드포인트) +5. [API 응답 예시](#5-api-응답-예시) + +--- + +## 1. 기사 목록 조회 API 변경 + +### 영향받는 엔드포인트 +- `GET /news` - 뉴스 목록 조회 +- `GET /news/today` - 오늘의 뉴스 조회 +- `GET /news/recommended` - 추천 뉴스 조회 + +### 변경사항 +각 기사 객체에 `isBookmarked` 필드가 추가되었습니다. + +| 필드 | 타입 | 설명 | +|------|------|------| +| `isBookmarked` | boolean | 현재 사용자가 해당 기사를 북마크했는지 여부 | + +### 주의사항 +- **로그인한 사용자**: 실제 북마크 상태 반환 +- **비로그인 사용자**: 모든 기사에 `false` 반환 + +### 기존 응답 (변경 전) +```json +{ + "articles": [ + { + "articleId": "abc123", + "title": "...", + "summary": "...", + "category": "TECH", + "level": "INTERMEDIATE" + } + ] +} +``` + +### 새 응답 (변경 후) +```json +{ + "articles": [ + { + "articleId": "abc123", + "title": "...", + "summary": "...", + "category": "TECH", + "level": "INTERMEDIATE", + "cefrLevel": "B1", + "isBookmarked": true + } + ] +} +``` + +--- + +## 2. 기사 상세 조회 API 변경 + +### 영향받는 엔드포인트 +- `GET /news/{articleId}` - 기사 상세 조회 + +### 변경사항 +응답에 `isBookmarked`와 `isRead` 필드가 추가되었습니다. + +| 필드 | 타입 | 설명 | +|------|------|------| +| `isBookmarked` | boolean | 현재 사용자가 해당 기사를 북마크했는지 여부 | +| `isRead` | boolean | 현재 사용자가 해당 기사를 읽었는지 여부 | + +### 새 응답 형식 +```json +{ + "success": true, + "message": "뉴스 조회 성공", + "data": { + "article": { + "articleId": "abc123", + "title": "Tech Giants Report Strong Quarterly Earnings", + "summary": "Major technology companies...", + "category": "TECH", + "level": "INTERMEDIATE", + "cefrLevel": "B1", + "keywords": [...], + "highlightWords": ["earnings", "revenue", "growth"], + "quiz": [...] + }, + "isBookmarked": true, + "isRead": false + } +} +``` + +--- + +## 3. 키워드 필드 추가 + +### 변경사항 +`keywords` 배열의 각 키워드 객체에 `meaningKo` (한국어 뜻) 필드가 추가되었습니다. + +### 키워드 객체 구조 + +| 필드 | 타입 | 설명 | +|------|------|------| +| `word` | string | 영어 단어 | +| `meaning` | string | 영어 정의 (간단한 설명) | +| `meaningKo` | string | **[신규]** 한국어 뜻 | +| `example` | string | 기사에서 발췌한 예문 | + +### 키워드 예시 +```json +{ + "keywords": [ + { + "word": "economy", + "meaning": "the system of trade and industry", + "meaningKo": "경제", + "example": "The economy is growing steadily." + }, + { + "word": "revenue", + "meaning": "income, especially of a company", + "meaningKo": "수익", + "example": "The company reported record revenue." + } + ] +} +``` + +### 프론트엔드 활용 +- 단어장 기능에서 한국어 뜻 표시 +- 학습 카드에 영어/한국어 뜻 모두 표시 가능 + +--- + +## 4. 인증 필수 엔드포인트 + +다음 엔드포인트들은 Cognito 인증 토큰이 필요합니다. + +### 인증 필수 (Authorization 헤더 필요) +| 메서드 | 엔드포인트 | 설명 | +|--------|------------|------| +| GET | `/news/stats` | 뉴스 학습 통계 조회 | +| GET | `/news/bookmarks` | 북마크 목록 조회 | +| GET | `/news/words` | 수집 단어 목록 조회 | +| GET | `/news/quiz/history` | 퀴즈 기록 조회 | +| POST | `/news/{articleId}/read` | 읽기 완료 기록 | +| POST | `/news/{articleId}/bookmark` | 북마크 토글 | +| GET | `/news/{articleId}/quiz` | 퀴즈 조회 | +| POST | `/news/{articleId}/quiz` | 퀴즈 제출 | +| POST | `/news/{articleId}/words` | 단어 수집 | +| DELETE | `/news/{articleId}/words/{word}` | 단어 삭제 | +| POST | `/news/words/{word}/sync` | 단어 Vocabulary 연동 | + +### 인증 선택 (토큰 있으면 개인화된 응답) +| 메서드 | 엔드포인트 | 설명 | +|--------|------------|------| +| GET | `/news` | 뉴스 목록 (북마크 상태 포함) | +| GET | `/news/today` | 오늘의 뉴스 (북마크 상태 포함) | +| GET | `/news/recommended` | 추천 뉴스 (북마크 상태 포함) | +| GET | `/news/{articleId}` | 기사 상세 (북마크/읽기 상태 포함) | + +### 요청 헤더 예시 +``` +Authorization: Bearer eyJraWQiOiJ... +``` + +--- + +## 5. API 응답 예시 + +### 기사 목록 조회 (GET /news) +```json +{ + "success": true, + "message": "뉴스 목록 조회 성공", + "data": { + "articles": [ + { + "articleId": "news_20250123_001", + "title": "Global Tech Summit Addresses AI Regulation", + "summary": "World leaders gathered to discuss...", + "source": "Reuters", + "publishedAt": "2025-01-23T09:00:00Z", + "category": "TECH", + "level": "INTERMEDIATE", + "cefrLevel": "B1", + "imageUrl": "https://...", + "readCount": 150, + "keywords": [ + { + "word": "regulation", + "meaning": "official rules made by a government", + "meaningKo": "규제", + "example": "New AI regulation will take effect next year." + } + ], + "highlightWords": ["regulation", "summit", "artificial intelligence"], + "isBookmarked": false + } + ], + "nextCursor": "eyJwayI6Ik5FV1MjMjAyNS0wMS0yMyIsInNrIjoiQVJUSUNMRSMxMjM0NSJ9", + "hasMore": true, + "count": 10 + } +} +``` + +### 기사 상세 조회 (GET /news/{articleId}) +```json +{ + "success": true, + "message": "뉴스 조회 성공", + "data": { + "article": { + "articleId": "news_20250123_001", + "title": "Global Tech Summit Addresses AI Regulation", + "summary": "World leaders gathered to discuss the future of artificial intelligence...", + "source": "Reuters", + "publishedAt": "2025-01-23T09:00:00Z", + "category": "TECH", + "level": "INTERMEDIATE", + "cefrLevel": "B1", + "imageUrl": "https://...", + "readCount": 151, + "keywords": [ + { + "word": "regulation", + "meaning": "official rules made by a government", + "meaningKo": "규제", + "example": "New AI regulation will take effect next year." + }, + { + "word": "summit", + "meaning": "an important meeting between leaders", + "meaningKo": "정상회담", + "example": "The summit brought together leaders from 50 countries." + } + ], + "highlightWords": ["regulation", "summit", "artificial intelligence"], + "quiz": [ + { + "questionId": "q1", + "type": "COMPREHENSION", + "question": "What is the main topic of this article?", + "options": ["AI regulation", "Climate change", "Economic policy", "Healthcare"], + "points": 20 + }, + { + "questionId": "q2", + "type": "WORD_MATCH", + "question": "What does 'regulation' mean in this context?", + "options": ["Official rules", "Technology", "Meeting", "Country"], + "points": 15 + }, + { + "questionId": "q3", + "type": "FILL_BLANK", + "question": "World leaders gathered at the _____ to discuss AI.", + "options": ["summit", "office", "factory", "school"], + "points": 30 + } + ] + }, + "isBookmarked": true, + "isRead": false + } +} +``` + +--- + +## 프론트엔드 체크리스트 + +### 기사 목록 화면 +- [ ] 각 기사 카드에 북마크 아이콘 표시 (`isBookmarked` 활용) +- [ ] 북마크된 기사는 다른 색상/아이콘으로 구분 + +### 기사 상세 화면 +- [ ] 북마크 버튼 상태 초기화 (`isBookmarked` 활용) +- [ ] 읽기 완료 표시 (`isRead` 활용) +- [ ] 키워드 목록에 한국어 뜻 표시 (`meaningKo` 활용) + +### 단어장/학습 카드 +- [ ] 한국어 뜻 표시 기능 추가 +- [ ] 영어/한국어 토글 기능 (선택사항) + +### 인증 +- [ ] 필수 인증 엔드포인트에 토큰 전송 확인 +- [ ] 401 에러 처리 (로그인 페이지로 리다이렉트) + +--- + +## 질문 및 문의 + +백엔드 관련 문의사항이 있으면 연락주세요. diff --git a/ServerlessFunction/docs/VOCABULARY_NEWS_INTEGRATION.md b/ServerlessFunction/docs/VOCABULARY_NEWS_INTEGRATION.md new file mode 100644 index 00000000..f1ff6c62 --- /dev/null +++ b/ServerlessFunction/docs/VOCABULARY_NEWS_INTEGRATION.md @@ -0,0 +1,308 @@ +# 단어장 - 뉴스 연동 기능 프론트엔드 가이드 + +> 마지막 업데이트: 2025-01-23 + +## 목차 +1. [뉴스 단어 수집 흐름](#1-뉴스-단어-수집-흐름) +2. [API 엔드포인트](#2-api-엔드포인트) +3. [카테고리 필터링](#3-카테고리-필터링) +4. [응답 예시](#4-응답-예시) +5. [프론트엔드 구현 가이드](#5-프론트엔드-구현-가이드) + +--- + +## 1. 뉴스 단어 수집 흐름 + +### 자동 연동 프로세스 + +``` +사용자가 뉴스 기사에서 "단어 가져오기" 클릭 + ↓ + POST /news/{articleId}/words + ↓ +┌─────────────────────────────────────────┐ +│ 1. 기사 키워드에서 한국어 뜻 추출 │ +│ 2. Word 테이블에 자동 저장 (NEWS 카테고리) │ +│ 3. UserWord에 자동 추가 (NEW 상태) │ +│ 4. NewsWordCollect 기록 저장 │ +└─────────────────────────────────────────┘ + ↓ + 단어장(/user-words)에서 바로 확인 가능! +``` + +### 핵심 포인트 +- **별도의 "연동" 버튼 불필요**: 단어 수집 시 자동으로 단어장에 추가됨 +- **카테고리 자동 설정**: 뉴스에서 수집한 단어는 `NEWS` 카테고리로 저장 +- **한국어 뜻 자동 포함**: 기사 AI 분석 결과에서 `meaningKo` 추출 + +--- + +## 2. API 엔드포인트 + +### 뉴스 단어 수집 API + +#### 단어 수집 (단어 가져오기) +```http +POST /news/{articleId}/words +Authorization: Bearer {token} +Content-Type: application/json + +{ + "word": "economy", + "context": "The economy is growing rapidly" // 선택사항 +} +``` + +**응답:** +```json +{ + "success": true, + "message": "단어 수집 성공", + "data": { + "wordCollect": { + "word": "economy", + "meaning": "경제", + "articleId": "abc123", + "articleTitle": "Global Economic Outlook", + "collectedAt": "2025-01-23T12:00:00Z", + "syncedToVocab": true, + "vocabUserWordId": "economy" + }, + "newBadges": [] + } +} +``` + +#### 뉴스에서 수집한 단어 목록 +```http +GET /news/words?limit=20 +Authorization: Bearer {token} +``` + +**응답:** +```json +{ + "success": true, + "data": { + "words": [ + { + "word": "economy", + "meaning": "경제", + "articleId": "abc123", + "articleTitle": "Global Economic Outlook", + "context": "The economy is growing", + "collectedAt": "2025-01-23T12:00:00Z", + "syncedToVocab": true + } + ], + "stats": { + "totalCollected": 15, + "syncedToVocab": 15 + }, + "count": 1 + } +} +``` + +--- + +### 단어장 API (카테고리 필터 추가됨) + +#### 내 단어장 조회 +```http +GET /user-words?category=NEWS&limit=20 +Authorization: Bearer {token} +``` + +**쿼리 파라미터:** + +| 파라미터 | 타입 | 설명 | 예시 | +|----------|------|------|------| +| `category` | string | 카테고리 필터 **(신규)** | `NEWS`, `DAILY`, `BUSINESS` | +| `status` | string | 학습 상태 필터 | `NEW`, `LEARNING`, `REVIEWING`, `MASTERED` | +| `bookmarked` | boolean | 북마크 필터 | `true` | +| `incorrectOnly` | boolean | 오답만 | `true` | +| `limit` | number | 조회 개수 (최대 50) | `20` | +| `cursor` | string | 페이지네이션 커서 | `eyJ...` | + +--- + +## 3. 카테고리 필터링 + +### 사용 가능한 카테고리 + +| 카테고리 | 코드 | 설명 | +|----------|------|------| +| 일상 | `DAILY` | 일상 생활 단어 | +| 비즈니스 | `BUSINESS` | 비즈니스/업무 단어 | +| 학술 | `ACADEMIC` | 학술/전문 단어 | +| 여행 | `TRAVEL` | 여행 관련 단어 | +| 기술 | `TECHNOLOGY` | IT/기술 단어 | +| **뉴스** | `NEWS` | **뉴스에서 수집한 단어 (신규)** | + +### 필터 조합 예시 + +``` +# 뉴스에서 수집한 모든 단어 +GET /user-words?category=NEWS + +# 뉴스 단어 중 학습 중인 것만 +GET /user-words?category=NEWS&status=LEARNING + +# 뉴스 단어 중 북마크한 것만 +GET /user-words?category=NEWS&bookmarked=true + +# 뉴스 단어 중 틀린 것만 +GET /user-words?category=NEWS&incorrectOnly=true + +# 모든 카테고리의 북마크 단어 +GET /user-words?bookmarked=true +``` + +--- + +## 4. 응답 예시 + +### 단어장 조회 응답 (GET /user-words?category=NEWS) + +```json +{ + "success": true, + "message": "User words retrieved", + "data": { + "userWords": [ + { + "wordId": "economy", + "userId": "user-123", + "status": "NEW", + "correctCount": 0, + "incorrectCount": 0, + "bookmarked": false, + "favorite": false, + "difficulty": null, + "nextReviewAt": null, + "lastReviewedAt": null, + "repetitions": 0, + "interval": 0, + "english": "economy", + "korean": "경제", + "level": "INTERMEDIATE", + "category": "NEWS", + "example": "The economy is growing steadily.", + "maleVoiceKey": null, + "femaleVoiceKey": null + }, + { + "wordId": "regulation", + "userId": "user-123", + "status": "LEARNING", + "correctCount": 2, + "incorrectCount": 1, + "bookmarked": true, + "favorite": false, + "difficulty": "HARD", + "english": "regulation", + "korean": "규제", + "level": "ADVANCED", + "category": "NEWS", + "example": "New regulation will take effect." + } + ], + "nextCursor": "eyJwayI6IlVTRVIjdXNlci0xMjMiLCJzayI6IldPUkQjcmVndWxhdGlvbiJ9", + "hasMore": true + } +} +``` + +--- + +## 5. 프론트엔드 구현 가이드 + +### 단어장 UI 변경사항 + +#### 1. 카테고리 탭/필터 추가 +``` +[전체] [일상] [비즈니스] [학술] [여행] [기술] [뉴스] + ↑ 신규 +``` + +#### 2. 뉴스 단어 표시 +- 뉴스에서 수집한 단어는 `category: "NEWS"` 표시 +- 출처 표시 가능 (NewsWordCollect의 articleTitle 활용) + +#### 3. 단어 수집 후 UI 업데이트 +```javascript +// 단어 수집 API 호출 +const response = await fetch(`/news/${articleId}/words`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ word: selectedWord }) +}); + +const result = await response.json(); + +if (result.success) { + // syncedToVocab: true 이므로 단어장에 자동 추가됨 + showToast('단어가 단어장에 추가되었습니다!'); + + // 새 배지 획득 시 알림 + if (result.data.newBadges?.length > 0) { + showBadgeNotification(result.data.newBadges); + } +} +``` + +### 체크리스트 + +#### 단어장 페이지 +- [ ] 카테고리 필터 UI 추가 (탭 또는 드롭다운) +- [ ] `NEWS` 카테고리 옵션 추가 +- [ ] API 호출 시 `category` 파라미터 전달 +- [ ] 카테고리별 단어 개수 표시 (선택사항) + +#### 뉴스 상세 페이지 +- [ ] "단어 가져오기" 버튼 동작 확인 +- [ ] 수집 성공 시 토스트 메시지 +- [ ] 이미 수집된 단어 표시 (비활성화 또는 체크 아이콘) + +#### 뉴스 키워드 표시 +- [ ] `keywords` 배열의 `meaningKo` 필드 표시 +- [ ] 각 키워드 클릭 시 수집 가능하도록 UI 구성 + +--- + +## 데이터 흐름 다이어그램 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 뉴스 기사 상세 │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ Keywords: │ │ +│ │ [economy: 경제] [regulation: 규제] [summit: 정상회담] │ │ +│ │ ↓ 클릭 │ │ +│ │ "단어 가져오기" → POST /news/{id}/words │ │ +│ └─────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ + ↓ + 자동으로 단어장에 추가 + ↓ +┌─────────────────────────────────────────────────────────────────┐ +│ 단어장 │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ 카테고리: [전체] [일상] [비즈니스] ... [뉴스✓] │ │ +│ │ │ │ +│ │ economy 경제 NEW 뉴스 │ │ +│ │ regulation 규제 LEARNING 뉴스 ⭐ │ │ +│ │ summit 정상회담 NEW 뉴스 │ │ +│ └─────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 질문 및 문의 + +백엔드 관련 문의사항이 있으면 연락주세요. diff --git a/docs/CATCHMIND_ARCHITECTURE_SOLUTION.md b/docs/CATCHMIND_ARCHITECTURE_SOLUTION.md deleted file mode 100644 index e4c22aa4..00000000 --- a/docs/CATCHMIND_ARCHITECTURE_SOLUTION.md +++ /dev/null @@ -1,521 +0,0 @@ -# 채팅방 / 캐치마인드 게임 분리 - 종합 솔루션 - -## 1. 현재 문제점 분석 - -### 1.1 백엔드 현황 - -``` -ChatRoom.java (현재 - 혼합 모델) -├── 채팅 필드 -│ ├── roomId, name, description -│ ├── memberIds, currentMembers -│ └── lastMessageAt -│ -└── 게임 필드 (여기에 섞여있음) - ├── gameStatus, gameStartedBy - ├── currentRound, totalRounds - ├── currentDrawerId, currentWord - ├── roundStartTime, roundTimeLimit ← serverTime 없음! - ├── scores, streaks - └── correctGuessers -``` - -**문제점:** - -1. `roundStartTime`만 전송, `serverTime` 누락 → 클라이언트 타이머 동기화 불가 -2. 게임 세션이 채팅방에 종속 → 게임 상태 독립 관리 불가 -3. 재접속 시 게임 상태 복구 어려움 -4. 게임 종료 후 상태 정리 복잡 - -### 1.2 WebSocket 메시지 현황 - -```java -// WebSocketMessageHandler.java - 현재 구조 -handleRequest() { - switch (messageType) { - case "DRAWING", "DRAWING_CLEAR" -> handleDrawingMessage() // 게임 - default -> handleRegularMessage() { - // 1. 슬래시 명령어 처리 (/start, /stop, /score...) - // 2. 게임 중 정답 체크 - // 3. 일반 채팅 메시지 - } - } -} -``` - -**문제점:** - -- 채팅/게임 구분 없이 모든 메시지가 동일 핸들러에서 처리 -- 메시지에 `domain` 필드 없음 - ---- - -## 2. 최적 솔루션 - -### 2.1 아키텍처 개요 - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ WebSocket (단일 엔드포인트 유지) │ -│ │ -│ ┌──────────────────────┐ ┌────────────────────────────────┐ │ -│ │ domain: "chat" │ │ domain: "game" │ │ -│ │ │ │ │ │ -│ │ • TEXT │ │ • GAME_START / GAME_END │ │ -│ │ • USER_JOIN │ │ • ROUND_START / ROUND_END │ │ -│ │ • USER_LEAVE │ │ • DRAWING / DRAWING_CLEAR │ │ -│ │ • SYSTEM │ │ • GUESS / CORRECT_ANSWER │ │ -│ │ │ │ • SCORE_UPDATE / HINT │ │ -│ └──────────────────────┘ └────────────────────────────────┘ │ -│ │ -│ GameSession (별도 모델) │ -│ ├── gameSessionId │ -│ ├── roomId (연결용) │ -│ ├── status, currentRound │ -│ ├── roundStartTime + serverTime ← 핵심! │ -│ └── scores, players │ -└─────────────────────────────────────────────────────────────────┘ -``` - -### 2.2 핵심 변경사항 - -| 구분 | 현재 | 변경 후 | -|-----|----------------------|---------------------------------| -| 모델 | `ChatRoom`에 게임 필드 포함 | `ChatRoom` + `GameSession` 분리 | -| 타이머 | `roundStartTime`만 전송 | `roundStartTime` + `serverTime` | -| 메시지 | `messageType`만 존재 | `domain` + `messageType` | -| API | 채팅방 API만 존재 | 게임 세션 API 추가 | - ---- - -## 3. 백엔드 변경사항 - -### 3.1 Phase 1: 타이머 버그 수정 (즉시) - -**변경 파일:** `WebSocketMessageHandler.java` - -```java -// GAME_START 메시지에 serverTime 추가 -private void broadcastGameStart(...) { - Map message = new HashMap<>(); - // ... 기존 코드 ... - - message.put("roundStartTime", gameResult.room().getRoundStartTime()); - message.put("serverTime", System.currentTimeMillis()); // 추가! - message.put("roundDuration", gameResult.room().getRoundTimeLimit()); // 명확한 이름 - - // ... -} - -// ROUND_END → ROUND_START 메시지에도 동일하게 추가 -private void broadcastRoundEnd(...) { - // ... - messageData.put("roundStartTime", room.getRoundStartTime()); - messageData.put("serverTime", System.currentTimeMillis()); // 추가! - messageData.put("roundDuration", room.getRoundTimeLimit()); - // ... -} -``` - -**예상 작업량:** 30분 - -### 3.2 Phase 2: 메시지 구조 개선 (1일) - -**변경 파일:** `WebSocketMessageHandler.java`, 모든 브로드캐스트 메서드 - -```java -// 모든 메시지에 domain 필드 추가 -private Map createMessage(String domain, String messageType, Object data) { - Map message = new HashMap<>(); - message.put("domain", domain); // "chat" 또는 "game" - message.put("messageType", messageType); - message.put("data", data); - message.put("timestamp", System.currentTimeMillis()); - return message; -} - -// 채팅 메시지 -createMessage("chat", "TEXT", chatData); -createMessage("chat", "USER_JOIN", joinData); - -// 게임 메시지 -createMessage("game", "GAME_START", gameStartData); -createMessage("game", "ROUND_START", roundStartData); -createMessage("game", "DRAWING", drawingData); -``` - -### 3.3 Phase 3: 게임 세션 분리 (1주) - -#### 3.3.1 새 모델: GameSession.java - -```java -@DynamoDbBean -public class GameSession { - private String pk; // GAME#{gameSessionId} - private String sk; // METADATA - private String gsi1pk; // ROOM#{roomId} - private String gsi1sk; // GAME#{createdAt} - - // 게임 식별 - private String gameSessionId; - private String roomId; // 연결된 채팅방 - private String gameType; // "catchmind" - - // 게임 상태 - private String status; // WAITING, PLAYING, FINISHED - private String startedBy; - private Long startedAt; - private Long endedAt; - - // 라운드 정보 - private Integer currentRound; - private Integer totalRounds; - private String currentDrawerId; - private String currentWordId; - private String currentWord; - private Long roundStartTime; - private Integer roundDuration; - - // 점수 - private Map scores; - private Map streaks; - private List players; - private List drawerOrder; - - // 자동 종료 - private Long gameEndScheduledAt; - private String scheduleRuleArn; - - // TTL - private Long ttl; -} -``` - -#### 3.3.2 ChatRoom에서 게임 필드 제거 - -```java -@DynamoDbBean -public class ChatRoom { - // 채팅 필드만 유지 - private String roomId; - private String name; - private String description; - private String level; - private Integer currentMembers; - private Integer maxMembers; - private Boolean isPrivate; - private String password; - private String createdBy; - private String createdAt; - private String lastMessageAt; - private List memberIds; - - // 게임 연결 (참조만) - private String activeGameSessionId; // 현재 진행중인 게임 세션 ID - - // 게임 필드 모두 제거! - // - gameStatus, gameStartedBy, currentRound... 전부 GameSession으로 이동 -} -``` - -#### 3.3.3 게임 세션 API - -``` -# 게임 세션 생성 -POST /api/chat/rooms/{roomId}/games -Request: -{ - "gameType": "catchmind", - "settings": { - "totalRounds": 5, - "roundDuration": 60 - } -} - -Response: -{ - "gameSessionId": "game-abc123", - "roomId": "room-xyz", - "status": "WAITING", - "createdAt": "2024-01-20T10:00:00Z" -} - -# 게임 상태 조회 (재접속 시 필수!) -GET /api/games/{gameSessionId} - -Response: -{ - "gameSessionId": "game-abc123", - "roomId": "room-xyz", - "status": "PLAYING", - "currentRound": 2, - "totalRounds": 5, - "currentDrawerId": "user123", - "roundStartTime": 1705744800000, - "serverTime": 1705744830000, // 핵심! - "roundDuration": 60, - "scores": { - "user1": 150, - "user2": 120 - }, - "players": ["user1", "user2", "user3"] -} - -# 게임 시작 (기존 /start 명령어 대체) -POST /api/games/{gameSessionId}/start - -# 게임 종료 -POST /api/games/{gameSessionId}/stop -``` - ---- - -## 4. 프론트엔드 변경사항 - -### 4.1 Phase 1: 타이머 버그 수정 (즉시) - -```javascript -// useTimer.js - 독립적인 타이머 훅 -export function useTimer(roundStartTime, roundDuration, serverTime) { - const [remainingTime, setRemainingTime] = useState(roundDuration); - - useEffect(() => { - if (!roundStartTime || !roundDuration) return; - - // 서버-클라이언트 시간 차이 보정 - const timeOffset = serverTime ? (Date.now() - serverTime) : 0; - - const interval = setInterval(() => { - const adjustedNow = Date.now() - timeOffset; - const elapsed = Math.floor((adjustedNow - roundStartTime) / 1000); - const remaining = Math.max(0, roundDuration - elapsed); - setRemainingTime(remaining); - - if (remaining <= 0) { - clearInterval(interval); - } - }, 100); - - return () => clearInterval(interval); - }, [roundStartTime, roundDuration, serverTime]); - - return remainingTime; -} -``` - -### 4.2 Phase 2: 메시지 핸들러 분리 - -```javascript -// WebSocket 메시지 핸들러 -onMessage(event) { - const message = JSON.parse(event.data); - - switch (message.domain) { - case 'chat': - this.handleChatMessage(message); - break; - case 'game': - this.handleGameMessage(message); - break; - } -} - -handleChatMessage(message) { - switch (message.messageType) { - case 'TEXT': // 채팅 메시지 - case 'USER_JOIN': - case 'USER_LEAVE': - case 'SYSTEM': - } -} - -handleGameMessage(message) { - switch (message.messageType) { - case 'GAME_START': - case 'ROUND_START': - case 'DRAWING': - case 'CORRECT_ANSWER': - case 'SCORE_UPDATE': - } -} -``` - -### 4.3 Phase 3: 훅 분리 - -``` -src/domains/ -├── chat/ -│ ├── hooks/ -│ │ └── useChatWebSocket.js # 채팅만 처리 -│ └── components/ -│ ├── ChatMessages.jsx -│ └── ChatInput.jsx -│ -├── catchmind/ -│ ├── hooks/ -│ │ ├── useGameWebSocket.js # 게임만 처리 -│ │ ├── useGameState.js -│ │ └── useTimer.js -│ └── components/ -│ ├── DrawingCanvas.jsx -│ ├── ScoreBoard.jsx -│ └── Timer.jsx -│ -└── freetalk/ - └── pages/ - └── FreeTalkPage.jsx # chat + catchmind 조합 -``` - ---- - -## 5. 메시지 스펙 (최종) - -### 5.1 공통 메시지 구조 - -```json -{ - "domain": "chat" | "game", - "messageType": "...", - "data": { ... }, - "timestamp": 1705744800000 -} -``` - -### 5.2 채팅 메시지 - -| Type | 방향 | data 필드 | -|--------------|-----|-----------------------------------------------| -| `TEXT` | 양방향 | `messageId`, `userId`, `content`, `createdAt` | -| `USER_JOIN` | S→C | `userId`, `memberCount` | -| `USER_LEAVE` | S→C | `userId`, `memberCount` | -| `SYSTEM` | S→C | `content` | - -### 5.3 게임 메시지 - -| Type | 방향 | data 필드 | -|------------------|-----|---------------------------------------------------------------------------------------------------------------| -| `GAME_START` | S→C | `gameSessionId`, `totalRounds`, `currentDrawerId`, `roundStartTime`, `serverTime`, `roundDuration`, `players` | -| `GAME_END` | S→C | `gameSessionId`, `reason`, `finalScores`, `winner` | -| `ROUND_START` | S→C | `currentRound`, `currentDrawerId`, `roundStartTime`, `serverTime`, `roundDuration`, `currentWord`(출제자만) | -| `ROUND_END` | S→C | `currentRound`, `answer`, `scores` | -| `DRAWING` | 양방향 | `drawingData` | -| `DRAWING_CLEAR` | 양방향 | - | -| `GUESS` | C→S | `content` | -| `CORRECT_ANSWER` | S→C | `userId`, `score`, `elapsedTime` | -| `SCORE_UPDATE` | S→C | `scores`, `currentRound`, `totalRounds` | -| `HINT` | S→C | `hint` | - -### 5.4 ROUND_START 상세 (핵심!) - -```json -{ - "domain": "game", - "messageType": "ROUND_START", - "data": { - "gameSessionId": "game-abc123", - "currentRound": 2, - "totalRounds": 5, - "currentDrawerId": "user123", - "roundStartTime": 1705744800000, - "serverTime": 1705744800500, - "roundDuration": 60, - "currentWord": { - "wordId": "word-1", - "word": "apple" - } - }, - "timestamp": 1705744800500 -} -``` - -**중요:** `currentWord`는 출제자에게만 전송! - ---- - -## 6. 구현 일정 - -``` -Week 1: 긴급 버그 수정 -├── [BE] serverTime 필드 추가 (0.5일) -├── [FE] useTimer 훅 수정 (0.5일) -├── [BE] 메시지에 domain 필드 추가 (1일) -└── [FE] 메시지 핸들러 domain 분기 (0.5일) - -Week 2: 게임 세션 분리 (BE) -├── [BE] GameSession 모델 생성 -├── [BE] GameSessionRepository 구현 -├── [BE] GameService 리팩토링 -└── [BE] 게임 세션 API 구현 - -Week 3: 프론트엔드 리팩토링 -├── [FE] useChatWebSocket 분리 -├── [FE] useGameWebSocket 신규 -├── [FE] 컴포넌트 분리 -└── [FE/BE] 통합 테스트 - -Week 4: 안정화 및 추가 기능 -├── [BE] 게임 자동 종료 (7분) - Issue #417 -├── [BE] 재접속 시 게임 상태 복구 -└── [FE/BE] E2E 테스트 -``` - ---- - -## 7. 기대 효과 - -| 항목 | 현재 | 개선 후 | -|---------|-------------|------------------| -| 타이머 정확도 | 클라이언트 시계 의존 | 서버 시간 기준 동기화 | -| 재접속 | 게임 상태 유실 | 완전 복구 가능 | -| 테스트 | 채팅/게임 분리 불가 | 독립 테스트 가능 | -| 확장성 | 새 게임 추가 어려움 | gameType으로 확장 용이 | -| 유지보수 | 책임 혼재 | 명확한 책임 분리 | - ---- - -## 8. 즉시 적용 (백엔드 변경 전 프론트엔드 임시 조치) - -```javascript -// 백엔드 변경 전까지 프론트엔드에서 적용 가능한 임시 코드 - -onRoundStart: (data) => { - const roundData = data.data || data; - const now = Date.now(); - - // serverTime이 없으면 클라이언트 시간 사용 (임시) - const serverTime = roundData.serverTime || now; - let roundStartTime = roundData.roundStartTime || now; - - // roundStartTime이 미래 시간이면 현재로 보정 - if (roundStartTime > now + 1000) { - console.warn('Invalid roundStartTime, using current time'); - roundStartTime = now; - } - - setGameState((prev) => ({ - ...prev, - currentRound: roundData.currentRound, - currentDrawerId: roundData.currentDrawerId, - roundStartTime: roundStartTime, - serverTime: serverTime, - roundDuration: roundData.roundDuration || roundData.roundTimeLimit || 60, - })); -} -``` - ---- - -## 9. 결론 - -**우선순위:** - -1. **즉시 (이번 주)**: `serverTime` 추가 + `domain` 필드 추가 -2. **단기 (2주)**: GameSession 모델 분리 + API 구현 -3. **중기 (3-4주)**: FE/BE 완전 분리 + 자동 종료 + 재접속 복구 - -**핵심 원칙:** - -- 단일 WebSocket 엔드포인트 유지 (비용/복잡도) -- `domain` 필드로 채팅/게임 구분 -- `serverTime`으로 정확한 타이머 동기화 -- GameSession 독립 모델로 상태 관리 명확화 diff --git a/docs/CICD-IMPLEMENTATION-QNA.md b/docs/CICD-IMPLEMENTATION-QNA.md deleted file mode 100644 index e00c5a11..00000000 --- a/docs/CICD-IMPLEMENTATION-QNA.md +++ /dev/null @@ -1,421 +0,0 @@ -# CI/CD 파이프라인 구현 설명 및 면접 Q&A - -## 1. CI/CD 아키텍처 개요 - -``` -┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ -│ GitHub │───▶│ CodePipeline│───▶│ CodeBuild │───▶│CloudFormation│ -│ (Source) │ │ (Pipeline) │ │ (Build) │ │ (Deploy) │ -└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ - │ │ │ │ - │ ▼ ▼ ▼ - │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ - │ │ SNS │ │ S3 │ │ Lambda │ - │ │(Notification)│ │ (Artifacts) │ │ Functions │ - │ └─────────────┘ └─────────────┘ └─────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────┐ -│ prod 브랜치 Push/Merge │ -└─────────────────────────────────────────────────────────────────────┘ -``` - -## 2. 구성 요소 상세 설명 - -### 2.1 Source Stage (GitHub) - -- **트리거**: prod 브랜치에 Push 또는 PR Merge 시 자동 실행 -- **연결 방식**: AWS CodeConnections (구 CodeStar Connections) -- **아티팩트**: 소스 코드를 ZIP으로 압축하여 다음 스테이지로 전달 - -### 2.2 Build Stage (CodeBuild) - -- **런타임**: Amazon Linux 2, Java Corretto 21 -- **빌드 단계**: - 1. **Install**: SAM CLI 설치 - 2. **Pre-build**: Gradle 테스트 실행 (`./gradlew clean test`) - 3. **Build**: SAM build & package - 4. **Post-build**: 완료 로그 -- **캐싱**: Gradle 캐시를 S3에 저장하여 빌드 시간 단축 -- **리포트**: JUnit 테스트 결과, JaCoCo 코드 커버리지 리포트 - -### 2.3 Deploy Stage (CloudFormation) - -- **배포 방식**: CloudFormation CREATE_UPDATE -- **템플릿**: SAM으로 패키징된 `packaged-template.yaml` -- **기능**: CAPABILITY_IAM, CAPABILITY_AUTO_EXPAND - -### 2.4 Notification (SNS) - -- **이벤트**: 파이프라인 시작, 성공, 실패 시 이메일 알림 -- **구현**: CodeStar Notifications + SNS Topic - -## 3. 주요 파일 구조 - -``` -BE_Repository/ -├── cicd/ -│ └── pipeline.yaml # CloudFormation 파이프라인 템플릿 -├── ServerlessFunction/ -│ ├── buildspec.yml # CodeBuild 빌드 명세 -│ ├── samconfig.toml # SAM 배포 설정 -│ └── template.yaml # SAM 애플리케이션 템플릿 -``` - -## 4. IAM 역할 구성 - -| 역할 | 목적 | 주요 권한 | -|--------------------|---------------------|----------------------------------------| -| PipelineRole | CodePipeline 서비스 역할 | S3, CodeBuild, CloudFormation, SNS | -| CodeBuildRole | CodeBuild 서비스 역할 | S3, CloudWatch Logs, CodeBuild Reports | -| CloudFormationRole | 리소스 배포 역할 | AdministratorAccess (SAM 리소스 생성용) | - ---- - -## 5. 면접 예상 질문 및 답변 - -### Q1. CI/CD 파이프라인을 구축한 이유는 무엇인가요? - -**A1:** -수동 배포의 문제점을 해결하기 위해 CI/CD를 도입했습니다. - -1. **일관성**: 수동 배포 시 발생할 수 있는 휴먼 에러 방지 -2. **자동화**: 코드 푸시만으로 테스트-빌드-배포가 자동 실행 -3. **품질 보장**: 테스트 실패 시 배포가 중단되어 결함 있는 코드가 프로덕션에 배포되는 것을 방지 -4. **추적성**: 모든 배포 이력이 CodePipeline에 기록되어 문제 발생 시 원인 추적 용이 -5. **속도**: 반복적인 배포 작업 시간을 단축하여 개발 생산성 향상 - ---- - -### Q2. GitHub과 AWS CodePipeline을 어떻게 연동했나요? - -**A2:** -AWS CodeConnections(구 CodeStar Connections)를 사용하여 연동했습니다. - -```yaml -# pipeline.yaml의 Source Stage 설정 -- Name: Source - Actions: - - Name: GitHub - ActionTypeId: - Category: Source - Owner: AWS - Provider: CodeStarSourceConnection - Version: '1' - Configuration: - ConnectionArn: !Ref GitHubConnectionArn - FullRepositoryId: "Language-Study-Prooject/BE_Repository" - BranchName: "prod" - DetectChanges: true -``` - -**연동 과정:** - -1. AWS Console에서 CodeConnections 생성 -2. GitHub OAuth 앱 승인 -3. Connection ARN을 파이프라인에 설정 -4. `DetectChanges: true`로 설정하여 자동 트리거 활성화 - ---- - -### Q3. CodeBuild의 buildspec.yml에서 각 phase의 역할은 무엇인가요? - -**A3:** - -```yaml -phases: - install: # 빌드 환경 설정 - runtime-versions: - java: corretto21 - commands: - - pip3 install aws-sam-cli - - pre_build: # 테스트 실행 (품질 게이트) - commands: - - cd ServerlessFunction - - ./gradlew clean test - - build: # 실제 빌드 및 패키징 - commands: - - sam build - - sam package --s3-bucket ... --output-template-file packaged-template.yaml - - post_build: # 후처리 (로깅, 정리) - commands: - - echo "Build completed" -``` - -- **install**: 빌드에 필요한 런타임과 도구 설치 -- **pre_build**: 테스트 실행 - 실패 시 빌드 중단 (품질 게이트 역할) -- **build**: SAM 애플리케이션 빌드 및 S3에 패키징 -- **post_build**: 완료 로그 기록, 정리 작업 - ---- - -### Q4. 테스트가 실패하면 배포가 어떻게 되나요? - -**A4:** -테스트 실패 시 배포가 자동으로 중단됩니다. - -**작동 원리:** - -1. `pre_build` 단계에서 `./gradlew clean test` 실행 -2. 테스트 실패 시 Gradle이 exit code 1 반환 -3. CodeBuild가 비정상 종료로 판단하여 빌드 실패 처리 -4. CodePipeline의 Build Stage가 실패 상태가 됨 -5. Deploy Stage로 진행되지 않음 -6. SNS를 통해 실패 알림 이메일 발송 - -``` -Pipeline Flow: -Source ──▶ Build (테스트 실패) ──✗ Deploy - │ - ▼ - SNS 알림 발송 -``` - ---- - -### Q5. SAM과 CloudFormation의 관계는 무엇인가요? - -**A5:** -SAM(Serverless Application Model)은 CloudFormation의 확장입니다. - -**관계:** - -- SAM 템플릿은 CloudFormation 템플릿의 상위 집합 -- `sam build`/`sam package` 실행 시 SAM 템플릿이 표준 CloudFormation 템플릿으로 변환 -- 변환된 템플릿(`packaged-template.yaml`)을 CloudFormation이 배포 - -**SAM의 장점:** - -1. 간결한 문법: `AWS::Serverless::Function`으로 Lambda + API Gateway + IAM 역할 한번에 정의 -2. 로컬 테스트: `sam local invoke`로 Lambda 로컬 실행 가능 -3. 자동 패키징: 코드를 S3에 업로드하고 참조 자동 생성 - -```yaml -# SAM 템플릿 (간결) -Type: AWS::Serverless::Function -Properties: - Handler: handler.main - Runtime: java21 - Events: - Api: - Type: Api - Properties: - Path: /hello - Method: get - -# 변환된 CloudFormation (복잡) -# Lambda Function + API Gateway + IAM Role + Permission 등 여러 리소스로 확장 -``` - ---- - -### Q6. 배포 중 롤백은 어떻게 처리되나요? - -**A6:** -CloudFormation의 기본 롤백 기능을 활용합니다. - -**설정:** - -```yaml -# samconfig.toml -disable_rollback = false # 롤백 활성화 -``` - -**롤백 시나리오:** - -1. **배포 실패 시**: CloudFormation이 자동으로 이전 상태로 롤백 -2. **Lambda 오류 시**: - - 현재는 기본 롤백만 사용 - - 추가로 Canary/Linear 배포 설정 가능 (AWS CodeDeploy 연동) - -```yaml -# 점진적 배포 예시 (선택적 구현) -DeploymentPreference: - Type: Canary10Percent5Minutes # 10%에 5분간 배포 후 문제없으면 전체 배포 -``` - ---- - -### Q7. 파이프라인의 아티팩트는 어떻게 관리되나요? - -**A7:** -S3 버킷을 사용하여 아티팩트를 관리합니다. - -```yaml -ArtifactBucket: - Type: AWS::S3::Bucket - Properties: - BucketName: group2-englishstudy-pipeline-artifacts - VersioningConfiguration: - Status: Enabled # 버전 관리 활성화 - BucketEncryption: - ServerSideEncryptionConfiguration: - - ServerSideEncryptionByDefault: - SSEAlgorithm: AES256 # 암호화 -``` - -**아티팩트 종류:** - -1. **SourceArtifact**: GitHub에서 가져온 소스 코드 ZIP -2. **BuildArtifact**: 빌드된 `packaged-template.yaml` -3. **Cache**: Gradle 캐시 (빌드 시간 단축용) - ---- - -### Q8. 파이프라인 알림은 어떻게 구현했나요? - -**A8:** -AWS CodeStar Notifications와 SNS를 연동하여 구현했습니다. - -```yaml -# SNS Topic 생성 -NotificationTopic: - Type: AWS::SNS::Topic - Properties: - TopicName: cicd-pipeline-notifications - -# 이메일 구독 -EmailSubscription: - Type: AWS::SNS::Subscription - Properties: - TopicArn: !Ref NotificationTopic - Protocol: email - Endpoint: !Ref NotificationEmail - -# 알림 규칙 -PipelineNotificationRule: - Type: AWS::CodeStarNotifications::NotificationRule - Properties: - EventTypeIds: - - codepipeline-pipeline-pipeline-execution-started - - codepipeline-pipeline-pipeline-execution-succeeded - - codepipeline-pipeline-pipeline-execution-failed - Targets: - - TargetType: SNS - TargetAddress: !Ref NotificationTopic -``` - ---- - -### Q9. CI/CD 구축 중 겪은 문제와 해결 방법은? - -**A9:** - -**문제 1: Gradle Wrapper를 찾을 수 없음** - -- 원인: `.gitignore`에서 `gradle/` 폴더 전체가 제외됨 -- 해결: `.gitignore` 수정하여 `!gradle/wrapper/` 예외 추가 - -**문제 2: JAVA_HOME 환경 변수 오류** - -- 원인: CodeBuild에서 JAVA_HOME을 수동 설정했으나 경로 불일치 -- 해결: `runtime-versions: java: corretto21`만 사용하고 JAVA_HOME 수동 설정 제거 - -**문제 3: SAM package S3 버킷 참조 오류** - -- 원인: 환경 변수를 사용한 멀티라인 명령어에서 변수 치환 실패 -- 해결: 단일 라인으로 버킷 이름 직접 지정 - -**문제 4: Lambda 환경 변수 누락** - -- 원인: WebSocket Connect 함수에 `WEBSOCKET_ENDPOINT` 환경 변수 미설정 -- 해결: `template.yaml`에 환경 변수 추가 - ---- - -### Q10. 현재 CI/CD의 개선점이 있다면? - -**A10:** - -1. **테스트 커버리지 게이트** - - 현재: 테스트 실행만 함 - - 개선: 커버리지 80% 미만 시 빌드 실패 설정 - -2. **점진적 배포 (Canary/Blue-Green)** - - 현재: 전체 교체 배포 - - 개선: Lambda Alias + CodeDeploy로 Canary 배포 구현 - -3. **다중 환경 지원** - - 현재: prod 단일 환경 - - 개선: dev, staging, prod 분리 및 승인 단계 추가 - -4. **보안 스캔** - - 개선: 의존성 취약점 스캔 (OWASP Dependency-Check) 추가 - -5. **성능 테스트** - - 개선: 배포 전 부하 테스트 단계 추가 - ---- - -### Q11. IaC(Infrastructure as Code)를 사용한 이유는? - -**A11:** -파이프라인 자체도 CloudFormation 템플릿(`pipeline.yaml`)으로 정의했습니다. - -**장점:** - -1. **버전 관리**: 인프라 변경 이력을 Git으로 추적 -2. **재현성**: 동일한 파이프라인을 다른 프로젝트/계정에 쉽게 복제 -3. **리뷰 가능**: 인프라 변경도 코드 리뷰 프로세스 적용 -4. **자동화**: 수동 콘솔 작업 없이 `aws cloudformation deploy`로 생성/업데이트 -5. **문서화**: 템플릿 자체가 인프라 문서 역할 - ---- - -### Q12. CodeBuild와 Jenkins의 차이점은? - -**A12:** - -| 항목 | CodeBuild | Jenkins | -|--------|---------------|----------------------| -| 관리 | 완전 관리형 (서버리스) | 자체 서버 운영 필요 | -| 비용 | 빌드 시간 기반 과금 | 서버 운영 비용 | -| 확장성 | 자동 확장 | 수동 확장 필요 | -| AWS 통합 | 네이티브 통합 | 플러그인 필요 | -| 커스터마이징 | buildspec.yml | Jenkinsfile (Groovy) | -| 플러그인 | 제한적 | 풍부한 생태계 | - -**선택 이유:** - -- AWS 서비스 중심 아키텍처에서 네이티브 통합의 이점 -- 서버 관리 부담 없음 -- SAM/CloudFormation과의 원활한 연동 - ---- - -## 6. 핵심 용어 정리 - -| 용어 | 설명 | -|-------------------------------------|------------------------------------------------| -| CI (Continuous Integration) | 코드 변경을 자주 통합하고 자동 테스트하는 방식 | -| CD (Continuous Delivery/Deployment) | 자동으로 프로덕션까지 배포하는 방식 | -| Pipeline | 소스-빌드-배포로 이어지는 자동화된 워크플로우 | -| Artifact | 빌드 결과물 (패키징된 코드, 템플릿 등) | -| buildspec.yml | CodeBuild의 빌드 명세 파일 | -| SAM | Serverless Application Model - 서버리스 앱 정의 프레임워크 | -| IaC | Infrastructure as Code - 코드로 인프라 관리 | - ---- - -## 7. 참고 명령어 - -```bash -# 파이프라인 생성 -aws cloudformation deploy \ - --template-file cicd/pipeline.yaml \ - --stack-name group2-cicd-pipeline \ - --capabilities CAPABILITY_NAMED_IAM \ - --parameter-overrides NotificationEmail=your@email.com - -# 파이프라인 상태 확인 -aws codepipeline get-pipeline-state --name group2-englishstudy-pipeline - -# 수동 파이프라인 실행 -aws codepipeline start-pipeline-execution --name group2-englishstudy-pipeline - -# 빌드 로그 확인 -aws logs tail /aws/codebuild/group2-englishstudy-build --follow -``` diff --git a/docs/FRONTEND-API-GUIDE.md b/docs/FRONTEND-API-GUIDE.md deleted file mode 100644 index 697d406a..00000000 --- a/docs/FRONTEND-API-GUIDE.md +++ /dev/null @@ -1,365 +0,0 @@ -# 프론트엔드 전달사항 - 채팅/게임 API 가이드 - -## 1. 아키텍처 구조 (업데이트됨) - -### 채팅방과 게임방 분리 - -``` -RoomType enum -├── CHAT ("chat") - 일반 채팅방 -└── GAME ("game") - 게임방 (캐치마인드 등) - -RoomStatus enum -├── WAITING ("waiting") - 대기 중 -├── PLAYING ("playing") - 게임 진행 중 -└── FINISHED ("finished") - 종료됨 -``` - -### GSI1SK 인덱스 설계 - -``` -GSI1PK: "ROOMS" (고정) -GSI1SK: {type}#{gameType}#{status}#{level}#{createdAt} - -예시: -- CHAT#-#WAITING#beginner#2026-01-22T10:00:00Z (일반 채팅방) -- GAME#CATCHMIND#WAITING#intermediate#2026-01-22T10:00:00Z (대기중 게임방) -- GAME#CATCHMIND#PLAYING#advanced#2026-01-22T10:00:00Z (진행중 게임방) -``` - -**핵심**: DB 레벨에서 `type`, `gameType`, `status`, `level` 조합으로 필터링 가능 - ---- - -## 2. 방 타입 (RoomType) - -| 타입 | 코드 | 설명 | -|--------|--------|---------------| -| `CHAT` | `chat` | 일반 채팅방 | -| `GAME` | `game` | 게임방 (캐치마인드 등) | - ---- - -## 3. 방 상태 (RoomStatus) - -| 상태 | 코드 | 설명 | 게임 시작 가능 | -|------------|------------|---------|:--------:| -| `WAITING` | `waiting` | 대기 중 | O | -| `PLAYING` | `playing` | 게임 진행 중 | X | -| `FINISHED` | `finished` | 게임 종료됨 | O | - ---- - -## 4. REST API 엔드포인트 - -### 채팅방 API (`/api/chat/rooms`) - -| Method | Endpoint | 설명 | -|--------|-------------------------|---------------------| -| POST | `/rooms` | 채팅방/게임방 생성 | -| GET | `/rooms` | 방 목록 조회 (필터 지원) | -| GET | `/rooms/{roomId}` | 방 상세 조회 | -| POST | `/rooms/{roomId}/join` | 방 입장 (roomToken 발급) | -| POST | `/rooms/{roomId}/leave` | 방 퇴장 | -| DELETE | `/rooms/{roomId}` | 방 삭제 (방장만) | - -### 게임 API (`/api/game`) - -| Method | Endpoint | 설명 | -|--------|-------------------------------|----------| -| POST | `/rooms/{roomId}/game/start` | 게임 시작 | -| POST | `/rooms/{roomId}/game/stop` | 게임 중단 | -| GET | `/rooms/{roomId}/game/status` | 게임 상태 조회 | -| GET | `/rooms/{roomId}/game/scores` | 점수판 조회 | - ---- - -## 5. 방 목록 조회 쿼리 파라미터 (업데이트됨) - -``` -GET /api/chat/rooms?type=GAME&gameType=CATCHMIND&status=WAITING&level=intermediate&limit=10&cursor=xxx -``` - -| 파라미터 | 타입 | 설명 | 예시 | -|------------|--------|----------------------|----------------------------------------| -| `type` | string | 방 타입 필터 | `CHAT`, `GAME` | -| `gameType` | string | 게임 타입 | `CATCHMIND` | -| `status` | string | 상태 필터 | `WAITING`, `PLAYING`, `FINISHED` | -| `level` | string | 난이도 필터 | `beginner`, `intermediate`, `advanced` | -| `limit` | number | 조회 개수 (기본 10, 최대 20) | | -| `cursor` | string | 페이지네이션 커서 | | - -### 필터 조합 예시 - -```bash -# 대기 중인 게임방만 -GET /api/chat/rooms?type=GAME&status=WAITING - -# 캐치마인드 게임방만 -GET /api/chat/rooms?type=GAME&gameType=CATCHMIND - -# 초급 난이도 채팅방 -GET /api/chat/rooms?type=CHAT&level=beginner - -# 진행 중인 고급 게임방 -GET /api/chat/rooms?type=GAME&status=PLAYING&level=advanced -``` - -### 응답 예시 - -```json -{ - "success": true, - "message": "Rooms retrieved", - "data": { - "rooms": [ - { - "roomId": "abc-123", - "name": "초보자 영어 스터디", - "type": "GAME", - "gameType": "CATCHMIND", - "status": "WAITING", - "level": "beginner", - "currentMembers": 3, - "maxMembers": 6, - "currentRound": 0, - "totalRounds": 5, - "createdAt": "2026-01-22T10:00:00Z" - } - ], - "nextCursor": "eyJQSyI6Ik...", - "hasMore": true - } -} -``` - ---- - -## 6. 방 생성 요청 (업데이트됨) - -### 채팅방 생성 - -```json -{ - "name": "영어 스터디 채팅방", - "type": "CHAT", - "level": "beginner", - "maxMembers": 6, - "description": "초보자를 위한 영어 채팅방" -} -``` - -### 게임방 생성 - -```json -{ - "name": "캐치마인드 게임", - "type": "GAME", - "gameType": "CATCHMIND", - "level": "intermediate", - "maxMembers": 8, - "description": "영어 단어 맞추기 게임" -} -``` - ---- - -## 7. 프론트엔드에서 방 타입 구분 - -### 방법 1: API 필터 사용 (권장) - -```javascript -// 게임방만 조회 -const gameRooms = await fetch('/api/chat/rooms?type=GAME'); - -// 대기 중인 게임방만 -const waitingGames = await fetch('/api/chat/rooms?type=GAME&status=WAITING'); - -// 채팅방만 -const chatRooms = await fetch('/api/chat/rooms?type=CHAT'); -``` - -### 방법 2: 전체 조회 후 클라이언트 필터링 - -```javascript -const allRooms = await fetchRooms(); - -// 게임방만 -const gameRooms = allRooms.filter(room => room.type === 'GAME'); - -// 채팅방만 -const chatRooms = allRooms.filter(room => room.type === 'CHAT'); - -// 대기 중인 방만 -const waitingRooms = allRooms.filter(room => room.status === 'WAITING'); -``` - ---- - -## 8. WebSocket 연결 - -### 채팅/게임 WebSocket - -``` -wss://t378dif43l.execute-api.ap-northeast-2.amazonaws.com/dev?roomToken={roomToken} -``` - -### Grammar WebSocket - -``` -wss://ltrccmteo8.execute-api.ap-northeast-2.amazonaws.com/dev?token={jwtToken} -``` - -### 연결 순서 - -1. `POST /rooms/{roomId}/join` → `roomToken` 발급 -2. WebSocket 연결 시 `roomToken` 쿼리 파라미터로 전달 - ---- - -## 9. WebSocket 메시지 타입 (messageType) - -| 코드 | 타입 | 설명 | -|------------------|--------|---------------| -| `MSG` | 일반 메시지 | 일반 채팅 메시지 | -| `VOICE` | 음성 메시지 | 음성 채팅 | -| `JOIN` | 입장 알림 | 사용자 입장 | -| `LEAVE` | 퇴장 알림 | 사용자 퇴장 | -| `GAME_START` | 게임 시작 | 게임 시작 알림 | -| `GAME_END` | 게임 종료 | 게임 종료 + 최종 순위 | -| `ROUND_START` | 라운드 시작 | 새 라운드 시작 | -| `ROUND_END` | 라운드 종료 | 정답 공개 | -| `ANSWER_CORRECT` | 정답 | 정답 맞춤 | -| `HINT` | 힌트 | 힌트 제공 | -| `SKIP` | 스킵 | 라운드 스킵 | -| `SYSTEM` | 시스템 | 시스템 메시지 | - ---- - -## 10. 게임 명령어 (WebSocket) - -채팅 메시지로 게임 명령어 전송: - -| 명령어 | 설명 | 권한 | -|----------|--------|-----------------| -| `/start` | 게임 시작 | 방장 (2명 이상 접속 시) | -| `/stop` | 게임 중단 | 방장 또는 게임 시작자 | -| `/skip` | 라운드 스킵 | 누구나 | -| `/hint` | 힌트 제공 | 출제자만 | -| `/score` | 점수 확인 | 누구나 | - ---- - -## 11. 게임 시작 응답 예시 - -```json -{ - "messageId": "uuid", - "roomId": "abc-123", - "userId": "SYSTEM", - "content": "게임 시작!\n총 5 라운드\n\n라운드 1 시작!\n출제자: user-456", - "messageType": "GAME_START", - "createdAt": "2026-01-22T10:00:00Z", - "serverTime": "2026-01-22T10:00:00Z", - "domain": "GAME", - "type": "GAME", - "status": "PLAYING", - "currentRound": 1, - "totalRounds": 5, - "currentDrawerId": "user-456", - "drawerOrder": ["user-456", "user-789", "user-123"] -} -``` - ---- - -## 12. 정답 체크 로직 - -- **한국어** 또는 **영어** 둘 다 정답으로 인정 -- 대소문자 구분 없음 -- 공백 무시 - -### 점수 계산 - -``` -기본 점수: 10점 -시간 보너스: (제한시간 - 경과시간) * 0.5 -연속 정답 보너스: 연속정답수 * 2 - -총점 = 기본점수 + 시간보너스 + 연속정답보너스 -``` - ---- - -## 13. 게임 설정 - -| 설정 | 기본값 | 환경변수 | -|--------------|----------|---------------------------------| -| 총 라운드 수 | 5 | `GAME_TOTAL_ROUNDS` | -| 라운드 제한 시간(초) | 60 | `GAME_ROUND_TIME_LIMIT` | -| 빠른 정답 기준(ms) | 5000 | `GAME_QUICK_GUESS_THRESHOLD_MS` | -| 게임 전체 제한(초) | 420 (7분) | `GAME_TIME_LIMIT_SECONDS` | - ---- - -## 14. 주의사항 - -1. **roomToken은 한 번만 사용**: 재연결 시 새로 발급 필요 -2. **WebSocket 연결 실패 시**: `POST /rooms/{roomId}/join`으로 새 토큰 발급 -3. **게임 중 퇴장**: 자동으로 다음 출제자로 넘어감 (2명 미만 시 게임 종료) -4. **출제자는 정답 입력 불가**: 본인이 출제자일 때 채팅해도 정답 체크 안됨 -5. **방 타입 변경 불가**: 생성 시 지정한 type은 변경 불가 - ---- - -## 15. 에러 코드 - -| 코드 | HTTP | 설명 | -|--------------|------|--------------| -| `ROOM_001` | 404 | 채팅방 없음 | -| `ROOM_002` | 409 | 채팅방 이미 존재 | -| `ROOM_003` | 400 | 채팅방 인원 초과 | -| `ROOM_004` | 400 | 채팅방 종료됨 | -| `ROOM_005` | 401 | 비밀번호 틀림 | -| `ROOM_006` | 403 | 방장 권한 없음 | -| `MEMBER_001` | 403 | 채팅방 멤버 아님 | -| `MEMBER_002` | 409 | 이미 참여 중 | -| `GAME_001` | 400 | 게임 시작 실패 | -| `GAME_002` | 400 | 게임 중단 실패 | -| `GAME_003` | 400 | 게임 진행 중 아님 | -| `GAME_004` | 409 | 게임 이미 진행 중 | -| `GAME_005` | 403 | 게임 시작자 아님 | -| `GAME_006` | 404 | 게임 없음 | -| `GAME_007` | 400 | 채팅방에서 게임 불가 | -| `GAME_008` | 400 | 게임 재시작 불가 | -| `GAME_009` | 403 | 방장만 게임 시작 가능 | - ---- - -## 16. UI 구현 가이드 - -### 탭 구조 (권장) - -``` -[전체] [채팅방] [게임방] -``` - -### 게임방 상태 표시 - -``` -대기 중 (WAITING) → 초록색 뱃지 "참여 가능" -진행 중 (PLAYING) → 빨간색 뱃지 "게임 중" -종료됨 (FINISHED) → 회색 뱃지 "종료" -``` - -### 게임방 카드 정보 - -``` -┌─────────────────────────────┐ -│ 캐치마인드 - 영어 단어 맞추기 │ -│ [게임방] [intermediate] │ -│ │ -│ 👥 3/8명 🎮 대기 중 │ -│ 🕐 2026-01-22 10:00 │ -└─────────────────────────────┘ -``` diff --git a/docs/MIDTERM-REPORT.md b/docs/MIDTERM-REPORT.md deleted file mode 100644 index 9a6bb1d1..00000000 --- a/docs/MIDTERM-REPORT.md +++ /dev/null @@ -1,439 +0,0 @@ -# 영어 학습 플랫폼 백엔드 최종 성과 보고서 - -## 프로젝트 개요 - -| 항목 | 내용 | -|-------|--------------------------------------------------------------------------| -| 프로젝트명 | 영어 회화 학습 플랫폼 (MZC 2nd Project) | -| 담당 영역 | Vocabulary, Chatting, Grammar, Badge, Stats, Common | -| 기술 스택 | Java 21, AWS Lambda, DynamoDB, API Gateway WebSocket, Bedrock, Polly, S3 | -| 배포 환경 | AWS SAM, CloudFormation | - ---- - -## 1. 전체 시스템 아키텍처 - -```mermaid -flowchart TB - subgraph Client["클라이언트"] - WEB[Web App] - end - - subgraph Gateway["API Gateway"] - REST[REST API] - WS[WebSocket API] - GRAMMAR_WS[Grammar WebSocket] - end - - subgraph Lambda["AWS Lambda - 도메인별 핸들러"] - direction TB - VOCAB[Vocabulary
단어/일일학습/테스트] - CHAT[Chatting
실시간 채팅/게임] - GRAMMAR[Grammar
문법 체크/스트리밍] - STATS[Stats
통계 집계] - BADGE[Badge
배지 시스템] - USER[User
사용자 관리] - end - - subgraph AI["AI Services"] - BEDROCK[AWS Bedrock
Claude 3.5 Sonnet] - POLLY[AWS Polly
TTS] - end - - subgraph Data["Data Layer"] - DYNAMO_VOCAB[(DynamoDB
Vocab Table)] - DYNAMO_CHAT[(DynamoDB
Chat Table)] - S3[(S3
음성/뱃지 이미지)] - STREAMS[DynamoDB Streams] - end - - WEB --> REST - WEB --> WS - WEB --> GRAMMAR_WS - REST --> VOCAB - REST --> CHAT - REST --> GRAMMAR - REST --> BADGE - REST --> STATS - REST --> USER - WS --> CHAT - GRAMMAR_WS --> GRAMMAR - VOCAB --> DYNAMO_VOCAB - VOCAB --> POLLY - VOCAB --> S3 - CHAT --> DYNAMO_CHAT - CHAT --> BEDROCK - GRAMMAR --> DYNAMO_VOCAB - GRAMMAR --> BEDROCK - STATS --> DYNAMO_VOCAB - BADGE --> DYNAMO_VOCAB - BADGE --> S3 - STREAMS -->|이벤트 트리거| STATS - STATS -->|배지 부여| BADGE -``` - ---- - -## 2. 주요 기능 구현 - -### 2.1 Vocabulary Domain (단어 학습) - -#### 2.1.1 일일 학습 시스템 (Daily Study) - -```mermaid -flowchart LR - subgraph DailyStudy["일일 학습 흐름"] - A[오늘의 단어 조회] --> B{기존 학습 존재?} - B -->|Yes| C[기존 학습 반환] - B -->|No| D[새 단어 50개 + 복습 5개 생성] - D --> E[학습 진행] - E --> F[단어별 학습 완료 처리] - F --> G{50개 완료?} - G -->|Yes| H[isCompleted = true] - end -``` - -**주요 기능:** - -- 레벨별 신규 단어 50개 + 복습 단어 5개 자동 선정 -- 학습 진행도 실시간 추적 (learnedCount/totalWords) -- 일일 학습 완료 시 isCompleted 플래그 설정 - -#### 2.1.2 SM-2 Spaced Repetition 알고리즘 - -```mermaid -stateDiagram-v2 - [*] --> NEW: 단어 추가 - NEW --> LEARNING: 첫 학습 - LEARNING --> LEARNING: 오답 - LEARNING --> REVIEWING: 2회 연속 정답 - REVIEWING --> LEARNING: 오답 - REVIEWING --> MASTERED: 5회 연속 정답 - MASTERED --> LEARNING: 오답 - MASTERED --> MASTERED: 정답 유지 -``` - -**구현 특징:** - -- State 패턴으로 학습 상태 전이 관리 -- easeFactor 동적 조정 (1.3 ~ 2.5) -- 복습 간격 자동 계산 (1일 → 6일 → interval * easeFactor) - -#### 2.1.3 TTS 음성 생성 - -- AWS Polly 연동 (남성/여성 음성) -- S3 캐싱으로 중복 생성 방지 -- 단어 + 예문 음성 생성 - ---- - -### 2.2 Chatting Domain (실시간 채팅 & 게임) - -#### 2.2.1 WebSocket 채팅 - -```mermaid -sequenceDiagram - participant Client - participant REST as REST API - participant WS as WebSocket API - participant DB as DynamoDB - Note over Client, DB: Phase 1: 방 입장 토큰 발급 - Client ->> REST: POST /rooms/{id}/join - REST ->> DB: RoomToken 저장 (TTL: 5분) - REST -->> Client: roomToken 반환 - Note over Client, DB: Phase 2: WebSocket 연결 - Client ->> WS: $connect?roomToken={token} - WS ->> DB: 토큰 검증 + Connection 저장 - WS -->> Client: 연결 성공 - Note over Client, DB: Phase 3: 메시지 송수신 - Client ->> WS: sendmessage (채팅) - WS ->> DB: 메시지 저장 + 브로드캐스트 -``` - -**주요 기능:** - -- RoomToken 기반 인증 (TTL 5분) -- BCrypt 비밀방 암호화 -- 슬래시 명령어 시스템 (/member, /game, /skip, /hint 등) -- Connection 자동 정리 (TTL + 실패 시 삭제) - -#### 2.2.2 캐치마인드 게임 - -```mermaid -flowchart TB - subgraph Game["캐치마인드 게임 흐름"] - START["#47;game 명령어"] --> INIT["게임 초기화
출제 순서 셔플"] - INIT --> ROUND[라운드 시작
출제자 + 단어 선정] - ROUND --> DRAW[출제자 그림 그리기] - DRAW --> GUESS[참가자 정답 입력] - GUESS --> CHECK{정답?} - CHECK -->|Yes| SCORE[점수 계산
시간보너스 + 연속정답보너스] - CHECK -->|No| GUESS - SCORE --> ALLCORRECT{전원 정답?} - ALLCORRECT -->|Yes| NEXTROUND - ALLCORRECT -->|No| TIMEOUT{시간 초과?} - TIMEOUT -->|Yes| NEXTROUND[다음 라운드] - TIMEOUT -->|No| GUESS - NEXTROUND --> LASTROUND{마지막 라운드?} - LASTROUND -->|Yes| END[게임 종료
순위 발표] - LASTROUND -->|No| ROUND - end -``` - -**점수 계산:** - -``` -점수 = 기본점수(10) + 시간보너스((60-경과초)*0.5) + 연속정답보너스(streak*2) -출제자 보너스 = 정답자당 5점 -``` - -**주요 기능:** - -- 실시간 점수 브로드캐스트 -- 연속 정답 스트릭 시스템 -- 접속자 변동 시 출제자 자동 재선정 -- 라운드별 순위 표시 - ---- - -### 2.3 Grammar Domain (문법 체크) - -#### 2.3.1 AI 스트리밍 응답 - -```mermaid -sequenceDiagram - participant Client - participant WS as Grammar WebSocket - participant Handler as GrammarStreamingHandler - participant Bedrock as AWS Bedrock - Client ->> WS: 문법 체크 요청 - WS ->> Handler: Lambda 호출 - Handler ->> Bedrock: 스트리밍 요청 (Claude 3.5 Sonnet) - - loop 청크 단위 응답 - Bedrock -->> Handler: 텍스트 청크 - Handler -->> WS: 실시간 전송 - WS -->> Client: 즉시 표시 - end - - Handler -->> Client: [DONE] 완료 - Handler ->> DB: 피드백 저장 -``` - -**주요 기능:** - -- Claude 3.5 Sonnet 모델 사용 -- 스트리밍으로 체감 대기 시간 80% 감소 -- 레벨별 맞춤 프롬프트 (BEGINNER: 한국어 번역 포함) -- 대화 히스토리 저장으로 문맥 유지 -- 피드백 영구 저장 (DynamoDB) - ---- - -### 2.4 Stats Domain (학습 통계) - -```mermaid -flowchart LR - subgraph StatsTypes["통계 유형"] - DAILY["일별 통계
#47;stats#47;daily"] - WEEKLY["주별 통계
#47;stats#47;weekly"] - MONTHLY["월별 통계
#47;stats#47;monthly"] - TOTAL["전체 통계
#47;stats#47;total"] - HISTORY["히스토리
#47;stats#47;history"] - end -``` - -**통계 항목:** - -| 필드 | 설명 | -|-------------------|-------------| -| testsCompleted | 완료한 테스트 수 | -| questionsAnswered | 답변한 문제 수 | -| correctAnswers | 정답 수 | -| incorrectAnswers | 오답 수 | -| successRate | 정답률 (%) | -| newWordsLearned | 새로 학습한 단어 수 | -| wordsReviewed | 복습한 단어 수 | -| currentStreak | 현재 연속 학습일 | -| longestStreak | 최장 연속 학습일 | -| gamesPlayed | 참여한 게임 수 | -| gamesWon | 1등 횟수 | -| totalGameScore | 누적 게임 점수 | - -**DynamoDB Streams 기반 비동기 집계:** - -- 테스트 결과 저장 시 자동 트리거 -- API 응답과 분리되어 응답 속도 향상 - ---- - -### 2.5 Badge Domain (배지 시스템) - -```mermaid -flowchart TB - subgraph BadgeSystem["배지 시스템"] - TRIGGER[통계 업데이트] --> CHECK[배지 조건 체크] - CHECK --> AWARD{조건 달성?} - AWARD -->|Yes| SAVE[배지 부여 + 저장] - AWARD -->|No| END[종료] - SAVE --> NOTIFY[프론트엔드 조회] - end -``` - -**배지 종류:** - -| Badge Type | 이름 | 조건 | -|----------------------|---------|------------| -| FIRST_STEP | 첫 걸음 | 첫 학습 완료 | -| STREAK_3, 7, 30 | 연속 학습 | N일 연속 학습 | -| WORDS_100, 500, 1000 | 단어 학습 | N개 단어 학습 | -| PERFECT_SCORE | 완벽주의자 | 테스트 만점 | -| ACCURACY_90 | 정확도 달인 | 전체 정확도 90% | -| GAME_FIRST_PLAY | 첫 게임 | 첫 게임 참여 | -| GAME_10_WINS | 게임 10승 | 10번 1등 | -| QUICK_GUESSER | 번개 정답 | 5초 내 정답 | -| PERFECT_DRAWER | 완벽한 출제자 | 전원 정답 유도 | - -**기술적 특징:** - -- S3 Presigned URL로 배지 이미지 제공 (1시간 유효) -- 획득/미획득 배지 + 진행도 표시 - ---- - -## 3. 기술적 성과 - -### 3.1 아키텍처 패턴 - -| 패턴 | 적용 영역 | 효과 | -|------------------|----------|----------------------------| -| **CQRS** | 전 도메인 | 읽기/쓰기 책임 분리, 테스트 용이성 | -| **State** | 단어 학습 상태 | 복잡한 조건문 제거, 확장성 | -| **Factory** | AI 서비스 | 서비스 교체 용이 (Claude ↔ Llama) | -| **Event-Driven** | 통계/배지 | 느슨한 결합, 비동기 처리 | - -### 3.2 DynamoDB 설계 - -**Single Table Design:** - -- Vocab Table: 단어, 사용자단어, 테스트, 일일학습, 통계, 배지, 문법 -- Chat Table: 채팅방, 메시지, 연결, 게임라운드 - -**GSI 구성:** - -| GSI | 용도 | -|------|---------------------| -| GSI1 | 레벨별 단어 조회, 복습 예정 단어 | -| GSI2 | 카테고리별 단어, 상태별 사용자단어 | -| GSI3 | 북마크 단어 조회 | - -### 3.3 보안 - -- Cognito 인증 (idToken) -- WebSocket RoomToken 인증 (TTL 5분) -- BCrypt 비밀방 암호화 -- S3 Presigned URL (배지 이미지) - -### 3.4 성능 최적화 - -| 최적화 | 효과 | -|--------------------------|-------------------------| -| TTS S3 캐싱 | Polly API 호출 90% 절감 | -| 배치 처리 | 최대 100개 단어 일괄 처리 | -| Strongly Consistent Read | 데이터 정합성 보장 | -| DynamoDB Streams | 비동기 통계 집계로 응답 속도 50% 향상 | -| AI 스트리밍 | 체감 대기 시간 80% 감소 | - ---- - -## 4. API 엔드포인트 요약 - -### REST API (https://gc8l9ijhzc.execute-api.ap-northeast-2.amazonaws.com/dev) - -| Method | Path | 설명 | -|--------|-------------------------------------|-----------| -| GET | /vocab/words | 단어 목록 조회 | -| POST | /vocab/words | 단어 등록 | -| GET | /vocab/daily | 오늘의 학습 단어 | -| POST | /vocab/daily/words/{wordId}/learned | 단어 학습 완료 | -| POST | /vocab/tests | 테스트 생성 | -| POST | /vocab/tests/{testId}/submit | 테스트 제출 | -| GET | /stats/daily | 일별 통계 | -| GET | /stats/weekly | 주별 통계 | -| GET | /stats/monthly | 월별 통계 | -| GET | /stats/total | 전체 통계 | -| GET | /stats/history?limit=100 | 통계 히스토리 | -| GET | /badges | 전체 배지 목록 | -| GET | /badges/earned | 획득한 배지 | -| GET | /rooms | 채팅방 목록 | -| POST | /rooms | 채팅방 생성 | -| POST | /rooms/{roomId}/join | 채팅방 입장 | -| POST | /grammar/check | 문법 체크 | - -### WebSocket API - -| Endpoint | 설명 | -|---------------------------------------------------------------|---------| -| wss://t378dif43l.execute-api.ap-northeast-2.amazonaws.com/dev | 채팅/게임 | -| wss://ltrccmteo8.execute-api.ap-northeast-2.amazonaws.com/dev | 문법 스트리밍 | - ---- - -## 5. 프로젝트 구조 - -``` -ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/ -├── common/ # 공통 모듈 -│ ├── config/ # AWS 클라이언트 (싱글톤) -│ ├── router/ # HandlerRouter, Route -│ ├── exception/ # 예외 처리 체계 -│ ├── dto/ # PaginatedResult, ErrorInfo -│ └── util/ # ResponseGenerator, CursorUtil -│ -├── domain/ -│ ├── vocabulary/ # 단어 학습 도메인 -│ │ ├── handler/ # Word, UserWord, Test, DailyStudy 핸들러 -│ │ ├── service/ # CQRS 서비스 (Command/Query) -│ │ ├── repository/ # DynamoDB 레포지토리 -│ │ ├── model/ # Word, UserWord, TestResult, DailyStudy -│ │ └── state/ # NEW, LEARNING, REVIEWING, MASTERED -│ │ -│ ├── chatting/ # 채팅 도메인 -│ │ ├── handler/ # REST + WebSocket 핸들러 -│ │ ├── service/ # ChatRoom, Game, Command 서비스 -│ │ └── model/ # ChatRoom, Connection, GameRound -│ │ -│ ├── grammar/ # 문법 체크 도메인 -│ │ ├── handler/ # REST + 스트리밍 핸들러 -│ │ ├── service/ # GrammarCheck, Conversation 서비스 -│ │ └── factory/ # BedrockGrammarCheckFactory -│ │ -│ ├── stats/ # 통계 도메인 -│ │ ├── handler/ # UserStats, Streams 핸들러 -│ │ └── repository/ # UserStatsRepository -│ │ -│ └── badge/ # 배지 도메인 -│ ├── handler/ # BadgeHandler -│ └── service/ # BadgeService -``` - ---- - -## 6. 성과 요약 - -| 카테고리 | 성과 | -|------------------|------------------------------------| -| **Lambda 함수** | 26개 | -| **API 엔드포인트** | REST 40+, WebSocket 2 | -| **DynamoDB 테이블** | 2개 (Single Table Design) | -| **GSI** | 5개 | -| **아키텍처 패턴** | CQRS, State, Factory, Event-Driven | -| **AI 연동** | Bedrock Claude 3.5 Sonnet (문법/대화) | -| **TTS** | AWS Polly (남성/여성 음성) | -| **실시간 통신** | WebSocket (채팅/게임/문법 스트리밍) | -| **인증** | Cognito + RoomToken | - ---- - -**작성일:** 2026-01-16 -**팀:** MZC 2nd Project Team / SMJ diff --git a/docs/domain-reports/BADGE-DOMAIN-REPORT.md b/docs/domain-reports/BADGE-DOMAIN-REPORT.md deleted file mode 100644 index 4cd58215..00000000 --- a/docs/domain-reports/BADGE-DOMAIN-REPORT.md +++ /dev/null @@ -1,681 +0,0 @@ -# Badge Domain 세부 보고서 - -## 1. 개요 - -Badge 도메인은 사용자의 학습 성취도에 따라 배지를 자동으로 부여하는 시스템입니다. 이벤트 기반 아키텍처를 통해 Stats, Vocabulary, Chatting 도메인과 연동되어 실시간으로 배지를 체크하고 -부여합니다. - ---- - -## 2. 전체 아키텍처 - -```mermaid -flowchart TB - subgraph Triggers["트리거 소스"] - TEST[테스트 완료
DynamoDB Streams] - WORD[단어 학습
Write-through] - GAME[게임 종료
Service Method] - end - - subgraph Processing["Badge 처리"] - CHECK[BadgeService
조건 체크] - AWARD[배지 부여] - end - - subgraph Storage["저장소"] - DDB[(DynamoDB
UserBadge)] - S3[(S3
배지 이미지)] - end - - subgraph Query["조회"] - API[BadgeHandler
REST API] - PRESIGN[S3 Presigned URL] - end - - TEST --> CHECK - WORD --> CHECK - GAME --> CHECK - CHECK --> AWARD - AWARD --> DDB - DDB --> API - S3 --> PRESIGN - PRESIGN --> API -``` - ---- - -## 3. 배지 종류 - -### 3.1 배지 카테고리 - -```mermaid -mindmap - root((배지 시스템)) - 학습 - FIRST_STEP[첫 걸음] - WORDS_100[단어 수집가] - WORDS_500[단어 전문가] - WORDS_1000[단어 마스터] - 연속학습 - STREAK_3[3일 연속] - STREAK_7[7일 연속] - STREAK_30[30일 연속] - 테스트 - PERFECT_SCORE[완벽주의자] - TEST_10[테스트 도전자] - ACCURACY_90[정확도 달인] - 게임 - GAME_FIRST[첫 게임] - GAME_10_WINS[10승 달성] - QUICK_GUESSER[번개 정답] - PERFECT_DRAWER[완벽한 출제자] - 최종 - MASTER[학습 마스터] -``` - -### 3.2 배지 상세 - -| Badge Type | 이름 | 설명 | 카테고리 | 조건 | -|-----------------|-----------|--------------------|-----------------|-----------------------| -| FIRST_STEP | 첫 걸음 | 첫 학습을 완료했습니다 | FIRST_STUDY | testsCompleted >= 1 | -| STREAK_3 | 3일 연속 학습 | 3일 연속으로 학습했습니다 | STREAK | currentStreak >= 3 | -| STREAK_7 | 일주일 연속 학습 | 7일 연속으로 학습했습니다 | STREAK | currentStreak >= 7 | -| STREAK_30 | 한 달 연속 학습 | 30일 연속으로 학습했습니다 | STREAK | currentStreak >= 30 | -| WORDS_100 | 단어 수집가 | 100개의 단어를 학습했습니다 | WORDS_LEARNED | totalWords >= 100 | -| WORDS_500 | 단어 전문가 | 500개의 단어를 학습했습니다 | WORDS_LEARNED | totalWords >= 500 | -| WORDS_1000 | 단어 마스터 | 1000개의 단어를 학습했습니다 | WORDS_LEARNED | totalWords >= 1000 | -| PERFECT_SCORE | 완벽주의자 | 테스트에서 만점을 받았습니다 | PERFECT_TEST | incorrectAnswers == 0 | -| TEST_10 | 테스트 도전자 | 10회의 테스트를 완료했습니다 | TESTS_COMPLETED | testsCompleted >= 10 | -| ACCURACY_90 | 정확도 달인 | 전체 정확도 90%를 달성했습니다 | ACCURACY | successRate >= 90 | -| GAME_FIRST_PLAY | 첫 게임 | 첫 게임에 참여했습니다 | GAMES_PLAYED | gamesPlayed >= 1 | -| GAME_10_WINS | 게임 10승 | 게임에서 10번 1등을 했습니다 | GAMES_WON | gamesWon >= 10 | -| QUICK_GUESSER | 번개 정답 | 5초 내에 정답을 맞췄습니다 | QUICK_GUESSES | quickGuesses >= 1 | -| PERFECT_DRAWER | 완벽한 출제자 | 출제 시 전원이 정답을 맞췄습니다 | PERFECT_DRAWS | perfectDraws >= 1 | -| MASTER | 학습 마스터 | 모든 업적을 달성했습니다 | ALL_BADGES | 모든 배지 획득 | - ---- - -## 4. 배지 부여 흐름 - -### 4.1 테스트 완료 시 - -```mermaid -sequenceDiagram - participant Test as TestResult - participant Streams as DynamoDB Streams - participant Handler as StatsStreamHandler - participant Stats as UserStats - participant Badge as BadgeService - participant DB as DynamoDB - Test ->> Streams: INSERT 이벤트 - Streams ->> Handler: 트리거 - Handler ->> Stats: incrementTestStats() - Handler ->> Stats: updateStudyStreak() - Note over Handler: 만점 체크 - alt 정답 > 0 && 오답 == 0 - Handler ->> Badge: awardBadge("PERFECT_SCORE") - Badge ->> DB: UserBadge 저장 - end - - Handler ->> Stats: findTotalStats() - Stats -->> Handler: UserStats - Handler ->> Badge: checkAndAwardBadges() - Badge ->> Badge: 각 배지 조건 체크 - Badge ->> DB: 획득 배지 저장 -``` - -### 4.2 단어 학습 시 - -```mermaid -sequenceDiagram - participant API as DailyStudyHandler - participant Service as DailyStudyCommandService - participant Stats as UserStatsRepository - participant Badge as BadgeService - participant DB as DynamoDB - API ->> Service: markWordLearned() - Service ->> Stats: incrementWordsLearned() - Note over Service: 배지 체크 (WORDS_xxx) - Service ->> Stats: findTotalStats() - Stats -->> Service: UserStats - Service ->> Badge: checkAndAwardBadges() - Badge ->> Badge: WORDS_100, 500, 1000 체크 - Badge ->> DB: 획득 배지 저장 -``` - -### 4.3 게임 종료 시 - -```mermaid -sequenceDiagram - participant Game as GameService - participant Stats as GameStatsService - participant Repo as UserStatsRepository - participant Badge as BadgeService - participant DB as DynamoDB - Game ->> Stats: updateGameStats(room) - - loop 각 참가자 - Stats ->> Stats: 점수 집계 - Note over Stats: correctGuesses
quickGuesses (5초 이내)
perfectDraws - Stats ->> Repo: incrementGameStats() - Stats ->> Repo: findTotalStats() - Repo -->> Stats: UserStats - Stats ->> Badge: checkAndAwardBadges() - Badge ->> Badge: GAME_xxx 배지 체크 - Badge ->> DB: 획득 배지 저장 - end -``` - ---- - -## 5. 배지 조건 체크 로직 - -### 5.1 카테고리별 조건 - -```mermaid -flowchart TB - START[checkAndAwardBadges] --> LOOP{모든 BadgeType 순회} - LOOP --> EARNED{이미 획득?} - EARNED -->|Yes| SKIP[건너뛰기] - EARNED -->|No| CHECK[조건 체크] - CHECK --> SWITCH{카테고리} - SWITCH -->|FIRST_STUDY| FS[testsCompleted >= 1] - SWITCH -->|STREAK| ST[currentStreak >= threshold] - SWITCH -->|WORDS_LEARNED| WL[totalWords >= threshold] - SWITCH -->|PERFECT_TEST| PT[별도 처리] - SWITCH -->|TESTS_COMPLETED| TC[testsCompleted >= threshold] - SWITCH -->|ACCURACY| AC[successRate >= threshold] - SWITCH -->|GAMES_PLAYED| GP[gamesPlayed >= threshold] - SWITCH -->|GAMES_WON| GW[gamesWon >= threshold] - SWITCH -->|QUICK_GUESSES| QG[quickGuesses >= threshold] - SWITCH -->|PERFECT_DRAWS| PD[perfectDraws >= threshold] - SWITCH -->|ALL_BADGES| AB[모든 배지 획득 체크] - FS --> RESULT{조건 충족?} - ST --> RESULT - WL --> RESULT - TC --> RESULT - AC --> RESULT - GP --> RESULT - GW --> RESULT - QG --> RESULT - PD --> RESULT - RESULT -->|Yes| AWARD[배지 부여] - RESULT -->|No| SKIP - AWARD --> LOOP - SKIP --> LOOP -``` - -### 5.2 Switch Expression 패턴 - -```java -private boolean checkBadgeCondition(BadgeType type, UserStats stats) { - return switch (type.getCategory()) { - case "FIRST_STUDY" -> stats.getTestsCompleted() != null && stats.getTestsCompleted() >= 1; - - case "STREAK" -> stats.getCurrentStreak() != null && - stats.getCurrentStreak() >= type.getThreshold(); - - case "WORDS_LEARNED" -> { - int total = (stats.getNewWordsLearned() != null ? stats.getNewWordsLearned() : 0) - + (stats.getWordsReviewed() != null ? stats.getWordsReviewed() : 0); - yield total >= type.getThreshold(); - } - - case "ACCURACY" -> { - if (stats.getQuestionsAnswered() == null || stats.getQuestionsAnswered() == 0) - yield false; - double accuracy = (stats.getCorrectAnswers() * 100.0) / stats.getQuestionsAnswered(); - yield accuracy >= type.getThreshold(); - } - - case "TESTS_COMPLETED" -> stats.getTestsCompleted() != null && - stats.getTestsCompleted() >= type.getThreshold(); - - case "GAMES_PLAYED" -> stats.getGamesPlayed() != null && - stats.getGamesPlayed() >= type.getThreshold(); - - case "GAMES_WON" -> stats.getGamesWon() != null && - stats.getGamesWon() >= type.getThreshold(); - - case "QUICK_GUESSES" -> stats.getQuickGuesses() != null && - stats.getQuickGuesses() >= type.getThreshold(); - - case "PERFECT_DRAWS" -> stats.getPerfectDraws() != null && - stats.getPerfectDraws() >= type.getThreshold(); - - case "PERFECT_TEST" -> false; // 별도 처리 (StatsStreamHandler) - case "ALL_BADGES" -> false; // 특수 로직 필요 - - default -> false; - }; -} -``` - ---- - -## 6. API 엔드포인트 - -### 6.1 REST API - -| Method | Endpoint | 설명 | 응답 | -|--------|----------------|----------------|-------------| -| GET | /badges | 전체 배지 목록 + 진행도 | BadgeInfo[] | -| GET | /badges/earned | 획득한 배지만 조회 | UserBadge[] | - -### 6.2 전체 배지 조회 응답 - -```json -{ - "message": "Badges retrieved", - "data": { - "badges": [ - { - "badgeType": "FIRST_STEP", - "name": "첫 걸음", - "description": "첫 학습을 완료했습니다", - "imageUrl": "https://...presigned.../badges/first_step.png", - "category": "FIRST_STUDY", - "threshold": 1, - "progress": 1, - "earned": true, - "earnedAt": "2026-01-16T10:30:45.123Z" - }, - { - "badgeType": "WORDS_100", - "name": "단어 수집가", - "description": "100개의 단어를 학습했습니다", - "imageUrl": "https://...presigned.../badges/words_100.png", - "category": "WORDS_LEARNED", - "threshold": 100, - "progress": 45, - "earned": false, - "earnedAt": null - } - ], - "totalCount": 16, - "earnedCount": 8 - } -} -``` - -### 6.3 획득 배지 조회 응답 - -```json -{ - "message": "Earned badges retrieved", - "data": { - "badges": [ - { - "badgeType": "FIRST_STEP", - "name": "첫 걸음", - "description": "첫 학습을 완료했습니다", - "imageUrl": "https://...presigned.../badges/first_step.png", - "category": "FIRST_STUDY", - "threshold": 1, - "progress": 1, - "earnedAt": "2026-01-16T10:30:45.123Z" - } - ], - "count": 8 - } -} -``` - ---- - -## 7. 데이터 모델 - -### 7.1 UserBadge - -```java - -@DynamoDbBean -public class UserBadge { - // 기본 키 - String pk; // USER#{userId}#BADGE - String sk; // BADGE#{badgeType} - - // GSI (전체 배지 조회) - String gsi1pk; // BADGE#ALL - String gsi1sk; // EARNED#{earnedAt} - - // 메타데이터 - String odUserId; - String badgeType; // BadgeType enum 이름 - String name; - String description; - String imageUrl; - String category; - Integer threshold; - Integer progress; // 획득 시점 진행도 - - // 타임스탬프 - String earnedAt; - String createdAt; -} -``` - -### 7.2 DynamoDB 키 구조 - -| 필드 | 패턴 | 예시 | -|--------|---------------------|-----------------------------| -| PK | USER#{userId}#BADGE | USER#abc123#BADGE | -| SK | BADGE#{badgeType} | BADGE#STREAK_7 | -| GSI1PK | BADGE#ALL | BADGE#ALL | -| GSI1SK | EARNED#{earnedAt} | EARNED#2026-01-16T10:30:45Z | - -### 7.3 BadgeType Enum - -```java -public enum BadgeType { - FIRST_STEP("첫 걸음", "첫 학습을 완료했습니다", - "FIRST_STUDY", 1, "first_step.png"), - STREAK_3("3일 연속 학습", "3일 연속으로 학습했습니다", - "STREAK", 3, "streak_3.png"), - STREAK_7("일주일 연속 학습", "7일 연속으로 학습했습니다", - "STREAK", 7, "streak_7.png"), - // ... 생략 - MASTER("학습 마스터", "모든 업적을 달성했습니다", - "ALL_BADGES", 1, "master.png"); - - private final String name; - private final String description; - private final String category; - private final int threshold; - private final String imageFile; -} -``` - ---- - -## 8. 진행도 계산 - -### 8.1 카테고리별 진행도 - -```mermaid -flowchart TB - subgraph Progress["진행도 계산"] - FIRST["FIRST_STUDY
testsCompleted >= 1 ? 1 : 0"] - STREAK["STREAK
currentStreak"] - WORDS["WORDS_LEARNED
newWords + reviewed"] - TESTS["TESTS_COMPLETED
testsCompleted"] - ACC["ACCURACY
successRate (%)"] - GAMES["GAMES_PLAYED
gamesPlayed"] - WINS["GAMES_WON
gamesWon"] - QUICK["QUICK_GUESSES
quickGuesses"] - PERFECT["PERFECT_DRAWS
perfectDraws"] - end -``` - -### 8.2 calculateProgress 메서드 - -```java -private int calculateProgress(BadgeType type, UserStats stats) { - return switch (type.getCategory()) { - case "FIRST_STUDY" -> (stats.getTestsCompleted() != null && stats.getTestsCompleted() >= 1) ? 1 : 0; - - case "STREAK" -> stats.getCurrentStreak() != null ? stats.getCurrentStreak() : 0; - - case "WORDS_LEARNED" -> { - int newWords = stats.getNewWordsLearned() != null ? stats.getNewWordsLearned() : 0; - int reviewed = stats.getWordsReviewed() != null ? stats.getWordsReviewed() : 0; - yield newWords + reviewed; - } - - case "TESTS_COMPLETED" -> stats.getTestsCompleted() != null ? stats.getTestsCompleted() : 0; - - case "ACCURACY" -> { - if (stats.getQuestionsAnswered() == null || stats.getQuestionsAnswered() == 0) - yield 0; - yield (int) ((stats.getCorrectAnswers() * 100.0) / stats.getQuestionsAnswered()); - } - - case "GAMES_PLAYED" -> stats.getGamesPlayed() != null ? stats.getGamesPlayed() : 0; - - case "GAMES_WON" -> stats.getGamesWon() != null ? stats.getGamesWon() : 0; - - case "QUICK_GUESSES" -> stats.getQuickGuesses() != null ? stats.getQuickGuesses() : 0; - - case "PERFECT_DRAWS" -> stats.getPerfectDraws() != null ? stats.getPerfectDraws() : 0; - - default -> 0; - }; -} -``` - ---- - -## 9. 멱등성 보장 - -### 9.1 중복 부여 방지 흐름 - -```mermaid -flowchart TB - START[checkAndAwardBadges] --> LOOP[배지 타입 순회] - LOOP --> CHECK{hasBadge?} - CHECK -->|이미 있음| SKIP[건너뛰기] - CHECK -->|없음| CONDITION{조건 충족?} - CONDITION -->|Yes| CREATE[배지 생성] - CONDITION -->|No| SKIP - CREATE --> SAVE[DynamoDB 저장] - SAVE --> LOOP - SKIP --> LOOP -``` - -### 9.2 구현 코드 - -```java -public List checkAndAwardBadges(String userId, UserStats stats) { - List newBadges = new ArrayList<>(); - String now = Instant.now().toString(); - - for (BadgeType type : BadgeType.values()) { - // 1. 이미 획득한 배지는 건너뛰기 - if (badgeRepository.hasBadge(userId, type.name())) { - continue; - } - - // 2. 조건 체크 - if (checkBadgeCondition(type, stats)) { - // 3. 배지 생성 및 저장 - UserBadge badge = createBadge(userId, type, now); - badgeRepository.save(badge); - newBadges.add(badge); - } - } - - return newBadges; -} -``` - ---- - -## 10. S3 이미지 연동 - -### 10.1 Presigned URL 생성 - -```mermaid -flowchart LR - REQ[배지 조회] --> SERVICE[BadgeService] - SERVICE --> PRESIGN[S3PresignUtil] - PRESIGN --> CACHE{캐시 확인} - CACHE -->|있음| RETURN[URL 반환] - CACHE -->|없음| GENERATE[Presigned URL 생성] - GENERATE --> SAVE[캐시 저장] - SAVE --> RETURN -``` - -### 10.2 이미지 URL 생성 - -```java -// S3PresignUtil.java -public static String getBadgeImageUrl(String imageFile) { - return getPresignedUrl("badges/" + imageFile); -} - -// BadgeService - 배지 생성 시 -private UserBadge createBadge(String userId, BadgeType type, String now) { - return UserBadge.builder() - .pk(BadgeKey.userBadgePk(userId)) - .sk(BadgeKey.badgeSk(type.name())) - .gsi1pk(BadgeKey.BADGE_ALL) - .gsi1sk(BadgeKey.earnedSk(now)) - .odUserId(userId) - .badgeType(type.name()) - .name(type.getName()) - .description(type.getDescription()) - .imageUrl(S3PresignUtil.getBadgeImageUrl(type.getImageFile())) - .category(type.getCategory()) - .threshold(type.getThreshold()) - .earnedAt(now) - .createdAt(now) - .build(); -} -``` - -### 10.3 S3 버킷 구조 - -``` -s3://group2-englishstudy/ -└── badges/ - ├── first_step.png - ├── streak_3.png - ├── streak_7.png - ├── streak_30.png - ├── words_100.png - ├── words_500.png - ├── words_1000.png - ├── perfect_score.png - ├── test_10.png - ├── accuracy_90.png - ├── game_first.png - ├── game_10_wins.png - ├── quick_guesser.png - ├── perfect_drawer.png - └── master.png -``` - ---- - -## 11. Stats 도메인 연동 - -### 11.1 연동 포인트 - -```mermaid -flowchart TB - subgraph Stats["Stats 도메인"] - STREAM[StatsStreamHandler] - DAILY[DailyStudyCommandService] - GAME[GameStatsService] - REPO[UserStatsRepository] - end - - subgraph Badge["Badge 도메인"] - SERVICE[BadgeService] - BADGEREPO[BadgeRepository] - end - - STREAM -->|checkAndAwardBadges| SERVICE - DAILY -->|checkWordsBadge| SERVICE - GAME -->|checkAndAwardBadges| SERVICE - SERVICE -->|hasBadge, save| BADGEREPO - SERVICE -->|findTotalStats| REPO -``` - -### 11.2 UserStats 필드와 배지 매핑 - -| UserStats 필드 | 배지 | -|------------------------------------|----------------------------------| -| testsCompleted | FIRST_STEP, TEST_10 | -| currentStreak | STREAK_3, STREAK_7, STREAK_30 | -| newWordsLearned + wordsReviewed | WORDS_100, WORDS_500, WORDS_1000 | -| correctAnswers / questionsAnswered | ACCURACY_90 | -| gamesPlayed | GAME_FIRST_PLAY | -| gamesWon | GAME_10_WINS | -| quickGuesses | QUICK_GUESSER | -| perfectDraws | PERFECT_DRAWER | - ---- - -## 12. 파일 구조 - -``` -domain/badge/ -├── enums/ -│ └── BadgeType.java # 16가지 배지 정의 -├── constants/ -│ └── BadgeKey.java # DynamoDB 키 생성 -├── model/ -│ └── UserBadge.java # 배지 엔티티 -├── repository/ -│ └── BadgeRepository.java # CRUD 연산 -├── service/ -│ └── BadgeService.java # 조건 체크, 배지 부여 -└── handler/ - └── BadgeHandler.java # REST API - -연동 파일: -├── domain/stats/handler/StatsStreamHandler.java -├── domain/vocabulary/service/DailyStudyCommandService.java -└── domain/chatting/service/GameStatsService.java -``` - ---- - -## 13. 기술 스택 - -- **Runtime:** AWS Lambda (Java 21) -- **Database:** DynamoDB (Single Table Design) -- **Storage:** S3 (배지 이미지) -- **Event:** DynamoDB Streams, Write-through, Service Method -- **Pattern:** Event-driven, Idempotent, Switch Expression -- **Java 21 Features:** Enhanced Switch, Yield Statement - ---- - -## 14. 배지 획득 시나리오 - -### 14.1 시나리오 예시 - -```mermaid -flowchart LR - subgraph Day1["1일차"] - A1[테스트 완료] --> B1["FIRST_STEP 획득"] - end - - subgraph Day3["3일차"] - A3[3일 연속 학습] --> B3["STREAK_3 획득"] - end - - subgraph Day7["7일차"] - A7[7일 연속 학습] --> B7["STREAK_7 획득"] - A7_2[100단어 학습] --> B7_2["WORDS_100 획득"] - end - - subgraph Game["게임"] - G1[5초 내 정답] --> G2["QUICK_GUESSER 획득"] - G3[10회 1등] --> G4["GAME_10_WINS 획득"] - end -``` - -### 14.2 특수 배지 획득 조건 - -**PERFECT_SCORE (완벽주의자):** - -- 테스트 제출 시 오답 0개이면 즉시 부여 -- StatsStreamHandler에서 별도 처리 - -**QUICK_GUESSER (번개 정답):** - -- 게임 중 5초(5000ms) 이내 정답 시 -- GameStatsService에서 quickGuesses 카운트 - -**PERFECT_DRAWER (완벽한 출제자):** - -- 출제 시 모든 참가자가 정답을 맞춘 경우 -- 라운드 종료 시 endReason == "ALL_CORRECT"이면 카운트 - -**MASTER (학습 마스터):** - -- 다른 모든 배지를 획득한 경우 -- 특수 로직으로 모든 배지 보유 여부 확인 diff --git a/docs/domain-reports/CHATTING-DOMAIN-REPORT.md b/docs/domain-reports/CHATTING-DOMAIN-REPORT.md deleted file mode 100644 index c27eb552..00000000 --- a/docs/domain-reports/CHATTING-DOMAIN-REPORT.md +++ /dev/null @@ -1,434 +0,0 @@ -# Chatting Domain 세부 보고서 - -## 1. 개요 - -Chatting 도메인은 실시간 채팅과 캐치마인드 게임 기능을 제공하는 WebSocket 기반 시스템입니다. AWS API Gateway WebSocket과 Lambda를 활용하여 실시간 양방향 통신을 구현했습니다. - ---- - -## 2. 전체 아키텍처 - -```mermaid -flowchart TB - subgraph Client["클라이언트"] - APP[Mobile/Web App] - end - - subgraph Gateway["API Gateway"] - REST[REST API] - WS[WebSocket API] - end - - subgraph Lambda["Lambda Handlers"] - direction TB - ROOM[ChatRoomHandler] - MSG[ChatMessageHandler] - GAME[GameHandler] - VOICE[ChatVoiceHandler] - CONNECT[WebSocketConnectHandler] - DISCONNECT[WebSocketDisconnectHandler] - MESSAGE[WebSocketMessageHandler] - end - - subgraph Storage["데이터 저장소"] - DDB[(DynamoDB)] - S3[(S3 - 음성 캐시)] - end - - APP --> REST - APP <--> WS - REST --> ROOM - REST --> MSG - REST --> GAME - REST --> VOICE - WS --> CONNECT - WS --> DISCONNECT - WS --> MESSAGE - ROOM --> DDB - MSG --> DDB - GAME --> DDB - MESSAGE --> DDB - VOICE --> S3 -``` - ---- - -## 3. 채팅방 시스템 - -### 3.1 채팅방 입장 흐름 - -```mermaid -sequenceDiagram - participant Client - participant REST as REST API - participant WS as WebSocket API - participant DB as DynamoDB - Note over Client, DB: Phase 1 - 방 입장 및 토큰 발급 - Client ->> REST: POST /rooms/{roomId}/join - REST ->> DB: 비밀번호 검증 (비밀방인 경우) - REST ->> DB: RoomToken 저장 (TTL 5분) - REST -->> Client: roomToken 반환 - Note over Client, DB: Phase 2 - WebSocket 연결 - Client ->> WS: $connect?roomToken={token} - WS ->> DB: 토큰 검증 - WS ->> DB: Connection 저장 (TTL 10분) - WS -->> Client: 연결 성공 - Note over Client, DB: Phase 3 - 실시간 메시지 - Client ->> WS: sendMessage (채팅) - WS ->> DB: 메시지 저장 - WS -->> Client: 브로드캐스트 (같은 방 전체) -``` - -### 3.2 REST API 엔드포인트 - -| Method | Endpoint | 설명 | 인증 | -|--------|-------------------------------|---------------------------|----| -| POST | /chat/rooms | 채팅방 생성 | O | -| GET | /chat/rooms | 채팅방 목록 (level, joined 필터) | O | -| GET | /chat/rooms/{roomId} | 채팅방 상세 | O | -| POST | /chat/rooms/{roomId}/join | 채팅방 입장 (토큰 발급) | O | -| POST | /chat/rooms/{roomId}/leave | 채팅방 퇴장 | O | -| DELETE | /chat/rooms/{roomId} | 채팅방 삭제 (방장만) | O | -| GET | /chat/rooms/{roomId}/messages | 메시지 히스토리 | O | - -### 3.3 WebSocket 이벤트 - -| Route | 설명 | Payload | -|-------------|------------|------------------------------------------| -| $connect | 연결 (토큰 검증) | ?roomToken={token} | -| $disconnect | 연결 해제 | - | -| sendMessage | 메시지 전송 | { roomId, userId, content, messageType } | - ---- - -## 4. 캐치마인드 게임 시스템 - -### 4.1 게임 흐름 - -```mermaid -flowchart TB - subgraph GameFlow["캐치마인드 게임 흐름"] - START["/game 명령어"] --> INIT["게임 초기화
출제자 순서 셔플"] - INIT --> ROUND["라운드 시작
출제자 + 단어 선정"] - ROUND --> DRAW["출제자 그림 그리기
(DRAWING 메시지)"] - DRAW --> GUESS["참가자 정답 입력"] - GUESS --> CHECK{정답?} - CHECK -->|Yes| SCORE["점수 계산
시간보너스 + 연속보너스"] - CHECK -->|No| GUESS - SCORE --> ALLCORRECT{전원 정답?} - ALLCORRECT -->|Yes| NEXTROUND - ALLCORRECT -->|No| TIMEOUT{시간 초과?} - TIMEOUT -->|Yes| NEXTROUND["다음 라운드"] - TIMEOUT -->|No| GUESS - NEXTROUND --> LASTROUND{마지막 라운드?} - LASTROUND -->|Yes| END["게임 종료
순위 발표"] - LASTROUND -->|No| ROUND - end -``` - -### 4.2 게임 API - -| Method | Endpoint | 설명 | -|--------|----------------------------------|-------------| -| POST | /chat/rooms/{roomId}/game/start | 게임 시작 (방장만) | -| POST | /chat/rooms/{roomId}/game/stop | 게임 중지 | -| GET | /chat/rooms/{roomId}/game/status | 게임 상태 조회 | -| GET | /chat/rooms/{roomId}/game/scores | 점수판 조회 | - -### 4.3 슬래시 명령어 - -| 명령어 | 설명 | 사용 가능 | -|---------|----------------|--------| -| /start | 게임 시작 | 방장 | -| /stop | 게임 중지 | 방장/시작자 | -| /score | 점수판 보기 | 전체 | -| /member | 접속자 수 | 전체 | -| /hint | 힌트 제공 (첫글자○○○) | 출제자 | -| /skip | 라운드 스킵 | 출제자 | -| /help | 명령어 도움말 | 전체 | - -### 4.4 점수 계산 공식 - -``` -점수 = 기본점수(10) + 시간보너스 + 연속보너스 + 출제자보너스 - -- 시간보너스: (60 - 경과초) × 0.5 -- 연속보너스: streak × 2 -- 출제자보너스: 정답자당 5점 -``` - -**예시:** - -- 30초에 정답 + 연속 3회: 10 + 15 + 6 = 31점 -- 출제자가 3명 맞출 경우: 5 × 3 = 15점 - -### 4.5 게임 상태 - -```mermaid -stateDiagram-v2 - [*] --> NONE: 대기 - NONE --> PLAYING: /start 명령어 - PLAYING --> ROUND_END: 시간초과/전원정답 - ROUND_END --> PLAYING: 다음 라운드 - ROUND_END --> FINISHED: 마지막 라운드 - PLAYING --> FINISHED: /stop 명령어 - FINISHED --> [*]: 게임 종료 -``` - ---- - -## 5. WebSocket 메시지 타입 - -### 5.1 채팅 메시지 - -| Type | 설명 | 저장 | -|-------------|-------|----| -| TEXT | 일반 채팅 | O | -| IMAGE | 이미지 | O | -| VOICE | 음성 | O | -| AI_RESPONSE | AI 응답 | O | - -### 5.2 게임 메시지 - -| Type | 설명 | 저장 | -|----------------|--------------|----| -| DRAWING | 그림 데이터 (실시간) | X | -| DRAWING_CLEAR | 그림 지우기 | X | -| GUESS | 오답 추측 | X | -| CORRECT_ANSWER | 정답 알림 | X | -| SCORE_UPDATE | 점수 갱신 | X | -| GAME_START | 게임 시작 | X | -| ROUND_START | 라운드 시작 | X | -| ROUND_END | 라운드 종료 | X | -| GAME_END | 게임 종료 | X | -| HINT | 힌트 | X | - -### 5.3 실시간 점수 업데이트 메시지 - -```json -{ - "messageType": "SCORE_UPDATE", - "roomId": "uuid", - "scorerId": "user123", - "scoreGained": 25, - "ranking": [ - { - "rank": 1, - "userId": "user123", - "score": 85, - "change": 25 - }, - { - "rank": 2, - "userId": "user456", - "score": 60, - "change": 0 - } - ], - "currentRound": 3, - "totalRounds": 5 -} -``` - ---- - -## 6. 데이터 모델 - -### 6.1 ChatRoom - -```java - -@DynamoDbBean -public class ChatRoom { - // 기본 정보 - String roomId, name, description; - String level; // beginner, intermediate, advanced - Integer currentMembers, maxMembers; - Boolean isPrivate; - String password; // BCrypt 암호화 - String createdBy; // 방장 - List memberIds; - - // 게임 상태 - String gameStatus; // NONE, PLAYING, ROUND_END, FINISHED - Integer currentRound, totalRounds; - String currentDrawerId, currentWord; - Long roundStartTime; - Integer roundTimeLimit; // 60초 - List drawerOrder; - Map scores; - Map streaks; - List correctGuessers; - Boolean hintUsed; -} -``` - -**DynamoDB Keys:** - -- PK: `ROOM#{roomId}` | SK: `METADATA` -- GSI1: `ROOMS` | `{level}#{createdAt}` (레벨별 최신순) - -### 6.2 Connection - -```java - -@DynamoDbBean -public class Connection { - String connectionId; // API Gateway 연결 ID - String userId; - String roomId; - Long ttl; // 10분 (자동 삭제) -} -``` - -**DynamoDB Keys:** - -- PK: `CONN#{connectionId}` | SK: `METADATA` -- GSI1: `ROOM#{roomId}` | `CONN#{connectionId}` (방별 연결) -- GSI2: `USER#{userId}` | `CONN#{connectionId}` (사용자별 연결) - -### 6.3 GameRound - -```java - -@DynamoDbBean -public class GameRound { - Integer roundNumber; - String drawerId, word, wordEnglish; - List correctGuessers; - Map guessTimes; // 정답까지 걸린 시간 - Map roundScores; - Long startTime, endTime; - String endReason; // TIME_UP, ALL_CORRECT, SKIP - Long ttl; // 7일 -} -``` - -### 6.4 RoomToken - -```java - -@DynamoDbBean -public class RoomToken { - String token; // UUID - String roomId; - String userId; - Long ttl; // 5분 -} -``` - ---- - -## 7. 서비스 레이어 - -### 7.1 CQRS 패턴 - -| Service | 역할 | -|------------------------|----------------------| -| ChatRoomCommandService | 채팅방 생성, 입장, 퇴장, 삭제 | -| ChatRoomQueryService | 채팅방 조회, 목록 | -| GameService | 게임 시작, 정답 체크, 라운드 종료 | -| GameStatsService | 게임 종료 후 통계, 배지 처리 | -| CommandService | 슬래시 명령어 처리 | -| RoomTokenService | 토큰 발급 및 검증 | - -### 7.2 게임 정답 체크 로직 - -```mermaid -flowchart TB - INPUT[정답 입력] --> NORMALIZE["정규화
(소문자, 공백제거)"] - NORMALIZE --> VALIDATE{유효성 검사} - VALIDATE -->|게임 미진행| REJECT1[거부: 게임 없음] - VALIDATE -->|출제자 본인| REJECT2[거부: 출제자] - VALIDATE -->|이미 정답| REJECT3[거부: 중복] - VALIDATE -->|통과| COMPARE{정답 비교} - COMPARE -->|일치| CORRECT["정답 처리
점수 계산"] - COMPARE -->|불일치| WRONG["오답 처리
GUESS 메시지 전송"] - CORRECT --> BROADCAST["브로드캐스트
CORRECT_ANSWER + SCORE_UPDATE"] - WRONG --> GUESSBROADCAST["브로드캐스트
GUESS 메시지"] - BROADCAST --> ALLCHECK{전원 정답?} - ALLCHECK -->|Yes| ROUNDEND[라운드 자동 종료] - ALLCHECK -->|No| CONTINUE[게임 계속] -``` - ---- - -## 8. 브로드캐스트 시스템 - -### 8.1 WebSocketBroadcaster - -```java -public class WebSocketBroadcaster { - public List broadcast( - List connections, - String payload - ) { - // 1. 같은 방 모든 연결에 메시지 전송 - // 2. 실패한 연결 ID 반환 (Stale 정리용) - } -} -``` - -### 8.2 브로드캐스트 유형 - -| 유형 | 대상 | 예시 | -|--------|--------|-----------| -| 전체 | 방 전체 | 채팅, 정답 알림 | -| 본인 제외 | 발신자 제외 | 그림 데이터 | -| 출제자 전용 | 출제자만 | 단어 정보 | - ---- - -## 9. 파일 구조 - -``` -domain/chatting/ -├── handler/ -│ ├── ChatRoomHandler.java -│ ├── ChatMessageHandler.java -│ ├── ChatVoiceHandler.java -│ ├── GameHandler.java -│ └── websocket/ -│ ├── WebSocketConnectHandler.java -│ ├── WebSocketDisconnectHandler.java -│ └── WebSocketMessageHandler.java -├── service/ -│ ├── ChatRoomCommandService.java -│ ├── ChatRoomQueryService.java -│ ├── ChatMessageService.java -│ ├── GameService.java -│ ├── GameStatsService.java -│ ├── CommandService.java -│ └── RoomTokenService.java -├── repository/ -│ ├── ChatRoomRepository.java -│ ├── ChatMessageRepository.java -│ ├── ConnectionRepository.java -│ ├── GameRoundRepository.java -│ └── RoomTokenRepository.java -├── model/ -│ ├── ChatRoom.java -│ ├── ChatMessage.java -│ ├── Connection.java -│ ├── GameRound.java -│ └── RoomToken.java -├── dto/ -│ ├── request/ -│ └── response/ -│ └── ScoreUpdateMessage.java -└── enums/ - ├── GameStatus.java - └── MessageType.java -``` - ---- - -## 10. 기술 스택 - -- **Runtime:** AWS Lambda (Java 21) -- **API:** API Gateway REST + WebSocket -- **Database:** DynamoDB (Single Table Design) -- **Auth:** Cognito + RoomToken -- **Encryption:** BCrypt (비밀방 암호) -- **TTS:** AWS Polly + S3 캐시 -- **Pattern:** CQRS, Repository, Factory diff --git a/docs/domain-reports/COMMON-MODULE-REPORT.md b/docs/domain-reports/COMMON-MODULE-REPORT.md deleted file mode 100644 index aefe6d08..00000000 --- a/docs/domain-reports/COMMON-MODULE-REPORT.md +++ /dev/null @@ -1,1228 +0,0 @@ -# Common Module 세부 보고서 - -## 1. 개요 - -Common 모듈은 모든 도메인에서 공유하는 유틸리티, 설정, 예외 처리, 라우팅 등을 제공하는 핵심 인프라 모듈입니다. Java 21의 최신 기능(Records, Sealed Interface, Pattern -Matching)을 적극 활용하여 타입 안전성과 코드 간결성을 확보했습니다. - ---- - -## 2. 전체 패키지 구조 - -```mermaid -flowchart TB -subgraph Common["common/"] -CONFIG[config/] -CONST[constants/] -DTO[dto/] -ENUM[enums/] -EXCEPTION[exception/] -ROUTER[router/] -SERVICE[service/] -UTIL[util/] -VALIDATION[validation/] -end - -subgraph ConfigFiles["config/"] -AC[AwsClients.java] -WSC[WebSocketConfig.java] -RTC[RoomTokenConfig.java] -SC[StudyConfig.java] -end - -subgraph DtoFiles["dto/"] -AR[ApiResponse.java] -EI[ErrorInfo.java] -PR[PaginatedResult.java] -end - -subgraph ExceptionFiles["exception/"] -SE[ServerlessException.java] -EC[ErrorCode.java] -CEC[CommonErrorCode.java] -CE[CommonException.java] -end - -subgraph RouterFiles["router/"] -HR[HandlerRouter.java] -RT[Route.java] -AH[AuthenticatedHandler.java] -end - -CONFIG --> ConfigFiles -DTO --> DtoFiles -EXCEPTION --> ExceptionFiles -ROUTER --> RouterFiles -``` - ---- - -## 3. Handler 라우팅 시스템 - -### 3.1 HandlerRouter 아키텍처 - -```mermaid -flowchart TB - subgraph Request["요청 처리 흐름"] - REQ[APIGatewayProxyRequestEvent] --> ROUTER[HandlerRouter] - ROUTER --> MATCH{라우트 매칭} - MATCH -->|매칭 성공| VALIDATE[파라미터 검증] - MATCH -->|매칭 실패| NF404[404 Not Found] - VALIDATE --> EXECUTE[핸들러 실행] - EXECUTE --> RESPONSE[APIGatewayProxyResponseEvent] - end - - subgraph ErrorHandling["예외 처리"] - EXECUTE -->|ServerlessException| ERR1[ErrorCode 기반 응답] - EXECUTE -->|IllegalArgumentException| ERR2[400 Bad Request] - EXECUTE -->|IllegalStateException| ERR3[409 Conflict] - EXECUTE -->|SecurityException| ERR4[403 Forbidden] - EXECUTE -->|기타 예외| ERR5[500 Internal Error] - end -``` - -### 3.2 Route 정의 (Java 21 Record) - -```java -// Route.java - Java 21 Record 활용 -public record Route( - String method, // HTTP 메서드 - String pathPattern, // 경로 패턴 (e.g., "/rooms/{roomId}") - Function handler, - List requiredPathParams, // 필수 경로 파라미터 - List requiredQueryParams // 필수 쿼리 파라미터 - ) { - // 경로 파라미터 자동 추출: {roomId} → roomId - private static final Pattern PATH_PARAM_PATTERN = - Pattern.compile("\\{([^}]+)}"); -} -``` - -### 3.3 Route 팩토리 메서드 - -```mermaid -flowchart LR - subgraph BasicRoutes["기본 라우트"] - GET["Route.get()"] - POST["Route.post()"] - PUT["Route.put()"] - DELETE["Route.delete()"] - PATCH["Route.patch()"] - end - - subgraph AuthRoutes["인증 라우트"] - GETAUTH["Route.getAuth()"] - POSTAUTH["Route.postAuth()"] - PUTAUTH["Route.putAuth()"] - DELETEAUTH["Route.deleteAuth()"] - PATCHAUTH["Route.patchAuth()"] - end - - BasicRoutes -->|" + Cognito 인증 "| AuthRoutes -``` - -### 3.4 사용 예시 - -```java -// Handler에서 라우터 초기화 -private HandlerRouter initRouter() { - return new HandlerRouter().addRoutes( - // 인증 필요 라우트 (Cognito userId 자동 추출) - Route.postAuth("/grammar/check", this::checkGrammar), - Route.getAuth("/grammar/sessions/{sessionId}", this::getSessionDetail), - Route.deleteAuth("/grammar/sessions/{sessionId}", this::deleteSession), - - // 쿼리 파라미터 검증 - Route.getAuth("/rooms", this::getRooms) - .requireQueryParams("level") - ); -} - -// Lambda 핸들러 메서드 -@Override -public APIGatewayProxyResponseEvent handleRequest( - APIGatewayProxyRequestEvent request, Context context) { - return router.route(request); -} -``` - -### 3.5 AuthenticatedHandler 인터페이스 - -```java -// 함수형 인터페이스 - Cognito 인증 요청 처리 -@FunctionalInterface -public interface AuthenticatedHandler { - APIGatewayProxyResponseEvent handle( - APIGatewayProxyRequestEvent request, - String userId // Cognito sub claim에서 자동 추출 - ); -} - -// 사용 예시 - 람다 표현식으로 간결하게 -Route. - -postAuth("/rooms",(request, userId) ->{ -CreateRoomRequest dto = parseBody(request, CreateRoomRequest.class); -ChatRoom room = roomService.createRoom(userId, dto); - return ResponseGenerator. - -created("Room created",room); -}); -``` - ---- - -## 4. 예외 처리 시스템 - -### 4.1 ErrorCode 계층 구조 (Sealed Interface) - -```mermaid -flowchart TB - subgraph SealedHierarchy["Java 21 Sealed Interface 계층"] - EC[/"ErrorCode
(sealed interface)"/] - EC -->|permits| CEC["CommonErrorCode
(enum)"] - EC -->|permits| DEC[/"DomainErrorCode
(non-sealed interface)"/] - DEC --> VEC["VocabularyErrorCode"] - DEC --> CHEC["ChattingErrorCode"] - DEC --> GEC["GrammarErrorCode"] - DEC --> SEC["StatsErrorCode"] - DEC --> BEC["BadgeErrorCode"] - end -``` - -### 4.2 CommonErrorCode 정의 - -```java -public enum CommonErrorCode implements ErrorCode { - // 인증/인가 (AUTH_xxx) - UNAUTHORIZED("AUTH_001", "인증이 필요합니다", 401), - FORBIDDEN("AUTH_002", "접근 권한이 없습니다", 403), - INVALID_TOKEN("AUTH_003", "유효하지 않은 토큰입니다", 401), - TOKEN_EXPIRED("AUTH_004", "토큰이 만료되었습니다", 401), - - // 검증 (VALIDATION_xxx) - INVALID_INPUT("VALIDATION_001", "잘못된 입력입니다", 400), - REQUIRED_FIELD_MISSING("VALIDATION_002", "필수 필드가 누락되었습니다", 400), - INVALID_FORMAT("VALIDATION_003", "형식이 올바르지 않습니다", 400), - VALUE_OUT_OF_RANGE("VALIDATION_004", "값이 허용 범위를 벗어났습니다", 400), - - // 리소스 (RESOURCE_xxx) - RESOURCE_NOT_FOUND("RESOURCE_001", "리소스를 찾을 수 없습니다", 404), - RESOURCE_ALREADY_EXISTS("RESOURCE_002", "이미 존재하는 리소스입니다", 409), - METHOD_NOT_ALLOWED("RESOURCE_003", "허용되지 않는 메서드입니다", 405), - - // 시스템 (SYSTEM_xxx) - INTERNAL_SERVER_ERROR("SYSTEM_001", "내부 서버 오류가 발생했습니다", 500), - DATABASE_ERROR("SYSTEM_002", "데이터베이스 오류가 발생했습니다", 500), - EXTERNAL_API_ERROR("SYSTEM_003", "외부 API 호출 오류가 발생했습니다", 502), - SERVICE_UNAVAILABLE("SYSTEM_004", "서비스를 일시적으로 사용할 수 없습니다", 503); - - private final String code; - private final String message; - private final int statusCode; -} -``` - -### 4.3 예외 생성 팩토리 패턴 - -```mermaid -flowchart LR - subgraph FactoryMethods["CommonException 팩토리 메서드"] - AUTH["인증 오류"] - VALID["검증 오류"] - RES["리소스 오류"] - SYS["시스템 오류"] - end - - AUTH --> UNAUTH["unauthorized()"] - AUTH --> FORBID["forbidden()"] - AUTH --> TOKEN["invalidToken()"] - VALID --> INPUT["invalidInput(msg)"] - VALID --> MISS["requiredFieldMissing(field)"] - VALID --> FMT["invalidFormat(field)"] - RES --> NF["notFound(resource, id)"] - RES --> EXIST["alreadyExists(resource)"] - SYS --> INTERN["internalError(cause)"] - SYS --> DB["databaseError(cause)"] - SYS --> EXT["externalApiError(api, cause)"] -``` - -### 4.4 예외 사용 예시 - -```java -// 가독성 높은 예외 생성 -throw CommonException.notFound("User","user123"); -// → "User (ID: user123)를 찾을 수 없습니다", 404 - -throw CommonException. - -invalidInput("Email format is invalid"); -// → 400 INVALID_INPUT with custom message - -throw CommonException. - -alreadyExists("ChatRoom","room456"); -// → "ChatRoom (ID: room456)가 이미 존재합니다", 409 - -// 상세 컨텍스트 추가 (메서드 체이닝) -throw CommonException. - -internalError(cause) - . - -addDetail("operation","database_query") - . - -addDetail("table","users"); -``` - ---- - -## 5. AWS 클라이언트 관리 - -### 5.1 Singleton 패턴 (Cold Start 최적화) - -```mermaid -flowchart TB - subgraph ColdStart["Lambda Cold Start 최적화"] - INIT["Lambda 컨테이너 초기화
(1회)"] - STATIC["static final 클라이언트 생성"] - REUSE["요청마다 재사용"] - end - - INIT --> STATIC - STATIC --> REUSE - REUSE -->|" 다음 요청 "| REUSE -``` - -### 5.2 AwsClients.java 구조 - -```java -public final class AwsClients { - // DynamoDB (Enhanced Client 포함) - private static final DynamoDbClient DYNAMO_DB_CLIENT = - DynamoDbClient.builder().build(); - private static final DynamoDbEnhancedClient DYNAMO_DB_ENHANCED_CLIENT = - DynamoDbEnhancedClient.builder() - .dynamoDbClient(DYNAMO_DB_CLIENT) - .build(); - - // S3 (Presigner 포함) - private static final S3Client S3_CLIENT = S3Client.builder().build(); - private static final S3Presigner S3_PRESIGNER = S3Presigner.builder().build(); - - // AI/ML 서비스 - private static final PollyClient POLLY_CLIENT = PollyClient.builder().build(); - private static final BedrockRuntimeClient BEDROCK_CLIENT = - BedrockRuntimeClient.builder().build(); - private static final BedrockRuntimeAsyncClient BEDROCK_ASYNC_CLIENT = - BedrockRuntimeAsyncClient.builder().build(); - private static final ComprehendClient COMPREHEND_CLIENT = - ComprehendClient.builder().build(); - - // SNS - private static final SnsClient SNS_CLIENT = SnsClient.builder().build(); - - // 팩토리 메서드 - public static DynamoDbClient dynamoDb() { - return DYNAMO_DB_CLIENT; - } - - public static DynamoDbEnhancedClient dynamoDbEnhanced() { - return DYNAMO_DB_ENHANCED_CLIENT; - } - - public static S3Client s3() { - return S3_CLIENT; - } - - public static S3Presigner s3Presigner() { - return S3_PRESIGNER; - } - - public static PollyClient polly() { - return POLLY_CLIENT; - } - - public static BedrockRuntimeClient bedrock() { - return BEDROCK_CLIENT; - } - - public static BedrockRuntimeAsyncClient bedrockAsync() { - return BEDROCK_ASYNC_CLIENT; - } - - public static ComprehendClient comprehend() { - return COMPREHEND_CLIENT; - } - - public static SnsClient sns() { - return SNS_CLIENT; - } -} -``` - -### 5.3 사용 예시 - -```java -// Service에서 사용 -public class PollyService { - public VoiceSynthesisResult synthesizeSpeech(String id, String text, String voice) { - SynthesizeSpeechRequest request = SynthesizeSpeechRequest.builder() - .text(text) - .voiceId(VoiceId.MATTHEW) - .engine("neural") - .outputFormat(OutputFormat.MP3) - .build(); - - // Singleton 클라이언트 사용 - InputStream audioStream = AwsClients.polly().synthesizeSpeech(request); - AwsClients.s3().putObject(putRequest, RequestBody.fromInputStream(audioStream, -1)); - - return new VoiceSynthesisResult(s3Key, presignedUrl, false); - } -} -``` - ---- - -## 6. DTO 패턴 (Java 21 Records) - -### 6.1 ApiResponse (제네릭 응답 래퍼) - -```java -// 불변 데이터 클래스 - Java 21 Record -public record ApiResponse( - boolean isSuccess, - String message, - T data, - String error - ) { - // 성공 응답 팩토리 - public static ApiResponse ok(String message, T data) { - return new ApiResponse<>(true, message, data, null); - } - - public static ApiResponse ok(T data) { - return new ApiResponse<>(true, null, data, null); - } - - // 실패 응답 팩토리 - public static ApiResponse fail(String errorMessage) { - return new ApiResponse<>(false, null, null, errorMessage); - } -} -``` - -**JSON 응답 예시:** - -```json -{ - "isSuccess": true, - "message": "Grammar checked successfully", - "data": { - "correctedSentence": "I am a student", - "score": 85, - "errors": [ - ... - ] - }, - "error": null -} -``` - -### 6.2 ErrorInfo (RFC 7807 준수) - -```java -// Problem Details for HTTP APIs (RFC 7807) -public record ErrorInfo( - String code, // e.g., "VOCABULARY.WORD_001" - String message, // e.g., "단어를 찾을 수 없습니다" - int status, // e.g., 404 - Map details // Optional context - ) { - public static ErrorInfo from(ErrorCode errorCode) { ...} - - public static ErrorInfo from(ServerlessException ex) { ...} - - public boolean isClientError() { - return status >= 400 && status < 500; - } - - public boolean isServerError() { - return status >= 500 && status < 600; - } -} -``` - -**JSON 에러 응답 예시:** - -```json -{ - "code": "VOCABULARY.WORD_001", - "message": "단어를 찾을 수 없습니다", - "status": 404, - "details": { - "wordId": "abc-123", - "userId": "user456" - } -} -``` - -### 6.3 PaginatedResult (커서 페이지네이션) - -```java -public record PaginatedResult( - List items, - String nextCursor // Base64 인코딩된 DynamoDB lastEvaluatedKey -) { - public boolean hasMore() { - return nextCursor != null; - } -} -``` - ---- - -## 7. 페이지네이션 유틸리티 - -### 7.1 CursorUtil 동작 흐름 - -```mermaid -sequenceDiagram - participant Client - participant Handler - participant CursorUtil - participant DynamoDB - Note over Client, DynamoDB: 첫 페이지 요청 - Client ->> Handler: GET /items?limit=10 - Handler ->> CursorUtil: decode(null) → null - Handler ->> DynamoDB: Query (exclusiveStartKey=null) - DynamoDB -->> Handler: items + lastEvaluatedKey - Handler ->> CursorUtil: encode(lastEvaluatedKey) - CursorUtil -->> Handler: "dXNlcklkPXVzZXIxMjM..." - Handler -->> Client: {"items": [...], "nextCursor": "dXNlcklkPXVzZXIxMjM..."} - Note over Client, DynamoDB: 다음 페이지 요청 - Client ->> Handler: GET /items?cursor=dXNlcklkPXVzZXIxMjM... - Handler ->> CursorUtil: decode("dXNlcklkPXVzZXIxMjM...") - CursorUtil -->> Handler: {"userId": "user123", ...} - Handler ->> DynamoDB: Query (exclusiveStartKey={...}) - DynamoDB -->> Handler: items + lastEvaluatedKey -``` - -### 7.2 CursorUtil 구현 - -```java -public class CursorUtil { - // DynamoDB lastEvaluatedKey → Base64 문자열 - public static String encode(Map lastEvaluatedKey) { - if (lastEvaluatedKey == null || lastEvaluatedKey.isEmpty()) { - return null; - } - - StringBuilder sb = new StringBuilder(); - for (Map.Entry entry : lastEvaluatedKey.entrySet()) { - if (sb.length() > 0) sb.append("|"); - sb.append(entry.getKey()).append("=").append(entry.getValue().s()); - } - - return Base64.getUrlEncoder().encodeToString(sb.toString().getBytes()); - } - - // Base64 문자열 → DynamoDB exclusiveStartKey - public static Map decode(String cursor) { - if (cursor == null || cursor.isEmpty()) { - return null; - } - - String decoded = new String(Base64.getUrlDecoder().decode(cursor)); - Map startKey = new HashMap<>(); - - for (String pair : decoded.split("\\|")) { - String[] kv = pair.split("=", 2); - if (kv.length == 2) { - startKey.put(kv[0], AttributeValue.builder().s(kv[1]).build()); - } - } - - return startKey; - } -} -``` - ---- - -## 8. 인증 유틸리티 - -### 8.1 Cognito 인증 흐름 - -```mermaid -flowchart TB - subgraph CognitoAuth["Cognito 인증 흐름"] - REQ[요청] --> AUTH[API Gateway Authorizer] - AUTH --> CLAIMS[JWT Claims 추출] - CLAIMS --> INJECT["requestContext.authorizer.claims"] - end - - subgraph CognitoUtil["CognitoUtil 추출"] - INJECT --> EXTRACT[extractUserId] - EXTRACT --> SUB["claims.sub → userId"] - end -``` - -### 8.2 CognitoUtil.java - -```java -public class CognitoUtil { - // 기본 userId 추출 (sub claim) - public static String extractUserId(APIGatewayProxyRequestEvent request) { - Map authorizer = request.getRequestContext().getAuthorizer(); - if (authorizer == null) return null; - - Map claims = (Map) authorizer.get("claims"); - return claims != null ? claims.get("sub") : null; - } - - // 선택적 claim 추출 - public static Optional extractEmail(APIGatewayProxyRequestEvent request) { - return extractClaim(request, "email"); - } - - public static Optional extractNickname(APIGatewayProxyRequestEvent request) { - return extractClaim(request, "custom:nickname"); - } - - public static Optional extractClaim( - APIGatewayProxyRequestEvent request, String claimName) { - // ... claim 추출 로직 - } - - // 사용자 접근 권한 검증 - public static boolean validateUserAccess( - APIGatewayProxyRequestEvent request, String pathUserId) { - String tokenUserId = extractUserId(request); - return tokenUserId != null && tokenUserId.equals(pathUserId); - } -} -``` - -### 8.3 JwtUtil.java (WebSocket용) - -```java -// WebSocket 연결 시 직접 JWT 파싱 (Authorizer 미사용) -public final class JwtUtil { - public static Optional extractUserId(String token) { - // Bearer 제거 - if (token.startsWith("Bearer ")) { - token = token.substring(7); - } - - // JWT payload 추출 (헤더.페이로드.시그니처) - String[] parts = token.split("\\."); - if (parts.length != 3) return Optional.empty(); - - // Base64 URL 디코딩 - String payload = new String(Base64.getUrlDecoder().decode(parts[1])); - Map claims = gson.fromJson(payload, Map.class); - - return Optional.ofNullable((String) claims.get("sub")); - } - - public static boolean isExpired(String token) { - // exp claim 확인 - } -} -``` - ---- - -## 9. HTTP 응답 생성 - -### 9.1 ResponseGenerator.java - -```java -public class ResponseGenerator { - private static final Gson GSON = new GsonBuilder() - .setDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'") - .create(); - - private static final Map CORS_HEADERS = Map.of( - "Content-Type", "application/json", - "Access-Control-Allow-Origin", "*", - "Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS", - "Access-Control-Allow-Headers", "Content-Type,Authorization" - ); - - // 성공 응답 - public static APIGatewayProxyResponseEvent ok(String message, T data) { - return buildResponse(200, ApiResponse.ok(message, data)); - } - - public static APIGatewayProxyResponseEvent created(String message, T data) { - return buildResponse(201, ApiResponse.ok(message, data)); - } - - public static APIGatewayProxyResponseEvent noContent() { - return buildResponse(204, null); - } - - // 에러 응답 - public static APIGatewayProxyResponseEvent fail(ErrorCode errorCode) { - return buildResponse(errorCode.getStatusCode(), ErrorInfo.from(errorCode)); - } - - public static APIGatewayProxyResponseEvent badRequest(String message) { - return fail(CommonErrorCode.INVALID_INPUT, message); - } - - public static APIGatewayProxyResponseEvent notFound(String message) { - return fail(CommonErrorCode.RESOURCE_NOT_FOUND, message); - } - - // ... 기타 편의 메서드 - - private static APIGatewayProxyResponseEvent buildResponse(int statusCode, Object body) { - return new APIGatewayProxyResponseEvent() - .withStatusCode(statusCode) - .withHeaders(new HashMap<>(CORS_HEADERS)) - .withBody(body != null ? GSON.toJson(body) : null); - } - - public static Gson gson() { - return GSON; - } -} -``` - ---- - -## 10. Bean Validation - -### 10.1 BeanValidator 패턴 - -```mermaid -flowchart TB - REQ[요청 수신] --> PARSE[JSON 파싱 → DTO] -PARSE --> VALIDATE[BeanValidator.validateAndExecute] -VALIDATE --> CHECK{검증 통과?} -CHECK -->|Yes|HANDLER[핸들러 로직 실행] -CHECK -->|No|ERR400[400 Bad Request] -HANDLER --> RESPONSE[정상 응답] -``` - -### 10.2 BeanValidator.java - -```java -public final class BeanValidator { - private static final Validator VALIDATOR; - - static { - ValidatorFactory factory = Validation.buildDefaultValidatorFactory(); - VALIDATOR = factory.getValidator(); - } - - // 검증 + 실행 통합 패턴 - public static APIGatewayProxyResponseEvent validateAndExecute( - T object, - Function handler) { - - Optional error = validate(object); - if (error.isPresent()) { - return ResponseGenerator.badRequest(error.get()); - } - - return handler.apply(object); - } - - public static Optional validate(T object) { - Set> violations = VALIDATOR.validate(object); - if (violations.isEmpty()) { - return Optional.empty(); - } - - String message = violations.stream() - .map(ConstraintViolation::getMessage) - .collect(Collectors.joining(", ")); - - return Optional.of(message); - } -} -``` - -### 10.3 DTO 검증 예시 - -```java -// 요청 DTO -public class CreateRoomRequest { - @NotEmpty(message = "방 이름은 필수입니다") - private String roomName; - - @NotNull(message = "난이도는 필수입니다") - private String difficulty; - - @Min(value = 2, message = "최소 2명 이상이어야 합니다") - @Max(value = 10, message = "최대 10명까지 가능합니다") - private int maxMembers; -} - -// Handler에서 사용 -private APIGatewayProxyResponseEvent createRoom( - APIGatewayProxyRequestEvent request, String userId) { - - CreateRoomRequest req = ResponseGenerator.gson() - .fromJson(request.getBody(), CreateRoomRequest.class); - - return BeanValidator.validateAndExecute(req, dto -> { - // 검증 통과 시에만 실행됨 - ChatRoom room = roomService.createRoom(userId, dto); - return ResponseGenerator.created("방이 생성되었습니다", room); - }); -} -``` - ---- - -## 11. WebSocket 유틸리티 - -### 11.1 브로드캐스트 흐름 - -```mermaid -sequenceDiagram - participant Service - participant Broadcaster as WebSocketBroadcaster - participant APIGW as API Gateway - participant Clients as WebSocket Clients - Service ->> Broadcaster: broadcast(connections, message) - - loop 각 연결에 전송 - Broadcaster ->> APIGW: postToConnection(connectionId, data) - alt 성공 - APIGW -->> Clients: 메시지 전달 - else 연결 끊김 (410 Gone) - APIGW -->> Broadcaster: GoneException - Broadcaster ->> Broadcaster: failedIds에 추가 - end - end - - Broadcaster -->> Service: failedConnectionIds 반환 - Service ->> Service: Stale 연결 정리 -``` - -### 11.2 WebSocketBroadcaster.java - -```java -public class WebSocketBroadcaster { - private final ApiGatewayManagementApiClient apiClient; - - public WebSocketBroadcaster() { - String endpoint = WebSocketConfig.websocketEndpoint(); - this.apiClient = ApiGatewayManagementApiClient.builder() - .endpointOverride(URI.create(endpoint)) - .build(); - } - - // 단일 연결에 전송 - public boolean sendToConnection(String connectionId, String message) { - try { - apiClient.postToConnection(PostToConnectionRequest.builder() - .connectionId(connectionId) - .data(SdkBytes.fromUtf8String(message)) - .build()); - return true; - } catch (GoneException e) { - // 연결이 이미 끊김 - return false; - } - } - - // 다수 연결에 브로드캐스트 - public List broadcast(List connections, String message) { - List failedIds = new ArrayList<>(); - - for (Connection conn : connections) { - if (!sendToConnection(conn.getConnectionId(), message)) { - failedIds.add(conn.getConnectionId()); - } - } - - return failedIds; // 실패한 연결 ID 반환 (정리용) - } -} -``` - -### 11.3 WebSocket 응답 유틸리티 - -```java -public final class WebSocketResponseUtil { - public static Map ok(String message) { - return response(200, message); - } - - public static Map unauthorized(String message) { - return response(401, message); - } - - public static Map badRequest(String message) { - return response(400, message); - } - - private static Map response(int statusCode, String body) { - return Map.of( - "statusCode", statusCode, - "body", body - ); - } -} -``` - ---- - -## 12. S3 Presigned URL - -### 12.1 S3PresignUtil.java - -```java -public class S3PresignUtil { - private static final Duration DEFAULT_DURATION = Duration.ofHours(24); - private static final String BUCKET_NAME = System.getenv("S3_BUCKET_NAME"); - - // 내부 캐시 (Java 21 Record) - private record CachedUrl(String url, long expiresAt) { - boolean isExpired() { - // 1시간 버퍼 두고 만료 체크 - return System.currentTimeMillis() > (expiresAt - 3600_000); - } - } - - private static final Map URL_CACHE = new ConcurrentHashMap<>(); - - public static String getPresignedUrl(String key) { - return getPresignedUrl(key, DEFAULT_DURATION); - } - - public static String getPresignedUrl(String key, Duration duration) { - CachedUrl cached = URL_CACHE.get(key); - if (cached != null && !cached.isExpired()) { - return cached.url(); - } - - GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() - .signatureDuration(duration) - .getObjectRequest(r -> r.bucket(BUCKET_NAME).key(key)) - .build(); - - String url = AwsClients.s3Presigner() - .presignGetObject(presignRequest) - .url() - .toString(); - - URL_CACHE.put(key, new CachedUrl(url, - System.currentTimeMillis() + duration.toMillis())); - - return url; - } - - // 배지 이미지 URL 생성 편의 메서드 - public static String getBadgeImageUrl(String imageFile) { - return getPresignedUrl("badges/" + imageFile); - } -} -``` - ---- - -## 13. AWS 서비스 래퍼 - -### 13.1 PollyService (TTS + S3 캐시) - -```mermaid -flowchart TB - REQ[음성 합성 요청] --> CHECK{S3 캐시 확인} - CHECK -->|캐시 있음| PRESIGN[Presigned URL 생성] - CHECK -->|캐시 없음| SYNTH[Polly 음성 합성] - SYNTH --> SAVE[S3 저장] - SAVE --> PRESIGN - PRESIGN --> RETURN[URL 반환] -``` - -```java -public class PollyService { - public VoiceSynthesisResult synthesizeSpeech(String id, String text, String voice) { - String s3Key = generateS3Key(id, voice); - - // 캐시 확인 - if (existsInS3(s3Key)) { - return new VoiceSynthesisResult(s3Key, getPresignedUrl(s3Key), true); - } - - // Polly 음성 합성 - VoiceId voiceId = "MALE".equalsIgnoreCase(voice) ? VoiceId.MATTHEW : VoiceId.JOANNA; - - SynthesizeSpeechRequest request = SynthesizeSpeechRequest.builder() - .text(text) - .voiceId(voiceId) - .engine("neural") // Neural 음성 (고품질) - .outputFormat(OutputFormat.MP3) - .build(); - - InputStream audioStream = AwsClients.polly().synthesizeSpeech(request); - - // S3 저장 - AwsClients.s3().putObject( - PutObjectRequest.builder() - .bucket(bucketName) - .key(s3Key) - .contentType("audio/mpeg") - .build(), - RequestBody.fromInputStream(audioStream, -1) - ); - - return new VoiceSynthesisResult(s3Key, getPresignedUrl(s3Key), false); - } - - public String generateS3Key(String id, String voice) { - String suffix = "MALE".equalsIgnoreCase(voice) ? "male" : "female"; - return s3KeyPrefix + id + "_" + suffix + ".mp3"; - } -} -``` - -### 13.2 ComprehendService (NLP 분석) - -```java -public class ComprehendService { - public ComprehendAnalysis analyze(String text) { - // 감정 분석 - DetectSentimentResponse sentiment = AwsClients.comprehend() - .detectSentiment(DetectSentimentRequest.builder() - .text(text) - .languageCode("en") - .build()); - - // 구문 분석 (품사 태깅) - DetectSyntaxResponse syntax = AwsClients.comprehend() - .detectSyntax(DetectSyntaxRequest.builder() - .text(text) - .languageCode("en") - .build()); - - // 핵심 구문 추출 - DetectKeyPhrasesResponse keyPhrases = AwsClients.comprehend() - .detectKeyPhrases(DetectKeyPhrasesRequest.builder() - .text(text) - .languageCode("en") - .build()); - - // 문장 복잡도 계산 - String complexity = calculateComplexity(syntax.syntaxTokens()); - - return ComprehendAnalysis.builder() - .sentiment(sentiment.sentimentAsString()) - .syntax(mapTokens(syntax.syntaxTokens())) - .keyPhrases(mapKeyPhrases(keyPhrases.keyPhrases())) - .complexity(complexity) - .build(); - } - - private String calculateComplexity(List tokens) { - Set uniquePOS = tokens.stream() - .map(t -> t.partOfSpeech().tagAsString()) - .collect(Collectors.toSet()); - - if (uniquePOS.size() <= 3 && tokens.size() <= 5) return "BEGINNER"; - if (uniquePOS.size() <= 5 && tokens.size() <= 10) return "INTERMEDIATE"; - return "ADVANCED"; - } -} -``` - ---- - -## 14. 설정 클래스 - -### 14.1 StudyConfig (학습 알고리즘 상수) - -```java -public final class StudyConfig { - // SM-2 알고리즘 상수 - public static final int INITIAL_INTERVAL_DAYS = 1; - public static final double DEFAULT_EASE_FACTOR = 2.5; - public static final double MIN_EASE_FACTOR = 1.3; - public static final int INITIAL_REPETITIONS = 0; - - // 테스트 설정 - public static final int DEFAULT_WORD_COUNT = 20; - public static final int DAILY_TEST_WORD_COUNT = 10; - - // 복습 주기 (일) - public static final int[] REVIEW_INTERVALS = {1, 3, 7, 14, 30}; - - // 상태 기본값 - public static final String DEFAULT_WORD_STATUS = "NEW"; - public static final String DEFAULT_DIFFICULTY = "NORMAL"; - - // 오류 제한 - public static final int MAX_WRONG_COUNT = 3; -} -``` - -### 14.2 DynamoDbKey (키 패턴 상수) - -```java -public final class DynamoDbKey { - // 기본 키 - public static final String PK = "PK"; - public static final String SK = "SK"; - - // GSI 키 - public static final String GSI1_PK = "GSI1PK"; - public static final String GSI1_SK = "GSI1SK"; - public static final String GSI2_PK = "GSI2PK"; - public static final String GSI2_SK = "GSI2SK"; - - // GSI 이름 - public static final String GSI1 = "GSI1"; - public static final String GSI2 = "GSI2"; - - // 공통 접두사 - public static final String USER = "USER#"; - public static final String METADATA = "METADATA"; - - // 헬퍼 메서드 - public static String userPk(String userId) { - return USER + userId; // "USER#user-123" - } -} -``` - ---- - -## 15. Java 21 기능 활용 - -### 15.1 Records 활용 - -| 클래스 | 용도 | -|-----------------|----------------| -| ApiResponse | 제네릭 API 응답 래퍼 | -| ErrorInfo | RFC 7807 에러 응답 | -| PaginatedResult | 페이지네이션 결과 | -| Route | HTTP 라우트 정의 | -| RouteEntry | 라우터 내부 매칭 | -| CachedUrl | S3 URL 캐시 | - -### 15.2 Sealed Interface 활용 - -```mermaid -flowchart TB - subgraph SealedPattern["Sealed Interface 패턴"] - EC[/"sealed interface ErrorCode
permits CommonErrorCode, DomainErrorCode"/] - CEC["final enum CommonErrorCode
implements ErrorCode"] - DEC[/"non-sealed interface DomainErrorCode
extends ErrorCode"/] - EC --> CEC - EC --> DEC - end -``` - -### 15.3 Pattern Matching 활용 - -```java -// instanceof 패턴 매칭 -String code = errorCode instanceof DomainErrorCode domainCode - ? domainCode.getFullCode() // "VOCABULARY.WORD_001" - : errorCode.getCode(); // "AUTH_001" - -// switch 표현식 (Enhanced) -return switch(type. - -getCategory()){ - case"FIRST_STUDY"->stats. - -getTestsCompleted() >=1; - case"STREAK"->stats. - -getCurrentStreak() >=type. - -getThreshold(); - case"ACCURACY"->{ -double accuracy = (double) stats.getCorrectAnswers() / stats.getQuestionsAnswered() * 100; -yield accuracy >=type. - -getThreshold(); - } -default ->false; - }; -``` - ---- - -## 16. 디자인 패턴 요약 - -| 패턴 | 적용 위치 | 목적 | -|----------------------|------------------------|-------------------| -| **Singleton** | AwsClients | AWS SDK 클라이언트 재사용 | -| **Factory Method** | Route, CommonException | 객체 생성 캡슐화 | -| **Strategy** | AuthenticatedHandler | 요청 처리 전략 분리 | -| **Router** | HandlerRouter | HTTP 요청 라우팅 | -| **Builder** | ComprehendAnalysis | 복잡한 객체 생성 | -| **Template Method** | BeanValidator | 검증-실행 흐름 템플릿 | -| **Sealed Interface** | ErrorCode 계층 | 구현 제한 | -| **Data Class** | Records | 불변 데이터 전송 | - ---- - -## 17. 파일 구조 - -``` -common/ -├── config/ -│ ├── AwsClients.java # AWS SDK 클라이언트 싱글톤 -│ ├── WebSocketConfig.java # WebSocket 설정 -│ ├── RoomTokenConfig.java # 방 토큰 TTL 설정 -│ └── StudyConfig.java # 학습 알고리즘 상수 -├── constants/ -│ └── DynamoDbKey.java # DynamoDB 키 패턴 -├── dto/ -│ ├── ApiResponse.java # 제네릭 응답 래퍼 (Record) -│ ├── ErrorInfo.java # RFC 7807 에러 (Record) -│ └── PaginatedResult.java # 페이지네이션 (Record) -├── enums/ -│ ├── Difficulty.java # EASY, NORMAL, HARD -│ └── StudyLevel.java # BEGINNER, INTERMEDIATE, ADVANCED -├── exception/ -│ ├── ServerlessException.java # 기본 예외 클래스 -│ ├── ErrorCode.java # Sealed Interface -│ ├── CommonErrorCode.java # 공통 에러 코드 -│ ├── DomainErrorCode.java # 도메인 에러 인터페이스 -│ └── CommonException.java # 예외 팩토리 -├── router/ -│ ├── HandlerRouter.java # HTTP 라우터 -│ ├── Route.java # 라우트 정의 (Record) -│ └── AuthenticatedHandler.java # 인증 핸들러 인터페이스 -├── service/ -│ ├── PollyService.java # TTS + S3 캐시 -│ └── ComprehendService.java # NLP 분석 -├── util/ -│ ├── ResponseGenerator.java # HTTP 응답 빌더 -│ ├── CursorUtil.java # 커서 페이지네이션 -│ ├── CognitoUtil.java # Cognito 인증 추출 -│ ├── JwtUtil.java # JWT 직접 파싱 -│ ├── WebSocketBroadcaster.java # WebSocket 브로드캐스트 -│ ├── WebSocketEventUtil.java # WebSocket 이벤트 추출 -│ ├── WebSocketResponseUtil.java # WebSocket 응답 빌더 -│ └── S3PresignUtil.java # Presigned URL 생성 -└── validation/ - └── BeanValidator.java # Bean Validation 유틸 -``` - ---- - -## 18. 기술 스택 - -- **Runtime:** AWS Lambda (Java 21) -- **Build:** Gradle -- **AWS SDK:** AWS SDK for Java v2 -- **Validation:** Jakarta Bean Validation -- **JSON:** Gson -- **Pattern:** Singleton, Factory, Strategy, Router, Builder, Sealed Interface -- **Java 21 Features:** Records, Sealed Interface, Pattern Matching, Enhanced Switch diff --git a/docs/domain-reports/GRAMMAR-DOMAIN-REPORT.md b/docs/domain-reports/GRAMMAR-DOMAIN-REPORT.md deleted file mode 100644 index 5015a011..00000000 --- a/docs/domain-reports/GRAMMAR-DOMAIN-REPORT.md +++ /dev/null @@ -1,465 +0,0 @@ -# Grammar Domain 세부 보고서 - -## 1. 개요 - -Grammar 도메인은 AWS Bedrock(Claude 3 Haiku)을 활용한 AI 기반 영어 문법 체크 시스템입니다. REST API와 WebSocket 스트리밍을 통해 실시간 문법 교정 및 대화형 학습을 -제공합니다. - ---- - -## 2. 전체 아키텍처 - -```mermaid -flowchart TB - subgraph Client["클라이언트"] - APP[Mobile/Web App] - end - - subgraph Gateway["API Gateway"] - REST[REST API] - WS[Grammar WebSocket] - end - - subgraph Lambda["Lambda Handlers"] - HANDLER[GrammarHandler] - CONNECT[StreamingConnectHandler] - DISCONNECT[StreamingDisconnectHandler] - STREAM[StreamingHandler] - end - - subgraph AI["AWS AI 서비스"] - BEDROCK[Bedrock
Claude 3 Haiku] - COMPREHEND[Comprehend
언어 분석] - end - - subgraph Storage["저장소"] - DDB[(DynamoDB)] - end - - APP --> REST - APP <--> WS - REST --> HANDLER - WS --> CONNECT - WS --> DISCONNECT - WS --> STREAM - HANDLER --> BEDROCK - HANDLER --> COMPREHEND - STREAM --> BEDROCK - HANDLER --> DDB - STREAM --> DDB -``` - ---- - -## 3. 문법 체크 흐름 - -### 3.1 동기식 문법 체크 - -```mermaid -sequenceDiagram - participant Client - participant Handler as GrammarHandler - participant Service as GrammarCheckService - participant Bedrock as AWS Bedrock - participant DB as DynamoDB - Client ->> Handler: POST /grammar/check - Handler ->> Service: checkGrammar(sentence, level) - Service ->> Bedrock: Claude API 호출 - Bedrock -->> Service: JSON 응답 - Service -->> Handler: GrammarCheckResponse - Handler -->> Client: 문법 교정 결과 -``` - -### 3.2 스트리밍 대화 - -```mermaid -sequenceDiagram - participant Client - participant WS as WebSocket - participant Handler as StreamingHandler - participant Service as ConversationService - participant Bedrock as AWS Bedrock - Client ->> WS: $connect?token={jwt} - WS -->> Client: 연결 성공 - Client ->> WS: 메시지 전송 - WS ->> Handler: $default 라우트 - Handler ->> Service: chatStreaming() - Service -->> Client: StartEvent (sessionId) - - loop 토큰 단위 스트리밍 - Bedrock -->> Service: 텍스트 청크 - Service -->> Client: TokenEvent - end - - Service -->> Client: CompleteEvent (전체 응답) -``` - ---- - -## 4. API 엔드포인트 - -### 4.1 REST API - -| Method | Endpoint | 설명 | -|--------|-------------------------------|---------------| -| POST | /grammar/check | 문법 체크 (단일 문장) | -| POST | /grammar/conversation | 대화형 문법 학습 | -| GET | /grammar/sessions | 대화 세션 목록 | -| GET | /grammar/sessions/{sessionId} | 세션 상세 | -| DELETE | /grammar/sessions/{sessionId} | 세션 삭제 | - -### 4.2 WebSocket API - -| Route | 설명 | -|-------------|-------------| -| $connect | JWT 토큰으로 연결 | -| $disconnect | 연결 해제 | -| $default | 스트리밍 메시지 처리 | - ---- - -## 5. 레벨별 문법 체크 - -### 5.1 학습 레벨 - -| 레벨 | 설명 | 피드백 스타일 | -|--------------|----|--------------------| -| BEGINNER | 초급 | 한국어 번역 + 쉬운 설명 | -| INTERMEDIATE | 중급 | 영어 위주 설명 | -| ADVANCED | 고급 | 상세한 문법 규칙 + 스타일 제안 | - -### 5.2 오류 유형 - -```mermaid -mindmap - root((문법 오류)) - 시제 - VERB_TENSE - 동사 시제 오류 - 일치 - SUBJECT_VERB_AGREEMENT - 주어-동사 일치 - 품사 - ARTICLE - 관사 오류 - PREPOSITION - 전치사 오류 - PRONOUN - 대명사 오류 - 구조 - WORD_ORDER - 어순 오류 - SENTENCE_STRUCTURE - 문장 구조 - 기타 - SPELLING - 철자 - PUNCTUATION - 구두점 - WORD_CHOICE - 어휘 선택 -``` - ---- - -## 6. 응답 포맷 - -### 6.1 문법 체크 응답 - -```json -{ - "originalSentence": "I goed to school yesterday", - "correctedSentence": "I went to school yesterday", - "score": 70, - "isCorrect": false, - "errors": [ - { - "type": "VERB_TENSE", - "original": "goed", - "corrected": "went", - "explanation": "'go'의 과거형은 'went'입니다 (불규칙 동사)", - "startIndex": 2, - "endIndex": 6 - } - ], - "feedback": "과거 시제를 잘 사용하려고 노력했네요! 불규칙 동사를 조금 더 연습해보세요." -} -``` - -### 6.2 대화 응답 - -```json -{ - "sessionId": "uuid", - "grammarCheck": { - /* 위와 동일 */ - }, - "aiResponse": "Great job! Your sentence structure is correct. Let's practice more complex sentences.", - "conversationTip": "Try using 'had gone' for past perfect tense." -} -``` - -### 6.3 스트리밍 이벤트 - -```json -// StartEvent -{ - "type": "start", - "sessionId": "uuid" -} - -// TokenEvent (실시간) -{ - "type": "token", - "token": "Great " -} -{ - "type": "token", - "token": "job!" -} - -// CompleteEvent (완료) -{ - "type": "complete", - "sessionId": "uuid", - "grammarCheck": { - ... - }, - "aiResponse": "...", - "conversationTip": "..." -} - -// ErrorEvent (오류 시) -{ - "type": "error", - "message": "..." -} -``` - ---- - -## 7. AWS Bedrock 통합 - -### 7.1 Claude 3 Haiku 설정 - -```java -public class BedrockGrammarCheckFactory { - private static final String MODEL_ID = "anthropic.claude-3-haiku-20240307-v1:0"; - private static final int MAX_TOKENS = 2048; - private static final String API_VERSION = "bedrock-2023-05-31"; -} -``` - -### 7.2 프롬프트 구조 - -**시스템 프롬프트 (초급):** - -``` -You are a friendly English grammar tutor for Korean speakers. -- Use simple English with Korean translations -- Be encouraging and supportive -- Explain grammar rules clearly -``` - -**사용자 프롬프트:** - -``` -Please check the grammar of this sentence: "{sentence}" - -Return JSON: -{ - "correctedSentence": "...", - "score": 0-100, - "isCorrect": boolean, - "errors": [...], - "feedback": "..." -} -``` - -### 7.3 스트리밍 응답 파싱 - -``` -[RESPONSE] -AI의 자연스러운 대화 응답 -[/RESPONSE] - -[GRAMMAR] -{ JSON 형식의 문법 체크 결과 } -[/GRAMMAR] - -[TIP] -학습 팁 -[/TIP] -``` - ---- - -## 8. 데이터 모델 - -### 8.1 GrammarSession - -```java - -@DynamoDbBean -public class GrammarSession { - String sessionId; - String userId; - String level; // BEGINNER, INTERMEDIATE, ADVANCED - String topic; // "Conversation Practice" - Integer messageCount; - String lastMessage; // 마지막 메시지 (100자 제한) - String createdAt; - String updatedAt; - Long ttl; // 30일 -} -``` - -**DynamoDB Keys:** - -- PK: `GSESSION#{userId}` | SK: `SESSION#{sessionId}` -- GSI1: `GSESSION#ALL` | `UPDATED#{timestamp}` (최신순 정렬) - -### 8.2 GrammarMessage - -```java - -@DynamoDbBean -public class GrammarMessage { - String messageId; - String sessionId; - String userId; - String role; // USER, ASSISTANT - String content; // 원본 메시지 - String correctedContent; // 교정된 메시지 (USER만) - String errorsJson; // 오류 목록 JSON - Integer grammarScore; - String feedback; - Boolean isCorrect; - Long ttl; // 30일 -} -``` - -**DynamoDB Keys:** - -- PK: `GSESSION#{userId}` | SK: `MSG#{timestamp}#{messageId}` -- GSI1: `GSESSION#{sessionId}` | `MSG#{timestamp}` - -### 8.3 GrammarConnection (WebSocket) - -```java - -@DynamoDbBean -public class GrammarConnection { - String connectionId; // API Gateway 연결 ID - String userId; // JWT에서 추출 - String connectedAt; - Long ttl; // 연결 타임아웃 -} -``` - ---- - -## 9. AWS Comprehend 분석 (선택적) - -```mermaid -flowchart LR - INPUT[입력 문장] --> SENTIMENT[감정 분석] - INPUT --> SYNTAX[구문 분석] - INPUT --> KEYPHRASE[핵심 구문] - INPUT --> LANGUAGE[언어 감지] - SENTIMENT --> OUTPUT[분석 결과] - SYNTAX --> OUTPUT - KEYPHRASE --> OUTPUT - LANGUAGE --> OUTPUT -``` - -**분석 항목:** - -- 감정: POSITIVE, NEGATIVE, NEUTRAL, MIXED -- 품사 태깅: NOUN, VERB, ADJ 등 -- 핵심 구문 추출 -- 문장 복잡도 추정 - ---- - -## 10. 서비스 레이어 - -### 10.1 서비스 구성 - -| Service | 역할 | -|----------------------------|----------------| -| GrammarCheckService | 단일 문장 문법 체크 | -| GrammarConversationService | 대화형 학습 + 스트리밍 | -| GrammarSessionQueryService | 세션 조회, 삭제 | -| BedrockGrammarCheckFactory | Bedrock API 호출 | - -### 10.2 대화 히스토리 관리 - -```java -// 최근 10개 메시지만 컨텍스트로 유지 -private static final int MAX_HISTORY_MESSAGES = 10; - -// 대화 히스토리 빌드 -String buildConversationHistory(String sessionId) { - // 최근 메시지 조회 - // USER: 내용 / ASSISTANT: 내용 형식으로 포맷 -} -``` - ---- - -## 11. 파일 구조 - -``` -domain/grammar/ -├── handler/ -│ ├── GrammarHandler.java -│ └── websocket/ -│ ├── GrammarStreamingConnectHandler.java -│ ├── GrammarStreamingDisconnectHandler.java -│ └── GrammarStreamingHandler.java -├── service/ -│ ├── GrammarCheckService.java -│ ├── GrammarConversationService.java -│ └── GrammarSessionQueryService.java -├── factory/ -│ ├── GrammarCheckFactory.java (interface) -│ └── BedrockGrammarCheckFactory.java -├── repository/ -│ ├── GrammarSessionRepository.java -│ └── GrammarConnectionRepository.java -├── model/ -│ ├── GrammarSession.java -│ ├── GrammarMessage.java -│ └── GrammarConnection.java -├── dto/ -│ ├── request/ -│ │ ├── GrammarCheckRequest.java -│ │ └── ConversationRequest.java -│ └── response/ -│ ├── GrammarCheckResponse.java -│ ├── ConversationResponse.java -│ ├── GrammarError.java -│ └── ComprehendAnalysis.java -├── streaming/ -│ ├── StreamingCallback.java -│ ├── StreamingEvent.java (sealed interface) -│ └── StreamingRequest.java -├── enums/ -│ ├── GrammarLevel.java -│ └── GrammarErrorType.java -└── constants/ - └── GrammarKey.java -``` - ---- - -## 12. 기술 스택 - -- **Runtime:** AWS Lambda (Java 21) -- **API:** API Gateway REST + WebSocket -- **AI:** AWS Bedrock (Claude 3 Haiku) -- **NLP:** AWS Comprehend (선택적) -- **Database:** DynamoDB -- **Auth:** JWT (Cognito) -- **Pattern:** Factory, Callback, Sealed Interface (Java 17+) diff --git a/docs/domain-reports/STATS-DOMAIN-REPORT.md b/docs/domain-reports/STATS-DOMAIN-REPORT.md deleted file mode 100644 index 3ca3d3ff..00000000 --- a/docs/domain-reports/STATS-DOMAIN-REPORT.md +++ /dev/null @@ -1,379 +0,0 @@ -# Stats Domain 세부 보고서 - -## 1. 개요 - -Stats 도메인은 사용자의 학습 활동을 추적하고 통계를 집계하는 시스템입니다. DynamoDB Streams와 EventBridge를 활용한 이벤트 기반 아키텍처로 실시간 통계 업데이트를 제공합니다. - ---- - -## 2. 전체 아키텍처 - -```mermaid -flowchart TB - subgraph Triggers["트리거"] - TEST[테스트 완료] - DAILY[일일 학습] - GAME[게임 종료] - SCHEDULE[스케줄러
매일 자정] - end - - subgraph Processing["처리"] - STREAM[StatsStreamHandler
DynamoDB Streams] - SERVICE[StatsService
Write-through] - SCHEDULED[ScheduledStatsHandler
EventBridge] - end - - subgraph Storage["저장소"] - DDB[(DynamoDB
UserStats)] - end - - subgraph Query["조회"] - API[UserStatsHandler
REST API] - end - - TEST --> STREAM - DAILY --> SERVICE - GAME --> SERVICE - SCHEDULE --> SCHEDULED - STREAM --> DDB - SERVICE --> DDB - SCHEDULED --> DDB - DDB --> API -``` - ---- - -## 3. 통계 집계 방식 - -### 3.1 집계 레벨 - -```mermaid -flowchart LR - subgraph Levels["통계 집계 레벨"] - DAILY["일별
DAILY#2026-01-16"] - WEEKLY["주별
WEEKLY#2026-W03"] - MONTHLY["월별
MONTHLY#2026-01"] - TOTAL["전체
TOTAL"] - end - - EVENT[이벤트 발생] --> DAILY - EVENT --> WEEKLY - EVENT --> MONTHLY - EVENT --> TOTAL -``` - -### 3.2 Atomic Counter 패턴 - -```java -// 모든 레벨에 동시 업데이트 (원자적) -UpdateExpression: -SET correctAnswers = if_not_exists(correctAnswers, 0) + :correct, -incorrectAnswers = - -if_not_exists(incorrectAnswers, 0) +:incorrect, -testsCompleted = - -if_not_exists(testsCompleted, 0) +1, -updatedAt =:now -``` - ---- - -## 4. 이벤트 기반 통계 업데이트 - -### 4.1 DynamoDB Streams 처리 - -```mermaid -sequenceDiagram - participant Test as TestResult 저장 - participant Stream as DynamoDB Streams - participant Handler as StatsStreamHandler - participant DB as UserStats - Test ->> Stream: INSERT 이벤트 - Stream ->> Handler: 트리거 - Handler ->> Handler: PK/SK 패턴 확인
(TEST#userId, RESULT#timestamp) - Handler ->> DB: incrementTestStats() - Handler ->> DB: updateStudyStreak() - Handler ->> Handler: checkAndAwardBadges() -``` - -### 4.2 Write-through 패턴 - -```mermaid -sequenceDiagram - participant API as DailyStudyHandler - participant Service as StatsService - participant DB as UserStats - Note over API, DB: 단어 학습 완료 시 - API ->> Service: recordWordsLearned() - Service ->> DB: incrementWordsLearned()
(DAILY, WEEKLY, MONTHLY, TOTAL) - Service ->> DB: updateStudyStreak() -``` - ---- - -## 5. API 엔드포인트 - -### 5.1 통계 조회 API - -| Method | Endpoint | 설명 | 파라미터 | -|--------|----------------|---------|------------------| -| GET | /stats/daily | 일별 통계 | ?date=YYYY-MM-DD | -| GET | /stats/weekly | 주별 통계 | ?week=YYYY-Www | -| GET | /stats/monthly | 월별 통계 | ?month=YYYY-MM | -| GET | /stats/total | 전체 통계 | - | -| GET | /stats/history | 일별 히스토리 | ?cursor, ?limit | - -### 5.2 응답 예시 - -```json -{ - "periodType": "DAILY", - "period": "2026-01-16", - "testsCompleted": 3, - "questionsAnswered": 45, - "correctAnswers": 38, - "incorrectAnswers": 7, - "successRate": 84.44, - "newWordsLearned": 50, - "wordsReviewed": 5 -} -``` - -**전체 통계 추가 필드:** - -```json -{ - "currentStreak": 7, - "longestStreak": 14, - "lastStudyDate": "2026-01-16", - "gamesPlayed": 10, - "gamesWon": 3, - "totalGameScore": 450 -} -``` - ---- - -## 6. 연속 학습 (Streak) 시스템 - -### 6.1 스트릭 계산 로직 - -```mermaid -flowchart TB - START[학습 활동 발생] --> CHECK{lastStudyDate
확인} - CHECK -->|null| NEW["currentStreak = 1
longestStreak = 1"] - CHECK -->|오늘| SAME[변경 없음
이미 오늘 학습] - CHECK -->|어제| INCREMENT["currentStreak++
longestStreak = max()"] - CHECK -->|2일+ 전| RESET["currentStreak = 1
longestStreak 유지"] - NEW --> UPDATE[DB 업데이트] - INCREMENT --> UPDATE - RESET --> UPDATE -``` - -### 6.2 스트릭 리셋 (스케줄러) - -```java -// EventBridge: 매일 자정 실행 -@Scheduled -public void resetStreaks() { - String yesterday = LocalDate.now().minusDays(1).toString(); - // lastStudyDate != yesterday인 사용자의 스트릭 리셋 - // 비용 최적화로 클라이언트 측 계산 권장 -} -``` - ---- - -## 7. 데이터 모델 - -### 7.1 UserStats - -```java - -@DynamoDbBean -public class UserStats { - // 키 - String pk; // USER#{userId}#STATS - String sk; // DAILY#{date} | WEEKLY#{week} | MONTHLY#{month} | TOTAL - - // 메타데이터 - String userId; - String periodType; // DAILY, WEEKLY, MONTHLY, TOTAL - String period; // 2026-01-16, 2026-W03, 2026-01, TOTAL - - // 테스트 통계 - Integer testsCompleted; - Integer questionsAnswered; - Integer correctAnswers; - Integer incorrectAnswers; - Double successRate; - - // 학습 통계 - Integer newWordsLearned; - Integer wordsReviewed; - Integer wordsMastered; - - // 스트릭 (TOTAL만) - Integer currentStreak; - Integer longestStreak; - String lastStudyDate; - - // 게임 통계 (TOTAL만) - Integer gamesPlayed; - Integer gamesWon; - Integer correctGuesses; - Integer totalGameScore; - Integer quickGuesses; // 5초 이내 정답 - Integer perfectDraws; // 전원 정답 유도 - - // 타임스탬프 - String createdAt; - String updatedAt; -} -``` - -### 7.2 DynamoDB 키 구조 - -| 필드 | 패턴 | 예시 | -|---------|------------------------|-------------------| -| PK | USER#{userId}#STATS | USER#abc123#STATS | -| SK (일별) | DAILY#{date} | DAILY#2026-01-16 | -| SK (주별) | WEEKLY#{year}-W{week} | WEEKLY#2026-W03 | -| SK (월별) | MONTHLY#{year}-{month} | MONTHLY#2026-01 | -| SK (전체) | TOTAL | TOTAL | - ---- - -## 8. 통계 메트릭 - -### 8.1 테스트 메트릭 - -| 메트릭 | 설명 | 업데이트 시점 | -|-------------------|----------|---------| -| testsCompleted | 완료 테스트 수 | 테스트 제출 | -| questionsAnswered | 총 문제 수 | 테스트 제출 | -| correctAnswers | 정답 수 | 테스트 제출 | -| incorrectAnswers | 오답 수 | 테스트 제출 | -| successRate | 정답률 (%) | 조회 시 계산 | - -### 8.2 학습 메트릭 - -| 메트릭 | 설명 | 업데이트 시점 | -|-----------------|----------|---------| -| newWordsLearned | 신규 학습 단어 | 일일학습 완료 | -| wordsReviewed | 복습 단어 | 일일학습 완료 | -| wordsMastered | 마스터 단어 | 상태 변경 시 | - -### 8.3 게임 메트릭 - -| 메트릭 | 설명 | 업데이트 시점 | -|----------------|----------|---------| -| gamesPlayed | 참여 게임 수 | 게임 종료 | -| gamesWon | 1등 횟수 | 게임 종료 | -| correctGuesses | 정답 횟수 | 게임 종료 | -| totalGameScore | 누적 점수 | 게임 종료 | -| quickGuesses | 5초 내 정답 | 게임 종료 | -| perfectDraws | 전원 정답 유도 | 게임 종료 | - ---- - -## 9. 히스토리 조회 - -### 9.1 페이지네이션 - -```mermaid -flowchart LR - REQUEST["GET /stats/history
?limit=7&cursor=..."] - QUERY["Query
PK = USER#id#STATS
SK begins_with DAILY#
scanIndexForward = false"] - ENRICH["DailyStudy 조회
isCompleted 추가"] - RESPONSE["PaginatedResult
items, nextCursor, hasMore"] - REQUEST --> QUERY --> ENRICH --> RESPONSE -``` - -### 9.2 응답 구조 - -```json -{ - "history": [ - { - "period": "2026-01-16", - "testsCompleted": 2, - "questionsAnswered": 30, - "correctAnswers": 25, - "incorrectAnswers": 5, - "successRate": 83.33, - "newWordsLearned": 50, - "wordsReviewed": 5, - "isCompleted": true - } - ], - "nextCursor": "base64encoded...", - "hasMore": true -} -``` - ---- - -## 10. 배지 연동 - -### 10.1 자동 배지 체크 - -```mermaid -flowchart TB - STREAM[StatsStreamHandler] --> CHECK[배지 조건 체크] - CHECK --> PERFECT{만점 테스트?} - PERFECT -->|Yes| BADGE1[PERFECT_SCORE 배지] - CHECK --> STATS[전체 통계 조회] - STATS --> BADGESERVICE[BadgeService.checkAndAwardBadges] - BADGESERVICE --> AWARD[조건 충족 배지 부여] -``` - -### 10.2 배지 조건 예시 - -| 배지 | 조건 | 통계 필드 | -|--------------|----------|----------------------| -| STREAK_7 | 7일 연속 학습 | currentStreak >= 7 | -| ACCURACY_90 | 정확도 90% | successRate >= 90 | -| TEST_10 | 10회 테스트 | testsCompleted >= 10 | -| GAME_10_WINS | 10번 1등 | gamesWon >= 10 | - ---- - -## 11. 파일 구조 - -``` -domain/stats/ -├── handler/ -│ ├── UserStatsHandler.java (REST API) -│ ├── StatsStreamHandler.java (DynamoDB Streams) -│ └── ScheduledStatsHandler.java (EventBridge) -├── service/ -│ └── StatsService.java -├── repository/ -│ └── UserStatsRepository.java -├── model/ -│ └── UserStats.java -└── constants/ - └── StatsKey.java -``` - ---- - -## 12. 성능 최적화 - -| 최적화 | 기법 | 효과 | -|--------------------------|------------------|-------------------| -| 원자적 업데이트 | UpdateExpression | Race condition 방지 | -| 비동기 처리 | DynamoDB Streams | API 응답 속도 향상 | -| Cursor 페이지네이션 | lastEvaluatedKey | 대용량 히스토리 처리 | -| Strongly Consistent Read | 히스토리 조회 | 데이터 정합성 | - ---- - -## 13. 기술 스택 - -- **Runtime:** AWS Lambda (Java 21) -- **Database:** DynamoDB (Single Table Design) -- **Event:** DynamoDB Streams, EventBridge -- **Pattern:** Atomic Counter, Write-through, Event-driven diff --git a/docs/domain-reports/VOCABULARY-DOMAIN-REPORT.md b/docs/domain-reports/VOCABULARY-DOMAIN-REPORT.md deleted file mode 100644 index 7ee2c90e..00000000 --- a/docs/domain-reports/VOCABULARY-DOMAIN-REPORT.md +++ /dev/null @@ -1,504 +0,0 @@ -# Vocabulary Domain 세부 보고서 - -## 1. 개요 - -Vocabulary 도메인은 AWS Lambda와 DynamoDB를 기반으로 한 영어 단어 학습 시스템입니다. SM-2 Spaced Repetition 알고리즘과 CQRS 패턴을 적용하여 과학적이고 효율적인 단어 -암기를 지원합니다. - ---- - -## 2. 전체 아키텍처 - -```mermaid -flowchart TB - subgraph Client["클라이언트"] - APP[Mobile/Web App] - end - - subgraph Gateway["API Gateway"] - REST[REST API
HTTP] - end - - subgraph Lambda["Lambda Handlers"] - direction TB - WORD[WordHandler] - USERWORD[UserWordHandler] - DAILY[DailyStudyHandler] - TEST[TestHandler] - GROUP[WordGroupHandler] - VOICE[VoiceHandler] - STATS[StatisticsHandler
SQS Consumer] - end - - subgraph Services["서비스 레이어 (CQRS)"] - direction TB - CMD[Command Services
쓰기 작업] - QUERY[Query Services
읽기 작업] - end - - subgraph External["외부 서비스"] - POLLY[AWS Polly
TTS] - SNS[AWS SNS] - SQS[AWS SQS] - S3[(S3
음성 캐시)] - end - - subgraph Storage["데이터 저장소"] - DDB[(DynamoDB)] - end - - APP --> REST - REST --> WORD & USERWORD & DAILY & TEST & GROUP & VOICE - WORD & USERWORD & DAILY & TEST & GROUP --> CMD & QUERY - CMD & QUERY --> DDB - VOICE --> POLLY --> S3 - TEST --> SNS --> SQS --> STATS - STATS --> DDB -``` - ---- - -## 3. 일일 학습 시스템 - -### 3.1 일일 학습 흐름 - -```mermaid -flowchart TB - subgraph DailyStudyFlow["일일 학습 흐름"] - START[GET /vocab/daily] --> CHECK{기존 학습
존재?} - CHECK -->|Yes| RETURN[기존 학습 반환] - CHECK -->|No| CREATE[새 학습 생성] - CREATE --> REVIEW["복습 단어 5개 선정
(nextReviewAt <= today)"] - REVIEW --> NEW["신규 단어 50개 선정
(미학습 + 해당 레벨)"] - NEW --> SAVE[DailyStudy 저장] - SAVE --> RETURN - RETURN --> LEARN[학습 진행] - LEARN --> MARK["POST .../learned
단어별 학습 완료"] - MARK --> PROGRESS{50개 완료?} - PROGRESS -->|No| LEARN - PROGRESS -->|Yes| COMPLETE["isCompleted = true
배지 체크"] - end -``` - -### 3.2 Daily Study API - -| Method | Endpoint | 설명 | -|--------|-------------------------------------|-----------------| -| GET | /vocab/daily | 오늘의 학습 단어 조회/생성 | -| POST | /vocab/daily/words/{wordId}/learned | 단어 학습 완료 처리 | - -### 3.3 응답 예시 - -```json -{ - "userId": "user123", - "date": "2026-01-16", - "newWordIds": [ - "word1", - "word2", - ... - ], - "reviewWordIds": [ - "word51", - "word52", - ... - ], - "learnedWordIds": [], - "totalWords": 55, - "learnedCount": 0, - "isCompleted": false, - "progress": { - "percentage": 0, - "learned": 0, - "total": 55 - } -} -``` - ---- - -## 4. SM-2 Spaced Repetition 알고리즘 - -### 4.1 학습 상태 전이 - -```mermaid -stateDiagram-v2 - [*] --> NEW: 단어 추가 - NEW --> LEARNING: 첫 학습 - LEARNING --> LEARNING: 오답 - LEARNING --> REVIEWING: 2회 연속 정답 - REVIEWING --> LEARNING: 오답 - REVIEWING --> MASTERED: 5회 연속 정답 - MASTERED --> LEARNING: 오답 - MASTERED --> MASTERED: 정답 유지 -``` - -### 4.2 상태별 로직 - -| 상태 | 조건 | 정답 시 | 오답 시 | -|---------------|-------------|-----------------------------|---------------------------| -| **NEW** | 신규 단어 | LEARNING, rep=1, interval=1 | LEARNING, easeFactor-=0.2 | -| **LEARNING** | rep < 2 | rep++, interval 계산 | rep=0, interval=1 | -| **REVIEWING** | 2 ≤ rep < 5 | rep++, interval 증가 | rep=0, LEARNING | -| **MASTERED** | rep ≥ 5 | interval 증가, 유지 | rep=0, REVIEWING | - -### 4.3 복습 간격 계산 - -```mermaid -flowchart LR - REP1["rep = 1
interval = 1일"] - REP2["rep = 2
interval = 6일"] - REP3["rep >= 3
interval = interval × easeFactor"] - REP1 --> REP2 --> REP3 -``` - -**핵심 변수:** - -- `repetitions`: 연속 정답 횟수 (0~∞) -- `interval`: 복습 간격 (일 단위) -- `easeFactor`: 난이도 계수 (1.3~2.5, 기본 2.5) -- `nextReviewAt`: 다음 복습 예정일 - ---- - -## 5. 테스트 시스템 - -### 5.1 테스트 흐름 - -```mermaid -sequenceDiagram - participant Client - participant Handler as TestHandler - participant Service as TestCommandService - participant DB as DynamoDB - participant SNS as AWS SNS - Client ->> Handler: POST /vocab/tests/start - Handler ->> Service: startTest(userId, testType) - Service ->> DB: 오늘의 학습 단어 조회 - Service ->> Service: 4지선다 문제 생성 - Service -->> Client: 문제 목록 반환 - Note over Client: 사용자 답변 입력 - Client ->> Handler: POST /vocab/tests/submit - Handler ->> Service: submitTest(answers) - Service ->> DB: 결과 저장 - Service ->> SNS: 결과 발행 (비동기) - Service -->> Client: 테스트 결과 - Note over SNS, DB: 비동기 통계 처리 - SNS ->> DB: 통계 업데이트 -``` - -### 5.2 문제 생성 알고리즘 - -```mermaid -flowchart TB - START[문제 생성 시작] --> WORDS[일일 학습 단어 로드] - WORDS --> GROUP[레벨별 그룹화] - GROUP --> LOOP[각 단어마다] - LOOP --> CORRECT["정답 = 해당 단어의
한국어 뜻"] - CORRECT --> DIST["오답 3개 선정
(동일 레벨 단어)"] - DIST --> SHUFFLE[4개 보기 셔플] - SHUFFLE --> NEXT{다음 단어?} - NEXT -->|Yes| LOOP - NEXT -->|No| RETURN[문제 목록 반환] -``` - -### 5.3 Test API - -| Method | Endpoint | 설명 | -|--------|-------------------------------|------------| -| POST | /vocab/tests/start | 테스트 시작 | -| POST | /vocab/tests/submit | 테스트 제출 | -| GET | /vocab/tests/results | 테스트 결과 목록 | -| GET | /vocab/tests/results/{testId} | 테스트 상세 결과 | -| GET | /vocab/tests/tested-words | 최근 테스트된 단어 | - ---- - -## 6. 단어 관리 시스템 - -### 6.1 Word API - -| Method | Endpoint | 설명 | -|--------|------------------------|----------------------------| -| GET | /vocab/words | 단어 목록 (level, category 필터) | -| POST | /vocab/words | 단어 등록 | -| GET | /vocab/words/{wordId} | 단어 상세 | -| PUT | /vocab/words/{wordId} | 단어 수정 | -| DELETE | /vocab/words/{wordId} | 단어 삭제 | -| GET | /vocab/words/search | 키워드 검색 | -| POST | /vocab/words/batch | 배치 등록 (최대 100개) | -| POST | /vocab/words/batch/get | 배치 조회 | - -### 6.2 User Word API - -| Method | Endpoint | 설명 | -|--------|-----------------------------------|-------------| -| GET | /vocab/user-words | 사용자 단어 목록 | -| GET | /vocab/user-words/{wordId} | 사용자 단어 상세 | -| PUT | /vocab/user-words/{wordId} | 정답/오답 기록 | -| PATCH | /vocab/user-words/{wordId}/tag | 북마크, 난이도 설정 | -| PATCH | /vocab/user-words/{wordId}/status | 상태 수동 변경 | -| GET | /vocab/wrong-answers | 오답 단어 목록 | - -### 6.3 Word Group API - -| Method | Endpoint | 설명 | -|--------|----------------------------------------|--------| -| POST | /vocab/groups | 단어장 생성 | -| GET | /vocab/groups | 단어장 목록 | -| GET | /vocab/groups/{groupId} | 단어장 상세 | -| PUT | /vocab/groups/{groupId} | 단어장 수정 | -| DELETE | /vocab/groups/{groupId} | 단어장 삭제 | -| POST | /vocab/groups/{groupId}/words/{wordId} | 단어 추가 | -| DELETE | /vocab/groups/{groupId}/words/{wordId} | 단어 제거 | - ---- - -## 7. TTS 음성 합성 - -### 7.1 음성 생성 흐름 - -```mermaid -flowchart TB - REQUEST["POST /vocab/synthesize
{wordId, voice, type}"] - CHECK{S3 캐시
존재?} - REQUEST --> CHECK - CHECK -->|Yes| PRESIGN[Presigned URL 생성] - CHECK -->|No| POLLY[AWS Polly 호출] - POLLY --> SAVE[S3 저장] - SAVE --> PRESIGN - PRESIGN --> RESPONSE[URL 반환] -``` - -### 7.2 Voice API - -```json -// Request -{ - "wordId": "uuid", - "voice": "MALE", - // MALE | FEMALE - "type": "WORD" - // WORD | EXAMPLE -} - -// Response -{ - "url": "https://s3...presigned-url", - "expiresIn": 3600 -} -``` - ---- - -## 8. 데이터 모델 - -### 8.1 Word - -```java - -@DynamoDbBean -public class Word { - String wordId; // UUID - String english; // 영어 단어 - String korean; // 한국어 뜻 - String example; // 예문 - String level; // BEGINNER | INTERMEDIATE | ADVANCED - String category; // DAILY | BUSINESS | ACADEMIC | TRAVEL | TECHNOLOGY - String maleVoiceKey; // S3 음성 키 - String femaleVoiceKey; - String maleExampleVoiceKey; - String femaleExampleVoiceKey; -} -``` - -**DynamoDB Keys:** - -| Key | 패턴 | 용도 | -|--------|---------------------|----------| -| PK | WORD#{wordId} | 기본 조회 | -| SK | METADATA | - | -| GSI1PK | LEVEL#{level} | 레벨별 조회 | -| GSI2PK | CATEGORY#{category} | 카테고리별 조회 | - -### 8.2 UserWord - -```java - -@DynamoDbBean -public class UserWord { - String userId; - String wordId; - String status; // NEW | LEARNING | REVIEWING | MASTERED - - // SM-2 알고리즘 필드 - Integer interval; // 복습 간격 (일) - Double easeFactor; // 난이도 계수 (1.3~2.5) - Integer repetitions; // 연속 정답 횟수 - String nextReviewAt; // 다음 복습일 (YYYY-MM-DD) - - // 통계 - Integer correctCount; // 누적 정답 - Integer incorrectCount; // 누적 오답 - - // 사용자 설정 - Boolean bookmarked; // 북마크 - Boolean favorite; // 즐겨찾기 - String difficulty; // EASY | NORMAL | HARD -} -``` - -**DynamoDB Keys:** - -| Key | 패턴 | 용도 | -|--------|--------------------------|--------------| -| PK | USER#{userId} | 기본 조회 | -| SK | WORD#{wordId} | - | -| GSI1PK | USER#{userId}#REVIEW | 복습 예정 단어 | -| GSI1SK | DATE#{nextReviewAt} | - | -| GSI2PK | USER#{userId}#STATUS | 상태별 조회 | -| GSI2SK | STATUS#{status} | - | -| GSI3PK | USER#{userId}#BOOKMARKED | 북마크 (Sparse) | - -### 8.3 DailyStudy - -```java - -@DynamoDbBean -public class DailyStudy { - String userId; - String date; // YYYY-MM-DD - List newWordIds; // 신규 단어 50개 - List reviewWordIds; // 복습 단어 5개 - List learnedWordIds; // 학습 완료 단어 - Integer totalWords; // 총 단어 수 (55) - Integer learnedCount; // 학습 완료 수 - Boolean isCompleted; // 완료 여부 -} -``` - -### 8.4 TestResult - -```java - -@DynamoDbBean -public class TestResult { - String testId; - String userId; - String testType; // DAILY | WEEKLY | CUSTOM - Integer totalQuestions; - Integer correctAnswers; - Integer incorrectAnswers; - Double successRate; - List testedWordIds; - List incorrectWordIds; - String startedAt; - String completedAt; -} -``` - ---- - -## 9. 서비스 아키텍처 (CQRS) - -### 9.1 Command Services (쓰기) - -```mermaid -flowchart TB - subgraph Commands["Command Services"] - WC[WordCommandService
단어 생성/수정/삭제] - UC[UserWordCommandService
학습 상태 업데이트] - DC[DailyStudyCommandService
일일 학습 관리] - TC[TestCommandService
테스트 생성/제출] - GC[WordGroupCommandService
단어장 관리] - end -``` - -### 9.2 Query Services (읽기) - -```mermaid -flowchart TB - subgraph Queries["Query Services"] - WQ[WordQueryService
단어 조회/검색] - UQ[UserWordQueryService
학습 현황 조회] - DQ[DailyStudyQueryService
일일 학습 조회] - TQ[TestQueryService
테스트 결과 조회] - end -``` - ---- - -## 10. 성능 최적화 - -| 최적화 | 기법 | 효과 | -|---------------------|------------------------|-----------------| -| N+1 방지 | BatchGetItem (100개 단위) | DB 호출 90% 감소 | -| TTS 캐싱 | S3 + Presigned URL | Polly 호출 90% 절감 | -| 페이지네이션 | Cursor 기반 (Base64) | 대용량 데이터 처리 | -| Sparse Index | GSI3 (북마크 전용) | 인덱스 크기 최소화 | -| 비동기 통계 | SNS/SQS | API 응답 속도 향상 | -| Strongly Consistent | DailyStudy 조회 | 데이터 정합성 | - ---- - -## 11. 파일 구조 - -``` -domain/vocabulary/ -├── handler/ -│ ├── WordHandler.java -│ ├── UserWordHandler.java -│ ├── DailyStudyHandler.java -│ ├── TestHandler.java -│ ├── WordGroupHandler.java -│ ├── VoiceHandler.java -│ ├── StatsHandler.java -│ └── StatisticsHandler.java (SQS) -├── service/ -│ ├── WordCommandService.java -│ ├── WordQueryService.java -│ ├── UserWordCommandService.java -│ ├── UserWordQueryService.java -│ ├── TestCommandService.java -│ ├── TestQueryService.java -│ ├── DailyStudyCommandService.java -│ ├── DailyStudyQueryService.java -│ ├── WordGroupCommandService.java -│ ├── StatsService.java -│ └── StatisticsService.java -├── repository/ -│ ├── WordRepository.java -│ ├── UserWordRepository.java -│ ├── DailyStudyRepository.java -│ ├── TestResultRepository.java -│ └── WordGroupRepository.java -├── model/ -│ ├── Word.java -│ ├── UserWord.java -│ ├── DailyStudy.java -│ ├── TestResult.java -│ └── WordGroup.java -├── state/ -│ ├── WordState.java (interface) -│ ├── NewState.java -│ ├── LearningState.java -│ ├── ReviewingState.java -│ ├── MasteredState.java -│ ├── SpacedRepetitionContext.java -│ └── WordStateFactory.java -└── enums/ - ├── WordStatus.java - ├── WordCategory.java - └── TestType.java -``` - ---- - -## 12. 기술 스택 - -- **Runtime:** AWS Lambda (Java 21) -- **Database:** DynamoDB (Single Table Design) -- **TTS:** AWS Polly (남성/여성 음성) -- **Storage:** S3 (음성 캐시) -- **Messaging:** SNS/SQS (비동기 통계) -- **Pattern:** CQRS, State, Repository, Factory From 0ca9df0ac0ff7240d850892859042c00fe4d3270 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 23 Jan 2026 16:11:48 +0900 Subject: [PATCH 507/528] feat: add dashboard stats API and enhance news stats tracking - Introduce `/stats/dashboard` API for retrieving integrated user stats (today, overall, weekly progress, and level distribution) - Update `UserStats` model to include additional fields for news stats (newsRead, newsQuizCompleted, newsQuizPerfect, newsWordsCollected, etc.) - Add atomic updates to track news reading, quiz, and word stats in `UserStatsRepository` - Modify CloudFormation `template.yaml` to register the new `/stats/dashboard` endpoint --- .../docs/CATCHMIND_FRONTEND_GUIDE.md | 761 ------------------ .../docs/NEWS_API_FRONTEND_CHANGES.md | 304 ------- .../docs/VOCABULARY_NEWS_INTEGRATION.md | 308 ------- 3 files changed, 1373 deletions(-) delete mode 100644 ServerlessFunction/docs/CATCHMIND_FRONTEND_GUIDE.md delete mode 100644 ServerlessFunction/docs/NEWS_API_FRONTEND_CHANGES.md delete mode 100644 ServerlessFunction/docs/VOCABULARY_NEWS_INTEGRATION.md diff --git a/ServerlessFunction/docs/CATCHMIND_FRONTEND_GUIDE.md b/ServerlessFunction/docs/CATCHMIND_FRONTEND_GUIDE.md deleted file mode 100644 index 4dd03385..00000000 --- a/ServerlessFunction/docs/CATCHMIND_FRONTEND_GUIDE.md +++ /dev/null @@ -1,761 +0,0 @@ -# Catchmind 게임 프론트엔드 연동 가이드 - -## 목차 - -1. [개요](#개요) -2. [아키텍처](#아키텍처) -3. [WebSocket 연결](#websocket-연결) -4. [메시지 구조](#메시지-구조) -5. [게임 흐름](#게임-흐름) -6. [REST API](#rest-api) -7. [타이머 동기화](#타이머-동기화) -8. [게임 자동 종료](#게임-자동-종료) -9. [재접속 처리](#재접속-처리) -10. [에러 처리](#에러-처리) - ---- - -## 개요 - -Catchmind는 실시간 그림 맞추기 게임입니다. WebSocket을 통한 실시간 통신과 REST API를 통한 게임 세션 관리를 지원합니다. - -### 주요 특징 - -- **실시간 통신**: WebSocket 기반 양방향 통신 -- **도메인 분리**: `chat` / `game` 도메인으로 메시지 라우팅 -- **타이머 동기화**: `serverTime` 필드를 통한 클라이언트-서버 시간 동기화 -- **자동 종료**: 게임 시작 7분 후 자동 종료 -- **재접속 지원**: 게임 세션 API를 통한 상태 복원 - ---- - -## 아키텍처 - -``` -┌─────────────┐ WebSocket ┌──────────────────┐ -│ Frontend │◄──────────────────►│ API Gateway WS │ -│ (React) │ └────────┬─────────┘ -│ │ │ -│ │ REST API ┌───────▼─────────┐ -│ │◄───────────────────►│ API Gateway │ -└─────────────┘ │ REST │ - └────────┬────────┘ - │ - ┌─────────────┼─────────────┐ - │ │ │ - ┌─────▼────┐ ┌─────▼────┐ ┌─────▼────┐ - │ WS Msg │ │ Game │ │ Game │ - │ Handler │ │ Handler │ │ Session │ - └──────────┘ └──────────┘ │ Handler │ - └──────────┘ -``` - ---- - -## WebSocket 연결 - -### 연결 URL - -``` -wss://{api-id}.execute-api.{region}.amazonaws.com/dev?roomToken={token} -``` - -### 연결 절차 - -1. REST API로 방 토큰 발급 (`POST /chat/rooms/{roomId}/join`) -2. 토큰으로 WebSocket 연결 -3. 연결 성공 시 자동으로 방에 입장 - -### 연결 예시 (TypeScript) - -```typescript -const connectWebSocket = (roomToken: string): WebSocket => { - const ws = new WebSocket( - `wss://xxx.execute-api.ap-northeast-2.amazonaws.com/dev?roomToken=${roomToken}` - ); - - ws.onopen = () => console.log('WebSocket connected'); - ws.onmessage = (event) => handleMessage(JSON.parse(event.data)); - ws.onerror = (error) => console.error('WebSocket error:', error); - ws.onclose = () => console.log('WebSocket closed'); - - return ws; -}; -``` - ---- - -## 메시지 구조 - -### 공통 메시지 포맷 - -모든 WebSocket 메시지는 다음 필드를 포함합니다: - -```typescript -interface BaseMessage { - domain: 'chat' | 'game'; // 도메인 구분 - messageType: string; // 메시지 타입 - messageId: string; // 고유 메시지 ID - roomId: string; // 방 ID - userId: string; // 발신자 ID (시스템: "SYSTEM") - content?: string; // 메시지 내용 - createdAt: string; // ISO 8601 형식 시간 - timestamp: number; // Unix timestamp (ms) -} -``` - -### 도메인 구분 - -| 도메인 | 설명 | 메시지 타입 | -|--------|--------|-------------------------------------------------------------------------------------------| -| `chat` | 채팅 메시지 | text, image, voice, ai_response | -| `game` | 게임 메시지 | game_start, game_end, round_start, round_end, drawing, correct_answer, score_update, hint | - -### 메시지 라우팅 예시 - -```typescript -const handleMessage = (message: BaseMessage) => { - if (message.domain === 'chat') { - handleChatMessage(message); - } else if (message.domain === 'game') { - handleGameMessage(message); - } -}; -``` - ---- - -## 게임 흐름 - -### 게임 상태 (GameStatus) - -```typescript -type GameStatus = 'NONE' | 'WAITING' | 'PLAYING' | 'ROUND_END' | 'FINISHED'; -``` - -### 전체 흐름 - -``` -[대기] ─── /game 시작 ───► [게임 시작] ─► [라운드 1] ─► [라운드 종료] - │ │ - │ ◄───────────────────┘ - │ (반복) - ▼ - [게임 종료] - │ - ┌────┴────┐ - │ │ - 수동 종료 자동 종료 - (7분 경과) -``` - -### 1. 게임 시작 (game_start) - -**수신 메시지:** - -```json -{ - "domain": "game", - "messageType": "game_start", - "messageId": "uuid", - "roomId": "room-123", - "userId": "SYSTEM", - "content": "🎮 게임 시작!\n총 5 라운드\n\n라운드 1 시작!\n출제자: user-1", - "createdAt": "2024-01-20T10:00:00Z", - "timestamp": 1705746000000, - "serverTime": 1705746000000, - "gameStatus": "PLAYING", - "currentRound": 1, - "totalRounds": 5, - "currentDrawerId": "user-1", - "drawerOrder": ["user-1", "user-2", "user-3"], - "roundStartTime": 1705746000000, - "roundDuration": 60 -} -``` - -**프론트엔드 처리:** - -```typescript -const handleGameStart = (message: GameStartMessage) => { - setGameStatus('PLAYING'); - setCurrentRound(message.currentRound); - setTotalRounds(message.totalRounds); - setCurrentDrawer(message.currentDrawerId); - setDrawerOrder(message.drawerOrder); - - // 타이머 동기화 - startTimer(message.roundStartTime, message.roundDuration, message.serverTime); - - // 현재 사용자가 출제자인지 확인 - setIsDrawer(message.currentDrawerId === currentUserId); -}; -``` - -### 2. 그림 데이터 전송/수신 (drawing) - -**전송 (출제자만):** - -```typescript -const sendDrawing = (drawingData: DrawingData) => { - ws.send(JSON.stringify({ - action: 'sendMessage', - messageType: 'drawing', - content: JSON.stringify(drawingData) - })); -}; -``` - -**수신 메시지:** - -```json -{ - "domain": "game", - "messageType": "drawing", - "messageId": "uuid", - "roomId": "room-123", - "userId": "user-1", - "content": "{\"type\":\"path\",\"points\":[...],\"color\":\"#000\",\"width\":3}", - "timestamp": 1705746010000 -} -``` - -### 3. 정답 체크 - -**채팅 메시지로 자동 체크됩니다:** - -```typescript -const sendAnswer = (answer: string) => { - ws.send(JSON.stringify({ - action: 'sendMessage', - messageType: 'text', - content: answer - })); -}; -``` - -### 4. 정답 알림 (correct_answer) - -**수신 메시지:** - -```json -{ - "domain": "game", - "messageType": "correct_answer", - "roomId": "room-123", - "userId": "user-2", - "content": "🎉 user-2님이 정답을 맞혔습니다! (+35점)", - "timestamp": 1705746030000, - "serverTime": 1705746030000, - "score": 35, - "elapsedTime": 30000, - "allCorrect": false, - "scores": { - "user-1": 5, - "user-2": 35 - } -} -``` - -### 5. 점수 업데이트 (score_update) - -**수신 메시지:** - -```json -{ - "domain": "game", - "messageType": "score_update", - "roomId": "room-123", - "timestamp": 1705746030000, - "scores": { - "user-1": 15, - "user-2": 35, - "user-3": 20 - }, - "lastScorer": "user-2", - "lastScore": 35 -} -``` - -### 6. 라운드 종료 (round_end) - -**수신 메시지:** - -```json -{ - "domain": "game", - "messageType": "round_end", - "roomId": "room-123", - "content": "라운드 1 종료! 정답: 사과\n\n라운드 2 시작! 출제자: user-2", - "timestamp": 1705746060000, - "serverTime": 1705746060000, - "data": { - "answer": "사과", - "currentRound": 1, - "totalRounds": 5, - "nextRound": 2, - "nextDrawer": "user-2", - "nextWord": { - "wordId": "word-123", - "korean": "바나나" - }, - "roundStartTime": 1705746060000, - "roundDuration": 60, - "ranking": [ - { "rank": 1, "userId": "user-2", "score": 35 }, - { "rank": 2, "userId": "user-3", "score": 20 }, - { "rank": 3, "userId": "user-1", "score": 15 } - ] - } -} -``` - -**프론트엔드 처리:** - -```typescript -const handleRoundEnd = (message: RoundEndMessage) => { - const { data } = message; - - // 정답 표시 - showAnswer(data.answer); - - // 순위 표시 - showRanking(data.ranking); - - // 다음 라운드 준비 - if (data.nextRound) { - setCurrentRound(data.nextRound); - setCurrentDrawer(data.nextDrawer); - setIsDrawer(data.nextDrawer === currentUserId); - - // 출제자에게만 단어 표시 - if (data.nextDrawer === currentUserId && data.nextWord) { - setCurrentWord(data.nextWord.korean); - } - - // 타이머 재시작 - startTimer(data.roundStartTime, data.roundDuration, message.serverTime); - - // 캔버스 초기화 - clearCanvas(); - } -}; -``` - -### 7. 게임 종료 (game_end) - -**수신 메시지:** - -```json -{ - "domain": "game", - "messageType": "game_end", - "roomId": "room-123", - "content": "🎮 게임 종료!\n\n📊 최종 순위:\n 🥇 user-2: 120점\n 🥈 user-3: 95점\n 🥉 user-1: 80점", - "timestamp": 1705746300000, - "reason": "COMPLETED" -} -``` - -**종료 사유 (reason):** -| 값 | 설명 | -|----|------| -| `COMPLETED` | 모든 라운드 완료 | -| `STOPPED` | 수동 종료 | -| `TIME_EXPIRED` | 7분 시간 초과 | -| `NOT_ENOUGH_PLAYERS` | 인원 부족 | - ---- - -## REST API - -### 게임 시작 - -```http -POST /chat/rooms/{roomId}/game/start -Authorization: Bearer {accessToken} -``` - -**Response:** - -```json -{ - "success": true, - "message": "Game started", - "data": { - "gameSessionId": "session-123", - "roomId": "room-123", - "status": "PLAYING", - "currentRound": 1, - "totalRounds": 5, - "currentDrawerId": "user-1", - "roundStartTime": 1705746000000, - "serverTime": 1705746000000, - "roundDuration": 60, - "drawerOrder": ["user-1", "user-2", "user-3"], - "currentWord": { - "wordId": "word-1", - "word": "사과" - } - } -} -``` - -> **Note:** `currentWord`는 출제자에게만 포함됩니다. - -### 게임 종료 - -```http -POST /chat/rooms/{roomId}/game/stop -Authorization: Bearer {accessToken} -``` - -### 게임 상태 조회 - -```http -GET /chat/rooms/{roomId}/game/status -Authorization: Bearer {accessToken} -``` - -### 게임 세션 조회 (재접속용) - -```http -GET /games/{gameSessionId} -Authorization: Bearer {accessToken} -``` - -**Response:** - -```json -{ - "success": true, - "message": "Game session retrieved", - "data": { - "gameSessionId": "session-123", - "roomId": "room-123", - "gameType": "catchmind", - "status": "PLAYING", - "currentRound": 3, - "totalRounds": 5, - "currentDrawerId": "user-2", - "roundStartTime": 1705746180000, - "serverTime": 1705746200000, - "roundDuration": 60, - "scores": { - "user-1": 45, - "user-2": 60, - "user-3": 30 - }, - "players": ["user-1", "user-2", "user-3"], - "drawerOrder": ["user-1", "user-2", "user-3"], - "hintUsed": false, - "currentWord": { - "wordId": "word-5", - "word": "바나나" - } - } -} -``` - -> **Note:** `currentWord`는 출제자에게만 포함됩니다. - ---- - -## 타이머 동기화 - -### 문제 - -클라이언트와 서버 시간 차이로 인한 타이머 불일치 - -### 해결책 - -`serverTime` 필드를 사용하여 서버 시간 기준 타이머 계산 - -### 구현 예시 - -```typescript -interface TimerSync { - roundStartTime: number; // 라운드 시작 시간 (서버 기준) - roundDuration: number; // 라운드 지속 시간 (초) - serverTime: number; // 메시지 발송 시점의 서버 시간 -} - -const startTimer = ( - roundStartTime: number, - roundDuration: number, - serverTime: number -) => { - // 서버에서 이미 경과한 시간 계산 - const elapsedOnServer = serverTime - roundStartTime; - - // 남은 시간 계산 (밀리초) - const remainingTime = (roundDuration * 1000) - elapsedOnServer; - - // 음수 방지 - const safeRemainingTime = Math.max(0, remainingTime); - - setRemainingTime(safeRemainingTime); - - // 타이머 시작 - const interval = setInterval(() => { - setRemainingTime((prev) => { - if (prev <= 1000) { - clearInterval(interval); - return 0; - } - return prev - 1000; - }); - }, 1000); - - return () => clearInterval(interval); -}; -``` - -### React Hook 예시 - -```typescript -const useGameTimer = (timerSync: TimerSync | null) => { - const [remainingSeconds, setRemainingSeconds] = useState(0); - - useEffect(() => { - if (!timerSync) return; - - const { roundStartTime, roundDuration, serverTime } = timerSync; - const elapsed = (serverTime - roundStartTime) / 1000; - const remaining = Math.max(0, roundDuration - elapsed); - - setRemainingSeconds(Math.ceil(remaining)); - - const interval = setInterval(() => { - setRemainingSeconds((prev) => Math.max(0, prev - 1)); - }, 1000); - - return () => clearInterval(interval); - }, [timerSync]); - - return remainingSeconds; -}; -``` - ---- - -## 게임 자동 종료 - -### 개요 - -게임 시작 후 7분(420초)이 경과하면 자동으로 종료됩니다. - -### 자동 종료 메시지 - -```json -{ - "domain": "game", - "messageType": "game_end", - "roomId": "room-123", - "userId": "SYSTEM", - "content": "⏰ 시간 초과! 🎮 게임 종료!\n\n📊 최종 순위:\n 🥇 user-2: 120점\n 🥈 user-1: 95점", - "timestamp": 1705746420000, - "reason": "TIME_EXPIRED" -} -``` - -### 프론트엔드 처리 - -```typescript -const handleGameEnd = (message: GameEndMessage) => { - setGameStatus('FINISHED'); - - // 종료 사유에 따른 UI 처리 - if (message.reason === 'TIME_EXPIRED') { - showNotification('시간 초과로 게임이 종료되었습니다.'); - } else if (message.reason === 'STOPPED') { - showNotification('게임이 수동으로 종료되었습니다.'); - } - - // 최종 결과 표시 - showFinalResults(message.content); - - // 캔버스 초기화 - clearCanvas(); -}; -``` - ---- - -## 재접속 처리 - -### 시나리오 - -사용자가 게임 중 연결이 끊어졌다가 다시 접속하는 경우 - -### 처리 절차 - -1. WebSocket 재연결 -2. 게임 세션 API로 현재 상태 조회 -3. UI 상태 복원 -4. 타이머 동기화 - -### 구현 예시 - -```typescript -const handleReconnect = async (roomId: string, gameSessionId: string) => { - // 1. WebSocket 재연결 - const roomToken = await getRoomToken(roomId); - connectWebSocket(roomToken); - - // 2. 게임 세션 조회 - const session = await fetchGameSession(gameSessionId); - - if (session.status === 'PLAYING') { - // 3. UI 상태 복원 - setGameStatus('PLAYING'); - setCurrentRound(session.currentRound); - setScores(session.scores); - setCurrentDrawer(session.currentDrawerId); - setIsDrawer(session.currentDrawerId === currentUserId); - - // 출제자인 경우 단어 설정 - if (session.currentWord) { - setCurrentWord(session.currentWord.word); - } - - // 4. 타이머 동기화 - startTimer( - session.roundStartTime, - session.roundDuration, - session.serverTime - ); - } else if (session.status === 'FINISHED') { - setGameStatus('FINISHED'); - } -}; -``` - ---- - -## 에러 처리 - -### WebSocket 에러 코드 - -| 코드 | 설명 | 처리 방법 | -|------|--------|--------------| -| 1000 | 정상 종료 | - | -| 1001 | 서버 종료 | 재연결 시도 | -| 1006 | 비정상 종료 | 재연결 시도 | -| 4001 | 인증 실패 | 토큰 재발급 후 재연결 | -| 4003 | 권한 없음 | 에러 표시 | - -### REST API 에러 코드 - -| 코드 | 설명 | -|------------|-----------------------| -| `GAME_001` | 게임 시작 실패 | -| `GAME_002` | 게임 중단 실패 | -| `GAME_003` | 진행 중인 게임 없음 | -| `GAME_004` | 이미 게임 진행 중 | -| `GAME_005` | 권한 없음 (게임 시작자만 중단 가능) | -| `GAME_006` | 게임 세션을 찾을 수 없음 | - -### 에러 처리 예시 - -```typescript -const handleError = (error: ApiError) => { - switch (error.code) { - case 'GAME_001': - showNotification('게임을 시작할 수 없습니다. 최소 2명이 필요합니다.'); - break; - case 'GAME_004': - showNotification('이미 게임이 진행 중입니다.'); - break; - case 'GAME_006': - // 게임 세션 만료 - 목록으로 이동 - navigateToRoomList(); - break; - default: - showNotification('오류가 발생했습니다.'); - } -}; -``` - ---- - -## 전체 상태 관리 예시 (React) - -```typescript -interface GameState { - status: GameStatus; - currentRound: number; - totalRounds: number; - currentDrawerId: string | null; - currentWord: string | null; - scores: Record; - isDrawer: boolean; - remainingTime: number; - drawerOrder: string[]; -} - -const initialGameState: GameState = { - status: 'NONE', - currentRound: 0, - totalRounds: 0, - currentDrawerId: null, - currentWord: null, - scores: {}, - isDrawer: false, - remainingTime: 0, - drawerOrder: [], -}; - -const gameReducer = (state: GameState, action: GameAction): GameState => { - switch (action.type) { - case 'GAME_START': - return { - ...state, - status: 'PLAYING', - currentRound: action.payload.currentRound, - totalRounds: action.payload.totalRounds, - currentDrawerId: action.payload.currentDrawerId, - drawerOrder: action.payload.drawerOrder, - isDrawer: action.payload.currentDrawerId === action.payload.currentUserId, - scores: {}, - }; - - case 'ROUND_END': - return { - ...state, - currentRound: action.payload.nextRound, - currentDrawerId: action.payload.nextDrawer, - currentWord: action.payload.isDrawer ? action.payload.nextWord : null, - isDrawer: action.payload.isDrawer, - }; - - case 'SCORE_UPDATE': - return { - ...state, - scores: action.payload.scores, - }; - - case 'GAME_END': - return { - ...initialGameState, - status: 'FINISHED', - scores: state.scores, - }; - - case 'RESET': - return initialGameState; - - default: - return state; - } -}; -``` - ---- - -## 버전 이력 - -| 버전 | 날짜 | 변경 내용 | -|-------|------------|---------------------| -| 1.0.0 | 2024-01-20 | 초기 문서 작성 | -| 1.1.0 | 2024-01-20 | 게임 자동 종료 (7분) 기능 추가 | diff --git a/ServerlessFunction/docs/NEWS_API_FRONTEND_CHANGES.md b/ServerlessFunction/docs/NEWS_API_FRONTEND_CHANGES.md deleted file mode 100644 index 2d63691a..00000000 --- a/ServerlessFunction/docs/NEWS_API_FRONTEND_CHANGES.md +++ /dev/null @@ -1,304 +0,0 @@ -# News API 프론트엔드 변경사항 - -> 마지막 업데이트: 2025-01-23 - -## 목차 -1. [기사 목록 조회 API 변경](#1-기사-목록-조회-api-변경) -2. [기사 상세 조회 API 변경](#2-기사-상세-조회-api-변경) -3. [키워드 필드 추가](#3-키워드-필드-추가) -4. [인증 필수 엔드포인트](#4-인증-필수-엔드포인트) -5. [API 응답 예시](#5-api-응답-예시) - ---- - -## 1. 기사 목록 조회 API 변경 - -### 영향받는 엔드포인트 -- `GET /news` - 뉴스 목록 조회 -- `GET /news/today` - 오늘의 뉴스 조회 -- `GET /news/recommended` - 추천 뉴스 조회 - -### 변경사항 -각 기사 객체에 `isBookmarked` 필드가 추가되었습니다. - -| 필드 | 타입 | 설명 | -|------|------|------| -| `isBookmarked` | boolean | 현재 사용자가 해당 기사를 북마크했는지 여부 | - -### 주의사항 -- **로그인한 사용자**: 실제 북마크 상태 반환 -- **비로그인 사용자**: 모든 기사에 `false` 반환 - -### 기존 응답 (변경 전) -```json -{ - "articles": [ - { - "articleId": "abc123", - "title": "...", - "summary": "...", - "category": "TECH", - "level": "INTERMEDIATE" - } - ] -} -``` - -### 새 응답 (변경 후) -```json -{ - "articles": [ - { - "articleId": "abc123", - "title": "...", - "summary": "...", - "category": "TECH", - "level": "INTERMEDIATE", - "cefrLevel": "B1", - "isBookmarked": true - } - ] -} -``` - ---- - -## 2. 기사 상세 조회 API 변경 - -### 영향받는 엔드포인트 -- `GET /news/{articleId}` - 기사 상세 조회 - -### 변경사항 -응답에 `isBookmarked`와 `isRead` 필드가 추가되었습니다. - -| 필드 | 타입 | 설명 | -|------|------|------| -| `isBookmarked` | boolean | 현재 사용자가 해당 기사를 북마크했는지 여부 | -| `isRead` | boolean | 현재 사용자가 해당 기사를 읽었는지 여부 | - -### 새 응답 형식 -```json -{ - "success": true, - "message": "뉴스 조회 성공", - "data": { - "article": { - "articleId": "abc123", - "title": "Tech Giants Report Strong Quarterly Earnings", - "summary": "Major technology companies...", - "category": "TECH", - "level": "INTERMEDIATE", - "cefrLevel": "B1", - "keywords": [...], - "highlightWords": ["earnings", "revenue", "growth"], - "quiz": [...] - }, - "isBookmarked": true, - "isRead": false - } -} -``` - ---- - -## 3. 키워드 필드 추가 - -### 변경사항 -`keywords` 배열의 각 키워드 객체에 `meaningKo` (한국어 뜻) 필드가 추가되었습니다. - -### 키워드 객체 구조 - -| 필드 | 타입 | 설명 | -|------|------|------| -| `word` | string | 영어 단어 | -| `meaning` | string | 영어 정의 (간단한 설명) | -| `meaningKo` | string | **[신규]** 한국어 뜻 | -| `example` | string | 기사에서 발췌한 예문 | - -### 키워드 예시 -```json -{ - "keywords": [ - { - "word": "economy", - "meaning": "the system of trade and industry", - "meaningKo": "경제", - "example": "The economy is growing steadily." - }, - { - "word": "revenue", - "meaning": "income, especially of a company", - "meaningKo": "수익", - "example": "The company reported record revenue." - } - ] -} -``` - -### 프론트엔드 활용 -- 단어장 기능에서 한국어 뜻 표시 -- 학습 카드에 영어/한국어 뜻 모두 표시 가능 - ---- - -## 4. 인증 필수 엔드포인트 - -다음 엔드포인트들은 Cognito 인증 토큰이 필요합니다. - -### 인증 필수 (Authorization 헤더 필요) -| 메서드 | 엔드포인트 | 설명 | -|--------|------------|------| -| GET | `/news/stats` | 뉴스 학습 통계 조회 | -| GET | `/news/bookmarks` | 북마크 목록 조회 | -| GET | `/news/words` | 수집 단어 목록 조회 | -| GET | `/news/quiz/history` | 퀴즈 기록 조회 | -| POST | `/news/{articleId}/read` | 읽기 완료 기록 | -| POST | `/news/{articleId}/bookmark` | 북마크 토글 | -| GET | `/news/{articleId}/quiz` | 퀴즈 조회 | -| POST | `/news/{articleId}/quiz` | 퀴즈 제출 | -| POST | `/news/{articleId}/words` | 단어 수집 | -| DELETE | `/news/{articleId}/words/{word}` | 단어 삭제 | -| POST | `/news/words/{word}/sync` | 단어 Vocabulary 연동 | - -### 인증 선택 (토큰 있으면 개인화된 응답) -| 메서드 | 엔드포인트 | 설명 | -|--------|------------|------| -| GET | `/news` | 뉴스 목록 (북마크 상태 포함) | -| GET | `/news/today` | 오늘의 뉴스 (북마크 상태 포함) | -| GET | `/news/recommended` | 추천 뉴스 (북마크 상태 포함) | -| GET | `/news/{articleId}` | 기사 상세 (북마크/읽기 상태 포함) | - -### 요청 헤더 예시 -``` -Authorization: Bearer eyJraWQiOiJ... -``` - ---- - -## 5. API 응답 예시 - -### 기사 목록 조회 (GET /news) -```json -{ - "success": true, - "message": "뉴스 목록 조회 성공", - "data": { - "articles": [ - { - "articleId": "news_20250123_001", - "title": "Global Tech Summit Addresses AI Regulation", - "summary": "World leaders gathered to discuss...", - "source": "Reuters", - "publishedAt": "2025-01-23T09:00:00Z", - "category": "TECH", - "level": "INTERMEDIATE", - "cefrLevel": "B1", - "imageUrl": "https://...", - "readCount": 150, - "keywords": [ - { - "word": "regulation", - "meaning": "official rules made by a government", - "meaningKo": "규제", - "example": "New AI regulation will take effect next year." - } - ], - "highlightWords": ["regulation", "summit", "artificial intelligence"], - "isBookmarked": false - } - ], - "nextCursor": "eyJwayI6Ik5FV1MjMjAyNS0wMS0yMyIsInNrIjoiQVJUSUNMRSMxMjM0NSJ9", - "hasMore": true, - "count": 10 - } -} -``` - -### 기사 상세 조회 (GET /news/{articleId}) -```json -{ - "success": true, - "message": "뉴스 조회 성공", - "data": { - "article": { - "articleId": "news_20250123_001", - "title": "Global Tech Summit Addresses AI Regulation", - "summary": "World leaders gathered to discuss the future of artificial intelligence...", - "source": "Reuters", - "publishedAt": "2025-01-23T09:00:00Z", - "category": "TECH", - "level": "INTERMEDIATE", - "cefrLevel": "B1", - "imageUrl": "https://...", - "readCount": 151, - "keywords": [ - { - "word": "regulation", - "meaning": "official rules made by a government", - "meaningKo": "규제", - "example": "New AI regulation will take effect next year." - }, - { - "word": "summit", - "meaning": "an important meeting between leaders", - "meaningKo": "정상회담", - "example": "The summit brought together leaders from 50 countries." - } - ], - "highlightWords": ["regulation", "summit", "artificial intelligence"], - "quiz": [ - { - "questionId": "q1", - "type": "COMPREHENSION", - "question": "What is the main topic of this article?", - "options": ["AI regulation", "Climate change", "Economic policy", "Healthcare"], - "points": 20 - }, - { - "questionId": "q2", - "type": "WORD_MATCH", - "question": "What does 'regulation' mean in this context?", - "options": ["Official rules", "Technology", "Meeting", "Country"], - "points": 15 - }, - { - "questionId": "q3", - "type": "FILL_BLANK", - "question": "World leaders gathered at the _____ to discuss AI.", - "options": ["summit", "office", "factory", "school"], - "points": 30 - } - ] - }, - "isBookmarked": true, - "isRead": false - } -} -``` - ---- - -## 프론트엔드 체크리스트 - -### 기사 목록 화면 -- [ ] 각 기사 카드에 북마크 아이콘 표시 (`isBookmarked` 활용) -- [ ] 북마크된 기사는 다른 색상/아이콘으로 구분 - -### 기사 상세 화면 -- [ ] 북마크 버튼 상태 초기화 (`isBookmarked` 활용) -- [ ] 읽기 완료 표시 (`isRead` 활용) -- [ ] 키워드 목록에 한국어 뜻 표시 (`meaningKo` 활용) - -### 단어장/학습 카드 -- [ ] 한국어 뜻 표시 기능 추가 -- [ ] 영어/한국어 토글 기능 (선택사항) - -### 인증 -- [ ] 필수 인증 엔드포인트에 토큰 전송 확인 -- [ ] 401 에러 처리 (로그인 페이지로 리다이렉트) - ---- - -## 질문 및 문의 - -백엔드 관련 문의사항이 있으면 연락주세요. diff --git a/ServerlessFunction/docs/VOCABULARY_NEWS_INTEGRATION.md b/ServerlessFunction/docs/VOCABULARY_NEWS_INTEGRATION.md deleted file mode 100644 index f1ff6c62..00000000 --- a/ServerlessFunction/docs/VOCABULARY_NEWS_INTEGRATION.md +++ /dev/null @@ -1,308 +0,0 @@ -# 단어장 - 뉴스 연동 기능 프론트엔드 가이드 - -> 마지막 업데이트: 2025-01-23 - -## 목차 -1. [뉴스 단어 수집 흐름](#1-뉴스-단어-수집-흐름) -2. [API 엔드포인트](#2-api-엔드포인트) -3. [카테고리 필터링](#3-카테고리-필터링) -4. [응답 예시](#4-응답-예시) -5. [프론트엔드 구현 가이드](#5-프론트엔드-구현-가이드) - ---- - -## 1. 뉴스 단어 수집 흐름 - -### 자동 연동 프로세스 - -``` -사용자가 뉴스 기사에서 "단어 가져오기" 클릭 - ↓ - POST /news/{articleId}/words - ↓ -┌─────────────────────────────────────────┐ -│ 1. 기사 키워드에서 한국어 뜻 추출 │ -│ 2. Word 테이블에 자동 저장 (NEWS 카테고리) │ -│ 3. UserWord에 자동 추가 (NEW 상태) │ -│ 4. NewsWordCollect 기록 저장 │ -└─────────────────────────────────────────┘ - ↓ - 단어장(/user-words)에서 바로 확인 가능! -``` - -### 핵심 포인트 -- **별도의 "연동" 버튼 불필요**: 단어 수집 시 자동으로 단어장에 추가됨 -- **카테고리 자동 설정**: 뉴스에서 수집한 단어는 `NEWS` 카테고리로 저장 -- **한국어 뜻 자동 포함**: 기사 AI 분석 결과에서 `meaningKo` 추출 - ---- - -## 2. API 엔드포인트 - -### 뉴스 단어 수집 API - -#### 단어 수집 (단어 가져오기) -```http -POST /news/{articleId}/words -Authorization: Bearer {token} -Content-Type: application/json - -{ - "word": "economy", - "context": "The economy is growing rapidly" // 선택사항 -} -``` - -**응답:** -```json -{ - "success": true, - "message": "단어 수집 성공", - "data": { - "wordCollect": { - "word": "economy", - "meaning": "경제", - "articleId": "abc123", - "articleTitle": "Global Economic Outlook", - "collectedAt": "2025-01-23T12:00:00Z", - "syncedToVocab": true, - "vocabUserWordId": "economy" - }, - "newBadges": [] - } -} -``` - -#### 뉴스에서 수집한 단어 목록 -```http -GET /news/words?limit=20 -Authorization: Bearer {token} -``` - -**응답:** -```json -{ - "success": true, - "data": { - "words": [ - { - "word": "economy", - "meaning": "경제", - "articleId": "abc123", - "articleTitle": "Global Economic Outlook", - "context": "The economy is growing", - "collectedAt": "2025-01-23T12:00:00Z", - "syncedToVocab": true - } - ], - "stats": { - "totalCollected": 15, - "syncedToVocab": 15 - }, - "count": 1 - } -} -``` - ---- - -### 단어장 API (카테고리 필터 추가됨) - -#### 내 단어장 조회 -```http -GET /user-words?category=NEWS&limit=20 -Authorization: Bearer {token} -``` - -**쿼리 파라미터:** - -| 파라미터 | 타입 | 설명 | 예시 | -|----------|------|------|------| -| `category` | string | 카테고리 필터 **(신규)** | `NEWS`, `DAILY`, `BUSINESS` | -| `status` | string | 학습 상태 필터 | `NEW`, `LEARNING`, `REVIEWING`, `MASTERED` | -| `bookmarked` | boolean | 북마크 필터 | `true` | -| `incorrectOnly` | boolean | 오답만 | `true` | -| `limit` | number | 조회 개수 (최대 50) | `20` | -| `cursor` | string | 페이지네이션 커서 | `eyJ...` | - ---- - -## 3. 카테고리 필터링 - -### 사용 가능한 카테고리 - -| 카테고리 | 코드 | 설명 | -|----------|------|------| -| 일상 | `DAILY` | 일상 생활 단어 | -| 비즈니스 | `BUSINESS` | 비즈니스/업무 단어 | -| 학술 | `ACADEMIC` | 학술/전문 단어 | -| 여행 | `TRAVEL` | 여행 관련 단어 | -| 기술 | `TECHNOLOGY` | IT/기술 단어 | -| **뉴스** | `NEWS` | **뉴스에서 수집한 단어 (신규)** | - -### 필터 조합 예시 - -``` -# 뉴스에서 수집한 모든 단어 -GET /user-words?category=NEWS - -# 뉴스 단어 중 학습 중인 것만 -GET /user-words?category=NEWS&status=LEARNING - -# 뉴스 단어 중 북마크한 것만 -GET /user-words?category=NEWS&bookmarked=true - -# 뉴스 단어 중 틀린 것만 -GET /user-words?category=NEWS&incorrectOnly=true - -# 모든 카테고리의 북마크 단어 -GET /user-words?bookmarked=true -``` - ---- - -## 4. 응답 예시 - -### 단어장 조회 응답 (GET /user-words?category=NEWS) - -```json -{ - "success": true, - "message": "User words retrieved", - "data": { - "userWords": [ - { - "wordId": "economy", - "userId": "user-123", - "status": "NEW", - "correctCount": 0, - "incorrectCount": 0, - "bookmarked": false, - "favorite": false, - "difficulty": null, - "nextReviewAt": null, - "lastReviewedAt": null, - "repetitions": 0, - "interval": 0, - "english": "economy", - "korean": "경제", - "level": "INTERMEDIATE", - "category": "NEWS", - "example": "The economy is growing steadily.", - "maleVoiceKey": null, - "femaleVoiceKey": null - }, - { - "wordId": "regulation", - "userId": "user-123", - "status": "LEARNING", - "correctCount": 2, - "incorrectCount": 1, - "bookmarked": true, - "favorite": false, - "difficulty": "HARD", - "english": "regulation", - "korean": "규제", - "level": "ADVANCED", - "category": "NEWS", - "example": "New regulation will take effect." - } - ], - "nextCursor": "eyJwayI6IlVTRVIjdXNlci0xMjMiLCJzayI6IldPUkQjcmVndWxhdGlvbiJ9", - "hasMore": true - } -} -``` - ---- - -## 5. 프론트엔드 구현 가이드 - -### 단어장 UI 변경사항 - -#### 1. 카테고리 탭/필터 추가 -``` -[전체] [일상] [비즈니스] [학술] [여행] [기술] [뉴스] - ↑ 신규 -``` - -#### 2. 뉴스 단어 표시 -- 뉴스에서 수집한 단어는 `category: "NEWS"` 표시 -- 출처 표시 가능 (NewsWordCollect의 articleTitle 활용) - -#### 3. 단어 수집 후 UI 업데이트 -```javascript -// 단어 수집 API 호출 -const response = await fetch(`/news/${articleId}/words`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ word: selectedWord }) -}); - -const result = await response.json(); - -if (result.success) { - // syncedToVocab: true 이므로 단어장에 자동 추가됨 - showToast('단어가 단어장에 추가되었습니다!'); - - // 새 배지 획득 시 알림 - if (result.data.newBadges?.length > 0) { - showBadgeNotification(result.data.newBadges); - } -} -``` - -### 체크리스트 - -#### 단어장 페이지 -- [ ] 카테고리 필터 UI 추가 (탭 또는 드롭다운) -- [ ] `NEWS` 카테고리 옵션 추가 -- [ ] API 호출 시 `category` 파라미터 전달 -- [ ] 카테고리별 단어 개수 표시 (선택사항) - -#### 뉴스 상세 페이지 -- [ ] "단어 가져오기" 버튼 동작 확인 -- [ ] 수집 성공 시 토스트 메시지 -- [ ] 이미 수집된 단어 표시 (비활성화 또는 체크 아이콘) - -#### 뉴스 키워드 표시 -- [ ] `keywords` 배열의 `meaningKo` 필드 표시 -- [ ] 각 키워드 클릭 시 수집 가능하도록 UI 구성 - ---- - -## 데이터 흐름 다이어그램 - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ 뉴스 기사 상세 │ -│ ┌─────────────────────────────────────────────────────────┐ │ -│ │ Keywords: │ │ -│ │ [economy: 경제] [regulation: 규제] [summit: 정상회담] │ │ -│ │ ↓ 클릭 │ │ -│ │ "단어 가져오기" → POST /news/{id}/words │ │ -│ └─────────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────────┘ - ↓ - 자동으로 단어장에 추가 - ↓ -┌─────────────────────────────────────────────────────────────────┐ -│ 단어장 │ -│ ┌─────────────────────────────────────────────────────────┐ │ -│ │ 카테고리: [전체] [일상] [비즈니스] ... [뉴스✓] │ │ -│ │ │ │ -│ │ economy 경제 NEW 뉴스 │ │ -│ │ regulation 규제 LEARNING 뉴스 ⭐ │ │ -│ │ summit 정상회담 NEW 뉴스 │ │ -│ └─────────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────────┘ -``` - ---- - -## 질문 및 문의 - -백엔드 관련 문의사항이 있으면 연락주세요. From b5d62e82ca8a0215c86e3902bbc1d4ea2c623fb1 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 23 Jan 2026 16:16:04 +0900 Subject: [PATCH 508/528] refactor: format code with consistent indentation and spacing - Apply consistent formatting to improve code readability across multiple files - Adjust indentation, spacing, and alignment in model classes, DTOs, and repository methods --- .../domain/badge/enums/BadgeType.java | 14 +- .../domain/news/constants/NewsKey.java | 36 +- .../domain/news/dto/RawNewsArticle.java | 6 +- .../domain/news/enums/NewsCategory.java | 14 +- .../domain/news/enums/QuizType.java | 14 +- .../domain/news/exception/NewsErrorCode.java | 32 +- .../news/handler/NewsCollectionHandler.java | 20 +- .../domain/news/handler/NewsHandler.java | 187 +++--- .../domain/news/model/KeywordInfo.java | 2 +- .../domain/news/model/NewsArticle.java | 22 +- .../domain/news/model/NewsQuizResult.java | 12 +- .../domain/news/model/NewsWordCollect.java | 12 +- .../domain/news/model/QuizAnswerResult.java | 2 +- .../domain/news/model/QuizQuestion.java | 2 +- .../domain/news/model/UserNewsRecord.java | 12 +- .../repository/NewsArticleRepository.java | 97 +-- .../news/repository/NewsQuizRepository.java | 46 +- .../news/repository/NewsWordRepository.java | 38 +- .../news/repository/UserNewsRepository.java | 73 +-- .../news/service/NewsAnalysisService.java | 103 ++-- .../news/service/NewsCollectorService.java | 38 +- .../news/service/NewsDuplicateChecker.java | 34 +- .../news/service/NewsLearningService.java | 61 +- .../domain/news/service/NewsQueryService.java | 26 +- .../domain/news/service/NewsQuizService.java | 74 +-- .../domain/news/service/NewsWordService.java | 68 +-- .../domain/news/service/RssFeedParser.java | 50 +- .../speaking/dto/request/ResetRequest.java | 10 +- .../speaking/dto/request/SpeakingRequest.java | 50 +- .../dto/response/SpeakingResponse.java | 13 +- .../speaking/handler/SpeakingHandler.java | 268 ++++----- .../speaking/model/SpeakingSession.java | 156 ++--- .../repository/SpeakingSessionRepository.java | 114 ++-- .../speaking/service/SpeakingService.java | 557 +++++++++--------- .../stats/handler/UserStatsHandler.java | 82 +-- .../domain/stats/model/UserStats.java | 20 +- .../stats/repository/UserStatsRepository.java | 174 +++--- .../user/handler/PostConfirmationHandler.java | 11 +- .../domain/user/handler/PreSignUpHandler.java | 14 +- .../domain/user/handler/UserHandler.java | 4 +- .../serverless/domain/user/model/User.java | 8 +- .../domain/user/service/UserService.java | 31 +- .../vocabulary/handler/UserWordHandler.java | 6 +- .../service/UserWordQueryService.java | 8 +- 44 files changed, 1317 insertions(+), 1304 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/enums/BadgeType.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/enums/BadgeType.java index f9d32794..bc7e102f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/enums/BadgeType.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/enums/BadgeType.java @@ -32,21 +32,14 @@ public enum BadgeType { // 특별 MASTER("학습 마스터", "모든 업적을 달성했습니다", "master.png", "ALL_BADGES", 1); - + private static final String BUCKET_NAME = System.getenv("BUCKET_NAME"); private static final String BASE_URL = getBaseUrl(); - - private static String getBaseUrl() { - String bucket = BUCKET_NAME != null ? BUCKET_NAME : "group2-englishstudy"; - return String.format("https://%s.s3.ap-northeast-2.amazonaws.com/badges/", bucket); - } - private final String name; private final String description; private final String imageFile; private final String category; private final int threshold; - BadgeType(String name, String description, String imageFile, String category, int threshold) { this.name = name; this.description = description; @@ -55,6 +48,11 @@ private static String getBaseUrl() { this.threshold = threshold; } + private static String getBaseUrl() { + String bucket = BUCKET_NAME != null ? BUCKET_NAME : "group2-englishstudy"; + return String.format("https://%s.s3.ap-northeast-2.amazonaws.com/badges/", bucket); + } + public static BadgeType fromString(String value) { if (value == null) return null; try { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/constants/NewsKey.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/constants/NewsKey.java index 4c44a58f..eb1425d8 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/constants/NewsKey.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/constants/NewsKey.java @@ -6,7 +6,7 @@ * 뉴스 도메인 DynamoDB 키 상수 및 빌더 */ public final class NewsKey { - + // Entity Prefixes public static final String NEWS = "NEWS#"; public static final String ARTICLE = "ARTICLE#"; @@ -18,108 +18,108 @@ public final class NewsKey { public static final String BOOKMARK = "BOOKMARK#"; public static final String COMMENT = "COMMENT#"; public static final String STATS = "STATS"; - + // User Suffixes public static final String SUFFIX_NEWS = "#NEWS"; public static final String SUFFIX_NEWS_WORDS = "#NEWS_WORDS"; public static final String SUFFIX_NEWS_COMMENTS = "#NEWS_COMMENTS"; - + private NewsKey() { } - + // === Key Builders === - + /** * 뉴스 기사 PK: NEWS#{date} */ public static String newsPk(String date) { return NEWS + date; } - + /** * 뉴스 기사 SK: ARTICLE#{articleId} */ public static String articleSk(String articleId) { return ARTICLE + articleId; } - + /** * 레벨별 조회 GSI1 PK: LEVEL#{level} */ public static String levelPk(String level) { return LEVEL + level; } - + /** * 카테고리별 조회 GSI2 PK: CATEGORY#{category} */ public static String categoryPk(String category) { return CATEGORY + category; } - + /** * 사용자 뉴스 활동 PK: USER#{userId}#NEWS */ public static String userNewsPk(String userId) { return DynamoDbKey.USER + userId + SUFFIX_NEWS; } - + /** * 읽기 기록 SK: READ#{articleId} */ public static String readSk(String articleId) { return READ + articleId; } - + /** * 퀴즈 결과 SK: QUIZ#{articleId} */ public static String quizSk(String articleId) { return QUIZ + articleId; } - + /** * 단어 수집 SK: WORD#{word}#{articleId} */ public static String wordSk(String word, String articleId) { return WORD + word + "#" + articleId; } - + /** * 북마크 SK: BOOKMARK#{articleId} */ public static String bookmarkSk(String articleId) { return BOOKMARK + articleId; } - + /** * 사용자 수집 단어 GSI1 PK: USER#{userId}#NEWS_WORDS */ public static String userNewsWordsPk(String userId) { return DynamoDbKey.USER + userId + SUFFIX_NEWS_WORDS; } - + /** * 댓글 PK: NEWS_COMMENT#{articleId} */ public static String commentPk(String articleId) { return "NEWS_COMMENT#" + articleId; } - + /** * 댓글 SK: COMMENT#{commentId} */ public static String commentSk(String commentId) { return COMMENT + commentId; } - + /** * 사용자 댓글 GSI1 PK: USER#{userId}#NEWS_COMMENTS */ public static String userNewsCommentsPk(String userId) { return DynamoDbKey.USER + userId + SUFFIX_NEWS_COMMENTS; } - + /** * 사용자 뉴스 통계 GSI1 PK: USER_NEWS_STAT#{userId} */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/dto/RawNewsArticle.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/dto/RawNewsArticle.java index c9902559..4be72fac 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/dto/RawNewsArticle.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/dto/RawNewsArticle.java @@ -14,7 +14,7 @@ @NoArgsConstructor @AllArgsConstructor public class RawNewsArticle { - + private String title; private String description; private String url; @@ -22,7 +22,7 @@ public class RawNewsArticle { private String source; private String publishedAt; private String content; - + /** * URL 기반 고유 식별자 생성 */ @@ -32,7 +32,7 @@ public String generateId() { } return String.valueOf(url.hashCode()); } - + /** * 유효한 기사인지 검증 */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/enums/NewsCategory.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/enums/NewsCategory.java index 7f88078f..3f5a8bc5 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/enums/NewsCategory.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/enums/NewsCategory.java @@ -13,21 +13,21 @@ public enum NewsCategory { WORLD("world", "세계"), CULTURE("culture", "문화"), SCIENCE("science", "과학"); - + private final String code; private final String displayName; - + NewsCategory(String code, String displayName) { this.code = code; this.displayName = displayName; } - + public static boolean isValid(String value) { if (value == null) return false; return Arrays.stream(values()) .anyMatch(cat -> cat.name().equalsIgnoreCase(value) || cat.code.equalsIgnoreCase(value)); } - + public static NewsCategory fromString(String value) { if (value == null) { throw new IllegalArgumentException("NewsCategory value cannot be null"); @@ -37,18 +37,18 @@ public static NewsCategory fromString(String value) { .findFirst() .orElseThrow(() -> new IllegalArgumentException("Unknown NewsCategory: " + value)); } - + public static NewsCategory fromStringOrDefault(String value, NewsCategory defaultValue) { if (value == null || !isValid(value)) { return defaultValue; } return fromString(value); } - + public String getCode() { return code; } - + public String getDisplayName() { return displayName; } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/enums/QuizType.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/enums/QuizType.java index 7b95a466..20da38ae 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/enums/QuizType.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/enums/QuizType.java @@ -9,23 +9,23 @@ public enum QuizType { COMPREHENSION("comprehension", "독해 질문", 20), WORD_MATCH("word_match", "단어-뜻 매칭", 15), FILL_BLANK("fill_blank", "빈칸 채우기", 30); - + private final String code; private final String displayName; private final int defaultPoints; - + QuizType(String code, String displayName, int defaultPoints) { this.code = code; this.displayName = displayName; this.defaultPoints = defaultPoints; } - + public static boolean isValid(String value) { if (value == null) return false; return Arrays.stream(values()) .anyMatch(type -> type.name().equalsIgnoreCase(value) || type.code.equalsIgnoreCase(value)); } - + public static QuizType fromString(String value) { if (value == null) { throw new IllegalArgumentException("QuizType value cannot be null"); @@ -35,15 +35,15 @@ public static QuizType fromString(String value) { .findFirst() .orElseThrow(() -> new IllegalArgumentException("Unknown QuizType: " + value)); } - + public String getCode() { return code; } - + public String getDisplayName() { return displayName; } - + public int getDefaultPoints() { return defaultPoints; } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/exception/NewsErrorCode.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/exception/NewsErrorCode.java index 58197f0e..ef2c05cb 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/exception/NewsErrorCode.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/exception/NewsErrorCode.java @@ -7,72 +7,72 @@ * 뉴스 기사, 퀴즈, 단어 수집, 댓글 관련 에러 코드를 정의합니다. */ public enum NewsErrorCode implements DomainErrorCode { - + // 인증 관련 에러 UNAUTHORIZED("AUTH_001", "인증이 필요합니다", 401), - + // 뉴스 기사 관련 에러 ARTICLE_NOT_FOUND("ARTICLE_001", "뉴스 기사를 찾을 수 없습니다", 404), INVALID_ARTICLE_DATA("ARTICLE_002", "뉴스 기사 데이터가 유효하지 않습니다", 400), ARTICLE_ALREADY_EXISTS("ARTICLE_003", "이미 존재하는 뉴스 기사입니다", 409), - + // 카테고리/레벨 관련 에러 INVALID_CATEGORY("CATEGORY_001", "유효하지 않은 카테고리입니다", 400), INVALID_LEVEL("LEVEL_001", "유효하지 않은 레벨입니다", 400), - + // 읽기 기록 관련 에러 READ_RECORD_NOT_FOUND("READ_001", "읽기 기록을 찾을 수 없습니다", 404), ALREADY_READ("READ_002", "이미 읽은 기사입니다", 409), - + // 퀴즈 관련 에러 QUIZ_NOT_FOUND("QUIZ_001", "퀴즈를 찾을 수 없습니다", 404), QUIZ_ALREADY_SUBMITTED("QUIZ_002", "이미 제출한 퀴즈입니다", 409), INVALID_QUIZ_ANSWER("QUIZ_003", "유효하지 않은 퀴즈 답변입니다", 400), - + // 단어 수집 관련 에러 WORD_ALREADY_COLLECTED("WORD_001", "이미 수집한 단어입니다", 409), WORD_NOT_COLLECTED("WORD_002", "수집한 단어를 찾을 수 없습니다", 404), - + // 북마크 관련 에러 BOOKMARK_NOT_FOUND("BOOKMARK_001", "북마크를 찾을 수 없습니다", 404), ALREADY_BOOKMARKED("BOOKMARK_002", "이미 북마크한 기사입니다", 409), BOOKMARK_LIMIT_EXCEEDED("BOOKMARK_003", "북마크 한도를 초과했습니다", 400), - + // 댓글 관련 에러 COMMENT_NOT_FOUND("COMMENT_001", "댓글을 찾을 수 없습니다", 404), COMMENT_NOT_OWNER("COMMENT_002", "댓글 작성자만 수정/삭제할 수 있습니다", 403), INVALID_COMMENT_DATA("COMMENT_003", "유효하지 않은 댓글 데이터입니다", 400), - + // 통계 관련 에러 STATS_NOT_FOUND("STATS_001", "통계 정보를 찾을 수 없습니다", 404); - + private static final String DOMAIN = "NEWS"; - + private final String code; private final String message; private final int statusCode; - + NewsErrorCode(String code, String message, int statusCode) { this.code = code; this.message = message; this.statusCode = statusCode; } - + @Override public String getDomain() { return DOMAIN; } - + @Override public String getCode() { return code; } - + @Override public String getMessage() { return message; } - + @Override public int getStatusCode() { return statusCode; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsCollectionHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsCollectionHandler.java index 4d17463f..246617c3 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsCollectionHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsCollectionHandler.java @@ -14,29 +14,29 @@ * EventBridge 스케줄러에 의해 매일 18시에 트리거 */ public class NewsCollectionHandler implements RequestHandler> { - + private static final Logger logger = LoggerFactory.getLogger(NewsCollectionHandler.class); - + private final NewsCollectorService collectorService; - + public NewsCollectionHandler() { this.collectorService = new NewsCollectorService(); } - + public NewsCollectionHandler(NewsCollectorService collectorService) { this.collectorService = collectorService; } - + @Override public Map handleRequest(ScheduledEvent event, Context context) { logger.info("뉴스 수집 Lambda 시작 - requestId: {}", context.getAwsRequestId()); - + try { NewsCollectorService.CollectionResult result = collectorService.collectNews(); - + logger.info("뉴스 수집 완료 - 수집: {}, 저장: {}, 소요: {}ms", result.collectedCount(), result.savedCount(), result.elapsedMs()); - + return Map.of( "statusCode", 200, "message", "News collection completed", @@ -44,10 +44,10 @@ public Map handleRequest(ScheduledEvent event, Context context) "savedCount", result.savedCount(), "elapsedMs", result.elapsedMs() ); - + } catch (Exception e) { logger.error("뉴스 수집 실패", e); - + return Map.of( "statusCode", 500, "message", "News collection failed: " + e.getMessage() diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java index 44f9a43c..f806e2f2 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java @@ -4,6 +4,9 @@ import com.amazonaws.services.lambda.runtime.RequestHandler; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; import com.mzc.secondproject.serverless.common.dto.PaginatedResult; import com.mzc.secondproject.serverless.common.router.HandlerRouter; import com.mzc.secondproject.serverless.common.router.Route; @@ -13,14 +16,10 @@ import com.mzc.secondproject.serverless.domain.news.model.NewsArticle; import com.mzc.secondproject.serverless.domain.news.model.NewsQuizResult; import com.mzc.secondproject.serverless.domain.news.model.NewsWordCollect; -import com.mzc.secondproject.serverless.domain.news.model.UserNewsRecord; import com.mzc.secondproject.serverless.domain.news.service.NewsLearningService; import com.mzc.secondproject.serverless.domain.news.service.NewsQueryService; import com.mzc.secondproject.serverless.domain.news.service.NewsQuizService; import com.mzc.secondproject.serverless.domain.news.service.NewsWordService; -import com.google.gson.Gson; -import com.google.gson.JsonArray; -import com.google.gson.JsonObject; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -33,31 +32,31 @@ * 뉴스 학습 API 핸들러 */ public class NewsHandler implements RequestHandler { - + private static final Logger logger = LoggerFactory.getLogger(NewsHandler.class); private static final int DEFAULT_LIMIT = 10; private static final int MAX_LIMIT = 50; private static final Gson gson = new Gson(); - + private final NewsQueryService queryService; private final NewsLearningService learningService; private final NewsQuizService quizService; private final NewsWordService wordService; private final HandlerRouter router; - + public NewsHandler() { this(new NewsQueryService(), new NewsLearningService(), new NewsQuizService(), new NewsWordService()); } - + public NewsHandler(NewsQueryService queryService, NewsLearningService learningService, - NewsQuizService quizService, NewsWordService wordService) { + NewsQuizService quizService, NewsWordService wordService) { this.queryService = queryService; this.learningService = learningService; this.quizService = quizService; this.wordService = wordService; this.router = initRouter(); } - + private HandlerRouter initRouter() { return new HandlerRouter().addRoutes( Route.get("/news/today", this::getTodayNews), @@ -79,13 +78,13 @@ private HandlerRouter initRouter() { Route.get("/news", this::getNewsList) ); } - + @Override public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { logger.info("News API 요청: {} {}", request.getHttpMethod(), request.getPath()); return router.route(request); } - + /** * 뉴스 목록 조회 (필터링 지원) * GET /news?level=INTERMEDIATE&category=TECH&limit=10&cursor=xxx @@ -93,14 +92,14 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent re private APIGatewayProxyResponseEvent getNewsList(APIGatewayProxyRequestEvent request) { Map params = request.getQueryStringParameters(); if (params == null) params = new HashMap<>(); - + String level = params.get("level"); String category = params.get("category"); String cursor = params.get("cursor"); int limit = parseLimit(params.get("limit")); - + PaginatedResult result; - + if (level != null && category != null) { result = queryService.getNewsByLevelAndCategory(level.toUpperCase(), category.toUpperCase(), limit, cursor); } else if (level != null) { @@ -110,10 +109,10 @@ private APIGatewayProxyResponseEvent getNewsList(APIGatewayProxyRequestEvent req } else { result = queryService.getTodayNews(limit, cursor); } - + return buildPaginatedResponse(result, getUserId(request)); } - + /** * 오늘의 뉴스 조회 * GET /news/today?limit=10&cursor=xxx @@ -121,14 +120,14 @@ private APIGatewayProxyResponseEvent getNewsList(APIGatewayProxyRequestEvent req private APIGatewayProxyResponseEvent getTodayNews(APIGatewayProxyRequestEvent request) { Map params = request.getQueryStringParameters(); if (params == null) params = new HashMap<>(); - + String cursor = params.get("cursor"); int limit = parseLimit(params.get("limit")); - + PaginatedResult result = queryService.getTodayNews(limit, cursor); return buildPaginatedResponse(result, getUserId(request)); } - + /** * 내 레벨 맞춤 뉴스 추천 * GET /news/recommended?limit=10&cursor=xxx @@ -136,33 +135,33 @@ private APIGatewayProxyResponseEvent getTodayNews(APIGatewayProxyRequestEvent re private APIGatewayProxyResponseEvent getRecommendedNews(APIGatewayProxyRequestEvent request) { Map params = request.getQueryStringParameters(); if (params == null) params = new HashMap<>(); - + // 사용자 레벨 조회 (Cognito 토큰에서) String userLevel = getUserLevel(request); String cursor = params.get("cursor"); int limit = parseLimit(params.get("limit")); - + PaginatedResult result = queryService.getRecommendedNews(userLevel, limit, cursor); return buildPaginatedResponse(result, getUserId(request)); } - + /** * 뉴스 상세 조회 * GET /news/{articleId} */ private APIGatewayProxyResponseEvent getNewsDetail(APIGatewayProxyRequestEvent request) { String articleId = request.getPathParameters().get("articleId"); - + Optional article = queryService.getArticle(articleId); if (article.isEmpty()) { return ResponseGenerator.fail(NewsErrorCode.ARTICLE_NOT_FOUND); } - + // 로그인한 사용자의 경우 북마크/읽기 상태 추가 String userId = getUserId(request); Map response = new HashMap<>(); response.put("article", article.get()); - + if (userId != null) { response.put("isBookmarked", learningService.isBookmarked(userId, articleId)); response.put("isRead", learningService.hasRead(userId, articleId)); @@ -170,24 +169,24 @@ private APIGatewayProxyResponseEvent getNewsDetail(APIGatewayProxyRequestEvent r response.put("isBookmarked", false); response.put("isRead", false); } - + return ResponseGenerator.ok("뉴스 조회 성공", response); } - + /** * 페이지네이션 응답 생성 */ private APIGatewayProxyResponseEvent buildPaginatedResponse(PaginatedResult result) { return buildPaginatedResponse(result, null); } - + /** * 페이지네이션 응답 생성 (북마크 상태 포함) */ private APIGatewayProxyResponseEvent buildPaginatedResponse(PaginatedResult result, String userId) { List> articlesWithStatus = new java.util.ArrayList<>(); java.util.Set bookmarkedIds = java.util.Collections.emptySet(); - + // 로그인한 사용자의 경우 북마크 상태 조회 if (userId != null && !result.items().isEmpty()) { List articleIds = result.items().stream() @@ -195,7 +194,7 @@ private APIGatewayProxyResponseEvent buildPaginatedResponse(PaginatedResult articleWithStatus = new HashMap<>(); articleWithStatus.put("articleId", article.getArticleId()); @@ -213,16 +212,16 @@ private APIGatewayProxyResponseEvent buildPaginatedResponse(PaginatedResult response = new HashMap<>(); response.put("articles", articlesWithStatus); response.put("nextCursor", result.nextCursor()); response.put("hasMore", result.hasMore()); response.put("count", result.items().size()); - + return ResponseGenerator.ok("뉴스 목록 조회 성공", response); } - + /** * limit 파싱 */ @@ -235,7 +234,7 @@ private int parseLimit(String limitStr) { return DEFAULT_LIMIT; } } - + /** * 사용자 레벨 조회 */ @@ -243,7 +242,7 @@ private String getUserLevel(APIGatewayProxyRequestEvent request) { return CognitoUtil.extractClaim(request, "custom:level") .orElse("INTERMEDIATE"); } - + /** * 사용자 ID 추출 */ @@ -251,7 +250,7 @@ private String getUserId(APIGatewayProxyRequestEvent request) { return CognitoUtil.extractClaim(request, "sub") .orElse(null); } - + /** * 뉴스 학습 통계 조회 * GET /news/stats @@ -261,11 +260,11 @@ private APIGatewayProxyResponseEvent getNewsStats(APIGatewayProxyRequestEvent re if (userId == null) { return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); } - + Map stats = learningService.getUserStats(userId); return ResponseGenerator.ok("뉴스 학습 통계 조회 성공", stats); } - + /** * 북마크 목록 조회 * GET /news/bookmarks?limit=10 @@ -275,20 +274,20 @@ private APIGatewayProxyResponseEvent getBookmarks(APIGatewayProxyRequestEvent re if (userId == null) { return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); } - + Map params = request.getQueryStringParameters(); if (params == null) params = new HashMap<>(); - + int limit = parseLimit(params.get("limit")); List> bookmarks = learningService.getUserBookmarks(userId, limit); - + Map response = new HashMap<>(); response.put("bookmarks", bookmarks); response.put("count", bookmarks.size()); - + return ResponseGenerator.ok("북마크 목록 조회 성공", response); } - + /** * 뉴스 읽기 완료 기록 * POST /news/{articleId}/read @@ -298,13 +297,13 @@ private APIGatewayProxyResponseEvent markAsRead(APIGatewayProxyRequestEvent requ if (userId == null) { return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); } - + String articleId = request.getPathParameters().get("articleId"); learningService.markAsRead(userId, articleId); - + return ResponseGenerator.ok("읽기 완료 기록 성공", Map.of("articleId", articleId)); } - + /** * 북마크 토글 * POST /news/{articleId}/bookmark @@ -314,34 +313,34 @@ private APIGatewayProxyResponseEvent toggleBookmark(APIGatewayProxyRequestEvent if (userId == null) { return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); } - + String articleId = request.getPathParameters().get("articleId"); boolean isBookmarked = learningService.toggleBookmark(userId, articleId); - + return ResponseGenerator.ok( isBookmarked ? "북마크 추가 성공" : "북마크 해제 성공", Map.of("articleId", articleId, "bookmarked", isBookmarked) ); } - + /** * 뉴스 TTS 오디오 URL 조회 * GET /news/{articleId}/audio?voice=Joanna */ private APIGatewayProxyResponseEvent getAudio(APIGatewayProxyRequestEvent request) { String articleId = request.getPathParameters().get("articleId"); - + Map params = request.getQueryStringParameters(); String voice = (params != null) ? params.getOrDefault("voice", "Joanna") : "Joanna"; - + String audioUrl = learningService.getAudioUrl(articleId, voice); if (audioUrl == null) { return ResponseGenerator.fail(NewsErrorCode.ARTICLE_NOT_FOUND); } - + return ResponseGenerator.ok("TTS 오디오 URL 조회 성공", Map.of("audioUrl", audioUrl)); } - + /** * 퀴즈 조회 * GET /news/{articleId}/quiz @@ -351,17 +350,17 @@ private APIGatewayProxyResponseEvent getQuiz(APIGatewayProxyRequestEvent request if (userId == null) { return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); } - + String articleId = request.getPathParameters().get("articleId"); Optional quizData = quizService.getQuiz(articleId, userId); - + if (quizData.isEmpty()) { return ResponseGenerator.fail(NewsErrorCode.QUIZ_NOT_FOUND); } - + return ResponseGenerator.ok("퀴즈 조회 성공", quizData.get()); } - + /** * 퀴즈 제출 * POST /news/{articleId}/quiz @@ -371,14 +370,14 @@ private APIGatewayProxyResponseEvent submitQuiz(APIGatewayProxyRequestEvent requ if (userId == null) { return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); } - + String articleId = request.getPathParameters().get("articleId"); - + // 요청 바디 파싱 JsonObject body = gson.fromJson(request.getBody(), JsonObject.class); JsonArray answersArray = body.getAsJsonArray("answers"); Integer timeTaken = body.has("timeTaken") ? body.get("timeTaken").getAsInt() : null; - + List answers = new java.util.ArrayList<>(); if (answersArray != null) { answersArray.forEach(e -> { @@ -389,16 +388,16 @@ private APIGatewayProxyResponseEvent submitQuiz(APIGatewayProxyRequestEvent requ )); }); } - + NewsQuizService.QuizSubmitResult result = quizService.submitQuiz(userId, articleId, answers, timeTaken); - + if (result == null) { return ResponseGenerator.fail(NewsErrorCode.QUIZ_ALREADY_SUBMITTED); } - + return ResponseGenerator.ok("퀴즈 제출 성공", result); } - + /** * 퀴즈 기록 조회 * GET /news/quiz/history?limit=10 @@ -408,22 +407,22 @@ private APIGatewayProxyResponseEvent getQuizHistory(APIGatewayProxyRequestEvent if (userId == null) { return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); } - + Map params = request.getQueryStringParameters(); if (params == null) params = new HashMap<>(); - + int limit = parseLimit(params.get("limit")); List history = quizService.getUserQuizHistory(userId, limit); Map quizStats = quizService.getUserQuizStats(userId); - + Map response = new HashMap<>(); response.put("history", history); response.put("stats", quizStats); response.put("count", history.size()); - + return ResponseGenerator.ok("퀴즈 기록 조회 성공", response); } - + /** * 수집 단어 목록 조회 * GET /news/words?limit=10 @@ -433,22 +432,22 @@ private APIGatewayProxyResponseEvent getUserWords(APIGatewayProxyRequestEvent re if (userId == null) { return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); } - + Map params = request.getQueryStringParameters(); if (params == null) params = new HashMap<>(); - + int limit = parseLimit(params.get("limit")); List words = wordService.getUserWords(userId, limit); Map stats = wordService.getUserWordStats(userId); - + Map response = new HashMap<>(); response.put("words", words); response.put("stats", stats); response.put("count", words.size()); - + return ResponseGenerator.ok("수집 단어 목록 조회 성공", response); } - + /** * 단어 수집 * POST /news/{articleId}/words @@ -458,22 +457,22 @@ private APIGatewayProxyResponseEvent collectWord(APIGatewayProxyRequestEvent req if (userId == null) { return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); } - + String articleId = request.getPathParameters().get("articleId"); - + JsonObject body = gson.fromJson(request.getBody(), JsonObject.class); String word = body.get("word").getAsString(); String context = body.has("context") ? body.get("context").getAsString() : ""; - + NewsWordCollect collected = wordService.collectWord(userId, articleId, word, context); - + if (collected == null) { return ResponseGenerator.fail(NewsErrorCode.WORD_ALREADY_COLLECTED); } - + return ResponseGenerator.ok("단어 수집 성공", collected); } - + /** * 단어 삭제 * DELETE /news/{articleId}/words/{word} @@ -483,31 +482,31 @@ private APIGatewayProxyResponseEvent deleteWord(APIGatewayProxyRequestEvent requ if (userId == null) { return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); } - + String articleId = request.getPathParameters().get("articleId"); String word = request.getPathParameters().get("word"); - + wordService.deleteWord(userId, word, articleId); - + return ResponseGenerator.ok("단어 삭제 성공", Map.of("word", word)); } - + /** * 단어 상세 정보 조회 * GET /news/{articleId}/words/{word} */ private APIGatewayProxyResponseEvent getWordDetail(APIGatewayProxyRequestEvent request) { String word = request.getPathParameters().get("word"); - + Optional detail = wordService.getWordDetail(word); - + if (detail.isEmpty()) { return ResponseGenerator.fail(NewsErrorCode.WORD_NOT_COLLECTED); } - + return ResponseGenerator.ok("단어 상세 조회 성공", detail.get()); } - + /** * 단어 Vocabulary 연동 * POST /news/words/{word}/sync @@ -517,18 +516,18 @@ private APIGatewayProxyResponseEvent syncWordToVocab(APIGatewayProxyRequestEvent if (userId == null) { return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); } - + String word = request.getPathParameters().get("word"); - + JsonObject body = gson.fromJson(request.getBody(), JsonObject.class); String articleId = body.get("articleId").getAsString(); - + boolean synced = wordService.syncToVocabulary(userId, word, articleId); - + if (!synced) { return ResponseGenerator.fail(NewsErrorCode.WORD_NOT_COLLECTED); } - + return ResponseGenerator.ok("Vocabulary 연동 성공", Map.of("word", word, "synced", true)); } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/KeywordInfo.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/KeywordInfo.java index 948328ff..c4ad3708 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/KeywordInfo.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/KeywordInfo.java @@ -16,7 +16,7 @@ @AllArgsConstructor @DynamoDbBean public class KeywordInfo { - + private String word; // 영어 단어 private String meaning; // 영어 뜻 (간단한 정의) private String meaningKo; // 한국어 뜻 diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsArticle.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsArticle.java index 13fd8a19..3f0537f3 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsArticle.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsArticle.java @@ -21,14 +21,14 @@ @AllArgsConstructor @DynamoDbBean public class NewsArticle { - + private String pk; // NEWS#{date} private String sk; // ARTICLE#{articleId} private String gsi1pk; // LEVEL#{level} private String gsi1sk; // {publishedAt} private String gsi2pk; // CATEGORY#{category} private String gsi2sk; // {publishedAt} - + // 기본 정보 private String articleId; private String title; @@ -36,54 +36,54 @@ public class NewsArticle { private String originalUrl; // 원문 링크 private String source; // BBC, VOA, NPR, NewsAPI private String imageUrl; // 썸네일 이미지 - + // 분류 private String category; // TECH, BUSINESS, SPORTS 등 private String level; // BEGINNER, INTERMEDIATE, ADVANCED private String cefrLevel; // A1, A2, B1, B2, C1, C2 (원본 CEFR 레벨) - + // AI 분석 결과 private List keywords; // 핵심 단어 정보 private List highlightWords; // 사용자 레벨 대비 어려운 단어 private List quiz; // 퀴즈 문제 (5개) - + // 메타데이터 private String publishedAt; // 원본 발행일 private String collectedAt; // 수집일 private Long readCount; // 조회수 private Long commentCount; // 댓글수 private Long ttl; - + @DynamoDbPartitionKey @DynamoDbAttribute("PK") public String getPk() { return pk; } - + @DynamoDbSortKey @DynamoDbAttribute("SK") public String getSk() { return sk; } - + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") @DynamoDbAttribute("GSI1PK") public String getGsi1pk() { return gsi1pk; } - + @DynamoDbSecondarySortKey(indexNames = "GSI1") @DynamoDbAttribute("GSI1SK") public String getGsi1sk() { return gsi1sk; } - + @DynamoDbSecondaryPartitionKey(indexNames = "GSI2") @DynamoDbAttribute("GSI2PK") public String getGsi2pk() { return gsi2pk; } - + @DynamoDbSecondarySortKey(indexNames = "GSI2") @DynamoDbAttribute("GSI2SK") public String getGsi2sk() { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsQuizResult.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsQuizResult.java index 23b47f62..c2aaaae9 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsQuizResult.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsQuizResult.java @@ -19,12 +19,12 @@ @AllArgsConstructor @DynamoDbBean public class NewsQuizResult { - + private String pk; // USER#{userId}#NEWS private String sk; // QUIZ#{articleId} private String gsi1pk; // USER_NEWS_STAT#{userId} private String gsi1sk; // {date}#QUIZ - + private String userId; private String articleId; private String articleTitle; @@ -36,25 +36,25 @@ public class NewsQuizResult { private Integer timeTaken; // 소요 시간 (초) private String submittedAt; private Long ttl; - + @DynamoDbPartitionKey @DynamoDbAttribute("PK") public String getPk() { return pk; } - + @DynamoDbSortKey @DynamoDbAttribute("SK") public String getSk() { return sk; } - + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") @DynamoDbAttribute("GSI1PK") public String getGsi1pk() { return gsi1pk; } - + @DynamoDbSecondarySortKey(indexNames = "GSI1") @DynamoDbAttribute("GSI1SK") public String getGsi1sk() { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsWordCollect.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsWordCollect.java index 59f4fa93..227e90e3 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsWordCollect.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsWordCollect.java @@ -18,12 +18,12 @@ @AllArgsConstructor @DynamoDbBean public class NewsWordCollect { - + private String pk; // USER#{userId}#NEWS private String sk; // WORD#{word}#{articleId} private String gsi1pk; // USER#{userId}#NEWS_WORDS private String gsi1sk; // {collectedAt} - + private String userId; private String word; private String meaning; @@ -35,25 +35,25 @@ public class NewsWordCollect { private Boolean syncedToVocab; // Vocabulary 연동 여부 private String vocabUserWordId; // 연동된 UserWord ID private Long ttl; - + @DynamoDbPartitionKey @DynamoDbAttribute("PK") public String getPk() { return pk; } - + @DynamoDbSortKey @DynamoDbAttribute("SK") public String getSk() { return sk; } - + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") @DynamoDbAttribute("GSI1PK") public String getGsi1pk() { return gsi1pk; } - + @DynamoDbSecondarySortKey(indexNames = "GSI1") @DynamoDbAttribute("GSI1SK") public String getGsi1sk() { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/QuizAnswerResult.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/QuizAnswerResult.java index 3dee95b6..7340216f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/QuizAnswerResult.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/QuizAnswerResult.java @@ -15,7 +15,7 @@ @AllArgsConstructor @DynamoDbBean public class QuizAnswerResult { - + private String questionId; private String type; // COMPREHENSION, WORD_MATCH, FILL_BLANK private String userAnswer; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/QuizQuestion.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/QuizQuestion.java index 6657ef33..1f6dab4f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/QuizQuestion.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/QuizQuestion.java @@ -17,7 +17,7 @@ @AllArgsConstructor @DynamoDbBean public class QuizQuestion { - + private String questionId; // 문제 ID (q1, q2, ...) private String type; // COMPREHENSION, WORD_MATCH, FILL_BLANK private String question; // 문제 내용 diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/UserNewsRecord.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/UserNewsRecord.java index eedfa8c5..b0622e00 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/UserNewsRecord.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/UserNewsRecord.java @@ -17,12 +17,12 @@ @AllArgsConstructor @DynamoDbBean public class UserNewsRecord { - + private String pk; // USER_NEWS#{userId} private String sk; // READ#{articleId} 또는 BOOKMARK#{articleId} private String gsi1pk; // USER_NEWS_STAT#{userId} private String gsi1sk; // {date}#{type} - + private String userId; private String articleId; private String type; // READ, BOOKMARK @@ -31,25 +31,25 @@ public class UserNewsRecord { private String articleCategory; private String createdAt; private Long ttl; - + @DynamoDbPartitionKey @DynamoDbAttribute("PK") public String getPk() { return pk; } - + @DynamoDbSortKey @DynamoDbAttribute("SK") public String getSk() { return sk; } - + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") @DynamoDbAttribute("GSI1PK") public String getGsi1pk() { return gsi1pk; } - + @DynamoDbSecondarySortKey(indexNames = "GSI1") @DynamoDbAttribute("GSI1SK") public String getGsi1sk() { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsArticleRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsArticleRepository.java index 28ca35cc..4f4ec3ae 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsArticleRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsArticleRepository.java @@ -9,7 +9,10 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import software.amazon.awssdk.enhanced.dynamodb.*; -import software.amazon.awssdk.enhanced.dynamodb.model.*; +import software.amazon.awssdk.enhanced.dynamodb.model.Page; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; +import software.amazon.awssdk.enhanced.dynamodb.model.ScanEnhancedRequest; import software.amazon.awssdk.services.dynamodb.model.AttributeValue; import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest; @@ -22,20 +25,20 @@ * 뉴스 기사 Repository */ public class NewsArticleRepository { - + private static final Logger logger = LoggerFactory.getLogger(NewsArticleRepository.class); private static final String TABLE_NAME = EnvConfig.getRequired("NEWS_TABLE_NAME"); - + private final DynamoDbEnhancedClient enhancedClient; private final DynamoDbTable table; - + /** * 기본 생성자 (Lambda에서 사용) */ public NewsArticleRepository() { this(AwsClients.dynamoDbEnhanced()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ @@ -43,7 +46,7 @@ public NewsArticleRepository(DynamoDbEnhancedClient enhancedClient) { this.enhancedClient = enhancedClient; this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(NewsArticle.class)); } - + /** * 뉴스 기사 저장 */ @@ -52,7 +55,7 @@ public NewsArticle save(NewsArticle article) { table.putItem(article); return article; } - + /** * 뉴스 기사 조회 (날짜 + 기사ID) */ @@ -61,11 +64,11 @@ public Optional findByDateAndId(String date, String articleId) { .partitionValue(NewsKey.newsPk(date)) .sortValue(NewsKey.articleSk(articleId)) .build(); - + NewsArticle article = table.getItem(key); return Optional.ofNullable(article); } - + /** * 뉴스 기사 조회 (기사ID만으로 - GSI 활용 또는 Scan) * 참고: 실제로는 articleId로 date를 알 수 있도록 설계하거나 GSI 추가 필요 @@ -75,12 +78,12 @@ public Optional findById(String articleId) { .expression("articleId = :articleId") .putExpressionValue(":articleId", AttributeValue.builder().s(articleId).build()) .build(); - + ScanEnhancedRequest request = ScanEnhancedRequest.builder() .filterExpression(filterExpression) .limit(1) .build(); - + for (Page page : table.scan(request)) { List items = page.items(); if (!items.isEmpty()) { @@ -89,7 +92,7 @@ public Optional findById(String articleId) { } return Optional.empty(); } - + /** * 뉴스 기사 삭제 */ @@ -98,88 +101,88 @@ public void delete(String date, String articleId) { .partitionValue(NewsKey.newsPk(date)) .sortValue(NewsKey.articleSk(articleId)) .build(); - + table.deleteItem(key); logger.info("Deleted news article: {}", articleId); } - + /** * 날짜별 뉴스 기사 조회 (페이지네이션) */ public PaginatedResult findByDate(String date, int limit, String cursor) { QueryConditional queryConditional = QueryConditional .keyEqualTo(Key.builder().partitionValue(NewsKey.newsPk(date)).build()); - + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() .queryConditional(queryConditional) .scanIndexForward(false) // 최신순 (SK 역순) .limit(limit); - + if (cursor != null && !cursor.isEmpty()) { Map exclusiveStartKey = CursorUtil.decode(cursor); if (exclusiveStartKey != null) { requestBuilder.exclusiveStartKey(exclusiveStartKey); } } - + Page page = table.query(requestBuilder.build()).iterator().next(); String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); - + return new PaginatedResult<>(page.items(), nextCursor); } - + /** * 레벨별 뉴스 기사 조회 (GSI1 - 최신순) */ public PaginatedResult findByLevel(String level, int limit, String cursor) { QueryConditional queryConditional = QueryConditional .keyEqualTo(Key.builder().partitionValue(NewsKey.levelPk(level.toUpperCase())).build()); - + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() .queryConditional(queryConditional) .scanIndexForward(false) // 최신순 .limit(limit); - + if (cursor != null && !cursor.isEmpty()) { Map exclusiveStartKey = CursorUtil.decode(cursor); if (exclusiveStartKey != null) { requestBuilder.exclusiveStartKey(exclusiveStartKey); } } - + DynamoDbIndex gsi1 = table.index("GSI1"); Page page = gsi1.query(requestBuilder.build()).iterator().next(); String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); - + return new PaginatedResult<>(page.items(), nextCursor); } - + /** * 카테고리별 뉴스 기사 조회 (GSI2 - 최신순) */ public PaginatedResult findByCategory(String category, int limit, String cursor) { QueryConditional queryConditional = QueryConditional .keyEqualTo(Key.builder().partitionValue(NewsKey.categoryPk(category.toUpperCase())).build()); - + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() .queryConditional(queryConditional) .scanIndexForward(false) // 최신순 .limit(limit); - + if (cursor != null && !cursor.isEmpty()) { Map exclusiveStartKey = CursorUtil.decode(cursor); if (exclusiveStartKey != null) { requestBuilder.exclusiveStartKey(exclusiveStartKey); } } - + DynamoDbIndex gsi2 = table.index("GSI2"); Page page = gsi2.query(requestBuilder.build()).iterator().next(); String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); - + return new PaginatedResult<>(page.items(), nextCursor); } - + /** * 레벨 + 카테고리 필터 조회 (GSI1 쿼리 후 필터) */ @@ -188,27 +191,27 @@ public PaginatedResult findByLevelAndCategory(String level, String .expression("category = :category") .putExpressionValue(":category", AttributeValue.builder().s(category.toUpperCase()).build()) .build(); - + QueryConditional queryConditional = QueryConditional .keyEqualTo(Key.builder().partitionValue(NewsKey.levelPk(level.toUpperCase())).build()); - + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() .queryConditional(queryConditional) .filterExpression(filterExpression) .scanIndexForward(false) .limit(limit * 2); // 필터 적용되므로 넉넉히 - + if (cursor != null && !cursor.isEmpty()) { Map exclusiveStartKey = CursorUtil.decode(cursor); if (exclusiveStartKey != null) { requestBuilder.exclusiveStartKey(exclusiveStartKey); } } - + DynamoDbIndex gsi1 = table.index("GSI1"); List results = new ArrayList<>(); Map lastKey = null; - + for (Page page : gsi1.query(requestBuilder.build())) { for (NewsArticle article : page.items()) { results.add(article); @@ -217,11 +220,11 @@ public PaginatedResult findByLevelAndCategory(String level, String lastKey = page.lastEvaluatedKey(); if (results.size() >= limit) break; } - + String nextCursor = results.size() >= limit ? CursorUtil.encode(lastKey) : null; return new PaginatedResult<>(results.subList(0, Math.min(results.size(), limit)), nextCursor); } - + /** * 조회수 증가 (Atomic Update) */ @@ -230,23 +233,23 @@ public void incrementReadCount(String date, String articleId) { "PK", AttributeValue.builder().s(NewsKey.newsPk(date)).build(), "SK", AttributeValue.builder().s(NewsKey.articleSk(articleId)).build() ); - + Map values = Map.of( ":zero", AttributeValue.builder().n("0").build(), ":inc", AttributeValue.builder().n("1").build() ); - + UpdateItemRequest request = UpdateItemRequest.builder() .tableName(TABLE_NAME) .key(key) .updateExpression("SET readCount = if_not_exists(readCount, :zero) + :inc") .expressionAttributeValues(values) .build(); - + AwsClients.dynamoDb().updateItem(request); logger.debug("Incremented read count for article: {}", articleId); } - + /** * 댓글수 증가 (Atomic Update) */ @@ -255,22 +258,22 @@ public void incrementCommentCount(String date, String articleId) { "PK", AttributeValue.builder().s(NewsKey.newsPk(date)).build(), "SK", AttributeValue.builder().s(NewsKey.articleSk(articleId)).build() ); - + Map values = Map.of( ":zero", AttributeValue.builder().n("0").build(), ":inc", AttributeValue.builder().n("1").build() ); - + UpdateItemRequest request = UpdateItemRequest.builder() .tableName(TABLE_NAME) .key(key) .updateExpression("SET commentCount = if_not_exists(commentCount, :zero) + :inc") .expressionAttributeValues(values) .build(); - + AwsClients.dynamoDb().updateItem(request); } - + /** * 댓글수 감소 (Atomic Update) */ @@ -279,19 +282,19 @@ public void decrementCommentCount(String date, String articleId) { "PK", AttributeValue.builder().s(NewsKey.newsPk(date)).build(), "SK", AttributeValue.builder().s(NewsKey.articleSk(articleId)).build() ); - + Map values = Map.of( ":one", AttributeValue.builder().n("1").build(), ":dec", AttributeValue.builder().n("1").build() ); - + UpdateItemRequest request = UpdateItemRequest.builder() .tableName(TABLE_NAME) .key(key) .updateExpression("SET commentCount = if_not_exists(commentCount, :one) - :dec") .expressionAttributeValues(values) .build(); - + AwsClients.dynamoDb().updateItem(request); } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsQuizRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsQuizRepository.java index b2786f99..d772ee5a 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsQuizRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsQuizRepository.java @@ -6,8 +6,13 @@ import com.mzc.secondproject.serverless.domain.news.model.NewsQuizResult; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import software.amazon.awssdk.enhanced.dynamodb.*; -import software.amazon.awssdk.enhanced.dynamodb.model.*; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; +import software.amazon.awssdk.enhanced.dynamodb.Key; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.model.Page; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; import java.util.ArrayList; import java.util.List; @@ -17,20 +22,20 @@ * 뉴스 퀴즈 결과 Repository */ public class NewsQuizRepository { - + private static final Logger logger = LoggerFactory.getLogger(NewsQuizRepository.class); private static final String TABLE_NAME = EnvConfig.getRequired("NEWS_TABLE_NAME"); - + private final DynamoDbTable table; - + public NewsQuizRepository() { this(AwsClients.dynamoDbEnhanced()); } - + public NewsQuizRepository(DynamoDbEnhancedClient enhancedClient) { this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(NewsQuizResult.class)); } - + /** * 퀴즈 결과 저장 */ @@ -39,7 +44,7 @@ public void save(NewsQuizResult result) { logger.debug("퀴즈 결과 저장: userId={}, articleId={}, score={}", result.getUserId(), result.getArticleId(), result.getScore()); } - + /** * 퀴즈 결과 조회 */ @@ -48,18 +53,18 @@ public Optional findByUserAndArticle(String userId, String artic .partitionValue(NewsKey.userNewsPk(userId)) .sortValue(NewsKey.quizSk(articleId)) .build(); - + NewsQuizResult result = table.getItem(key); return Optional.ofNullable(result); } - + /** * 퀴즈 제출 여부 확인 */ public boolean hasSubmitted(String userId, String articleId) { return findByUserAndArticle(userId, articleId).isPresent(); } - + /** * 사용자 퀴즈 결과 목록 조회 */ @@ -70,22 +75,22 @@ public List getUserQuizResults(String userId, int limit) { .sortValue("QUIZ#") .build() ); - + QueryEnhancedRequest request = QueryEnhancedRequest.builder() .queryConditional(queryConditional) .scanIndexForward(false) .limit(limit) .build(); - + List results = new ArrayList<>(); for (Page page : table.query(request)) { results.addAll(page.items()); if (results.size() >= limit) break; } - + return results.subList(0, Math.min(results.size(), limit)); } - + /** * 사용자 퀴즈 통계 조회 */ @@ -96,11 +101,11 @@ public QuizStats getUserQuizStats(String userId) { .sortValue("QUIZ#") .build() ); - + int totalQuizzes = 0; int totalScore = 0; int perfectScores = 0; - + for (Page page : table.query(queryConditional)) { for (NewsQuizResult result : page.items()) { totalQuizzes++; @@ -110,11 +115,11 @@ public QuizStats getUserQuizStats(String userId) { } } } - + int avgScore = totalQuizzes > 0 ? totalScore / totalQuizzes : 0; return new QuizStats(totalQuizzes, avgScore, perfectScores); } - + /** * 퀴즈 통계 레코드 */ @@ -122,5 +127,6 @@ public record QuizStats( int totalQuizzes, int avgScore, int perfectScores - ) {} + ) { + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsWordRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsWordRepository.java index 5dfebc80..be899b98 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsWordRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsWordRepository.java @@ -7,7 +7,9 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import software.amazon.awssdk.enhanced.dynamodb.*; -import software.amazon.awssdk.enhanced.dynamodb.model.*; +import software.amazon.awssdk.enhanced.dynamodb.model.Page; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; import java.util.ArrayList; import java.util.List; @@ -17,22 +19,22 @@ * 뉴스 단어 수집 Repository */ public class NewsWordRepository { - + private static final Logger logger = LoggerFactory.getLogger(NewsWordRepository.class); private static final String TABLE_NAME = EnvConfig.getRequired("NEWS_TABLE_NAME"); - + private final DynamoDbTable table; private final DynamoDbIndex gsi1Index; - + public NewsWordRepository() { this(AwsClients.dynamoDbEnhanced()); } - + public NewsWordRepository(DynamoDbEnhancedClient enhancedClient) { this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(NewsWordCollect.class)); this.gsi1Index = table.index("GSI1"); } - + /** * 단어 수집 저장 */ @@ -40,7 +42,7 @@ public void save(NewsWordCollect wordCollect) { table.putItem(wordCollect); logger.debug("단어 수집 저장: userId={}, word={}", wordCollect.getUserId(), wordCollect.getWord()); } - + /** * 단어 수집 조회 */ @@ -49,18 +51,18 @@ public Optional findByUserWordArticle(String userId, String wor .partitionValue(NewsKey.userNewsPk(userId)) .sortValue(NewsKey.wordSk(word, articleId)) .build(); - + NewsWordCollect result = table.getItem(key); return Optional.ofNullable(result); } - + /** * 이미 수집했는지 확인 */ public boolean hasCollected(String userId, String word, String articleId) { return findByUserWordArticle(userId, word, articleId).isPresent(); } - + /** * 단어 수집 삭제 */ @@ -69,11 +71,11 @@ public void delete(String userId, String word, String articleId) { .partitionValue(NewsKey.userNewsPk(userId)) .sortValue(NewsKey.wordSk(word, articleId)) .build(); - + table.deleteItem(key); logger.debug("단어 수집 삭제: userId={}, word={}", userId, word); } - + /** * 사용자 수집 단어 목록 조회 (최신순) */ @@ -83,22 +85,22 @@ public List getUserWords(String userId, int limit) { .partitionValue(NewsKey.userNewsWordsPk(userId)) .build() ); - + QueryEnhancedRequest request = QueryEnhancedRequest.builder() .queryConditional(queryConditional) .scanIndexForward(false) .limit(limit) .build(); - + List results = new ArrayList<>(); for (Page page : gsi1Index.query(request)) { results.addAll(page.items()); if (results.size() >= limit) break; } - + return results.subList(0, Math.min(results.size(), limit)); } - + /** * 사용자 수집 단어 수 조회 */ @@ -109,14 +111,14 @@ public int countUserWords(String userId) { .sortValue("WORD#") .build() ); - + int count = 0; for (Page page : table.query(queryConditional)) { count += page.items().size(); } return count; } - + /** * Vocabulary 연동 상태 업데이트 */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/UserNewsRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/UserNewsRepository.java index a5fa2b67..febc8895 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/UserNewsRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/UserNewsRepository.java @@ -6,9 +6,13 @@ import com.mzc.secondproject.serverless.domain.news.model.UserNewsRecord; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import software.amazon.awssdk.enhanced.dynamodb.*; -import software.amazon.awssdk.enhanced.dynamodb.model.*; -import software.amazon.awssdk.services.dynamodb.model.AttributeValue; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; +import software.amazon.awssdk.enhanced.dynamodb.Key; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.model.Page; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; import java.time.Instant; import java.time.LocalDate; @@ -18,27 +22,27 @@ * 사용자 뉴스 학습 기록 Repository */ public class UserNewsRepository { - + private static final Logger logger = LoggerFactory.getLogger(UserNewsRepository.class); private static final String TABLE_NAME = EnvConfig.getRequired("NEWS_TABLE_NAME"); - + private final DynamoDbTable table; - + public UserNewsRepository() { this(AwsClients.dynamoDbEnhanced()); } - + public UserNewsRepository(DynamoDbEnhancedClient enhancedClient) { this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(UserNewsRecord.class)); } - + /** * 읽기 기록 저장 */ public void saveReadRecord(String userId, String articleId, String title, String level, String category) { String now = Instant.now().toString(); String today = LocalDate.now().toString(); - + UserNewsRecord record = UserNewsRecord.builder() .pk(NewsKey.userNewsPk(userId)) .sk(NewsKey.readSk(articleId)) @@ -52,18 +56,18 @@ public void saveReadRecord(String userId, String articleId, String title, String .articleCategory(category) .createdAt(now) .build(); - + table.putItem(record); logger.debug("읽기 기록 저장: userId={}, articleId={}", userId, articleId); } - + /** * 북마크 저장 */ public void saveBookmark(String userId, String articleId, String title, String level, String category) { String now = Instant.now().toString(); String today = LocalDate.now().toString(); - + UserNewsRecord record = UserNewsRecord.builder() .pk(NewsKey.userNewsPk(userId)) .sk(NewsKey.bookmarkSk(articleId)) @@ -77,11 +81,11 @@ public void saveBookmark(String userId, String articleId, String title, String l .articleCategory(category) .createdAt(now) .build(); - + table.putItem(record); logger.debug("북마크 저장: userId={}, articleId={}", userId, articleId); } - + /** * 북마크 삭제 */ @@ -90,11 +94,11 @@ public void deleteBookmark(String userId, String articleId) { .partitionValue(NewsKey.userNewsPk(userId)) .sortValue(NewsKey.bookmarkSk(articleId)) .build(); - + table.deleteItem(key); logger.debug("북마크 삭제: userId={}, articleId={}", userId, articleId); } - + /** * 북마크 여부 확인 */ @@ -103,10 +107,10 @@ public boolean isBookmarked(String userId, String articleId) { .partitionValue(NewsKey.userNewsPk(userId)) .sortValue(NewsKey.bookmarkSk(articleId)) .build(); - + return table.getItem(key) != null; } - + /** * 읽기 기록 여부 확인 */ @@ -115,10 +119,10 @@ public boolean hasRead(String userId, String articleId) { .partitionValue(NewsKey.userNewsPk(userId)) .sortValue(NewsKey.readSk(articleId)) .build(); - + return table.getItem(key) != null; } - + /** * 사용자 북마크 목록 조회 */ @@ -129,22 +133,22 @@ public List getUserBookmarks(String userId, int limit) { .sortValue("BOOKMARK#") .build() ); - + QueryEnhancedRequest request = QueryEnhancedRequest.builder() .queryConditional(queryConditional) .scanIndexForward(false) .limit(limit) .build(); - + List results = new ArrayList<>(); for (Page page : table.query(request)) { results.addAll(page.items()); if (results.size() >= limit) break; } - + return results.subList(0, Math.min(results.size(), limit)); } - + /** * 여러 기사의 북마크 여부 확인 (배치) */ @@ -157,7 +161,7 @@ public Set getBookmarkedArticleIds(String userId, List articleId } return bookmarkedIds; } - + /** * 사용자 뉴스 통계 조회 */ @@ -165,20 +169,20 @@ public NewsStats getUserStats(String userId) { QueryConditional queryConditional = QueryConditional.keyEqualTo( Key.builder().partitionValue(NewsKey.userNewsPk(userId)).build() ); - + int totalRead = 0; int thisWeekRead = 0; int totalBookmarks = 0; Map byLevel = new HashMap<>(); Map byCategory = new HashMap<>(); - + LocalDate weekAgo = LocalDate.now().minusDays(7); - + for (Page page : table.query(queryConditional)) { for (UserNewsRecord record : page.items()) { if ("READ".equals(record.getType())) { totalRead++; - + // 이번 주 읽은 것 if (record.getCreatedAt() != null) { LocalDate readDate = Instant.parse(record.getCreatedAt()) @@ -187,13 +191,13 @@ public NewsStats getUserStats(String userId) { thisWeekRead++; } } - + // 레벨별 통계 String level = record.getArticleLevel(); if (level != null) { byLevel.merge(level, 1, Integer::sum); } - + // 카테고리별 통계 String category = record.getArticleCategory(); if (category != null) { @@ -204,10 +208,10 @@ public NewsStats getUserStats(String userId) { } } } - + return new NewsStats(totalRead, thisWeekRead, totalBookmarks, byLevel, byCategory); } - + /** * 뉴스 통계 레코드 */ @@ -217,5 +221,6 @@ public record NewsStats( int totalBookmarks, Map byLevel, Map byCategory - ) {} + ) { + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java index 87da58f1..4badd15f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java @@ -13,11 +13,13 @@ import software.amazon.awssdk.core.SdkBytes; import software.amazon.awssdk.services.bedrockruntime.model.InvokeModelRequest; import software.amazon.awssdk.services.bedrockruntime.model.InvokeModelResponse; -import software.amazon.awssdk.services.comprehend.model.*; +import software.amazon.awssdk.services.comprehend.model.DetectKeyPhrasesRequest; +import software.amazon.awssdk.services.comprehend.model.DetectKeyPhrasesResponse; +import software.amazon.awssdk.services.comprehend.model.KeyPhrase; +import software.amazon.awssdk.services.comprehend.model.LanguageCode; import java.util.ArrayList; import java.util.List; -import java.util.stream.Collectors; /** * 뉴스 AI 분석 서비스 @@ -27,37 +29,37 @@ * - 퀴즈 생성 (Bedrock) */ public class NewsAnalysisService { - + private static final Logger logger = LoggerFactory.getLogger(NewsAnalysisService.class); private static final Gson gson = new Gson(); private static final String MODEL_ID = "anthropic.claude-3-haiku-20240307-v1:0"; - + private final NewsArticleRepository articleRepository; - + public NewsAnalysisService() { this.articleRepository = new NewsArticleRepository(); } - + public NewsAnalysisService(NewsArticleRepository articleRepository) { this.articleRepository = articleRepository; } - + /** * 뉴스 기사 전체 분석 */ public NewsArticle analyzeArticle(NewsArticle article) { logger.info("뉴스 분석 시작: {}", article.getArticleId()); long startTime = System.currentTimeMillis(); - + String content = article.getTitle() + ". " + (article.getSummary() != null ? article.getSummary() : ""); - + try { // 1. CEFR 난이도 분석 String cefrLevel = analyzeDifficulty(content); article.setCefrLevel(cefrLevel); article.setLevel(mapCefrToLevel(cefrLevel)); - + // 2. 3줄 요약 + 키워드 + 퀴즈 생성 (Bedrock - 한 번에 처리) AnalysisResult result = generateSummaryAndQuiz(content, cefrLevel); if (result.summary() != null) { @@ -65,7 +67,7 @@ public NewsArticle analyzeArticle(NewsArticle article) { } article.setQuiz(result.quiz()); article.setHighlightWords(result.highlightWords()); - + // Bedrock 키워드 사용 (meaningKo 포함) if (result.keywords() != null && !result.keywords().isEmpty()) { article.setKeywords(result.keywords()); @@ -74,7 +76,7 @@ public NewsArticle analyzeArticle(NewsArticle article) { List keywords = extractKeywords(content); article.setKeywords(keywords); } - + // 4. GSI 키 설정 article.setGsi1pk("LEVEL#" + article.getLevel()); article.setGsi1sk(article.getPublishedAt()); @@ -82,13 +84,13 @@ public NewsArticle analyzeArticle(NewsArticle article) { article.setGsi2pk("CATEGORY#" + article.getCategory()); article.setGsi2sk(article.getPublishedAt()); } - + // 5. 저장 articleRepository.save(article); - + long elapsed = System.currentTimeMillis() - startTime; logger.info("뉴스 분석 완료: {} ({}ms)", article.getArticleId(), elapsed); - + } catch (Exception e) { logger.error("뉴스 분석 실패: {}", article.getArticleId(), e); // 분석 실패해도 기본값으로 저장 @@ -96,10 +98,10 @@ public NewsArticle analyzeArticle(NewsArticle article) { article.setCefrLevel("B1"); articleRepository.save(article); } - + return article; } - + /** * CEFR 난이도 분석 (Bedrock) */ @@ -107,31 +109,31 @@ private String analyzeDifficulty(String content) { String systemPrompt = """ You are an English language expert. Analyze the text and determine its CEFR level. Consider vocabulary complexity, sentence structure, and topic familiarity. - + Respond with ONLY the CEFR level code: A1, A2, B1, B2, C1, or C2 No explanation, just the level code. """; - + String userPrompt = "Determine the CEFR level of this text:\n\n" + truncate(content, 1000); - + String response = invokeBedrock(systemPrompt, userPrompt); String level = response.trim().toUpperCase(); - + // 유효한 레벨인지 확인 if (List.of("A1", "A2", "B1", "B2", "C1", "C2").contains(level)) { return level; } - + // 레벨 추출 시도 for (String validLevel : List.of("C2", "C1", "B2", "B1", "A2", "A1")) { if (response.toUpperCase().contains(validLevel)) { return validLevel; } } - + return "B1"; // 기본값 } - + /** * CEFR을 3단계 레벨로 매핑 */ @@ -143,7 +145,7 @@ private String mapCefrToLevel(String cefrLevel) { default -> "INTERMEDIATE"; }; } - + /** * 핵심 단어 추출 (Comprehend) */ @@ -155,10 +157,10 @@ private List extractKeywords(String content) { .languageCode(LanguageCode.EN) .build() ); - + List keywords = new ArrayList<>(); List phrases = response.keyPhrases(); - + for (int i = 0; i < Math.min(phrases.size(), 10); i++) { KeyPhrase phrase = phrases.get(i); if (phrase.score() > 0.8) { @@ -168,22 +170,22 @@ private List extractKeywords(String content) { .build()); } } - + return keywords; - + } catch (Exception e) { logger.error("키워드 추출 실패", e); return new ArrayList<>(); } } - + /** * 요약 + 퀴즈 생성 (Bedrock) */ private AnalysisResult generateSummaryAndQuiz(String content, String cefrLevel) { String systemPrompt = """ You are an English learning assistant for Korean learners. Analyze the news article and create learning materials. - + Respond in this exact JSON format: { "summary": "3-line summary in English (each line separated by newline)", @@ -219,7 +221,7 @@ private AnalysisResult generateSummaryAndQuiz(String content, String cefrLevel) } ] } - + IMPORTANT: - keywords: Extract 5-8 important vocabulary words from the article. Include: - word: the English word @@ -230,9 +232,9 @@ private AnalysisResult generateSummaryAndQuiz(String content, String cefrLevel) - category: Choose EXACTLY ONE from: WORLD, POLITICS, BUSINESS, TECH, SCIENCE, HEALTH, SPORTS, ENTERTAINMENT, LIFESTYLE - Create exactly 3 quiz questions. - Adjust difficulty based on CEFR level: """ + cefrLevel; - + String userPrompt = "Create learning materials for this article:\n\n" + truncate(content, 1500); - + try { String response = invokeBedrock(systemPrompt, userPrompt); return parseAnalysisResult(response); @@ -241,7 +243,7 @@ private AnalysisResult generateSummaryAndQuiz(String content, String cefrLevel) return new AnalysisResult(null, new ArrayList<>(), new ArrayList<>(), new ArrayList<>()); } } - + /** * Bedrock API 호출 */ @@ -250,42 +252,42 @@ private String invokeBedrock(String systemPrompt, String userPrompt) { requestBody.addProperty("anthropic_version", "bedrock-2023-05-31"); requestBody.addProperty("max_tokens", 2000); requestBody.addProperty("system", systemPrompt); - + JsonArray messages = new JsonArray(); JsonObject userMessage = new JsonObject(); userMessage.addProperty("role", "user"); userMessage.addProperty("content", userPrompt); messages.add(userMessage); requestBody.add("messages", messages); - + InvokeModelRequest request = InvokeModelRequest.builder() .modelId(MODEL_ID) .contentType("application/json") .accept("application/json") .body(SdkBytes.fromUtf8String(gson.toJson(requestBody))) .build(); - + InvokeModelResponse response = AwsClients.bedrock().invokeModel(request); JsonObject jsonResponse = gson.fromJson(response.body().asUtf8String(), JsonObject.class); - + JsonArray contentArray = jsonResponse.getAsJsonArray("content"); if (contentArray != null && !contentArray.isEmpty()) { return contentArray.get(0).getAsJsonObject().get("text").getAsString(); } - + throw new RuntimeException("Empty response from Bedrock"); } - + /** * 분석 결과 파싱 */ private AnalysisResult parseAnalysisResult(String response) { String jsonStr = extractJson(response); JsonObject json = gson.fromJson(jsonStr, JsonObject.class); - + String summary = json.has("summary") ? json.get("summary").getAsString() : null; String category = json.has("category") ? json.get("category").getAsString().toUpperCase() : "WORLD"; - + // keywords 파싱 List keywords = new ArrayList<>(); if (json.has("keywords")) { @@ -299,12 +301,12 @@ private AnalysisResult parseAnalysisResult(String response) { .build()); }); } - + List highlightWords = new ArrayList<>(); if (json.has("highlightWords")) { json.getAsJsonArray("highlightWords").forEach(e -> highlightWords.add(e.getAsString())); } - + List quiz = new ArrayList<>(); if (json.has("quiz")) { json.getAsJsonArray("quiz").forEach(e -> { @@ -323,10 +325,10 @@ private AnalysisResult parseAnalysisResult(String response) { .build()); }); } - + return new AnalysisResult(summary, keywords, highlightWords, quiz); } - + private String extractJson(String response) { int start = response.indexOf('{'); int end = response.lastIndexOf('}'); @@ -335,12 +337,12 @@ private String extractJson(String response) { } return response; } - + private String truncate(String text, int maxLength) { if (text == null) return ""; return text.length() > maxLength ? text.substring(0, maxLength) : text; } - + /** * 분석 결과 레코드 */ @@ -349,5 +351,6 @@ private record AnalysisResult( List keywords, List highlightWords, List quiz - ) {} + ) { + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsCollectorService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsCollectorService.java index ecac47df..1709fc42 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsCollectorService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsCollectorService.java @@ -18,41 +18,41 @@ * RSS 피드에서 뉴스를 수집하고 저장 (BBC, VOA, NPR) */ public class NewsCollectorService { - + private static final Logger logger = LoggerFactory.getLogger(NewsCollectorService.class); - + private static final int RSS_LIMIT_PER_SOURCE = 7; private static final long TTL_DAYS = 30; - + private final RssFeedParser rssFeedParser; private final NewsDuplicateChecker duplicateChecker; private final NewsArticleRepository articleRepository; private final NewsAnalysisService analysisService; - + public NewsCollectorService() { this.rssFeedParser = new RssFeedParser(); this.duplicateChecker = new NewsDuplicateChecker(); this.articleRepository = new NewsArticleRepository(); this.analysisService = new NewsAnalysisService(); } - + public NewsCollectorService(RssFeedParser rssFeedParser, - NewsDuplicateChecker duplicateChecker, - NewsArticleRepository articleRepository, - NewsAnalysisService analysisService) { + NewsDuplicateChecker duplicateChecker, + NewsArticleRepository articleRepository, + NewsAnalysisService analysisService) { this.rssFeedParser = rssFeedParser; this.duplicateChecker = duplicateChecker; this.articleRepository = articleRepository; this.analysisService = analysisService; } - + /** * 뉴스 수집 실행 */ public CollectionResult collectNews() { logger.info("뉴스 수집 시작"); long startTime = System.currentTimeMillis(); - + List rssArticles; try { rssArticles = rssFeedParser.fetchAllFeeds(RSS_LIMIT_PER_SOURCE); @@ -61,16 +61,16 @@ public CollectionResult collectNews() { logger.error("RSS 수집 실패", e); return new CollectionResult(0, 0, System.currentTimeMillis() - startTime); } - + List uniqueArticles = duplicateChecker.filterDuplicates(rssArticles); logger.info("중복 제거 후 {}개 기사", uniqueArticles.size()); - + int savedCount = 0; int analyzedCount = 0; for (RawNewsArticle rawArticle : uniqueArticles) { try { NewsArticle article = convertToNewsArticle(rawArticle); - + // AI 분석 수행 (난이도, 요약, 키워드, 퀴즈) analysisService.analyzeArticle(article); analyzedCount++; @@ -79,13 +79,13 @@ public CollectionResult collectNews() { logger.error("기사 처리 실패: {}", rawArticle.getTitle(), e); } } - + long elapsed = System.currentTimeMillis() - startTime; logger.info("뉴스 수집/분석 완료 - 저장: {}, 분석: {}, 소요시간: {}ms", savedCount, analyzedCount, elapsed); - + return new CollectionResult(rssArticles.size(), savedCount, elapsed); } - + /** * RawNewsArticle을 NewsArticle로 변환 * AI 분석은 별도 Story에서 처리 @@ -94,12 +94,12 @@ private NewsArticle convertToNewsArticle(RawNewsArticle raw) { String today = LocalDate.now().toString(); String articleId = UUID.randomUUID().toString().substring(0, 8); String now = Instant.now().toString(); - + long ttlEpoch = Instant.now() .atOffset(ZoneOffset.UTC) .plusDays(TTL_DAYS) .toEpochSecond(); - + return NewsArticle.builder() .pk(NewsKey.newsPk(today)) .sk(NewsKey.articleSk(articleId)) @@ -116,7 +116,7 @@ private NewsArticle convertToNewsArticle(RawNewsArticle raw) { .ttl(ttlEpoch) .build(); } - + /** * 수집 결과 레코드 */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsDuplicateChecker.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsDuplicateChecker.java index d4eedd82..b939c7bc 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsDuplicateChecker.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsDuplicateChecker.java @@ -11,21 +11,17 @@ import software.amazon.awssdk.services.dynamodb.model.QueryResponse; import java.time.LocalDate; -import java.util.ArrayList; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; +import java.util.*; /** * 뉴스 중복 검사 서비스 * URL 기반으로 중복 뉴스 필터링 */ public class NewsDuplicateChecker { - + private static final Logger logger = LoggerFactory.getLogger(NewsDuplicateChecker.class); private static final String TABLE_NAME = EnvConfig.getRequired("NEWS_TABLE_NAME"); - + /** * 중복 뉴스 필터링 */ @@ -33,38 +29,38 @@ public List filterDuplicates(List articles) { if (articles.isEmpty()) { return articles; } - + Set existingUrls = getExistingUrls(); Set seenUrls = new HashSet<>(); List uniqueArticles = new ArrayList<>(); - + for (RawNewsArticle article : articles) { String url = article.getUrl(); if (url == null) { continue; } - + if (!existingUrls.contains(url) && !seenUrls.contains(url)) { uniqueArticles.add(article); seenUrls.add(url); } } - + int duplicateCount = articles.size() - uniqueArticles.size(); if (duplicateCount > 0) { logger.info("{}개 중복 기사 필터링됨", duplicateCount); } - + return uniqueArticles; } - + /** * 오늘 날짜의 기존 뉴스 URL 조회 */ private Set getExistingUrls() { Set urls = new HashSet<>(); String today = LocalDate.now().toString(); - + try { QueryRequest request = QueryRequest.builder() .tableName(TABLE_NAME) @@ -74,21 +70,21 @@ private Set getExistingUrls() { )) .projectionExpression("originalUrl") .build(); - + QueryResponse response = AwsClients.dynamoDb().query(request); - + for (Map item : response.items()) { if (item.containsKey("originalUrl")) { urls.add(item.get("originalUrl").s()); } } - + logger.debug("기존 뉴스 {}개 URL 로드됨", urls.size()); - + } catch (Exception e) { logger.error("기존 뉴스 URL 조회 실패", e); } - + return urls; } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java index 02930b1d..dfc5b799 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java @@ -19,16 +19,16 @@ * 뉴스 학습 부가 기능 서비스 */ public class NewsLearningService { - + private static final Logger logger = LoggerFactory.getLogger(NewsLearningService.class); private static final String BUCKET_NAME = EnvConfig.getOrDefault("NEWS_BUCKET_NAME", "group2-englishstudy"); - + private final NewsArticleRepository articleRepository; private final UserNewsRepository userNewsRepository; private final PollyService pollyService; private final UserStatsRepository userStatsRepository; private final BadgeService badgeService; - + public NewsLearningService() { this.articleRepository = new NewsArticleRepository(); this.userNewsRepository = new UserNewsRepository(); @@ -36,21 +36,22 @@ public NewsLearningService() { this.userStatsRepository = new UserStatsRepository(); this.badgeService = new BadgeService(); } - + public NewsLearningService(NewsArticleRepository articleRepository, - UserNewsRepository userNewsRepository, - PollyService pollyService, - UserStatsRepository userStatsRepository, - BadgeService badgeService) { + UserNewsRepository userNewsRepository, + PollyService pollyService, + UserStatsRepository userStatsRepository, + BadgeService badgeService) { this.articleRepository = articleRepository; this.userNewsRepository = userNewsRepository; this.pollyService = pollyService; this.userStatsRepository = userStatsRepository; this.badgeService = badgeService; } - + /** * 뉴스 읽기 완료 기록 + * * @return 새로 획득한 배지 목록 */ public List markAsRead(String userId, String articleId) { @@ -59,13 +60,13 @@ public List markAsRead(String userId, String articleId) { logger.warn("기사를 찾을 수 없음: {}", articleId); return new ArrayList<>(); } - + // 이미 읽은 기사인지 확인 (중복 조회수 증가 방지) if (userNewsRepository.hasRead(userId, articleId)) { logger.debug("이미 읽은 기사: userId={}, articleId={}", userId, articleId); return new ArrayList<>(); } - + NewsArticle a = article.get(); userNewsRepository.saveReadRecord( userId, @@ -74,15 +75,15 @@ public List markAsRead(String userId, String articleId) { a.getLevel(), a.getCategory() ); - + // 조회수 증가 (새로운 읽기만) String date = extractDateFromPk(a.getPk()); if (date != null) { articleRepository.incrementReadCount(date, articleId); } - + logger.info("읽기 완료 기록: userId={}, articleId={}", userId, articleId); - + // 통계 업데이트 및 배지 체크 List newBadges = new ArrayList<>(); try { @@ -97,16 +98,16 @@ public List markAsRead(String userId, String articleId) { } catch (Exception e) { logger.error("통계/배지 업데이트 실패: userId={}, error={}", userId, e.getMessage()); } - + return newBadges; } - + /** * 북마크 토글 */ public boolean toggleBookmark(String userId, String articleId) { boolean isBookmarked = userNewsRepository.isBookmarked(userId, articleId); - + if (isBookmarked) { userNewsRepository.deleteBookmark(userId, articleId); logger.info("북마크 해제: userId={}, articleId={}", userId, articleId); @@ -117,7 +118,7 @@ public boolean toggleBookmark(String userId, String articleId) { logger.warn("기사를 찾을 수 없음: {}", articleId); return false; } - + NewsArticle a = article.get(); userNewsRepository.saveBookmark( userId, @@ -130,35 +131,35 @@ public boolean toggleBookmark(String userId, String articleId) { return true; } } - + /** * 북마크 여부 확인 */ public boolean isBookmarked(String userId, String articleId) { return userNewsRepository.isBookmarked(userId, articleId); } - + /** * 읽기 여부 확인 */ public boolean hasRead(String userId, String articleId) { return userNewsRepository.hasRead(userId, articleId); } - + /** * 여러 기사의 북마크 여부 확인 (배치) */ public Set getBookmarkedArticleIds(String userId, List articleIds) { return userNewsRepository.getBookmarkedArticleIds(userId, articleIds); } - + /** * 사용자 북마크 목록 조회 (기사 정보 포함) */ public List> getUserBookmarks(String userId, int limit) { List bookmarks = userNewsRepository.getUserBookmarks(userId, limit); List> result = new ArrayList<>(); - + for (UserNewsRecord bookmark : bookmarks) { Optional articleOpt = articleRepository.findById(bookmark.getArticleId()); if (articleOpt.isPresent()) { @@ -180,7 +181,7 @@ public List> getUserBookmarks(String userId, int limit) { } return result; } - + /** * 뉴스 TTS 오디오 URL 생성 */ @@ -190,25 +191,25 @@ public String getAudioUrl(String articleId, String voice) { logger.warn("기사를 찾을 수 없음: {}", articleId); return null; } - + NewsArticle a = article.get(); String text = a.getTitle() + ". " + (a.getSummary() != null ? a.getSummary() : ""); - + // 텍스트가 너무 길면 제한 if (text.length() > 3000) { text = text.substring(0, 3000); } - + PollyService.VoiceSynthesisResult result = pollyService.synthesizeSpeech(articleId, text, voice); return result.getAudioUrl(); } - + /** * 사용자 뉴스 학습 통계 조회 */ public Map getUserStats(String userId) { UserNewsRepository.NewsStats stats = userNewsRepository.getUserStats(userId); - + return Map.of( "totalRead", stats.totalRead(), "thisWeekRead", stats.thisWeekRead(), @@ -217,7 +218,7 @@ public Map getUserStats(String userId) { "byCategory", stats.byCategory() ); } - + /** * PK에서 날짜 추출 */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQueryService.java index 5a3930ed..7f25e408 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQueryService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQueryService.java @@ -13,26 +13,26 @@ * 뉴스 조회 서비스 */ public class NewsQueryService { - + private static final Logger logger = LoggerFactory.getLogger(NewsQueryService.class); - + private final NewsArticleRepository articleRepository; - + public NewsQueryService() { this.articleRepository = new NewsArticleRepository(); } - + public NewsQueryService(NewsArticleRepository articleRepository) { this.articleRepository = articleRepository; } - + /** * 뉴스 상세 조회 */ public Optional getArticle(String articleId) { logger.debug("뉴스 상세 조회: {}", articleId); Optional article = articleRepository.findById(articleId); - + // 조회수 증가 article.ifPresent(a -> { String date = extractDateFromPk(a.getPk()); @@ -40,10 +40,10 @@ public Optional getArticle(String articleId) { articleRepository.incrementReadCount(date, articleId); } }); - + return article; } - + /** * 오늘의 뉴스 목록 조회 */ @@ -52,7 +52,7 @@ public PaginatedResult getTodayNews(int limit, String cursor) { logger.debug("오늘의 뉴스 조회: date={}, limit={}", today, limit); return articleRepository.findByDate(today, limit, cursor); } - + /** * 레벨별 뉴스 조회 */ @@ -60,7 +60,7 @@ public PaginatedResult getNewsByLevel(String level, int limit, Stri logger.debug("레벨별 뉴스 조회: level={}, limit={}", level, limit); return articleRepository.findByLevel(level, limit, cursor); } - + /** * 카테고리별 뉴스 조회 */ @@ -68,7 +68,7 @@ public PaginatedResult getNewsByCategory(String category, int limit logger.debug("카테고리별 뉴스 조회: category={}, limit={}", category, limit); return articleRepository.findByCategory(category, limit, cursor); } - + /** * 레벨 + 카테고리 복합 필터 조회 */ @@ -76,7 +76,7 @@ public PaginatedResult getNewsByLevelAndCategory(String level, Stri logger.debug("레벨+카테고리 뉴스 조회: level={}, category={}, limit={}", level, category, limit); return articleRepository.findByLevelAndCategory(level, category, limit, cursor); } - + /** * 사용자 레벨 맞춤 뉴스 추천 */ @@ -85,7 +85,7 @@ public PaginatedResult getRecommendedNews(String userLevel, int lim // 사용자 레벨에 맞는 뉴스 조회 return articleRepository.findByLevel(userLevel, limit, cursor); } - + /** * PK에서 날짜 추출 (NEWS#2024-01-15 → 2024-01-15) */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQuizService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQuizService.java index bb22fc90..31c768c1 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQuizService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQuizService.java @@ -1,7 +1,10 @@ package com.mzc.secondproject.serverless.domain.news.service; import com.mzc.secondproject.serverless.domain.news.constants.NewsKey; -import com.mzc.secondproject.serverless.domain.news.model.*; +import com.mzc.secondproject.serverless.domain.news.model.NewsArticle; +import com.mzc.secondproject.serverless.domain.news.model.NewsQuizResult; +import com.mzc.secondproject.serverless.domain.news.model.QuizAnswerResult; +import com.mzc.secondproject.serverless.domain.news.model.QuizQuestion; import com.mzc.secondproject.serverless.domain.news.repository.NewsArticleRepository; import com.mzc.secondproject.serverless.domain.news.repository.NewsQuizRepository; import org.slf4j.Logger; @@ -15,22 +18,22 @@ * 뉴스 퀴즈 서비스 */ public class NewsQuizService { - + private static final Logger logger = LoggerFactory.getLogger(NewsQuizService.class); - + private final NewsArticleRepository articleRepository; private final NewsQuizRepository quizRepository; - + public NewsQuizService() { this.articleRepository = new NewsArticleRepository(); this.quizRepository = new NewsQuizRepository(); } - + public NewsQuizService(NewsArticleRepository articleRepository, NewsQuizRepository quizRepository) { this.articleRepository = articleRepository; this.quizRepository = quizRepository; } - + /** * 퀴즈 조회 */ @@ -40,18 +43,18 @@ public Optional getQuiz(String articleId, String userId) { logger.warn("기사를 찾을 수 없음: {}", articleId); return Optional.empty(); } - + NewsArticle article = articleOpt.get(); List questions = article.getQuiz(); - + if (questions == null || questions.isEmpty()) { logger.warn("퀴즈가 없는 기사: {}", articleId); return Optional.empty(); } - + // 이미 제출했는지 확인 boolean submitted = quizRepository.hasSubmitted(userId, articleId); - + // 정답 제거한 퀴즈 반환 List questionViews = questions.stream() .map(q -> QuizQuestionView.builder() @@ -62,7 +65,7 @@ public Optional getQuiz(String articleId, String userId) { .points(q.getPoints()) .build()) .toList(); - + return Optional.of(QuizData.builder() .articleId(articleId) .articleTitle(article.getTitle()) @@ -72,7 +75,7 @@ public Optional getQuiz(String articleId, String userId) { .submitted(submitted) .build()); } - + /** * 퀴즈 제출 및 채점 */ @@ -82,42 +85,42 @@ public QuizSubmitResult submitQuiz(String userId, String articleId, List articleOpt = articleRepository.findById(articleId); if (articleOpt.isEmpty()) { logger.warn("기사를 찾을 수 없음: {}", articleId); return null; } - + NewsArticle article = articleOpt.get(); List questions = article.getQuiz(); - + if (questions == null || questions.isEmpty()) { logger.warn("퀴즈가 없는 기사: {}", articleId); return null; } - + // 정답 맵 생성 Map questionMap = new HashMap<>(); for (QuizQuestion q : questions) { questionMap.put(q.getQuestionId(), q); } - + // 채점 List answerResults = new ArrayList<>(); int earnedPoints = 0; int totalPoints = 0; - + for (QuizAnswer answer : answers) { QuizQuestion question = questionMap.get(answer.questionId()); if (question == null) continue; - + boolean correct = question.getCorrectAnswer().equalsIgnoreCase(answer.answer()); int points = correct ? question.getPoints() : 0; earnedPoints += points; totalPoints += question.getPoints(); - + answerResults.add(QuizAnswerResult.builder() .questionId(answer.questionId()) .type(question.getType()) @@ -127,14 +130,14 @@ public QuizSubmitResult submitQuiz(String userId, String articleId, List 0 ? (earnedPoints * 100) / totalPoints : 0; - + // 결과 저장 String now = Instant.now().toString(); String today = LocalDate.now().toString(); - + NewsQuizResult result = NewsQuizResult.builder() .pk(NewsKey.userNewsPk(userId)) .sk(NewsKey.quizSk(articleId)) @@ -151,13 +154,13 @@ public QuizSubmitResult submitQuiz(String userId, String articleId, List getQuizResult(String userId, String articleId) { return quizRepository.findByUserAndArticle(userId, articleId); } - + /** * 사용자 퀴즈 기록 목록 조회 */ public List getUserQuizHistory(String userId, int limit) { return quizRepository.getUserQuizResults(userId, limit); } - + /** * 사용자 퀴즈 통계 조회 */ @@ -192,7 +195,7 @@ public Map getUserQuizStats(String userId) { "perfectScores", stats.perfectScores() ); } - + /** * 피드백 생성 */ @@ -209,7 +212,7 @@ private String generateFeedback(int score, List results) { return "Don't give up! Focus on vocabulary and main ideas."; } } - + /** * 퀴즈 데이터 (정답 제외) */ @@ -225,7 +228,7 @@ public static class QuizData { private int totalPoints; private boolean submitted; } - + /** * 퀴즈 문제 뷰 (정답 제외) */ @@ -240,12 +243,13 @@ public static class QuizQuestionView { private List options; private int points; } - + /** * 사용자 답변 */ - public record QuizAnswer(String questionId, String answer) {} - + public record QuizAnswer(String questionId, String answer) { + } + /** * 퀴즈 제출 결과 */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsWordService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsWordService.java index edf8901b..b38ce3ca 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsWordService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsWordService.java @@ -20,31 +20,31 @@ * 뉴스 단어 수집 서비스 */ public class NewsWordService { - + private static final Logger logger = LoggerFactory.getLogger(NewsWordService.class); - + private final NewsWordRepository newsWordRepository; private final NewsArticleRepository articleRepository; private final WordRepository wordRepository; private final UserWordCommandService userWordCommandService; - + public NewsWordService() { this.newsWordRepository = new NewsWordRepository(); this.articleRepository = new NewsArticleRepository(); this.wordRepository = new WordRepository(); this.userWordCommandService = new UserWordCommandService(); } - + public NewsWordService(NewsWordRepository newsWordRepository, - NewsArticleRepository articleRepository, - WordRepository wordRepository, - UserWordCommandService userWordCommandService) { + NewsArticleRepository articleRepository, + WordRepository wordRepository, + UserWordCommandService userWordCommandService) { this.newsWordRepository = newsWordRepository; this.articleRepository = articleRepository; this.wordRepository = wordRepository; this.userWordCommandService = userWordCommandService; } - + /** * 단어 수집 (자동으로 Word 테이블 + UserWord에 추가) */ @@ -54,12 +54,12 @@ public NewsWordCollect collectWord(String userId, String articleId, String word, logger.warn("이미 수집한 단어: userId={}, word={}", userId, word); return newsWordRepository.findByUserWordArticle(userId, word, articleId).orElse(null); } - + // 기사 조회 Optional articleOpt = articleRepository.findById(articleId); String articleTitle = articleOpt.map(NewsArticle::getTitle).orElse(""); String articleLevel = articleOpt.map(NewsArticle::getLevel).orElse("INTERMEDIATE"); - + // 기사 키워드에서 단어 정보 추출 String meaningKo = ""; String meaningEn = ""; @@ -74,12 +74,12 @@ public NewsWordCollect collectWord(String userId, String articleId, String word, } } } - + // 단어 정보 조회 (Word 테이블에서) String wordId = word.toLowerCase().trim(); Optional wordOpt = wordRepository.findById(wordId); String meaning = meaningKo; - + // Word 테이블에 없으면 자동 생성 if (wordOpt.isEmpty() && !meaningKo.isEmpty()) { String now = Instant.now().toString(); @@ -103,9 +103,9 @@ public NewsWordCollect collectWord(String userId, String articleId, String word, } else if (wordOpt.isPresent()) { meaning = wordOpt.get().getKorean(); } - + String now = Instant.now().toString(); - + NewsWordCollect wordCollect = NewsWordCollect.builder() .pk(NewsKey.userNewsPk(userId)) .sk(NewsKey.wordSk(word, articleId)) @@ -122,10 +122,10 @@ public NewsWordCollect collectWord(String userId, String articleId, String word, .syncedToVocab(true) // 자동 연동됨 .vocabUserWordId(wordId) .build(); - + newsWordRepository.save(wordCollect); logger.info("단어 수집 완료: userId={}, word={}, articleId={}", userId, word, articleId); - + // UserWord에 자동 추가 (NEW 상태로) try { userWordCommandService.updateWordStatus(userId, wordId, "NEW"); @@ -133,10 +133,10 @@ public NewsWordCollect collectWord(String userId, String articleId, String word, } catch (Exception e) { logger.warn("UserWord 추가 실패 (이미 존재할 수 있음): userId={}, wordId={}, error={}", userId, wordId, e.getMessage()); } - + return wordCollect; } - + /** * 수집한 단어 삭제 */ @@ -144,32 +144,32 @@ public void deleteWord(String userId, String word, String articleId) { newsWordRepository.delete(userId, word, articleId); logger.info("단어 삭제: userId={}, word={}", userId, word); } - + /** * 사용자 수집 단어 목록 조회 */ public List getUserWords(String userId, int limit) { return newsWordRepository.getUserWords(userId, limit); } - + /** * 사용자 수집 단어 수 조회 */ public int countUserWords(String userId) { return newsWordRepository.countUserWords(userId); } - + /** * 단어 상세 정보 조회 */ public Optional getWordDetail(String word) { String wordId = word.toLowerCase().trim(); Optional wordOpt = wordRepository.findById(wordId); - + if (wordOpt.isEmpty()) { return Optional.empty(); } - + Word w = wordOpt.get(); return Optional.of(WordDetail.builder() .word(w.getEnglish()) @@ -179,7 +179,7 @@ public Optional getWordDetail(String word) { .level(w.getLevel()) .build()); } - + /** * Vocabulary 도메인으로 단어 연동 */ @@ -189,34 +189,34 @@ public boolean syncToVocabulary(String userId, String word, String articleId) { logger.warn("수집한 단어를 찾을 수 없음: userId={}, word={}", userId, word); return false; } - + NewsWordCollect wordCollect = wordOpt.get(); - + // 이미 연동됐는지 확인 if (Boolean.TRUE.equals(wordCollect.getSyncedToVocab())) { logger.info("이미 Vocabulary에 연동됨: userId={}, word={}", userId, word); return true; } - + // Word 테이블에서 단어 조회 String wordId = word.toLowerCase().trim(); Optional vocabWord = wordRepository.findById(wordId); - + if (vocabWord.isEmpty()) { logger.warn("Vocabulary에 없는 단어: {}", word); return false; } - + // UserWord 생성 (NEW 상태로) userWordCommandService.updateWordStatus(userId, wordId, "NEW"); - + // 연동 상태 업데이트 newsWordRepository.updateSyncStatus(userId, word, articleId, wordId); - + logger.info("Vocabulary 연동 완료: userId={}, word={}", userId, word); return true; } - + /** * 사용자 단어 수집 통계 */ @@ -226,14 +226,14 @@ public Map getUserWordStats(String userId) { long syncedCount = recentWords.stream() .filter(w -> Boolean.TRUE.equals(w.getSyncedToVocab())) .count(); - + return Map.of( "totalCollected", totalWords, "recentWords", recentWords, "syncedToVocab", syncedCount ); } - + /** * 단어 상세 정보 */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/RssFeedParser.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/RssFeedParser.java index ca2c98b8..bc7facd1 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/RssFeedParser.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/RssFeedParser.java @@ -24,34 +24,34 @@ * BBC, VOA, NPR 등의 RSS 피드에서 뉴스 수집 */ public class RssFeedParser { - + private static final Logger logger = LoggerFactory.getLogger(RssFeedParser.class); - + private static final Map RSS_FEEDS = Map.of( "BBC", "https://feeds.bbci.co.uk/news/world/rss.xml", "VOA", "https://www.voanews.com/api/ziqpoe-mqm", "NPR", "https://feeds.npr.org/1001/rss.xml" ); - + private final HttpClient httpClient; - + public RssFeedParser() { this.httpClient = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(10)) .followRedirects(HttpClient.Redirect.NORMAL) .build(); } - + /** * 모든 RSS 피드에서 뉴스 수집 */ public List fetchAllFeeds(int maxPerSource) { List allArticles = new ArrayList<>(); - + for (Map.Entry entry : RSS_FEEDS.entrySet()) { String source = entry.getKey(); String feedUrl = entry.getValue(); - + try { List articles = fetchFeed(feedUrl, source, maxPerSource); allArticles.addAll(articles); @@ -60,16 +60,16 @@ public List fetchAllFeeds(int maxPerSource) { logger.error("{} RSS 피드 수집 실패: {}", source, e.getMessage()); } } - + return allArticles; } - + /** * 특정 RSS 피드에서 뉴스 수집 */ public List fetchFeed(String feedUrl, String source, int maxItems) { List articles = new ArrayList<>(); - + try { HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(feedUrl)) @@ -77,22 +77,22 @@ public List fetchFeed(String feedUrl, String source, int maxItem .timeout(Duration.ofSeconds(30)) .GET() .build(); - + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream()); - + if (response.statusCode() != 200) { logger.error("RSS 피드 요청 실패 - url: {}, status: {}", feedUrl, response.statusCode()); return articles; } - + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); DocumentBuilder builder = factory.newDocumentBuilder(); Document document = builder.parse(response.body()); - + NodeList items = document.getElementsByTagName("item"); int count = Math.min(items.getLength(), maxItems); - + for (int i = 0; i < count; i++) { Element item = (Element) items.item(i); RawNewsArticle article = parseRssItem(item, source); @@ -100,14 +100,14 @@ public List fetchFeed(String feedUrl, String source, int maxItem articles.add(article); } } - + } catch (Exception e) { logger.error("RSS 피드 파싱 중 오류 발생 - url: {}", feedUrl, e); } - + return articles; } - + /** * RSS item 요소를 RawNewsArticle로 변환 */ @@ -121,7 +121,7 @@ private RawNewsArticle parseRssItem(Element item, String source) { .publishedAt(parsePublishedDate(getElementText(item, "pubDate"))) .build(); } - + /** * 요소에서 텍스트 추출 */ @@ -132,7 +132,7 @@ private String getElementText(Element parent, String tagName) { } return null; } - + /** * 이미지 URL 추출 (media:content, enclosure 등) */ @@ -142,7 +142,7 @@ private String extractImageUrl(Element item) { Element media = (Element) mediaContent.item(0); return media.getAttribute("url"); } - + NodeList enclosure = item.getElementsByTagName("enclosure"); if (enclosure.getLength() > 0) { Element enc = (Element) enclosure.item(0); @@ -151,16 +151,16 @@ private String extractImageUrl(Element item) { return enc.getAttribute("url"); } } - + NodeList mediaThumbnail = item.getElementsByTagName("media:thumbnail"); if (mediaThumbnail.getLength() > 0) { Element thumbnail = (Element) mediaThumbnail.item(0); return thumbnail.getAttribute("url"); } - + return null; } - + /** * RSS pubDate를 ISO 8601 형식으로 변환 */ @@ -170,7 +170,7 @@ private String parsePublishedDate(String pubDate) { } return pubDate; } - + /** * HTML 태그 제거 */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/dto/request/ResetRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/dto/request/ResetRequest.java index 8f600c66..3152beaf 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/dto/request/ResetRequest.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/dto/request/ResetRequest.java @@ -4,9 +4,9 @@ * 대화 초기화 요청 DTO */ public record ResetRequest( - String sessionId + String sessionId ) { - public boolean isValid() { - return sessionId != null && !sessionId.isEmpty(); - } -} \ No newline at end of file + public boolean isValid() { + return sessionId != null && !sessionId.isEmpty(); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/dto/request/SpeakingRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/dto/request/SpeakingRequest.java index 58ad78c1..f75ec1bb 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/dto/request/SpeakingRequest.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/dto/request/SpeakingRequest.java @@ -4,29 +4,29 @@ * Speaking API 요청 DTO */ public record SpeakingRequest( - String sessionId, // 세션 ID (첫 요청 시 null) - String audio, // 음성 데이터 (base64) - String text, // 텍스트 입력 - String level // 레벨 (BEGINNER, INTERMEDIATE, ADVANCED) + String sessionId, // 세션 ID (첫 요청 시 null) + String audio, // 음성 데이터 (base64) + String text, // 텍스트 입력 + String level // 레벨 (BEGINNER, INTERMEDIATE, ADVANCED) ) { - /** - * 기본값 적용된 레벨 반환 - */ - public String getLevelOrDefault() { - return level != null && !level.isEmpty() ? level : "INTERMEDIATE"; - } - - /** - * 음성 입력인지 확인 - */ - public boolean hasAudio() { - return audio != null && !audio.isEmpty(); - } - - /** - * 텍스트 입력인지 확인 - */ - public boolean hasText() { - return text != null && !text.trim().isEmpty(); - } -} \ No newline at end of file + /** + * 기본값 적용된 레벨 반환 + */ + public String getLevelOrDefault() { + return level != null && !level.isEmpty() ? level : "INTERMEDIATE"; + } + + /** + * 음성 입력인지 확인 + */ + public boolean hasAudio() { + return audio != null && !audio.isEmpty(); + } + + /** + * 텍스트 입력인지 확인 + */ + public boolean hasText() { + return text != null && !text.trim().isEmpty(); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/dto/response/SpeakingResponse.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/dto/response/SpeakingResponse.java index 49d714dd..21cec696 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/dto/response/SpeakingResponse.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/dto/response/SpeakingResponse.java @@ -4,9 +4,10 @@ * Speaking API 응답 DTO */ public record SpeakingResponse( - String sessionId, // 세션 ID (다음 요청에 사용) - String userTranscript, // 사용자가 말한 내용 (STT 결과) - String aiText, // AI 응답 텍스트 - String aiAudioUrl, // AI 응답 음성 URL (Polly) - double confidence // STT 신뢰도 -) {} \ No newline at end of file + String sessionId, // 세션 ID (다음 요청에 사용) + String userTranscript, // 사용자가 말한 내용 (STT 결과) + String aiText, // AI 응답 텍스트 + String aiAudioUrl, // AI 응답 음성 URL (Polly) + double confidence // STT 신뢰도 +) { +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingHandler.java index c4166ed8..ed6fbda0 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingHandler.java @@ -19,142 +19,142 @@ /** * Speaking API 핸들러 - * + *

* POST /api/speaking/chat - 대화 (음성 또는 텍스트) * POST /api/speaking/reset - 대화 초기화 */ public class SpeakingHandler implements RequestHandler { - - private static final Logger logger = LoggerFactory.getLogger(SpeakingHandler.class); - private static final Gson gson = new GsonBuilder().create(); - - private static final Map CORS_HEADERS = Map.of( - "Content-Type", "application/json", - "Access-Control-Allow-Origin", "*", - "Access-Control-Allow-Headers", "Content-Type,Authorization", - "Access-Control-Allow-Methods", "POST,OPTIONS" - ); - - private final SpeakingService speakingService; - - public SpeakingHandler() { - this.speakingService = new SpeakingService(); - } - - @Override - public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent event, Context context) { - logger.info("Speaking API request received"); - - // OPTIONS 요청 처리 (CORS preflight) - if ("OPTIONS".equalsIgnoreCase(event.getHttpMethod())) { - return response(200, Map.of("message", "OK")); - } - - try { - // JWT 토큰 검증 - String authHeader = event.getHeaders().get("Authorization"); - if (authHeader == null || !authHeader.startsWith("Bearer ")) { - return response(401, Map.of("error", "Authorization header is required")); - } - - String token = authHeader.substring(7); - if (!JwtUtil.isValid(token)) { - return response(401, Map.of("error", "Invalid or expired token")); - } - - Optional userIdOpt = JwtUtil.extractUserId(token); - if (userIdOpt.isEmpty()) { - return response(401, Map.of("error", "Invalid token")); - } - - String userId = userIdOpt.get(); - String path = event.getPath(); - String body = event.getBody(); - - logger.info("Processing request: path={}, userId={}", path, userId); - - // 라우팅 - if (path.endsWith("/chat")) { - return handleChat(userId, body); - } else if (path.endsWith("/reset")) { - return handleReset(userId, body); - } else { - return response(404, Map.of("error", "Not found")); - } - - } catch (Exception e) { - logger.error("Error processing request: {}", e.getMessage(), e); - return response(500, Map.of("error", "Internal server error: " + e.getMessage())); - } - } - - /** - * 대화 처리 (음성 또는 텍스트) - */ - private APIGatewayProxyResponseEvent handleChat(String userId, String body) { - if (body == null || body.isEmpty()) { - return response(400, Map.of("error", "Request body is required")); - } - - JsonObject request = JsonParser.parseString(body).getAsJsonObject(); - - String sessionId = request.has("sessionId") ? request.get("sessionId").getAsString() : null; - String level = request.has("level") ? request.get("level").getAsString() : "INTERMEDIATE"; - String audio = request.has("audio") ? request.get("audio").getAsString() : null; - String text = request.has("text") ? request.get("text").getAsString() : null; - - SpeakingResponse result; - - if (audio != null && !audio.isEmpty()) { - // 음성 입력 처리 - logger.info("Processing voice input"); - result = speakingService.processVoiceInput(sessionId, userId, audio, level); - } else if (text != null && !text.trim().isEmpty()) { - // 텍스트 입력 처리 - logger.info("Processing text input: {}", text); - result = speakingService.processTextInput(sessionId, userId, text.trim(), level); - } else { - return response(400, Map.of("error", "Either 'audio' or 'text' is required")); - } - - return response(200, Map.of( - "sessionId", result.sessionId(), - "userTranscript", result.userTranscript(), - "aiText", result.aiText(), - "aiAudioUrl", result.aiAudioUrl(), - "confidence", result.confidence() - )); - } - - /** - * 대화 초기화 - */ - private APIGatewayProxyResponseEvent handleReset(String userId, String body) { - if (body == null || body.isEmpty()) { - return response(400, Map.of("error", "Request body is required")); - } - - JsonObject request = JsonParser.parseString(body).getAsJsonObject(); - String sessionId = request.has("sessionId") ? request.get("sessionId").getAsString() : null; - - if (sessionId == null || sessionId.isEmpty()) { - return response(400, Map.of("error", "sessionId is required")); - } - - speakingService.resetConversation(sessionId); - - return response(200, Map.of( - "message", "Conversation reset successfully", - "sessionId", sessionId - )); - } - - private APIGatewayProxyResponseEvent response(int statusCode, Map body) { - return new APIGatewayProxyResponseEvent() - .withStatusCode(statusCode) - .withHeaders(CORS_HEADERS) - .withBody(gson.toJson(body)); - } - - + + private static final Logger logger = LoggerFactory.getLogger(SpeakingHandler.class); + private static final Gson gson = new GsonBuilder().create(); + + private static final Map CORS_HEADERS = Map.of( + "Content-Type", "application/json", + "Access-Control-Allow-Origin", "*", + "Access-Control-Allow-Headers", "Content-Type,Authorization", + "Access-Control-Allow-Methods", "POST,OPTIONS" + ); + + private final SpeakingService speakingService; + + public SpeakingHandler() { + this.speakingService = new SpeakingService(); + } + + @Override + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent event, Context context) { + logger.info("Speaking API request received"); + + // OPTIONS 요청 처리 (CORS preflight) + if ("OPTIONS".equalsIgnoreCase(event.getHttpMethod())) { + return response(200, Map.of("message", "OK")); + } + + try { + // JWT 토큰 검증 + String authHeader = event.getHeaders().get("Authorization"); + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + return response(401, Map.of("error", "Authorization header is required")); + } + + String token = authHeader.substring(7); + if (!JwtUtil.isValid(token)) { + return response(401, Map.of("error", "Invalid or expired token")); + } + + Optional userIdOpt = JwtUtil.extractUserId(token); + if (userIdOpt.isEmpty()) { + return response(401, Map.of("error", "Invalid token")); + } + + String userId = userIdOpt.get(); + String path = event.getPath(); + String body = event.getBody(); + + logger.info("Processing request: path={}, userId={}", path, userId); + + // 라우팅 + if (path.endsWith("/chat")) { + return handleChat(userId, body); + } else if (path.endsWith("/reset")) { + return handleReset(userId, body); + } else { + return response(404, Map.of("error", "Not found")); + } + + } catch (Exception e) { + logger.error("Error processing request: {}", e.getMessage(), e); + return response(500, Map.of("error", "Internal server error: " + e.getMessage())); + } + } + + /** + * 대화 처리 (음성 또는 텍스트) + */ + private APIGatewayProxyResponseEvent handleChat(String userId, String body) { + if (body == null || body.isEmpty()) { + return response(400, Map.of("error", "Request body is required")); + } + + JsonObject request = JsonParser.parseString(body).getAsJsonObject(); + + String sessionId = request.has("sessionId") ? request.get("sessionId").getAsString() : null; + String level = request.has("level") ? request.get("level").getAsString() : "INTERMEDIATE"; + String audio = request.has("audio") ? request.get("audio").getAsString() : null; + String text = request.has("text") ? request.get("text").getAsString() : null; + + SpeakingResponse result; + + if (audio != null && !audio.isEmpty()) { + // 음성 입력 처리 + logger.info("Processing voice input"); + result = speakingService.processVoiceInput(sessionId, userId, audio, level); + } else if (text != null && !text.trim().isEmpty()) { + // 텍스트 입력 처리 + logger.info("Processing text input: {}", text); + result = speakingService.processTextInput(sessionId, userId, text.trim(), level); + } else { + return response(400, Map.of("error", "Either 'audio' or 'text' is required")); + } + + return response(200, Map.of( + "sessionId", result.sessionId(), + "userTranscript", result.userTranscript(), + "aiText", result.aiText(), + "aiAudioUrl", result.aiAudioUrl(), + "confidence", result.confidence() + )); + } + + /** + * 대화 초기화 + */ + private APIGatewayProxyResponseEvent handleReset(String userId, String body) { + if (body == null || body.isEmpty()) { + return response(400, Map.of("error", "Request body is required")); + } + + JsonObject request = JsonParser.parseString(body).getAsJsonObject(); + String sessionId = request.has("sessionId") ? request.get("sessionId").getAsString() : null; + + if (sessionId == null || sessionId.isEmpty()) { + return response(400, Map.of("error", "sessionId is required")); + } + + speakingService.resetConversation(sessionId); + + return response(200, Map.of( + "message", "Conversation reset successfully", + "sessionId", sessionId + )); + } + + private APIGatewayProxyResponseEvent response(int statusCode, Map body) { + return new APIGatewayProxyResponseEvent() + .withStatusCode(statusCode) + .withHeaders(CORS_HEADERS) + .withBody(gson.toJson(body)); + } + + } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/SpeakingSession.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/SpeakingSession.java index 07956b2f..a0712d4b 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/SpeakingSession.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/SpeakingSession.java @@ -16,81 +16,81 @@ @AllArgsConstructor @DynamoDbBean public class SpeakingSession { - - // DynamoDB Key Prefixes - public static final String PK_PREFIX = "SPEAKING_SESSION#"; - public static final String SK_METADATA = "METADATA"; - public static final String GSI1PK_PREFIX = "SPEAKING_USER#"; - public static final String GSI1SK_PREFIX = "SESSION#"; - - private String pk; // SPEAKING_SESSION#{sessionId} - private String sk; // METADATA - private String gsi1pk; // SPEAKING_USER#{userId} - private String gsi1sk; // SESSION#{sessionId} - - private String sessionId; - private String userId; - private String createdAt; - private String updatedAt; - private Long ttl; // 자동 삭제용 (24시간) - - // Speaking 전용 필드 - private String conversationHistory; // 대화 히스토리 (JSON) - private String targetLevel; // 목표 레벨 (BEGINNER, INTERMEDIATE, ADVANCED) - - /** - * 세션 생성 팩토리 메서드 - */ - public static SpeakingSession create(String sessionId, String userId, String level) { - String now = java.time.Instant.now().toString(); - // 24시간 후 자동 삭제 - long ttl = java.time.Instant.now().plusSeconds(86400).getEpochSecond(); - - return SpeakingSession.builder() - .pk(PK_PREFIX + sessionId) - .sk(SK_METADATA) - .gsi1pk(GSI1PK_PREFIX + userId) - .gsi1sk(GSI1SK_PREFIX + sessionId) - .sessionId(sessionId) - .userId(userId) - .createdAt(now) - .updatedAt(now) - .ttl(ttl) - .conversationHistory("[]") - .targetLevel(level != null ? level.toUpperCase() : "INTERMEDIATE") - .build(); - } - - /** - * 업데이트 시간 갱신 - */ - public void touch() { - this.updatedAt = java.time.Instant.now().toString(); - // TTL 연장 (24시간) - this.ttl = java.time.Instant.now().plusSeconds(86400).getEpochSecond(); - } - - @DynamoDbPartitionKey - @DynamoDbAttribute("PK") - public String getPk() { - return pk; - } - - @DynamoDbSortKey - @DynamoDbAttribute("SK") - public String getSk() { - return sk; - } - - @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") - @DynamoDbAttribute("GSI1PK") - public String getGsi1pk() { - return gsi1pk; - } - - @DynamoDbSecondarySortKey(indexNames = "GSI1") - @DynamoDbAttribute("GSI1SK") - public String getGsi1sk() { - return gsi1sk; - } -} \ No newline at end of file + + // DynamoDB Key Prefixes + public static final String PK_PREFIX = "SPEAKING_SESSION#"; + public static final String SK_METADATA = "METADATA"; + public static final String GSI1PK_PREFIX = "SPEAKING_USER#"; + public static final String GSI1SK_PREFIX = "SESSION#"; + + private String pk; // SPEAKING_SESSION#{sessionId} + private String sk; // METADATA + private String gsi1pk; // SPEAKING_USER#{userId} + private String gsi1sk; // SESSION#{sessionId} + + private String sessionId; + private String userId; + private String createdAt; + private String updatedAt; + private Long ttl; // 자동 삭제용 (24시간) + + // Speaking 전용 필드 + private String conversationHistory; // 대화 히스토리 (JSON) + private String targetLevel; // 목표 레벨 (BEGINNER, INTERMEDIATE, ADVANCED) + + /** + * 세션 생성 팩토리 메서드 + */ + public static SpeakingSession create(String sessionId, String userId, String level) { + String now = java.time.Instant.now().toString(); + // 24시간 후 자동 삭제 + long ttl = java.time.Instant.now().plusSeconds(86400).getEpochSecond(); + + return SpeakingSession.builder() + .pk(PK_PREFIX + sessionId) + .sk(SK_METADATA) + .gsi1pk(GSI1PK_PREFIX + userId) + .gsi1sk(GSI1SK_PREFIX + sessionId) + .sessionId(sessionId) + .userId(userId) + .createdAt(now) + .updatedAt(now) + .ttl(ttl) + .conversationHistory("[]") + .targetLevel(level != null ? level.toUpperCase() : "INTERMEDIATE") + .build(); + } + + /** + * 업데이트 시간 갱신 + */ + public void touch() { + this.updatedAt = java.time.Instant.now().toString(); + // TTL 연장 (24시간) + this.ttl = java.time.Instant.now().plusSeconds(86400).getEpochSecond(); + } + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { + return pk; + } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { + return sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1PK") + public String getGsi1pk() { + return gsi1pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1SK") + public String getGsi1sk() { + return gsi1sk; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingSessionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingSessionRepository.java index fed1cd66..aa7acb63 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingSessionRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingSessionRepository.java @@ -15,60 +15,60 @@ * Speaking WebSocket 연결 정보 Repository */ public class SpeakingSessionRepository { - - private static final Logger logger = LoggerFactory.getLogger(SpeakingSessionRepository.class); - private static final String TABLE_NAME = EnvConfig.getRequired("CHAT_TABLE_NAME"); - - private final DynamoDbTable table; - - public SpeakingSessionRepository() { - this.table = AwsClients.dynamoDbEnhanced().table( - TABLE_NAME, - TableSchema.fromBean(SpeakingSession.class) - ); - } - - /** - * 연결 정보 저장 - */ - public void save(SpeakingSession session) { - table.putItem(session); - logger.debug("Speaking session saved: sessionId={}, userId={}", - session.getSessionId(), session.getUserId()); - } - - /** - * sessionId로 연결 정보 조회 - */ - public Optional findBySessionId(String sessionId) { - Key key = Key.builder() - .partitionValue(SpeakingSession.PK_PREFIX + sessionId) - .sortValue(SpeakingSession.SK_METADATA) - .build(); - - SpeakingSession session = table.getItem(key); - return Optional.ofNullable(session); - } - - /** - * 연결 정보 업데이트 (대화 히스토리 등) - */ - public void update(SpeakingSession session) { - session.touch(); // 업데이트 시간 및 TTL 갱신 - table.putItem(session); - logger.debug("Speaking session updated: sessionId={}", session.getSessionId()); - } - - /** - * 연결 정보 삭제 - */ - public void delete(String sessionId) { - Key key = Key.builder() - .partitionValue(SpeakingSession.PK_PREFIX + sessionId) - .sortValue(SpeakingSession.SK_METADATA) - .build(); - - table.deleteItem(key); - logger.info("Speaking session deleted: sessionId={}", sessionId); - } -} \ No newline at end of file + + private static final Logger logger = LoggerFactory.getLogger(SpeakingSessionRepository.class); + private static final String TABLE_NAME = EnvConfig.getRequired("CHAT_TABLE_NAME"); + + private final DynamoDbTable table; + + public SpeakingSessionRepository() { + this.table = AwsClients.dynamoDbEnhanced().table( + TABLE_NAME, + TableSchema.fromBean(SpeakingSession.class) + ); + } + + /** + * 연결 정보 저장 + */ + public void save(SpeakingSession session) { + table.putItem(session); + logger.debug("Speaking session saved: sessionId={}, userId={}", + session.getSessionId(), session.getUserId()); + } + + /** + * sessionId로 연결 정보 조회 + */ + public Optional findBySessionId(String sessionId) { + Key key = Key.builder() + .partitionValue(SpeakingSession.PK_PREFIX + sessionId) + .sortValue(SpeakingSession.SK_METADATA) + .build(); + + SpeakingSession session = table.getItem(key); + return Optional.ofNullable(session); + } + + /** + * 연결 정보 업데이트 (대화 히스토리 등) + */ + public void update(SpeakingSession session) { + session.touch(); // 업데이트 시간 및 TTL 갱신 + table.putItem(session); + logger.debug("Speaking session updated: sessionId={}", session.getSessionId()); + } + + /** + * 연결 정보 삭제 + */ + public void delete(String sessionId) { + Key key = Key.builder() + .partitionValue(SpeakingSession.PK_PREFIX + sessionId) + .sortValue(SpeakingSession.SK_METADATA) + .build(); + + table.deleteItem(key); + logger.info("Speaking session deleted: sessionId={}", sessionId); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/service/SpeakingService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/service/SpeakingService.java index 180e14dc..fb94ce14 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/service/SpeakingService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/service/SpeakingService.java @@ -8,14 +8,12 @@ import com.mzc.secondproject.serverless.domain.speaking.dto.response.SpeakingResponse; import com.mzc.secondproject.serverless.domain.speaking.model.SpeakingSession; import com.mzc.secondproject.serverless.domain.speaking.repository.SpeakingSessionRepository; - import org.slf4j.Logger; import org.slf4j.LoggerFactory; import software.amazon.awssdk.core.SdkBytes; import software.amazon.awssdk.services.bedrockruntime.model.InvokeModelRequest; import software.amazon.awssdk.services.bedrockruntime.model.InvokeModelResponse; - import java.util.ArrayList; import java.util.List; import java.util.UUID; @@ -25,253 +23,253 @@ * 음성 입력 → STT → Bedrock → TTS → 음성 출력 */ public class SpeakingService { - - private static final Logger logger = LoggerFactory.getLogger(SpeakingService.class); - private static final Gson gson = new GsonBuilder().create(); - - private static final String MODEL_ID = "anthropic.claude-3-haiku-20240307-v1:0"; - private static final int MAX_TOKENS = 500; - private static final int MAX_HISTORY_SIZE = 10; // 최근 10턴만 유지 - - private final TranscribeProxyService transcribeService; - private final PollyService pollyService; - private final SpeakingSessionRepository sessionRepository; - - /** - * 세션 생성 또는 조회 - */ - public SpeakingSession getOrCreateSession(String sessionId, String userId, String level) { - if (sessionId != null && !sessionId.isEmpty()) { - return sessionRepository.findBySessionId(sessionId) - .orElseGet(() -> createNewSession(userId, level)); - } - return createNewSession(userId, level); - } - - /** - * 새 세션 생성 - */ - private SpeakingSession createNewSession(String userId, String level) { - String newSessionId = UUID.randomUUID().toString(); - SpeakingSession session = SpeakingSession.create(newSessionId, userId, level); - sessionRepository.save(session); - logger.info("New speaking session created: sessionId={}, userId={}", newSessionId, userId); - return session; - } - - public SpeakingService() { - this.transcribeService = new TranscribeProxyService(); - this.pollyService = new PollyService( - EnvConfig.getRequired("BUCKET_NAME"), - "speaking/voice/" - ); - this.sessionRepository = new SpeakingSessionRepository(); - } - - /** - * 음성 입력 처리 (전체 플로우) - */ - public SpeakingResponse processVoiceInput(String sessionId, String userId, String audioBase64, String level) { - logger.info("Processing voice input for sessionId: {}", sessionId); - - // 세션 조회 또는 생성 - SpeakingSession session = getOrCreateSession(sessionId, userId, level); - - String targetLevel = session.getTargetLevel(); - - // STT: 음성 → 텍스트 (Transcribe Proxy 사용) - logger.info("Step 1: Transcribing audio..."); - TranscribeProxyService.TranscribeResult sttResult = transcribeService.transcribe( - audioBase64, - session.getSessionId(), - "en-US" - ); - String userText = sttResult.transcript(); - logger.info("Transcription complete: {} (confidence: {})", userText, sttResult.confidence()); - - // 대화 히스토리 로드 - List history = parseHistory(session.getConversationHistory()); - - // Bedrock: AI 응답 생성 - logger.info("Step 2: Generating AI response..."); - String aiResponse = generateAiResponse(userText, history, targetLevel); - logger.info("AI response generated: {}", aiResponse); - - // 히스토리 업데이트 (최근 N턴만 유지) - history.add(new Message("user", userText)); - history.add(new Message("assistant", aiResponse)); - if (history.size() > MAX_HISTORY_SIZE * 2) { - history = new ArrayList<>(history.subList(history.size() - MAX_HISTORY_SIZE * 2, history.size())); - } - session.setConversationHistory(toJson(history)); - sessionRepository.update(session); - - // TTS: 텍스트 → 음성 (Polly 사용) - logger.info("Step 3: Synthesizing speech..."); - String audioId = session.getSessionId() + "_" + System.currentTimeMillis(); - PollyService.VoiceSynthesisResult ttsResult = pollyService.synthesizeSpeech( - audioId, - aiResponse, - "FEMALE" - ); - logger.info("Speech synthesis complete: cached={}", ttsResult.isCached()); - - return new SpeakingResponse( - session.getSessionId(), - userText, - aiResponse, - ttsResult.getAudioUrl(), - sttResult.confidence() - ); - } - - /** - * 텍스트 입력 처리 (음성 없이 텍스트만) - */ - public SpeakingResponse processTextInput(String sessionId, String userId, String userText, String level){ - logger.info("Processing text input for sessionId: {}", sessionId); - - // 세션 조회 또는 생성 - SpeakingSession session = getOrCreateSession(sessionId, userId, level); - - // 대화 히스토리 로드 - List history = parseHistory(session.getConversationHistory()); - - // AI 응답 생성 - String aiResponse = generateAiResponse(userText, history, session.getTargetLevel()); - - // 히스토리 업데이트 - history.add(new Message("user", userText)); - history.add(new Message("assistant", aiResponse)); - if (history.size() > MAX_HISTORY_SIZE * 2) { - history = new ArrayList<>(history.subList(history.size() - MAX_HISTORY_SIZE * 2, history.size())); - } - session.setConversationHistory(toJson(history)); - sessionRepository.update(session); - - // TTS 생성 - String audioId = session.getSessionId() + "_" + System.currentTimeMillis(); - PollyService.VoiceSynthesisResult ttsResult = pollyService.synthesizeSpeech( - audioId, aiResponse, "FEMALE" - ); - - return new SpeakingResponse( - session.getSessionId(), - userText, - aiResponse, - ttsResult.getAudioUrl(), - 1.0 - ); - } - - /** - * 레벨 변경 - */ - public void updateLevel(String sessionId, String level) { - SpeakingSession session = sessionRepository.findBySessionId(sessionId) - .orElseThrow(() -> new RuntimeException("session not found: " + sessionId)); - - session.setTargetLevel(level.toUpperCase()); - sessionRepository.update(session); - logger.info("Level updated for sessionId {}: {}", sessionId, level); - } - - /** - * 대화 히스토리 초기화 - */ - public void resetConversation(String sessionId) { - SpeakingSession session = sessionRepository.findBySessionId(sessionId) - .orElseThrow(() -> new RuntimeException("session not found: " + sessionId)); - - session.setConversationHistory("[]"); - sessionRepository.update(session); - logger.info("Conversation reset for sessionId: {}", sessionId); - } - - - /** - * Bedrock Claude 호출하여 AI 응답 생성 - */ - private String generateAiResponse(String userText, List history, String targetLevel) { - String systemPrompt = buildSystemPrompt(targetLevel); - - JsonObject requestBody = new JsonObject(); - requestBody.addProperty("anthropic_version", "bedrock-2023-05-31"); - requestBody.addProperty("max_tokens", MAX_TOKENS); - requestBody.addProperty("system", systemPrompt); - - // 메시지 배열 구성 - JsonArray messages = new JsonArray(); - - // 기존 히스토리 추가 - for (Message msg : history) { - JsonObject m = new JsonObject(); - m.addProperty("role", msg.role()); - m.addProperty("content", msg.content()); - messages.add(m); - } - - // 현재 사용자 입력 추가 - JsonObject userMsg = new JsonObject(); - userMsg.addProperty("role", "user"); - userMsg.addProperty("content", userText); - messages.add(userMsg); - - requestBody.add("messages", messages); - - // Bedrock 호출 - InvokeModelResponse response = AwsClients.bedrock().invokeModel( - InvokeModelRequest.builder() - .modelId(MODEL_ID) - .contentType("application/json") - .body(SdkBytes.fromUtf8String(requestBody.toString())) - .build() - ); - - // 응답 파싱 - JsonObject result = JsonParser.parseString( - response.body().asUtf8String() - ).getAsJsonObject(); - - return result.getAsJsonArray("content") - .get(0).getAsJsonObject() - .get("text").getAsString(); - } - - /** - * 레벨별 시스템 프롬프트 생성 - */ - private String buildSystemPrompt(String targetLevel) { - String levelGuidance = switch (targetLevel.toUpperCase()) { - case "BEGINNER" -> """ + + private static final Logger logger = LoggerFactory.getLogger(SpeakingService.class); + private static final Gson gson = new GsonBuilder().create(); + + private static final String MODEL_ID = "anthropic.claude-3-haiku-20240307-v1:0"; + private static final int MAX_TOKENS = 500; + private static final int MAX_HISTORY_SIZE = 10; // 최근 10턴만 유지 + + private final TranscribeProxyService transcribeService; + private final PollyService pollyService; + private final SpeakingSessionRepository sessionRepository; + + public SpeakingService() { + this.transcribeService = new TranscribeProxyService(); + this.pollyService = new PollyService( + EnvConfig.getRequired("BUCKET_NAME"), + "speaking/voice/" + ); + this.sessionRepository = new SpeakingSessionRepository(); + } + + /** + * 세션 생성 또는 조회 + */ + public SpeakingSession getOrCreateSession(String sessionId, String userId, String level) { + if (sessionId != null && !sessionId.isEmpty()) { + return sessionRepository.findBySessionId(sessionId) + .orElseGet(() -> createNewSession(userId, level)); + } + return createNewSession(userId, level); + } + + /** + * 새 세션 생성 + */ + private SpeakingSession createNewSession(String userId, String level) { + String newSessionId = UUID.randomUUID().toString(); + SpeakingSession session = SpeakingSession.create(newSessionId, userId, level); + sessionRepository.save(session); + logger.info("New speaking session created: sessionId={}, userId={}", newSessionId, userId); + return session; + } + + /** + * 음성 입력 처리 (전체 플로우) + */ + public SpeakingResponse processVoiceInput(String sessionId, String userId, String audioBase64, String level) { + logger.info("Processing voice input for sessionId: {}", sessionId); + + // 세션 조회 또는 생성 + SpeakingSession session = getOrCreateSession(sessionId, userId, level); + + String targetLevel = session.getTargetLevel(); + + // STT: 음성 → 텍스트 (Transcribe Proxy 사용) + logger.info("Step 1: Transcribing audio..."); + TranscribeProxyService.TranscribeResult sttResult = transcribeService.transcribe( + audioBase64, + session.getSessionId(), + "en-US" + ); + String userText = sttResult.transcript(); + logger.info("Transcription complete: {} (confidence: {})", userText, sttResult.confidence()); + + // 대화 히스토리 로드 + List history = parseHistory(session.getConversationHistory()); + + // Bedrock: AI 응답 생성 + logger.info("Step 2: Generating AI response..."); + String aiResponse = generateAiResponse(userText, history, targetLevel); + logger.info("AI response generated: {}", aiResponse); + + // 히스토리 업데이트 (최근 N턴만 유지) + history.add(new Message("user", userText)); + history.add(new Message("assistant", aiResponse)); + if (history.size() > MAX_HISTORY_SIZE * 2) { + history = new ArrayList<>(history.subList(history.size() - MAX_HISTORY_SIZE * 2, history.size())); + } + session.setConversationHistory(toJson(history)); + sessionRepository.update(session); + + // TTS: 텍스트 → 음성 (Polly 사용) + logger.info("Step 3: Synthesizing speech..."); + String audioId = session.getSessionId() + "_" + System.currentTimeMillis(); + PollyService.VoiceSynthesisResult ttsResult = pollyService.synthesizeSpeech( + audioId, + aiResponse, + "FEMALE" + ); + logger.info("Speech synthesis complete: cached={}", ttsResult.isCached()); + + return new SpeakingResponse( + session.getSessionId(), + userText, + aiResponse, + ttsResult.getAudioUrl(), + sttResult.confidence() + ); + } + + /** + * 텍스트 입력 처리 (음성 없이 텍스트만) + */ + public SpeakingResponse processTextInput(String sessionId, String userId, String userText, String level) { + logger.info("Processing text input for sessionId: {}", sessionId); + + // 세션 조회 또는 생성 + SpeakingSession session = getOrCreateSession(sessionId, userId, level); + + // 대화 히스토리 로드 + List history = parseHistory(session.getConversationHistory()); + + // AI 응답 생성 + String aiResponse = generateAiResponse(userText, history, session.getTargetLevel()); + + // 히스토리 업데이트 + history.add(new Message("user", userText)); + history.add(new Message("assistant", aiResponse)); + if (history.size() > MAX_HISTORY_SIZE * 2) { + history = new ArrayList<>(history.subList(history.size() - MAX_HISTORY_SIZE * 2, history.size())); + } + session.setConversationHistory(toJson(history)); + sessionRepository.update(session); + + // TTS 생성 + String audioId = session.getSessionId() + "_" + System.currentTimeMillis(); + PollyService.VoiceSynthesisResult ttsResult = pollyService.synthesizeSpeech( + audioId, aiResponse, "FEMALE" + ); + + return new SpeakingResponse( + session.getSessionId(), + userText, + aiResponse, + ttsResult.getAudioUrl(), + 1.0 + ); + } + + /** + * 레벨 변경 + */ + public void updateLevel(String sessionId, String level) { + SpeakingSession session = sessionRepository.findBySessionId(sessionId) + .orElseThrow(() -> new RuntimeException("session not found: " + sessionId)); + + session.setTargetLevel(level.toUpperCase()); + sessionRepository.update(session); + logger.info("Level updated for sessionId {}: {}", sessionId, level); + } + + /** + * 대화 히스토리 초기화 + */ + public void resetConversation(String sessionId) { + SpeakingSession session = sessionRepository.findBySessionId(sessionId) + .orElseThrow(() -> new RuntimeException("session not found: " + sessionId)); + + session.setConversationHistory("[]"); + sessionRepository.update(session); + logger.info("Conversation reset for sessionId: {}", sessionId); + } + + + /** + * Bedrock Claude 호출하여 AI 응답 생성 + */ + private String generateAiResponse(String userText, List history, String targetLevel) { + String systemPrompt = buildSystemPrompt(targetLevel); + + JsonObject requestBody = new JsonObject(); + requestBody.addProperty("anthropic_version", "bedrock-2023-05-31"); + requestBody.addProperty("max_tokens", MAX_TOKENS); + requestBody.addProperty("system", systemPrompt); + + // 메시지 배열 구성 + JsonArray messages = new JsonArray(); + + // 기존 히스토리 추가 + for (Message msg : history) { + JsonObject m = new JsonObject(); + m.addProperty("role", msg.role()); + m.addProperty("content", msg.content()); + messages.add(m); + } + + // 현재 사용자 입력 추가 + JsonObject userMsg = new JsonObject(); + userMsg.addProperty("role", "user"); + userMsg.addProperty("content", userText); + messages.add(userMsg); + + requestBody.add("messages", messages); + + // Bedrock 호출 + InvokeModelResponse response = AwsClients.bedrock().invokeModel( + InvokeModelRequest.builder() + .modelId(MODEL_ID) + .contentType("application/json") + .body(SdkBytes.fromUtf8String(requestBody.toString())) + .build() + ); + + // 응답 파싱 + JsonObject result = JsonParser.parseString( + response.body().asUtf8String() + ).getAsJsonObject(); + + return result.getAsJsonArray("content") + .get(0).getAsJsonObject() + .get("text").getAsString(); + } + + /** + * 레벨별 시스템 프롬프트 생성 + */ + private String buildSystemPrompt(String targetLevel) { + String levelGuidance = switch (targetLevel.toUpperCase()) { + case "BEGINNER" -> """ - Use simple vocabulary and short sentences - Speak slowly and clearly - Use basic grammar structures - Provide Korean translations for difficult words in parentheses """; - case "ADVANCED" -> """ + case "ADVANCED" -> """ - Use sophisticated vocabulary and complex sentences - Include idiomatic expressions and phrasal verbs - Discuss abstract concepts naturally - Challenge the user with nuanced topics """; - default -> """ + default -> """ - Use moderate vocabulary appropriate for intermediate learners - Mix simple and compound sentences - Introduce useful expressions gradually - Balance challenge with accessibility """; - }; - - return String.format(""" + }; + + return String.format(""" You are a friendly English conversation partner for Korean learners. Your name is "Amy" and you're an American English teacher living in Seoul. - + ## Target Level: %s - + ## Level-Specific Guidelines: %s - + ## General Guidelines: - Keep responses conversational (2-4 sentences) - Be warm, encouraging, and supportive @@ -280,59 +278,60 @@ private String buildSystemPrompt(String targetLevel) { - Respond in English only (except for occasional Korean translations for difficult words) - Match the conversation topic to the user's interests - Use natural filler words occasionally (well, you know, actually) - + ## Correction Style: Instead of: "You said 'I go to store.' It should be 'I went to the store.'" Do this: "Oh, so you went to the store? That's nice! What did you buy?" - + Remember: Your goal is to make the user feel comfortable practicing English! """, targetLevel, levelGuidance); - } - - /** - * 히스토리 JSON 파싱 - */ - private List parseHistory(String historyJson) { - List history = new ArrayList<>(); - - if (historyJson == null || historyJson.isEmpty() || historyJson.equals("[]")) { - return history; - } - - try { - JsonArray array = JsonParser.parseString(historyJson).getAsJsonArray(); - for (JsonElement el : array) { - JsonObject obj = el.getAsJsonObject(); - history.add(new Message( - obj.get("role").getAsString(), - obj.get("content").getAsString() - )); - } - } catch (Exception e) { - logger.warn("Failed to parse history, starting fresh: {}", e.getMessage()); - } - - return history; - } - - - /** - * 히스토리 JSON 변환 - */ - private String toJson(List history) { - JsonArray array = new JsonArray(); - for (Message msg : history) { - JsonObject obj = new JsonObject(); - obj.addProperty("role", msg.role()); - obj.addProperty("content", msg.content()); - array.add(obj); - } - return array.toString(); - } - - /** - * 대화 메시지 (히스토리용) - */ - private record Message(String role, String content) {} - + } + + /** + * 히스토리 JSON 파싱 + */ + private List parseHistory(String historyJson) { + List history = new ArrayList<>(); + + if (historyJson == null || historyJson.isEmpty() || historyJson.equals("[]")) { + return history; + } + + try { + JsonArray array = JsonParser.parseString(historyJson).getAsJsonArray(); + for (JsonElement el : array) { + JsonObject obj = el.getAsJsonObject(); + history.add(new Message( + obj.get("role").getAsString(), + obj.get("content").getAsString() + )); + } + } catch (Exception e) { + logger.warn("Failed to parse history, starting fresh: {}", e.getMessage()); + } + + return history; + } + + + /** + * 히스토리 JSON 변환 + */ + private String toJson(List history) { + JsonArray array = new JsonArray(); + for (Message msg : history) { + JsonObject obj = new JsonObject(); + obj.addProperty("role", msg.role()); + obj.addProperty("content", msg.content()); + array.add(obj); + } + return array.toString(); + } + + /** + * 대화 메시지 (히스토리용) + */ + private record Message(String role, String content) { + } + } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java index 66edddce..40fd94d7 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java @@ -24,20 +24,20 @@ * 사용자 학습 통계 API Handler */ public class UserStatsHandler implements RequestHandler { - + private static final Logger logger = LoggerFactory.getLogger(UserStatsHandler.class); - + private final UserStatsRepository statsRepository; private final DailyStudyRepository dailyStudyRepository; private final HandlerRouter router; - + /** * 기본 생성자 (Lambda에서 사용) */ public UserStatsHandler() { this(new UserStatsRepository(), new DailyStudyRepository()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ @@ -46,7 +46,7 @@ public UserStatsHandler(UserStatsRepository statsRepository, DailyStudyRepositor this.dailyStudyRepository = dailyStudyRepository; this.router = initRouter(); } - + private HandlerRouter initRouter() { return new HandlerRouter().addRoutes( Route.getAuth("/stats/dashboard", this::getDashboardStats), @@ -57,20 +57,20 @@ private HandlerRouter initRouter() { Route.getAuth("/stats/history", this::getStatsHistory) ); } - + @Override public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { logger.info("Received request: {} {}", request.getHttpMethod(), request.getPath()); return router.route(request); } - + /** * 대시보드용 통합 통계 조회 (프론트엔드 요청 형식) * GET /stats/dashboard */ private APIGatewayProxyResponseEvent getDashboardStats(APIGatewayProxyRequestEvent request, String userId) { String today = LocalDate.now().toString(); - + // 오늘 통계 조회 Optional dailyStats = statsRepository.findDailyStats(userId, today); // 전체 통계 조회 @@ -79,9 +79,9 @@ private APIGatewayProxyResponseEvent getDashboardStats(APIGatewayProxyRequestEve PaginatedResult weekHistory = statsRepository.findRecentDailyStats(userId, 7, null); // 오늘 학습 목표 조회 Optional dailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today); - + Map response = new HashMap<>(); - + // today 섹션 Map todaySection = new HashMap<>(); if (dailyStats.isPresent()) { @@ -97,7 +97,7 @@ private APIGatewayProxyResponseEvent getDashboardStats(APIGatewayProxyRequestEve } todaySection.put("wordsTotal", dailyStudy.map(ds -> ds.getTotalWords() != null ? ds.getTotalWords() : 25).orElse(25)); response.put("today", todaySection); - + // overall 섹션 Map overallSection = new HashMap<>(); if (totalStats.isPresent()) { @@ -122,7 +122,7 @@ private APIGatewayProxyResponseEvent getDashboardStats(APIGatewayProxyRequestEve // totalStudyDays 계산 (최근 히스토리에서 실제 학습한 날 수) overallSection.put("totalStudyDays", weekHistory.items().size()); response.put("overall", overallSection); - + // weeklyProgress 섹션 List> weeklyProgress = weekHistory.items().stream() .map(stats -> { @@ -134,17 +134,17 @@ private APIGatewayProxyResponseEvent getDashboardStats(APIGatewayProxyRequestEve }) .collect(Collectors.toList()); response.put("weeklyProgress", weeklyProgress); - + // levelDistribution (현재 미구현 - 향후 추가 가능) Map levelDistribution = new HashMap<>(); levelDistribution.put("beginner", 0); levelDistribution.put("intermediate", 0); levelDistribution.put("advanced", 0); response.put("levelDistribution", levelDistribution); - + return ResponseGenerator.ok("학습 통계 조회 성공", response); } - + /** * 오늘의 통계 조회 */ @@ -152,12 +152,12 @@ private APIGatewayProxyResponseEvent getDailyStats(APIGatewayProxyRequestEvent r Map queryParams = request.getQueryStringParameters(); String date = queryParams != null && queryParams.get("date") != null ? queryParams.get("date") : LocalDate.now().toString(); - + Optional stats = statsRepository.findDailyStats(userId, date); - + return ResponseGenerator.ok("Daily stats retrieved", buildStatsResponse(stats, "DAILY", date)); } - + /** * 이번 주 통계 조회 */ @@ -165,12 +165,12 @@ private APIGatewayProxyResponseEvent getWeeklyStats(APIGatewayProxyRequestEvent Map queryParams = request.getQueryStringParameters(); String yearWeek = queryParams != null && queryParams.get("week") != null ? queryParams.get("week") : getCurrentYearWeek(); - + Optional stats = statsRepository.findWeeklyStats(userId, yearWeek); - + return ResponseGenerator.ok("Weekly stats retrieved", buildStatsResponse(stats, "WEEKLY", yearWeek)); } - + /** * 이번 달 통계 조회 */ @@ -178,20 +178,20 @@ private APIGatewayProxyResponseEvent getMonthlyStats(APIGatewayProxyRequestEvent Map queryParams = request.getQueryStringParameters(); String yearMonth = queryParams != null && queryParams.get("month") != null ? queryParams.get("month") : getCurrentYearMonth(); - + Optional stats = statsRepository.findMonthlyStats(userId, yearMonth); - + return ResponseGenerator.ok("Monthly stats retrieved", buildStatsResponse(stats, "MONTHLY", yearMonth)); } - + /** * 전체 통계 조회 */ private APIGatewayProxyResponseEvent getTotalStats(APIGatewayProxyRequestEvent request, String userId) { Optional stats = statsRepository.findTotalStats(userId); - + Map response = buildStatsResponse(stats, "TOTAL", "ALL"); - + // 전체 통계에는 streak 정보 추가 if (stats.isPresent()) { UserStats s = stats.get(); @@ -203,24 +203,24 @@ private APIGatewayProxyResponseEvent getTotalStats(APIGatewayProxyRequestEvent r response.put("longestStreak", 0); response.put("lastStudyDate", null); } - + return ResponseGenerator.ok("Total stats retrieved", response); } - + /** * 최근 일별 통계 히스토리 조회 */ private APIGatewayProxyResponseEvent getStatsHistory(APIGatewayProxyRequestEvent request, String userId) { Map queryParams = request.getQueryStringParameters(); String cursor = queryParams != null ? queryParams.get("cursor") : null; - + int limit = 7; // 기본 7일 if (queryParams != null && queryParams.get("limit") != null) { limit = Math.min(Integer.parseInt(queryParams.get("limit")), 100); } - + PaginatedResult result = statsRepository.findRecentDailyStats(userId, limit, cursor); - + // 각 날짜별 isCompleted 정보 조회 및 응답 구성 List> historyWithCompletion = result.items().stream() .map(stats -> { @@ -233,28 +233,28 @@ private APIGatewayProxyResponseEvent getStatsHistory(APIGatewayProxyRequestEvent item.put("successRate", calculateSuccessRate(stats)); item.put("newWordsLearned", stats.getNewWordsLearned() != null ? stats.getNewWordsLearned() : 0); item.put("wordsReviewed", stats.getWordsReviewed() != null ? stats.getWordsReviewed() : 0); - + // DailyStudy에서 isCompleted 조회 Optional dailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, stats.getPeriod()); item.put("isCompleted", dailyStudy.map(ds -> ds.getIsCompleted() != null && ds.getIsCompleted()).orElse(false)); - + return item; }) .collect(Collectors.toList()); - + Map response = new HashMap<>(); response.put("history", historyWithCompletion); response.put("nextCursor", result.nextCursor()); response.put("hasMore", result.hasMore()); - + return ResponseGenerator.ok("Stats history retrieved", response); } - + private Map buildStatsResponse(Optional stats, String periodType, String period) { Map response = new HashMap<>(); response.put("periodType", periodType); response.put("period", period); - + if (stats.isPresent()) { UserStats s = stats.get(); response.put("testsCompleted", s.getTestsCompleted() != null ? s.getTestsCompleted() : 0); @@ -283,16 +283,16 @@ private Map buildStatsResponse(Optional stats, String response.put("newsQuizPerfect", 0); response.put("newsWordsCollected", 0); } - + return response; } - + private double calculateSuccessRate(UserStats stats) { int correct = stats.getCorrectAnswers() != null ? stats.getCorrectAnswers() : 0; int total = stats.getQuestionsAnswered() != null ? stats.getQuestionsAnswered() : 0; return total > 0 ? (correct * 100.0 / total) : 0.0; } - + private String getCurrentYearWeek() { LocalDate now = LocalDate.now(); WeekFields weekFields = WeekFields.of(Locale.getDefault()); @@ -300,7 +300,7 @@ private String getCurrentYearWeek() { int year = now.get(weekFields.weekBasedYear()); return String.format("%d-W%02d", year, week); } - + private String getCurrentYearMonth() { LocalDate now = LocalDate.now(); return String.format("%d-%02d", now.getYear(), now.getMonthValue()); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/model/UserStats.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/model/UserStats.java index 3c268897..1955d429 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/model/UserStats.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/model/UserStats.java @@ -24,31 +24,31 @@ @AllArgsConstructor @DynamoDbBean public class UserStats { - + private String pk; // USER#{userId}#STATS private String sk; // DAILY#{date} / WEEKLY#{year}-W{week} / MONTHLY#{year}-{month} / TOTAL - + private String userId; private String periodType; // DAILY, WEEKLY, MONTHLY, TOTAL private String period; // 2026-01-13, 2026-W02, 2026-01, TOTAL - + // 테스트 통계 private Integer testsCompleted; // 완료한 테스트 수 private Integer questionsAnswered; // 답변한 문제 수 private Integer correctAnswers; // 정답 수 private Integer incorrectAnswers; // 오답 수 private Double successRate; // 정답률 - + // 학습 통계 private Integer newWordsLearned; // 새로 학습한 단어 수 private Integer wordsReviewed; // 복습한 단어 수 private Integer wordsMastered; // 마스터한 단어 수 - + // Streak (연속 학습) private Integer currentStreak; // 현재 연속 학습일 private Integer longestStreak; // 최장 연속 학습일 private String lastStudyDate; // 마지막 학습일 - + // 게임 통계 private Integer gamesPlayed; // 참여한 게임 수 private Integer gamesWon; // 1등 횟수 @@ -56,7 +56,7 @@ public class UserStats { private Integer totalGameScore; // 누적 게임 점수 private Integer quickGuesses; // 5초 내 정답 횟수 private Integer perfectDraws; // 전원 정답 유도 횟수 - + // 뉴스 통계 private Integer newsRead; // 읽은 뉴스 수 private Integer newsQuizCompleted; // 완료한 뉴스 퀴즈 수 @@ -64,17 +64,17 @@ public class UserStats { private Integer newsWordsCollected; // 뉴스에서 수집한 단어 수 private Integer newsStreak; // 뉴스 연속 읽기 일수 private String lastNewsReadDate; // 마지막 뉴스 읽은 날짜 - + // 메타데이터 private String createdAt; private String updatedAt; - + @DynamoDbPartitionKey @DynamoDbAttribute("PK") public String getPk() { return pk; } - + @DynamoDbSortKey @DynamoDbAttribute("SK") public String getSk() { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java index 89e9b385..86b90969 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java @@ -28,26 +28,26 @@ * Atomic Counter 패턴을 사용하여 Scan 없이 통계 업데이트 */ public class UserStatsRepository { - + private static final Logger logger = LoggerFactory.getLogger(UserStatsRepository.class); private static final String TABLE_NAME = EnvConfig.getRequired("VOCAB_TABLE_NAME"); - + private final DynamoDbTable table; - + /** * 기본 생성자 (Lambda에서 사용) */ public UserStatsRepository() { this(AwsClients.dynamoDbEnhanced()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ public UserStatsRepository(DynamoDbEnhancedClient enhancedClient) { this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(UserStats.class)); } - + /** * 특정 기간의 통계 조회 */ @@ -56,39 +56,39 @@ public Optional findByUserIdAndPeriod(String userId, String sk) { .partitionValue(StatsKey.userStatsPk(userId)) .sortValue(sk) .build(); - + UserStats stats = table.getItem(key); return Optional.ofNullable(stats); } - + /** * 일별 통계 조회 */ public Optional findDailyStats(String userId, String date) { return findByUserIdAndPeriod(userId, StatsKey.statsDailySk(date)); } - + /** * 주별 통계 조회 */ public Optional findWeeklyStats(String userId, String yearWeek) { return findByUserIdAndPeriod(userId, StatsKey.statsWeeklySk(yearWeek)); } - + /** * 월별 통계 조회 */ public Optional findMonthlyStats(String userId, String yearMonth) { return findByUserIdAndPeriod(userId, StatsKey.statsMonthlySk(yearMonth)); } - + /** * 전체 통계 조회 */ public Optional findTotalStats(String userId) { return findByUserIdAndPeriod(userId, StatsKey.statsTotalSk()); } - + /** * 최근 N일 일별 통계 조회 */ @@ -98,25 +98,25 @@ public PaginatedResult findRecentDailyStats(String userId, int limit, .partitionValue(StatsKey.userStatsPk(userId)) .sortValue(StatsKey.STATS_DAILY) .build()); - + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() .queryConditional(queryConditional) .scanIndexForward(false) // 최신순 .limit(limit); - + if (cursor != null && !cursor.isEmpty()) { Map exclusiveStartKey = CursorUtil.decode(cursor); if (exclusiveStartKey != null) { requestBuilder.exclusiveStartKey(exclusiveStartKey); } } - + Page page = table.query(requestBuilder.build()).iterator().next(); String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); - + return new PaginatedResult<>(page.items(), nextCursor); } - + /** * 테스트 결과 통계 Atomic 업데이트 * 일/주/월/전체 통계를 한 번에 업데이트 @@ -125,31 +125,31 @@ public void incrementTestStats(String userId, int correctAnswers, int incorrectA String today = LocalDate.now().toString(); String yearWeek = getYearWeek(); String yearMonth = getYearMonth(); - + List sortKeys = List.of( StatsKey.statsDailySk(today), StatsKey.statsWeeklySk(yearWeek), StatsKey.statsMonthlySk(yearMonth), StatsKey.statsTotalSk() ); - + String pk = StatsKey.userStatsPk(userId); String now = Instant.now().toString(); int totalQuestions = correctAnswers + incorrectAnswers; - + for (String sk : sortKeys) { updateTestStats(pk, sk, correctAnswers, incorrectAnswers, totalQuestions, now); } - + logger.info("Incremented test stats: userId={}, correct={}, incorrect={}", userId, correctAnswers, incorrectAnswers); } - + private void updateTestStats(String pk, String sk, int correct, int incorrect, int total, String now) { Map key = new HashMap<>(); key.put("PK", AttributeValue.builder().s(pk).build()); key.put("SK", AttributeValue.builder().s(sk).build()); - + Map values = new HashMap<>(); values.put(":correct", AttributeValue.builder().n(String.valueOf(correct)).build()); values.put(":incorrect", AttributeValue.builder().n(String.valueOf(incorrect)).build()); @@ -157,7 +157,7 @@ private void updateTestStats(String pk, String sk, int correct, int incorrect, i values.put(":one", AttributeValue.builder().n("1").build()); values.put(":zero", AttributeValue.builder().n("0").build()); values.put(":now", AttributeValue.builder().s(now).build()); - + String updateExpression = "SET " + "correctAnswers = if_not_exists(correctAnswers, :zero) + :correct, " + "incorrectAnswers = if_not_exists(incorrectAnswers, :zero) + :incorrect, " + @@ -165,17 +165,17 @@ private void updateTestStats(String pk, String sk, int correct, int incorrect, i "testsCompleted = if_not_exists(testsCompleted, :zero) + :one, " + "updatedAt = :now, " + "createdAt = if_not_exists(createdAt, :now)"; - + UpdateItemRequest request = UpdateItemRequest.builder() .tableName(TABLE_NAME) .key(key) .updateExpression(updateExpression) .expressionAttributeValues(values) .build(); - + AwsClients.dynamoDb().updateItem(request); } - + /** * 학습 완료 단어 수 Atomic 업데이트 */ @@ -183,52 +183,52 @@ public void incrementWordsLearned(String userId, int newWords, int reviewedWords String today = LocalDate.now().toString(); String yearWeek = getYearWeek(); String yearMonth = getYearMonth(); - + List sortKeys = List.of( StatsKey.statsDailySk(today), StatsKey.statsWeeklySk(yearWeek), StatsKey.statsMonthlySk(yearMonth), StatsKey.statsTotalSk() ); - + String pk = StatsKey.userStatsPk(userId); String now = Instant.now().toString(); - + for (String sk : sortKeys) { updateWordsLearned(pk, sk, newWords, reviewedWords, now); } - + logger.info("Incremented words learned: userId={}, new={}, reviewed={}", userId, newWords, reviewedWords); } - + private void updateWordsLearned(String pk, String sk, int newWords, int reviewedWords, String now) { Map key = new HashMap<>(); key.put("PK", AttributeValue.builder().s(pk).build()); key.put("SK", AttributeValue.builder().s(sk).build()); - + Map values = new HashMap<>(); values.put(":new", AttributeValue.builder().n(String.valueOf(newWords)).build()); values.put(":reviewed", AttributeValue.builder().n(String.valueOf(reviewedWords)).build()); values.put(":zero", AttributeValue.builder().n("0").build()); values.put(":now", AttributeValue.builder().s(now).build()); - + String updateExpression = "SET " + "newWordsLearned = if_not_exists(newWordsLearned, :zero) + :new, " + "wordsReviewed = if_not_exists(wordsReviewed, :zero) + :reviewed, " + "updatedAt = :now, " + "createdAt = if_not_exists(createdAt, :now)"; - + UpdateItemRequest request = UpdateItemRequest.builder() .tableName(TABLE_NAME) .key(key) .updateExpression(updateExpression) .expressionAttributeValues(values) .build(); - + AwsClients.dynamoDb().updateItem(request); } - + /** * Streak(연속 학습일) 업데이트 */ @@ -236,35 +236,35 @@ public void updateStreak(String userId, int currentStreak, int longestStreak, St String pk = StatsKey.userStatsPk(userId); String sk = StatsKey.statsTotalSk(); String now = Instant.now().toString(); - + Map key = new HashMap<>(); key.put("PK", AttributeValue.builder().s(pk).build()); key.put("SK", AttributeValue.builder().s(sk).build()); - + Map values = new HashMap<>(); values.put(":current", AttributeValue.builder().n(String.valueOf(currentStreak)).build()); values.put(":longest", AttributeValue.builder().n(String.valueOf(longestStreak)).build()); values.put(":lastDate", AttributeValue.builder().s(lastStudyDate).build()); values.put(":now", AttributeValue.builder().s(now).build()); - + String updateExpression = "SET " + "currentStreak = :current, " + "longestStreak = :longest, " + "lastStudyDate = :lastDate, " + "updatedAt = :now, " + "createdAt = if_not_exists(createdAt, :now)"; - + UpdateItemRequest request = UpdateItemRequest.builder() .tableName(TABLE_NAME) .key(key) .updateExpression(updateExpression) .expressionAttributeValues(values) .build(); - + AwsClients.dynamoDb().updateItem(request); logger.info("Updated streak: userId={}, current={}, longest={}", userId, currentStreak, longestStreak); } - + /** * 게임 통계 Atomic 업데이트 */ @@ -273,11 +273,11 @@ public void incrementGameStats(String userId, int gamesPlayed, int gamesWon, String pk = StatsKey.userStatsPk(userId); String sk = StatsKey.statsTotalSk(); String now = Instant.now().toString(); - + Map key = new HashMap<>(); key.put("PK", AttributeValue.builder().s(pk).build()); key.put("SK", AttributeValue.builder().s(sk).build()); - + Map values = new HashMap<>(); values.put(":gamesPlayed", AttributeValue.builder().n(String.valueOf(gamesPlayed)).build()); values.put(":gamesWon", AttributeValue.builder().n(String.valueOf(gamesWon)).build()); @@ -287,7 +287,7 @@ public void incrementGameStats(String userId, int gamesPlayed, int gamesWon, values.put(":perfectDraws", AttributeValue.builder().n(String.valueOf(perfectDraws)).build()); values.put(":zero", AttributeValue.builder().n("0").build()); values.put(":now", AttributeValue.builder().s(now).build()); - + String updateExpression = "SET " + "gamesPlayed = if_not_exists(gamesPlayed, :zero) + :gamesPlayed, " + "gamesWon = if_not_exists(gamesWon, :zero) + :gamesWon, " + @@ -297,19 +297,19 @@ public void incrementGameStats(String userId, int gamesPlayed, int gamesWon, "perfectDraws = if_not_exists(perfectDraws, :zero) + :perfectDraws, " + "updatedAt = :now, " + "createdAt = if_not_exists(createdAt, :now)"; - + UpdateItemRequest request = UpdateItemRequest.builder() .tableName(TABLE_NAME) .key(key) .updateExpression(updateExpression) .expressionAttributeValues(values) .build(); - + AwsClients.dynamoDb().updateItem(request); logger.info("Incremented game stats: userId={}, gamesPlayed={}, gamesWon={}, correctGuesses={}", userId, gamesPlayed, gamesWon, correctGuesses); } - + /** * 뉴스 읽기 통계 Atomic 업데이트 (TOTAL + DAILY) */ @@ -317,11 +317,11 @@ public UserStats incrementNewsReadStats(String userId) { String today = LocalDate.now().toString(); String pk = StatsKey.userStatsPk(userId); String now = Instant.now().toString(); - + // 먼저 현재 통계 조회 (streak 계산용) UserStats currentStats = findTotalStats(userId).orElse(null); String lastNewsReadDate = currentStats != null ? currentStats.getLastNewsReadDate() : null; - + // 연속 읽기 계산 int currentStreak = 1; if (lastNewsReadDate != null) { @@ -336,26 +336,26 @@ public UserStats incrementNewsReadStats(String userId) { } // 그 외의 경우는 streak 1로 초기화 } - + Map values = new HashMap<>(); values.put(":one", AttributeValue.builder().n("1").build()); values.put(":zero", AttributeValue.builder().n("0").build()); values.put(":streak", AttributeValue.builder().n(String.valueOf(currentStreak)).build()); values.put(":today", AttributeValue.builder().s(today).build()); values.put(":now", AttributeValue.builder().s(now).build()); - + // 1. TOTAL 통계 업데이트 Map totalKey = new HashMap<>(); totalKey.put("PK", AttributeValue.builder().s(pk).build()); totalKey.put("SK", AttributeValue.builder().s(StatsKey.statsTotalSk()).build()); - + String totalUpdateExpression = "SET " + "newsRead = if_not_exists(newsRead, :zero) + :one, " + "newsStreak = :streak, " + "lastNewsReadDate = :today, " + "updatedAt = :now, " + "createdAt = if_not_exists(createdAt, :now)"; - + UpdateItemRequest totalRequest = UpdateItemRequest.builder() .tableName(TABLE_NAME) .key(totalKey) @@ -363,39 +363,39 @@ public UserStats incrementNewsReadStats(String userId) { .expressionAttributeValues(values) .returnValues(software.amazon.awssdk.services.dynamodb.model.ReturnValue.ALL_NEW) .build(); - + AwsClients.dynamoDb().updateItem(totalRequest); - + // 2. DAILY 통계 업데이트 Map dailyKey = new HashMap<>(); dailyKey.put("PK", AttributeValue.builder().s(pk).build()); dailyKey.put("SK", AttributeValue.builder().s(StatsKey.statsDailySk(today)).build()); - + Map dailyValues = new HashMap<>(); dailyValues.put(":one", AttributeValue.builder().n("1").build()); dailyValues.put(":zero", AttributeValue.builder().n("0").build()); dailyValues.put(":now", AttributeValue.builder().s(now).build()); dailyValues.put(":today", AttributeValue.builder().s(today).build()); - + String dailyUpdateExpression = "SET " + "newsRead = if_not_exists(newsRead, :zero) + :one, " + "updatedAt = :now, " + "createdAt = if_not_exists(createdAt, :now), " + "period = if_not_exists(period, :today)"; - + UpdateItemRequest dailyRequest = UpdateItemRequest.builder() .tableName(TABLE_NAME) .key(dailyKey) .updateExpression(dailyUpdateExpression) .expressionAttributeValues(dailyValues) .build(); - + AwsClients.dynamoDb().updateItem(dailyRequest); logger.info("Incremented news read stats (TOTAL + DAILY): userId={}, streak={}", userId, currentStreak); - + return findTotalStats(userId).orElse(null); } - + /** * 뉴스 퀴즈 통계 Atomic 업데이트 (TOTAL + DAILY) */ @@ -403,24 +403,24 @@ public UserStats incrementNewsQuizStats(String userId, boolean isPerfect) { String today = LocalDate.now().toString(); String pk = StatsKey.userStatsPk(userId); String now = Instant.now().toString(); - + Map values = new HashMap<>(); values.put(":one", AttributeValue.builder().n("1").build()); values.put(":zero", AttributeValue.builder().n("0").build()); values.put(":perfect", AttributeValue.builder().n(isPerfect ? "1" : "0").build()); values.put(":now", AttributeValue.builder().s(now).build()); - + // 1. TOTAL 통계 업데이트 Map totalKey = new HashMap<>(); totalKey.put("PK", AttributeValue.builder().s(pk).build()); totalKey.put("SK", AttributeValue.builder().s(StatsKey.statsTotalSk()).build()); - + String totalUpdateExpression = "SET " + "newsQuizCompleted = if_not_exists(newsQuizCompleted, :zero) + :one, " + "newsQuizPerfect = if_not_exists(newsQuizPerfect, :zero) + :perfect, " + "updatedAt = :now, " + "createdAt = if_not_exists(createdAt, :now)"; - + UpdateItemRequest totalRequest = UpdateItemRequest.builder() .tableName(TABLE_NAME) .key(totalKey) @@ -428,41 +428,41 @@ public UserStats incrementNewsQuizStats(String userId, boolean isPerfect) { .expressionAttributeValues(values) .returnValues(software.amazon.awssdk.services.dynamodb.model.ReturnValue.ALL_NEW) .build(); - + AwsClients.dynamoDb().updateItem(totalRequest); - + // 2. DAILY 통계 업데이트 Map dailyKey = new HashMap<>(); dailyKey.put("PK", AttributeValue.builder().s(pk).build()); dailyKey.put("SK", AttributeValue.builder().s(StatsKey.statsDailySk(today)).build()); - + Map dailyValues = new HashMap<>(); dailyValues.put(":one", AttributeValue.builder().n("1").build()); dailyValues.put(":zero", AttributeValue.builder().n("0").build()); dailyValues.put(":perfect", AttributeValue.builder().n(isPerfect ? "1" : "0").build()); dailyValues.put(":now", AttributeValue.builder().s(now).build()); dailyValues.put(":today", AttributeValue.builder().s(today).build()); - + String dailyUpdateExpression = "SET " + "newsQuizCompleted = if_not_exists(newsQuizCompleted, :zero) + :one, " + "newsQuizPerfect = if_not_exists(newsQuizPerfect, :zero) + :perfect, " + "updatedAt = :now, " + "createdAt = if_not_exists(createdAt, :now), " + "period = if_not_exists(period, :today)"; - + UpdateItemRequest dailyRequest = UpdateItemRequest.builder() .tableName(TABLE_NAME) .key(dailyKey) .updateExpression(dailyUpdateExpression) .expressionAttributeValues(dailyValues) .build(); - + AwsClients.dynamoDb().updateItem(dailyRequest); logger.info("Incremented news quiz stats (TOTAL + DAILY): userId={}, isPerfect={}", userId, isPerfect); - + return findTotalStats(userId).orElse(null); } - + /** * 뉴스 단어 수집 통계 Atomic 업데이트 (TOTAL + DAILY) */ @@ -470,22 +470,22 @@ public UserStats incrementNewsWordStats(String userId, int wordCount) { String today = LocalDate.now().toString(); String pk = StatsKey.userStatsPk(userId); String now = Instant.now().toString(); - + Map values = new HashMap<>(); values.put(":count", AttributeValue.builder().n(String.valueOf(wordCount)).build()); values.put(":zero", AttributeValue.builder().n("0").build()); values.put(":now", AttributeValue.builder().s(now).build()); - + // 1. TOTAL 통계 업데이트 Map totalKey = new HashMap<>(); totalKey.put("PK", AttributeValue.builder().s(pk).build()); totalKey.put("SK", AttributeValue.builder().s(StatsKey.statsTotalSk()).build()); - + String totalUpdateExpression = "SET " + "newsWordsCollected = if_not_exists(newsWordsCollected, :zero) + :count, " + "updatedAt = :now, " + "createdAt = if_not_exists(createdAt, :now)"; - + UpdateItemRequest totalRequest = UpdateItemRequest.builder() .tableName(TABLE_NAME) .key(totalKey) @@ -493,39 +493,39 @@ public UserStats incrementNewsWordStats(String userId, int wordCount) { .expressionAttributeValues(values) .returnValues(software.amazon.awssdk.services.dynamodb.model.ReturnValue.ALL_NEW) .build(); - + AwsClients.dynamoDb().updateItem(totalRequest); - + // 2. DAILY 통계 업데이트 Map dailyKey = new HashMap<>(); dailyKey.put("PK", AttributeValue.builder().s(pk).build()); dailyKey.put("SK", AttributeValue.builder().s(StatsKey.statsDailySk(today)).build()); - + Map dailyValues = new HashMap<>(); dailyValues.put(":count", AttributeValue.builder().n(String.valueOf(wordCount)).build()); dailyValues.put(":zero", AttributeValue.builder().n("0").build()); dailyValues.put(":now", AttributeValue.builder().s(now).build()); dailyValues.put(":today", AttributeValue.builder().s(today).build()); - + String dailyUpdateExpression = "SET " + "newsWordsCollected = if_not_exists(newsWordsCollected, :zero) + :count, " + "updatedAt = :now, " + "createdAt = if_not_exists(createdAt, :now), " + "period = if_not_exists(period, :today)"; - + UpdateItemRequest dailyRequest = UpdateItemRequest.builder() .tableName(TABLE_NAME) .key(dailyKey) .updateExpression(dailyUpdateExpression) .expressionAttributeValues(dailyValues) .build(); - + AwsClients.dynamoDb().updateItem(dailyRequest); logger.info("Incremented news word stats (TOTAL + DAILY): userId={}, wordCount={}", userId, wordCount); - + return findTotalStats(userId).orElse(null); } - + /** * 현재 연도-주차 반환 (예: 2026-W02) */ @@ -536,7 +536,7 @@ private String getYearWeek() { int year = now.get(weekFields.weekBasedYear()); return String.format("%d-W%02d", year, week); } - + /** * 현재 연도-월 반환 (예: 2026-01) */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PostConfirmationHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PostConfirmationHandler.java index 74a601db..c43d41e5 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PostConfirmationHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PostConfirmationHandler.java @@ -21,18 +21,17 @@ public class PostConfirmationHandler implements RequestHandler, Map private static final Logger logger = LoggerFactory.getLogger(PreSignUpHandler.class); private static final String BUCKET_NAME = System.getenv("BUCKET_NAME"); private static final String DEFAULT_PROFILE_URL = getDefaultProfileUrl(); - + private static String getDefaultProfileUrl() { String envUrl = System.getenv("DEFAULT_PROFILE_URL"); if (envUrl != null && !envUrl.isEmpty()) { @@ -22,30 +22,30 @@ private static String getDefaultProfileUrl() { String bucket = BUCKET_NAME != null ? BUCKET_NAME : "group2-englishstudy"; return String.format("https://%s.s3.amazonaws.com/profile/default.png", bucket); } - + @Override public Map handleRequest(Map input, Context context) { - + try { @SuppressWarnings("unchecked") Map request = (Map) input.get("request"); - + @SuppressWarnings("unchecked") Map userAttributes = (Map) request.get("userAttributes"); - + String nickname = userAttributes.get("nickname"); if (nickname == null || nickname.trim().isEmpty()) { String defaultNickname = UUID.randomUUID().toString().substring(0, 6).toUpperCase() + "님"; userAttributes.put("nickname", defaultNickname); logger.info("nickname 기본값: {}", defaultNickname); } - + String level = userAttributes.get("custom:level"); if (level == null || level.trim().isEmpty()) { userAttributes.put("custom:level", "BEGINNER"); logger.info("level 선택 기본값: BEGINNER"); } - + String profileUrl = userAttributes.get("custom:profileUrl"); if (profileUrl == null || profileUrl.trim().isEmpty()) { userAttributes.put("custom:profileUrl", DEFAULT_PROFILE_URL); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/UserHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/UserHandler.java index 9ad8618f..c2f7b41c 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/UserHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/UserHandler.java @@ -60,10 +60,10 @@ private APIGatewayProxyResponseEvent getMyProfile( String userId // cognitoSub ) { User user = userService.getProfile(userId, request); - + // profileUrl을 Presigned URL로 변환 String presignedUrl = userService.getPresignedProfileUrl(user.getProfileUrl()); - + ProfileResponse response = ProfileResponse.builder() .userId(user.getCognitoSub()) .email(user.getEmail()) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/model/User.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/model/User.java index 60dc6be5..218c7eb9 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/model/User.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/model/User.java @@ -32,8 +32,8 @@ public class User { private String updatedAt; private String lastLoginAt; private Long ttl; - - + + /** * 신규 사용자 생성 * - Lazy Registration 적용: 최초 프로필 조회 시 DynamoDB에 저장 @@ -116,12 +116,12 @@ public void updateProfileUrl(String newProfileUrl) { this.profileUrl = newProfileUrl; this.updatedAt = Instant.now().toString(); } - + @DynamoDbIgnore public String getProfileUrlForResponse() { return profileUrlForResponse != null ? profileUrlForResponse : profileUrl; } - + public void updateLastLoginAt() { this.lastLoginAt = Instant.now().toString(); } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/service/UserService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/service/UserService.java index 6783c42b..4f635106 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/service/UserService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/service/UserService.java @@ -25,26 +25,23 @@ public class UserService { private static final Logger logger = LoggerFactory.getLogger(UserService.class); private static final String BUCKET_NAME = System.getenv("PROFILE_BUCKET_NAME"); private static final String DEFAULT_PROFILE_URL = getDefaultProfileUrl(); - - private static String getDefaultProfileUrl() { - String bucket = BUCKET_NAME != null ? BUCKET_NAME : "group2-englishstudy"; - return String.format("https://%s.s3.amazonaws.com/profile/default.png", bucket); - } private static final List VALID_LEVELS = Arrays.asList("BEGINNER", "INTERMEDIATE", "ADVANCED"); - private static final List VALID_IMAGE_TYPES = Arrays.asList("image/jpeg", "image/png", "image/gif", "image/webp"); private static final int NICKNAME_MIN_LENGTH = 2; private static final int NICKNAME_MAX_LENGTH = 20; - private final UserRepository userRepository; private final S3Presigner s3Presigner; - public UserService(UserRepository userRepository) { this.userRepository = userRepository; // AwsClients 싱글톤 사용 - Cold Start 최적화 this.s3Presigner = AwsClients.s3Presigner(); } + private static String getDefaultProfileUrl() { + String bucket = BUCKET_NAME != null ? BUCKET_NAME : "group2-englishstudy"; + return String.format("https://%s.s3.amazonaws.com/profile/default.png", bucket); + } + /** * 프로필 조회 * DynamoDB에 없으면 request에서 claims 추출 → fallback 저장 @@ -54,7 +51,7 @@ public UserService(UserRepository userRepository) { * @return User 객체 */ public User getProfile(String userId, APIGatewayProxyRequestEvent request) { - + User user = userRepository.findByCognitoSub(userId) .map(u -> { u.updateLastLoginAt(); @@ -62,14 +59,14 @@ public User getProfile(String userId, APIGatewayProxyRequestEvent request) { return u; }) .orElseGet(() -> createUserFromRequest(userId, request)); - + // 프로필 URL을 Presigned URL로 변환 String presignedProfileUrl = getPresignedProfileUrl(user.getProfileUrl()); user.setProfileUrlForResponse(presignedProfileUrl); // 응답용으로만 설정 - + return user; } - + public String getPresignedProfileUrl(String s3Url) { if (s3Url == null || s3Url.isEmpty()) { return generateGetPresignedUrl("profile/default.png"); @@ -77,22 +74,22 @@ public String getPresignedProfileUrl(String s3Url) { String key = extractKeyFromS3Url(s3Url); return generateGetPresignedUrl(key); } - + private String generateGetPresignedUrl(String imageKey) { GetObjectRequest getObjectRequest = GetObjectRequest.builder() .bucket(BUCKET_NAME) .key(imageKey) .build(); - + GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() .signatureDuration(Duration.ofHours(24)) .getObjectRequest(getObjectRequest) .build(); - + return s3Presigner.presignGetObject(presignRequest).url().toString(); } - - + + private String extractKeyFromS3Url(String s3Url) { // https://group2-englishstudy.s3.amazonaws.com/profile/user123/img.png // → profile/user123/img.png diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java index 8cb93c80..84aca50a 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java @@ -66,18 +66,18 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent re private APIGatewayProxyResponseEvent getUserWords(APIGatewayProxyRequestEvent request, String userId) { Map queryParams = request.getQueryStringParameters(); - + String status = queryParams != null ? queryParams.get("status") : null; String cursor = queryParams != null ? queryParams.get("cursor") : null; String bookmarked = queryParams != null ? queryParams.get("bookmarked") : null; String incorrectOnly = queryParams != null ? queryParams.get("incorrectOnly") : null; String category = queryParams != null ? queryParams.get("category") : null; - + int limit = 20; if (queryParams != null && queryParams.get("limit") != null) { limit = Math.min(Integer.parseInt(queryParams.get("limit")), 50); } - + UserWordQueryService.UserWordsResult result = queryService.getUserWords(userId, status, bookmarked, incorrectOnly, category, limit, cursor); Map response = new HashMap<>(); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordQueryService.java index 6b862566..a606d5f7 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordQueryService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordQueryService.java @@ -39,7 +39,7 @@ public UserWordQueryService(UserWordRepository userWordRepository, WordRepositor public UserWordsResult getUserWords(String userId, String status, String bookmarked, String incorrectOnly, String category, int limit, String cursor) { PaginatedResult userWordPage; - + if ("true".equalsIgnoreCase(bookmarked)) { userWordPage = userWordRepository.findBookmarkedWords(userId, limit * 3, cursor); } else if ("true".equalsIgnoreCase(incorrectOnly)) { @@ -49,9 +49,9 @@ public UserWordsResult getUserWords(String userId, String status, String bookmar } else { userWordPage = userWordRepository.findByUserIdWithPagination(userId, limit * 3, cursor); } - + List> enrichedUserWords = enrichWithWordInfo(userWordPage.items()); - + // 카테고리 필터링 (Word 테이블 조인 후 필터) if (category != null && !category.isEmpty()) { String upperCategory = category.toUpperCase(); @@ -64,7 +64,7 @@ public UserWordsResult getUserWords(String userId, String status, String bookmar .limit(limit) .collect(Collectors.toList()); } - + return new UserWordsResult(enrichedUserWords, userWordPage.nextCursor(), userWordPage.hasMore()); } From 6a04d014c743e398f3a6cd4131abb5260f7c4312 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Fri, 23 Jan 2026 16:25:34 +0900 Subject: [PATCH 509/528] fix: filter by ARTICLE# prefix in findById to avoid returning bookmark records Co-Authored-By: Claude Opus 4.5 --- .../domain/news/repository/NewsArticleRepository.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsArticleRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsArticleRepository.java index 4f4ec3ae..9cb2b587 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsArticleRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsArticleRepository.java @@ -75,8 +75,9 @@ public Optional findByDateAndId(String date, String articleId) { */ public Optional findById(String articleId) { Expression filterExpression = Expression.builder() - .expression("articleId = :articleId") + .expression("articleId = :articleId AND begins_with(SK, :skPrefix)") .putExpressionValue(":articleId", AttributeValue.builder().s(articleId).build()) + .putExpressionValue(":skPrefix", AttributeValue.builder().s("ARTICLE#").build()) .build(); ScanEnhancedRequest request = ScanEnhancedRequest.builder() From 126104c1027c6d7c139595993b1e5bd820ecf5b4 Mon Sep 17 00:00:00 2001 From: hyein Heo <128613248+hye-inA@users.noreply.github.com> Date: Fri, 23 Jan 2026 23:13:54 +0900 Subject: [PATCH 510/528] =?UTF-8?q?feature=20:=20Speaking=20Table=20&=20Fu?= =?UTF-8?q?nction=20template.yaml=20=ED=8C=8C=EC=9D=BC=EC=97=90=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#513)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: BadgeRepository 클라이언트 초기화 패턴 통일 - 개별 DynamoDbEnhancedClient 생성 대신 AwsClients.dynamoDbEnhanced() 싱글톤 사용 - 다른 Repository들과 동일한 패턴 적용 - 불필요한 import 제거 Closes #396 * feat: EnvConfig 유틸리티 추가 및 환경 변수 검증 적용 환경 변수 미설정 시 명확한 에러 메시지를 제공하는 EnvConfig 유틸리티를 추가하고, 기존 System.getenv 호출을 EnvConfig.getRequired/getOrDefault로 대체함. - EnvConfig: getRequired, getOrDefault, getIntOrDefault, getLongOrDefault 메서드 제공 - Lambda Cold Start 시점에 환경 변수 누락을 조기 감지 - 기존 Config 클래스(WebSocketConfig, RoomTokenConfig) EnvConfig 사용으로 통일 Closes #403 * refactor: TestService submitTest 메서드 책임 분리 submitTest 메서드를 단일 책임 원칙에 맞게 리팩토링: - gradeAnswers(): 답안 채점 및 결과 집계 - isAnswerCorrect(): 단일 답안 정답 여부 판단 - buildResultItem(): 결과 항목 생성 - saveTestResult(): 테스트 결과 저장 - GradingResult record: 채점 결과 캡슐화 TestService와 TestCommandService 모두 동일하게 적용 Closes #404 * refactor: 하드코딩된 설정값 환경 변수로 외부화 각 도메인별 Config 클래스를 생성하여 하드코딩된 값들을 환경 변수로 설정 가능하게 변경. 기본값이 있어 환경 변수 미설정 시에도 기존 동작 유지. ## 새로 추가된 Config 클래스 - GrammarConfig: SESSION_TTL_DAYS, MAX_HISTORY_MESSAGES, MAX_TOKENS 등 - GameConfig: TOTAL_ROUNDS, ROUND_TIME_LIMIT, QUICK_GUESS_THRESHOLD_MS - VocabularyConfig: NEW_WORDS_COUNT, REVIEW_WORDS_COUNT, 상태 전이 임계값 등 ## 지원하는 환경 변수 - GRAMMAR_SESSION_TTL_DAYS, GRAMMAR_MAX_HISTORY_MESSAGES, GRAMMAR_MAX_TOKENS - GAME_TOTAL_ROUNDS, GAME_ROUND_TIME_LIMIT, GAME_QUICK_GUESS_THRESHOLD_MS - VOCAB_NEW_WORDS_COUNT, VOCAB_REVIEW_WORDS_COUNT - VOCAB_TRANSITION_TO_REVIEWING, VOCAB_TRANSITION_TO_MASTERED Closes #406 * test: StudyLevel enum 단위 테스트 추가 * test: Difficulty enum 단위 테스트 추가 * test: StudyConfig 단위 테스트 추가 * test: EnvConfig 단위 테스트 추가 * test: PaginatedResult 단위 테스트 추가 * test: JsonUtil 단위 테스트 추가 * test: CursorUtil 단위 테스트 추가 * test: CommonErrorCode 단위 테스트 추가 * test: CommonException 단위 테스트 추가 * test: BadgeType enum 단위 테스트 추가 * test: BadgeKey 상수 단위 테스트 추가 * test: WordStatus enum 단위 테스트 추가 * test: TestType enum 단위 테스트 추가 * test: VocabularyConfig 단위 테스트 추가 * test: VocabKey 상수 단위 테스트 추가 * test: VocabularyErrorCode 단위 테스트 추가 * test: VocabularyException 단위 테스트 추가 * test: SpacedRepetitionContext 단위 테스트 추가 * test: GrammarLevel enum 단위 테스트 추가 * test: GrammarConfig 단위 테스트 추가 * test: GrammarErrorCode 단위 테스트 추가 * test: GrammarException 단위 테스트 추가 * test: GameStatus enum 단위 테스트 추가 * test: GameConfig 단위 테스트 추가 * test: ChattingErrorCode 단위 테스트 추가 * test: ChattingException 단위 테스트 추가 * fix: OPIc FeedbackResponse.java 문법 오류 수정 * style: AwsClients 코드 포맷팅 * style: EnvConfig 코드 포맷팅 * style: RoomTokenConfig 코드 포맷팅 * style: WebSocketConfig 코드 포맷팅 * style: JsonUtil 코드 포맷팅 * style: GameConfig 코드 포맷팅 * style: GrammarConfig 코드 포맷팅 * style: GrammarKey 코드 포맷팅 * style: GrammarErrorCode 코드 포맷팅 * style: GrammarException 코드 포맷팅 * style: BedrockGrammarCheckFactory 코드 포맷팅 * style: GrammarConversationService 코드 포맷팅 * style: FeedbackResponse 코드 포맷팅 * style: SessionReportResponse 코드 포맷팅 * style: SpeakingError 코드 포맷팅 * style: SpeakingErrorType 코드 포맷팅 * style: OPIcException 코드 포맷팅 * style: OPIcAnswer 코드 포맷팅 * style: OPIcQuestion 코드 포맷팅 * style: OPIcSession 코드 포맷팅 * style: OPIcRepository 코드 포맷팅 * style: FeedbackService 코드 포맷팅 * style: TranscribeProxyService 코드 포맷팅 * style: VocabularyConfig 코드 포맷팅 * style: DailyStudyCommandService 코드 포맷팅 * style: TestCommandService 코드 포맷팅 * style: TestService 코드 포맷팅 * style: CommonErrorCodeSpec 코드 포맷팅 * style: JsonUtilSpec 코드 포맷팅 * style: BadgeTypeSpec 코드 포맷팅 * style: ChattingErrorCodeSpec 코드 포맷팅 * style: GrammarLevelSpec 코드 포맷팅 * style: GrammarErrorCodeSpec 코드 포맷팅 * style: VocabularyErrorCodeSpec 코드 포맷팅 * feature : OPIc 세션관리 + 답변 처리 파이프라인 구현 (#413) * feat : OPIc 질문 & 세션 관련 dto 생성 * refactor : @DynamoDbBean 주석 추가 * feat : 주제 + 소주제 + 레벨로 오픽 질문 조회 추가 * feat : OPIc 세션, 질문, 답변 종합 handler 구현 * feat : 오픽 주제, 소주제별 seed 데이터 추가 * refactor : Transcribe API KEY 환경변수명 수정 * refactor : Bedrock에 사용하는 클로드 모델 변경 * refactor : S3 Key 대신 Proxy에서 요청하는 Base64 값으로 변환 * feat: GAME_START, ROUND_END 메시지에 serverTime 추가 - broadcastGameStart(): serverTime, roundDuration 필드 추가 - broadcastRoundEnd(): serverTime, roundStartTime, roundDuration 필드 추가 - GameService.endRound(): data에 roundStartTime, roundDuration 포함 - 타이머 동기화 버그 수정을 위한 서버 시간 제공 * feat: WebSocketMessageHelper 유틸리티 클래스 추가 - domain 필드 포함 메시지 생성 헬퍼 - DOMAIN_CHAT, DOMAIN_GAME 상수 정의 - buildChatMessage(), buildGameMessage() 메서드 * feat: 모든 WebSocket 메시지에 domain 필드 추가 - ScoreUpdateMessage에 domain 필드 추가 - broadcastGameStart에 domain:"game" 추가 - broadcastRoundEnd에 domain:"game" 추가 - broadcastCorrectAnswerMessage에 domain:"game" 추가 - handleCommandResult 시스템 메시지에 domain:"game" 추가 - handleRegularMessage 채팅 메시지에 domain:"chat" 추가 Closes #426, #427 * feat: GameSession 모델 클래스 생성 - DynamoDB Enhanced Client 어노테이션 적용 - GSI1: roomId로 활성 게임 세션 조회 가능 - 게임 상태 관리용 헬퍼 메서드 포함 Closes #428 * feat: GameSessionRepository 구현 - 기본 CRUD (save, findById, delete) - roomId로 활성/전체 게임 세션 조회 - 상태, 라운드, 점수 업데이트 메서드 - 정답자 추가, 힌트 사용, 게임 종료 처리 Closes #429 * refactor: ChatRoom에서 게임 필드 분리 - 게임 관련 필드 제거 (gameStatus, currentRound 등) - activeGameSessionId 필드 추가 - 게임 상태는 GameSession으로 분리됨 Note: 의존 코드 수정은 #431에서 진행 Closes #430 * refactor: GameSession 기반으로 전체 게임 로직 리팩토링 - GameService: ChatRoom 대신 GameSession 사용 - GameStatsService: GameSession 매개변수로 변경 - CommandService: GameSession 기반 점수 조회 - GameHandler: GameSession 기반 REST API - WebSocketMessageHandler: GameSession 기반 브로드캐스트 - GameStatusResponse, ScoreboardResponse: GameSession 매개변수 Closes #431 * feat: GameSessionHandler Lambda 및 게임 세션 API 구현 - POST /rooms/{roomId}/games - 게임 세션 생성 - GET /games/{gameSessionId} - 게임 상태 조회 (재접속용) - POST /games/{gameSessionId}/start - 게임 시작 - POST /games/{gameSessionId}/stop - 게임 종료 모든 응답에 serverTime 포함 (타이머 동기화) 출제자에게만 currentWord 포함 Closes #432, #433 * feat: 게임 시작 7분 후 자동 종료 기능 구현 - GameConfig에 gameTimeLimit() 메서드 추가 (기본값: 420초) - GameSchedulerClient 유틸리티 클래스 생성 (EventBridge Scheduler 연동) - GameService에 스케줄 생성/취소 로직 추가 - GameAutoCloseHandler Lambda 함수 생성 - template.yaml에 Lambda, IAM Role, Schedule Group 추가 Closes #417 * fix : 메모리 증가 및 Lambda 응답 제한 시간 Cognito 트리거 제한시간과 동일하게 수정 (#439) * feat: 캐치마인드 게임 방 분리 기능 구현 (#455) - Room 타입 분리 (CHAT/GAME) - RoomType, RoomStatus enum 추가 - ChatRoom 모델에 type, gameType, gameSettings, status, hostId 필드 추가 - GameSettings 모델 추가 - 방 생성/조회 API 수정 - CreateRoomRequest에 type, gameType, gameSettings 필드 추가 - 방 목록 조회 시 type, gameType, status 필터 지원 - 게임 시작 조건 검증 - GAME 타입 방에서만 게임 시작 가능 (GAME_007 에러) - 게임 재시작 API 구현 (#452) - POST /rooms/{roomId}/game/restart 엔드포인트 추가 - 방장만 재시작 가능 (GAME_009 에러) - 게임 진행 중 재시작 불가 (GAME_008 에러) - 방장 변경 로직 구현 (#453) - 방장 퇴장 시 다음 멤버에게 자동 이전 - 모든 멤버 퇴장 시 방 자동 삭제 - WebSocket 메시지 타입 추가 (#454) - ROOM_STATUS_CHANGE, HOST_CHANGE 메시지 타입 추가 - WebSocketMessageHelper에 빌더 메서드 추가 - 테스트 추가 - RoomType, RoomStatus enum 테스트 - GameSettings 모델 테스트 - ChattingErrorCode 테스트 업데이트 Related: #440, #441, #442, #443, #444, #445, #446 Closes: #447, #448, #449, #450, #451, #452, #453, #454 * feat: 참가자 닉네임 및 방장 변경 WebSocket 알림 구현 (#456) - RoomParticipant DTO 추가 (userId, nickname, isHost) - ChatRoomQueryService에 닉네임 조회 메서드 추가 - getParticipantsWithNicknames(): 참가자 목록 + 닉네임 - getHostNickname(): 방장 닉네임 조회 - ChatRoomHandler.getRoom() 응답에 participants, hostNickname 추가 - ChatRoomCommandService.leaveRoom()에서 방장 변경 시 WebSocket 브로드캐스트 Related: #440 * fix: ChatRoomFunction에 UserTable DynamoDB 권한 추가 - 참가자/방장 닉네임 조회를 위한 UserTable 읽기 권한 추가 - DynamoDBReadPolicy로 UserTable 접근 허용 Fixes #457 * fix: GameSettings에 @DynamoDbBean 어노테이션 추가 - DynamoDB Enhanced Client가 중첩 객체를 직렬화/역직렬화할 수 있도록 수정 - ChatRoom 저장/조회 시 GameSettings 변환 오류 해결 Fixes #457 * fix: ChatRoomFunction에 WEBSOCKET_ENDPOINT 환경변수 및 권한 추가 - leaveRoom에서 WebSocketBroadcaster 사용을 위한 환경변수 추가 - execute-api:ManageConnections 권한 추가 * fix: WebSocket Lambda 함수들에 WEBSOCKET_ENDPOINT 환경변수 추가 - WebSocketConnectFunction에 WEBSOCKET_ENDPOINT 추가 - WebSocketDisconnectFunction에 WEBSOCKET_ENDPOINT 추가 - execute-api:ManageConnections 권한 추가 * fix: Grammar WebSocket Lambda 환경 변수 및 권한 추가 - GrammarStreamingConnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - GrammarStreamingDisconnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - 두 함수에 execute-api:ManageConnections 권한 추가 * feat: GSI1SK 확장성 있는 재설계 및 DB 레벨 필터링 ## 변경 사항 ### GSI1SK 포맷 변경 - 기존: {level}#{createdAt} - 신규: {type}#{gameType}#{status}#{level}#{createdAt} ### 지원 쿼리 패턴 - 전체 방: GSI1PK = "ROOMS" - 게임방만: begins_with(GSI1SK, "GAME#") - 캐치마인드만: begins_with(GSI1SK, "GAME#CATCHMIND#") - 대기중 캐치마인드: begins_with(GSI1SK, "GAME#CATCHMIND#WAITING#") ### 파일 수정 - ChatRoomCommandService.java: 방 생성 시 새 GSI1SK 포맷 적용 - ChatRoomRepository.java: findByFilters() 메서드 추가, updateStatus() 메서드 추가 - ChatRoomQueryService.java: 메모리 필터링 제거, DB 레벨 필터링으로 변경 - GameService.java: 게임 시작/종료 시 방 상태 업데이트 (GSI1SK 포함) ### 마이그레이션 - scripts/migrate-gsi1sk.sh: 기존 데이터 마이그레이션 스크립트 - 37개 기존 방 마이그레이션 완료 * fix: increase stats query limit to 100 days - Adjust maximum allowable limit for recent stats query from 30 to 100 days to support extended data range responses. * feat: improve game round and connection management logic - Added handling for game round timeout (`ROUND_TIMEOUT`) in WebSocketMessageHandler. - Enhanced game start broadcast to include `currentWord` for the drawer only. - Updated ConnectionRepository to remove duplicate user connections in the same room. - Added `currentWordEnglish` to GameSession for better answer verification. - Normalized ChatRoom levels to match database storage format. - Updated template.yaml to include DynamoDBReadPolicy for VocabTable. * refactor: 코드 정리 및 미사용 클래스 제거 (#459) * refactor: remove unused WebSocketResponseUtil * refactor: add AutoCloseable to WebSocketBroadcaster * refactor: remove unused TestService * refactor: remove unused WordService * refactor: remove unused DailyStudyService * refactor: remove unused UserWordService * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * fix: resolve N+1 query in StatsService * refactor(all): DI 패턴 및 전략 패턴 적용 (#461) - Repository, Service, Handler에 DI 생성자 추가 (테스트 용이성) - BadgeService에 Strategy 패턴 적용 (뱃지 조건 검증 로직 분리) * refactor: relocate and restructure seed data files - Moved `question-homes.json` and `words.json` from subdirectories to `seed` folder. - Updated file paths for better organization and clarity. * chore: seed 데이터 폴더 구조 정리 * feat: add CI/CD pipeline configuration for CodePipeline * fix: add SNS topic policy and DependsOn for notification rule * fix: correct paths in buildspec.yml for CodeBuild * fix: remove hardcoded JAVA_HOME, use runtime default * fix: add gradle wrapper for CI/CD build * fix: use single line sam package command with hardcoded bucket * fix: use existing stack name group2-englishstudy-chatting * fix: add missing WEBSOCKET_ENDPOINT env var to WebSocket connect functions * docs: update FRONTEND-API-GUIDE with new RoomType/RoomStatus structure * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix: update WebSocketDisconnectHandler to use GameSession model - Remove references to deleted ChatRoom game fields - Use GameSessionRepository to finish active game sessions - Use ChatRoomRepository.updateStatus() for room state management * perf: optimize CI/CD build time - Add pip cache for SAM CLI dependencies - Enable Gradle parallel builds and build cache - Add SAM build cache (.aws-sam) - Use sam build --parallel --cached - Skip SAM CLI install if already cached - Remove unnecessary 'clean' to leverage cache * feat: add custom CodeBuild Docker image with pre-installed tools - Dockerfile with Java 21 + SAM CLI + Gradle pre-installed - build-and-push.sh script for ECR deployment - Updated buildspec.yml for custom image (removes SAM CLI install) - Expected build time reduction: ~30-40 seconds * feature : AI 영어 회화 연습 기능 (#468) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix: remove typo in SpeakingConnectionRepository * fix : 오타 수정 * chore: trigger build test with custom Docker image * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes * Release: 캐치마인드 게임 분리, AI 회화 연습, CI/CD 파이프라인 (#469) * feature : OPIc 세션관리 + 답변 처리 파이프라인 구현 (#413) * feat : OPIc 질문 & 세션 관련 dto 생성 * refactor : @DynamoDbBean 주석 추가 * feat : 주제 + 소주제 + 레벨로 오픽 질문 조회 추가 * feat : OPIc 세션, 질문, 답변 종합 handler 구현 * feat : 오픽 주제, 소주제별 seed 데이터 추가 * refactor : Transcribe API KEY 환경변수명 수정 * refactor : Bedrock에 사용하는 클로드 모델 변경 * refactor : S3 Key 대신 Proxy에서 요청하는 Base64 값으로 변환 * feat: GAME_START, ROUND_END 메시지에 serverTime 추가 - broadcastGameStart(): serverTime, roundDuration 필드 추가 - broadcastRoundEnd(): serverTime, roundStartTime, roundDuration 필드 추가 - GameService.endRound(): data에 roundStartTime, roundDuration 포함 - 타이머 동기화 버그 수정을 위한 서버 시간 제공 * feat: WebSocketMessageHelper 유틸리티 클래스 추가 - domain 필드 포함 메시지 생성 헬퍼 - DOMAIN_CHAT, DOMAIN_GAME 상수 정의 - buildChatMessage(), buildGameMessage() 메서드 * feat: 모든 WebSocket 메시지에 domain 필드 추가 - ScoreUpdateMessage에 domain 필드 추가 - broadcastGameStart에 domain:"game" 추가 - broadcastRoundEnd에 domain:"game" 추가 - broadcastCorrectAnswerMessage에 domain:"game" 추가 - handleCommandResult 시스템 메시지에 domain:"game" 추가 - handleRegularMessage 채팅 메시지에 domain:"chat" 추가 Closes #426, #427 * feat: GameSession 모델 클래스 생성 - DynamoDB Enhanced Client 어노테이션 적용 - GSI1: roomId로 활성 게임 세션 조회 가능 - 게임 상태 관리용 헬퍼 메서드 포함 Closes #428 * feat: GameSessionRepository 구현 - 기본 CRUD (save, findById, delete) - roomId로 활성/전체 게임 세션 조회 - 상태, 라운드, 점수 업데이트 메서드 - 정답자 추가, 힌트 사용, 게임 종료 처리 Closes #429 * refactor: ChatRoom에서 게임 필드 분리 - 게임 관련 필드 제거 (gameStatus, currentRound 등) - activeGameSessionId 필드 추가 - 게임 상태는 GameSession으로 분리됨 Note: 의존 코드 수정은 #431에서 진행 Closes #430 * refactor: GameSession 기반으로 전체 게임 로직 리팩토링 - GameService: ChatRoom 대신 GameSession 사용 - GameStatsService: GameSession 매개변수로 변경 - CommandService: GameSession 기반 점수 조회 - GameHandler: GameSession 기반 REST API - WebSocketMessageHandler: GameSession 기반 브로드캐스트 - GameStatusResponse, ScoreboardResponse: GameSession 매개변수 Closes #431 * feat: GameSessionHandler Lambda 및 게임 세션 API 구현 - POST /rooms/{roomId}/games - 게임 세션 생성 - GET /games/{gameSessionId} - 게임 상태 조회 (재접속용) - POST /games/{gameSessionId}/start - 게임 시작 - POST /games/{gameSessionId}/stop - 게임 종료 모든 응답에 serverTime 포함 (타이머 동기화) 출제자에게만 currentWord 포함 Closes #432, #433 * feat: 게임 시작 7분 후 자동 종료 기능 구현 - GameConfig에 gameTimeLimit() 메서드 추가 (기본값: 420초) - GameSchedulerClient 유틸리티 클래스 생성 (EventBridge Scheduler 연동) - GameService에 스케줄 생성/취소 로직 추가 - GameAutoCloseHandler Lambda 함수 생성 - template.yaml에 Lambda, IAM Role, Schedule Group 추가 Closes #417 * fix : 메모리 증가 및 Lambda 응답 제한 시간 Cognito 트리거 제한시간과 동일하게 수정 (#439) * feat: 캐치마인드 게임 방 분리 기능 구현 (#455) - Room 타입 분리 (CHAT/GAME) - RoomType, RoomStatus enum 추가 - ChatRoom 모델에 type, gameType, gameSettings, status, hostId 필드 추가 - GameSettings 모델 추가 - 방 생성/조회 API 수정 - CreateRoomRequest에 type, gameType, gameSettings 필드 추가 - 방 목록 조회 시 type, gameType, status 필터 지원 - 게임 시작 조건 검증 - GAME 타입 방에서만 게임 시작 가능 (GAME_007 에러) - 게임 재시작 API 구현 (#452) - POST /rooms/{roomId}/game/restart 엔드포인트 추가 - 방장만 재시작 가능 (GAME_009 에러) - 게임 진행 중 재시작 불가 (GAME_008 에러) - 방장 변경 로직 구현 (#453) - 방장 퇴장 시 다음 멤버에게 자동 이전 - 모든 멤버 퇴장 시 방 자동 삭제 - WebSocket 메시지 타입 추가 (#454) - ROOM_STATUS_CHANGE, HOST_CHANGE 메시지 타입 추가 - WebSocketMessageHelper에 빌더 메서드 추가 - 테스트 추가 - RoomType, RoomStatus enum 테스트 - GameSettings 모델 테스트 - ChattingErrorCode 테스트 업데이트 Related: #440, #441, #442, #443, #444, #445, #446 Closes: #447, #448, #449, #450, #451, #452, #453, #454 * feat: 참가자 닉네임 및 방장 변경 WebSocket 알림 구현 (#456) - RoomParticipant DTO 추가 (userId, nickname, isHost) - ChatRoomQueryService에 닉네임 조회 메서드 추가 - getParticipantsWithNicknames(): 참가자 목록 + 닉네임 - getHostNickname(): 방장 닉네임 조회 - ChatRoomHandler.getRoom() 응답에 participants, hostNickname 추가 - ChatRoomCommandService.leaveRoom()에서 방장 변경 시 WebSocket 브로드캐스트 Related: #440 * fix: ChatRoomFunction에 UserTable DynamoDB 권한 추가 - 참가자/방장 닉네임 조회를 위한 UserTable 읽기 권한 추가 - DynamoDBReadPolicy로 UserTable 접근 허용 Fixes #457 * fix: GameSettings에 @DynamoDbBean 어노테이션 추가 - DynamoDB Enhanced Client가 중첩 객체를 직렬화/역직렬화할 수 있도록 수정 - ChatRoom 저장/조회 시 GameSettings 변환 오류 해결 Fixes #457 * fix: ChatRoomFunction에 WEBSOCKET_ENDPOINT 환경변수 및 권한 추가 - leaveRoom에서 WebSocketBroadcaster 사용을 위한 환경변수 추가 - execute-api:ManageConnections 권한 추가 * fix: WebSocket Lambda 함수들에 WEBSOCKET_ENDPOINT 환경변수 추가 - WebSocketConnectFunction에 WEBSOCKET_ENDPOINT 추가 - WebSocketDisconnectFunction에 WEBSOCKET_ENDPOINT 추가 - execute-api:ManageConnections 권한 추가 * fix: Grammar WebSocket Lambda 환경 변수 및 권한 추가 - GrammarStreamingConnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - GrammarStreamingDisconnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - 두 함수에 execute-api:ManageConnections 권한 추가 * feat: GSI1SK 확장성 있는 재설계 및 DB 레벨 필터링 ## 변경 사항 ### GSI1SK 포맷 변경 - 기존: {level}#{createdAt} - 신규: {type}#{gameType}#{status}#{level}#{createdAt} ### 지원 쿼리 패턴 - 전체 방: GSI1PK = "ROOMS" - 게임방만: begins_with(GSI1SK, "GAME#") - 캐치마인드만: begins_with(GSI1SK, "GAME#CATCHMIND#") - 대기중 캐치마인드: begins_with(GSI1SK, "GAME#CATCHMIND#WAITING#") ### 파일 수정 - ChatRoomCommandService.java: 방 생성 시 새 GSI1SK 포맷 적용 - ChatRoomRepository.java: findByFilters() 메서드 추가, updateStatus() 메서드 추가 - ChatRoomQueryService.java: 메모리 필터링 제거, DB 레벨 필터링으로 변경 - GameService.java: 게임 시작/종료 시 방 상태 업데이트 (GSI1SK 포함) ### 마이그레이션 - scripts/migrate-gsi1sk.sh: 기존 데이터 마이그레이션 스크립트 - 37개 기존 방 마이그레이션 완료 * fix: increase stats query limit to 100 days - Adjust maximum allowable limit for recent stats query from 30 to 100 days to support extended data range responses. * feat: improve game round and connection management logic - Added handling for game round timeout (`ROUND_TIMEOUT`) in WebSocketMessageHandler. - Enhanced game start broadcast to include `currentWord` for the drawer only. - Updated ConnectionRepository to remove duplicate user connections in the same room. - Added `currentWordEnglish` to GameSession for better answer verification. - Normalized ChatRoom levels to match database storage format. - Updated template.yaml to include DynamoDBReadPolicy for VocabTable. * refactor: 코드 정리 및 미사용 클래스 제거 (#459) * refactor: remove unused WebSocketResponseUtil * refactor: add AutoCloseable to WebSocketBroadcaster * refactor: remove unused TestService * refactor: remove unused WordService * refactor: remove unused DailyStudyService * refactor: remove unused UserWordService * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * fix: resolve N+1 query in StatsService * refactor(all): DI 패턴 및 전략 패턴 적용 (#461) - Repository, Service, Handler에 DI 생성자 추가 (테스트 용이성) - BadgeService에 Strategy 패턴 적용 (뱃지 조건 검증 로직 분리) * refactor: relocate and restructure seed data files - Moved `question-homes.json` and `words.json` from subdirectories to `seed` folder. - Updated file paths for better organization and clarity. * chore: seed 데이터 폴더 구조 정리 * feat: add CI/CD pipeline configuration for CodePipeline * fix: add SNS topic policy and DependsOn for notification rule * fix: correct paths in buildspec.yml for CodeBuild * fix: remove hardcoded JAVA_HOME, use runtime default * fix: add gradle wrapper for CI/CD build * fix: use single line sam package command with hardcoded bucket * fix: use existing stack name group2-englishstudy-chatting * fix: add missing WEBSOCKET_ENDPOINT env var to WebSocket connect functions * docs: update FRONTEND-API-GUIDE with new RoomType/RoomStatus structure * fix: update WebSocketDisconnectHandler to use GameSession model - Remove references to deleted ChatRoom game fields - Use GameSessionRepository to finish active game sessions - Use ChatRoomRepository.updateStatus() for room state management * perf: optimize CI/CD build time - Add pip cache for SAM CLI dependencies - Enable Gradle parallel builds and build cache - Add SAM build cache (.aws-sam) - Use sam build --parallel --cached - Skip SAM CLI install if already cached - Remove unnecessary 'clean' to leverage cache * feat: add custom CodeBuild Docker image with pre-installed tools - Dockerfile with Java 21 + SAM CLI + Gradle pre-installed - build-and-push.sh script for ECR deployment - Updated buildspec.yml for custom image (removes SAM CLI install) - Expected build time reduction: ~30-40 seconds * feature : AI 영어 회화 연습 기능 (#468) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix: remove typo in SpeakingConnectionRepository * chore: trigger build test with custom Docker image * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes --------- Co-authored-by: hyein Heo <128613248+hye-inA@users.noreply.github.com> * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * fix: add CORS headers to API Gateway error responses (#479) API Gateway의 인증 실패 등 에러 응답에 CORS 헤더가 누락되어 CloudFront를 통한 프론트엔드 요청이 차단되는 문제 수정 - UNAUTHORIZED (401) 응답에 CORS 헤더 추가 - ACCESS_DENIED (403) 응답에 CORS 헤더 추가 - DEFAULT_4XX/5XX 응답에 CORS 헤더 추가 - EXPIRED_TOKEN 응답에 CORS 헤더 추가 * feat(news): 뉴스 도메인 기반 구조 구축 (#385) - NewsCategory, QuizType enum 추가 - NewsKey 상수 클래스 추가 - NewsErrorCode 예외 클래스 추가 - NewsArticle, KeywordInfo, QuizQuestion 모델 추가 - NewsArticleRepository CRUD 구현 - NewsTable DynamoDB 테이블 정의 - CORS GatewayResponses 설정 추가 * feat(news): 뉴스 수집 파이프라인 구현 (#386) - NewsApiClient: NewsAPI 연동 서비스 - RssFeedParser: RSS 피드 파싱 (BBC, VOA, NPR) - NewsDuplicateChecker: URL 기반 중복 필터링 - NewsCollectorService: 수집 오케스트레이션 - NewsCollectionHandler: Lambda 핸들러 - EventBridge 스케줄러: 매일 18시 KST 실행 * refactor(news): NewsAPI 제거, RSS만 사용 - NewsApiClient 삭제 - SSM Parameter 의존성 제거 - RSS 소스당 7개씩 수집 (BBC, VOA, NPR = 약 21개/일) * feat(news): AI 뉴스 분석 시스템 구현 (#387) - NewsAnalysisService: AI 분석 통합 서비스 - Bedrock: CEFR 난이도 분석 (A1~C2) - Bedrock: 3줄 요약 + 퀴즈 3문제 생성 - Comprehend: 핵심 키워드 추출 - NewsCollectorService: 수집 시 자동 분석 연동 - GSI1/GSI2 키 자동 설정 (레벨별, 카테고리별 조회) * feat(news): 뉴스 학습 API 구현 (#388) - NewsQueryService: 뉴스 조회 서비스 - NewsHandler: API 핸들러 - GET /news - 목록 조회 (level, category 필터) - GET /news/today - 오늘의 뉴스 - GET /news/recommended - 내 레벨 맞춤 추천 - GET /news/{articleId} - 상세 조회 (조회수 증가) - template.yaml: NewsFunction Lambda 추가 * feat(news): 뉴스 학습 부가 기능 구현 (#389) - 읽기 완료 기록 API (POST /news/{articleId}/read) - 북마크 토글 API (POST /news/{articleId}/bookmark) - 북마크 목록 조회 API (GET /news/bookmarks) - 학습 통계 조회 API (GET /news/stats) - TTS 오디오 URL 조회 API (GET /news/{articleId}/audio) - UserNewsRecord 모델 추가 - UserNewsRepository 추가 - NewsLearningService 추가 * feat(news): 복합 퀴즈 시스템 구현 (#471) - 퀴즈 조회 API (GET /news/{articleId}/quiz) - 퀴즈 제출 API (POST /news/{articleId}/quiz) - 퀴즈 기록 조회 API (GET /news/quiz/history) - NewsQuizResult 모델 추가 - QuizAnswerResult 모델 추가 - NewsQuizRepository 추가 - NewsQuizService 추가 * feat(news): 단어 수집 & Vocabulary 연동 구현 (#472) - 단어 수집 API (POST /news/{articleId}/words) - 수집 단어 목록 API (GET /news/words) - 단어 상세 조회 API (GET /news/{articleId}/words/{word}) - 단어 삭제 API (DELETE /news/{articleId}/words/{word}) - Vocabulary 연동 API (POST /news/words/{word}/sync) - NewsWordCollect 모델 추가 - NewsWordRepository 추가 - NewsWordService 추가 * feat: add multi-environment deployment support (dev/test/prod) * fix: update buildspec.yml to deploy prod environment with parameter overrides * feat(news): 뉴스 도메인 기반 구조 구축 (#385) - NewsCategory, QuizType enum 추가 - NewsKey 상수 클래스 추가 - NewsErrorCode 예외 클래스 추가 - NewsArticle, KeywordInfo, QuizQuestion 모델 추가 - NewsArticleRepository CRUD 구현 - NewsTable DynamoDB 테이블 정의 - CORS GatewayResponses 설정 추가 * feat(news): 뉴스 수집 파이프라인 구현 (#386) - NewsApiClient: NewsAPI 연동 서비스 - RssFeedParser: RSS 피드 파싱 (BBC, VOA, NPR) - NewsDuplicateChecker: URL 기반 중복 필터링 - NewsCollectorService: 수집 오케스트레이션 - NewsCollectionHandler: Lambda 핸들러 - EventBridge 스케줄러: 매일 18시 KST 실행 * refactor(news): NewsAPI 제거, RSS만 사용 - NewsApiClient 삭제 - SSM Parameter 의존성 제거 - RSS 소스당 7개씩 수집 (BBC, VOA, NPR = 약 21개/일) * feat(news): AI 뉴스 분석 시스템 구현 (#387) - NewsAnalysisService: AI 분석 통합 서비스 - Bedrock: CEFR 난이도 분석 (A1~C2) - Bedrock: 3줄 요약 + 퀴즈 3문제 생성 - Comprehend: 핵심 키워드 추출 - NewsCollectorService: 수집 시 자동 분석 연동 - GSI1/GSI2 키 자동 설정 (레벨별, 카테고리별 조회) * feat(news): 뉴스 학습 API 구현 (#388) - NewsQueryService: 뉴스 조회 서비스 - NewsHandler: API 핸들러 - GET /news - 목록 조회 (level, category 필터) - GET /news/today - 오늘의 뉴스 - GET /news/recommended - 내 레벨 맞춤 추천 - GET /news/{articleId} - 상세 조회 (조회수 증가) - template.yaml: NewsFunction Lambda 추가 * feat(news): 뉴스 학습 부가 기능 구현 (#389) - 읽기 완료 기록 API (POST /news/{articleId}/read) - 북마크 토글 API (POST /news/{articleId}/bookmark) - 북마크 목록 조회 API (GET /news/bookmarks) - 학습 통계 조회 API (GET /news/stats) - TTS 오디오 URL 조회 API (GET /news/{articleId}/audio) - UserNewsRecord 모델 추가 - UserNewsRepository 추가 - NewsLearningService 추가 * feat(news): 복합 퀴즈 시스템 구현 (#471) - 퀴즈 조회 API (GET /news/{articleId}/quiz) - 퀴즈 제출 API (POST /news/{articleId}/quiz) - 퀴즈 기록 조회 API (GET /news/quiz/history) - NewsQuizResult 모델 추가 - QuizAnswerResult 모델 추가 - NewsQuizRepository 추가 - NewsQuizService 추가 * feat(news): 단어 수집 & Vocabulary 연동 구현 (#472) - 단어 수집 API (POST /news/{articleId}/words) - 수집 단어 목록 API (GET /news/words) - 단어 상세 조회 API (GET /news/{articleId}/words/{word}) - 단어 삭제 API (DELETE /news/{articleId}/words/{word}) - Vocabulary 연동 API (POST /news/words/{word}/sync) - NewsWordCollect 모델 추가 - NewsWordRepository 추가 - NewsWordService 추가 * feat: add multi-environment deployment support (dev/test/prod) * fix: update buildspec.yml to deploy prod environment with parameter overrides * refactor : AI 말하기 Websocket 구현 -> REST API 구현으로 리팩토링 (#490) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix : 오타 수정 * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * fix: revert buildspec.yml to build-only for CloudFormation deploy stage * fix: revert buildspec.yml to build-only for CloudFormation deploy stage * fix: update all API Gateway StageName to use Environment parameter * fix: correct VocabularyTable and ContentBucket references in NewsFunction * fix: correct VocabularyTable and ContentBucket references in NewsFunction * fix: add stack name prefix to GameScheduleGroup and daily-stats schedule to avoid conflicts * fix: add stack name prefix to GameScheduleGroup and daily-stats schedule to avoid conflicts * feat: add support for existing Cognito User Pool reuse across environments * fix: add conditional Cognito ARN reference in API Gateway Authorizer * fix: remove Cognito resources completely, use existing Cognito only * fix: remove Cognito resources completely, use existing Cognito only * feat : speaking rest API 람다 함수 추가 * feat: add S3 bucket resource and fix environment-specific endpoints - Add ContentBucket S3 resource for content storage - Replace hardcoded /dev with ${Environment} in all WebSocket endpoints - Update Output URLs to use dynamic environment stage Co-Authored-By: Claude Opus 4.5 * fix: add stack name prefix to news-collection schedule name Prevent EventBridge rule name conflicts across environments Co-Authored-By: Claude Opus 4.5 * fix: add X-Requested-With and Accept headers to CORS config Enable additional headers for CloudFront CORS compatibility Co-Authored-By: Claude Opus 4.5 * fix: use environment variable for S3 bucket URLs Replace hardcoded bucket name with BUCKET_NAME env var for multi-env support: - PreSignUpHandler: dynamic default profile URL - PostConfirmationHandler: dynamic default profile URL - UserService: dynamic default profile URL - BadgeType: dynamic badge image base URL Co-Authored-By: Claude Opus 4.5 * fix: disable authorizer for CORS preflight requests Add AddDefaultAuthorizerToCorsPreflight: false to prevent Cognito Authorizer from blocking OPTIONS requests Co-Authored-By: Claude Opus 4.5 * feat : speaking REST API 람다 함수 추가 (#491) * feature : OPIc 세션관리 + 답변 처리 파이프라인 구현 (#413) * feat : OPIc 질문 & 세션 관련 dto 생성 * refactor : @DynamoDbBean 주석 추가 * feat : 주제 + 소주제 + 레벨로 오픽 질문 조회 추가 * feat : OPIc 세션, 질문, 답변 종합 handler 구현 * feat : 오픽 주제, 소주제별 seed 데이터 추가 * refactor : Transcribe API KEY 환경변수명 수정 * refactor : Bedrock에 사용하는 클로드 모델 변경 * refactor : S3 Key 대신 Proxy에서 요청하는 Base64 값으로 변환 * feat: GAME_START, ROUND_END 메시지에 serverTime 추가 - broadcastGameStart(): serverTime, roundDuration 필드 추가 - broadcastRoundEnd(): serverTime, roundStartTime, roundDuration 필드 추가 - GameService.endRound(): data에 roundStartTime, roundDuration 포함 - 타이머 동기화 버그 수정을 위한 서버 시간 제공 * feat: WebSocketMessageHelper 유틸리티 클래스 추가 - domain 필드 포함 메시지 생성 헬퍼 - DOMAIN_CHAT, DOMAIN_GAME 상수 정의 - buildChatMessage(), buildGameMessage() 메서드 * feat: 모든 WebSocket 메시지에 domain 필드 추가 - ScoreUpdateMessage에 domain 필드 추가 - broadcastGameStart에 domain:"game" 추가 - broadcastRoundEnd에 domain:"game" 추가 - broadcastCorrectAnswerMessage에 domain:"game" 추가 - handleCommandResult 시스템 메시지에 domain:"game" 추가 - handleRegularMessage 채팅 메시지에 domain:"chat" 추가 Closes #426, #427 * feat: GameSession 모델 클래스 생성 - DynamoDB Enhanced Client 어노테이션 적용 - GSI1: roomId로 활성 게임 세션 조회 가능 - 게임 상태 관리용 헬퍼 메서드 포함 Closes #428 * feat: GameSessionRepository 구현 - 기본 CRUD (save, findById, delete) - roomId로 활성/전체 게임 세션 조회 - 상태, 라운드, 점수 업데이트 메서드 - 정답자 추가, 힌트 사용, 게임 종료 처리 Closes #429 * refactor: ChatRoom에서 게임 필드 분리 - 게임 관련 필드 제거 (gameStatus, currentRound 등) - activeGameSessionId 필드 추가 - 게임 상태는 GameSession으로 분리됨 Note: 의존 코드 수정은 #431에서 진행 Closes #430 * refactor: GameSession 기반으로 전체 게임 로직 리팩토링 - GameService: ChatRoom 대신 GameSession 사용 - GameStatsService: GameSession 매개변수로 변경 - CommandService: GameSession 기반 점수 조회 - GameHandler: GameSession 기반 REST API - WebSocketMessageHandler: GameSession 기반 브로드캐스트 - GameStatusResponse, ScoreboardResponse: GameSession 매개변수 Closes #431 * feat: GameSessionHandler Lambda 및 게임 세션 API 구현 - POST /rooms/{roomId}/games - 게임 세션 생성 - GET /games/{gameSessionId} - 게임 상태 조회 (재접속용) - POST /games/{gameSessionId}/start - 게임 시작 - POST /games/{gameSessionId}/stop - 게임 종료 모든 응답에 serverTime 포함 (타이머 동기화) 출제자에게만 currentWord 포함 Closes #432, #433 * feat: 게임 시작 7분 후 자동 종료 기능 구현 - GameConfig에 gameTimeLimit() 메서드 추가 (기본값: 420초) - GameSchedulerClient 유틸리티 클래스 생성 (EventBridge Scheduler 연동) - GameService에 스케줄 생성/취소 로직 추가 - GameAutoCloseHandler Lambda 함수 생성 - template.yaml에 Lambda, IAM Role, Schedule Group 추가 Closes #417 * fix : 메모리 증가 및 Lambda 응답 제한 시간 Cognito 트리거 제한시간과 동일하게 수정 (#439) * feat: 캐치마인드 게임 방 분리 기능 구현 (#455) - Room 타입 분리 (CHAT/GAME) - RoomType, RoomStatus enum 추가 - ChatRoom 모델에 type, gameType, gameSettings, status, hostId 필드 추가 - GameSettings 모델 추가 - 방 생성/조회 API 수정 - CreateRoomRequest에 type, gameType, gameSettings 필드 추가 - 방 목록 조회 시 type, gameType, status 필터 지원 - 게임 시작 조건 검증 - GAME 타입 방에서만 게임 시작 가능 (GAME_007 에러) - 게임 재시작 API 구현 (#452) - POST /rooms/{roomId}/game/restart 엔드포인트 추가 - 방장만 재시작 가능 (GAME_009 에러) - 게임 진행 중 재시작 불가 (GAME_008 에러) - 방장 변경 로직 구현 (#453) - 방장 퇴장 시 다음 멤버에게 자동 이전 - 모든 멤버 퇴장 시 방 자동 삭제 - WebSocket 메시지 타입 추가 (#454) - ROOM_STATUS_CHANGE, HOST_CHANGE 메시지 타입 추가 - WebSocketMessageHelper에 빌더 메서드 추가 - 테스트 추가 - RoomType, RoomStatus enum 테스트 - GameSettings 모델 테스트 - ChattingErrorCode 테스트 업데이트 Related: #440, #441, #442, #443, #444, #445, #446 Closes: #447, #448, #449, #450, #451, #452, #453, #454 * feat: 참가자 닉네임 및 방장 변경 WebSocket 알림 구현 (#456) - RoomParticipant DTO 추가 (userId, nickname, isHost) - ChatRoomQueryService에 닉네임 조회 메서드 추가 - getParticipantsWithNicknames(): 참가자 목록 + 닉네임 - getHostNickname(): 방장 닉네임 조회 - ChatRoomHandler.getRoom() 응답에 participants, hostNickname 추가 - ChatRoomCommandService.leaveRoom()에서 방장 변경 시 WebSocket 브로드캐스트 Related: #440 * fix: ChatRoomFunction에 UserTable DynamoDB 권한 추가 - 참가자/방장 닉네임 조회를 위한 UserTable 읽기 권한 추가 - DynamoDBReadPolicy로 UserTable 접근 허용 Fixes #457 * fix: GameSettings에 @DynamoDbBean 어노테이션 추가 - DynamoDB Enhanced Client가 중첩 객체를 직렬화/역직렬화할 수 있도록 수정 - ChatRoom 저장/조회 시 GameSettings 변환 오류 해결 Fixes #457 * fix: ChatRoomFunction에 WEBSOCKET_ENDPOINT 환경변수 및 권한 추가 - leaveRoom에서 WebSocketBroadcaster 사용을 위한 환경변수 추가 - execute-api:ManageConnections 권한 추가 * fix: WebSocket Lambda 함수들에 WEBSOCKET_ENDPOINT 환경변수 추가 - WebSocketConnectFunction에 WEBSOCKET_ENDPOINT 추가 - WebSocketDisconnectFunction에 WEBSOCKET_ENDPOINT 추가 - execute-api:ManageConnections 권한 추가 * fix: Grammar WebSocket Lambda 환경 변수 및 권한 추가 - GrammarStreamingConnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - GrammarStreamingDisconnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - 두 함수에 execute-api:ManageConnections 권한 추가 * feat: GSI1SK 확장성 있는 재설계 및 DB 레벨 필터링 - 기존: {level}#{createdAt} - 신규: {type}#{gameType}#{status}#{level}#{createdAt} - 전체 방: GSI1PK = "ROOMS" - 게임방만: begins_with(GSI1SK, "GAME#") - 캐치마인드만: begins_with(GSI1SK, "GAME#CATCHMIND#") - 대기중 캐치마인드: begins_with(GSI1SK, "GAME#CATCHMIND#WAITING#") - ChatRoomCommandService.java: 방 생성 시 새 GSI1SK 포맷 적용 - ChatRoomRepository.java: findByFilters() 메서드 추가, updateStatus() 메서드 추가 - ChatRoomQueryService.java: 메모리 필터링 제거, DB 레벨 필터링으로 변경 - GameService.java: 게임 시작/종료 시 방 상태 업데이트 (GSI1SK 포함) - scripts/migrate-gsi1sk.sh: 기존 데이터 마이그레이션 스크립트 - 37개 기존 방 마이그레이션 완료 * fix: increase stats query limit to 100 days - Adjust maximum allowable limit for recent stats query from 30 to 100 days to support extended data range responses. * feat: improve game round and connection management logic - Added handling for game round timeout (`ROUND_TIMEOUT`) in WebSocketMessageHandler. - Enhanced game start broadcast to include `currentWord` for the drawer only. - Updated ConnectionRepository to remove duplicate user connections in the same room. - Added `currentWordEnglish` to GameSession for better answer verification. - Normalized ChatRoom levels to match database storage format. - Updated template.yaml to include DynamoDBReadPolicy for VocabTable. * refactor: 코드 정리 및 미사용 클래스 제거 (#459) * refactor: remove unused WebSocketResponseUtil * refactor: add AutoCloseable to WebSocketBroadcaster * refactor: remove unused TestService * refactor: remove unused WordService * refactor: remove unused DailyStudyService * refactor: remove unused UserWordService * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * fix: resolve N+1 query in StatsService * refactor(all): DI 패턴 및 전략 패턴 적용 (#461) - Repository, Service, Handler에 DI 생성자 추가 (테스트 용이성) - BadgeService에 Strategy 패턴 적용 (뱃지 조건 검증 로직 분리) * refactor: relocate and restructure seed data files - Moved `question-homes.json` and `words.json` from subdirectories to `seed` folder. - Updated file paths for better organization and clarity. * chore: seed 데이터 폴더 구조 정리 * feat: add CI/CD pipeline configuration for CodePipeline * fix: add SNS topic policy and DependsOn for notification rule * fix: correct paths in buildspec.yml for CodeBuild * fix: remove hardcoded JAVA_HOME, use runtime default * fix: add gradle wrapper for CI/CD build * fix: use single line sam package command with hardcoded bucket * fix: use existing stack name group2-englishstudy-chatting * fix: add missing WEBSOCKET_ENDPOINT env var to WebSocket connect functions * docs: update FRONTEND-API-GUIDE with new RoomType/RoomStatus structure * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix: update WebSocketDisconnectHandler to use GameSession model - Remove references to deleted ChatRoom game fields - Use GameSessionRepository to finish active game sessions - Use ChatRoomRepository.updateStatus() for room state management * perf: optimize CI/CD build time - Add pip cache for SAM CLI dependencies - Enable Gradle parallel builds and build cache - Add SAM build cache (.aws-sam) - Use sam build --parallel --cached - Skip SAM CLI install if already cached - Remove unnecessary 'clean' to leverage cache * feat: add custom CodeBuild Docker image with pre-installed tools - Dockerfile with Java 21 + SAM CLI + Gradle pre-installed - build-and-push.sh script for ECR deployment - Updated buildspec.yml for custom image (removes SAM CLI install) - Expected build time reduction: ~30-40 seconds * feature : AI 영어 회화 연습 기능 (#468) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix: remove typo in SpeakingConnectionRepository * fix : 오타 수정 * chore: trigger build test with custom Docker image * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * fix: add CORS headers to API Gateway error responses (#479) API Gateway의 인증 실패 등 에러 응답에 CORS 헤더가 누락되어 CloudFront를 통한 프론트엔드 요청이 차단되는 문제 수정 - UNAUTHORIZED (401) 응답에 CORS 헤더 추가 - ACCESS_DENIED (403) 응답에 CORS 헤더 추가 - DEFAULT_4XX/5XX 응답에 CORS 헤더 추가 - EXPIRED_TOKEN 응답에 CORS 헤더 추가 * feat : speaking rest API 람다 함수 추가 --------- Co-authored-by: ddingjoo * refactor : speaking service 재사용 * refactor : AI 영어 회화 연습 코드 리팩토링 * feature : test 벡엔드 서버에 AI 말하기 연습 기능 배포 (#492) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix : 오타 수정 * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * feat : speaking rest API 람다 함수 추가 * refactor : speaking service 재사용 * refactor : AI 영어 회화 연습 코드 리팩토링 --------- Co-authored-by: DDING JOO * feat : handleChat 메서드 JsonNull 체크 푸가 * feature : handleChat 메서드 JsonNull 체크 추가 (#493) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix : 오타 수정 * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * feat : speaking rest API 람다 함수 추가 * refactor : speaking service 재사용 * refactor : AI 영어 회화 연습 코드 리팩토링 * feat : handleChat 메서드 JsonNull 체크 푸가 --------- Co-authored-by: DDING JOO * feat(news): 뉴스 학습 배지 시스템 구현 (#473) - 14개 뉴스 관련 배지 추가 (읽기, 퀴즈, 단어수집, 연속학습, 마스터) - UserStats에 뉴스 통계 필드 추가 - 6개 뉴스 배지 Strategy 클래스 생성 - 뉴스 읽기/퀴즈/단어수집 시 통계 업데이트 및 배지 체크 연동 * fix: add PATCH method to CORS AllowMethods * test: BadgeType 개수 테스트 수정 (15 -> 29) * fix: CORS PATCH 메서드 추가 * docs: 뉴스 기능 프론트엔드 연동 가이드 작성 * fix: NewsCollectionFunction에 Bedrock, Comprehend 권한 추가 * fix: add null check for collectWord request body - Add INVALID_REQUEST error code to NewsErrorCode - Check body and word field before accessing in collectWord() - Prevents NullPointerException when request body is malformed * feat: enhance stats API and bookmark response for frontend - Add DAILY stats update for news read/quiz/word collection - Add /stats/dashboard endpoint with frontend-requested format - today: wordsLearned, newsRead, quizzesTaken, wordsTotal - overall: totalWordsLearned, totalNewsRead, averageAccuracy, streaks - weeklyProgress: last 7 days with date/wordsLearned/newsRead - Add news-related fields to all stats API responses - Fix bookmark API to include full article details (title, summary, etc.) * feat: add category classification to news AI analysis - Add category field to AnalysisResult record - Update Bedrock prompt to classify articles into categories (WORLD, POLITICS, BUSINESS, TECH, SCIENCE, HEALTH, SPORTS, ENTERTAINMENT, LIFESTYLE) - Parse and set category from AI response - Set GSI2 (CATEGORY#) index when category is available * fix: add /stats/dashboard endpoint to template.yaml * fix: filter by ARTICLE# prefix in findById to avoid returning UserNewsRecord * docs: add News API troubleshooting guide * feat: add Cognito authorizer to News API and enhance keyword extraction - Set CognitoAuthorizer for all News API endpoints in template.yaml - Update Bedrock AI to extract keywords with meanings and examples - Add fallback to Comprehend for keyword extraction when Bedrock fails - Modify KeywordInfo model to include example field - Adjust AI prompt to include keyword extraction with examples - Update AnalysisResult to store keywords and parse them from AI response * Revert "Merge branch 'test' into prod" This reverts commit c1a958eac6799d5838a76e4078ae304bcdb62278, reversing changes made to a6662e0cfc46c6e12f20a89f0df285ff282160bc. * feat: enhance bookmark and reading status tracking for News API - Add bookmark and reading status to individual article responses - Include bookmark status in paginated news responses - Modify `KeywordInfo` to support Korean translations (`meaningKo`) - Update AI prompts to extract Korean meanings for keywords * refactor : session_id가 null 체크 추가 * feat : template 환경변수 리펙토링 * fix : sessionId NullPointerException 에러 수정 (#496) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix : 오타 수정 * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * feat : speaking rest API 람다 함수 추가 * refactor : speaking service 재사용 * refactor : AI 영어 회화 연습 코드 리팩토링 * feat : handleChat 메서드 JsonNull 체크 푸가 * refactor : session_id가 null 체크 추가 * feat : template 환경변수 리펙토링 --------- Co-authored-by: DDING JOO * feat: enhance bookmark and reading status tracking for News API - Add bookmark and reading status to individual article responses - Include bookmark status in paginated news responses - Modify `KeywordInfo` to support Korean translations (`meaningKo`) - Update AI prompts to extract Korean meanings for keywords * feat: add category filtering to UserWord API with enhanced query logic - Introduce `category` filtering to `getUserWords` API - Update WordCategory enums to include "news" category - Apply category filter after enrichment with word info - Adjust query limits for bookmarked and incorrect words queries * feat: add default value for ExistingCognitoClientId in template.yaml - Set default to an empty string to ensure compatibility with templates using this parameter without explicitly specifying a value. * fix: SpeakingHandler getStringOrNull 컴파일 에러 수정 * fix: SpeakingHandler getStringOrNull 컴파일 에러 수정 * fix: Bedrock 키워드(meaningKo 포함)를 article에 저장하도록 수정 * fix: Bedrock 키워드(meaningKo 포함)를 article에 저장하도록 수정 * fix: Bedrock 키워드(meaningKo 포함)를 article에 저장하도록 수정 * feat: add dashboard stats API and enhance news stats tracking - Introduce `/stats/dashboard` API for retrieving integrated user stats (today, overall, weekly progress, and level distribution) - Update `UserStats` model to include additional fields for news stats (newsRead, newsQuizCompleted, newsQuizPerfect, newsWordsCollected, etc.) - Add atomic updates to track news reading, quiz, and word stats in `UserStatsRepository` - Modify CloudFormation `template.yaml` to register the new `/stats/dashboard` endpoint * feat: add dashboard stats API and enhance news stats tracking - Introduce `/stats/dashboard` API for retrieving integrated user stats (today, overall, weekly progress, and level distribution) - Update `UserStats` model to include additional fields for news stats (newsRead, newsQuizCompleted, newsQuizPerfect, newsWordsCollected, etc.) - Add atomic updates to track news reading, quiz, and word stats in `UserStatsRepository` - Modify CloudFormation `template.yaml` to register the new `/stats/dashboard` endpoint * feat: add dashboard stats API and enhance news stats tracking - Introduce `/stats/dashboard` API for retrieving integrated user stats (today, overall, weekly progress, and level distribution) - Update `UserStats` model to include additional fields for news stats (newsRead, newsQuizCompleted, newsQuizPerfect, newsWordsCollected, etc.) - Add atomic updates to track news reading, quiz, and word stats in `UserStatsRepository` - Modify CloudFormation `template.yaml` to register the new `/stats/dashboard` endpoint * refactor: format code with consistent indentation and spacing - Apply consistent formatting to improve code readability across multiple files - Adjust indentation, spacing, and alignment in model classes, DTOs, and repository methods * fix: filter by ARTICLE# prefix in findById to avoid returning bookmark records Co-Authored-By: Claude Opus 4.5 * feat : Speaking 관련 template 람다 함수 및 테이블 추가 --------- Co-authored-by: DDING JOO Co-authored-by: Claude Opus 4.5 --- .../handler/SpeakingConnectHandler.java | 0 .../handler/SpeakingDisconnectHandler.java | 0 .../speaking/handler/SpeakingHandler.java | 271 +++++++++--------- .../handler/SpeakingMessageHandler.java | 0 .../SpeakingConnectionRepository.java | 0 .../repository/SpeakingSessionRepository.java | 116 ++++---- ServerlessFunction/template.yaml | 78 +++++ 7 files changed, 273 insertions(+), 192 deletions(-) delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingConnectHandler.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingDisconnectHandler.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingMessageHandler.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingConnectHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingConnectHandler.java deleted file mode 100644 index e69de29b..00000000 diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingDisconnectHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingDisconnectHandler.java deleted file mode 100644 index e69de29b..00000000 diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingHandler.java index ed6fbda0..2ebda605 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingHandler.java @@ -19,142 +19,145 @@ /** * Speaking API 핸들러 - *

+ * * POST /api/speaking/chat - 대화 (음성 또는 텍스트) * POST /api/speaking/reset - 대화 초기화 */ public class SpeakingHandler implements RequestHandler { - - private static final Logger logger = LoggerFactory.getLogger(SpeakingHandler.class); - private static final Gson gson = new GsonBuilder().create(); - - private static final Map CORS_HEADERS = Map.of( - "Content-Type", "application/json", - "Access-Control-Allow-Origin", "*", - "Access-Control-Allow-Headers", "Content-Type,Authorization", - "Access-Control-Allow-Methods", "POST,OPTIONS" - ); - - private final SpeakingService speakingService; - - public SpeakingHandler() { - this.speakingService = new SpeakingService(); - } - - @Override - public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent event, Context context) { - logger.info("Speaking API request received"); - - // OPTIONS 요청 처리 (CORS preflight) - if ("OPTIONS".equalsIgnoreCase(event.getHttpMethod())) { - return response(200, Map.of("message", "OK")); - } - - try { - // JWT 토큰 검증 - String authHeader = event.getHeaders().get("Authorization"); - if (authHeader == null || !authHeader.startsWith("Bearer ")) { - return response(401, Map.of("error", "Authorization header is required")); - } - - String token = authHeader.substring(7); - if (!JwtUtil.isValid(token)) { - return response(401, Map.of("error", "Invalid or expired token")); - } - - Optional userIdOpt = JwtUtil.extractUserId(token); - if (userIdOpt.isEmpty()) { - return response(401, Map.of("error", "Invalid token")); - } - - String userId = userIdOpt.get(); - String path = event.getPath(); - String body = event.getBody(); - - logger.info("Processing request: path={}, userId={}", path, userId); - - // 라우팅 - if (path.endsWith("/chat")) { - return handleChat(userId, body); - } else if (path.endsWith("/reset")) { - return handleReset(userId, body); - } else { - return response(404, Map.of("error", "Not found")); - } - - } catch (Exception e) { - logger.error("Error processing request: {}", e.getMessage(), e); - return response(500, Map.of("error", "Internal server error: " + e.getMessage())); - } - } - - /** - * 대화 처리 (음성 또는 텍스트) - */ - private APIGatewayProxyResponseEvent handleChat(String userId, String body) { - if (body == null || body.isEmpty()) { - return response(400, Map.of("error", "Request body is required")); - } - - JsonObject request = JsonParser.parseString(body).getAsJsonObject(); - - String sessionId = request.has("sessionId") ? request.get("sessionId").getAsString() : null; - String level = request.has("level") ? request.get("level").getAsString() : "INTERMEDIATE"; - String audio = request.has("audio") ? request.get("audio").getAsString() : null; - String text = request.has("text") ? request.get("text").getAsString() : null; - - SpeakingResponse result; - - if (audio != null && !audio.isEmpty()) { - // 음성 입력 처리 - logger.info("Processing voice input"); - result = speakingService.processVoiceInput(sessionId, userId, audio, level); - } else if (text != null && !text.trim().isEmpty()) { - // 텍스트 입력 처리 - logger.info("Processing text input: {}", text); - result = speakingService.processTextInput(sessionId, userId, text.trim(), level); - } else { - return response(400, Map.of("error", "Either 'audio' or 'text' is required")); - } - - return response(200, Map.of( - "sessionId", result.sessionId(), - "userTranscript", result.userTranscript(), - "aiText", result.aiText(), - "aiAudioUrl", result.aiAudioUrl(), - "confidence", result.confidence() - )); - } - - /** - * 대화 초기화 - */ - private APIGatewayProxyResponseEvent handleReset(String userId, String body) { - if (body == null || body.isEmpty()) { - return response(400, Map.of("error", "Request body is required")); - } - - JsonObject request = JsonParser.parseString(body).getAsJsonObject(); - String sessionId = request.has("sessionId") ? request.get("sessionId").getAsString() : null; - - if (sessionId == null || sessionId.isEmpty()) { - return response(400, Map.of("error", "sessionId is required")); - } - - speakingService.resetConversation(sessionId); - - return response(200, Map.of( - "message", "Conversation reset successfully", - "sessionId", sessionId - )); - } - - private APIGatewayProxyResponseEvent response(int statusCode, Map body) { - return new APIGatewayProxyResponseEvent() - .withStatusCode(statusCode) - .withHeaders(CORS_HEADERS) - .withBody(gson.toJson(body)); - } - - + + private static final Logger logger = LoggerFactory.getLogger(SpeakingHandler.class); + private static final Gson gson = new GsonBuilder().create(); + + private static final Map CORS_HEADERS = Map.of( + "Content-Type", "application/json", + "Access-Control-Allow-Origin", "*", + "Access-Control-Allow-Headers", "Content-Type,Authorization", + "Access-Control-Allow-Methods", "POST,OPTIONS" + ); + + private final SpeakingService speakingService; + + public SpeakingHandler() { + this.speakingService = new SpeakingService(); + } + + @Override + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent event, Context context) { + logger.info("Speaking API request received"); + + // OPTIONS 요청 처리 (CORS preflight) + if ("OPTIONS".equalsIgnoreCase(event.getHttpMethod())) { + return response(200, Map.of("message", "OK")); + } + + try { + // 사용자 인증 정보 추출 (Cognito Authorizer -> requestContext) + if (event.getRequestContext() == null || event.getRequestContext().getAuthorizer() == null) { + logger.error("No Authorizer found in request context"); + return response(401, Map.of("error", "Unauthorized: User context missing")); + } + + Map authorizer = event.getRequestContext().getAuthorizer(); + Map claims = (Map) authorizer.get("claims"); + + if (claims == null) { + return response(401, Map.of("error", "Unauthorized: Claims missing")); + } + + String userId = (String) claims.get("sub"); // Cognito User Pool의 고유 ID (UUID 형태) + + // 요청 정보 추출 + String path = event.getPath(); + String body = event.getBody(); + + logger.info("Processing request: path={}, userId={}", path, userId); + + // 라우팅 + if (path != null && path.endsWith("/chat")) { + return handleChat(userId, body); + } else if (path != null && path.endsWith("/reset")) { + return handleReset(userId, body); + } else { + return response(404, Map.of("error", "Not found")); + } + + } catch (Exception e) { + logger.error("Error processing request: {}", e.getMessage(), e); + return response(500, Map.of("error", "Internal server error: " + e.getMessage())); + } + } + + /** + * 대화 처리 (음성 또는 텍스트) + */ + private APIGatewayProxyResponseEvent handleChat(String userId, String body) { + if (body == null || body.isEmpty()) { + return response(400, Map.of("error", "Request body is required")); + } + + JsonObject request = JsonParser.parseString(body).getAsJsonObject(); + + String sessionId = request.has("sessionId") && !request.get("sessionId").isJsonNull() + ? request.get("sessionId").getAsString() : null; + String level = request.has("level") && !request.get("level").isJsonNull() + ? request.get("level").getAsString() : "INTERMEDIATE"; + String audio = request.has("audio") && !request.get("audio").isJsonNull() + ? request.get("audio").getAsString() : null; + String text = request.has("text") && !request.get("text").isJsonNull() + ? request.get("text").getAsString() : null; + + SpeakingResponse result; + + if (audio != null && !audio.isEmpty()) { + // 음성 입력 처리 + logger.info("Processing voice event"); + result = speakingService.processVoiceInput(sessionId, userId, audio, level); + } else if (text != null && !text.trim().isEmpty()) { + // 텍스트 입력 처리 + logger.info("Processing text event: {}", text); + result = speakingService.processTextInput(sessionId, userId, text.trim(), level); + } else { + return response(400, Map.of("error", "Either 'audio' or 'text' is required")); + } + + return response(200, Map.of( + "sessionId", result.sessionId(), + "userTranscript", result.userTranscript(), + "aiText", result.aiText(), + "aiAudioUrl", result.aiAudioUrl(), + "confidence", result.confidence() + )); + } + + /** + * 대화 초기화 + */ + private APIGatewayProxyResponseEvent handleReset(String userId, String body) { + if (body == null || body.isEmpty()) { + return response(400, Map.of("error", "Request body is required")); + } + + JsonObject request = JsonParser.parseString(body).getAsJsonObject(); + String sessionId = request.has("sessionId") ? request.get("sessionId").getAsString() : null; + + if (sessionId == null || sessionId.isEmpty()) { + return response(400, Map.of("error", "sessionId is required")); + } + + speakingService.resetConversation(sessionId); + + return response(200, Map.of( + "message", "Conversation reset successfully", + "sessionId", sessionId + )); + } + + private APIGatewayProxyResponseEvent response(int statusCode, Map body) { + return new APIGatewayProxyResponseEvent() + .withStatusCode(statusCode) + .withHeaders(CORS_HEADERS) + .withBody(gson.toJson(body)); + } + + } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingMessageHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingMessageHandler.java deleted file mode 100644 index e69de29b..00000000 diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java deleted file mode 100644 index e69de29b..00000000 diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingSessionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingSessionRepository.java index aa7acb63..dadda7e7 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingSessionRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingSessionRepository.java @@ -12,63 +12,63 @@ import java.util.Optional; /** - * Speaking WebSocket 연결 정보 Repository + * Speaking API 연결 정보 Repository */ public class SpeakingSessionRepository { - - private static final Logger logger = LoggerFactory.getLogger(SpeakingSessionRepository.class); - private static final String TABLE_NAME = EnvConfig.getRequired("CHAT_TABLE_NAME"); - - private final DynamoDbTable table; - - public SpeakingSessionRepository() { - this.table = AwsClients.dynamoDbEnhanced().table( - TABLE_NAME, - TableSchema.fromBean(SpeakingSession.class) - ); - } - - /** - * 연결 정보 저장 - */ - public void save(SpeakingSession session) { - table.putItem(session); - logger.debug("Speaking session saved: sessionId={}, userId={}", - session.getSessionId(), session.getUserId()); - } - - /** - * sessionId로 연결 정보 조회 - */ - public Optional findBySessionId(String sessionId) { - Key key = Key.builder() - .partitionValue(SpeakingSession.PK_PREFIX + sessionId) - .sortValue(SpeakingSession.SK_METADATA) - .build(); - - SpeakingSession session = table.getItem(key); - return Optional.ofNullable(session); - } - - /** - * 연결 정보 업데이트 (대화 히스토리 등) - */ - public void update(SpeakingSession session) { - session.touch(); // 업데이트 시간 및 TTL 갱신 - table.putItem(session); - logger.debug("Speaking session updated: sessionId={}", session.getSessionId()); - } - - /** - * 연결 정보 삭제 - */ - public void delete(String sessionId) { - Key key = Key.builder() - .partitionValue(SpeakingSession.PK_PREFIX + sessionId) - .sortValue(SpeakingSession.SK_METADATA) - .build(); - - table.deleteItem(key); - logger.info("Speaking session deleted: sessionId={}", sessionId); - } -} + + private static final Logger logger = LoggerFactory.getLogger(SpeakingSessionRepository.class); + private static final String TABLE_NAME = EnvConfig.getRequired("SPEAKING_TABLE_NAME"); + + private final DynamoDbTable table; + + public SpeakingSessionRepository() { + this.table = AwsClients.dynamoDbEnhanced().table( + TABLE_NAME, + TableSchema.fromBean(SpeakingSession.class) + ); + } + + /** + * 연결 정보 저장 + */ + public void save(SpeakingSession session) { + table.putItem(session); + logger.debug("Speaking session saved: sessionId={}, userId={}", + session.getSessionId(), session.getUserId()); + } + + /** + * sessionId로 연결 정보 조회 + */ + public Optional findBySessionId(String sessionId) { + Key key = Key.builder() + .partitionValue(SpeakingSession.PK_PREFIX + sessionId) + .sortValue(SpeakingSession.SK_METADATA) + .build(); + + SpeakingSession session = table.getItem(key); + return Optional.ofNullable(session); + } + + /** + * 연결 정보 업데이트 (대화 히스토리 등) + */ + public void update(SpeakingSession session) { + session.touch(); // 업데이트 시간 및 TTL 갱신 + table.putItem(session); + logger.debug("Speaking session updated: sessionId={}", session.getSessionId()); + } + + /** + * 연결 정보 삭제 + */ + public void delete(String sessionId) { + Key key = Key.builder() + .partitionValue(SpeakingSession.PK_PREFIX + sessionId) + .sortValue(SpeakingSession.SK_METADATA) + .build(); + + table.deleteItem(key); + logger.info("Speaking session deleted: sessionId={}", sessionId); + } +} \ No newline at end of file diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 3fa504b5..50fbb74d 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -37,6 +37,7 @@ Globals: VOCAB_TABLE_NAME: !Ref VocabTable OPIC_TABLE_NAME: !Ref OPIcTable NEWS_TABLE_NAME: !Ref NewsTable + SPEAKING_TABLE_NAME: !Ref SpeakingTable BUCKET_NAME: !Sub "${AWS::StackName}" CHAT_BUCKET_NAME: !Sub "${AWS::StackName}" VOCAB_BUCKET_NAME: !Sub "${AWS::StackName}" @@ -1551,6 +1552,47 @@ Resources: Auth: Authorizer: CognitoAuthorizer + ############################################# + # Speaking Lambda Functions + ############################################# + + SpeakingFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: !Sub "${AWS::StackName}-speaking-handler" + CodeUri: . + Handler: com.mzc.secondproject.serverless.domain.speaking.handler.SpeakingHandler::handleRequest + Description: Handle speaking chat API + SnapStart: + ApplyOn: PublishedVersions + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref SpeakingTable + - S3CrudPolicy: + BucketName: !Sub "${AWS::StackName}" + - Statement: + - Effect: Allow + Action: + - bedrock:InvokeModel + Resource: "*" + Events: + SpeakingChat: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /speaking/chat + Method: POST + Auth: + Authorizer: CognitoAuthorizer + SpeakingReset: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /speaking/reset + Method: POST + Auth: + Authorizer: CognitoAuthorizer + ############################################# # DynamoDB Tables ############################################# @@ -1935,6 +1977,38 @@ Resources: AttributeName: ttl Enabled: true + SpeakingTable: + Type: AWS::DynamoDB::Table + Properties: + TableName: !Sub "${AWS::StackName}-speaking" + BillingMode: PAY_PER_REQUEST + AttributeDefinitions: + - AttributeName: PK + AttributeType: S + - AttributeName: SK + AttributeType: S + - AttributeName: GSI1PK + AttributeType: S + - AttributeName: GSI1SK + AttributeType: S + KeySchema: + - AttributeName: PK + KeyType: HASH + - AttributeName: SK + KeyType: RANGE + GlobalSecondaryIndexes: + - IndexName: GSI1 + KeySchema: + - AttributeName: GSI1PK + KeyType: HASH + - AttributeName: GSI1SK + KeyType: RANGE + Projection: + ProjectionType: ALL + TimeToLiveSpecification: + AttributeName: ttl + Enabled: true + ############################################# # S3 Bucket for Content Storage ############################################# @@ -2164,6 +2238,10 @@ Outputs: Description: OPIc DynamoDB Table Name Value: !Ref OPIcTable + SpeakingTableName: + Description: Speaking DynamoDB Table Name + Value: !Ref SpeakingTable + NotificationStreamUrl: Description: Notification SSE Stream Function URL Value: !GetAtt NotificationStreamFunctionUrl.FunctionUrl From 9c2d671a264932db3cbc6b6089f1ce408a776714 Mon Sep 17 00:00:00 2001 From: hyein Heo <128613248+hye-inA@users.noreply.github.com> Date: Fri, 23 Jan 2026 23:13:54 +0900 Subject: [PATCH 511/528] =?UTF-8?q?feature=20:=20Speaking=20Table=20&=20Fu?= =?UTF-8?q?nction=20template.yaml=20=ED=8C=8C=EC=9D=BC=EC=97=90=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#513)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: BadgeRepository 클라이언트 초기화 패턴 통일 - 개별 DynamoDbEnhancedClient 생성 대신 AwsClients.dynamoDbEnhanced() 싱글톤 사용 - 다른 Repository들과 동일한 패턴 적용 - 불필요한 import 제거 Closes #396 * feat: EnvConfig 유틸리티 추가 및 환경 변수 검증 적용 환경 변수 미설정 시 명확한 에러 메시지를 제공하는 EnvConfig 유틸리티를 추가하고, 기존 System.getenv 호출을 EnvConfig.getRequired/getOrDefault로 대체함. - EnvConfig: getRequired, getOrDefault, getIntOrDefault, getLongOrDefault 메서드 제공 - Lambda Cold Start 시점에 환경 변수 누락을 조기 감지 - 기존 Config 클래스(WebSocketConfig, RoomTokenConfig) EnvConfig 사용으로 통일 Closes #403 * refactor: TestService submitTest 메서드 책임 분리 submitTest 메서드를 단일 책임 원칙에 맞게 리팩토링: - gradeAnswers(): 답안 채점 및 결과 집계 - isAnswerCorrect(): 단일 답안 정답 여부 판단 - buildResultItem(): 결과 항목 생성 - saveTestResult(): 테스트 결과 저장 - GradingResult record: 채점 결과 캡슐화 TestService와 TestCommandService 모두 동일하게 적용 Closes #404 * refactor: 하드코딩된 설정값 환경 변수로 외부화 각 도메인별 Config 클래스를 생성하여 하드코딩된 값들을 환경 변수로 설정 가능하게 변경. 기본값이 있어 환경 변수 미설정 시에도 기존 동작 유지. ## 새로 추가된 Config 클래스 - GrammarConfig: SESSION_TTL_DAYS, MAX_HISTORY_MESSAGES, MAX_TOKENS 등 - GameConfig: TOTAL_ROUNDS, ROUND_TIME_LIMIT, QUICK_GUESS_THRESHOLD_MS - VocabularyConfig: NEW_WORDS_COUNT, REVIEW_WORDS_COUNT, 상태 전이 임계값 등 ## 지원하는 환경 변수 - GRAMMAR_SESSION_TTL_DAYS, GRAMMAR_MAX_HISTORY_MESSAGES, GRAMMAR_MAX_TOKENS - GAME_TOTAL_ROUNDS, GAME_ROUND_TIME_LIMIT, GAME_QUICK_GUESS_THRESHOLD_MS - VOCAB_NEW_WORDS_COUNT, VOCAB_REVIEW_WORDS_COUNT - VOCAB_TRANSITION_TO_REVIEWING, VOCAB_TRANSITION_TO_MASTERED Closes #406 * test: StudyLevel enum 단위 테스트 추가 * test: Difficulty enum 단위 테스트 추가 * test: StudyConfig 단위 테스트 추가 * test: EnvConfig 단위 테스트 추가 * test: PaginatedResult 단위 테스트 추가 * test: JsonUtil 단위 테스트 추가 * test: CursorUtil 단위 테스트 추가 * test: CommonErrorCode 단위 테스트 추가 * test: CommonException 단위 테스트 추가 * test: BadgeType enum 단위 테스트 추가 * test: BadgeKey 상수 단위 테스트 추가 * test: WordStatus enum 단위 테스트 추가 * test: TestType enum 단위 테스트 추가 * test: VocabularyConfig 단위 테스트 추가 * test: VocabKey 상수 단위 테스트 추가 * test: VocabularyErrorCode 단위 테스트 추가 * test: VocabularyException 단위 테스트 추가 * test: SpacedRepetitionContext 단위 테스트 추가 * test: GrammarLevel enum 단위 테스트 추가 * test: GrammarConfig 단위 테스트 추가 * test: GrammarErrorCode 단위 테스트 추가 * test: GrammarException 단위 테스트 추가 * test: GameStatus enum 단위 테스트 추가 * test: GameConfig 단위 테스트 추가 * test: ChattingErrorCode 단위 테스트 추가 * test: ChattingException 단위 테스트 추가 * fix: OPIc FeedbackResponse.java 문법 오류 수정 * style: AwsClients 코드 포맷팅 * style: EnvConfig 코드 포맷팅 * style: RoomTokenConfig 코드 포맷팅 * style: WebSocketConfig 코드 포맷팅 * style: JsonUtil 코드 포맷팅 * style: GameConfig 코드 포맷팅 * style: GrammarConfig 코드 포맷팅 * style: GrammarKey 코드 포맷팅 * style: GrammarErrorCode 코드 포맷팅 * style: GrammarException 코드 포맷팅 * style: BedrockGrammarCheckFactory 코드 포맷팅 * style: GrammarConversationService 코드 포맷팅 * style: FeedbackResponse 코드 포맷팅 * style: SessionReportResponse 코드 포맷팅 * style: SpeakingError 코드 포맷팅 * style: SpeakingErrorType 코드 포맷팅 * style: OPIcException 코드 포맷팅 * style: OPIcAnswer 코드 포맷팅 * style: OPIcQuestion 코드 포맷팅 * style: OPIcSession 코드 포맷팅 * style: OPIcRepository 코드 포맷팅 * style: FeedbackService 코드 포맷팅 * style: TranscribeProxyService 코드 포맷팅 * style: VocabularyConfig 코드 포맷팅 * style: DailyStudyCommandService 코드 포맷팅 * style: TestCommandService 코드 포맷팅 * style: TestService 코드 포맷팅 * style: CommonErrorCodeSpec 코드 포맷팅 * style: JsonUtilSpec 코드 포맷팅 * style: BadgeTypeSpec 코드 포맷팅 * style: ChattingErrorCodeSpec 코드 포맷팅 * style: GrammarLevelSpec 코드 포맷팅 * style: GrammarErrorCodeSpec 코드 포맷팅 * style: VocabularyErrorCodeSpec 코드 포맷팅 * feature : OPIc 세션관리 + 답변 처리 파이프라인 구현 (#413) * feat : OPIc 질문 & 세션 관련 dto 생성 * refactor : @DynamoDbBean 주석 추가 * feat : 주제 + 소주제 + 레벨로 오픽 질문 조회 추가 * feat : OPIc 세션, 질문, 답변 종합 handler 구현 * feat : 오픽 주제, 소주제별 seed 데이터 추가 * refactor : Transcribe API KEY 환경변수명 수정 * refactor : Bedrock에 사용하는 클로드 모델 변경 * refactor : S3 Key 대신 Proxy에서 요청하는 Base64 값으로 변환 * feat: GAME_START, ROUND_END 메시지에 serverTime 추가 - broadcastGameStart(): serverTime, roundDuration 필드 추가 - broadcastRoundEnd(): serverTime, roundStartTime, roundDuration 필드 추가 - GameService.endRound(): data에 roundStartTime, roundDuration 포함 - 타이머 동기화 버그 수정을 위한 서버 시간 제공 * feat: WebSocketMessageHelper 유틸리티 클래스 추가 - domain 필드 포함 메시지 생성 헬퍼 - DOMAIN_CHAT, DOMAIN_GAME 상수 정의 - buildChatMessage(), buildGameMessage() 메서드 * feat: 모든 WebSocket 메시지에 domain 필드 추가 - ScoreUpdateMessage에 domain 필드 추가 - broadcastGameStart에 domain:"game" 추가 - broadcastRoundEnd에 domain:"game" 추가 - broadcastCorrectAnswerMessage에 domain:"game" 추가 - handleCommandResult 시스템 메시지에 domain:"game" 추가 - handleRegularMessage 채팅 메시지에 domain:"chat" 추가 Closes #426, #427 * feat: GameSession 모델 클래스 생성 - DynamoDB Enhanced Client 어노테이션 적용 - GSI1: roomId로 활성 게임 세션 조회 가능 - 게임 상태 관리용 헬퍼 메서드 포함 Closes #428 * feat: GameSessionRepository 구현 - 기본 CRUD (save, findById, delete) - roomId로 활성/전체 게임 세션 조회 - 상태, 라운드, 점수 업데이트 메서드 - 정답자 추가, 힌트 사용, 게임 종료 처리 Closes #429 * refactor: ChatRoom에서 게임 필드 분리 - 게임 관련 필드 제거 (gameStatus, currentRound 등) - activeGameSessionId 필드 추가 - 게임 상태는 GameSession으로 분리됨 Note: 의존 코드 수정은 #431에서 진행 Closes #430 * refactor: GameSession 기반으로 전체 게임 로직 리팩토링 - GameService: ChatRoom 대신 GameSession 사용 - GameStatsService: GameSession 매개변수로 변경 - CommandService: GameSession 기반 점수 조회 - GameHandler: GameSession 기반 REST API - WebSocketMessageHandler: GameSession 기반 브로드캐스트 - GameStatusResponse, ScoreboardResponse: GameSession 매개변수 Closes #431 * feat: GameSessionHandler Lambda 및 게임 세션 API 구현 - POST /rooms/{roomId}/games - 게임 세션 생성 - GET /games/{gameSessionId} - 게임 상태 조회 (재접속용) - POST /games/{gameSessionId}/start - 게임 시작 - POST /games/{gameSessionId}/stop - 게임 종료 모든 응답에 serverTime 포함 (타이머 동기화) 출제자에게만 currentWord 포함 Closes #432, #433 * feat: 게임 시작 7분 후 자동 종료 기능 구현 - GameConfig에 gameTimeLimit() 메서드 추가 (기본값: 420초) - GameSchedulerClient 유틸리티 클래스 생성 (EventBridge Scheduler 연동) - GameService에 스케줄 생성/취소 로직 추가 - GameAutoCloseHandler Lambda 함수 생성 - template.yaml에 Lambda, IAM Role, Schedule Group 추가 Closes #417 * fix : 메모리 증가 및 Lambda 응답 제한 시간 Cognito 트리거 제한시간과 동일하게 수정 (#439) * feat: 캐치마인드 게임 방 분리 기능 구현 (#455) - Room 타입 분리 (CHAT/GAME) - RoomType, RoomStatus enum 추가 - ChatRoom 모델에 type, gameType, gameSettings, status, hostId 필드 추가 - GameSettings 모델 추가 - 방 생성/조회 API 수정 - CreateRoomRequest에 type, gameType, gameSettings 필드 추가 - 방 목록 조회 시 type, gameType, status 필터 지원 - 게임 시작 조건 검증 - GAME 타입 방에서만 게임 시작 가능 (GAME_007 에러) - 게임 재시작 API 구현 (#452) - POST /rooms/{roomId}/game/restart 엔드포인트 추가 - 방장만 재시작 가능 (GAME_009 에러) - 게임 진행 중 재시작 불가 (GAME_008 에러) - 방장 변경 로직 구현 (#453) - 방장 퇴장 시 다음 멤버에게 자동 이전 - 모든 멤버 퇴장 시 방 자동 삭제 - WebSocket 메시지 타입 추가 (#454) - ROOM_STATUS_CHANGE, HOST_CHANGE 메시지 타입 추가 - WebSocketMessageHelper에 빌더 메서드 추가 - 테스트 추가 - RoomType, RoomStatus enum 테스트 - GameSettings 모델 테스트 - ChattingErrorCode 테스트 업데이트 Related: #440, #441, #442, #443, #444, #445, #446 Closes: #447, #448, #449, #450, #451, #452, #453, #454 * feat: 참가자 닉네임 및 방장 변경 WebSocket 알림 구현 (#456) - RoomParticipant DTO 추가 (userId, nickname, isHost) - ChatRoomQueryService에 닉네임 조회 메서드 추가 - getParticipantsWithNicknames(): 참가자 목록 + 닉네임 - getHostNickname(): 방장 닉네임 조회 - ChatRoomHandler.getRoom() 응답에 participants, hostNickname 추가 - ChatRoomCommandService.leaveRoom()에서 방장 변경 시 WebSocket 브로드캐스트 Related: #440 * fix: ChatRoomFunction에 UserTable DynamoDB 권한 추가 - 참가자/방장 닉네임 조회를 위한 UserTable 읽기 권한 추가 - DynamoDBReadPolicy로 UserTable 접근 허용 Fixes #457 * fix: GameSettings에 @DynamoDbBean 어노테이션 추가 - DynamoDB Enhanced Client가 중첩 객체를 직렬화/역직렬화할 수 있도록 수정 - ChatRoom 저장/조회 시 GameSettings 변환 오류 해결 Fixes #457 * fix: ChatRoomFunction에 WEBSOCKET_ENDPOINT 환경변수 및 권한 추가 - leaveRoom에서 WebSocketBroadcaster 사용을 위한 환경변수 추가 - execute-api:ManageConnections 권한 추가 * fix: WebSocket Lambda 함수들에 WEBSOCKET_ENDPOINT 환경변수 추가 - WebSocketConnectFunction에 WEBSOCKET_ENDPOINT 추가 - WebSocketDisconnectFunction에 WEBSOCKET_ENDPOINT 추가 - execute-api:ManageConnections 권한 추가 * fix: Grammar WebSocket Lambda 환경 변수 및 권한 추가 - GrammarStreamingConnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - GrammarStreamingDisconnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - 두 함수에 execute-api:ManageConnections 권한 추가 * feat: GSI1SK 확장성 있는 재설계 및 DB 레벨 필터링 ## 변경 사항 ### GSI1SK 포맷 변경 - 기존: {level}#{createdAt} - 신규: {type}#{gameType}#{status}#{level}#{createdAt} ### 지원 쿼리 패턴 - 전체 방: GSI1PK = "ROOMS" - 게임방만: begins_with(GSI1SK, "GAME#") - 캐치마인드만: begins_with(GSI1SK, "GAME#CATCHMIND#") - 대기중 캐치마인드: begins_with(GSI1SK, "GAME#CATCHMIND#WAITING#") ### 파일 수정 - ChatRoomCommandService.java: 방 생성 시 새 GSI1SK 포맷 적용 - ChatRoomRepository.java: findByFilters() 메서드 추가, updateStatus() 메서드 추가 - ChatRoomQueryService.java: 메모리 필터링 제거, DB 레벨 필터링으로 변경 - GameService.java: 게임 시작/종료 시 방 상태 업데이트 (GSI1SK 포함) ### 마이그레이션 - scripts/migrate-gsi1sk.sh: 기존 데이터 마이그레이션 스크립트 - 37개 기존 방 마이그레이션 완료 * fix: increase stats query limit to 100 days - Adjust maximum allowable limit for recent stats query from 30 to 100 days to support extended data range responses. * feat: improve game round and connection management logic - Added handling for game round timeout (`ROUND_TIMEOUT`) in WebSocketMessageHandler. - Enhanced game start broadcast to include `currentWord` for the drawer only. - Updated ConnectionRepository to remove duplicate user connections in the same room. - Added `currentWordEnglish` to GameSession for better answer verification. - Normalized ChatRoom levels to match database storage format. - Updated template.yaml to include DynamoDBReadPolicy for VocabTable. * refactor: 코드 정리 및 미사용 클래스 제거 (#459) * refactor: remove unused WebSocketResponseUtil * refactor: add AutoCloseable to WebSocketBroadcaster * refactor: remove unused TestService * refactor: remove unused WordService * refactor: remove unused DailyStudyService * refactor: remove unused UserWordService * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * fix: resolve N+1 query in StatsService * refactor(all): DI 패턴 및 전략 패턴 적용 (#461) - Repository, Service, Handler에 DI 생성자 추가 (테스트 용이성) - BadgeService에 Strategy 패턴 적용 (뱃지 조건 검증 로직 분리) * refactor: relocate and restructure seed data files - Moved `question-homes.json` and `words.json` from subdirectories to `seed` folder. - Updated file paths for better organization and clarity. * chore: seed 데이터 폴더 구조 정리 * feat: add CI/CD pipeline configuration for CodePipeline * fix: add SNS topic policy and DependsOn for notification rule * fix: correct paths in buildspec.yml for CodeBuild * fix: remove hardcoded JAVA_HOME, use runtime default * fix: add gradle wrapper for CI/CD build * fix: use single line sam package command with hardcoded bucket * fix: use existing stack name group2-englishstudy-chatting * fix: add missing WEBSOCKET_ENDPOINT env var to WebSocket connect functions * docs: update FRONTEND-API-GUIDE with new RoomType/RoomStatus structure * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix: update WebSocketDisconnectHandler to use GameSession model - Remove references to deleted ChatRoom game fields - Use GameSessionRepository to finish active game sessions - Use ChatRoomRepository.updateStatus() for room state management * perf: optimize CI/CD build time - Add pip cache for SAM CLI dependencies - Enable Gradle parallel builds and build cache - Add SAM build cache (.aws-sam) - Use sam build --parallel --cached - Skip SAM CLI install if already cached - Remove unnecessary 'clean' to leverage cache * feat: add custom CodeBuild Docker image with pre-installed tools - Dockerfile with Java 21 + SAM CLI + Gradle pre-installed - build-and-push.sh script for ECR deployment - Updated buildspec.yml for custom image (removes SAM CLI install) - Expected build time reduction: ~30-40 seconds * feature : AI 영어 회화 연습 기능 (#468) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix: remove typo in SpeakingConnectionRepository * fix : 오타 수정 * chore: trigger build test with custom Docker image * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes * Release: 캐치마인드 게임 분리, AI 회화 연습, CI/CD 파이프라인 (#469) * feature : OPIc 세션관리 + 답변 처리 파이프라인 구현 (#413) * feat : OPIc 질문 & 세션 관련 dto 생성 * refactor : @DynamoDbBean 주석 추가 * feat : 주제 + 소주제 + 레벨로 오픽 질문 조회 추가 * feat : OPIc 세션, 질문, 답변 종합 handler 구현 * feat : 오픽 주제, 소주제별 seed 데이터 추가 * refactor : Transcribe API KEY 환경변수명 수정 * refactor : Bedrock에 사용하는 클로드 모델 변경 * refactor : S3 Key 대신 Proxy에서 요청하는 Base64 값으로 변환 * feat: GAME_START, ROUND_END 메시지에 serverTime 추가 - broadcastGameStart(): serverTime, roundDuration 필드 추가 - broadcastRoundEnd(): serverTime, roundStartTime, roundDuration 필드 추가 - GameService.endRound(): data에 roundStartTime, roundDuration 포함 - 타이머 동기화 버그 수정을 위한 서버 시간 제공 * feat: WebSocketMessageHelper 유틸리티 클래스 추가 - domain 필드 포함 메시지 생성 헬퍼 - DOMAIN_CHAT, DOMAIN_GAME 상수 정의 - buildChatMessage(), buildGameMessage() 메서드 * feat: 모든 WebSocket 메시지에 domain 필드 추가 - ScoreUpdateMessage에 domain 필드 추가 - broadcastGameStart에 domain:"game" 추가 - broadcastRoundEnd에 domain:"game" 추가 - broadcastCorrectAnswerMessage에 domain:"game" 추가 - handleCommandResult 시스템 메시지에 domain:"game" 추가 - handleRegularMessage 채팅 메시지에 domain:"chat" 추가 Closes #426, #427 * feat: GameSession 모델 클래스 생성 - DynamoDB Enhanced Client 어노테이션 적용 - GSI1: roomId로 활성 게임 세션 조회 가능 - 게임 상태 관리용 헬퍼 메서드 포함 Closes #428 * feat: GameSessionRepository 구현 - 기본 CRUD (save, findById, delete) - roomId로 활성/전체 게임 세션 조회 - 상태, 라운드, 점수 업데이트 메서드 - 정답자 추가, 힌트 사용, 게임 종료 처리 Closes #429 * refactor: ChatRoom에서 게임 필드 분리 - 게임 관련 필드 제거 (gameStatus, currentRound 등) - activeGameSessionId 필드 추가 - 게임 상태는 GameSession으로 분리됨 Note: 의존 코드 수정은 #431에서 진행 Closes #430 * refactor: GameSession 기반으로 전체 게임 로직 리팩토링 - GameService: ChatRoom 대신 GameSession 사용 - GameStatsService: GameSession 매개변수로 변경 - CommandService: GameSession 기반 점수 조회 - GameHandler: GameSession 기반 REST API - WebSocketMessageHandler: GameSession 기반 브로드캐스트 - GameStatusResponse, ScoreboardResponse: GameSession 매개변수 Closes #431 * feat: GameSessionHandler Lambda 및 게임 세션 API 구현 - POST /rooms/{roomId}/games - 게임 세션 생성 - GET /games/{gameSessionId} - 게임 상태 조회 (재접속용) - POST /games/{gameSessionId}/start - 게임 시작 - POST /games/{gameSessionId}/stop - 게임 종료 모든 응답에 serverTime 포함 (타이머 동기화) 출제자에게만 currentWord 포함 Closes #432, #433 * feat: 게임 시작 7분 후 자동 종료 기능 구현 - GameConfig에 gameTimeLimit() 메서드 추가 (기본값: 420초) - GameSchedulerClient 유틸리티 클래스 생성 (EventBridge Scheduler 연동) - GameService에 스케줄 생성/취소 로직 추가 - GameAutoCloseHandler Lambda 함수 생성 - template.yaml에 Lambda, IAM Role, Schedule Group 추가 Closes #417 * fix : 메모리 증가 및 Lambda 응답 제한 시간 Cognito 트리거 제한시간과 동일하게 수정 (#439) * feat: 캐치마인드 게임 방 분리 기능 구현 (#455) - Room 타입 분리 (CHAT/GAME) - RoomType, RoomStatus enum 추가 - ChatRoom 모델에 type, gameType, gameSettings, status, hostId 필드 추가 - GameSettings 모델 추가 - 방 생성/조회 API 수정 - CreateRoomRequest에 type, gameType, gameSettings 필드 추가 - 방 목록 조회 시 type, gameType, status 필터 지원 - 게임 시작 조건 검증 - GAME 타입 방에서만 게임 시작 가능 (GAME_007 에러) - 게임 재시작 API 구현 (#452) - POST /rooms/{roomId}/game/restart 엔드포인트 추가 - 방장만 재시작 가능 (GAME_009 에러) - 게임 진행 중 재시작 불가 (GAME_008 에러) - 방장 변경 로직 구현 (#453) - 방장 퇴장 시 다음 멤버에게 자동 이전 - 모든 멤버 퇴장 시 방 자동 삭제 - WebSocket 메시지 타입 추가 (#454) - ROOM_STATUS_CHANGE, HOST_CHANGE 메시지 타입 추가 - WebSocketMessageHelper에 빌더 메서드 추가 - 테스트 추가 - RoomType, RoomStatus enum 테스트 - GameSettings 모델 테스트 - ChattingErrorCode 테스트 업데이트 Related: #440, #441, #442, #443, #444, #445, #446 Closes: #447, #448, #449, #450, #451, #452, #453, #454 * feat: 참가자 닉네임 및 방장 변경 WebSocket 알림 구현 (#456) - RoomParticipant DTO 추가 (userId, nickname, isHost) - ChatRoomQueryService에 닉네임 조회 메서드 추가 - getParticipantsWithNicknames(): 참가자 목록 + 닉네임 - getHostNickname(): 방장 닉네임 조회 - ChatRoomHandler.getRoom() 응답에 participants, hostNickname 추가 - ChatRoomCommandService.leaveRoom()에서 방장 변경 시 WebSocket 브로드캐스트 Related: #440 * fix: ChatRoomFunction에 UserTable DynamoDB 권한 추가 - 참가자/방장 닉네임 조회를 위한 UserTable 읽기 권한 추가 - DynamoDBReadPolicy로 UserTable 접근 허용 Fixes #457 * fix: GameSettings에 @DynamoDbBean 어노테이션 추가 - DynamoDB Enhanced Client가 중첩 객체를 직렬화/역직렬화할 수 있도록 수정 - ChatRoom 저장/조회 시 GameSettings 변환 오류 해결 Fixes #457 * fix: ChatRoomFunction에 WEBSOCKET_ENDPOINT 환경변수 및 권한 추가 - leaveRoom에서 WebSocketBroadcaster 사용을 위한 환경변수 추가 - execute-api:ManageConnections 권한 추가 * fix: WebSocket Lambda 함수들에 WEBSOCKET_ENDPOINT 환경변수 추가 - WebSocketConnectFunction에 WEBSOCKET_ENDPOINT 추가 - WebSocketDisconnectFunction에 WEBSOCKET_ENDPOINT 추가 - execute-api:ManageConnections 권한 추가 * fix: Grammar WebSocket Lambda 환경 변수 및 권한 추가 - GrammarStreamingConnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - GrammarStreamingDisconnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - 두 함수에 execute-api:ManageConnections 권한 추가 * feat: GSI1SK 확장성 있는 재설계 및 DB 레벨 필터링 ## 변경 사항 ### GSI1SK 포맷 변경 - 기존: {level}#{createdAt} - 신규: {type}#{gameType}#{status}#{level}#{createdAt} ### 지원 쿼리 패턴 - 전체 방: GSI1PK = "ROOMS" - 게임방만: begins_with(GSI1SK, "GAME#") - 캐치마인드만: begins_with(GSI1SK, "GAME#CATCHMIND#") - 대기중 캐치마인드: begins_with(GSI1SK, "GAME#CATCHMIND#WAITING#") ### 파일 수정 - ChatRoomCommandService.java: 방 생성 시 새 GSI1SK 포맷 적용 - ChatRoomRepository.java: findByFilters() 메서드 추가, updateStatus() 메서드 추가 - ChatRoomQueryService.java: 메모리 필터링 제거, DB 레벨 필터링으로 변경 - GameService.java: 게임 시작/종료 시 방 상태 업데이트 (GSI1SK 포함) ### 마이그레이션 - scripts/migrate-gsi1sk.sh: 기존 데이터 마이그레이션 스크립트 - 37개 기존 방 마이그레이션 완료 * fix: increase stats query limit to 100 days - Adjust maximum allowable limit for recent stats query from 30 to 100 days to support extended data range responses. * feat: improve game round and connection management logic - Added handling for game round timeout (`ROUND_TIMEOUT`) in WebSocketMessageHandler. - Enhanced game start broadcast to include `currentWord` for the drawer only. - Updated ConnectionRepository to remove duplicate user connections in the same room. - Added `currentWordEnglish` to GameSession for better answer verification. - Normalized ChatRoom levels to match database storage format. - Updated template.yaml to include DynamoDBReadPolicy for VocabTable. * refactor: 코드 정리 및 미사용 클래스 제거 (#459) * refactor: remove unused WebSocketResponseUtil * refactor: add AutoCloseable to WebSocketBroadcaster * refactor: remove unused TestService * refactor: remove unused WordService * refactor: remove unused DailyStudyService * refactor: remove unused UserWordService * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * fix: resolve N+1 query in StatsService * refactor(all): DI 패턴 및 전략 패턴 적용 (#461) - Repository, Service, Handler에 DI 생성자 추가 (테스트 용이성) - BadgeService에 Strategy 패턴 적용 (뱃지 조건 검증 로직 분리) * refactor: relocate and restructure seed data files - Moved `question-homes.json` and `words.json` from subdirectories to `seed` folder. - Updated file paths for better organization and clarity. * chore: seed 데이터 폴더 구조 정리 * feat: add CI/CD pipeline configuration for CodePipeline * fix: add SNS topic policy and DependsOn for notification rule * fix: correct paths in buildspec.yml for CodeBuild * fix: remove hardcoded JAVA_HOME, use runtime default * fix: add gradle wrapper for CI/CD build * fix: use single line sam package command with hardcoded bucket * fix: use existing stack name group2-englishstudy-chatting * fix: add missing WEBSOCKET_ENDPOINT env var to WebSocket connect functions * docs: update FRONTEND-API-GUIDE with new RoomType/RoomStatus structure * fix: update WebSocketDisconnectHandler to use GameSession model - Remove references to deleted ChatRoom game fields - Use GameSessionRepository to finish active game sessions - Use ChatRoomRepository.updateStatus() for room state management * perf: optimize CI/CD build time - Add pip cache for SAM CLI dependencies - Enable Gradle parallel builds and build cache - Add SAM build cache (.aws-sam) - Use sam build --parallel --cached - Skip SAM CLI install if already cached - Remove unnecessary 'clean' to leverage cache * feat: add custom CodeBuild Docker image with pre-installed tools - Dockerfile with Java 21 + SAM CLI + Gradle pre-installed - build-and-push.sh script for ECR deployment - Updated buildspec.yml for custom image (removes SAM CLI install) - Expected build time reduction: ~30-40 seconds * feature : AI 영어 회화 연습 기능 (#468) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix: remove typo in SpeakingConnectionRepository * chore: trigger build test with custom Docker image * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes --------- Co-authored-by: hyein Heo <128613248+hye-inA@users.noreply.github.com> * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * fix: add CORS headers to API Gateway error responses (#479) API Gateway의 인증 실패 등 에러 응답에 CORS 헤더가 누락되어 CloudFront를 통한 프론트엔드 요청이 차단되는 문제 수정 - UNAUTHORIZED (401) 응답에 CORS 헤더 추가 - ACCESS_DENIED (403) 응답에 CORS 헤더 추가 - DEFAULT_4XX/5XX 응답에 CORS 헤더 추가 - EXPIRED_TOKEN 응답에 CORS 헤더 추가 * feat(news): 뉴스 도메인 기반 구조 구축 (#385) - NewsCategory, QuizType enum 추가 - NewsKey 상수 클래스 추가 - NewsErrorCode 예외 클래스 추가 - NewsArticle, KeywordInfo, QuizQuestion 모델 추가 - NewsArticleRepository CRUD 구현 - NewsTable DynamoDB 테이블 정의 - CORS GatewayResponses 설정 추가 * feat(news): 뉴스 수집 파이프라인 구현 (#386) - NewsApiClient: NewsAPI 연동 서비스 - RssFeedParser: RSS 피드 파싱 (BBC, VOA, NPR) - NewsDuplicateChecker: URL 기반 중복 필터링 - NewsCollectorService: 수집 오케스트레이션 - NewsCollectionHandler: Lambda 핸들러 - EventBridge 스케줄러: 매일 18시 KST 실행 * refactor(news): NewsAPI 제거, RSS만 사용 - NewsApiClient 삭제 - SSM Parameter 의존성 제거 - RSS 소스당 7개씩 수집 (BBC, VOA, NPR = 약 21개/일) * feat(news): AI 뉴스 분석 시스템 구현 (#387) - NewsAnalysisService: AI 분석 통합 서비스 - Bedrock: CEFR 난이도 분석 (A1~C2) - Bedrock: 3줄 요약 + 퀴즈 3문제 생성 - Comprehend: 핵심 키워드 추출 - NewsCollectorService: 수집 시 자동 분석 연동 - GSI1/GSI2 키 자동 설정 (레벨별, 카테고리별 조회) * feat(news): 뉴스 학습 API 구현 (#388) - NewsQueryService: 뉴스 조회 서비스 - NewsHandler: API 핸들러 - GET /news - 목록 조회 (level, category 필터) - GET /news/today - 오늘의 뉴스 - GET /news/recommended - 내 레벨 맞춤 추천 - GET /news/{articleId} - 상세 조회 (조회수 증가) - template.yaml: NewsFunction Lambda 추가 * feat(news): 뉴스 학습 부가 기능 구현 (#389) - 읽기 완료 기록 API (POST /news/{articleId}/read) - 북마크 토글 API (POST /news/{articleId}/bookmark) - 북마크 목록 조회 API (GET /news/bookmarks) - 학습 통계 조회 API (GET /news/stats) - TTS 오디오 URL 조회 API (GET /news/{articleId}/audio) - UserNewsRecord 모델 추가 - UserNewsRepository 추가 - NewsLearningService 추가 * feat(news): 복합 퀴즈 시스템 구현 (#471) - 퀴즈 조회 API (GET /news/{articleId}/quiz) - 퀴즈 제출 API (POST /news/{articleId}/quiz) - 퀴즈 기록 조회 API (GET /news/quiz/history) - NewsQuizResult 모델 추가 - QuizAnswerResult 모델 추가 - NewsQuizRepository 추가 - NewsQuizService 추가 * feat(news): 단어 수집 & Vocabulary 연동 구현 (#472) - 단어 수집 API (POST /news/{articleId}/words) - 수집 단어 목록 API (GET /news/words) - 단어 상세 조회 API (GET /news/{articleId}/words/{word}) - 단어 삭제 API (DELETE /news/{articleId}/words/{word}) - Vocabulary 연동 API (POST /news/words/{word}/sync) - NewsWordCollect 모델 추가 - NewsWordRepository 추가 - NewsWordService 추가 * feat: add multi-environment deployment support (dev/test/prod) * fix: update buildspec.yml to deploy prod environment with parameter overrides * feat(news): 뉴스 도메인 기반 구조 구축 (#385) - NewsCategory, QuizType enum 추가 - NewsKey 상수 클래스 추가 - NewsErrorCode 예외 클래스 추가 - NewsArticle, KeywordInfo, QuizQuestion 모델 추가 - NewsArticleRepository CRUD 구현 - NewsTable DynamoDB 테이블 정의 - CORS GatewayResponses 설정 추가 * feat(news): 뉴스 수집 파이프라인 구현 (#386) - NewsApiClient: NewsAPI 연동 서비스 - RssFeedParser: RSS 피드 파싱 (BBC, VOA, NPR) - NewsDuplicateChecker: URL 기반 중복 필터링 - NewsCollectorService: 수집 오케스트레이션 - NewsCollectionHandler: Lambda 핸들러 - EventBridge 스케줄러: 매일 18시 KST 실행 * refactor(news): NewsAPI 제거, RSS만 사용 - NewsApiClient 삭제 - SSM Parameter 의존성 제거 - RSS 소스당 7개씩 수집 (BBC, VOA, NPR = 약 21개/일) * feat(news): AI 뉴스 분석 시스템 구현 (#387) - NewsAnalysisService: AI 분석 통합 서비스 - Bedrock: CEFR 난이도 분석 (A1~C2) - Bedrock: 3줄 요약 + 퀴즈 3문제 생성 - Comprehend: 핵심 키워드 추출 - NewsCollectorService: 수집 시 자동 분석 연동 - GSI1/GSI2 키 자동 설정 (레벨별, 카테고리별 조회) * feat(news): 뉴스 학습 API 구현 (#388) - NewsQueryService: 뉴스 조회 서비스 - NewsHandler: API 핸들러 - GET /news - 목록 조회 (level, category 필터) - GET /news/today - 오늘의 뉴스 - GET /news/recommended - 내 레벨 맞춤 추천 - GET /news/{articleId} - 상세 조회 (조회수 증가) - template.yaml: NewsFunction Lambda 추가 * feat(news): 뉴스 학습 부가 기능 구현 (#389) - 읽기 완료 기록 API (POST /news/{articleId}/read) - 북마크 토글 API (POST /news/{articleId}/bookmark) - 북마크 목록 조회 API (GET /news/bookmarks) - 학습 통계 조회 API (GET /news/stats) - TTS 오디오 URL 조회 API (GET /news/{articleId}/audio) - UserNewsRecord 모델 추가 - UserNewsRepository 추가 - NewsLearningService 추가 * feat(news): 복합 퀴즈 시스템 구현 (#471) - 퀴즈 조회 API (GET /news/{articleId}/quiz) - 퀴즈 제출 API (POST /news/{articleId}/quiz) - 퀴즈 기록 조회 API (GET /news/quiz/history) - NewsQuizResult 모델 추가 - QuizAnswerResult 모델 추가 - NewsQuizRepository 추가 - NewsQuizService 추가 * feat(news): 단어 수집 & Vocabulary 연동 구현 (#472) - 단어 수집 API (POST /news/{articleId}/words) - 수집 단어 목록 API (GET /news/words) - 단어 상세 조회 API (GET /news/{articleId}/words/{word}) - 단어 삭제 API (DELETE /news/{articleId}/words/{word}) - Vocabulary 연동 API (POST /news/words/{word}/sync) - NewsWordCollect 모델 추가 - NewsWordRepository 추가 - NewsWordService 추가 * feat: add multi-environment deployment support (dev/test/prod) * fix: update buildspec.yml to deploy prod environment with parameter overrides * refactor : AI 말하기 Websocket 구현 -> REST API 구현으로 리팩토링 (#490) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix : 오타 수정 * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * fix: revert buildspec.yml to build-only for CloudFormation deploy stage * fix: revert buildspec.yml to build-only for CloudFormation deploy stage * fix: update all API Gateway StageName to use Environment parameter * fix: correct VocabularyTable and ContentBucket references in NewsFunction * fix: correct VocabularyTable and ContentBucket references in NewsFunction * fix: add stack name prefix to GameScheduleGroup and daily-stats schedule to avoid conflicts * fix: add stack name prefix to GameScheduleGroup and daily-stats schedule to avoid conflicts * feat: add support for existing Cognito User Pool reuse across environments * fix: add conditional Cognito ARN reference in API Gateway Authorizer * fix: remove Cognito resources completely, use existing Cognito only * fix: remove Cognito resources completely, use existing Cognito only * feat : speaking rest API 람다 함수 추가 * feat: add S3 bucket resource and fix environment-specific endpoints - Add ContentBucket S3 resource for content storage - Replace hardcoded /dev with ${Environment} in all WebSocket endpoints - Update Output URLs to use dynamic environment stage * fix: add stack name prefix to news-collection schedule name Prevent EventBridge rule name conflicts across environments * fix: add X-Requested-With and Accept headers to CORS config Enable additional headers for CloudFront CORS compatibility * fix: use environment variable for S3 bucket URLs Replace hardcoded bucket name with BUCKET_NAME env var for multi-env support: - PreSignUpHandler: dynamic default profile URL - PostConfirmationHandler: dynamic default profile URL - UserService: dynamic default profile URL - BadgeType: dynamic badge image base URL * fix: disable authorizer for CORS preflight requests Add AddDefaultAuthorizerToCorsPreflight: false to prevent Cognito Authorizer from blocking OPTIONS requests * feat : speaking REST API 람다 함수 추가 (#491) * feature : OPIc 세션관리 + 답변 처리 파이프라인 구현 (#413) * feat : OPIc 질문 & 세션 관련 dto 생성 * refactor : @DynamoDbBean 주석 추가 * feat : 주제 + 소주제 + 레벨로 오픽 질문 조회 추가 * feat : OPIc 세션, 질문, 답변 종합 handler 구현 * feat : 오픽 주제, 소주제별 seed 데이터 추가 * refactor : Transcribe API KEY 환경변수명 수정 * refactor : Bedrock에 사용하는 클로드 모델 변경 * refactor : S3 Key 대신 Proxy에서 요청하는 Base64 값으로 변환 * feat: GAME_START, ROUND_END 메시지에 serverTime 추가 - broadcastGameStart(): serverTime, roundDuration 필드 추가 - broadcastRoundEnd(): serverTime, roundStartTime, roundDuration 필드 추가 - GameService.endRound(): data에 roundStartTime, roundDuration 포함 - 타이머 동기화 버그 수정을 위한 서버 시간 제공 * feat: WebSocketMessageHelper 유틸리티 클래스 추가 - domain 필드 포함 메시지 생성 헬퍼 - DOMAIN_CHAT, DOMAIN_GAME 상수 정의 - buildChatMessage(), buildGameMessage() 메서드 * feat: 모든 WebSocket 메시지에 domain 필드 추가 - ScoreUpdateMessage에 domain 필드 추가 - broadcastGameStart에 domain:"game" 추가 - broadcastRoundEnd에 domain:"game" 추가 - broadcastCorrectAnswerMessage에 domain:"game" 추가 - handleCommandResult 시스템 메시지에 domain:"game" 추가 - handleRegularMessage 채팅 메시지에 domain:"chat" 추가 Closes #426, #427 * feat: GameSession 모델 클래스 생성 - DynamoDB Enhanced Client 어노테이션 적용 - GSI1: roomId로 활성 게임 세션 조회 가능 - 게임 상태 관리용 헬퍼 메서드 포함 Closes #428 * feat: GameSessionRepository 구현 - 기본 CRUD (save, findById, delete) - roomId로 활성/전체 게임 세션 조회 - 상태, 라운드, 점수 업데이트 메서드 - 정답자 추가, 힌트 사용, 게임 종료 처리 Closes #429 * refactor: ChatRoom에서 게임 필드 분리 - 게임 관련 필드 제거 (gameStatus, currentRound 등) - activeGameSessionId 필드 추가 - 게임 상태는 GameSession으로 분리됨 Note: 의존 코드 수정은 #431에서 진행 Closes #430 * refactor: GameSession 기반으로 전체 게임 로직 리팩토링 - GameService: ChatRoom 대신 GameSession 사용 - GameStatsService: GameSession 매개변수로 변경 - CommandService: GameSession 기반 점수 조회 - GameHandler: GameSession 기반 REST API - WebSocketMessageHandler: GameSession 기반 브로드캐스트 - GameStatusResponse, ScoreboardResponse: GameSession 매개변수 Closes #431 * feat: GameSessionHandler Lambda 및 게임 세션 API 구현 - POST /rooms/{roomId}/games - 게임 세션 생성 - GET /games/{gameSessionId} - 게임 상태 조회 (재접속용) - POST /games/{gameSessionId}/start - 게임 시작 - POST /games/{gameSessionId}/stop - 게임 종료 모든 응답에 serverTime 포함 (타이머 동기화) 출제자에게만 currentWord 포함 Closes #432, #433 * feat: 게임 시작 7분 후 자동 종료 기능 구현 - GameConfig에 gameTimeLimit() 메서드 추가 (기본값: 420초) - GameSchedulerClient 유틸리티 클래스 생성 (EventBridge Scheduler 연동) - GameService에 스케줄 생성/취소 로직 추가 - GameAutoCloseHandler Lambda 함수 생성 - template.yaml에 Lambda, IAM Role, Schedule Group 추가 Closes #417 * fix : 메모리 증가 및 Lambda 응답 제한 시간 Cognito 트리거 제한시간과 동일하게 수정 (#439) * feat: 캐치마인드 게임 방 분리 기능 구현 (#455) - Room 타입 분리 (CHAT/GAME) - RoomType, RoomStatus enum 추가 - ChatRoom 모델에 type, gameType, gameSettings, status, hostId 필드 추가 - GameSettings 모델 추가 - 방 생성/조회 API 수정 - CreateRoomRequest에 type, gameType, gameSettings 필드 추가 - 방 목록 조회 시 type, gameType, status 필터 지원 - 게임 시작 조건 검증 - GAME 타입 방에서만 게임 시작 가능 (GAME_007 에러) - 게임 재시작 API 구현 (#452) - POST /rooms/{roomId}/game/restart 엔드포인트 추가 - 방장만 재시작 가능 (GAME_009 에러) - 게임 진행 중 재시작 불가 (GAME_008 에러) - 방장 변경 로직 구현 (#453) - 방장 퇴장 시 다음 멤버에게 자동 이전 - 모든 멤버 퇴장 시 방 자동 삭제 - WebSocket 메시지 타입 추가 (#454) - ROOM_STATUS_CHANGE, HOST_CHANGE 메시지 타입 추가 - WebSocketMessageHelper에 빌더 메서드 추가 - 테스트 추가 - RoomType, RoomStatus enum 테스트 - GameSettings 모델 테스트 - ChattingErrorCode 테스트 업데이트 Related: #440, #441, #442, #443, #444, #445, #446 Closes: #447, #448, #449, #450, #451, #452, #453, #454 * feat: 참가자 닉네임 및 방장 변경 WebSocket 알림 구현 (#456) - RoomParticipant DTO 추가 (userId, nickname, isHost) - ChatRoomQueryService에 닉네임 조회 메서드 추가 - getParticipantsWithNicknames(): 참가자 목록 + 닉네임 - getHostNickname(): 방장 닉네임 조회 - ChatRoomHandler.getRoom() 응답에 participants, hostNickname 추가 - ChatRoomCommandService.leaveRoom()에서 방장 변경 시 WebSocket 브로드캐스트 Related: #440 * fix: ChatRoomFunction에 UserTable DynamoDB 권한 추가 - 참가자/방장 닉네임 조회를 위한 UserTable 읽기 권한 추가 - DynamoDBReadPolicy로 UserTable 접근 허용 Fixes #457 * fix: GameSettings에 @DynamoDbBean 어노테이션 추가 - DynamoDB Enhanced Client가 중첩 객체를 직렬화/역직렬화할 수 있도록 수정 - ChatRoom 저장/조회 시 GameSettings 변환 오류 해결 Fixes #457 * fix: ChatRoomFunction에 WEBSOCKET_ENDPOINT 환경변수 및 권한 추가 - leaveRoom에서 WebSocketBroadcaster 사용을 위한 환경변수 추가 - execute-api:ManageConnections 권한 추가 * fix: WebSocket Lambda 함수들에 WEBSOCKET_ENDPOINT 환경변수 추가 - WebSocketConnectFunction에 WEBSOCKET_ENDPOINT 추가 - WebSocketDisconnectFunction에 WEBSOCKET_ENDPOINT 추가 - execute-api:ManageConnections 권한 추가 * fix: Grammar WebSocket Lambda 환경 변수 및 권한 추가 - GrammarStreamingConnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - GrammarStreamingDisconnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - 두 함수에 execute-api:ManageConnections 권한 추가 * feat: GSI1SK 확장성 있는 재설계 및 DB 레벨 필터링 - 기존: {level}#{createdAt} - 신규: {type}#{gameType}#{status}#{level}#{createdAt} - 전체 방: GSI1PK = "ROOMS" - 게임방만: begins_with(GSI1SK, "GAME#") - 캐치마인드만: begins_with(GSI1SK, "GAME#CATCHMIND#") - 대기중 캐치마인드: begins_with(GSI1SK, "GAME#CATCHMIND#WAITING#") - ChatRoomCommandService.java: 방 생성 시 새 GSI1SK 포맷 적용 - ChatRoomRepository.java: findByFilters() 메서드 추가, updateStatus() 메서드 추가 - ChatRoomQueryService.java: 메모리 필터링 제거, DB 레벨 필터링으로 변경 - GameService.java: 게임 시작/종료 시 방 상태 업데이트 (GSI1SK 포함) - scripts/migrate-gsi1sk.sh: 기존 데이터 마이그레이션 스크립트 - 37개 기존 방 마이그레이션 완료 * fix: increase stats query limit to 100 days - Adjust maximum allowable limit for recent stats query from 30 to 100 days to support extended data range responses. * feat: improve game round and connection management logic - Added handling for game round timeout (`ROUND_TIMEOUT`) in WebSocketMessageHandler. - Enhanced game start broadcast to include `currentWord` for the drawer only. - Updated ConnectionRepository to remove duplicate user connections in the same room. - Added `currentWordEnglish` to GameSession for better answer verification. - Normalized ChatRoom levels to match database storage format. - Updated template.yaml to include DynamoDBReadPolicy for VocabTable. * refactor: 코드 정리 및 미사용 클래스 제거 (#459) * refactor: remove unused WebSocketResponseUtil * refactor: add AutoCloseable to WebSocketBroadcaster * refactor: remove unused TestService * refactor: remove unused WordService * refactor: remove unused DailyStudyService * refactor: remove unused UserWordService * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * fix: resolve N+1 query in StatsService * refactor(all): DI 패턴 및 전략 패턴 적용 (#461) - Repository, Service, Handler에 DI 생성자 추가 (테스트 용이성) - BadgeService에 Strategy 패턴 적용 (뱃지 조건 검증 로직 분리) * refactor: relocate and restructure seed data files - Moved `question-homes.json` and `words.json` from subdirectories to `seed` folder. - Updated file paths for better organization and clarity. * chore: seed 데이터 폴더 구조 정리 * feat: add CI/CD pipeline configuration for CodePipeline * fix: add SNS topic policy and DependsOn for notification rule * fix: correct paths in buildspec.yml for CodeBuild * fix: remove hardcoded JAVA_HOME, use runtime default * fix: add gradle wrapper for CI/CD build * fix: use single line sam package command with hardcoded bucket * fix: use existing stack name group2-englishstudy-chatting * fix: add missing WEBSOCKET_ENDPOINT env var to WebSocket connect functions * docs: update FRONTEND-API-GUIDE with new RoomType/RoomStatus structure * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix: update WebSocketDisconnectHandler to use GameSession model - Remove references to deleted ChatRoom game fields - Use GameSessionRepository to finish active game sessions - Use ChatRoomRepository.updateStatus() for room state management * perf: optimize CI/CD build time - Add pip cache for SAM CLI dependencies - Enable Gradle parallel builds and build cache - Add SAM build cache (.aws-sam) - Use sam build --parallel --cached - Skip SAM CLI install if already cached - Remove unnecessary 'clean' to leverage cache * feat: add custom CodeBuild Docker image with pre-installed tools - Dockerfile with Java 21 + SAM CLI + Gradle pre-installed - build-and-push.sh script for ECR deployment - Updated buildspec.yml for custom image (removes SAM CLI install) - Expected build time reduction: ~30-40 seconds * feature : AI 영어 회화 연습 기능 (#468) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix: remove typo in SpeakingConnectionRepository * fix : 오타 수정 * chore: trigger build test with custom Docker image * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * fix: add CORS headers to API Gateway error responses (#479) API Gateway의 인증 실패 등 에러 응답에 CORS 헤더가 누락되어 CloudFront를 통한 프론트엔드 요청이 차단되는 문제 수정 - UNAUTHORIZED (401) 응답에 CORS 헤더 추가 - ACCESS_DENIED (403) 응답에 CORS 헤더 추가 - DEFAULT_4XX/5XX 응답에 CORS 헤더 추가 - EXPIRED_TOKEN 응답에 CORS 헤더 추가 * feat : speaking rest API 람다 함수 추가 --------- Co-authored-by: ddingjoo * refactor : speaking service 재사용 * refactor : AI 영어 회화 연습 코드 리팩토링 * feature : test 벡엔드 서버에 AI 말하기 연습 기능 배포 (#492) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix : 오타 수정 * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * feat : speaking rest API 람다 함수 추가 * refactor : speaking service 재사용 * refactor : AI 영어 회화 연습 코드 리팩토링 --------- Co-authored-by: DDING JOO * feat : handleChat 메서드 JsonNull 체크 푸가 * feature : handleChat 메서드 JsonNull 체크 추가 (#493) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix : 오타 수정 * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * feat : speaking rest API 람다 함수 추가 * refactor : speaking service 재사용 * refactor : AI 영어 회화 연습 코드 리팩토링 * feat : handleChat 메서드 JsonNull 체크 푸가 --------- Co-authored-by: DDING JOO * feat(news): 뉴스 학습 배지 시스템 구현 (#473) - 14개 뉴스 관련 배지 추가 (읽기, 퀴즈, 단어수집, 연속학습, 마스터) - UserStats에 뉴스 통계 필드 추가 - 6개 뉴스 배지 Strategy 클래스 생성 - 뉴스 읽기/퀴즈/단어수집 시 통계 업데이트 및 배지 체크 연동 * fix: add PATCH method to CORS AllowMethods * test: BadgeType 개수 테스트 수정 (15 -> 29) * fix: CORS PATCH 메서드 추가 * docs: 뉴스 기능 프론트엔드 연동 가이드 작성 * fix: NewsCollectionFunction에 Bedrock, Comprehend 권한 추가 * fix: add null check for collectWord request body - Add INVALID_REQUEST error code to NewsErrorCode - Check body and word field before accessing in collectWord() - Prevents NullPointerException when request body is malformed * feat: enhance stats API and bookmark response for frontend - Add DAILY stats update for news read/quiz/word collection - Add /stats/dashboard endpoint with frontend-requested format - today: wordsLearned, newsRead, quizzesTaken, wordsTotal - overall: totalWordsLearned, totalNewsRead, averageAccuracy, streaks - weeklyProgress: last 7 days with date/wordsLearned/newsRead - Add news-related fields to all stats API responses - Fix bookmark API to include full article details (title, summary, etc.) * feat: add category classification to news AI analysis - Add category field to AnalysisResult record - Update Bedrock prompt to classify articles into categories (WORLD, POLITICS, BUSINESS, TECH, SCIENCE, HEALTH, SPORTS, ENTERTAINMENT, LIFESTYLE) - Parse and set category from AI response - Set GSI2 (CATEGORY#) index when category is available * fix: add /stats/dashboard endpoint to template.yaml * fix: filter by ARTICLE# prefix in findById to avoid returning UserNewsRecord * docs: add News API troubleshooting guide * feat: add Cognito authorizer to News API and enhance keyword extraction - Set CognitoAuthorizer for all News API endpoints in template.yaml - Update Bedrock AI to extract keywords with meanings and examples - Add fallback to Comprehend for keyword extraction when Bedrock fails - Modify KeywordInfo model to include example field - Adjust AI prompt to include keyword extraction with examples - Update AnalysisResult to store keywords and parse them from AI response * Revert "Merge branch 'test' into prod" This reverts commit c1a958eac6799d5838a76e4078ae304bcdb62278, reversing changes made to a6662e0cfc46c6e12f20a89f0df285ff282160bc. * feat: enhance bookmark and reading status tracking for News API - Add bookmark and reading status to individual article responses - Include bookmark status in paginated news responses - Modify `KeywordInfo` to support Korean translations (`meaningKo`) - Update AI prompts to extract Korean meanings for keywords * refactor : session_id가 null 체크 추가 * feat : template 환경변수 리펙토링 * fix : sessionId NullPointerException 에러 수정 (#496) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix : 오타 수정 * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * feat : speaking rest API 람다 함수 추가 * refactor : speaking service 재사용 * refactor : AI 영어 회화 연습 코드 리팩토링 * feat : handleChat 메서드 JsonNull 체크 푸가 * refactor : session_id가 null 체크 추가 * feat : template 환경변수 리펙토링 --------- Co-authored-by: DDING JOO * feat: enhance bookmark and reading status tracking for News API - Add bookmark and reading status to individual article responses - Include bookmark status in paginated news responses - Modify `KeywordInfo` to support Korean translations (`meaningKo`) - Update AI prompts to extract Korean meanings for keywords * feat: add category filtering to UserWord API with enhanced query logic - Introduce `category` filtering to `getUserWords` API - Update WordCategory enums to include "news" category - Apply category filter after enrichment with word info - Adjust query limits for bookmarked and incorrect words queries * feat: add default value for ExistingCognitoClientId in template.yaml - Set default to an empty string to ensure compatibility with templates using this parameter without explicitly specifying a value. * fix: SpeakingHandler getStringOrNull 컴파일 에러 수정 * fix: SpeakingHandler getStringOrNull 컴파일 에러 수정 * fix: Bedrock 키워드(meaningKo 포함)를 article에 저장하도록 수정 * fix: Bedrock 키워드(meaningKo 포함)를 article에 저장하도록 수정 * fix: Bedrock 키워드(meaningKo 포함)를 article에 저장하도록 수정 * feat: add dashboard stats API and enhance news stats tracking - Introduce `/stats/dashboard` API for retrieving integrated user stats (today, overall, weekly progress, and level distribution) - Update `UserStats` model to include additional fields for news stats (newsRead, newsQuizCompleted, newsQuizPerfect, newsWordsCollected, etc.) - Add atomic updates to track news reading, quiz, and word stats in `UserStatsRepository` - Modify CloudFormation `template.yaml` to register the new `/stats/dashboard` endpoint * feat: add dashboard stats API and enhance news stats tracking - Introduce `/stats/dashboard` API for retrieving integrated user stats (today, overall, weekly progress, and level distribution) - Update `UserStats` model to include additional fields for news stats (newsRead, newsQuizCompleted, newsQuizPerfect, newsWordsCollected, etc.) - Add atomic updates to track news reading, quiz, and word stats in `UserStatsRepository` - Modify CloudFormation `template.yaml` to register the new `/stats/dashboard` endpoint * feat: add dashboard stats API and enhance news stats tracking - Introduce `/stats/dashboard` API for retrieving integrated user stats (today, overall, weekly progress, and level distribution) - Update `UserStats` model to include additional fields for news stats (newsRead, newsQuizCompleted, newsQuizPerfect, newsWordsCollected, etc.) - Add atomic updates to track news reading, quiz, and word stats in `UserStatsRepository` - Modify CloudFormation `template.yaml` to register the new `/stats/dashboard` endpoint * refactor: format code with consistent indentation and spacing - Apply consistent formatting to improve code readability across multiple files - Adjust indentation, spacing, and alignment in model classes, DTOs, and repository methods * fix: filter by ARTICLE# prefix in findById to avoid returning bookmark records * feat : Speaking 관련 template 람다 함수 및 테이블 추가 --------- Co-authored-by: DDING JOO --- .../handler/SpeakingConnectHandler.java | 0 .../handler/SpeakingDisconnectHandler.java | 0 .../speaking/handler/SpeakingHandler.java | 271 +++++++++--------- .../handler/SpeakingMessageHandler.java | 0 .../SpeakingConnectionRepository.java | 0 .../repository/SpeakingSessionRepository.java | 116 ++++---- ServerlessFunction/template.yaml | 78 +++++ 7 files changed, 273 insertions(+), 192 deletions(-) delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingConnectHandler.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingDisconnectHandler.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingMessageHandler.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingConnectHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingConnectHandler.java deleted file mode 100644 index e69de29b..00000000 diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingDisconnectHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingDisconnectHandler.java deleted file mode 100644 index e69de29b..00000000 diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingHandler.java index ed6fbda0..2ebda605 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingHandler.java @@ -19,142 +19,145 @@ /** * Speaking API 핸들러 - *

+ * * POST /api/speaking/chat - 대화 (음성 또는 텍스트) * POST /api/speaking/reset - 대화 초기화 */ public class SpeakingHandler implements RequestHandler { - - private static final Logger logger = LoggerFactory.getLogger(SpeakingHandler.class); - private static final Gson gson = new GsonBuilder().create(); - - private static final Map CORS_HEADERS = Map.of( - "Content-Type", "application/json", - "Access-Control-Allow-Origin", "*", - "Access-Control-Allow-Headers", "Content-Type,Authorization", - "Access-Control-Allow-Methods", "POST,OPTIONS" - ); - - private final SpeakingService speakingService; - - public SpeakingHandler() { - this.speakingService = new SpeakingService(); - } - - @Override - public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent event, Context context) { - logger.info("Speaking API request received"); - - // OPTIONS 요청 처리 (CORS preflight) - if ("OPTIONS".equalsIgnoreCase(event.getHttpMethod())) { - return response(200, Map.of("message", "OK")); - } - - try { - // JWT 토큰 검증 - String authHeader = event.getHeaders().get("Authorization"); - if (authHeader == null || !authHeader.startsWith("Bearer ")) { - return response(401, Map.of("error", "Authorization header is required")); - } - - String token = authHeader.substring(7); - if (!JwtUtil.isValid(token)) { - return response(401, Map.of("error", "Invalid or expired token")); - } - - Optional userIdOpt = JwtUtil.extractUserId(token); - if (userIdOpt.isEmpty()) { - return response(401, Map.of("error", "Invalid token")); - } - - String userId = userIdOpt.get(); - String path = event.getPath(); - String body = event.getBody(); - - logger.info("Processing request: path={}, userId={}", path, userId); - - // 라우팅 - if (path.endsWith("/chat")) { - return handleChat(userId, body); - } else if (path.endsWith("/reset")) { - return handleReset(userId, body); - } else { - return response(404, Map.of("error", "Not found")); - } - - } catch (Exception e) { - logger.error("Error processing request: {}", e.getMessage(), e); - return response(500, Map.of("error", "Internal server error: " + e.getMessage())); - } - } - - /** - * 대화 처리 (음성 또는 텍스트) - */ - private APIGatewayProxyResponseEvent handleChat(String userId, String body) { - if (body == null || body.isEmpty()) { - return response(400, Map.of("error", "Request body is required")); - } - - JsonObject request = JsonParser.parseString(body).getAsJsonObject(); - - String sessionId = request.has("sessionId") ? request.get("sessionId").getAsString() : null; - String level = request.has("level") ? request.get("level").getAsString() : "INTERMEDIATE"; - String audio = request.has("audio") ? request.get("audio").getAsString() : null; - String text = request.has("text") ? request.get("text").getAsString() : null; - - SpeakingResponse result; - - if (audio != null && !audio.isEmpty()) { - // 음성 입력 처리 - logger.info("Processing voice input"); - result = speakingService.processVoiceInput(sessionId, userId, audio, level); - } else if (text != null && !text.trim().isEmpty()) { - // 텍스트 입력 처리 - logger.info("Processing text input: {}", text); - result = speakingService.processTextInput(sessionId, userId, text.trim(), level); - } else { - return response(400, Map.of("error", "Either 'audio' or 'text' is required")); - } - - return response(200, Map.of( - "sessionId", result.sessionId(), - "userTranscript", result.userTranscript(), - "aiText", result.aiText(), - "aiAudioUrl", result.aiAudioUrl(), - "confidence", result.confidence() - )); - } - - /** - * 대화 초기화 - */ - private APIGatewayProxyResponseEvent handleReset(String userId, String body) { - if (body == null || body.isEmpty()) { - return response(400, Map.of("error", "Request body is required")); - } - - JsonObject request = JsonParser.parseString(body).getAsJsonObject(); - String sessionId = request.has("sessionId") ? request.get("sessionId").getAsString() : null; - - if (sessionId == null || sessionId.isEmpty()) { - return response(400, Map.of("error", "sessionId is required")); - } - - speakingService.resetConversation(sessionId); - - return response(200, Map.of( - "message", "Conversation reset successfully", - "sessionId", sessionId - )); - } - - private APIGatewayProxyResponseEvent response(int statusCode, Map body) { - return new APIGatewayProxyResponseEvent() - .withStatusCode(statusCode) - .withHeaders(CORS_HEADERS) - .withBody(gson.toJson(body)); - } - - + + private static final Logger logger = LoggerFactory.getLogger(SpeakingHandler.class); + private static final Gson gson = new GsonBuilder().create(); + + private static final Map CORS_HEADERS = Map.of( + "Content-Type", "application/json", + "Access-Control-Allow-Origin", "*", + "Access-Control-Allow-Headers", "Content-Type,Authorization", + "Access-Control-Allow-Methods", "POST,OPTIONS" + ); + + private final SpeakingService speakingService; + + public SpeakingHandler() { + this.speakingService = new SpeakingService(); + } + + @Override + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent event, Context context) { + logger.info("Speaking API request received"); + + // OPTIONS 요청 처리 (CORS preflight) + if ("OPTIONS".equalsIgnoreCase(event.getHttpMethod())) { + return response(200, Map.of("message", "OK")); + } + + try { + // 사용자 인증 정보 추출 (Cognito Authorizer -> requestContext) + if (event.getRequestContext() == null || event.getRequestContext().getAuthorizer() == null) { + logger.error("No Authorizer found in request context"); + return response(401, Map.of("error", "Unauthorized: User context missing")); + } + + Map authorizer = event.getRequestContext().getAuthorizer(); + Map claims = (Map) authorizer.get("claims"); + + if (claims == null) { + return response(401, Map.of("error", "Unauthorized: Claims missing")); + } + + String userId = (String) claims.get("sub"); // Cognito User Pool의 고유 ID (UUID 형태) + + // 요청 정보 추출 + String path = event.getPath(); + String body = event.getBody(); + + logger.info("Processing request: path={}, userId={}", path, userId); + + // 라우팅 + if (path != null && path.endsWith("/chat")) { + return handleChat(userId, body); + } else if (path != null && path.endsWith("/reset")) { + return handleReset(userId, body); + } else { + return response(404, Map.of("error", "Not found")); + } + + } catch (Exception e) { + logger.error("Error processing request: {}", e.getMessage(), e); + return response(500, Map.of("error", "Internal server error: " + e.getMessage())); + } + } + + /** + * 대화 처리 (음성 또는 텍스트) + */ + private APIGatewayProxyResponseEvent handleChat(String userId, String body) { + if (body == null || body.isEmpty()) { + return response(400, Map.of("error", "Request body is required")); + } + + JsonObject request = JsonParser.parseString(body).getAsJsonObject(); + + String sessionId = request.has("sessionId") && !request.get("sessionId").isJsonNull() + ? request.get("sessionId").getAsString() : null; + String level = request.has("level") && !request.get("level").isJsonNull() + ? request.get("level").getAsString() : "INTERMEDIATE"; + String audio = request.has("audio") && !request.get("audio").isJsonNull() + ? request.get("audio").getAsString() : null; + String text = request.has("text") && !request.get("text").isJsonNull() + ? request.get("text").getAsString() : null; + + SpeakingResponse result; + + if (audio != null && !audio.isEmpty()) { + // 음성 입력 처리 + logger.info("Processing voice event"); + result = speakingService.processVoiceInput(sessionId, userId, audio, level); + } else if (text != null && !text.trim().isEmpty()) { + // 텍스트 입력 처리 + logger.info("Processing text event: {}", text); + result = speakingService.processTextInput(sessionId, userId, text.trim(), level); + } else { + return response(400, Map.of("error", "Either 'audio' or 'text' is required")); + } + + return response(200, Map.of( + "sessionId", result.sessionId(), + "userTranscript", result.userTranscript(), + "aiText", result.aiText(), + "aiAudioUrl", result.aiAudioUrl(), + "confidence", result.confidence() + )); + } + + /** + * 대화 초기화 + */ + private APIGatewayProxyResponseEvent handleReset(String userId, String body) { + if (body == null || body.isEmpty()) { + return response(400, Map.of("error", "Request body is required")); + } + + JsonObject request = JsonParser.parseString(body).getAsJsonObject(); + String sessionId = request.has("sessionId") ? request.get("sessionId").getAsString() : null; + + if (sessionId == null || sessionId.isEmpty()) { + return response(400, Map.of("error", "sessionId is required")); + } + + speakingService.resetConversation(sessionId); + + return response(200, Map.of( + "message", "Conversation reset successfully", + "sessionId", sessionId + )); + } + + private APIGatewayProxyResponseEvent response(int statusCode, Map body) { + return new APIGatewayProxyResponseEvent() + .withStatusCode(statusCode) + .withHeaders(CORS_HEADERS) + .withBody(gson.toJson(body)); + } + + } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingMessageHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingMessageHandler.java deleted file mode 100644 index e69de29b..00000000 diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java deleted file mode 100644 index e69de29b..00000000 diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingSessionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingSessionRepository.java index aa7acb63..dadda7e7 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingSessionRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingSessionRepository.java @@ -12,63 +12,63 @@ import java.util.Optional; /** - * Speaking WebSocket 연결 정보 Repository + * Speaking API 연결 정보 Repository */ public class SpeakingSessionRepository { - - private static final Logger logger = LoggerFactory.getLogger(SpeakingSessionRepository.class); - private static final String TABLE_NAME = EnvConfig.getRequired("CHAT_TABLE_NAME"); - - private final DynamoDbTable table; - - public SpeakingSessionRepository() { - this.table = AwsClients.dynamoDbEnhanced().table( - TABLE_NAME, - TableSchema.fromBean(SpeakingSession.class) - ); - } - - /** - * 연결 정보 저장 - */ - public void save(SpeakingSession session) { - table.putItem(session); - logger.debug("Speaking session saved: sessionId={}, userId={}", - session.getSessionId(), session.getUserId()); - } - - /** - * sessionId로 연결 정보 조회 - */ - public Optional findBySessionId(String sessionId) { - Key key = Key.builder() - .partitionValue(SpeakingSession.PK_PREFIX + sessionId) - .sortValue(SpeakingSession.SK_METADATA) - .build(); - - SpeakingSession session = table.getItem(key); - return Optional.ofNullable(session); - } - - /** - * 연결 정보 업데이트 (대화 히스토리 등) - */ - public void update(SpeakingSession session) { - session.touch(); // 업데이트 시간 및 TTL 갱신 - table.putItem(session); - logger.debug("Speaking session updated: sessionId={}", session.getSessionId()); - } - - /** - * 연결 정보 삭제 - */ - public void delete(String sessionId) { - Key key = Key.builder() - .partitionValue(SpeakingSession.PK_PREFIX + sessionId) - .sortValue(SpeakingSession.SK_METADATA) - .build(); - - table.deleteItem(key); - logger.info("Speaking session deleted: sessionId={}", sessionId); - } -} + + private static final Logger logger = LoggerFactory.getLogger(SpeakingSessionRepository.class); + private static final String TABLE_NAME = EnvConfig.getRequired("SPEAKING_TABLE_NAME"); + + private final DynamoDbTable table; + + public SpeakingSessionRepository() { + this.table = AwsClients.dynamoDbEnhanced().table( + TABLE_NAME, + TableSchema.fromBean(SpeakingSession.class) + ); + } + + /** + * 연결 정보 저장 + */ + public void save(SpeakingSession session) { + table.putItem(session); + logger.debug("Speaking session saved: sessionId={}, userId={}", + session.getSessionId(), session.getUserId()); + } + + /** + * sessionId로 연결 정보 조회 + */ + public Optional findBySessionId(String sessionId) { + Key key = Key.builder() + .partitionValue(SpeakingSession.PK_PREFIX + sessionId) + .sortValue(SpeakingSession.SK_METADATA) + .build(); + + SpeakingSession session = table.getItem(key); + return Optional.ofNullable(session); + } + + /** + * 연결 정보 업데이트 (대화 히스토리 등) + */ + public void update(SpeakingSession session) { + session.touch(); // 업데이트 시간 및 TTL 갱신 + table.putItem(session); + logger.debug("Speaking session updated: sessionId={}", session.getSessionId()); + } + + /** + * 연결 정보 삭제 + */ + public void delete(String sessionId) { + Key key = Key.builder() + .partitionValue(SpeakingSession.PK_PREFIX + sessionId) + .sortValue(SpeakingSession.SK_METADATA) + .build(); + + table.deleteItem(key); + logger.info("Speaking session deleted: sessionId={}", sessionId); + } +} \ No newline at end of file diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 3fa504b5..50fbb74d 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -37,6 +37,7 @@ Globals: VOCAB_TABLE_NAME: !Ref VocabTable OPIC_TABLE_NAME: !Ref OPIcTable NEWS_TABLE_NAME: !Ref NewsTable + SPEAKING_TABLE_NAME: !Ref SpeakingTable BUCKET_NAME: !Sub "${AWS::StackName}" CHAT_BUCKET_NAME: !Sub "${AWS::StackName}" VOCAB_BUCKET_NAME: !Sub "${AWS::StackName}" @@ -1551,6 +1552,47 @@ Resources: Auth: Authorizer: CognitoAuthorizer + ############################################# + # Speaking Lambda Functions + ############################################# + + SpeakingFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: !Sub "${AWS::StackName}-speaking-handler" + CodeUri: . + Handler: com.mzc.secondproject.serverless.domain.speaking.handler.SpeakingHandler::handleRequest + Description: Handle speaking chat API + SnapStart: + ApplyOn: PublishedVersions + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref SpeakingTable + - S3CrudPolicy: + BucketName: !Sub "${AWS::StackName}" + - Statement: + - Effect: Allow + Action: + - bedrock:InvokeModel + Resource: "*" + Events: + SpeakingChat: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /speaking/chat + Method: POST + Auth: + Authorizer: CognitoAuthorizer + SpeakingReset: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /speaking/reset + Method: POST + Auth: + Authorizer: CognitoAuthorizer + ############################################# # DynamoDB Tables ############################################# @@ -1935,6 +1977,38 @@ Resources: AttributeName: ttl Enabled: true + SpeakingTable: + Type: AWS::DynamoDB::Table + Properties: + TableName: !Sub "${AWS::StackName}-speaking" + BillingMode: PAY_PER_REQUEST + AttributeDefinitions: + - AttributeName: PK + AttributeType: S + - AttributeName: SK + AttributeType: S + - AttributeName: GSI1PK + AttributeType: S + - AttributeName: GSI1SK + AttributeType: S + KeySchema: + - AttributeName: PK + KeyType: HASH + - AttributeName: SK + KeyType: RANGE + GlobalSecondaryIndexes: + - IndexName: GSI1 + KeySchema: + - AttributeName: GSI1PK + KeyType: HASH + - AttributeName: GSI1SK + KeyType: RANGE + Projection: + ProjectionType: ALL + TimeToLiveSpecification: + AttributeName: ttl + Enabled: true + ############################################# # S3 Bucket for Content Storage ############################################# @@ -2164,6 +2238,10 @@ Outputs: Description: OPIc DynamoDB Table Name Value: !Ref OPIcTable + SpeakingTableName: + Description: Speaking DynamoDB Table Name + Value: !Ref SpeakingTable + NotificationStreamUrl: Description: Notification SSE Stream Function URL Value: !GetAtt NotificationStreamFunctionUrl.FunctionUrl From e5ff39ae802af9c5be73603f346f5bb97d0652fb Mon Sep 17 00:00:00 2001 From: hyein Heo <128613248+hye-inA@users.noreply.github.com> Date: Sat, 24 Jan 2026 00:40:38 +0900 Subject: [PATCH 512/528] =?UTF-8?q?feature=20:=20=EB=A7=90=ED=95=98?= =?UTF-8?q?=EA=B8=B0=20=EC=97=B0=EC=8A=B5=20=EA=B8=B0=EB=8A=A5=20polly=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=20=EA=B6=8C=ED=95=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#514)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: EnvConfig 유틸리티 추가 및 환경 변수 검증 적용 환경 변수 미설정 시 명확한 에러 메시지를 제공하는 EnvConfig 유틸리티를 추가하고, 기존 System.getenv 호출을 EnvConfig.getRequired/getOrDefault로 대체함. - EnvConfig: getRequired, getOrDefault, getIntOrDefault, getLongOrDefault 메서드 제공 - Lambda Cold Start 시점에 환경 변수 누락을 조기 감지 - 기존 Config 클래스(WebSocketConfig, RoomTokenConfig) EnvConfig 사용으로 통일 Closes #403 * refactor: TestService submitTest 메서드 책임 분리 submitTest 메서드를 단일 책임 원칙에 맞게 리팩토링: - gradeAnswers(): 답안 채점 및 결과 집계 - isAnswerCorrect(): 단일 답안 정답 여부 판단 - buildResultItem(): 결과 항목 생성 - saveTestResult(): 테스트 결과 저장 - GradingResult record: 채점 결과 캡슐화 TestService와 TestCommandService 모두 동일하게 적용 Closes #404 * refactor: 하드코딩된 설정값 환경 변수로 외부화 각 도메인별 Config 클래스를 생성하여 하드코딩된 값들을 환경 변수로 설정 가능하게 변경. 기본값이 있어 환경 변수 미설정 시에도 기존 동작 유지. ## 새로 추가된 Config 클래스 - GrammarConfig: SESSION_TTL_DAYS, MAX_HISTORY_MESSAGES, MAX_TOKENS 등 - GameConfig: TOTAL_ROUNDS, ROUND_TIME_LIMIT, QUICK_GUESS_THRESHOLD_MS - VocabularyConfig: NEW_WORDS_COUNT, REVIEW_WORDS_COUNT, 상태 전이 임계값 등 ## 지원하는 환경 변수 - GRAMMAR_SESSION_TTL_DAYS, GRAMMAR_MAX_HISTORY_MESSAGES, GRAMMAR_MAX_TOKENS - GAME_TOTAL_ROUNDS, GAME_ROUND_TIME_LIMIT, GAME_QUICK_GUESS_THRESHOLD_MS - VOCAB_NEW_WORDS_COUNT, VOCAB_REVIEW_WORDS_COUNT - VOCAB_TRANSITION_TO_REVIEWING, VOCAB_TRANSITION_TO_MASTERED Closes #406 * test: StudyLevel enum 단위 테스트 추가 * test: Difficulty enum 단위 테스트 추가 * test: StudyConfig 단위 테스트 추가 * test: EnvConfig 단위 테스트 추가 * test: PaginatedResult 단위 테스트 추가 * test: JsonUtil 단위 테스트 추가 * test: CursorUtil 단위 테스트 추가 * test: CommonErrorCode 단위 테스트 추가 * test: CommonException 단위 테스트 추가 * test: BadgeType enum 단위 테스트 추가 * test: BadgeKey 상수 단위 테스트 추가 * test: WordStatus enum 단위 테스트 추가 * test: TestType enum 단위 테스트 추가 * test: VocabularyConfig 단위 테스트 추가 * test: VocabKey 상수 단위 테스트 추가 * test: VocabularyErrorCode 단위 테스트 추가 * test: VocabularyException 단위 테스트 추가 * test: SpacedRepetitionContext 단위 테스트 추가 * test: GrammarLevel enum 단위 테스트 추가 * test: GrammarConfig 단위 테스트 추가 * test: GrammarErrorCode 단위 테스트 추가 * test: GrammarException 단위 테스트 추가 * test: GameStatus enum 단위 테스트 추가 * test: GameConfig 단위 테스트 추가 * test: ChattingErrorCode 단위 테스트 추가 * test: ChattingException 단위 테스트 추가 * fix: OPIc FeedbackResponse.java 문법 오류 수정 * style: AwsClients 코드 포맷팅 * style: EnvConfig 코드 포맷팅 * style: RoomTokenConfig 코드 포맷팅 * style: WebSocketConfig 코드 포맷팅 * style: JsonUtil 코드 포맷팅 * style: GameConfig 코드 포맷팅 * style: GrammarConfig 코드 포맷팅 * style: GrammarKey 코드 포맷팅 * style: GrammarErrorCode 코드 포맷팅 * style: GrammarException 코드 포맷팅 * style: BedrockGrammarCheckFactory 코드 포맷팅 * style: GrammarConversationService 코드 포맷팅 * style: FeedbackResponse 코드 포맷팅 * style: SessionReportResponse 코드 포맷팅 * style: SpeakingError 코드 포맷팅 * style: SpeakingErrorType 코드 포맷팅 * style: OPIcException 코드 포맷팅 * style: OPIcAnswer 코드 포맷팅 * style: OPIcQuestion 코드 포맷팅 * style: OPIcSession 코드 포맷팅 * style: OPIcRepository 코드 포맷팅 * style: FeedbackService 코드 포맷팅 * style: TranscribeProxyService 코드 포맷팅 * style: VocabularyConfig 코드 포맷팅 * style: DailyStudyCommandService 코드 포맷팅 * style: TestCommandService 코드 포맷팅 * style: TestService 코드 포맷팅 * style: CommonErrorCodeSpec 코드 포맷팅 * style: JsonUtilSpec 코드 포맷팅 * style: BadgeTypeSpec 코드 포맷팅 * style: ChattingErrorCodeSpec 코드 포맷팅 * style: GrammarLevelSpec 코드 포맷팅 * style: GrammarErrorCodeSpec 코드 포맷팅 * style: VocabularyErrorCodeSpec 코드 포맷팅 * feature : OPIc 세션관리 + 답변 처리 파이프라인 구현 (#413) * feat : OPIc 질문 & 세션 관련 dto 생성 * refactor : @DynamoDbBean 주석 추가 * feat : 주제 + 소주제 + 레벨로 오픽 질문 조회 추가 * feat : OPIc 세션, 질문, 답변 종합 handler 구현 * feat : 오픽 주제, 소주제별 seed 데이터 추가 * refactor : Transcribe API KEY 환경변수명 수정 * refactor : Bedrock에 사용하는 클로드 모델 변경 * refactor : S3 Key 대신 Proxy에서 요청하는 Base64 값으로 변환 * feat: GAME_START, ROUND_END 메시지에 serverTime 추가 - broadcastGameStart(): serverTime, roundDuration 필드 추가 - broadcastRoundEnd(): serverTime, roundStartTime, roundDuration 필드 추가 - GameService.endRound(): data에 roundStartTime, roundDuration 포함 - 타이머 동기화 버그 수정을 위한 서버 시간 제공 * feat: WebSocketMessageHelper 유틸리티 클래스 추가 - domain 필드 포함 메시지 생성 헬퍼 - DOMAIN_CHAT, DOMAIN_GAME 상수 정의 - buildChatMessage(), buildGameMessage() 메서드 * feat: 모든 WebSocket 메시지에 domain 필드 추가 - ScoreUpdateMessage에 domain 필드 추가 - broadcastGameStart에 domain:"game" 추가 - broadcastRoundEnd에 domain:"game" 추가 - broadcastCorrectAnswerMessage에 domain:"game" 추가 - handleCommandResult 시스템 메시지에 domain:"game" 추가 - handleRegularMessage 채팅 메시지에 domain:"chat" 추가 Closes #426, #427 * feat: GameSession 모델 클래스 생성 - DynamoDB Enhanced Client 어노테이션 적용 - GSI1: roomId로 활성 게임 세션 조회 가능 - 게임 상태 관리용 헬퍼 메서드 포함 Closes #428 * feat: GameSessionRepository 구현 - 기본 CRUD (save, findById, delete) - roomId로 활성/전체 게임 세션 조회 - 상태, 라운드, 점수 업데이트 메서드 - 정답자 추가, 힌트 사용, 게임 종료 처리 Closes #429 * refactor: ChatRoom에서 게임 필드 분리 - 게임 관련 필드 제거 (gameStatus, currentRound 등) - activeGameSessionId 필드 추가 - 게임 상태는 GameSession으로 분리됨 Note: 의존 코드 수정은 #431에서 진행 Closes #430 * refactor: GameSession 기반으로 전체 게임 로직 리팩토링 - GameService: ChatRoom 대신 GameSession 사용 - GameStatsService: GameSession 매개변수로 변경 - CommandService: GameSession 기반 점수 조회 - GameHandler: GameSession 기반 REST API - WebSocketMessageHandler: GameSession 기반 브로드캐스트 - GameStatusResponse, ScoreboardResponse: GameSession 매개변수 Closes #431 * feat: GameSessionHandler Lambda 및 게임 세션 API 구현 - POST /rooms/{roomId}/games - 게임 세션 생성 - GET /games/{gameSessionId} - 게임 상태 조회 (재접속용) - POST /games/{gameSessionId}/start - 게임 시작 - POST /games/{gameSessionId}/stop - 게임 종료 모든 응답에 serverTime 포함 (타이머 동기화) 출제자에게만 currentWord 포함 Closes #432, #433 * feat: 게임 시작 7분 후 자동 종료 기능 구현 - GameConfig에 gameTimeLimit() 메서드 추가 (기본값: 420초) - GameSchedulerClient 유틸리티 클래스 생성 (EventBridge Scheduler 연동) - GameService에 스케줄 생성/취소 로직 추가 - GameAutoCloseHandler Lambda 함수 생성 - template.yaml에 Lambda, IAM Role, Schedule Group 추가 Closes #417 * fix : 메모리 증가 및 Lambda 응답 제한 시간 Cognito 트리거 제한시간과 동일하게 수정 (#439) * feat: 캐치마인드 게임 방 분리 기능 구현 (#455) - Room 타입 분리 (CHAT/GAME) - RoomType, RoomStatus enum 추가 - ChatRoom 모델에 type, gameType, gameSettings, status, hostId 필드 추가 - GameSettings 모델 추가 - 방 생성/조회 API 수정 - CreateRoomRequest에 type, gameType, gameSettings 필드 추가 - 방 목록 조회 시 type, gameType, status 필터 지원 - 게임 시작 조건 검증 - GAME 타입 방에서만 게임 시작 가능 (GAME_007 에러) - 게임 재시작 API 구현 (#452) - POST /rooms/{roomId}/game/restart 엔드포인트 추가 - 방장만 재시작 가능 (GAME_009 에러) - 게임 진행 중 재시작 불가 (GAME_008 에러) - 방장 변경 로직 구현 (#453) - 방장 퇴장 시 다음 멤버에게 자동 이전 - 모든 멤버 퇴장 시 방 자동 삭제 - WebSocket 메시지 타입 추가 (#454) - ROOM_STATUS_CHANGE, HOST_CHANGE 메시지 타입 추가 - WebSocketMessageHelper에 빌더 메서드 추가 - 테스트 추가 - RoomType, RoomStatus enum 테스트 - GameSettings 모델 테스트 - ChattingErrorCode 테스트 업데이트 Related: #440, #441, #442, #443, #444, #445, #446 Closes: #447, #448, #449, #450, #451, #452, #453, #454 * feat: 참가자 닉네임 및 방장 변경 WebSocket 알림 구현 (#456) - RoomParticipant DTO 추가 (userId, nickname, isHost) - ChatRoomQueryService에 닉네임 조회 메서드 추가 - getParticipantsWithNicknames(): 참가자 목록 + 닉네임 - getHostNickname(): 방장 닉네임 조회 - ChatRoomHandler.getRoom() 응답에 participants, hostNickname 추가 - ChatRoomCommandService.leaveRoom()에서 방장 변경 시 WebSocket 브로드캐스트 Related: #440 * fix: ChatRoomFunction에 UserTable DynamoDB 권한 추가 - 참가자/방장 닉네임 조회를 위한 UserTable 읽기 권한 추가 - DynamoDBReadPolicy로 UserTable 접근 허용 Fixes #457 * fix: GameSettings에 @DynamoDbBean 어노테이션 추가 - DynamoDB Enhanced Client가 중첩 객체를 직렬화/역직렬화할 수 있도록 수정 - ChatRoom 저장/조회 시 GameSettings 변환 오류 해결 Fixes #457 * fix: ChatRoomFunction에 WEBSOCKET_ENDPOINT 환경변수 및 권한 추가 - leaveRoom에서 WebSocketBroadcaster 사용을 위한 환경변수 추가 - execute-api:ManageConnections 권한 추가 * fix: WebSocket Lambda 함수들에 WEBSOCKET_ENDPOINT 환경변수 추가 - WebSocketConnectFunction에 WEBSOCKET_ENDPOINT 추가 - WebSocketDisconnectFunction에 WEBSOCKET_ENDPOINT 추가 - execute-api:ManageConnections 권한 추가 * fix: Grammar WebSocket Lambda 환경 변수 및 권한 추가 - GrammarStreamingConnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - GrammarStreamingDisconnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - 두 함수에 execute-api:ManageConnections 권한 추가 * feat: GSI1SK 확장성 있는 재설계 및 DB 레벨 필터링 ## 변경 사항 ### GSI1SK 포맷 변경 - 기존: {level}#{createdAt} - 신규: {type}#{gameType}#{status}#{level}#{createdAt} ### 지원 쿼리 패턴 - 전체 방: GSI1PK = "ROOMS" - 게임방만: begins_with(GSI1SK, "GAME#") - 캐치마인드만: begins_with(GSI1SK, "GAME#CATCHMIND#") - 대기중 캐치마인드: begins_with(GSI1SK, "GAME#CATCHMIND#WAITING#") ### 파일 수정 - ChatRoomCommandService.java: 방 생성 시 새 GSI1SK 포맷 적용 - ChatRoomRepository.java: findByFilters() 메서드 추가, updateStatus() 메서드 추가 - ChatRoomQueryService.java: 메모리 필터링 제거, DB 레벨 필터링으로 변경 - GameService.java: 게임 시작/종료 시 방 상태 업데이트 (GSI1SK 포함) ### 마이그레이션 - scripts/migrate-gsi1sk.sh: 기존 데이터 마이그레이션 스크립트 - 37개 기존 방 마이그레이션 완료 * fix: increase stats query limit to 100 days - Adjust maximum allowable limit for recent stats query from 30 to 100 days to support extended data range responses. * feat: improve game round and connection management logic - Added handling for game round timeout (`ROUND_TIMEOUT`) in WebSocketMessageHandler. - Enhanced game start broadcast to include `currentWord` for the drawer only. - Updated ConnectionRepository to remove duplicate user connections in the same room. - Added `currentWordEnglish` to GameSession for better answer verification. - Normalized ChatRoom levels to match database storage format. - Updated template.yaml to include DynamoDBReadPolicy for VocabTable. * refactor: 코드 정리 및 미사용 클래스 제거 (#459) * refactor: remove unused WebSocketResponseUtil * refactor: add AutoCloseable to WebSocketBroadcaster * refactor: remove unused TestService * refactor: remove unused WordService * refactor: remove unused DailyStudyService * refactor: remove unused UserWordService * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * fix: resolve N+1 query in StatsService * refactor(all): DI 패턴 및 전략 패턴 적용 (#461) - Repository, Service, Handler에 DI 생성자 추가 (테스트 용이성) - BadgeService에 Strategy 패턴 적용 (뱃지 조건 검증 로직 분리) * refactor: relocate and restructure seed data files - Moved `question-homes.json` and `words.json` from subdirectories to `seed` folder. - Updated file paths for better organization and clarity. * chore: seed 데이터 폴더 구조 정리 * feat: add CI/CD pipeline configuration for CodePipeline * fix: add SNS topic policy and DependsOn for notification rule * fix: correct paths in buildspec.yml for CodeBuild * fix: remove hardcoded JAVA_HOME, use runtime default * fix: add gradle wrapper for CI/CD build * fix: use single line sam package command with hardcoded bucket * fix: use existing stack name group2-englishstudy-chatting * fix: add missing WEBSOCKET_ENDPOINT env var to WebSocket connect functions * docs: update FRONTEND-API-GUIDE with new RoomType/RoomStatus structure * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix: update WebSocketDisconnectHandler to use GameSession model - Remove references to deleted ChatRoom game fields - Use GameSessionRepository to finish active game sessions - Use ChatRoomRepository.updateStatus() for room state management * perf: optimize CI/CD build time - Add pip cache for SAM CLI dependencies - Enable Gradle parallel builds and build cache - Add SAM build cache (.aws-sam) - Use sam build --parallel --cached - Skip SAM CLI install if already cached - Remove unnecessary 'clean' to leverage cache * feat: add custom CodeBuild Docker image with pre-installed tools - Dockerfile with Java 21 + SAM CLI + Gradle pre-installed - build-and-push.sh script for ECR deployment - Updated buildspec.yml for custom image (removes SAM CLI install) - Expected build time reduction: ~30-40 seconds * feature : AI 영어 회화 연습 기능 (#468) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix: remove typo in SpeakingConnectionRepository * fix : 오타 수정 * chore: trigger build test with custom Docker image * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes * Release: 캐치마인드 게임 분리, AI 회화 연습, CI/CD 파이프라인 (#469) * feature : OPIc 세션관리 + 답변 처리 파이프라인 구현 (#413) * feat : OPIc 질문 & 세션 관련 dto 생성 * refactor : @DynamoDbBean 주석 추가 * feat : 주제 + 소주제 + 레벨로 오픽 질문 조회 추가 * feat : OPIc 세션, 질문, 답변 종합 handler 구현 * feat : 오픽 주제, 소주제별 seed 데이터 추가 * refactor : Transcribe API KEY 환경변수명 수정 * refactor : Bedrock에 사용하는 클로드 모델 변경 * refactor : S3 Key 대신 Proxy에서 요청하는 Base64 값으로 변환 * feat: GAME_START, ROUND_END 메시지에 serverTime 추가 - broadcastGameStart(): serverTime, roundDuration 필드 추가 - broadcastRoundEnd(): serverTime, roundStartTime, roundDuration 필드 추가 - GameService.endRound(): data에 roundStartTime, roundDuration 포함 - 타이머 동기화 버그 수정을 위한 서버 시간 제공 * feat: WebSocketMessageHelper 유틸리티 클래스 추가 - domain 필드 포함 메시지 생성 헬퍼 - DOMAIN_CHAT, DOMAIN_GAME 상수 정의 - buildChatMessage(), buildGameMessage() 메서드 * feat: 모든 WebSocket 메시지에 domain 필드 추가 - ScoreUpdateMessage에 domain 필드 추가 - broadcastGameStart에 domain:"game" 추가 - broadcastRoundEnd에 domain:"game" 추가 - broadcastCorrectAnswerMessage에 domain:"game" 추가 - handleCommandResult 시스템 메시지에 domain:"game" 추가 - handleRegularMessage 채팅 메시지에 domain:"chat" 추가 Closes #426, #427 * feat: GameSession 모델 클래스 생성 - DynamoDB Enhanced Client 어노테이션 적용 - GSI1: roomId로 활성 게임 세션 조회 가능 - 게임 상태 관리용 헬퍼 메서드 포함 Closes #428 * feat: GameSessionRepository 구현 - 기본 CRUD (save, findById, delete) - roomId로 활성/전체 게임 세션 조회 - 상태, 라운드, 점수 업데이트 메서드 - 정답자 추가, 힌트 사용, 게임 종료 처리 Closes #429 * refactor: ChatRoom에서 게임 필드 분리 - 게임 관련 필드 제거 (gameStatus, currentRound 등) - activeGameSessionId 필드 추가 - 게임 상태는 GameSession으로 분리됨 Note: 의존 코드 수정은 #431에서 진행 Closes #430 * refactor: GameSession 기반으로 전체 게임 로직 리팩토링 - GameService: ChatRoom 대신 GameSession 사용 - GameStatsService: GameSession 매개변수로 변경 - CommandService: GameSession 기반 점수 조회 - GameHandler: GameSession 기반 REST API - WebSocketMessageHandler: GameSession 기반 브로드캐스트 - GameStatusResponse, ScoreboardResponse: GameSession 매개변수 Closes #431 * feat: GameSessionHandler Lambda 및 게임 세션 API 구현 - POST /rooms/{roomId}/games - 게임 세션 생성 - GET /games/{gameSessionId} - 게임 상태 조회 (재접속용) - POST /games/{gameSessionId}/start - 게임 시작 - POST /games/{gameSessionId}/stop - 게임 종료 모든 응답에 serverTime 포함 (타이머 동기화) 출제자에게만 currentWord 포함 Closes #432, #433 * feat: 게임 시작 7분 후 자동 종료 기능 구현 - GameConfig에 gameTimeLimit() 메서드 추가 (기본값: 420초) - GameSchedulerClient 유틸리티 클래스 생성 (EventBridge Scheduler 연동) - GameService에 스케줄 생성/취소 로직 추가 - GameAutoCloseHandler Lambda 함수 생성 - template.yaml에 Lambda, IAM Role, Schedule Group 추가 Closes #417 * fix : 메모리 증가 및 Lambda 응답 제한 시간 Cognito 트리거 제한시간과 동일하게 수정 (#439) * feat: 캐치마인드 게임 방 분리 기능 구현 (#455) - Room 타입 분리 (CHAT/GAME) - RoomType, RoomStatus enum 추가 - ChatRoom 모델에 type, gameType, gameSettings, status, hostId 필드 추가 - GameSettings 모델 추가 - 방 생성/조회 API 수정 - CreateRoomRequest에 type, gameType, gameSettings 필드 추가 - 방 목록 조회 시 type, gameType, status 필터 지원 - 게임 시작 조건 검증 - GAME 타입 방에서만 게임 시작 가능 (GAME_007 에러) - 게임 재시작 API 구현 (#452) - POST /rooms/{roomId}/game/restart 엔드포인트 추가 - 방장만 재시작 가능 (GAME_009 에러) - 게임 진행 중 재시작 불가 (GAME_008 에러) - 방장 변경 로직 구현 (#453) - 방장 퇴장 시 다음 멤버에게 자동 이전 - 모든 멤버 퇴장 시 방 자동 삭제 - WebSocket 메시지 타입 추가 (#454) - ROOM_STATUS_CHANGE, HOST_CHANGE 메시지 타입 추가 - WebSocketMessageHelper에 빌더 메서드 추가 - 테스트 추가 - RoomType, RoomStatus enum 테스트 - GameSettings 모델 테스트 - ChattingErrorCode 테스트 업데이트 Related: #440, #441, #442, #443, #444, #445, #446 Closes: #447, #448, #449, #450, #451, #452, #453, #454 * feat: 참가자 닉네임 및 방장 변경 WebSocket 알림 구현 (#456) - RoomParticipant DTO 추가 (userId, nickname, isHost) - ChatRoomQueryService에 닉네임 조회 메서드 추가 - getParticipantsWithNicknames(): 참가자 목록 + 닉네임 - getHostNickname(): 방장 닉네임 조회 - ChatRoomHandler.getRoom() 응답에 participants, hostNickname 추가 - ChatRoomCommandService.leaveRoom()에서 방장 변경 시 WebSocket 브로드캐스트 Related: #440 * fix: ChatRoomFunction에 UserTable DynamoDB 권한 추가 - 참가자/방장 닉네임 조회를 위한 UserTable 읽기 권한 추가 - DynamoDBReadPolicy로 UserTable 접근 허용 Fixes #457 * fix: GameSettings에 @DynamoDbBean 어노테이션 추가 - DynamoDB Enhanced Client가 중첩 객체를 직렬화/역직렬화할 수 있도록 수정 - ChatRoom 저장/조회 시 GameSettings 변환 오류 해결 Fixes #457 * fix: ChatRoomFunction에 WEBSOCKET_ENDPOINT 환경변수 및 권한 추가 - leaveRoom에서 WebSocketBroadcaster 사용을 위한 환경변수 추가 - execute-api:ManageConnections 권한 추가 * fix: WebSocket Lambda 함수들에 WEBSOCKET_ENDPOINT 환경변수 추가 - WebSocketConnectFunction에 WEBSOCKET_ENDPOINT 추가 - WebSocketDisconnectFunction에 WEBSOCKET_ENDPOINT 추가 - execute-api:ManageConnections 권한 추가 * fix: Grammar WebSocket Lambda 환경 변수 및 권한 추가 - GrammarStreamingConnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - GrammarStreamingDisconnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - 두 함수에 execute-api:ManageConnections 권한 추가 * feat: GSI1SK 확장성 있는 재설계 및 DB 레벨 필터링 ## 변경 사항 ### GSI1SK 포맷 변경 - 기존: {level}#{createdAt} - 신규: {type}#{gameType}#{status}#{level}#{createdAt} ### 지원 쿼리 패턴 - 전체 방: GSI1PK = "ROOMS" - 게임방만: begins_with(GSI1SK, "GAME#") - 캐치마인드만: begins_with(GSI1SK, "GAME#CATCHMIND#") - 대기중 캐치마인드: begins_with(GSI1SK, "GAME#CATCHMIND#WAITING#") ### 파일 수정 - ChatRoomCommandService.java: 방 생성 시 새 GSI1SK 포맷 적용 - ChatRoomRepository.java: findByFilters() 메서드 추가, updateStatus() 메서드 추가 - ChatRoomQueryService.java: 메모리 필터링 제거, DB 레벨 필터링으로 변경 - GameService.java: 게임 시작/종료 시 방 상태 업데이트 (GSI1SK 포함) ### 마이그레이션 - scripts/migrate-gsi1sk.sh: 기존 데이터 마이그레이션 스크립트 - 37개 기존 방 마이그레이션 완료 * fix: increase stats query limit to 100 days - Adjust maximum allowable limit for recent stats query from 30 to 100 days to support extended data range responses. * feat: improve game round and connection management logic - Added handling for game round timeout (`ROUND_TIMEOUT`) in WebSocketMessageHandler. - Enhanced game start broadcast to include `currentWord` for the drawer only. - Updated ConnectionRepository to remove duplicate user connections in the same room. - Added `currentWordEnglish` to GameSession for better answer verification. - Normalized ChatRoom levels to match database storage format. - Updated template.yaml to include DynamoDBReadPolicy for VocabTable. * refactor: 코드 정리 및 미사용 클래스 제거 (#459) * refactor: remove unused WebSocketResponseUtil * refactor: add AutoCloseable to WebSocketBroadcaster * refactor: remove unused TestService * refactor: remove unused WordService * refactor: remove unused DailyStudyService * refactor: remove unused UserWordService * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * fix: resolve N+1 query in StatsService * refactor(all): DI 패턴 및 전략 패턴 적용 (#461) - Repository, Service, Handler에 DI 생성자 추가 (테스트 용이성) - BadgeService에 Strategy 패턴 적용 (뱃지 조건 검증 로직 분리) * refactor: relocate and restructure seed data files - Moved `question-homes.json` and `words.json` from subdirectories to `seed` folder. - Updated file paths for better organization and clarity. * chore: seed 데이터 폴더 구조 정리 * feat: add CI/CD pipeline configuration for CodePipeline * fix: add SNS topic policy and DependsOn for notification rule * fix: correct paths in buildspec.yml for CodeBuild * fix: remove hardcoded JAVA_HOME, use runtime default * fix: add gradle wrapper for CI/CD build * fix: use single line sam package command with hardcoded bucket * fix: use existing stack name group2-englishstudy-chatting * fix: add missing WEBSOCKET_ENDPOINT env var to WebSocket connect functions * docs: update FRONTEND-API-GUIDE with new RoomType/RoomStatus structure * fix: update WebSocketDisconnectHandler to use GameSession model - Remove references to deleted ChatRoom game fields - Use GameSessionRepository to finish active game sessions - Use ChatRoomRepository.updateStatus() for room state management * perf: optimize CI/CD build time - Add pip cache for SAM CLI dependencies - Enable Gradle parallel builds and build cache - Add SAM build cache (.aws-sam) - Use sam build --parallel --cached - Skip SAM CLI install if already cached - Remove unnecessary 'clean' to leverage cache * feat: add custom CodeBuild Docker image with pre-installed tools - Dockerfile with Java 21 + SAM CLI + Gradle pre-installed - build-and-push.sh script for ECR deployment - Updated buildspec.yml for custom image (removes SAM CLI install) - Expected build time reduction: ~30-40 seconds * feature : AI 영어 회화 연습 기능 (#468) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix: remove typo in SpeakingConnectionRepository * chore: trigger build test with custom Docker image * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes --------- Co-authored-by: hyein Heo <128613248+hye-inA@users.noreply.github.com> * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * fix: add CORS headers to API Gateway error responses (#479) API Gateway의 인증 실패 등 에러 응답에 CORS 헤더가 누락되어 CloudFront를 통한 프론트엔드 요청이 차단되는 문제 수정 - UNAUTHORIZED (401) 응답에 CORS 헤더 추가 - ACCESS_DENIED (403) 응답에 CORS 헤더 추가 - DEFAULT_4XX/5XX 응답에 CORS 헤더 추가 - EXPIRED_TOKEN 응답에 CORS 헤더 추가 * feat(news): 뉴스 도메인 기반 구조 구축 (#385) - NewsCategory, QuizType enum 추가 - NewsKey 상수 클래스 추가 - NewsErrorCode 예외 클래스 추가 - NewsArticle, KeywordInfo, QuizQuestion 모델 추가 - NewsArticleRepository CRUD 구현 - NewsTable DynamoDB 테이블 정의 - CORS GatewayResponses 설정 추가 * feat(news): 뉴스 수집 파이프라인 구현 (#386) - NewsApiClient: NewsAPI 연동 서비스 - RssFeedParser: RSS 피드 파싱 (BBC, VOA, NPR) - NewsDuplicateChecker: URL 기반 중복 필터링 - NewsCollectorService: 수집 오케스트레이션 - NewsCollectionHandler: Lambda 핸들러 - EventBridge 스케줄러: 매일 18시 KST 실행 * refactor(news): NewsAPI 제거, RSS만 사용 - NewsApiClient 삭제 - SSM Parameter 의존성 제거 - RSS 소스당 7개씩 수집 (BBC, VOA, NPR = 약 21개/일) * feat(news): AI 뉴스 분석 시스템 구현 (#387) - NewsAnalysisService: AI 분석 통합 서비스 - Bedrock: CEFR 난이도 분석 (A1~C2) - Bedrock: 3줄 요약 + 퀴즈 3문제 생성 - Comprehend: 핵심 키워드 추출 - NewsCollectorService: 수집 시 자동 분석 연동 - GSI1/GSI2 키 자동 설정 (레벨별, 카테고리별 조회) * feat(news): 뉴스 학습 API 구현 (#388) - NewsQueryService: 뉴스 조회 서비스 - NewsHandler: API 핸들러 - GET /news - 목록 조회 (level, category 필터) - GET /news/today - 오늘의 뉴스 - GET /news/recommended - 내 레벨 맞춤 추천 - GET /news/{articleId} - 상세 조회 (조회수 증가) - template.yaml: NewsFunction Lambda 추가 * feat(news): 뉴스 학습 부가 기능 구현 (#389) - 읽기 완료 기록 API (POST /news/{articleId}/read) - 북마크 토글 API (POST /news/{articleId}/bookmark) - 북마크 목록 조회 API (GET /news/bookmarks) - 학습 통계 조회 API (GET /news/stats) - TTS 오디오 URL 조회 API (GET /news/{articleId}/audio) - UserNewsRecord 모델 추가 - UserNewsRepository 추가 - NewsLearningService 추가 * feat(news): 복합 퀴즈 시스템 구현 (#471) - 퀴즈 조회 API (GET /news/{articleId}/quiz) - 퀴즈 제출 API (POST /news/{articleId}/quiz) - 퀴즈 기록 조회 API (GET /news/quiz/history) - NewsQuizResult 모델 추가 - QuizAnswerResult 모델 추가 - NewsQuizRepository 추가 - NewsQuizService 추가 * feat(news): 단어 수집 & Vocabulary 연동 구현 (#472) - 단어 수집 API (POST /news/{articleId}/words) - 수집 단어 목록 API (GET /news/words) - 단어 상세 조회 API (GET /news/{articleId}/words/{word}) - 단어 삭제 API (DELETE /news/{articleId}/words/{word}) - Vocabulary 연동 API (POST /news/words/{word}/sync) - NewsWordCollect 모델 추가 - NewsWordRepository 추가 - NewsWordService 추가 * feat: add multi-environment deployment support (dev/test/prod) * fix: update buildspec.yml to deploy prod environment with parameter overrides * feat(news): 뉴스 도메인 기반 구조 구축 (#385) - NewsCategory, QuizType enum 추가 - NewsKey 상수 클래스 추가 - NewsErrorCode 예외 클래스 추가 - NewsArticle, KeywordInfo, QuizQuestion 모델 추가 - NewsArticleRepository CRUD 구현 - NewsTable DynamoDB 테이블 정의 - CORS GatewayResponses 설정 추가 * feat(news): 뉴스 수집 파이프라인 구현 (#386) - NewsApiClient: NewsAPI 연동 서비스 - RssFeedParser: RSS 피드 파싱 (BBC, VOA, NPR) - NewsDuplicateChecker: URL 기반 중복 필터링 - NewsCollectorService: 수집 오케스트레이션 - NewsCollectionHandler: Lambda 핸들러 - EventBridge 스케줄러: 매일 18시 KST 실행 * refactor(news): NewsAPI 제거, RSS만 사용 - NewsApiClient 삭제 - SSM Parameter 의존성 제거 - RSS 소스당 7개씩 수집 (BBC, VOA, NPR = 약 21개/일) * feat(news): AI 뉴스 분석 시스템 구현 (#387) - NewsAnalysisService: AI 분석 통합 서비스 - Bedrock: CEFR 난이도 분석 (A1~C2) - Bedrock: 3줄 요약 + 퀴즈 3문제 생성 - Comprehend: 핵심 키워드 추출 - NewsCollectorService: 수집 시 자동 분석 연동 - GSI1/GSI2 키 자동 설정 (레벨별, 카테고리별 조회) * feat(news): 뉴스 학습 API 구현 (#388) - NewsQueryService: 뉴스 조회 서비스 - NewsHandler: API 핸들러 - GET /news - 목록 조회 (level, category 필터) - GET /news/today - 오늘의 뉴스 - GET /news/recommended - 내 레벨 맞춤 추천 - GET /news/{articleId} - 상세 조회 (조회수 증가) - template.yaml: NewsFunction Lambda 추가 * feat(news): 뉴스 학습 부가 기능 구현 (#389) - 읽기 완료 기록 API (POST /news/{articleId}/read) - 북마크 토글 API (POST /news/{articleId}/bookmark) - 북마크 목록 조회 API (GET /news/bookmarks) - 학습 통계 조회 API (GET /news/stats) - TTS 오디오 URL 조회 API (GET /news/{articleId}/audio) - UserNewsRecord 모델 추가 - UserNewsRepository 추가 - NewsLearningService 추가 * feat(news): 복합 퀴즈 시스템 구현 (#471) - 퀴즈 조회 API (GET /news/{articleId}/quiz) - 퀴즈 제출 API (POST /news/{articleId}/quiz) - 퀴즈 기록 조회 API (GET /news/quiz/history) - NewsQuizResult 모델 추가 - QuizAnswerResult 모델 추가 - NewsQuizRepository 추가 - NewsQuizService 추가 * feat(news): 단어 수집 & Vocabulary 연동 구현 (#472) - 단어 수집 API (POST /news/{articleId}/words) - 수집 단어 목록 API (GET /news/words) - 단어 상세 조회 API (GET /news/{articleId}/words/{word}) - 단어 삭제 API (DELETE /news/{articleId}/words/{word}) - Vocabulary 연동 API (POST /news/words/{word}/sync) - NewsWordCollect 모델 추가 - NewsWordRepository 추가 - NewsWordService 추가 * feat: add multi-environment deployment support (dev/test/prod) * fix: update buildspec.yml to deploy prod environment with parameter overrides * refactor : AI 말하기 Websocket 구현 -> REST API 구현으로 리팩토링 (#490) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix : 오타 수정 * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * fix: revert buildspec.yml to build-only for CloudFormation deploy stage * fix: revert buildspec.yml to build-only for CloudFormation deploy stage * fix: update all API Gateway StageName to use Environment parameter * fix: correct VocabularyTable and ContentBucket references in NewsFunction * fix: correct VocabularyTable and ContentBucket references in NewsFunction * fix: add stack name prefix to GameScheduleGroup and daily-stats schedule to avoid conflicts * fix: add stack name prefix to GameScheduleGroup and daily-stats schedule to avoid conflicts * feat: add support for existing Cognito User Pool reuse across environments * fix: add conditional Cognito ARN reference in API Gateway Authorizer * fix: remove Cognito resources completely, use existing Cognito only * fix: remove Cognito resources completely, use existing Cognito only * feat : speaking rest API 람다 함수 추가 * feat: add S3 bucket resource and fix environment-specific endpoints - Add ContentBucket S3 resource for content storage - Replace hardcoded /dev with ${Environment} in all WebSocket endpoints - Update Output URLs to use dynamic environment stage Co-Authored-By: Claude Opus 4.5 * fix: add stack name prefix to news-collection schedule name Prevent EventBridge rule name conflicts across environments Co-Authored-By: Claude Opus 4.5 * fix: add X-Requested-With and Accept headers to CORS config Enable additional headers for CloudFront CORS compatibility Co-Authored-By: Claude Opus 4.5 * fix: use environment variable for S3 bucket URLs Replace hardcoded bucket name with BUCKET_NAME env var for multi-env support: - PreSignUpHandler: dynamic default profile URL - PostConfirmationHandler: dynamic default profile URL - UserService: dynamic default profile URL - BadgeType: dynamic badge image base URL Co-Authored-By: Claude Opus 4.5 * fix: disable authorizer for CORS preflight requests Add AddDefaultAuthorizerToCorsPreflight: false to prevent Cognito Authorizer from blocking OPTIONS requests Co-Authored-By: Claude Opus 4.5 * feat : speaking REST API 람다 함수 추가 (#491) * feature : OPIc 세션관리 + 답변 처리 파이프라인 구현 (#413) * feat : OPIc 질문 & 세션 관련 dto 생성 * refactor : @DynamoDbBean 주석 추가 * feat : 주제 + 소주제 + 레벨로 오픽 질문 조회 추가 * feat : OPIc 세션, 질문, 답변 종합 handler 구현 * feat : 오픽 주제, 소주제별 seed 데이터 추가 * refactor : Transcribe API KEY 환경변수명 수정 * refactor : Bedrock에 사용하는 클로드 모델 변경 * refactor : S3 Key 대신 Proxy에서 요청하는 Base64 값으로 변환 * feat: GAME_START, ROUND_END 메시지에 serverTime 추가 - broadcastGameStart(): serverTime, roundDuration 필드 추가 - broadcastRoundEnd(): serverTime, roundStartTime, roundDuration 필드 추가 - GameService.endRound(): data에 roundStartTime, roundDuration 포함 - 타이머 동기화 버그 수정을 위한 서버 시간 제공 * feat: WebSocketMessageHelper 유틸리티 클래스 추가 - domain 필드 포함 메시지 생성 헬퍼 - DOMAIN_CHAT, DOMAIN_GAME 상수 정의 - buildChatMessage(), buildGameMessage() 메서드 * feat: 모든 WebSocket 메시지에 domain 필드 추가 - ScoreUpdateMessage에 domain 필드 추가 - broadcastGameStart에 domain:"game" 추가 - broadcastRoundEnd에 domain:"game" 추가 - broadcastCorrectAnswerMessage에 domain:"game" 추가 - handleCommandResult 시스템 메시지에 domain:"game" 추가 - handleRegularMessage 채팅 메시지에 domain:"chat" 추가 Closes #426, #427 * feat: GameSession 모델 클래스 생성 - DynamoDB Enhanced Client 어노테이션 적용 - GSI1: roomId로 활성 게임 세션 조회 가능 - 게임 상태 관리용 헬퍼 메서드 포함 Closes #428 * feat: GameSessionRepository 구현 - 기본 CRUD (save, findById, delete) - roomId로 활성/전체 게임 세션 조회 - 상태, 라운드, 점수 업데이트 메서드 - 정답자 추가, 힌트 사용, 게임 종료 처리 Closes #429 * refactor: ChatRoom에서 게임 필드 분리 - 게임 관련 필드 제거 (gameStatus, currentRound 등) - activeGameSessionId 필드 추가 - 게임 상태는 GameSession으로 분리됨 Note: 의존 코드 수정은 #431에서 진행 Closes #430 * refactor: GameSession 기반으로 전체 게임 로직 리팩토링 - GameService: ChatRoom 대신 GameSession 사용 - GameStatsService: GameSession 매개변수로 변경 - CommandService: GameSession 기반 점수 조회 - GameHandler: GameSession 기반 REST API - WebSocketMessageHandler: GameSession 기반 브로드캐스트 - GameStatusResponse, ScoreboardResponse: GameSession 매개변수 Closes #431 * feat: GameSessionHandler Lambda 및 게임 세션 API 구현 - POST /rooms/{roomId}/games - 게임 세션 생성 - GET /games/{gameSessionId} - 게임 상태 조회 (재접속용) - POST /games/{gameSessionId}/start - 게임 시작 - POST /games/{gameSessionId}/stop - 게임 종료 모든 응답에 serverTime 포함 (타이머 동기화) 출제자에게만 currentWord 포함 Closes #432, #433 * feat: 게임 시작 7분 후 자동 종료 기능 구현 - GameConfig에 gameTimeLimit() 메서드 추가 (기본값: 420초) - GameSchedulerClient 유틸리티 클래스 생성 (EventBridge Scheduler 연동) - GameService에 스케줄 생성/취소 로직 추가 - GameAutoCloseHandler Lambda 함수 생성 - template.yaml에 Lambda, IAM Role, Schedule Group 추가 Closes #417 * fix : 메모리 증가 및 Lambda 응답 제한 시간 Cognito 트리거 제한시간과 동일하게 수정 (#439) * feat: 캐치마인드 게임 방 분리 기능 구현 (#455) - Room 타입 분리 (CHAT/GAME) - RoomType, RoomStatus enum 추가 - ChatRoom 모델에 type, gameType, gameSettings, status, hostId 필드 추가 - GameSettings 모델 추가 - 방 생성/조회 API 수정 - CreateRoomRequest에 type, gameType, gameSettings 필드 추가 - 방 목록 조회 시 type, gameType, status 필터 지원 - 게임 시작 조건 검증 - GAME 타입 방에서만 게임 시작 가능 (GAME_007 에러) - 게임 재시작 API 구현 (#452) - POST /rooms/{roomId}/game/restart 엔드포인트 추가 - 방장만 재시작 가능 (GAME_009 에러) - 게임 진행 중 재시작 불가 (GAME_008 에러) - 방장 변경 로직 구현 (#453) - 방장 퇴장 시 다음 멤버에게 자동 이전 - 모든 멤버 퇴장 시 방 자동 삭제 - WebSocket 메시지 타입 추가 (#454) - ROOM_STATUS_CHANGE, HOST_CHANGE 메시지 타입 추가 - WebSocketMessageHelper에 빌더 메서드 추가 - 테스트 추가 - RoomType, RoomStatus enum 테스트 - GameSettings 모델 테스트 - ChattingErrorCode 테스트 업데이트 Related: #440, #441, #442, #443, #444, #445, #446 Closes: #447, #448, #449, #450, #451, #452, #453, #454 * feat: 참가자 닉네임 및 방장 변경 WebSocket 알림 구현 (#456) - RoomParticipant DTO 추가 (userId, nickname, isHost) - ChatRoomQueryService에 닉네임 조회 메서드 추가 - getParticipantsWithNicknames(): 참가자 목록 + 닉네임 - getHostNickname(): 방장 닉네임 조회 - ChatRoomHandler.getRoom() 응답에 participants, hostNickname 추가 - ChatRoomCommandService.leaveRoom()에서 방장 변경 시 WebSocket 브로드캐스트 Related: #440 * fix: ChatRoomFunction에 UserTable DynamoDB 권한 추가 - 참가자/방장 닉네임 조회를 위한 UserTable 읽기 권한 추가 - DynamoDBReadPolicy로 UserTable 접근 허용 Fixes #457 * fix: GameSettings에 @DynamoDbBean 어노테이션 추가 - DynamoDB Enhanced Client가 중첩 객체를 직렬화/역직렬화할 수 있도록 수정 - ChatRoom 저장/조회 시 GameSettings 변환 오류 해결 Fixes #457 * fix: ChatRoomFunction에 WEBSOCKET_ENDPOINT 환경변수 및 권한 추가 - leaveRoom에서 WebSocketBroadcaster 사용을 위한 환경변수 추가 - execute-api:ManageConnections 권한 추가 * fix: WebSocket Lambda 함수들에 WEBSOCKET_ENDPOINT 환경변수 추가 - WebSocketConnectFunction에 WEBSOCKET_ENDPOINT 추가 - WebSocketDisconnectFunction에 WEBSOCKET_ENDPOINT 추가 - execute-api:ManageConnections 권한 추가 * fix: Grammar WebSocket Lambda 환경 변수 및 권한 추가 - GrammarStreamingConnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - GrammarStreamingDisconnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - 두 함수에 execute-api:ManageConnections 권한 추가 * feat: GSI1SK 확장성 있는 재설계 및 DB 레벨 필터링 - 기존: {level}#{createdAt} - 신규: {type}#{gameType}#{status}#{level}#{createdAt} - 전체 방: GSI1PK = "ROOMS" - 게임방만: begins_with(GSI1SK, "GAME#") - 캐치마인드만: begins_with(GSI1SK, "GAME#CATCHMIND#") - 대기중 캐치마인드: begins_with(GSI1SK, "GAME#CATCHMIND#WAITING#") - ChatRoomCommandService.java: 방 생성 시 새 GSI1SK 포맷 적용 - ChatRoomRepository.java: findByFilters() 메서드 추가, updateStatus() 메서드 추가 - ChatRoomQueryService.java: 메모리 필터링 제거, DB 레벨 필터링으로 변경 - GameService.java: 게임 시작/종료 시 방 상태 업데이트 (GSI1SK 포함) - scripts/migrate-gsi1sk.sh: 기존 데이터 마이그레이션 스크립트 - 37개 기존 방 마이그레이션 완료 * fix: increase stats query limit to 100 days - Adjust maximum allowable limit for recent stats query from 30 to 100 days to support extended data range responses. * feat: improve game round and connection management logic - Added handling for game round timeout (`ROUND_TIMEOUT`) in WebSocketMessageHandler. - Enhanced game start broadcast to include `currentWord` for the drawer only. - Updated ConnectionRepository to remove duplicate user connections in the same room. - Added `currentWordEnglish` to GameSession for better answer verification. - Normalized ChatRoom levels to match database storage format. - Updated template.yaml to include DynamoDBReadPolicy for VocabTable. * refactor: 코드 정리 및 미사용 클래스 제거 (#459) * refactor: remove unused WebSocketResponseUtil * refactor: add AutoCloseable to WebSocketBroadcaster * refactor: remove unused TestService * refactor: remove unused WordService * refactor: remove unused DailyStudyService * refactor: remove unused UserWordService * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * fix: resolve N+1 query in StatsService * refactor(all): DI 패턴 및 전략 패턴 적용 (#461) - Repository, Service, Handler에 DI 생성자 추가 (테스트 용이성) - BadgeService에 Strategy 패턴 적용 (뱃지 조건 검증 로직 분리) * refactor: relocate and restructure seed data files - Moved `question-homes.json` and `words.json` from subdirectories to `seed` folder. - Updated file paths for better organization and clarity. * chore: seed 데이터 폴더 구조 정리 * feat: add CI/CD pipeline configuration for CodePipeline * fix: add SNS topic policy and DependsOn for notification rule * fix: correct paths in buildspec.yml for CodeBuild * fix: remove hardcoded JAVA_HOME, use runtime default * fix: add gradle wrapper for CI/CD build * fix: use single line sam package command with hardcoded bucket * fix: use existing stack name group2-englishstudy-chatting * fix: add missing WEBSOCKET_ENDPOINT env var to WebSocket connect functions * docs: update FRONTEND-API-GUIDE with new RoomType/RoomStatus structure * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix: update WebSocketDisconnectHandler to use GameSession model - Remove references to deleted ChatRoom game fields - Use GameSessionRepository to finish active game sessions - Use ChatRoomRepository.updateStatus() for room state management * perf: optimize CI/CD build time - Add pip cache for SAM CLI dependencies - Enable Gradle parallel builds and build cache - Add SAM build cache (.aws-sam) - Use sam build --parallel --cached - Skip SAM CLI install if already cached - Remove unnecessary 'clean' to leverage cache * feat: add custom CodeBuild Docker image with pre-installed tools - Dockerfile with Java 21 + SAM CLI + Gradle pre-installed - build-and-push.sh script for ECR deployment - Updated buildspec.yml for custom image (removes SAM CLI install) - Expected build time reduction: ~30-40 seconds * feature : AI 영어 회화 연습 기능 (#468) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix: remove typo in SpeakingConnectionRepository * fix : 오타 수정 * chore: trigger build test with custom Docker image * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * fix: add CORS headers to API Gateway error responses (#479) API Gateway의 인증 실패 등 에러 응답에 CORS 헤더가 누락되어 CloudFront를 통한 프론트엔드 요청이 차단되는 문제 수정 - UNAUTHORIZED (401) 응답에 CORS 헤더 추가 - ACCESS_DENIED (403) 응답에 CORS 헤더 추가 - DEFAULT_4XX/5XX 응답에 CORS 헤더 추가 - EXPIRED_TOKEN 응답에 CORS 헤더 추가 * feat : speaking rest API 람다 함수 추가 --------- Co-authored-by: ddingjoo * refactor : speaking service 재사용 * refactor : AI 영어 회화 연습 코드 리팩토링 * feature : test 벡엔드 서버에 AI 말하기 연습 기능 배포 (#492) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix : 오타 수정 * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * feat : speaking rest API 람다 함수 추가 * refactor : speaking service 재사용 * refactor : AI 영어 회화 연습 코드 리팩토링 --------- Co-authored-by: DDING JOO * feat : handleChat 메서드 JsonNull 체크 푸가 * feature : handleChat 메서드 JsonNull 체크 추가 (#493) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix : 오타 수정 * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * feat : speaking rest API 람다 함수 추가 * refactor : speaking service 재사용 * refactor : AI 영어 회화 연습 코드 리팩토링 * feat : handleChat 메서드 JsonNull 체크 푸가 --------- Co-authored-by: DDING JOO * feat(news): 뉴스 학습 배지 시스템 구현 (#473) - 14개 뉴스 관련 배지 추가 (읽기, 퀴즈, 단어수집, 연속학습, 마스터) - UserStats에 뉴스 통계 필드 추가 - 6개 뉴스 배지 Strategy 클래스 생성 - 뉴스 읽기/퀴즈/단어수집 시 통계 업데이트 및 배지 체크 연동 * fix: add PATCH method to CORS AllowMethods * test: BadgeType 개수 테스트 수정 (15 -> 29) * fix: CORS PATCH 메서드 추가 * docs: 뉴스 기능 프론트엔드 연동 가이드 작성 * fix: NewsCollectionFunction에 Bedrock, Comprehend 권한 추가 * fix: add null check for collectWord request body - Add INVALID_REQUEST error code to NewsErrorCode - Check body and word field before accessing in collectWord() - Prevents NullPointerException when request body is malformed * feat: enhance stats API and bookmark response for frontend - Add DAILY stats update for news read/quiz/word collection - Add /stats/dashboard endpoint with frontend-requested format - today: wordsLearned, newsRead, quizzesTaken, wordsTotal - overall: totalWordsLearned, totalNewsRead, averageAccuracy, streaks - weeklyProgress: last 7 days with date/wordsLearned/newsRead - Add news-related fields to all stats API responses - Fix bookmark API to include full article details (title, summary, etc.) * feat: add category classification to news AI analysis - Add category field to AnalysisResult record - Update Bedrock prompt to classify articles into categories (WORLD, POLITICS, BUSINESS, TECH, SCIENCE, HEALTH, SPORTS, ENTERTAINMENT, LIFESTYLE) - Parse and set category from AI response - Set GSI2 (CATEGORY#) index when category is available * fix: add /stats/dashboard endpoint to template.yaml * fix: filter by ARTICLE# prefix in findById to avoid returning UserNewsRecord * docs: add News API troubleshooting guide * feat: add Cognito authorizer to News API and enhance keyword extraction - Set CognitoAuthorizer for all News API endpoints in template.yaml - Update Bedrock AI to extract keywords with meanings and examples - Add fallback to Comprehend for keyword extraction when Bedrock fails - Modify KeywordInfo model to include example field - Adjust AI prompt to include keyword extraction with examples - Update AnalysisResult to store keywords and parse them from AI response * Revert "Merge branch 'test' into prod" This reverts commit c1a958eac6799d5838a76e4078ae304bcdb62278, reversing changes made to a6662e0cfc46c6e12f20a89f0df285ff282160bc. * feat: enhance bookmark and reading status tracking for News API - Add bookmark and reading status to individual article responses - Include bookmark status in paginated news responses - Modify `KeywordInfo` to support Korean translations (`meaningKo`) - Update AI prompts to extract Korean meanings for keywords * refactor : session_id가 null 체크 추가 * feat : template 환경변수 리펙토링 * fix : sessionId NullPointerException 에러 수정 (#496) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix : 오타 수정 * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * feat : speaking rest API 람다 함수 추가 * refactor : speaking service 재사용 * refactor : AI 영어 회화 연습 코드 리팩토링 * feat : handleChat 메서드 JsonNull 체크 푸가 * refactor : session_id가 null 체크 추가 * feat : template 환경변수 리펙토링 --------- Co-authored-by: DDING JOO * feat: enhance bookmark and reading status tracking for News API - Add bookmark and reading status to individual article responses - Include bookmark status in paginated news responses - Modify `KeywordInfo` to support Korean translations (`meaningKo`) - Update AI prompts to extract Korean meanings for keywords * feat: add category filtering to UserWord API with enhanced query logic - Introduce `category` filtering to `getUserWords` API - Update WordCategory enums to include "news" category - Apply category filter after enrichment with word info - Adjust query limits for bookmarked and incorrect words queries * feat: add default value for ExistingCognitoClientId in template.yaml - Set default to an empty string to ensure compatibility with templates using this parameter without explicitly specifying a value. * fix: SpeakingHandler getStringOrNull 컴파일 에러 수정 * fix: SpeakingHandler getStringOrNull 컴파일 에러 수정 * fix: Bedrock 키워드(meaningKo 포함)를 article에 저장하도록 수정 * fix: Bedrock 키워드(meaningKo 포함)를 article에 저장하도록 수정 * fix: Bedrock 키워드(meaningKo 포함)를 article에 저장하도록 수정 * feat: add dashboard stats API and enhance news stats tracking - Introduce `/stats/dashboard` API for retrieving integrated user stats (today, overall, weekly progress, and level distribution) - Update `UserStats` model to include additional fields for news stats (newsRead, newsQuizCompleted, newsQuizPerfect, newsWordsCollected, etc.) - Add atomic updates to track news reading, quiz, and word stats in `UserStatsRepository` - Modify CloudFormation `template.yaml` to register the new `/stats/dashboard` endpoint * feat: add dashboard stats API and enhance news stats tracking - Introduce `/stats/dashboard` API for retrieving integrated user stats (today, overall, weekly progress, and level distribution) - Update `UserStats` model to include additional fields for news stats (newsRead, newsQuizCompleted, newsQuizPerfect, newsWordsCollected, etc.) - Add atomic updates to track news reading, quiz, and word stats in `UserStatsRepository` - Modify CloudFormation `template.yaml` to register the new `/stats/dashboard` endpoint * feat: add dashboard stats API and enhance news stats tracking - Introduce `/stats/dashboard` API for retrieving integrated user stats (today, overall, weekly progress, and level distribution) - Update `UserStats` model to include additional fields for news stats (newsRead, newsQuizCompleted, newsQuizPerfect, newsWordsCollected, etc.) - Add atomic updates to track news reading, quiz, and word stats in `UserStatsRepository` - Modify CloudFormation `template.yaml` to register the new `/stats/dashboard` endpoint * refactor: format code with consistent indentation and spacing - Apply consistent formatting to improve code readability across multiple files - Adjust indentation, spacing, and alignment in model classes, DTOs, and repository methods * fix: filter by ARTICLE# prefix in findById to avoid returning bookmark records Co-Authored-By: Claude Opus 4.5 * feat : Speaking 관련 template 람다 함수 및 테이블 추가 * feat : 말하기 기능에 polly 서비스 권한 추가 --------- Co-authored-by: DDING JOO Co-authored-by: Claude Opus 4.5 --- ServerlessFunction/template.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 50fbb74d..c0a7be9b 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -44,6 +44,7 @@ Globals: PROFILE_BUCKET_NAME: !Sub "${AWS::StackName}" OPIC_BUCKET_NAME: !Sub "${AWS::StackName}" NEWS_BUCKET_NAME: !Sub "${AWS::StackName}" + SPEAKING_BUCKET_NAME: !Sub "${AWS::StackName}" AWS_REGION_NAME: !Ref AWS::Region ROOM_TOKEN_TTL_SECONDS: "300" TRANSCRIBE_PROXY_URL: "https://tfo1zm7vec.execute-api.ap-northeast-2.amazonaws.com/prod/transcribe" @@ -1574,6 +1575,7 @@ Resources: - Effect: Allow Action: - bedrock:InvokeModel + - polly:SynthesizeSpeech Resource: "*" Events: SpeakingChat: From 4cae51e1cdaf910f7469f11920f98968a332adfa Mon Sep 17 00:00:00 2001 From: hyein Heo <128613248+hye-inA@users.noreply.github.com> Date: Sat, 24 Jan 2026 00:40:38 +0900 Subject: [PATCH 513/528] =?UTF-8?q?feature=20:=20=EB=A7=90=ED=95=98?= =?UTF-8?q?=EA=B8=B0=20=EC=97=B0=EC=8A=B5=20=EA=B8=B0=EB=8A=A5=20polly=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=20=EA=B6=8C=ED=95=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#514)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: EnvConfig 유틸리티 추가 및 환경 변수 검증 적용 환경 변수 미설정 시 명확한 에러 메시지를 제공하는 EnvConfig 유틸리티를 추가하고, 기존 System.getenv 호출을 EnvConfig.getRequired/getOrDefault로 대체함. - EnvConfig: getRequired, getOrDefault, getIntOrDefault, getLongOrDefault 메서드 제공 - Lambda Cold Start 시점에 환경 변수 누락을 조기 감지 - 기존 Config 클래스(WebSocketConfig, RoomTokenConfig) EnvConfig 사용으로 통일 Closes #403 * refactor: TestService submitTest 메서드 책임 분리 submitTest 메서드를 단일 책임 원칙에 맞게 리팩토링: - gradeAnswers(): 답안 채점 및 결과 집계 - isAnswerCorrect(): 단일 답안 정답 여부 판단 - buildResultItem(): 결과 항목 생성 - saveTestResult(): 테스트 결과 저장 - GradingResult record: 채점 결과 캡슐화 TestService와 TestCommandService 모두 동일하게 적용 Closes #404 * refactor: 하드코딩된 설정값 환경 변수로 외부화 각 도메인별 Config 클래스를 생성하여 하드코딩된 값들을 환경 변수로 설정 가능하게 변경. 기본값이 있어 환경 변수 미설정 시에도 기존 동작 유지. ## 새로 추가된 Config 클래스 - GrammarConfig: SESSION_TTL_DAYS, MAX_HISTORY_MESSAGES, MAX_TOKENS 등 - GameConfig: TOTAL_ROUNDS, ROUND_TIME_LIMIT, QUICK_GUESS_THRESHOLD_MS - VocabularyConfig: NEW_WORDS_COUNT, REVIEW_WORDS_COUNT, 상태 전이 임계값 등 ## 지원하는 환경 변수 - GRAMMAR_SESSION_TTL_DAYS, GRAMMAR_MAX_HISTORY_MESSAGES, GRAMMAR_MAX_TOKENS - GAME_TOTAL_ROUNDS, GAME_ROUND_TIME_LIMIT, GAME_QUICK_GUESS_THRESHOLD_MS - VOCAB_NEW_WORDS_COUNT, VOCAB_REVIEW_WORDS_COUNT - VOCAB_TRANSITION_TO_REVIEWING, VOCAB_TRANSITION_TO_MASTERED Closes #406 * test: StudyLevel enum 단위 테스트 추가 * test: Difficulty enum 단위 테스트 추가 * test: StudyConfig 단위 테스트 추가 * test: EnvConfig 단위 테스트 추가 * test: PaginatedResult 단위 테스트 추가 * test: JsonUtil 단위 테스트 추가 * test: CursorUtil 단위 테스트 추가 * test: CommonErrorCode 단위 테스트 추가 * test: CommonException 단위 테스트 추가 * test: BadgeType enum 단위 테스트 추가 * test: BadgeKey 상수 단위 테스트 추가 * test: WordStatus enum 단위 테스트 추가 * test: TestType enum 단위 테스트 추가 * test: VocabularyConfig 단위 테스트 추가 * test: VocabKey 상수 단위 테스트 추가 * test: VocabularyErrorCode 단위 테스트 추가 * test: VocabularyException 단위 테스트 추가 * test: SpacedRepetitionContext 단위 테스트 추가 * test: GrammarLevel enum 단위 테스트 추가 * test: GrammarConfig 단위 테스트 추가 * test: GrammarErrorCode 단위 테스트 추가 * test: GrammarException 단위 테스트 추가 * test: GameStatus enum 단위 테스트 추가 * test: GameConfig 단위 테스트 추가 * test: ChattingErrorCode 단위 테스트 추가 * test: ChattingException 단위 테스트 추가 * fix: OPIc FeedbackResponse.java 문법 오류 수정 * style: AwsClients 코드 포맷팅 * style: EnvConfig 코드 포맷팅 * style: RoomTokenConfig 코드 포맷팅 * style: WebSocketConfig 코드 포맷팅 * style: JsonUtil 코드 포맷팅 * style: GameConfig 코드 포맷팅 * style: GrammarConfig 코드 포맷팅 * style: GrammarKey 코드 포맷팅 * style: GrammarErrorCode 코드 포맷팅 * style: GrammarException 코드 포맷팅 * style: BedrockGrammarCheckFactory 코드 포맷팅 * style: GrammarConversationService 코드 포맷팅 * style: FeedbackResponse 코드 포맷팅 * style: SessionReportResponse 코드 포맷팅 * style: SpeakingError 코드 포맷팅 * style: SpeakingErrorType 코드 포맷팅 * style: OPIcException 코드 포맷팅 * style: OPIcAnswer 코드 포맷팅 * style: OPIcQuestion 코드 포맷팅 * style: OPIcSession 코드 포맷팅 * style: OPIcRepository 코드 포맷팅 * style: FeedbackService 코드 포맷팅 * style: TranscribeProxyService 코드 포맷팅 * style: VocabularyConfig 코드 포맷팅 * style: DailyStudyCommandService 코드 포맷팅 * style: TestCommandService 코드 포맷팅 * style: TestService 코드 포맷팅 * style: CommonErrorCodeSpec 코드 포맷팅 * style: JsonUtilSpec 코드 포맷팅 * style: BadgeTypeSpec 코드 포맷팅 * style: ChattingErrorCodeSpec 코드 포맷팅 * style: GrammarLevelSpec 코드 포맷팅 * style: GrammarErrorCodeSpec 코드 포맷팅 * style: VocabularyErrorCodeSpec 코드 포맷팅 * feature : OPIc 세션관리 + 답변 처리 파이프라인 구현 (#413) * feat : OPIc 질문 & 세션 관련 dto 생성 * refactor : @DynamoDbBean 주석 추가 * feat : 주제 + 소주제 + 레벨로 오픽 질문 조회 추가 * feat : OPIc 세션, 질문, 답변 종합 handler 구현 * feat : 오픽 주제, 소주제별 seed 데이터 추가 * refactor : Transcribe API KEY 환경변수명 수정 * refactor : Bedrock에 사용하는 클로드 모델 변경 * refactor : S3 Key 대신 Proxy에서 요청하는 Base64 값으로 변환 * feat: GAME_START, ROUND_END 메시지에 serverTime 추가 - broadcastGameStart(): serverTime, roundDuration 필드 추가 - broadcastRoundEnd(): serverTime, roundStartTime, roundDuration 필드 추가 - GameService.endRound(): data에 roundStartTime, roundDuration 포함 - 타이머 동기화 버그 수정을 위한 서버 시간 제공 * feat: WebSocketMessageHelper 유틸리티 클래스 추가 - domain 필드 포함 메시지 생성 헬퍼 - DOMAIN_CHAT, DOMAIN_GAME 상수 정의 - buildChatMessage(), buildGameMessage() 메서드 * feat: 모든 WebSocket 메시지에 domain 필드 추가 - ScoreUpdateMessage에 domain 필드 추가 - broadcastGameStart에 domain:"game" 추가 - broadcastRoundEnd에 domain:"game" 추가 - broadcastCorrectAnswerMessage에 domain:"game" 추가 - handleCommandResult 시스템 메시지에 domain:"game" 추가 - handleRegularMessage 채팅 메시지에 domain:"chat" 추가 Closes #426, #427 * feat: GameSession 모델 클래스 생성 - DynamoDB Enhanced Client 어노테이션 적용 - GSI1: roomId로 활성 게임 세션 조회 가능 - 게임 상태 관리용 헬퍼 메서드 포함 Closes #428 * feat: GameSessionRepository 구현 - 기본 CRUD (save, findById, delete) - roomId로 활성/전체 게임 세션 조회 - 상태, 라운드, 점수 업데이트 메서드 - 정답자 추가, 힌트 사용, 게임 종료 처리 Closes #429 * refactor: ChatRoom에서 게임 필드 분리 - 게임 관련 필드 제거 (gameStatus, currentRound 등) - activeGameSessionId 필드 추가 - 게임 상태는 GameSession으로 분리됨 Note: 의존 코드 수정은 #431에서 진행 Closes #430 * refactor: GameSession 기반으로 전체 게임 로직 리팩토링 - GameService: ChatRoom 대신 GameSession 사용 - GameStatsService: GameSession 매개변수로 변경 - CommandService: GameSession 기반 점수 조회 - GameHandler: GameSession 기반 REST API - WebSocketMessageHandler: GameSession 기반 브로드캐스트 - GameStatusResponse, ScoreboardResponse: GameSession 매개변수 Closes #431 * feat: GameSessionHandler Lambda 및 게임 세션 API 구현 - POST /rooms/{roomId}/games - 게임 세션 생성 - GET /games/{gameSessionId} - 게임 상태 조회 (재접속용) - POST /games/{gameSessionId}/start - 게임 시작 - POST /games/{gameSessionId}/stop - 게임 종료 모든 응답에 serverTime 포함 (타이머 동기화) 출제자에게만 currentWord 포함 Closes #432, #433 * feat: 게임 시작 7분 후 자동 종료 기능 구현 - GameConfig에 gameTimeLimit() 메서드 추가 (기본값: 420초) - GameSchedulerClient 유틸리티 클래스 생성 (EventBridge Scheduler 연동) - GameService에 스케줄 생성/취소 로직 추가 - GameAutoCloseHandler Lambda 함수 생성 - template.yaml에 Lambda, IAM Role, Schedule Group 추가 Closes #417 * fix : 메모리 증가 및 Lambda 응답 제한 시간 Cognito 트리거 제한시간과 동일하게 수정 (#439) * feat: 캐치마인드 게임 방 분리 기능 구현 (#455) - Room 타입 분리 (CHAT/GAME) - RoomType, RoomStatus enum 추가 - ChatRoom 모델에 type, gameType, gameSettings, status, hostId 필드 추가 - GameSettings 모델 추가 - 방 생성/조회 API 수정 - CreateRoomRequest에 type, gameType, gameSettings 필드 추가 - 방 목록 조회 시 type, gameType, status 필터 지원 - 게임 시작 조건 검증 - GAME 타입 방에서만 게임 시작 가능 (GAME_007 에러) - 게임 재시작 API 구현 (#452) - POST /rooms/{roomId}/game/restart 엔드포인트 추가 - 방장만 재시작 가능 (GAME_009 에러) - 게임 진행 중 재시작 불가 (GAME_008 에러) - 방장 변경 로직 구현 (#453) - 방장 퇴장 시 다음 멤버에게 자동 이전 - 모든 멤버 퇴장 시 방 자동 삭제 - WebSocket 메시지 타입 추가 (#454) - ROOM_STATUS_CHANGE, HOST_CHANGE 메시지 타입 추가 - WebSocketMessageHelper에 빌더 메서드 추가 - 테스트 추가 - RoomType, RoomStatus enum 테스트 - GameSettings 모델 테스트 - ChattingErrorCode 테스트 업데이트 Related: #440, #441, #442, #443, #444, #445, #446 Closes: #447, #448, #449, #450, #451, #452, #453, #454 * feat: 참가자 닉네임 및 방장 변경 WebSocket 알림 구현 (#456) - RoomParticipant DTO 추가 (userId, nickname, isHost) - ChatRoomQueryService에 닉네임 조회 메서드 추가 - getParticipantsWithNicknames(): 참가자 목록 + 닉네임 - getHostNickname(): 방장 닉네임 조회 - ChatRoomHandler.getRoom() 응답에 participants, hostNickname 추가 - ChatRoomCommandService.leaveRoom()에서 방장 변경 시 WebSocket 브로드캐스트 Related: #440 * fix: ChatRoomFunction에 UserTable DynamoDB 권한 추가 - 참가자/방장 닉네임 조회를 위한 UserTable 읽기 권한 추가 - DynamoDBReadPolicy로 UserTable 접근 허용 Fixes #457 * fix: GameSettings에 @DynamoDbBean 어노테이션 추가 - DynamoDB Enhanced Client가 중첩 객체를 직렬화/역직렬화할 수 있도록 수정 - ChatRoom 저장/조회 시 GameSettings 변환 오류 해결 Fixes #457 * fix: ChatRoomFunction에 WEBSOCKET_ENDPOINT 환경변수 및 권한 추가 - leaveRoom에서 WebSocketBroadcaster 사용을 위한 환경변수 추가 - execute-api:ManageConnections 권한 추가 * fix: WebSocket Lambda 함수들에 WEBSOCKET_ENDPOINT 환경변수 추가 - WebSocketConnectFunction에 WEBSOCKET_ENDPOINT 추가 - WebSocketDisconnectFunction에 WEBSOCKET_ENDPOINT 추가 - execute-api:ManageConnections 권한 추가 * fix: Grammar WebSocket Lambda 환경 변수 및 권한 추가 - GrammarStreamingConnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - GrammarStreamingDisconnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - 두 함수에 execute-api:ManageConnections 권한 추가 * feat: GSI1SK 확장성 있는 재설계 및 DB 레벨 필터링 ## 변경 사항 ### GSI1SK 포맷 변경 - 기존: {level}#{createdAt} - 신규: {type}#{gameType}#{status}#{level}#{createdAt} ### 지원 쿼리 패턴 - 전체 방: GSI1PK = "ROOMS" - 게임방만: begins_with(GSI1SK, "GAME#") - 캐치마인드만: begins_with(GSI1SK, "GAME#CATCHMIND#") - 대기중 캐치마인드: begins_with(GSI1SK, "GAME#CATCHMIND#WAITING#") ### 파일 수정 - ChatRoomCommandService.java: 방 생성 시 새 GSI1SK 포맷 적용 - ChatRoomRepository.java: findByFilters() 메서드 추가, updateStatus() 메서드 추가 - ChatRoomQueryService.java: 메모리 필터링 제거, DB 레벨 필터링으로 변경 - GameService.java: 게임 시작/종료 시 방 상태 업데이트 (GSI1SK 포함) ### 마이그레이션 - scripts/migrate-gsi1sk.sh: 기존 데이터 마이그레이션 스크립트 - 37개 기존 방 마이그레이션 완료 * fix: increase stats query limit to 100 days - Adjust maximum allowable limit for recent stats query from 30 to 100 days to support extended data range responses. * feat: improve game round and connection management logic - Added handling for game round timeout (`ROUND_TIMEOUT`) in WebSocketMessageHandler. - Enhanced game start broadcast to include `currentWord` for the drawer only. - Updated ConnectionRepository to remove duplicate user connections in the same room. - Added `currentWordEnglish` to GameSession for better answer verification. - Normalized ChatRoom levels to match database storage format. - Updated template.yaml to include DynamoDBReadPolicy for VocabTable. * refactor: 코드 정리 및 미사용 클래스 제거 (#459) * refactor: remove unused WebSocketResponseUtil * refactor: add AutoCloseable to WebSocketBroadcaster * refactor: remove unused TestService * refactor: remove unused WordService * refactor: remove unused DailyStudyService * refactor: remove unused UserWordService * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * fix: resolve N+1 query in StatsService * refactor(all): DI 패턴 및 전략 패턴 적용 (#461) - Repository, Service, Handler에 DI 생성자 추가 (테스트 용이성) - BadgeService에 Strategy 패턴 적용 (뱃지 조건 검증 로직 분리) * refactor: relocate and restructure seed data files - Moved `question-homes.json` and `words.json` from subdirectories to `seed` folder. - Updated file paths for better organization and clarity. * chore: seed 데이터 폴더 구조 정리 * feat: add CI/CD pipeline configuration for CodePipeline * fix: add SNS topic policy and DependsOn for notification rule * fix: correct paths in buildspec.yml for CodeBuild * fix: remove hardcoded JAVA_HOME, use runtime default * fix: add gradle wrapper for CI/CD build * fix: use single line sam package command with hardcoded bucket * fix: use existing stack name group2-englishstudy-chatting * fix: add missing WEBSOCKET_ENDPOINT env var to WebSocket connect functions * docs: update FRONTEND-API-GUIDE with new RoomType/RoomStatus structure * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix: update WebSocketDisconnectHandler to use GameSession model - Remove references to deleted ChatRoom game fields - Use GameSessionRepository to finish active game sessions - Use ChatRoomRepository.updateStatus() for room state management * perf: optimize CI/CD build time - Add pip cache for SAM CLI dependencies - Enable Gradle parallel builds and build cache - Add SAM build cache (.aws-sam) - Use sam build --parallel --cached - Skip SAM CLI install if already cached - Remove unnecessary 'clean' to leverage cache * feat: add custom CodeBuild Docker image with pre-installed tools - Dockerfile with Java 21 + SAM CLI + Gradle pre-installed - build-and-push.sh script for ECR deployment - Updated buildspec.yml for custom image (removes SAM CLI install) - Expected build time reduction: ~30-40 seconds * feature : AI 영어 회화 연습 기능 (#468) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix: remove typo in SpeakingConnectionRepository * fix : 오타 수정 * chore: trigger build test with custom Docker image * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes * Release: 캐치마인드 게임 분리, AI 회화 연습, CI/CD 파이프라인 (#469) * feature : OPIc 세션관리 + 답변 처리 파이프라인 구현 (#413) * feat : OPIc 질문 & 세션 관련 dto 생성 * refactor : @DynamoDbBean 주석 추가 * feat : 주제 + 소주제 + 레벨로 오픽 질문 조회 추가 * feat : OPIc 세션, 질문, 답변 종합 handler 구현 * feat : 오픽 주제, 소주제별 seed 데이터 추가 * refactor : Transcribe API KEY 환경변수명 수정 * refactor : Bedrock에 사용하는 클로드 모델 변경 * refactor : S3 Key 대신 Proxy에서 요청하는 Base64 값으로 변환 * feat: GAME_START, ROUND_END 메시지에 serverTime 추가 - broadcastGameStart(): serverTime, roundDuration 필드 추가 - broadcastRoundEnd(): serverTime, roundStartTime, roundDuration 필드 추가 - GameService.endRound(): data에 roundStartTime, roundDuration 포함 - 타이머 동기화 버그 수정을 위한 서버 시간 제공 * feat: WebSocketMessageHelper 유틸리티 클래스 추가 - domain 필드 포함 메시지 생성 헬퍼 - DOMAIN_CHAT, DOMAIN_GAME 상수 정의 - buildChatMessage(), buildGameMessage() 메서드 * feat: 모든 WebSocket 메시지에 domain 필드 추가 - ScoreUpdateMessage에 domain 필드 추가 - broadcastGameStart에 domain:"game" 추가 - broadcastRoundEnd에 domain:"game" 추가 - broadcastCorrectAnswerMessage에 domain:"game" 추가 - handleCommandResult 시스템 메시지에 domain:"game" 추가 - handleRegularMessage 채팅 메시지에 domain:"chat" 추가 Closes #426, #427 * feat: GameSession 모델 클래스 생성 - DynamoDB Enhanced Client 어노테이션 적용 - GSI1: roomId로 활성 게임 세션 조회 가능 - 게임 상태 관리용 헬퍼 메서드 포함 Closes #428 * feat: GameSessionRepository 구현 - 기본 CRUD (save, findById, delete) - roomId로 활성/전체 게임 세션 조회 - 상태, 라운드, 점수 업데이트 메서드 - 정답자 추가, 힌트 사용, 게임 종료 처리 Closes #429 * refactor: ChatRoom에서 게임 필드 분리 - 게임 관련 필드 제거 (gameStatus, currentRound 등) - activeGameSessionId 필드 추가 - 게임 상태는 GameSession으로 분리됨 Note: 의존 코드 수정은 #431에서 진행 Closes #430 * refactor: GameSession 기반으로 전체 게임 로직 리팩토링 - GameService: ChatRoom 대신 GameSession 사용 - GameStatsService: GameSession 매개변수로 변경 - CommandService: GameSession 기반 점수 조회 - GameHandler: GameSession 기반 REST API - WebSocketMessageHandler: GameSession 기반 브로드캐스트 - GameStatusResponse, ScoreboardResponse: GameSession 매개변수 Closes #431 * feat: GameSessionHandler Lambda 및 게임 세션 API 구현 - POST /rooms/{roomId}/games - 게임 세션 생성 - GET /games/{gameSessionId} - 게임 상태 조회 (재접속용) - POST /games/{gameSessionId}/start - 게임 시작 - POST /games/{gameSessionId}/stop - 게임 종료 모든 응답에 serverTime 포함 (타이머 동기화) 출제자에게만 currentWord 포함 Closes #432, #433 * feat: 게임 시작 7분 후 자동 종료 기능 구현 - GameConfig에 gameTimeLimit() 메서드 추가 (기본값: 420초) - GameSchedulerClient 유틸리티 클래스 생성 (EventBridge Scheduler 연동) - GameService에 스케줄 생성/취소 로직 추가 - GameAutoCloseHandler Lambda 함수 생성 - template.yaml에 Lambda, IAM Role, Schedule Group 추가 Closes #417 * fix : 메모리 증가 및 Lambda 응답 제한 시간 Cognito 트리거 제한시간과 동일하게 수정 (#439) * feat: 캐치마인드 게임 방 분리 기능 구현 (#455) - Room 타입 분리 (CHAT/GAME) - RoomType, RoomStatus enum 추가 - ChatRoom 모델에 type, gameType, gameSettings, status, hostId 필드 추가 - GameSettings 모델 추가 - 방 생성/조회 API 수정 - CreateRoomRequest에 type, gameType, gameSettings 필드 추가 - 방 목록 조회 시 type, gameType, status 필터 지원 - 게임 시작 조건 검증 - GAME 타입 방에서만 게임 시작 가능 (GAME_007 에러) - 게임 재시작 API 구현 (#452) - POST /rooms/{roomId}/game/restart 엔드포인트 추가 - 방장만 재시작 가능 (GAME_009 에러) - 게임 진행 중 재시작 불가 (GAME_008 에러) - 방장 변경 로직 구현 (#453) - 방장 퇴장 시 다음 멤버에게 자동 이전 - 모든 멤버 퇴장 시 방 자동 삭제 - WebSocket 메시지 타입 추가 (#454) - ROOM_STATUS_CHANGE, HOST_CHANGE 메시지 타입 추가 - WebSocketMessageHelper에 빌더 메서드 추가 - 테스트 추가 - RoomType, RoomStatus enum 테스트 - GameSettings 모델 테스트 - ChattingErrorCode 테스트 업데이트 Related: #440, #441, #442, #443, #444, #445, #446 Closes: #447, #448, #449, #450, #451, #452, #453, #454 * feat: 참가자 닉네임 및 방장 변경 WebSocket 알림 구현 (#456) - RoomParticipant DTO 추가 (userId, nickname, isHost) - ChatRoomQueryService에 닉네임 조회 메서드 추가 - getParticipantsWithNicknames(): 참가자 목록 + 닉네임 - getHostNickname(): 방장 닉네임 조회 - ChatRoomHandler.getRoom() 응답에 participants, hostNickname 추가 - ChatRoomCommandService.leaveRoom()에서 방장 변경 시 WebSocket 브로드캐스트 Related: #440 * fix: ChatRoomFunction에 UserTable DynamoDB 권한 추가 - 참가자/방장 닉네임 조회를 위한 UserTable 읽기 권한 추가 - DynamoDBReadPolicy로 UserTable 접근 허용 Fixes #457 * fix: GameSettings에 @DynamoDbBean 어노테이션 추가 - DynamoDB Enhanced Client가 중첩 객체를 직렬화/역직렬화할 수 있도록 수정 - ChatRoom 저장/조회 시 GameSettings 변환 오류 해결 Fixes #457 * fix: ChatRoomFunction에 WEBSOCKET_ENDPOINT 환경변수 및 권한 추가 - leaveRoom에서 WebSocketBroadcaster 사용을 위한 환경변수 추가 - execute-api:ManageConnections 권한 추가 * fix: WebSocket Lambda 함수들에 WEBSOCKET_ENDPOINT 환경변수 추가 - WebSocketConnectFunction에 WEBSOCKET_ENDPOINT 추가 - WebSocketDisconnectFunction에 WEBSOCKET_ENDPOINT 추가 - execute-api:ManageConnections 권한 추가 * fix: Grammar WebSocket Lambda 환경 변수 및 권한 추가 - GrammarStreamingConnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - GrammarStreamingDisconnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - 두 함수에 execute-api:ManageConnections 권한 추가 * feat: GSI1SK 확장성 있는 재설계 및 DB 레벨 필터링 ## 변경 사항 ### GSI1SK 포맷 변경 - 기존: {level}#{createdAt} - 신규: {type}#{gameType}#{status}#{level}#{createdAt} ### 지원 쿼리 패턴 - 전체 방: GSI1PK = "ROOMS" - 게임방만: begins_with(GSI1SK, "GAME#") - 캐치마인드만: begins_with(GSI1SK, "GAME#CATCHMIND#") - 대기중 캐치마인드: begins_with(GSI1SK, "GAME#CATCHMIND#WAITING#") ### 파일 수정 - ChatRoomCommandService.java: 방 생성 시 새 GSI1SK 포맷 적용 - ChatRoomRepository.java: findByFilters() 메서드 추가, updateStatus() 메서드 추가 - ChatRoomQueryService.java: 메모리 필터링 제거, DB 레벨 필터링으로 변경 - GameService.java: 게임 시작/종료 시 방 상태 업데이트 (GSI1SK 포함) ### 마이그레이션 - scripts/migrate-gsi1sk.sh: 기존 데이터 마이그레이션 스크립트 - 37개 기존 방 마이그레이션 완료 * fix: increase stats query limit to 100 days - Adjust maximum allowable limit for recent stats query from 30 to 100 days to support extended data range responses. * feat: improve game round and connection management logic - Added handling for game round timeout (`ROUND_TIMEOUT`) in WebSocketMessageHandler. - Enhanced game start broadcast to include `currentWord` for the drawer only. - Updated ConnectionRepository to remove duplicate user connections in the same room. - Added `currentWordEnglish` to GameSession for better answer verification. - Normalized ChatRoom levels to match database storage format. - Updated template.yaml to include DynamoDBReadPolicy for VocabTable. * refactor: 코드 정리 및 미사용 클래스 제거 (#459) * refactor: remove unused WebSocketResponseUtil * refactor: add AutoCloseable to WebSocketBroadcaster * refactor: remove unused TestService * refactor: remove unused WordService * refactor: remove unused DailyStudyService * refactor: remove unused UserWordService * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * fix: resolve N+1 query in StatsService * refactor(all): DI 패턴 및 전략 패턴 적용 (#461) - Repository, Service, Handler에 DI 생성자 추가 (테스트 용이성) - BadgeService에 Strategy 패턴 적용 (뱃지 조건 검증 로직 분리) * refactor: relocate and restructure seed data files - Moved `question-homes.json` and `words.json` from subdirectories to `seed` folder. - Updated file paths for better organization and clarity. * chore: seed 데이터 폴더 구조 정리 * feat: add CI/CD pipeline configuration for CodePipeline * fix: add SNS topic policy and DependsOn for notification rule * fix: correct paths in buildspec.yml for CodeBuild * fix: remove hardcoded JAVA_HOME, use runtime default * fix: add gradle wrapper for CI/CD build * fix: use single line sam package command with hardcoded bucket * fix: use existing stack name group2-englishstudy-chatting * fix: add missing WEBSOCKET_ENDPOINT env var to WebSocket connect functions * docs: update FRONTEND-API-GUIDE with new RoomType/RoomStatus structure * fix: update WebSocketDisconnectHandler to use GameSession model - Remove references to deleted ChatRoom game fields - Use GameSessionRepository to finish active game sessions - Use ChatRoomRepository.updateStatus() for room state management * perf: optimize CI/CD build time - Add pip cache for SAM CLI dependencies - Enable Gradle parallel builds and build cache - Add SAM build cache (.aws-sam) - Use sam build --parallel --cached - Skip SAM CLI install if already cached - Remove unnecessary 'clean' to leverage cache * feat: add custom CodeBuild Docker image with pre-installed tools - Dockerfile with Java 21 + SAM CLI + Gradle pre-installed - build-and-push.sh script for ECR deployment - Updated buildspec.yml for custom image (removes SAM CLI install) - Expected build time reduction: ~30-40 seconds * feature : AI 영어 회화 연습 기능 (#468) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix: remove typo in SpeakingConnectionRepository * chore: trigger build test with custom Docker image * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes --------- Co-authored-by: hyein Heo <128613248+hye-inA@users.noreply.github.com> * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * fix: add CORS headers to API Gateway error responses (#479) API Gateway의 인증 실패 등 에러 응답에 CORS 헤더가 누락되어 CloudFront를 통한 프론트엔드 요청이 차단되는 문제 수정 - UNAUTHORIZED (401) 응답에 CORS 헤더 추가 - ACCESS_DENIED (403) 응답에 CORS 헤더 추가 - DEFAULT_4XX/5XX 응답에 CORS 헤더 추가 - EXPIRED_TOKEN 응답에 CORS 헤더 추가 * feat(news): 뉴스 도메인 기반 구조 구축 (#385) - NewsCategory, QuizType enum 추가 - NewsKey 상수 클래스 추가 - NewsErrorCode 예외 클래스 추가 - NewsArticle, KeywordInfo, QuizQuestion 모델 추가 - NewsArticleRepository CRUD 구현 - NewsTable DynamoDB 테이블 정의 - CORS GatewayResponses 설정 추가 * feat(news): 뉴스 수집 파이프라인 구현 (#386) - NewsApiClient: NewsAPI 연동 서비스 - RssFeedParser: RSS 피드 파싱 (BBC, VOA, NPR) - NewsDuplicateChecker: URL 기반 중복 필터링 - NewsCollectorService: 수집 오케스트레이션 - NewsCollectionHandler: Lambda 핸들러 - EventBridge 스케줄러: 매일 18시 KST 실행 * refactor(news): NewsAPI 제거, RSS만 사용 - NewsApiClient 삭제 - SSM Parameter 의존성 제거 - RSS 소스당 7개씩 수집 (BBC, VOA, NPR = 약 21개/일) * feat(news): AI 뉴스 분석 시스템 구현 (#387) - NewsAnalysisService: AI 분석 통합 서비스 - Bedrock: CEFR 난이도 분석 (A1~C2) - Bedrock: 3줄 요약 + 퀴즈 3문제 생성 - Comprehend: 핵심 키워드 추출 - NewsCollectorService: 수집 시 자동 분석 연동 - GSI1/GSI2 키 자동 설정 (레벨별, 카테고리별 조회) * feat(news): 뉴스 학습 API 구현 (#388) - NewsQueryService: 뉴스 조회 서비스 - NewsHandler: API 핸들러 - GET /news - 목록 조회 (level, category 필터) - GET /news/today - 오늘의 뉴스 - GET /news/recommended - 내 레벨 맞춤 추천 - GET /news/{articleId} - 상세 조회 (조회수 증가) - template.yaml: NewsFunction Lambda 추가 * feat(news): 뉴스 학습 부가 기능 구현 (#389) - 읽기 완료 기록 API (POST /news/{articleId}/read) - 북마크 토글 API (POST /news/{articleId}/bookmark) - 북마크 목록 조회 API (GET /news/bookmarks) - 학습 통계 조회 API (GET /news/stats) - TTS 오디오 URL 조회 API (GET /news/{articleId}/audio) - UserNewsRecord 모델 추가 - UserNewsRepository 추가 - NewsLearningService 추가 * feat(news): 복합 퀴즈 시스템 구현 (#471) - 퀴즈 조회 API (GET /news/{articleId}/quiz) - 퀴즈 제출 API (POST /news/{articleId}/quiz) - 퀴즈 기록 조회 API (GET /news/quiz/history) - NewsQuizResult 모델 추가 - QuizAnswerResult 모델 추가 - NewsQuizRepository 추가 - NewsQuizService 추가 * feat(news): 단어 수집 & Vocabulary 연동 구현 (#472) - 단어 수집 API (POST /news/{articleId}/words) - 수집 단어 목록 API (GET /news/words) - 단어 상세 조회 API (GET /news/{articleId}/words/{word}) - 단어 삭제 API (DELETE /news/{articleId}/words/{word}) - Vocabulary 연동 API (POST /news/words/{word}/sync) - NewsWordCollect 모델 추가 - NewsWordRepository 추가 - NewsWordService 추가 * feat: add multi-environment deployment support (dev/test/prod) * fix: update buildspec.yml to deploy prod environment with parameter overrides * feat(news): 뉴스 도메인 기반 구조 구축 (#385) - NewsCategory, QuizType enum 추가 - NewsKey 상수 클래스 추가 - NewsErrorCode 예외 클래스 추가 - NewsArticle, KeywordInfo, QuizQuestion 모델 추가 - NewsArticleRepository CRUD 구현 - NewsTable DynamoDB 테이블 정의 - CORS GatewayResponses 설정 추가 * feat(news): 뉴스 수집 파이프라인 구현 (#386) - NewsApiClient: NewsAPI 연동 서비스 - RssFeedParser: RSS 피드 파싱 (BBC, VOA, NPR) - NewsDuplicateChecker: URL 기반 중복 필터링 - NewsCollectorService: 수집 오케스트레이션 - NewsCollectionHandler: Lambda 핸들러 - EventBridge 스케줄러: 매일 18시 KST 실행 * refactor(news): NewsAPI 제거, RSS만 사용 - NewsApiClient 삭제 - SSM Parameter 의존성 제거 - RSS 소스당 7개씩 수집 (BBC, VOA, NPR = 약 21개/일) * feat(news): AI 뉴스 분석 시스템 구현 (#387) - NewsAnalysisService: AI 분석 통합 서비스 - Bedrock: CEFR 난이도 분석 (A1~C2) - Bedrock: 3줄 요약 + 퀴즈 3문제 생성 - Comprehend: 핵심 키워드 추출 - NewsCollectorService: 수집 시 자동 분석 연동 - GSI1/GSI2 키 자동 설정 (레벨별, 카테고리별 조회) * feat(news): 뉴스 학습 API 구현 (#388) - NewsQueryService: 뉴스 조회 서비스 - NewsHandler: API 핸들러 - GET /news - 목록 조회 (level, category 필터) - GET /news/today - 오늘의 뉴스 - GET /news/recommended - 내 레벨 맞춤 추천 - GET /news/{articleId} - 상세 조회 (조회수 증가) - template.yaml: NewsFunction Lambda 추가 * feat(news): 뉴스 학습 부가 기능 구현 (#389) - 읽기 완료 기록 API (POST /news/{articleId}/read) - 북마크 토글 API (POST /news/{articleId}/bookmark) - 북마크 목록 조회 API (GET /news/bookmarks) - 학습 통계 조회 API (GET /news/stats) - TTS 오디오 URL 조회 API (GET /news/{articleId}/audio) - UserNewsRecord 모델 추가 - UserNewsRepository 추가 - NewsLearningService 추가 * feat(news): 복합 퀴즈 시스템 구현 (#471) - 퀴즈 조회 API (GET /news/{articleId}/quiz) - 퀴즈 제출 API (POST /news/{articleId}/quiz) - 퀴즈 기록 조회 API (GET /news/quiz/history) - NewsQuizResult 모델 추가 - QuizAnswerResult 모델 추가 - NewsQuizRepository 추가 - NewsQuizService 추가 * feat(news): 단어 수집 & Vocabulary 연동 구현 (#472) - 단어 수집 API (POST /news/{articleId}/words) - 수집 단어 목록 API (GET /news/words) - 단어 상세 조회 API (GET /news/{articleId}/words/{word}) - 단어 삭제 API (DELETE /news/{articleId}/words/{word}) - Vocabulary 연동 API (POST /news/words/{word}/sync) - NewsWordCollect 모델 추가 - NewsWordRepository 추가 - NewsWordService 추가 * feat: add multi-environment deployment support (dev/test/prod) * fix: update buildspec.yml to deploy prod environment with parameter overrides * refactor : AI 말하기 Websocket 구현 -> REST API 구현으로 리팩토링 (#490) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix : 오타 수정 * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * fix: revert buildspec.yml to build-only for CloudFormation deploy stage * fix: revert buildspec.yml to build-only for CloudFormation deploy stage * fix: update all API Gateway StageName to use Environment parameter * fix: correct VocabularyTable and ContentBucket references in NewsFunction * fix: correct VocabularyTable and ContentBucket references in NewsFunction * fix: add stack name prefix to GameScheduleGroup and daily-stats schedule to avoid conflicts * fix: add stack name prefix to GameScheduleGroup and daily-stats schedule to avoid conflicts * feat: add support for existing Cognito User Pool reuse across environments * fix: add conditional Cognito ARN reference in API Gateway Authorizer * fix: remove Cognito resources completely, use existing Cognito only * fix: remove Cognito resources completely, use existing Cognito only * feat : speaking rest API 람다 함수 추가 * feat: add S3 bucket resource and fix environment-specific endpoints - Add ContentBucket S3 resource for content storage - Replace hardcoded /dev with ${Environment} in all WebSocket endpoints - Update Output URLs to use dynamic environment stage * fix: add stack name prefix to news-collection schedule name Prevent EventBridge rule name conflicts across environments * fix: add X-Requested-With and Accept headers to CORS config Enable additional headers for CloudFront CORS compatibility * fix: use environment variable for S3 bucket URLs Replace hardcoded bucket name with BUCKET_NAME env var for multi-env support: - PreSignUpHandler: dynamic default profile URL - PostConfirmationHandler: dynamic default profile URL - UserService: dynamic default profile URL - BadgeType: dynamic badge image base URL * fix: disable authorizer for CORS preflight requests Add AddDefaultAuthorizerToCorsPreflight: false to prevent Cognito Authorizer from blocking OPTIONS requests * feat : speaking REST API 람다 함수 추가 (#491) * feature : OPIc 세션관리 + 답변 처리 파이프라인 구현 (#413) * feat : OPIc 질문 & 세션 관련 dto 생성 * refactor : @DynamoDbBean 주석 추가 * feat : 주제 + 소주제 + 레벨로 오픽 질문 조회 추가 * feat : OPIc 세션, 질문, 답변 종합 handler 구현 * feat : 오픽 주제, 소주제별 seed 데이터 추가 * refactor : Transcribe API KEY 환경변수명 수정 * refactor : Bedrock에 사용하는 클로드 모델 변경 * refactor : S3 Key 대신 Proxy에서 요청하는 Base64 값으로 변환 * feat: GAME_START, ROUND_END 메시지에 serverTime 추가 - broadcastGameStart(): serverTime, roundDuration 필드 추가 - broadcastRoundEnd(): serverTime, roundStartTime, roundDuration 필드 추가 - GameService.endRound(): data에 roundStartTime, roundDuration 포함 - 타이머 동기화 버그 수정을 위한 서버 시간 제공 * feat: WebSocketMessageHelper 유틸리티 클래스 추가 - domain 필드 포함 메시지 생성 헬퍼 - DOMAIN_CHAT, DOMAIN_GAME 상수 정의 - buildChatMessage(), buildGameMessage() 메서드 * feat: 모든 WebSocket 메시지에 domain 필드 추가 - ScoreUpdateMessage에 domain 필드 추가 - broadcastGameStart에 domain:"game" 추가 - broadcastRoundEnd에 domain:"game" 추가 - broadcastCorrectAnswerMessage에 domain:"game" 추가 - handleCommandResult 시스템 메시지에 domain:"game" 추가 - handleRegularMessage 채팅 메시지에 domain:"chat" 추가 Closes #426, #427 * feat: GameSession 모델 클래스 생성 - DynamoDB Enhanced Client 어노테이션 적용 - GSI1: roomId로 활성 게임 세션 조회 가능 - 게임 상태 관리용 헬퍼 메서드 포함 Closes #428 * feat: GameSessionRepository 구현 - 기본 CRUD (save, findById, delete) - roomId로 활성/전체 게임 세션 조회 - 상태, 라운드, 점수 업데이트 메서드 - 정답자 추가, 힌트 사용, 게임 종료 처리 Closes #429 * refactor: ChatRoom에서 게임 필드 분리 - 게임 관련 필드 제거 (gameStatus, currentRound 등) - activeGameSessionId 필드 추가 - 게임 상태는 GameSession으로 분리됨 Note: 의존 코드 수정은 #431에서 진행 Closes #430 * refactor: GameSession 기반으로 전체 게임 로직 리팩토링 - GameService: ChatRoom 대신 GameSession 사용 - GameStatsService: GameSession 매개변수로 변경 - CommandService: GameSession 기반 점수 조회 - GameHandler: GameSession 기반 REST API - WebSocketMessageHandler: GameSession 기반 브로드캐스트 - GameStatusResponse, ScoreboardResponse: GameSession 매개변수 Closes #431 * feat: GameSessionHandler Lambda 및 게임 세션 API 구현 - POST /rooms/{roomId}/games - 게임 세션 생성 - GET /games/{gameSessionId} - 게임 상태 조회 (재접속용) - POST /games/{gameSessionId}/start - 게임 시작 - POST /games/{gameSessionId}/stop - 게임 종료 모든 응답에 serverTime 포함 (타이머 동기화) 출제자에게만 currentWord 포함 Closes #432, #433 * feat: 게임 시작 7분 후 자동 종료 기능 구현 - GameConfig에 gameTimeLimit() 메서드 추가 (기본값: 420초) - GameSchedulerClient 유틸리티 클래스 생성 (EventBridge Scheduler 연동) - GameService에 스케줄 생성/취소 로직 추가 - GameAutoCloseHandler Lambda 함수 생성 - template.yaml에 Lambda, IAM Role, Schedule Group 추가 Closes #417 * fix : 메모리 증가 및 Lambda 응답 제한 시간 Cognito 트리거 제한시간과 동일하게 수정 (#439) * feat: 캐치마인드 게임 방 분리 기능 구현 (#455) - Room 타입 분리 (CHAT/GAME) - RoomType, RoomStatus enum 추가 - ChatRoom 모델에 type, gameType, gameSettings, status, hostId 필드 추가 - GameSettings 모델 추가 - 방 생성/조회 API 수정 - CreateRoomRequest에 type, gameType, gameSettings 필드 추가 - 방 목록 조회 시 type, gameType, status 필터 지원 - 게임 시작 조건 검증 - GAME 타입 방에서만 게임 시작 가능 (GAME_007 에러) - 게임 재시작 API 구현 (#452) - POST /rooms/{roomId}/game/restart 엔드포인트 추가 - 방장만 재시작 가능 (GAME_009 에러) - 게임 진행 중 재시작 불가 (GAME_008 에러) - 방장 변경 로직 구현 (#453) - 방장 퇴장 시 다음 멤버에게 자동 이전 - 모든 멤버 퇴장 시 방 자동 삭제 - WebSocket 메시지 타입 추가 (#454) - ROOM_STATUS_CHANGE, HOST_CHANGE 메시지 타입 추가 - WebSocketMessageHelper에 빌더 메서드 추가 - 테스트 추가 - RoomType, RoomStatus enum 테스트 - GameSettings 모델 테스트 - ChattingErrorCode 테스트 업데이트 Related: #440, #441, #442, #443, #444, #445, #446 Closes: #447, #448, #449, #450, #451, #452, #453, #454 * feat: 참가자 닉네임 및 방장 변경 WebSocket 알림 구현 (#456) - RoomParticipant DTO 추가 (userId, nickname, isHost) - ChatRoomQueryService에 닉네임 조회 메서드 추가 - getParticipantsWithNicknames(): 참가자 목록 + 닉네임 - getHostNickname(): 방장 닉네임 조회 - ChatRoomHandler.getRoom() 응답에 participants, hostNickname 추가 - ChatRoomCommandService.leaveRoom()에서 방장 변경 시 WebSocket 브로드캐스트 Related: #440 * fix: ChatRoomFunction에 UserTable DynamoDB 권한 추가 - 참가자/방장 닉네임 조회를 위한 UserTable 읽기 권한 추가 - DynamoDBReadPolicy로 UserTable 접근 허용 Fixes #457 * fix: GameSettings에 @DynamoDbBean 어노테이션 추가 - DynamoDB Enhanced Client가 중첩 객체를 직렬화/역직렬화할 수 있도록 수정 - ChatRoom 저장/조회 시 GameSettings 변환 오류 해결 Fixes #457 * fix: ChatRoomFunction에 WEBSOCKET_ENDPOINT 환경변수 및 권한 추가 - leaveRoom에서 WebSocketBroadcaster 사용을 위한 환경변수 추가 - execute-api:ManageConnections 권한 추가 * fix: WebSocket Lambda 함수들에 WEBSOCKET_ENDPOINT 환경변수 추가 - WebSocketConnectFunction에 WEBSOCKET_ENDPOINT 추가 - WebSocketDisconnectFunction에 WEBSOCKET_ENDPOINT 추가 - execute-api:ManageConnections 권한 추가 * fix: Grammar WebSocket Lambda 환경 변수 및 권한 추가 - GrammarStreamingConnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - GrammarStreamingDisconnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - 두 함수에 execute-api:ManageConnections 권한 추가 * feat: GSI1SK 확장성 있는 재설계 및 DB 레벨 필터링 - 기존: {level}#{createdAt} - 신규: {type}#{gameType}#{status}#{level}#{createdAt} - 전체 방: GSI1PK = "ROOMS" - 게임방만: begins_with(GSI1SK, "GAME#") - 캐치마인드만: begins_with(GSI1SK, "GAME#CATCHMIND#") - 대기중 캐치마인드: begins_with(GSI1SK, "GAME#CATCHMIND#WAITING#") - ChatRoomCommandService.java: 방 생성 시 새 GSI1SK 포맷 적용 - ChatRoomRepository.java: findByFilters() 메서드 추가, updateStatus() 메서드 추가 - ChatRoomQueryService.java: 메모리 필터링 제거, DB 레벨 필터링으로 변경 - GameService.java: 게임 시작/종료 시 방 상태 업데이트 (GSI1SK 포함) - scripts/migrate-gsi1sk.sh: 기존 데이터 마이그레이션 스크립트 - 37개 기존 방 마이그레이션 완료 * fix: increase stats query limit to 100 days - Adjust maximum allowable limit for recent stats query from 30 to 100 days to support extended data range responses. * feat: improve game round and connection management logic - Added handling for game round timeout (`ROUND_TIMEOUT`) in WebSocketMessageHandler. - Enhanced game start broadcast to include `currentWord` for the drawer only. - Updated ConnectionRepository to remove duplicate user connections in the same room. - Added `currentWordEnglish` to GameSession for better answer verification. - Normalized ChatRoom levels to match database storage format. - Updated template.yaml to include DynamoDBReadPolicy for VocabTable. * refactor: 코드 정리 및 미사용 클래스 제거 (#459) * refactor: remove unused WebSocketResponseUtil * refactor: add AutoCloseable to WebSocketBroadcaster * refactor: remove unused TestService * refactor: remove unused WordService * refactor: remove unused DailyStudyService * refactor: remove unused UserWordService * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * fix: resolve N+1 query in StatsService * refactor(all): DI 패턴 및 전략 패턴 적용 (#461) - Repository, Service, Handler에 DI 생성자 추가 (테스트 용이성) - BadgeService에 Strategy 패턴 적용 (뱃지 조건 검증 로직 분리) * refactor: relocate and restructure seed data files - Moved `question-homes.json` and `words.json` from subdirectories to `seed` folder. - Updated file paths for better organization and clarity. * chore: seed 데이터 폴더 구조 정리 * feat: add CI/CD pipeline configuration for CodePipeline * fix: add SNS topic policy and DependsOn for notification rule * fix: correct paths in buildspec.yml for CodeBuild * fix: remove hardcoded JAVA_HOME, use runtime default * fix: add gradle wrapper for CI/CD build * fix: use single line sam package command with hardcoded bucket * fix: use existing stack name group2-englishstudy-chatting * fix: add missing WEBSOCKET_ENDPOINT env var to WebSocket connect functions * docs: update FRONTEND-API-GUIDE with new RoomType/RoomStatus structure * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix: update WebSocketDisconnectHandler to use GameSession model - Remove references to deleted ChatRoom game fields - Use GameSessionRepository to finish active game sessions - Use ChatRoomRepository.updateStatus() for room state management * perf: optimize CI/CD build time - Add pip cache for SAM CLI dependencies - Enable Gradle parallel builds and build cache - Add SAM build cache (.aws-sam) - Use sam build --parallel --cached - Skip SAM CLI install if already cached - Remove unnecessary 'clean' to leverage cache * feat: add custom CodeBuild Docker image with pre-installed tools - Dockerfile with Java 21 + SAM CLI + Gradle pre-installed - build-and-push.sh script for ECR deployment - Updated buildspec.yml for custom image (removes SAM CLI install) - Expected build time reduction: ~30-40 seconds * feature : AI 영어 회화 연습 기능 (#468) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix: remove typo in SpeakingConnectionRepository * fix : 오타 수정 * chore: trigger build test with custom Docker image * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * fix: add CORS headers to API Gateway error responses (#479) API Gateway의 인증 실패 등 에러 응답에 CORS 헤더가 누락되어 CloudFront를 통한 프론트엔드 요청이 차단되는 문제 수정 - UNAUTHORIZED (401) 응답에 CORS 헤더 추가 - ACCESS_DENIED (403) 응답에 CORS 헤더 추가 - DEFAULT_4XX/5XX 응답에 CORS 헤더 추가 - EXPIRED_TOKEN 응답에 CORS 헤더 추가 * feat : speaking rest API 람다 함수 추가 --------- Co-authored-by: ddingjoo * refactor : speaking service 재사용 * refactor : AI 영어 회화 연습 코드 리팩토링 * feature : test 벡엔드 서버에 AI 말하기 연습 기능 배포 (#492) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix : 오타 수정 * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * feat : speaking rest API 람다 함수 추가 * refactor : speaking service 재사용 * refactor : AI 영어 회화 연습 코드 리팩토링 --------- Co-authored-by: DDING JOO * feat : handleChat 메서드 JsonNull 체크 푸가 * feature : handleChat 메서드 JsonNull 체크 추가 (#493) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix : 오타 수정 * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * feat : speaking rest API 람다 함수 추가 * refactor : speaking service 재사용 * refactor : AI 영어 회화 연습 코드 리팩토링 * feat : handleChat 메서드 JsonNull 체크 푸가 --------- Co-authored-by: DDING JOO * feat(news): 뉴스 학습 배지 시스템 구현 (#473) - 14개 뉴스 관련 배지 추가 (읽기, 퀴즈, 단어수집, 연속학습, 마스터) - UserStats에 뉴스 통계 필드 추가 - 6개 뉴스 배지 Strategy 클래스 생성 - 뉴스 읽기/퀴즈/단어수집 시 통계 업데이트 및 배지 체크 연동 * fix: add PATCH method to CORS AllowMethods * test: BadgeType 개수 테스트 수정 (15 -> 29) * fix: CORS PATCH 메서드 추가 * docs: 뉴스 기능 프론트엔드 연동 가이드 작성 * fix: NewsCollectionFunction에 Bedrock, Comprehend 권한 추가 * fix: add null check for collectWord request body - Add INVALID_REQUEST error code to NewsErrorCode - Check body and word field before accessing in collectWord() - Prevents NullPointerException when request body is malformed * feat: enhance stats API and bookmark response for frontend - Add DAILY stats update for news read/quiz/word collection - Add /stats/dashboard endpoint with frontend-requested format - today: wordsLearned, newsRead, quizzesTaken, wordsTotal - overall: totalWordsLearned, totalNewsRead, averageAccuracy, streaks - weeklyProgress: last 7 days with date/wordsLearned/newsRead - Add news-related fields to all stats API responses - Fix bookmark API to include full article details (title, summary, etc.) * feat: add category classification to news AI analysis - Add category field to AnalysisResult record - Update Bedrock prompt to classify articles into categories (WORLD, POLITICS, BUSINESS, TECH, SCIENCE, HEALTH, SPORTS, ENTERTAINMENT, LIFESTYLE) - Parse and set category from AI response - Set GSI2 (CATEGORY#) index when category is available * fix: add /stats/dashboard endpoint to template.yaml * fix: filter by ARTICLE# prefix in findById to avoid returning UserNewsRecord * docs: add News API troubleshooting guide * feat: add Cognito authorizer to News API and enhance keyword extraction - Set CognitoAuthorizer for all News API endpoints in template.yaml - Update Bedrock AI to extract keywords with meanings and examples - Add fallback to Comprehend for keyword extraction when Bedrock fails - Modify KeywordInfo model to include example field - Adjust AI prompt to include keyword extraction with examples - Update AnalysisResult to store keywords and parse them from AI response * Revert "Merge branch 'test' into prod" This reverts commit c1a958eac6799d5838a76e4078ae304bcdb62278, reversing changes made to a6662e0cfc46c6e12f20a89f0df285ff282160bc. * feat: enhance bookmark and reading status tracking for News API - Add bookmark and reading status to individual article responses - Include bookmark status in paginated news responses - Modify `KeywordInfo` to support Korean translations (`meaningKo`) - Update AI prompts to extract Korean meanings for keywords * refactor : session_id가 null 체크 추가 * feat : template 환경변수 리펙토링 * fix : sessionId NullPointerException 에러 수정 (#496) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix : 오타 수정 * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * feat : speaking rest API 람다 함수 추가 * refactor : speaking service 재사용 * refactor : AI 영어 회화 연습 코드 리팩토링 * feat : handleChat 메서드 JsonNull 체크 푸가 * refactor : session_id가 null 체크 추가 * feat : template 환경변수 리펙토링 --------- Co-authored-by: DDING JOO * feat: enhance bookmark and reading status tracking for News API - Add bookmark and reading status to individual article responses - Include bookmark status in paginated news responses - Modify `KeywordInfo` to support Korean translations (`meaningKo`) - Update AI prompts to extract Korean meanings for keywords * feat: add category filtering to UserWord API with enhanced query logic - Introduce `category` filtering to `getUserWords` API - Update WordCategory enums to include "news" category - Apply category filter after enrichment with word info - Adjust query limits for bookmarked and incorrect words queries * feat: add default value for ExistingCognitoClientId in template.yaml - Set default to an empty string to ensure compatibility with templates using this parameter without explicitly specifying a value. * fix: SpeakingHandler getStringOrNull 컴파일 에러 수정 * fix: SpeakingHandler getStringOrNull 컴파일 에러 수정 * fix: Bedrock 키워드(meaningKo 포함)를 article에 저장하도록 수정 * fix: Bedrock 키워드(meaningKo 포함)를 article에 저장하도록 수정 * fix: Bedrock 키워드(meaningKo 포함)를 article에 저장하도록 수정 * feat: add dashboard stats API and enhance news stats tracking - Introduce `/stats/dashboard` API for retrieving integrated user stats (today, overall, weekly progress, and level distribution) - Update `UserStats` model to include additional fields for news stats (newsRead, newsQuizCompleted, newsQuizPerfect, newsWordsCollected, etc.) - Add atomic updates to track news reading, quiz, and word stats in `UserStatsRepository` - Modify CloudFormation `template.yaml` to register the new `/stats/dashboard` endpoint * feat: add dashboard stats API and enhance news stats tracking - Introduce `/stats/dashboard` API for retrieving integrated user stats (today, overall, weekly progress, and level distribution) - Update `UserStats` model to include additional fields for news stats (newsRead, newsQuizCompleted, newsQuizPerfect, newsWordsCollected, etc.) - Add atomic updates to track news reading, quiz, and word stats in `UserStatsRepository` - Modify CloudFormation `template.yaml` to register the new `/stats/dashboard` endpoint * feat: add dashboard stats API and enhance news stats tracking - Introduce `/stats/dashboard` API for retrieving integrated user stats (today, overall, weekly progress, and level distribution) - Update `UserStats` model to include additional fields for news stats (newsRead, newsQuizCompleted, newsQuizPerfect, newsWordsCollected, etc.) - Add atomic updates to track news reading, quiz, and word stats in `UserStatsRepository` - Modify CloudFormation `template.yaml` to register the new `/stats/dashboard` endpoint * refactor: format code with consistent indentation and spacing - Apply consistent formatting to improve code readability across multiple files - Adjust indentation, spacing, and alignment in model classes, DTOs, and repository methods * fix: filter by ARTICLE# prefix in findById to avoid returning bookmark records * feat : Speaking 관련 template 람다 함수 및 테이블 추가 * feat : 말하기 기능에 polly 서비스 권한 추가 --------- Co-authored-by: DDING JOO --- ServerlessFunction/template.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 50fbb74d..c0a7be9b 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -44,6 +44,7 @@ Globals: PROFILE_BUCKET_NAME: !Sub "${AWS::StackName}" OPIC_BUCKET_NAME: !Sub "${AWS::StackName}" NEWS_BUCKET_NAME: !Sub "${AWS::StackName}" + SPEAKING_BUCKET_NAME: !Sub "${AWS::StackName}" AWS_REGION_NAME: !Ref AWS::Region ROOM_TOKEN_TTL_SECONDS: "300" TRANSCRIBE_PROXY_URL: "https://tfo1zm7vec.execute-api.ap-northeast-2.amazonaws.com/prod/transcribe" @@ -1574,6 +1575,7 @@ Resources: - Effect: Allow Action: - bedrock:InvokeModel + - polly:SynthesizeSpeech Resource: "*" Events: SpeakingChat: From c01be8a7ab847f94ca82e40d1c1947f843ede567 Mon Sep 17 00:00:00 2001 From: hyein Heo <128613248+hye-inA@users.noreply.github.com> Date: Sat, 24 Jan 2026 01:06:18 +0900 Subject: [PATCH 514/528] =?UTF-8?q?feature=20:=20transcribe=20API=20KEY=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=20(#516)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: EnvConfig 유틸리티 추가 및 환경 변수 검증 적용 환경 변수 미설정 시 명확한 에러 메시지를 제공하는 EnvConfig 유틸리티를 추가하고, 기존 System.getenv 호출을 EnvConfig.getRequired/getOrDefault로 대체함. - EnvConfig: getRequired, getOrDefault, getIntOrDefault, getLongOrDefault 메서드 제공 - Lambda Cold Start 시점에 환경 변수 누락을 조기 감지 - 기존 Config 클래스(WebSocketConfig, RoomTokenConfig) EnvConfig 사용으로 통일 Closes #403 * refactor: TestService submitTest 메서드 책임 분리 submitTest 메서드를 단일 책임 원칙에 맞게 리팩토링: - gradeAnswers(): 답안 채점 및 결과 집계 - isAnswerCorrect(): 단일 답안 정답 여부 판단 - buildResultItem(): 결과 항목 생성 - saveTestResult(): 테스트 결과 저장 - GradingResult record: 채점 결과 캡슐화 TestService와 TestCommandService 모두 동일하게 적용 Closes #404 * refactor: 하드코딩된 설정값 환경 변수로 외부화 각 도메인별 Config 클래스를 생성하여 하드코딩된 값들을 환경 변수로 설정 가능하게 변경. 기본값이 있어 환경 변수 미설정 시에도 기존 동작 유지. ## 새로 추가된 Config 클래스 - GrammarConfig: SESSION_TTL_DAYS, MAX_HISTORY_MESSAGES, MAX_TOKENS 등 - GameConfig: TOTAL_ROUNDS, ROUND_TIME_LIMIT, QUICK_GUESS_THRESHOLD_MS - VocabularyConfig: NEW_WORDS_COUNT, REVIEW_WORDS_COUNT, 상태 전이 임계값 등 ## 지원하는 환경 변수 - GRAMMAR_SESSION_TTL_DAYS, GRAMMAR_MAX_HISTORY_MESSAGES, GRAMMAR_MAX_TOKENS - GAME_TOTAL_ROUNDS, GAME_ROUND_TIME_LIMIT, GAME_QUICK_GUESS_THRESHOLD_MS - VOCAB_NEW_WORDS_COUNT, VOCAB_REVIEW_WORDS_COUNT - VOCAB_TRANSITION_TO_REVIEWING, VOCAB_TRANSITION_TO_MASTERED Closes #406 * test: StudyLevel enum 단위 테스트 추가 * test: Difficulty enum 단위 테스트 추가 * test: StudyConfig 단위 테스트 추가 * test: EnvConfig 단위 테스트 추가 * test: PaginatedResult 단위 테스트 추가 * test: JsonUtil 단위 테스트 추가 * test: CursorUtil 단위 테스트 추가 * test: CommonErrorCode 단위 테스트 추가 * test: CommonException 단위 테스트 추가 * test: BadgeType enum 단위 테스트 추가 * test: BadgeKey 상수 단위 테스트 추가 * test: WordStatus enum 단위 테스트 추가 * test: TestType enum 단위 테스트 추가 * test: VocabularyConfig 단위 테스트 추가 * test: VocabKey 상수 단위 테스트 추가 * test: VocabularyErrorCode 단위 테스트 추가 * test: VocabularyException 단위 테스트 추가 * test: SpacedRepetitionContext 단위 테스트 추가 * test: GrammarLevel enum 단위 테스트 추가 * test: GrammarConfig 단위 테스트 추가 * test: GrammarErrorCode 단위 테스트 추가 * test: GrammarException 단위 테스트 추가 * test: GameStatus enum 단위 테스트 추가 * test: GameConfig 단위 테스트 추가 * test: ChattingErrorCode 단위 테스트 추가 * test: ChattingException 단위 테스트 추가 * fix: OPIc FeedbackResponse.java 문법 오류 수정 * style: AwsClients 코드 포맷팅 * style: EnvConfig 코드 포맷팅 * style: RoomTokenConfig 코드 포맷팅 * style: WebSocketConfig 코드 포맷팅 * style: JsonUtil 코드 포맷팅 * style: GameConfig 코드 포맷팅 * style: GrammarConfig 코드 포맷팅 * style: GrammarKey 코드 포맷팅 * style: GrammarErrorCode 코드 포맷팅 * style: GrammarException 코드 포맷팅 * style: BedrockGrammarCheckFactory 코드 포맷팅 * style: GrammarConversationService 코드 포맷팅 * style: FeedbackResponse 코드 포맷팅 * style: SessionReportResponse 코드 포맷팅 * style: SpeakingError 코드 포맷팅 * style: SpeakingErrorType 코드 포맷팅 * style: OPIcException 코드 포맷팅 * style: OPIcAnswer 코드 포맷팅 * style: OPIcQuestion 코드 포맷팅 * style: OPIcSession 코드 포맷팅 * style: OPIcRepository 코드 포맷팅 * style: FeedbackService 코드 포맷팅 * style: TranscribeProxyService 코드 포맷팅 * style: VocabularyConfig 코드 포맷팅 * style: DailyStudyCommandService 코드 포맷팅 * style: TestCommandService 코드 포맷팅 * style: TestService 코드 포맷팅 * style: CommonErrorCodeSpec 코드 포맷팅 * style: JsonUtilSpec 코드 포맷팅 * style: BadgeTypeSpec 코드 포맷팅 * style: ChattingErrorCodeSpec 코드 포맷팅 * style: GrammarLevelSpec 코드 포맷팅 * style: GrammarErrorCodeSpec 코드 포맷팅 * style: VocabularyErrorCodeSpec 코드 포맷팅 * feature : OPIc 세션관리 + 답변 처리 파이프라인 구현 (#413) * feat : OPIc 질문 & 세션 관련 dto 생성 * refactor : @DynamoDbBean 주석 추가 * feat : 주제 + 소주제 + 레벨로 오픽 질문 조회 추가 * feat : OPIc 세션, 질문, 답변 종합 handler 구현 * feat : 오픽 주제, 소주제별 seed 데이터 추가 * refactor : Transcribe API KEY 환경변수명 수정 * refactor : Bedrock에 사용하는 클로드 모델 변경 * refactor : S3 Key 대신 Proxy에서 요청하는 Base64 값으로 변환 * feat: GAME_START, ROUND_END 메시지에 serverTime 추가 - broadcastGameStart(): serverTime, roundDuration 필드 추가 - broadcastRoundEnd(): serverTime, roundStartTime, roundDuration 필드 추가 - GameService.endRound(): data에 roundStartTime, roundDuration 포함 - 타이머 동기화 버그 수정을 위한 서버 시간 제공 * feat: WebSocketMessageHelper 유틸리티 클래스 추가 - domain 필드 포함 메시지 생성 헬퍼 - DOMAIN_CHAT, DOMAIN_GAME 상수 정의 - buildChatMessage(), buildGameMessage() 메서드 * feat: 모든 WebSocket 메시지에 domain 필드 추가 - ScoreUpdateMessage에 domain 필드 추가 - broadcastGameStart에 domain:"game" 추가 - broadcastRoundEnd에 domain:"game" 추가 - broadcastCorrectAnswerMessage에 domain:"game" 추가 - handleCommandResult 시스템 메시지에 domain:"game" 추가 - handleRegularMessage 채팅 메시지에 domain:"chat" 추가 Closes #426, #427 * feat: GameSession 모델 클래스 생성 - DynamoDB Enhanced Client 어노테이션 적용 - GSI1: roomId로 활성 게임 세션 조회 가능 - 게임 상태 관리용 헬퍼 메서드 포함 Closes #428 * feat: GameSessionRepository 구현 - 기본 CRUD (save, findById, delete) - roomId로 활성/전체 게임 세션 조회 - 상태, 라운드, 점수 업데이트 메서드 - 정답자 추가, 힌트 사용, 게임 종료 처리 Closes #429 * refactor: ChatRoom에서 게임 필드 분리 - 게임 관련 필드 제거 (gameStatus, currentRound 등) - activeGameSessionId 필드 추가 - 게임 상태는 GameSession으로 분리됨 Note: 의존 코드 수정은 #431에서 진행 Closes #430 * refactor: GameSession 기반으로 전체 게임 로직 리팩토링 - GameService: ChatRoom 대신 GameSession 사용 - GameStatsService: GameSession 매개변수로 변경 - CommandService: GameSession 기반 점수 조회 - GameHandler: GameSession 기반 REST API - WebSocketMessageHandler: GameSession 기반 브로드캐스트 - GameStatusResponse, ScoreboardResponse: GameSession 매개변수 Closes #431 * feat: GameSessionHandler Lambda 및 게임 세션 API 구현 - POST /rooms/{roomId}/games - 게임 세션 생성 - GET /games/{gameSessionId} - 게임 상태 조회 (재접속용) - POST /games/{gameSessionId}/start - 게임 시작 - POST /games/{gameSessionId}/stop - 게임 종료 모든 응답에 serverTime 포함 (타이머 동기화) 출제자에게만 currentWord 포함 Closes #432, #433 * feat: 게임 시작 7분 후 자동 종료 기능 구현 - GameConfig에 gameTimeLimit() 메서드 추가 (기본값: 420초) - GameSchedulerClient 유틸리티 클래스 생성 (EventBridge Scheduler 연동) - GameService에 스케줄 생성/취소 로직 추가 - GameAutoCloseHandler Lambda 함수 생성 - template.yaml에 Lambda, IAM Role, Schedule Group 추가 Closes #417 * fix : 메모리 증가 및 Lambda 응답 제한 시간 Cognito 트리거 제한시간과 동일하게 수정 (#439) * feat: 캐치마인드 게임 방 분리 기능 구현 (#455) - Room 타입 분리 (CHAT/GAME) - RoomType, RoomStatus enum 추가 - ChatRoom 모델에 type, gameType, gameSettings, status, hostId 필드 추가 - GameSettings 모델 추가 - 방 생성/조회 API 수정 - CreateRoomRequest에 type, gameType, gameSettings 필드 추가 - 방 목록 조회 시 type, gameType, status 필터 지원 - 게임 시작 조건 검증 - GAME 타입 방에서만 게임 시작 가능 (GAME_007 에러) - 게임 재시작 API 구현 (#452) - POST /rooms/{roomId}/game/restart 엔드포인트 추가 - 방장만 재시작 가능 (GAME_009 에러) - 게임 진행 중 재시작 불가 (GAME_008 에러) - 방장 변경 로직 구현 (#453) - 방장 퇴장 시 다음 멤버에게 자동 이전 - 모든 멤버 퇴장 시 방 자동 삭제 - WebSocket 메시지 타입 추가 (#454) - ROOM_STATUS_CHANGE, HOST_CHANGE 메시지 타입 추가 - WebSocketMessageHelper에 빌더 메서드 추가 - 테스트 추가 - RoomType, RoomStatus enum 테스트 - GameSettings 모델 테스트 - ChattingErrorCode 테스트 업데이트 Related: #440, #441, #442, #443, #444, #445, #446 Closes: #447, #448, #449, #450, #451, #452, #453, #454 * feat: 참가자 닉네임 및 방장 변경 WebSocket 알림 구현 (#456) - RoomParticipant DTO 추가 (userId, nickname, isHost) - ChatRoomQueryService에 닉네임 조회 메서드 추가 - getParticipantsWithNicknames(): 참가자 목록 + 닉네임 - getHostNickname(): 방장 닉네임 조회 - ChatRoomHandler.getRoom() 응답에 participants, hostNickname 추가 - ChatRoomCommandService.leaveRoom()에서 방장 변경 시 WebSocket 브로드캐스트 Related: #440 * fix: ChatRoomFunction에 UserTable DynamoDB 권한 추가 - 참가자/방장 닉네임 조회를 위한 UserTable 읽기 권한 추가 - DynamoDBReadPolicy로 UserTable 접근 허용 Fixes #457 * fix: GameSettings에 @DynamoDbBean 어노테이션 추가 - DynamoDB Enhanced Client가 중첩 객체를 직렬화/역직렬화할 수 있도록 수정 - ChatRoom 저장/조회 시 GameSettings 변환 오류 해결 Fixes #457 * fix: ChatRoomFunction에 WEBSOCKET_ENDPOINT 환경변수 및 권한 추가 - leaveRoom에서 WebSocketBroadcaster 사용을 위한 환경변수 추가 - execute-api:ManageConnections 권한 추가 * fix: WebSocket Lambda 함수들에 WEBSOCKET_ENDPOINT 환경변수 추가 - WebSocketConnectFunction에 WEBSOCKET_ENDPOINT 추가 - WebSocketDisconnectFunction에 WEBSOCKET_ENDPOINT 추가 - execute-api:ManageConnections 권한 추가 * fix: Grammar WebSocket Lambda 환경 변수 및 권한 추가 - GrammarStreamingConnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - GrammarStreamingDisconnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - 두 함수에 execute-api:ManageConnections 권한 추가 * feat: GSI1SK 확장성 있는 재설계 및 DB 레벨 필터링 ## 변경 사항 ### GSI1SK 포맷 변경 - 기존: {level}#{createdAt} - 신규: {type}#{gameType}#{status}#{level}#{createdAt} ### 지원 쿼리 패턴 - 전체 방: GSI1PK = "ROOMS" - 게임방만: begins_with(GSI1SK, "GAME#") - 캐치마인드만: begins_with(GSI1SK, "GAME#CATCHMIND#") - 대기중 캐치마인드: begins_with(GSI1SK, "GAME#CATCHMIND#WAITING#") ### 파일 수정 - ChatRoomCommandService.java: 방 생성 시 새 GSI1SK 포맷 적용 - ChatRoomRepository.java: findByFilters() 메서드 추가, updateStatus() 메서드 추가 - ChatRoomQueryService.java: 메모리 필터링 제거, DB 레벨 필터링으로 변경 - GameService.java: 게임 시작/종료 시 방 상태 업데이트 (GSI1SK 포함) ### 마이그레이션 - scripts/migrate-gsi1sk.sh: 기존 데이터 마이그레이션 스크립트 - 37개 기존 방 마이그레이션 완료 * fix: increase stats query limit to 100 days - Adjust maximum allowable limit for recent stats query from 30 to 100 days to support extended data range responses. * feat: improve game round and connection management logic - Added handling for game round timeout (`ROUND_TIMEOUT`) in WebSocketMessageHandler. - Enhanced game start broadcast to include `currentWord` for the drawer only. - Updated ConnectionRepository to remove duplicate user connections in the same room. - Added `currentWordEnglish` to GameSession for better answer verification. - Normalized ChatRoom levels to match database storage format. - Updated template.yaml to include DynamoDBReadPolicy for VocabTable. * refactor: 코드 정리 및 미사용 클래스 제거 (#459) * refactor: remove unused WebSocketResponseUtil * refactor: add AutoCloseable to WebSocketBroadcaster * refactor: remove unused TestService * refactor: remove unused WordService * refactor: remove unused DailyStudyService * refactor: remove unused UserWordService * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * fix: resolve N+1 query in StatsService * refactor(all): DI 패턴 및 전략 패턴 적용 (#461) - Repository, Service, Handler에 DI 생성자 추가 (테스트 용이성) - BadgeService에 Strategy 패턴 적용 (뱃지 조건 검증 로직 분리) * refactor: relocate and restructure seed data files - Moved `question-homes.json` and `words.json` from subdirectories to `seed` folder. - Updated file paths for better organization and clarity. * chore: seed 데이터 폴더 구조 정리 * feat: add CI/CD pipeline configuration for CodePipeline * fix: add SNS topic policy and DependsOn for notification rule * fix: correct paths in buildspec.yml for CodeBuild * fix: remove hardcoded JAVA_HOME, use runtime default * fix: add gradle wrapper for CI/CD build * fix: use single line sam package command with hardcoded bucket * fix: use existing stack name group2-englishstudy-chatting * fix: add missing WEBSOCKET_ENDPOINT env var to WebSocket connect functions * docs: update FRONTEND-API-GUIDE with new RoomType/RoomStatus structure * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix: update WebSocketDisconnectHandler to use GameSession model - Remove references to deleted ChatRoom game fields - Use GameSessionRepository to finish active game sessions - Use ChatRoomRepository.updateStatus() for room state management * perf: optimize CI/CD build time - Add pip cache for SAM CLI dependencies - Enable Gradle parallel builds and build cache - Add SAM build cache (.aws-sam) - Use sam build --parallel --cached - Skip SAM CLI install if already cached - Remove unnecessary 'clean' to leverage cache * feat: add custom CodeBuild Docker image with pre-installed tools - Dockerfile with Java 21 + SAM CLI + Gradle pre-installed - build-and-push.sh script for ECR deployment - Updated buildspec.yml for custom image (removes SAM CLI install) - Expected build time reduction: ~30-40 seconds * feature : AI 영어 회화 연습 기능 (#468) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix: remove typo in SpeakingConnectionRepository * fix : 오타 수정 * chore: trigger build test with custom Docker image * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes * Release: 캐치마인드 게임 분리, AI 회화 연습, CI/CD 파이프라인 (#469) * feature : OPIc 세션관리 + 답변 처리 파이프라인 구현 (#413) * feat : OPIc 질문 & 세션 관련 dto 생성 * refactor : @DynamoDbBean 주석 추가 * feat : 주제 + 소주제 + 레벨로 오픽 질문 조회 추가 * feat : OPIc 세션, 질문, 답변 종합 handler 구현 * feat : 오픽 주제, 소주제별 seed 데이터 추가 * refactor : Transcribe API KEY 환경변수명 수정 * refactor : Bedrock에 사용하는 클로드 모델 변경 * refactor : S3 Key 대신 Proxy에서 요청하는 Base64 값으로 변환 * feat: GAME_START, ROUND_END 메시지에 serverTime 추가 - broadcastGameStart(): serverTime, roundDuration 필드 추가 - broadcastRoundEnd(): serverTime, roundStartTime, roundDuration 필드 추가 - GameService.endRound(): data에 roundStartTime, roundDuration 포함 - 타이머 동기화 버그 수정을 위한 서버 시간 제공 * feat: WebSocketMessageHelper 유틸리티 클래스 추가 - domain 필드 포함 메시지 생성 헬퍼 - DOMAIN_CHAT, DOMAIN_GAME 상수 정의 - buildChatMessage(), buildGameMessage() 메서드 * feat: 모든 WebSocket 메시지에 domain 필드 추가 - ScoreUpdateMessage에 domain 필드 추가 - broadcastGameStart에 domain:"game" 추가 - broadcastRoundEnd에 domain:"game" 추가 - broadcastCorrectAnswerMessage에 domain:"game" 추가 - handleCommandResult 시스템 메시지에 domain:"game" 추가 - handleRegularMessage 채팅 메시지에 domain:"chat" 추가 Closes #426, #427 * feat: GameSession 모델 클래스 생성 - DynamoDB Enhanced Client 어노테이션 적용 - GSI1: roomId로 활성 게임 세션 조회 가능 - 게임 상태 관리용 헬퍼 메서드 포함 Closes #428 * feat: GameSessionRepository 구현 - 기본 CRUD (save, findById, delete) - roomId로 활성/전체 게임 세션 조회 - 상태, 라운드, 점수 업데이트 메서드 - 정답자 추가, 힌트 사용, 게임 종료 처리 Closes #429 * refactor: ChatRoom에서 게임 필드 분리 - 게임 관련 필드 제거 (gameStatus, currentRound 등) - activeGameSessionId 필드 추가 - 게임 상태는 GameSession으로 분리됨 Note: 의존 코드 수정은 #431에서 진행 Closes #430 * refactor: GameSession 기반으로 전체 게임 로직 리팩토링 - GameService: ChatRoom 대신 GameSession 사용 - GameStatsService: GameSession 매개변수로 변경 - CommandService: GameSession 기반 점수 조회 - GameHandler: GameSession 기반 REST API - WebSocketMessageHandler: GameSession 기반 브로드캐스트 - GameStatusResponse, ScoreboardResponse: GameSession 매개변수 Closes #431 * feat: GameSessionHandler Lambda 및 게임 세션 API 구현 - POST /rooms/{roomId}/games - 게임 세션 생성 - GET /games/{gameSessionId} - 게임 상태 조회 (재접속용) - POST /games/{gameSessionId}/start - 게임 시작 - POST /games/{gameSessionId}/stop - 게임 종료 모든 응답에 serverTime 포함 (타이머 동기화) 출제자에게만 currentWord 포함 Closes #432, #433 * feat: 게임 시작 7분 후 자동 종료 기능 구현 - GameConfig에 gameTimeLimit() 메서드 추가 (기본값: 420초) - GameSchedulerClient 유틸리티 클래스 생성 (EventBridge Scheduler 연동) - GameService에 스케줄 생성/취소 로직 추가 - GameAutoCloseHandler Lambda 함수 생성 - template.yaml에 Lambda, IAM Role, Schedule Group 추가 Closes #417 * fix : 메모리 증가 및 Lambda 응답 제한 시간 Cognito 트리거 제한시간과 동일하게 수정 (#439) * feat: 캐치마인드 게임 방 분리 기능 구현 (#455) - Room 타입 분리 (CHAT/GAME) - RoomType, RoomStatus enum 추가 - ChatRoom 모델에 type, gameType, gameSettings, status, hostId 필드 추가 - GameSettings 모델 추가 - 방 생성/조회 API 수정 - CreateRoomRequest에 type, gameType, gameSettings 필드 추가 - 방 목록 조회 시 type, gameType, status 필터 지원 - 게임 시작 조건 검증 - GAME 타입 방에서만 게임 시작 가능 (GAME_007 에러) - 게임 재시작 API 구현 (#452) - POST /rooms/{roomId}/game/restart 엔드포인트 추가 - 방장만 재시작 가능 (GAME_009 에러) - 게임 진행 중 재시작 불가 (GAME_008 에러) - 방장 변경 로직 구현 (#453) - 방장 퇴장 시 다음 멤버에게 자동 이전 - 모든 멤버 퇴장 시 방 자동 삭제 - WebSocket 메시지 타입 추가 (#454) - ROOM_STATUS_CHANGE, HOST_CHANGE 메시지 타입 추가 - WebSocketMessageHelper에 빌더 메서드 추가 - 테스트 추가 - RoomType, RoomStatus enum 테스트 - GameSettings 모델 테스트 - ChattingErrorCode 테스트 업데이트 Related: #440, #441, #442, #443, #444, #445, #446 Closes: #447, #448, #449, #450, #451, #452, #453, #454 * feat: 참가자 닉네임 및 방장 변경 WebSocket 알림 구현 (#456) - RoomParticipant DTO 추가 (userId, nickname, isHost) - ChatRoomQueryService에 닉네임 조회 메서드 추가 - getParticipantsWithNicknames(): 참가자 목록 + 닉네임 - getHostNickname(): 방장 닉네임 조회 - ChatRoomHandler.getRoom() 응답에 participants, hostNickname 추가 - ChatRoomCommandService.leaveRoom()에서 방장 변경 시 WebSocket 브로드캐스트 Related: #440 * fix: ChatRoomFunction에 UserTable DynamoDB 권한 추가 - 참가자/방장 닉네임 조회를 위한 UserTable 읽기 권한 추가 - DynamoDBReadPolicy로 UserTable 접근 허용 Fixes #457 * fix: GameSettings에 @DynamoDbBean 어노테이션 추가 - DynamoDB Enhanced Client가 중첩 객체를 직렬화/역직렬화할 수 있도록 수정 - ChatRoom 저장/조회 시 GameSettings 변환 오류 해결 Fixes #457 * fix: ChatRoomFunction에 WEBSOCKET_ENDPOINT 환경변수 및 권한 추가 - leaveRoom에서 WebSocketBroadcaster 사용을 위한 환경변수 추가 - execute-api:ManageConnections 권한 추가 * fix: WebSocket Lambda 함수들에 WEBSOCKET_ENDPOINT 환경변수 추가 - WebSocketConnectFunction에 WEBSOCKET_ENDPOINT 추가 - WebSocketDisconnectFunction에 WEBSOCKET_ENDPOINT 추가 - execute-api:ManageConnections 권한 추가 * fix: Grammar WebSocket Lambda 환경 변수 및 권한 추가 - GrammarStreamingConnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - GrammarStreamingDisconnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - 두 함수에 execute-api:ManageConnections 권한 추가 * feat: GSI1SK 확장성 있는 재설계 및 DB 레벨 필터링 ## 변경 사항 ### GSI1SK 포맷 변경 - 기존: {level}#{createdAt} - 신규: {type}#{gameType}#{status}#{level}#{createdAt} ### 지원 쿼리 패턴 - 전체 방: GSI1PK = "ROOMS" - 게임방만: begins_with(GSI1SK, "GAME#") - 캐치마인드만: begins_with(GSI1SK, "GAME#CATCHMIND#") - 대기중 캐치마인드: begins_with(GSI1SK, "GAME#CATCHMIND#WAITING#") ### 파일 수정 - ChatRoomCommandService.java: 방 생성 시 새 GSI1SK 포맷 적용 - ChatRoomRepository.java: findByFilters() 메서드 추가, updateStatus() 메서드 추가 - ChatRoomQueryService.java: 메모리 필터링 제거, DB 레벨 필터링으로 변경 - GameService.java: 게임 시작/종료 시 방 상태 업데이트 (GSI1SK 포함) ### 마이그레이션 - scripts/migrate-gsi1sk.sh: 기존 데이터 마이그레이션 스크립트 - 37개 기존 방 마이그레이션 완료 * fix: increase stats query limit to 100 days - Adjust maximum allowable limit for recent stats query from 30 to 100 days to support extended data range responses. * feat: improve game round and connection management logic - Added handling for game round timeout (`ROUND_TIMEOUT`) in WebSocketMessageHandler. - Enhanced game start broadcast to include `currentWord` for the drawer only. - Updated ConnectionRepository to remove duplicate user connections in the same room. - Added `currentWordEnglish` to GameSession for better answer verification. - Normalized ChatRoom levels to match database storage format. - Updated template.yaml to include DynamoDBReadPolicy for VocabTable. * refactor: 코드 정리 및 미사용 클래스 제거 (#459) * refactor: remove unused WebSocketResponseUtil * refactor: add AutoCloseable to WebSocketBroadcaster * refactor: remove unused TestService * refactor: remove unused WordService * refactor: remove unused DailyStudyService * refactor: remove unused UserWordService * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * fix: resolve N+1 query in StatsService * refactor(all): DI 패턴 및 전략 패턴 적용 (#461) - Repository, Service, Handler에 DI 생성자 추가 (테스트 용이성) - BadgeService에 Strategy 패턴 적용 (뱃지 조건 검증 로직 분리) * refactor: relocate and restructure seed data files - Moved `question-homes.json` and `words.json` from subdirectories to `seed` folder. - Updated file paths for better organization and clarity. * chore: seed 데이터 폴더 구조 정리 * feat: add CI/CD pipeline configuration for CodePipeline * fix: add SNS topic policy and DependsOn for notification rule * fix: correct paths in buildspec.yml for CodeBuild * fix: remove hardcoded JAVA_HOME, use runtime default * fix: add gradle wrapper for CI/CD build * fix: use single line sam package command with hardcoded bucket * fix: use existing stack name group2-englishstudy-chatting * fix: add missing WEBSOCKET_ENDPOINT env var to WebSocket connect functions * docs: update FRONTEND-API-GUIDE with new RoomType/RoomStatus structure * fix: update WebSocketDisconnectHandler to use GameSession model - Remove references to deleted ChatRoom game fields - Use GameSessionRepository to finish active game sessions - Use ChatRoomRepository.updateStatus() for room state management * perf: optimize CI/CD build time - Add pip cache for SAM CLI dependencies - Enable Gradle parallel builds and build cache - Add SAM build cache (.aws-sam) - Use sam build --parallel --cached - Skip SAM CLI install if already cached - Remove unnecessary 'clean' to leverage cache * feat: add custom CodeBuild Docker image with pre-installed tools - Dockerfile with Java 21 + SAM CLI + Gradle pre-installed - build-and-push.sh script for ECR deployment - Updated buildspec.yml for custom image (removes SAM CLI install) - Expected build time reduction: ~30-40 seconds * feature : AI 영어 회화 연습 기능 (#468) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix: remove typo in SpeakingConnectionRepository * chore: trigger build test with custom Docker image * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes --------- Co-authored-by: hyein Heo <128613248+hye-inA@users.noreply.github.com> * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * fix: add CORS headers to API Gateway error responses (#479) API Gateway의 인증 실패 등 에러 응답에 CORS 헤더가 누락되어 CloudFront를 통한 프론트엔드 요청이 차단되는 문제 수정 - UNAUTHORIZED (401) 응답에 CORS 헤더 추가 - ACCESS_DENIED (403) 응답에 CORS 헤더 추가 - DEFAULT_4XX/5XX 응답에 CORS 헤더 추가 - EXPIRED_TOKEN 응답에 CORS 헤더 추가 * feat(news): 뉴스 도메인 기반 구조 구축 (#385) - NewsCategory, QuizType enum 추가 - NewsKey 상수 클래스 추가 - NewsErrorCode 예외 클래스 추가 - NewsArticle, KeywordInfo, QuizQuestion 모델 추가 - NewsArticleRepository CRUD 구현 - NewsTable DynamoDB 테이블 정의 - CORS GatewayResponses 설정 추가 * feat(news): 뉴스 수집 파이프라인 구현 (#386) - NewsApiClient: NewsAPI 연동 서비스 - RssFeedParser: RSS 피드 파싱 (BBC, VOA, NPR) - NewsDuplicateChecker: URL 기반 중복 필터링 - NewsCollectorService: 수집 오케스트레이션 - NewsCollectionHandler: Lambda 핸들러 - EventBridge 스케줄러: 매일 18시 KST 실행 * refactor(news): NewsAPI 제거, RSS만 사용 - NewsApiClient 삭제 - SSM Parameter 의존성 제거 - RSS 소스당 7개씩 수집 (BBC, VOA, NPR = 약 21개/일) * feat(news): AI 뉴스 분석 시스템 구현 (#387) - NewsAnalysisService: AI 분석 통합 서비스 - Bedrock: CEFR 난이도 분석 (A1~C2) - Bedrock: 3줄 요약 + 퀴즈 3문제 생성 - Comprehend: 핵심 키워드 추출 - NewsCollectorService: 수집 시 자동 분석 연동 - GSI1/GSI2 키 자동 설정 (레벨별, 카테고리별 조회) * feat(news): 뉴스 학습 API 구현 (#388) - NewsQueryService: 뉴스 조회 서비스 - NewsHandler: API 핸들러 - GET /news - 목록 조회 (level, category 필터) - GET /news/today - 오늘의 뉴스 - GET /news/recommended - 내 레벨 맞춤 추천 - GET /news/{articleId} - 상세 조회 (조회수 증가) - template.yaml: NewsFunction Lambda 추가 * feat(news): 뉴스 학습 부가 기능 구현 (#389) - 읽기 완료 기록 API (POST /news/{articleId}/read) - 북마크 토글 API (POST /news/{articleId}/bookmark) - 북마크 목록 조회 API (GET /news/bookmarks) - 학습 통계 조회 API (GET /news/stats) - TTS 오디오 URL 조회 API (GET /news/{articleId}/audio) - UserNewsRecord 모델 추가 - UserNewsRepository 추가 - NewsLearningService 추가 * feat(news): 복합 퀴즈 시스템 구현 (#471) - 퀴즈 조회 API (GET /news/{articleId}/quiz) - 퀴즈 제출 API (POST /news/{articleId}/quiz) - 퀴즈 기록 조회 API (GET /news/quiz/history) - NewsQuizResult 모델 추가 - QuizAnswerResult 모델 추가 - NewsQuizRepository 추가 - NewsQuizService 추가 * feat(news): 단어 수집 & Vocabulary 연동 구현 (#472) - 단어 수집 API (POST /news/{articleId}/words) - 수집 단어 목록 API (GET /news/words) - 단어 상세 조회 API (GET /news/{articleId}/words/{word}) - 단어 삭제 API (DELETE /news/{articleId}/words/{word}) - Vocabulary 연동 API (POST /news/words/{word}/sync) - NewsWordCollect 모델 추가 - NewsWordRepository 추가 - NewsWordService 추가 * feat: add multi-environment deployment support (dev/test/prod) * fix: update buildspec.yml to deploy prod environment with parameter overrides * feat(news): 뉴스 도메인 기반 구조 구축 (#385) - NewsCategory, QuizType enum 추가 - NewsKey 상수 클래스 추가 - NewsErrorCode 예외 클래스 추가 - NewsArticle, KeywordInfo, QuizQuestion 모델 추가 - NewsArticleRepository CRUD 구현 - NewsTable DynamoDB 테이블 정의 - CORS GatewayResponses 설정 추가 * feat(news): 뉴스 수집 파이프라인 구현 (#386) - NewsApiClient: NewsAPI 연동 서비스 - RssFeedParser: RSS 피드 파싱 (BBC, VOA, NPR) - NewsDuplicateChecker: URL 기반 중복 필터링 - NewsCollectorService: 수집 오케스트레이션 - NewsCollectionHandler: Lambda 핸들러 - EventBridge 스케줄러: 매일 18시 KST 실행 * refactor(news): NewsAPI 제거, RSS만 사용 - NewsApiClient 삭제 - SSM Parameter 의존성 제거 - RSS 소스당 7개씩 수집 (BBC, VOA, NPR = 약 21개/일) * feat(news): AI 뉴스 분석 시스템 구현 (#387) - NewsAnalysisService: AI 분석 통합 서비스 - Bedrock: CEFR 난이도 분석 (A1~C2) - Bedrock: 3줄 요약 + 퀴즈 3문제 생성 - Comprehend: 핵심 키워드 추출 - NewsCollectorService: 수집 시 자동 분석 연동 - GSI1/GSI2 키 자동 설정 (레벨별, 카테고리별 조회) * feat(news): 뉴스 학습 API 구현 (#388) - NewsQueryService: 뉴스 조회 서비스 - NewsHandler: API 핸들러 - GET /news - 목록 조회 (level, category 필터) - GET /news/today - 오늘의 뉴스 - GET /news/recommended - 내 레벨 맞춤 추천 - GET /news/{articleId} - 상세 조회 (조회수 증가) - template.yaml: NewsFunction Lambda 추가 * feat(news): 뉴스 학습 부가 기능 구현 (#389) - 읽기 완료 기록 API (POST /news/{articleId}/read) - 북마크 토글 API (POST /news/{articleId}/bookmark) - 북마크 목록 조회 API (GET /news/bookmarks) - 학습 통계 조회 API (GET /news/stats) - TTS 오디오 URL 조회 API (GET /news/{articleId}/audio) - UserNewsRecord 모델 추가 - UserNewsRepository 추가 - NewsLearningService 추가 * feat(news): 복합 퀴즈 시스템 구현 (#471) - 퀴즈 조회 API (GET /news/{articleId}/quiz) - 퀴즈 제출 API (POST /news/{articleId}/quiz) - 퀴즈 기록 조회 API (GET /news/quiz/history) - NewsQuizResult 모델 추가 - QuizAnswerResult 모델 추가 - NewsQuizRepository 추가 - NewsQuizService 추가 * feat(news): 단어 수집 & Vocabulary 연동 구현 (#472) - 단어 수집 API (POST /news/{articleId}/words) - 수집 단어 목록 API (GET /news/words) - 단어 상세 조회 API (GET /news/{articleId}/words/{word}) - 단어 삭제 API (DELETE /news/{articleId}/words/{word}) - Vocabulary 연동 API (POST /news/words/{word}/sync) - NewsWordCollect 모델 추가 - NewsWordRepository 추가 - NewsWordService 추가 * feat: add multi-environment deployment support (dev/test/prod) * fix: update buildspec.yml to deploy prod environment with parameter overrides * refactor : AI 말하기 Websocket 구현 -> REST API 구현으로 리팩토링 (#490) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix : 오타 수정 * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * fix: revert buildspec.yml to build-only for CloudFormation deploy stage * fix: revert buildspec.yml to build-only for CloudFormation deploy stage * fix: update all API Gateway StageName to use Environment parameter * fix: correct VocabularyTable and ContentBucket references in NewsFunction * fix: correct VocabularyTable and ContentBucket references in NewsFunction * fix: add stack name prefix to GameScheduleGroup and daily-stats schedule to avoid conflicts * fix: add stack name prefix to GameScheduleGroup and daily-stats schedule to avoid conflicts * feat: add support for existing Cognito User Pool reuse across environments * fix: add conditional Cognito ARN reference in API Gateway Authorizer * fix: remove Cognito resources completely, use existing Cognito only * fix: remove Cognito resources completely, use existing Cognito only * feat : speaking rest API 람다 함수 추가 * feat: add S3 bucket resource and fix environment-specific endpoints - Add ContentBucket S3 resource for content storage - Replace hardcoded /dev with ${Environment} in all WebSocket endpoints - Update Output URLs to use dynamic environment stage Co-Authored-By: Claude Opus 4.5 * fix: add stack name prefix to news-collection schedule name Prevent EventBridge rule name conflicts across environments Co-Authored-By: Claude Opus 4.5 * fix: add X-Requested-With and Accept headers to CORS config Enable additional headers for CloudFront CORS compatibility Co-Authored-By: Claude Opus 4.5 * fix: use environment variable for S3 bucket URLs Replace hardcoded bucket name with BUCKET_NAME env var for multi-env support: - PreSignUpHandler: dynamic default profile URL - PostConfirmationHandler: dynamic default profile URL - UserService: dynamic default profile URL - BadgeType: dynamic badge image base URL Co-Authored-By: Claude Opus 4.5 * fix: disable authorizer for CORS preflight requests Add AddDefaultAuthorizerToCorsPreflight: false to prevent Cognito Authorizer from blocking OPTIONS requests Co-Authored-By: Claude Opus 4.5 * feat : speaking REST API 람다 함수 추가 (#491) * feature : OPIc 세션관리 + 답변 처리 파이프라인 구현 (#413) * feat : OPIc 질문 & 세션 관련 dto 생성 * refactor : @DynamoDbBean 주석 추가 * feat : 주제 + 소주제 + 레벨로 오픽 질문 조회 추가 * feat : OPIc 세션, 질문, 답변 종합 handler 구현 * feat : 오픽 주제, 소주제별 seed 데이터 추가 * refactor : Transcribe API KEY 환경변수명 수정 * refactor : Bedrock에 사용하는 클로드 모델 변경 * refactor : S3 Key 대신 Proxy에서 요청하는 Base64 값으로 변환 * feat: GAME_START, ROUND_END 메시지에 serverTime 추가 - broadcastGameStart(): serverTime, roundDuration 필드 추가 - broadcastRoundEnd(): serverTime, roundStartTime, roundDuration 필드 추가 - GameService.endRound(): data에 roundStartTime, roundDuration 포함 - 타이머 동기화 버그 수정을 위한 서버 시간 제공 * feat: WebSocketMessageHelper 유틸리티 클래스 추가 - domain 필드 포함 메시지 생성 헬퍼 - DOMAIN_CHAT, DOMAIN_GAME 상수 정의 - buildChatMessage(), buildGameMessage() 메서드 * feat: 모든 WebSocket 메시지에 domain 필드 추가 - ScoreUpdateMessage에 domain 필드 추가 - broadcastGameStart에 domain:"game" 추가 - broadcastRoundEnd에 domain:"game" 추가 - broadcastCorrectAnswerMessage에 domain:"game" 추가 - handleCommandResult 시스템 메시지에 domain:"game" 추가 - handleRegularMessage 채팅 메시지에 domain:"chat" 추가 Closes #426, #427 * feat: GameSession 모델 클래스 생성 - DynamoDB Enhanced Client 어노테이션 적용 - GSI1: roomId로 활성 게임 세션 조회 가능 - 게임 상태 관리용 헬퍼 메서드 포함 Closes #428 * feat: GameSessionRepository 구현 - 기본 CRUD (save, findById, delete) - roomId로 활성/전체 게임 세션 조회 - 상태, 라운드, 점수 업데이트 메서드 - 정답자 추가, 힌트 사용, 게임 종료 처리 Closes #429 * refactor: ChatRoom에서 게임 필드 분리 - 게임 관련 필드 제거 (gameStatus, currentRound 등) - activeGameSessionId 필드 추가 - 게임 상태는 GameSession으로 분리됨 Note: 의존 코드 수정은 #431에서 진행 Closes #430 * refactor: GameSession 기반으로 전체 게임 로직 리팩토링 - GameService: ChatRoom 대신 GameSession 사용 - GameStatsService: GameSession 매개변수로 변경 - CommandService: GameSession 기반 점수 조회 - GameHandler: GameSession 기반 REST API - WebSocketMessageHandler: GameSession 기반 브로드캐스트 - GameStatusResponse, ScoreboardResponse: GameSession 매개변수 Closes #431 * feat: GameSessionHandler Lambda 및 게임 세션 API 구현 - POST /rooms/{roomId}/games - 게임 세션 생성 - GET /games/{gameSessionId} - 게임 상태 조회 (재접속용) - POST /games/{gameSessionId}/start - 게임 시작 - POST /games/{gameSessionId}/stop - 게임 종료 모든 응답에 serverTime 포함 (타이머 동기화) 출제자에게만 currentWord 포함 Closes #432, #433 * feat: 게임 시작 7분 후 자동 종료 기능 구현 - GameConfig에 gameTimeLimit() 메서드 추가 (기본값: 420초) - GameSchedulerClient 유틸리티 클래스 생성 (EventBridge Scheduler 연동) - GameService에 스케줄 생성/취소 로직 추가 - GameAutoCloseHandler Lambda 함수 생성 - template.yaml에 Lambda, IAM Role, Schedule Group 추가 Closes #417 * fix : 메모리 증가 및 Lambda 응답 제한 시간 Cognito 트리거 제한시간과 동일하게 수정 (#439) * feat: 캐치마인드 게임 방 분리 기능 구현 (#455) - Room 타입 분리 (CHAT/GAME) - RoomType, RoomStatus enum 추가 - ChatRoom 모델에 type, gameType, gameSettings, status, hostId 필드 추가 - GameSettings 모델 추가 - 방 생성/조회 API 수정 - CreateRoomRequest에 type, gameType, gameSettings 필드 추가 - 방 목록 조회 시 type, gameType, status 필터 지원 - 게임 시작 조건 검증 - GAME 타입 방에서만 게임 시작 가능 (GAME_007 에러) - 게임 재시작 API 구현 (#452) - POST /rooms/{roomId}/game/restart 엔드포인트 추가 - 방장만 재시작 가능 (GAME_009 에러) - 게임 진행 중 재시작 불가 (GAME_008 에러) - 방장 변경 로직 구현 (#453) - 방장 퇴장 시 다음 멤버에게 자동 이전 - 모든 멤버 퇴장 시 방 자동 삭제 - WebSocket 메시지 타입 추가 (#454) - ROOM_STATUS_CHANGE, HOST_CHANGE 메시지 타입 추가 - WebSocketMessageHelper에 빌더 메서드 추가 - 테스트 추가 - RoomType, RoomStatus enum 테스트 - GameSettings 모델 테스트 - ChattingErrorCode 테스트 업데이트 Related: #440, #441, #442, #443, #444, #445, #446 Closes: #447, #448, #449, #450, #451, #452, #453, #454 * feat: 참가자 닉네임 및 방장 변경 WebSocket 알림 구현 (#456) - RoomParticipant DTO 추가 (userId, nickname, isHost) - ChatRoomQueryService에 닉네임 조회 메서드 추가 - getParticipantsWithNicknames(): 참가자 목록 + 닉네임 - getHostNickname(): 방장 닉네임 조회 - ChatRoomHandler.getRoom() 응답에 participants, hostNickname 추가 - ChatRoomCommandService.leaveRoom()에서 방장 변경 시 WebSocket 브로드캐스트 Related: #440 * fix: ChatRoomFunction에 UserTable DynamoDB 권한 추가 - 참가자/방장 닉네임 조회를 위한 UserTable 읽기 권한 추가 - DynamoDBReadPolicy로 UserTable 접근 허용 Fixes #457 * fix: GameSettings에 @DynamoDbBean 어노테이션 추가 - DynamoDB Enhanced Client가 중첩 객체를 직렬화/역직렬화할 수 있도록 수정 - ChatRoom 저장/조회 시 GameSettings 변환 오류 해결 Fixes #457 * fix: ChatRoomFunction에 WEBSOCKET_ENDPOINT 환경변수 및 권한 추가 - leaveRoom에서 WebSocketBroadcaster 사용을 위한 환경변수 추가 - execute-api:ManageConnections 권한 추가 * fix: WebSocket Lambda 함수들에 WEBSOCKET_ENDPOINT 환경변수 추가 - WebSocketConnectFunction에 WEBSOCKET_ENDPOINT 추가 - WebSocketDisconnectFunction에 WEBSOCKET_ENDPOINT 추가 - execute-api:ManageConnections 권한 추가 * fix: Grammar WebSocket Lambda 환경 변수 및 권한 추가 - GrammarStreamingConnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - GrammarStreamingDisconnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - 두 함수에 execute-api:ManageConnections 권한 추가 * feat: GSI1SK 확장성 있는 재설계 및 DB 레벨 필터링 - 기존: {level}#{createdAt} - 신규: {type}#{gameType}#{status}#{level}#{createdAt} - 전체 방: GSI1PK = "ROOMS" - 게임방만: begins_with(GSI1SK, "GAME#") - 캐치마인드만: begins_with(GSI1SK, "GAME#CATCHMIND#") - 대기중 캐치마인드: begins_with(GSI1SK, "GAME#CATCHMIND#WAITING#") - ChatRoomCommandService.java: 방 생성 시 새 GSI1SK 포맷 적용 - ChatRoomRepository.java: findByFilters() 메서드 추가, updateStatus() 메서드 추가 - ChatRoomQueryService.java: 메모리 필터링 제거, DB 레벨 필터링으로 변경 - GameService.java: 게임 시작/종료 시 방 상태 업데이트 (GSI1SK 포함) - scripts/migrate-gsi1sk.sh: 기존 데이터 마이그레이션 스크립트 - 37개 기존 방 마이그레이션 완료 * fix: increase stats query limit to 100 days - Adjust maximum allowable limit for recent stats query from 30 to 100 days to support extended data range responses. * feat: improve game round and connection management logic - Added handling for game round timeout (`ROUND_TIMEOUT`) in WebSocketMessageHandler. - Enhanced game start broadcast to include `currentWord` for the drawer only. - Updated ConnectionRepository to remove duplicate user connections in the same room. - Added `currentWordEnglish` to GameSession for better answer verification. - Normalized ChatRoom levels to match database storage format. - Updated template.yaml to include DynamoDBReadPolicy for VocabTable. * refactor: 코드 정리 및 미사용 클래스 제거 (#459) * refactor: remove unused WebSocketResponseUtil * refactor: add AutoCloseable to WebSocketBroadcaster * refactor: remove unused TestService * refactor: remove unused WordService * refactor: remove unused DailyStudyService * refactor: remove unused UserWordService * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * fix: resolve N+1 query in StatsService * refactor(all): DI 패턴 및 전략 패턴 적용 (#461) - Repository, Service, Handler에 DI 생성자 추가 (테스트 용이성) - BadgeService에 Strategy 패턴 적용 (뱃지 조건 검증 로직 분리) * refactor: relocate and restructure seed data files - Moved `question-homes.json` and `words.json` from subdirectories to `seed` folder. - Updated file paths for better organization and clarity. * chore: seed 데이터 폴더 구조 정리 * feat: add CI/CD pipeline configuration for CodePipeline * fix: add SNS topic policy and DependsOn for notification rule * fix: correct paths in buildspec.yml for CodeBuild * fix: remove hardcoded JAVA_HOME, use runtime default * fix: add gradle wrapper for CI/CD build * fix: use single line sam package command with hardcoded bucket * fix: use existing stack name group2-englishstudy-chatting * fix: add missing WEBSOCKET_ENDPOINT env var to WebSocket connect functions * docs: update FRONTEND-API-GUIDE with new RoomType/RoomStatus structure * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix: update WebSocketDisconnectHandler to use GameSession model - Remove references to deleted ChatRoom game fields - Use GameSessionRepository to finish active game sessions - Use ChatRoomRepository.updateStatus() for room state management * perf: optimize CI/CD build time - Add pip cache for SAM CLI dependencies - Enable Gradle parallel builds and build cache - Add SAM build cache (.aws-sam) - Use sam build --parallel --cached - Skip SAM CLI install if already cached - Remove unnecessary 'clean' to leverage cache * feat: add custom CodeBuild Docker image with pre-installed tools - Dockerfile with Java 21 + SAM CLI + Gradle pre-installed - build-and-push.sh script for ECR deployment - Updated buildspec.yml for custom image (removes SAM CLI install) - Expected build time reduction: ~30-40 seconds * feature : AI 영어 회화 연습 기능 (#468) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix: remove typo in SpeakingConnectionRepository * fix : 오타 수정 * chore: trigger build test with custom Docker image * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * fix: add CORS headers to API Gateway error responses (#479) API Gateway의 인증 실패 등 에러 응답에 CORS 헤더가 누락되어 CloudFront를 통한 프론트엔드 요청이 차단되는 문제 수정 - UNAUTHORIZED (401) 응답에 CORS 헤더 추가 - ACCESS_DENIED (403) 응답에 CORS 헤더 추가 - DEFAULT_4XX/5XX 응답에 CORS 헤더 추가 - EXPIRED_TOKEN 응답에 CORS 헤더 추가 * feat : speaking rest API 람다 함수 추가 --------- Co-authored-by: ddingjoo * refactor : speaking service 재사용 * refactor : AI 영어 회화 연습 코드 리팩토링 * feature : test 벡엔드 서버에 AI 말하기 연습 기능 배포 (#492) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix : 오타 수정 * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * feat : speaking rest API 람다 함수 추가 * refactor : speaking service 재사용 * refactor : AI 영어 회화 연습 코드 리팩토링 --------- Co-authored-by: DDING JOO * feat : handleChat 메서드 JsonNull 체크 푸가 * feature : handleChat 메서드 JsonNull 체크 추가 (#493) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix : 오타 수정 * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * feat : speaking rest API 람다 함수 추가 * refactor : speaking service 재사용 * refactor : AI 영어 회화 연습 코드 리팩토링 * feat : handleChat 메서드 JsonNull 체크 푸가 --------- Co-authored-by: DDING JOO * feat(news): 뉴스 학습 배지 시스템 구현 (#473) - 14개 뉴스 관련 배지 추가 (읽기, 퀴즈, 단어수집, 연속학습, 마스터) - UserStats에 뉴스 통계 필드 추가 - 6개 뉴스 배지 Strategy 클래스 생성 - 뉴스 읽기/퀴즈/단어수집 시 통계 업데이트 및 배지 체크 연동 * fix: add PATCH method to CORS AllowMethods * test: BadgeType 개수 테스트 수정 (15 -> 29) * fix: CORS PATCH 메서드 추가 * docs: 뉴스 기능 프론트엔드 연동 가이드 작성 * fix: NewsCollectionFunction에 Bedrock, Comprehend 권한 추가 * fix: add null check for collectWord request body - Add INVALID_REQUEST error code to NewsErrorCode - Check body and word field before accessing in collectWord() - Prevents NullPointerException when request body is malformed * feat: enhance stats API and bookmark response for frontend - Add DAILY stats update for news read/quiz/word collection - Add /stats/dashboard endpoint with frontend-requested format - today: wordsLearned, newsRead, quizzesTaken, wordsTotal - overall: totalWordsLearned, totalNewsRead, averageAccuracy, streaks - weeklyProgress: last 7 days with date/wordsLearned/newsRead - Add news-related fields to all stats API responses - Fix bookmark API to include full article details (title, summary, etc.) * feat: add category classification to news AI analysis - Add category field to AnalysisResult record - Update Bedrock prompt to classify articles into categories (WORLD, POLITICS, BUSINESS, TECH, SCIENCE, HEALTH, SPORTS, ENTERTAINMENT, LIFESTYLE) - Parse and set category from AI response - Set GSI2 (CATEGORY#) index when category is available * fix: add /stats/dashboard endpoint to template.yaml * fix: filter by ARTICLE# prefix in findById to avoid returning UserNewsRecord * docs: add News API troubleshooting guide * feat: add Cognito authorizer to News API and enhance keyword extraction - Set CognitoAuthorizer for all News API endpoints in template.yaml - Update Bedrock AI to extract keywords with meanings and examples - Add fallback to Comprehend for keyword extraction when Bedrock fails - Modify KeywordInfo model to include example field - Adjust AI prompt to include keyword extraction with examples - Update AnalysisResult to store keywords and parse them from AI response * Revert "Merge branch 'test' into prod" This reverts commit c1a958eac6799d5838a76e4078ae304bcdb62278, reversing changes made to a6662e0cfc46c6e12f20a89f0df285ff282160bc. * feat: enhance bookmark and reading status tracking for News API - Add bookmark and reading status to individual article responses - Include bookmark status in paginated news responses - Modify `KeywordInfo` to support Korean translations (`meaningKo`) - Update AI prompts to extract Korean meanings for keywords * refactor : session_id가 null 체크 추가 * feat : template 환경변수 리펙토링 * fix : sessionId NullPointerException 에러 수정 (#496) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix : 오타 수정 * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * feat : speaking rest API 람다 함수 추가 * refactor : speaking service 재사용 * refactor : AI 영어 회화 연습 코드 리팩토링 * feat : handleChat 메서드 JsonNull 체크 푸가 * refactor : session_id가 null 체크 추가 * feat : template 환경변수 리펙토링 --------- Co-authored-by: DDING JOO * feat: enhance bookmark and reading status tracking for News API - Add bookmark and reading status to individual article responses - Include bookmark status in paginated news responses - Modify `KeywordInfo` to support Korean translations (`meaningKo`) - Update AI prompts to extract Korean meanings for keywords * feat: add category filtering to UserWord API with enhanced query logic - Introduce `category` filtering to `getUserWords` API - Update WordCategory enums to include "news" category - Apply category filter after enrichment with word info - Adjust query limits for bookmarked and incorrect words queries * feat: add default value for ExistingCognitoClientId in template.yaml - Set default to an empty string to ensure compatibility with templates using this parameter without explicitly specifying a value. * fix: SpeakingHandler getStringOrNull 컴파일 에러 수정 * fix: SpeakingHandler getStringOrNull 컴파일 에러 수정 * fix: Bedrock 키워드(meaningKo 포함)를 article에 저장하도록 수정 * fix: Bedrock 키워드(meaningKo 포함)를 article에 저장하도록 수정 * fix: Bedrock 키워드(meaningKo 포함)를 article에 저장하도록 수정 * feat: add dashboard stats API and enhance news stats tracking - Introduce `/stats/dashboard` API for retrieving integrated user stats (today, overall, weekly progress, and level distribution) - Update `UserStats` model to include additional fields for news stats (newsRead, newsQuizCompleted, newsQuizPerfect, newsWordsCollected, etc.) - Add atomic updates to track news reading, quiz, and word stats in `UserStatsRepository` - Modify CloudFormation `template.yaml` to register the new `/stats/dashboard` endpoint * feat: add dashboard stats API and enhance news stats tracking - Introduce `/stats/dashboard` API for retrieving integrated user stats (today, overall, weekly progress, and level distribution) - Update `UserStats` model to include additional fields for news stats (newsRead, newsQuizCompleted, newsQuizPerfect, newsWordsCollected, etc.) - Add atomic updates to track news reading, quiz, and word stats in `UserStatsRepository` - Modify CloudFormation `template.yaml` to register the new `/stats/dashboard` endpoint * feat: add dashboard stats API and enhance news stats tracking - Introduce `/stats/dashboard` API for retrieving integrated user stats (today, overall, weekly progress, and level distribution) - Update `UserStats` model to include additional fields for news stats (newsRead, newsQuizCompleted, newsQuizPerfect, newsWordsCollected, etc.) - Add atomic updates to track news reading, quiz, and word stats in `UserStatsRepository` - Modify CloudFormation `template.yaml` to register the new `/stats/dashboard` endpoint * refactor: format code with consistent indentation and spacing - Apply consistent formatting to improve code readability across multiple files - Adjust indentation, spacing, and alignment in model classes, DTOs, and repository methods * fix: filter by ARTICLE# prefix in findById to avoid returning bookmark records Co-Authored-By: Claude Opus 4.5 * feat : Speaking 관련 template 람다 함수 및 테이블 추가 * feat : 말하기 기능에 polly 서비스 권한 추가 * feat : transcribe API KEY 추가 --------- Co-authored-by: DDING JOO Co-authored-by: Claude Opus 4.5 --- ServerlessFunction/template.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index c0a7be9b..857336c8 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -1566,11 +1566,19 @@ Resources: Description: Handle speaking chat API SnapStart: ApplyOn: PublishedVersions + Environment: + Variables: + TRANSCRIBE_API_KEY: "/opic/transcribe-proxy-api-key" Policies: - DynamoDBCrudPolicy: TableName: !Ref SpeakingTable - S3CrudPolicy: BucketName: !Sub "${AWS::StackName}" + - Statement: + - Effect: Allow + Action: + - ssm:GetParameter + Resource: !Sub "arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/opic/*" - Statement: - Effect: Allow Action: From 7986bd1f4749d3a9d33a19025eefb56fd78ba067 Mon Sep 17 00:00:00 2001 From: hyein Heo <128613248+hye-inA@users.noreply.github.com> Date: Sat, 24 Jan 2026 01:06:18 +0900 Subject: [PATCH 515/528] =?UTF-8?q?feature=20:=20transcribe=20API=20KEY=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=20(#516)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: EnvConfig 유틸리티 추가 및 환경 변수 검증 적용 환경 변수 미설정 시 명확한 에러 메시지를 제공하는 EnvConfig 유틸리티를 추가하고, 기존 System.getenv 호출을 EnvConfig.getRequired/getOrDefault로 대체함. - EnvConfig: getRequired, getOrDefault, getIntOrDefault, getLongOrDefault 메서드 제공 - Lambda Cold Start 시점에 환경 변수 누락을 조기 감지 - 기존 Config 클래스(WebSocketConfig, RoomTokenConfig) EnvConfig 사용으로 통일 Closes #403 * refactor: TestService submitTest 메서드 책임 분리 submitTest 메서드를 단일 책임 원칙에 맞게 리팩토링: - gradeAnswers(): 답안 채점 및 결과 집계 - isAnswerCorrect(): 단일 답안 정답 여부 판단 - buildResultItem(): 결과 항목 생성 - saveTestResult(): 테스트 결과 저장 - GradingResult record: 채점 결과 캡슐화 TestService와 TestCommandService 모두 동일하게 적용 Closes #404 * refactor: 하드코딩된 설정값 환경 변수로 외부화 각 도메인별 Config 클래스를 생성하여 하드코딩된 값들을 환경 변수로 설정 가능하게 변경. 기본값이 있어 환경 변수 미설정 시에도 기존 동작 유지. ## 새로 추가된 Config 클래스 - GrammarConfig: SESSION_TTL_DAYS, MAX_HISTORY_MESSAGES, MAX_TOKENS 등 - GameConfig: TOTAL_ROUNDS, ROUND_TIME_LIMIT, QUICK_GUESS_THRESHOLD_MS - VocabularyConfig: NEW_WORDS_COUNT, REVIEW_WORDS_COUNT, 상태 전이 임계값 등 ## 지원하는 환경 변수 - GRAMMAR_SESSION_TTL_DAYS, GRAMMAR_MAX_HISTORY_MESSAGES, GRAMMAR_MAX_TOKENS - GAME_TOTAL_ROUNDS, GAME_ROUND_TIME_LIMIT, GAME_QUICK_GUESS_THRESHOLD_MS - VOCAB_NEW_WORDS_COUNT, VOCAB_REVIEW_WORDS_COUNT - VOCAB_TRANSITION_TO_REVIEWING, VOCAB_TRANSITION_TO_MASTERED Closes #406 * test: StudyLevel enum 단위 테스트 추가 * test: Difficulty enum 단위 테스트 추가 * test: StudyConfig 단위 테스트 추가 * test: EnvConfig 단위 테스트 추가 * test: PaginatedResult 단위 테스트 추가 * test: JsonUtil 단위 테스트 추가 * test: CursorUtil 단위 테스트 추가 * test: CommonErrorCode 단위 테스트 추가 * test: CommonException 단위 테스트 추가 * test: BadgeType enum 단위 테스트 추가 * test: BadgeKey 상수 단위 테스트 추가 * test: WordStatus enum 단위 테스트 추가 * test: TestType enum 단위 테스트 추가 * test: VocabularyConfig 단위 테스트 추가 * test: VocabKey 상수 단위 테스트 추가 * test: VocabularyErrorCode 단위 테스트 추가 * test: VocabularyException 단위 테스트 추가 * test: SpacedRepetitionContext 단위 테스트 추가 * test: GrammarLevel enum 단위 테스트 추가 * test: GrammarConfig 단위 테스트 추가 * test: GrammarErrorCode 단위 테스트 추가 * test: GrammarException 단위 테스트 추가 * test: GameStatus enum 단위 테스트 추가 * test: GameConfig 단위 테스트 추가 * test: ChattingErrorCode 단위 테스트 추가 * test: ChattingException 단위 테스트 추가 * fix: OPIc FeedbackResponse.java 문법 오류 수정 * style: AwsClients 코드 포맷팅 * style: EnvConfig 코드 포맷팅 * style: RoomTokenConfig 코드 포맷팅 * style: WebSocketConfig 코드 포맷팅 * style: JsonUtil 코드 포맷팅 * style: GameConfig 코드 포맷팅 * style: GrammarConfig 코드 포맷팅 * style: GrammarKey 코드 포맷팅 * style: GrammarErrorCode 코드 포맷팅 * style: GrammarException 코드 포맷팅 * style: BedrockGrammarCheckFactory 코드 포맷팅 * style: GrammarConversationService 코드 포맷팅 * style: FeedbackResponse 코드 포맷팅 * style: SessionReportResponse 코드 포맷팅 * style: SpeakingError 코드 포맷팅 * style: SpeakingErrorType 코드 포맷팅 * style: OPIcException 코드 포맷팅 * style: OPIcAnswer 코드 포맷팅 * style: OPIcQuestion 코드 포맷팅 * style: OPIcSession 코드 포맷팅 * style: OPIcRepository 코드 포맷팅 * style: FeedbackService 코드 포맷팅 * style: TranscribeProxyService 코드 포맷팅 * style: VocabularyConfig 코드 포맷팅 * style: DailyStudyCommandService 코드 포맷팅 * style: TestCommandService 코드 포맷팅 * style: TestService 코드 포맷팅 * style: CommonErrorCodeSpec 코드 포맷팅 * style: JsonUtilSpec 코드 포맷팅 * style: BadgeTypeSpec 코드 포맷팅 * style: ChattingErrorCodeSpec 코드 포맷팅 * style: GrammarLevelSpec 코드 포맷팅 * style: GrammarErrorCodeSpec 코드 포맷팅 * style: VocabularyErrorCodeSpec 코드 포맷팅 * feature : OPIc 세션관리 + 답변 처리 파이프라인 구현 (#413) * feat : OPIc 질문 & 세션 관련 dto 생성 * refactor : @DynamoDbBean 주석 추가 * feat : 주제 + 소주제 + 레벨로 오픽 질문 조회 추가 * feat : OPIc 세션, 질문, 답변 종합 handler 구현 * feat : 오픽 주제, 소주제별 seed 데이터 추가 * refactor : Transcribe API KEY 환경변수명 수정 * refactor : Bedrock에 사용하는 클로드 모델 변경 * refactor : S3 Key 대신 Proxy에서 요청하는 Base64 값으로 변환 * feat: GAME_START, ROUND_END 메시지에 serverTime 추가 - broadcastGameStart(): serverTime, roundDuration 필드 추가 - broadcastRoundEnd(): serverTime, roundStartTime, roundDuration 필드 추가 - GameService.endRound(): data에 roundStartTime, roundDuration 포함 - 타이머 동기화 버그 수정을 위한 서버 시간 제공 * feat: WebSocketMessageHelper 유틸리티 클래스 추가 - domain 필드 포함 메시지 생성 헬퍼 - DOMAIN_CHAT, DOMAIN_GAME 상수 정의 - buildChatMessage(), buildGameMessage() 메서드 * feat: 모든 WebSocket 메시지에 domain 필드 추가 - ScoreUpdateMessage에 domain 필드 추가 - broadcastGameStart에 domain:"game" 추가 - broadcastRoundEnd에 domain:"game" 추가 - broadcastCorrectAnswerMessage에 domain:"game" 추가 - handleCommandResult 시스템 메시지에 domain:"game" 추가 - handleRegularMessage 채팅 메시지에 domain:"chat" 추가 Closes #426, #427 * feat: GameSession 모델 클래스 생성 - DynamoDB Enhanced Client 어노테이션 적용 - GSI1: roomId로 활성 게임 세션 조회 가능 - 게임 상태 관리용 헬퍼 메서드 포함 Closes #428 * feat: GameSessionRepository 구현 - 기본 CRUD (save, findById, delete) - roomId로 활성/전체 게임 세션 조회 - 상태, 라운드, 점수 업데이트 메서드 - 정답자 추가, 힌트 사용, 게임 종료 처리 Closes #429 * refactor: ChatRoom에서 게임 필드 분리 - 게임 관련 필드 제거 (gameStatus, currentRound 등) - activeGameSessionId 필드 추가 - 게임 상태는 GameSession으로 분리됨 Note: 의존 코드 수정은 #431에서 진행 Closes #430 * refactor: GameSession 기반으로 전체 게임 로직 리팩토링 - GameService: ChatRoom 대신 GameSession 사용 - GameStatsService: GameSession 매개변수로 변경 - CommandService: GameSession 기반 점수 조회 - GameHandler: GameSession 기반 REST API - WebSocketMessageHandler: GameSession 기반 브로드캐스트 - GameStatusResponse, ScoreboardResponse: GameSession 매개변수 Closes #431 * feat: GameSessionHandler Lambda 및 게임 세션 API 구현 - POST /rooms/{roomId}/games - 게임 세션 생성 - GET /games/{gameSessionId} - 게임 상태 조회 (재접속용) - POST /games/{gameSessionId}/start - 게임 시작 - POST /games/{gameSessionId}/stop - 게임 종료 모든 응답에 serverTime 포함 (타이머 동기화) 출제자에게만 currentWord 포함 Closes #432, #433 * feat: 게임 시작 7분 후 자동 종료 기능 구현 - GameConfig에 gameTimeLimit() 메서드 추가 (기본값: 420초) - GameSchedulerClient 유틸리티 클래스 생성 (EventBridge Scheduler 연동) - GameService에 스케줄 생성/취소 로직 추가 - GameAutoCloseHandler Lambda 함수 생성 - template.yaml에 Lambda, IAM Role, Schedule Group 추가 Closes #417 * fix : 메모리 증가 및 Lambda 응답 제한 시간 Cognito 트리거 제한시간과 동일하게 수정 (#439) * feat: 캐치마인드 게임 방 분리 기능 구현 (#455) - Room 타입 분리 (CHAT/GAME) - RoomType, RoomStatus enum 추가 - ChatRoom 모델에 type, gameType, gameSettings, status, hostId 필드 추가 - GameSettings 모델 추가 - 방 생성/조회 API 수정 - CreateRoomRequest에 type, gameType, gameSettings 필드 추가 - 방 목록 조회 시 type, gameType, status 필터 지원 - 게임 시작 조건 검증 - GAME 타입 방에서만 게임 시작 가능 (GAME_007 에러) - 게임 재시작 API 구현 (#452) - POST /rooms/{roomId}/game/restart 엔드포인트 추가 - 방장만 재시작 가능 (GAME_009 에러) - 게임 진행 중 재시작 불가 (GAME_008 에러) - 방장 변경 로직 구현 (#453) - 방장 퇴장 시 다음 멤버에게 자동 이전 - 모든 멤버 퇴장 시 방 자동 삭제 - WebSocket 메시지 타입 추가 (#454) - ROOM_STATUS_CHANGE, HOST_CHANGE 메시지 타입 추가 - WebSocketMessageHelper에 빌더 메서드 추가 - 테스트 추가 - RoomType, RoomStatus enum 테스트 - GameSettings 모델 테스트 - ChattingErrorCode 테스트 업데이트 Related: #440, #441, #442, #443, #444, #445, #446 Closes: #447, #448, #449, #450, #451, #452, #453, #454 * feat: 참가자 닉네임 및 방장 변경 WebSocket 알림 구현 (#456) - RoomParticipant DTO 추가 (userId, nickname, isHost) - ChatRoomQueryService에 닉네임 조회 메서드 추가 - getParticipantsWithNicknames(): 참가자 목록 + 닉네임 - getHostNickname(): 방장 닉네임 조회 - ChatRoomHandler.getRoom() 응답에 participants, hostNickname 추가 - ChatRoomCommandService.leaveRoom()에서 방장 변경 시 WebSocket 브로드캐스트 Related: #440 * fix: ChatRoomFunction에 UserTable DynamoDB 권한 추가 - 참가자/방장 닉네임 조회를 위한 UserTable 읽기 권한 추가 - DynamoDBReadPolicy로 UserTable 접근 허용 Fixes #457 * fix: GameSettings에 @DynamoDbBean 어노테이션 추가 - DynamoDB Enhanced Client가 중첩 객체를 직렬화/역직렬화할 수 있도록 수정 - ChatRoom 저장/조회 시 GameSettings 변환 오류 해결 Fixes #457 * fix: ChatRoomFunction에 WEBSOCKET_ENDPOINT 환경변수 및 권한 추가 - leaveRoom에서 WebSocketBroadcaster 사용을 위한 환경변수 추가 - execute-api:ManageConnections 권한 추가 * fix: WebSocket Lambda 함수들에 WEBSOCKET_ENDPOINT 환경변수 추가 - WebSocketConnectFunction에 WEBSOCKET_ENDPOINT 추가 - WebSocketDisconnectFunction에 WEBSOCKET_ENDPOINT 추가 - execute-api:ManageConnections 권한 추가 * fix: Grammar WebSocket Lambda 환경 변수 및 권한 추가 - GrammarStreamingConnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - GrammarStreamingDisconnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - 두 함수에 execute-api:ManageConnections 권한 추가 * feat: GSI1SK 확장성 있는 재설계 및 DB 레벨 필터링 ## 변경 사항 ### GSI1SK 포맷 변경 - 기존: {level}#{createdAt} - 신규: {type}#{gameType}#{status}#{level}#{createdAt} ### 지원 쿼리 패턴 - 전체 방: GSI1PK = "ROOMS" - 게임방만: begins_with(GSI1SK, "GAME#") - 캐치마인드만: begins_with(GSI1SK, "GAME#CATCHMIND#") - 대기중 캐치마인드: begins_with(GSI1SK, "GAME#CATCHMIND#WAITING#") ### 파일 수정 - ChatRoomCommandService.java: 방 생성 시 새 GSI1SK 포맷 적용 - ChatRoomRepository.java: findByFilters() 메서드 추가, updateStatus() 메서드 추가 - ChatRoomQueryService.java: 메모리 필터링 제거, DB 레벨 필터링으로 변경 - GameService.java: 게임 시작/종료 시 방 상태 업데이트 (GSI1SK 포함) ### 마이그레이션 - scripts/migrate-gsi1sk.sh: 기존 데이터 마이그레이션 스크립트 - 37개 기존 방 마이그레이션 완료 * fix: increase stats query limit to 100 days - Adjust maximum allowable limit for recent stats query from 30 to 100 days to support extended data range responses. * feat: improve game round and connection management logic - Added handling for game round timeout (`ROUND_TIMEOUT`) in WebSocketMessageHandler. - Enhanced game start broadcast to include `currentWord` for the drawer only. - Updated ConnectionRepository to remove duplicate user connections in the same room. - Added `currentWordEnglish` to GameSession for better answer verification. - Normalized ChatRoom levels to match database storage format. - Updated template.yaml to include DynamoDBReadPolicy for VocabTable. * refactor: 코드 정리 및 미사용 클래스 제거 (#459) * refactor: remove unused WebSocketResponseUtil * refactor: add AutoCloseable to WebSocketBroadcaster * refactor: remove unused TestService * refactor: remove unused WordService * refactor: remove unused DailyStudyService * refactor: remove unused UserWordService * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * fix: resolve N+1 query in StatsService * refactor(all): DI 패턴 및 전략 패턴 적용 (#461) - Repository, Service, Handler에 DI 생성자 추가 (테스트 용이성) - BadgeService에 Strategy 패턴 적용 (뱃지 조건 검증 로직 분리) * refactor: relocate and restructure seed data files - Moved `question-homes.json` and `words.json` from subdirectories to `seed` folder. - Updated file paths for better organization and clarity. * chore: seed 데이터 폴더 구조 정리 * feat: add CI/CD pipeline configuration for CodePipeline * fix: add SNS topic policy and DependsOn for notification rule * fix: correct paths in buildspec.yml for CodeBuild * fix: remove hardcoded JAVA_HOME, use runtime default * fix: add gradle wrapper for CI/CD build * fix: use single line sam package command with hardcoded bucket * fix: use existing stack name group2-englishstudy-chatting * fix: add missing WEBSOCKET_ENDPOINT env var to WebSocket connect functions * docs: update FRONTEND-API-GUIDE with new RoomType/RoomStatus structure * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix: update WebSocketDisconnectHandler to use GameSession model - Remove references to deleted ChatRoom game fields - Use GameSessionRepository to finish active game sessions - Use ChatRoomRepository.updateStatus() for room state management * perf: optimize CI/CD build time - Add pip cache for SAM CLI dependencies - Enable Gradle parallel builds and build cache - Add SAM build cache (.aws-sam) - Use sam build --parallel --cached - Skip SAM CLI install if already cached - Remove unnecessary 'clean' to leverage cache * feat: add custom CodeBuild Docker image with pre-installed tools - Dockerfile with Java 21 + SAM CLI + Gradle pre-installed - build-and-push.sh script for ECR deployment - Updated buildspec.yml for custom image (removes SAM CLI install) - Expected build time reduction: ~30-40 seconds * feature : AI 영어 회화 연습 기능 (#468) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix: remove typo in SpeakingConnectionRepository * fix : 오타 수정 * chore: trigger build test with custom Docker image * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes * Release: 캐치마인드 게임 분리, AI 회화 연습, CI/CD 파이프라인 (#469) * feature : OPIc 세션관리 + 답변 처리 파이프라인 구현 (#413) * feat : OPIc 질문 & 세션 관련 dto 생성 * refactor : @DynamoDbBean 주석 추가 * feat : 주제 + 소주제 + 레벨로 오픽 질문 조회 추가 * feat : OPIc 세션, 질문, 답변 종합 handler 구현 * feat : 오픽 주제, 소주제별 seed 데이터 추가 * refactor : Transcribe API KEY 환경변수명 수정 * refactor : Bedrock에 사용하는 클로드 모델 변경 * refactor : S3 Key 대신 Proxy에서 요청하는 Base64 값으로 변환 * feat: GAME_START, ROUND_END 메시지에 serverTime 추가 - broadcastGameStart(): serverTime, roundDuration 필드 추가 - broadcastRoundEnd(): serverTime, roundStartTime, roundDuration 필드 추가 - GameService.endRound(): data에 roundStartTime, roundDuration 포함 - 타이머 동기화 버그 수정을 위한 서버 시간 제공 * feat: WebSocketMessageHelper 유틸리티 클래스 추가 - domain 필드 포함 메시지 생성 헬퍼 - DOMAIN_CHAT, DOMAIN_GAME 상수 정의 - buildChatMessage(), buildGameMessage() 메서드 * feat: 모든 WebSocket 메시지에 domain 필드 추가 - ScoreUpdateMessage에 domain 필드 추가 - broadcastGameStart에 domain:"game" 추가 - broadcastRoundEnd에 domain:"game" 추가 - broadcastCorrectAnswerMessage에 domain:"game" 추가 - handleCommandResult 시스템 메시지에 domain:"game" 추가 - handleRegularMessage 채팅 메시지에 domain:"chat" 추가 Closes #426, #427 * feat: GameSession 모델 클래스 생성 - DynamoDB Enhanced Client 어노테이션 적용 - GSI1: roomId로 활성 게임 세션 조회 가능 - 게임 상태 관리용 헬퍼 메서드 포함 Closes #428 * feat: GameSessionRepository 구현 - 기본 CRUD (save, findById, delete) - roomId로 활성/전체 게임 세션 조회 - 상태, 라운드, 점수 업데이트 메서드 - 정답자 추가, 힌트 사용, 게임 종료 처리 Closes #429 * refactor: ChatRoom에서 게임 필드 분리 - 게임 관련 필드 제거 (gameStatus, currentRound 등) - activeGameSessionId 필드 추가 - 게임 상태는 GameSession으로 분리됨 Note: 의존 코드 수정은 #431에서 진행 Closes #430 * refactor: GameSession 기반으로 전체 게임 로직 리팩토링 - GameService: ChatRoom 대신 GameSession 사용 - GameStatsService: GameSession 매개변수로 변경 - CommandService: GameSession 기반 점수 조회 - GameHandler: GameSession 기반 REST API - WebSocketMessageHandler: GameSession 기반 브로드캐스트 - GameStatusResponse, ScoreboardResponse: GameSession 매개변수 Closes #431 * feat: GameSessionHandler Lambda 및 게임 세션 API 구현 - POST /rooms/{roomId}/games - 게임 세션 생성 - GET /games/{gameSessionId} - 게임 상태 조회 (재접속용) - POST /games/{gameSessionId}/start - 게임 시작 - POST /games/{gameSessionId}/stop - 게임 종료 모든 응답에 serverTime 포함 (타이머 동기화) 출제자에게만 currentWord 포함 Closes #432, #433 * feat: 게임 시작 7분 후 자동 종료 기능 구현 - GameConfig에 gameTimeLimit() 메서드 추가 (기본값: 420초) - GameSchedulerClient 유틸리티 클래스 생성 (EventBridge Scheduler 연동) - GameService에 스케줄 생성/취소 로직 추가 - GameAutoCloseHandler Lambda 함수 생성 - template.yaml에 Lambda, IAM Role, Schedule Group 추가 Closes #417 * fix : 메모리 증가 및 Lambda 응답 제한 시간 Cognito 트리거 제한시간과 동일하게 수정 (#439) * feat: 캐치마인드 게임 방 분리 기능 구현 (#455) - Room 타입 분리 (CHAT/GAME) - RoomType, RoomStatus enum 추가 - ChatRoom 모델에 type, gameType, gameSettings, status, hostId 필드 추가 - GameSettings 모델 추가 - 방 생성/조회 API 수정 - CreateRoomRequest에 type, gameType, gameSettings 필드 추가 - 방 목록 조회 시 type, gameType, status 필터 지원 - 게임 시작 조건 검증 - GAME 타입 방에서만 게임 시작 가능 (GAME_007 에러) - 게임 재시작 API 구현 (#452) - POST /rooms/{roomId}/game/restart 엔드포인트 추가 - 방장만 재시작 가능 (GAME_009 에러) - 게임 진행 중 재시작 불가 (GAME_008 에러) - 방장 변경 로직 구현 (#453) - 방장 퇴장 시 다음 멤버에게 자동 이전 - 모든 멤버 퇴장 시 방 자동 삭제 - WebSocket 메시지 타입 추가 (#454) - ROOM_STATUS_CHANGE, HOST_CHANGE 메시지 타입 추가 - WebSocketMessageHelper에 빌더 메서드 추가 - 테스트 추가 - RoomType, RoomStatus enum 테스트 - GameSettings 모델 테스트 - ChattingErrorCode 테스트 업데이트 Related: #440, #441, #442, #443, #444, #445, #446 Closes: #447, #448, #449, #450, #451, #452, #453, #454 * feat: 참가자 닉네임 및 방장 변경 WebSocket 알림 구현 (#456) - RoomParticipant DTO 추가 (userId, nickname, isHost) - ChatRoomQueryService에 닉네임 조회 메서드 추가 - getParticipantsWithNicknames(): 참가자 목록 + 닉네임 - getHostNickname(): 방장 닉네임 조회 - ChatRoomHandler.getRoom() 응답에 participants, hostNickname 추가 - ChatRoomCommandService.leaveRoom()에서 방장 변경 시 WebSocket 브로드캐스트 Related: #440 * fix: ChatRoomFunction에 UserTable DynamoDB 권한 추가 - 참가자/방장 닉네임 조회를 위한 UserTable 읽기 권한 추가 - DynamoDBReadPolicy로 UserTable 접근 허용 Fixes #457 * fix: GameSettings에 @DynamoDbBean 어노테이션 추가 - DynamoDB Enhanced Client가 중첩 객체를 직렬화/역직렬화할 수 있도록 수정 - ChatRoom 저장/조회 시 GameSettings 변환 오류 해결 Fixes #457 * fix: ChatRoomFunction에 WEBSOCKET_ENDPOINT 환경변수 및 권한 추가 - leaveRoom에서 WebSocketBroadcaster 사용을 위한 환경변수 추가 - execute-api:ManageConnections 권한 추가 * fix: WebSocket Lambda 함수들에 WEBSOCKET_ENDPOINT 환경변수 추가 - WebSocketConnectFunction에 WEBSOCKET_ENDPOINT 추가 - WebSocketDisconnectFunction에 WEBSOCKET_ENDPOINT 추가 - execute-api:ManageConnections 권한 추가 * fix: Grammar WebSocket Lambda 환경 변수 및 권한 추가 - GrammarStreamingConnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - GrammarStreamingDisconnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - 두 함수에 execute-api:ManageConnections 권한 추가 * feat: GSI1SK 확장성 있는 재설계 및 DB 레벨 필터링 ## 변경 사항 ### GSI1SK 포맷 변경 - 기존: {level}#{createdAt} - 신규: {type}#{gameType}#{status}#{level}#{createdAt} ### 지원 쿼리 패턴 - 전체 방: GSI1PK = "ROOMS" - 게임방만: begins_with(GSI1SK, "GAME#") - 캐치마인드만: begins_with(GSI1SK, "GAME#CATCHMIND#") - 대기중 캐치마인드: begins_with(GSI1SK, "GAME#CATCHMIND#WAITING#") ### 파일 수정 - ChatRoomCommandService.java: 방 생성 시 새 GSI1SK 포맷 적용 - ChatRoomRepository.java: findByFilters() 메서드 추가, updateStatus() 메서드 추가 - ChatRoomQueryService.java: 메모리 필터링 제거, DB 레벨 필터링으로 변경 - GameService.java: 게임 시작/종료 시 방 상태 업데이트 (GSI1SK 포함) ### 마이그레이션 - scripts/migrate-gsi1sk.sh: 기존 데이터 마이그레이션 스크립트 - 37개 기존 방 마이그레이션 완료 * fix: increase stats query limit to 100 days - Adjust maximum allowable limit for recent stats query from 30 to 100 days to support extended data range responses. * feat: improve game round and connection management logic - Added handling for game round timeout (`ROUND_TIMEOUT`) in WebSocketMessageHandler. - Enhanced game start broadcast to include `currentWord` for the drawer only. - Updated ConnectionRepository to remove duplicate user connections in the same room. - Added `currentWordEnglish` to GameSession for better answer verification. - Normalized ChatRoom levels to match database storage format. - Updated template.yaml to include DynamoDBReadPolicy for VocabTable. * refactor: 코드 정리 및 미사용 클래스 제거 (#459) * refactor: remove unused WebSocketResponseUtil * refactor: add AutoCloseable to WebSocketBroadcaster * refactor: remove unused TestService * refactor: remove unused WordService * refactor: remove unused DailyStudyService * refactor: remove unused UserWordService * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * fix: resolve N+1 query in StatsService * refactor(all): DI 패턴 및 전략 패턴 적용 (#461) - Repository, Service, Handler에 DI 생성자 추가 (테스트 용이성) - BadgeService에 Strategy 패턴 적용 (뱃지 조건 검증 로직 분리) * refactor: relocate and restructure seed data files - Moved `question-homes.json` and `words.json` from subdirectories to `seed` folder. - Updated file paths for better organization and clarity. * chore: seed 데이터 폴더 구조 정리 * feat: add CI/CD pipeline configuration for CodePipeline * fix: add SNS topic policy and DependsOn for notification rule * fix: correct paths in buildspec.yml for CodeBuild * fix: remove hardcoded JAVA_HOME, use runtime default * fix: add gradle wrapper for CI/CD build * fix: use single line sam package command with hardcoded bucket * fix: use existing stack name group2-englishstudy-chatting * fix: add missing WEBSOCKET_ENDPOINT env var to WebSocket connect functions * docs: update FRONTEND-API-GUIDE with new RoomType/RoomStatus structure * fix: update WebSocketDisconnectHandler to use GameSession model - Remove references to deleted ChatRoom game fields - Use GameSessionRepository to finish active game sessions - Use ChatRoomRepository.updateStatus() for room state management * perf: optimize CI/CD build time - Add pip cache for SAM CLI dependencies - Enable Gradle parallel builds and build cache - Add SAM build cache (.aws-sam) - Use sam build --parallel --cached - Skip SAM CLI install if already cached - Remove unnecessary 'clean' to leverage cache * feat: add custom CodeBuild Docker image with pre-installed tools - Dockerfile with Java 21 + SAM CLI + Gradle pre-installed - build-and-push.sh script for ECR deployment - Updated buildspec.yml for custom image (removes SAM CLI install) - Expected build time reduction: ~30-40 seconds * feature : AI 영어 회화 연습 기능 (#468) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix: remove typo in SpeakingConnectionRepository * chore: trigger build test with custom Docker image * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes --------- Co-authored-by: hyein Heo <128613248+hye-inA@users.noreply.github.com> * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * fix: add CORS headers to API Gateway error responses (#479) API Gateway의 인증 실패 등 에러 응답에 CORS 헤더가 누락되어 CloudFront를 통한 프론트엔드 요청이 차단되는 문제 수정 - UNAUTHORIZED (401) 응답에 CORS 헤더 추가 - ACCESS_DENIED (403) 응답에 CORS 헤더 추가 - DEFAULT_4XX/5XX 응답에 CORS 헤더 추가 - EXPIRED_TOKEN 응답에 CORS 헤더 추가 * feat(news): 뉴스 도메인 기반 구조 구축 (#385) - NewsCategory, QuizType enum 추가 - NewsKey 상수 클래스 추가 - NewsErrorCode 예외 클래스 추가 - NewsArticle, KeywordInfo, QuizQuestion 모델 추가 - NewsArticleRepository CRUD 구현 - NewsTable DynamoDB 테이블 정의 - CORS GatewayResponses 설정 추가 * feat(news): 뉴스 수집 파이프라인 구현 (#386) - NewsApiClient: NewsAPI 연동 서비스 - RssFeedParser: RSS 피드 파싱 (BBC, VOA, NPR) - NewsDuplicateChecker: URL 기반 중복 필터링 - NewsCollectorService: 수집 오케스트레이션 - NewsCollectionHandler: Lambda 핸들러 - EventBridge 스케줄러: 매일 18시 KST 실행 * refactor(news): NewsAPI 제거, RSS만 사용 - NewsApiClient 삭제 - SSM Parameter 의존성 제거 - RSS 소스당 7개씩 수집 (BBC, VOA, NPR = 약 21개/일) * feat(news): AI 뉴스 분석 시스템 구현 (#387) - NewsAnalysisService: AI 분석 통합 서비스 - Bedrock: CEFR 난이도 분석 (A1~C2) - Bedrock: 3줄 요약 + 퀴즈 3문제 생성 - Comprehend: 핵심 키워드 추출 - NewsCollectorService: 수집 시 자동 분석 연동 - GSI1/GSI2 키 자동 설정 (레벨별, 카테고리별 조회) * feat(news): 뉴스 학습 API 구현 (#388) - NewsQueryService: 뉴스 조회 서비스 - NewsHandler: API 핸들러 - GET /news - 목록 조회 (level, category 필터) - GET /news/today - 오늘의 뉴스 - GET /news/recommended - 내 레벨 맞춤 추천 - GET /news/{articleId} - 상세 조회 (조회수 증가) - template.yaml: NewsFunction Lambda 추가 * feat(news): 뉴스 학습 부가 기능 구현 (#389) - 읽기 완료 기록 API (POST /news/{articleId}/read) - 북마크 토글 API (POST /news/{articleId}/bookmark) - 북마크 목록 조회 API (GET /news/bookmarks) - 학습 통계 조회 API (GET /news/stats) - TTS 오디오 URL 조회 API (GET /news/{articleId}/audio) - UserNewsRecord 모델 추가 - UserNewsRepository 추가 - NewsLearningService 추가 * feat(news): 복합 퀴즈 시스템 구현 (#471) - 퀴즈 조회 API (GET /news/{articleId}/quiz) - 퀴즈 제출 API (POST /news/{articleId}/quiz) - 퀴즈 기록 조회 API (GET /news/quiz/history) - NewsQuizResult 모델 추가 - QuizAnswerResult 모델 추가 - NewsQuizRepository 추가 - NewsQuizService 추가 * feat(news): 단어 수집 & Vocabulary 연동 구현 (#472) - 단어 수집 API (POST /news/{articleId}/words) - 수집 단어 목록 API (GET /news/words) - 단어 상세 조회 API (GET /news/{articleId}/words/{word}) - 단어 삭제 API (DELETE /news/{articleId}/words/{word}) - Vocabulary 연동 API (POST /news/words/{word}/sync) - NewsWordCollect 모델 추가 - NewsWordRepository 추가 - NewsWordService 추가 * feat: add multi-environment deployment support (dev/test/prod) * fix: update buildspec.yml to deploy prod environment with parameter overrides * feat(news): 뉴스 도메인 기반 구조 구축 (#385) - NewsCategory, QuizType enum 추가 - NewsKey 상수 클래스 추가 - NewsErrorCode 예외 클래스 추가 - NewsArticle, KeywordInfo, QuizQuestion 모델 추가 - NewsArticleRepository CRUD 구현 - NewsTable DynamoDB 테이블 정의 - CORS GatewayResponses 설정 추가 * feat(news): 뉴스 수집 파이프라인 구현 (#386) - NewsApiClient: NewsAPI 연동 서비스 - RssFeedParser: RSS 피드 파싱 (BBC, VOA, NPR) - NewsDuplicateChecker: URL 기반 중복 필터링 - NewsCollectorService: 수집 오케스트레이션 - NewsCollectionHandler: Lambda 핸들러 - EventBridge 스케줄러: 매일 18시 KST 실행 * refactor(news): NewsAPI 제거, RSS만 사용 - NewsApiClient 삭제 - SSM Parameter 의존성 제거 - RSS 소스당 7개씩 수집 (BBC, VOA, NPR = 약 21개/일) * feat(news): AI 뉴스 분석 시스템 구현 (#387) - NewsAnalysisService: AI 분석 통합 서비스 - Bedrock: CEFR 난이도 분석 (A1~C2) - Bedrock: 3줄 요약 + 퀴즈 3문제 생성 - Comprehend: 핵심 키워드 추출 - NewsCollectorService: 수집 시 자동 분석 연동 - GSI1/GSI2 키 자동 설정 (레벨별, 카테고리별 조회) * feat(news): 뉴스 학습 API 구현 (#388) - NewsQueryService: 뉴스 조회 서비스 - NewsHandler: API 핸들러 - GET /news - 목록 조회 (level, category 필터) - GET /news/today - 오늘의 뉴스 - GET /news/recommended - 내 레벨 맞춤 추천 - GET /news/{articleId} - 상세 조회 (조회수 증가) - template.yaml: NewsFunction Lambda 추가 * feat(news): 뉴스 학습 부가 기능 구현 (#389) - 읽기 완료 기록 API (POST /news/{articleId}/read) - 북마크 토글 API (POST /news/{articleId}/bookmark) - 북마크 목록 조회 API (GET /news/bookmarks) - 학습 통계 조회 API (GET /news/stats) - TTS 오디오 URL 조회 API (GET /news/{articleId}/audio) - UserNewsRecord 모델 추가 - UserNewsRepository 추가 - NewsLearningService 추가 * feat(news): 복합 퀴즈 시스템 구현 (#471) - 퀴즈 조회 API (GET /news/{articleId}/quiz) - 퀴즈 제출 API (POST /news/{articleId}/quiz) - 퀴즈 기록 조회 API (GET /news/quiz/history) - NewsQuizResult 모델 추가 - QuizAnswerResult 모델 추가 - NewsQuizRepository 추가 - NewsQuizService 추가 * feat(news): 단어 수집 & Vocabulary 연동 구현 (#472) - 단어 수집 API (POST /news/{articleId}/words) - 수집 단어 목록 API (GET /news/words) - 단어 상세 조회 API (GET /news/{articleId}/words/{word}) - 단어 삭제 API (DELETE /news/{articleId}/words/{word}) - Vocabulary 연동 API (POST /news/words/{word}/sync) - NewsWordCollect 모델 추가 - NewsWordRepository 추가 - NewsWordService 추가 * feat: add multi-environment deployment support (dev/test/prod) * fix: update buildspec.yml to deploy prod environment with parameter overrides * refactor : AI 말하기 Websocket 구현 -> REST API 구현으로 리팩토링 (#490) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix : 오타 수정 * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * fix: revert buildspec.yml to build-only for CloudFormation deploy stage * fix: revert buildspec.yml to build-only for CloudFormation deploy stage * fix: update all API Gateway StageName to use Environment parameter * fix: correct VocabularyTable and ContentBucket references in NewsFunction * fix: correct VocabularyTable and ContentBucket references in NewsFunction * fix: add stack name prefix to GameScheduleGroup and daily-stats schedule to avoid conflicts * fix: add stack name prefix to GameScheduleGroup and daily-stats schedule to avoid conflicts * feat: add support for existing Cognito User Pool reuse across environments * fix: add conditional Cognito ARN reference in API Gateway Authorizer * fix: remove Cognito resources completely, use existing Cognito only * fix: remove Cognito resources completely, use existing Cognito only * feat : speaking rest API 람다 함수 추가 * feat: add S3 bucket resource and fix environment-specific endpoints - Add ContentBucket S3 resource for content storage - Replace hardcoded /dev with ${Environment} in all WebSocket endpoints - Update Output URLs to use dynamic environment stage * fix: add stack name prefix to news-collection schedule name Prevent EventBridge rule name conflicts across environments * fix: add X-Requested-With and Accept headers to CORS config Enable additional headers for CloudFront CORS compatibility * fix: use environment variable for S3 bucket URLs Replace hardcoded bucket name with BUCKET_NAME env var for multi-env support: - PreSignUpHandler: dynamic default profile URL - PostConfirmationHandler: dynamic default profile URL - UserService: dynamic default profile URL - BadgeType: dynamic badge image base URL * fix: disable authorizer for CORS preflight requests Add AddDefaultAuthorizerToCorsPreflight: false to prevent Cognito Authorizer from blocking OPTIONS requests * feat : speaking REST API 람다 함수 추가 (#491) * feature : OPIc 세션관리 + 답변 처리 파이프라인 구현 (#413) * feat : OPIc 질문 & 세션 관련 dto 생성 * refactor : @DynamoDbBean 주석 추가 * feat : 주제 + 소주제 + 레벨로 오픽 질문 조회 추가 * feat : OPIc 세션, 질문, 답변 종합 handler 구현 * feat : 오픽 주제, 소주제별 seed 데이터 추가 * refactor : Transcribe API KEY 환경변수명 수정 * refactor : Bedrock에 사용하는 클로드 모델 변경 * refactor : S3 Key 대신 Proxy에서 요청하는 Base64 값으로 변환 * feat: GAME_START, ROUND_END 메시지에 serverTime 추가 - broadcastGameStart(): serverTime, roundDuration 필드 추가 - broadcastRoundEnd(): serverTime, roundStartTime, roundDuration 필드 추가 - GameService.endRound(): data에 roundStartTime, roundDuration 포함 - 타이머 동기화 버그 수정을 위한 서버 시간 제공 * feat: WebSocketMessageHelper 유틸리티 클래스 추가 - domain 필드 포함 메시지 생성 헬퍼 - DOMAIN_CHAT, DOMAIN_GAME 상수 정의 - buildChatMessage(), buildGameMessage() 메서드 * feat: 모든 WebSocket 메시지에 domain 필드 추가 - ScoreUpdateMessage에 domain 필드 추가 - broadcastGameStart에 domain:"game" 추가 - broadcastRoundEnd에 domain:"game" 추가 - broadcastCorrectAnswerMessage에 domain:"game" 추가 - handleCommandResult 시스템 메시지에 domain:"game" 추가 - handleRegularMessage 채팅 메시지에 domain:"chat" 추가 Closes #426, #427 * feat: GameSession 모델 클래스 생성 - DynamoDB Enhanced Client 어노테이션 적용 - GSI1: roomId로 활성 게임 세션 조회 가능 - 게임 상태 관리용 헬퍼 메서드 포함 Closes #428 * feat: GameSessionRepository 구현 - 기본 CRUD (save, findById, delete) - roomId로 활성/전체 게임 세션 조회 - 상태, 라운드, 점수 업데이트 메서드 - 정답자 추가, 힌트 사용, 게임 종료 처리 Closes #429 * refactor: ChatRoom에서 게임 필드 분리 - 게임 관련 필드 제거 (gameStatus, currentRound 등) - activeGameSessionId 필드 추가 - 게임 상태는 GameSession으로 분리됨 Note: 의존 코드 수정은 #431에서 진행 Closes #430 * refactor: GameSession 기반으로 전체 게임 로직 리팩토링 - GameService: ChatRoom 대신 GameSession 사용 - GameStatsService: GameSession 매개변수로 변경 - CommandService: GameSession 기반 점수 조회 - GameHandler: GameSession 기반 REST API - WebSocketMessageHandler: GameSession 기반 브로드캐스트 - GameStatusResponse, ScoreboardResponse: GameSession 매개변수 Closes #431 * feat: GameSessionHandler Lambda 및 게임 세션 API 구현 - POST /rooms/{roomId}/games - 게임 세션 생성 - GET /games/{gameSessionId} - 게임 상태 조회 (재접속용) - POST /games/{gameSessionId}/start - 게임 시작 - POST /games/{gameSessionId}/stop - 게임 종료 모든 응답에 serverTime 포함 (타이머 동기화) 출제자에게만 currentWord 포함 Closes #432, #433 * feat: 게임 시작 7분 후 자동 종료 기능 구현 - GameConfig에 gameTimeLimit() 메서드 추가 (기본값: 420초) - GameSchedulerClient 유틸리티 클래스 생성 (EventBridge Scheduler 연동) - GameService에 스케줄 생성/취소 로직 추가 - GameAutoCloseHandler Lambda 함수 생성 - template.yaml에 Lambda, IAM Role, Schedule Group 추가 Closes #417 * fix : 메모리 증가 및 Lambda 응답 제한 시간 Cognito 트리거 제한시간과 동일하게 수정 (#439) * feat: 캐치마인드 게임 방 분리 기능 구현 (#455) - Room 타입 분리 (CHAT/GAME) - RoomType, RoomStatus enum 추가 - ChatRoom 모델에 type, gameType, gameSettings, status, hostId 필드 추가 - GameSettings 모델 추가 - 방 생성/조회 API 수정 - CreateRoomRequest에 type, gameType, gameSettings 필드 추가 - 방 목록 조회 시 type, gameType, status 필터 지원 - 게임 시작 조건 검증 - GAME 타입 방에서만 게임 시작 가능 (GAME_007 에러) - 게임 재시작 API 구현 (#452) - POST /rooms/{roomId}/game/restart 엔드포인트 추가 - 방장만 재시작 가능 (GAME_009 에러) - 게임 진행 중 재시작 불가 (GAME_008 에러) - 방장 변경 로직 구현 (#453) - 방장 퇴장 시 다음 멤버에게 자동 이전 - 모든 멤버 퇴장 시 방 자동 삭제 - WebSocket 메시지 타입 추가 (#454) - ROOM_STATUS_CHANGE, HOST_CHANGE 메시지 타입 추가 - WebSocketMessageHelper에 빌더 메서드 추가 - 테스트 추가 - RoomType, RoomStatus enum 테스트 - GameSettings 모델 테스트 - ChattingErrorCode 테스트 업데이트 Related: #440, #441, #442, #443, #444, #445, #446 Closes: #447, #448, #449, #450, #451, #452, #453, #454 * feat: 참가자 닉네임 및 방장 변경 WebSocket 알림 구현 (#456) - RoomParticipant DTO 추가 (userId, nickname, isHost) - ChatRoomQueryService에 닉네임 조회 메서드 추가 - getParticipantsWithNicknames(): 참가자 목록 + 닉네임 - getHostNickname(): 방장 닉네임 조회 - ChatRoomHandler.getRoom() 응답에 participants, hostNickname 추가 - ChatRoomCommandService.leaveRoom()에서 방장 변경 시 WebSocket 브로드캐스트 Related: #440 * fix: ChatRoomFunction에 UserTable DynamoDB 권한 추가 - 참가자/방장 닉네임 조회를 위한 UserTable 읽기 권한 추가 - DynamoDBReadPolicy로 UserTable 접근 허용 Fixes #457 * fix: GameSettings에 @DynamoDbBean 어노테이션 추가 - DynamoDB Enhanced Client가 중첩 객체를 직렬화/역직렬화할 수 있도록 수정 - ChatRoom 저장/조회 시 GameSettings 변환 오류 해결 Fixes #457 * fix: ChatRoomFunction에 WEBSOCKET_ENDPOINT 환경변수 및 권한 추가 - leaveRoom에서 WebSocketBroadcaster 사용을 위한 환경변수 추가 - execute-api:ManageConnections 권한 추가 * fix: WebSocket Lambda 함수들에 WEBSOCKET_ENDPOINT 환경변수 추가 - WebSocketConnectFunction에 WEBSOCKET_ENDPOINT 추가 - WebSocketDisconnectFunction에 WEBSOCKET_ENDPOINT 추가 - execute-api:ManageConnections 권한 추가 * fix: Grammar WebSocket Lambda 환경 변수 및 권한 추가 - GrammarStreamingConnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - GrammarStreamingDisconnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - 두 함수에 execute-api:ManageConnections 권한 추가 * feat: GSI1SK 확장성 있는 재설계 및 DB 레벨 필터링 - 기존: {level}#{createdAt} - 신규: {type}#{gameType}#{status}#{level}#{createdAt} - 전체 방: GSI1PK = "ROOMS" - 게임방만: begins_with(GSI1SK, "GAME#") - 캐치마인드만: begins_with(GSI1SK, "GAME#CATCHMIND#") - 대기중 캐치마인드: begins_with(GSI1SK, "GAME#CATCHMIND#WAITING#") - ChatRoomCommandService.java: 방 생성 시 새 GSI1SK 포맷 적용 - ChatRoomRepository.java: findByFilters() 메서드 추가, updateStatus() 메서드 추가 - ChatRoomQueryService.java: 메모리 필터링 제거, DB 레벨 필터링으로 변경 - GameService.java: 게임 시작/종료 시 방 상태 업데이트 (GSI1SK 포함) - scripts/migrate-gsi1sk.sh: 기존 데이터 마이그레이션 스크립트 - 37개 기존 방 마이그레이션 완료 * fix: increase stats query limit to 100 days - Adjust maximum allowable limit for recent stats query from 30 to 100 days to support extended data range responses. * feat: improve game round and connection management logic - Added handling for game round timeout (`ROUND_TIMEOUT`) in WebSocketMessageHandler. - Enhanced game start broadcast to include `currentWord` for the drawer only. - Updated ConnectionRepository to remove duplicate user connections in the same room. - Added `currentWordEnglish` to GameSession for better answer verification. - Normalized ChatRoom levels to match database storage format. - Updated template.yaml to include DynamoDBReadPolicy for VocabTable. * refactor: 코드 정리 및 미사용 클래스 제거 (#459) * refactor: remove unused WebSocketResponseUtil * refactor: add AutoCloseable to WebSocketBroadcaster * refactor: remove unused TestService * refactor: remove unused WordService * refactor: remove unused DailyStudyService * refactor: remove unused UserWordService * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * fix: resolve N+1 query in StatsService * refactor(all): DI 패턴 및 전략 패턴 적용 (#461) - Repository, Service, Handler에 DI 생성자 추가 (테스트 용이성) - BadgeService에 Strategy 패턴 적용 (뱃지 조건 검증 로직 분리) * refactor: relocate and restructure seed data files - Moved `question-homes.json` and `words.json` from subdirectories to `seed` folder. - Updated file paths for better organization and clarity. * chore: seed 데이터 폴더 구조 정리 * feat: add CI/CD pipeline configuration for CodePipeline * fix: add SNS topic policy and DependsOn for notification rule * fix: correct paths in buildspec.yml for CodeBuild * fix: remove hardcoded JAVA_HOME, use runtime default * fix: add gradle wrapper for CI/CD build * fix: use single line sam package command with hardcoded bucket * fix: use existing stack name group2-englishstudy-chatting * fix: add missing WEBSOCKET_ENDPOINT env var to WebSocket connect functions * docs: update FRONTEND-API-GUIDE with new RoomType/RoomStatus structure * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix: update WebSocketDisconnectHandler to use GameSession model - Remove references to deleted ChatRoom game fields - Use GameSessionRepository to finish active game sessions - Use ChatRoomRepository.updateStatus() for room state management * perf: optimize CI/CD build time - Add pip cache for SAM CLI dependencies - Enable Gradle parallel builds and build cache - Add SAM build cache (.aws-sam) - Use sam build --parallel --cached - Skip SAM CLI install if already cached - Remove unnecessary 'clean' to leverage cache * feat: add custom CodeBuild Docker image with pre-installed tools - Dockerfile with Java 21 + SAM CLI + Gradle pre-installed - build-and-push.sh script for ECR deployment - Updated buildspec.yml for custom image (removes SAM CLI install) - Expected build time reduction: ~30-40 seconds * feature : AI 영어 회화 연습 기능 (#468) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix: remove typo in SpeakingConnectionRepository * fix : 오타 수정 * chore: trigger build test with custom Docker image * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * fix: add CORS headers to API Gateway error responses (#479) API Gateway의 인증 실패 등 에러 응답에 CORS 헤더가 누락되어 CloudFront를 통한 프론트엔드 요청이 차단되는 문제 수정 - UNAUTHORIZED (401) 응답에 CORS 헤더 추가 - ACCESS_DENIED (403) 응답에 CORS 헤더 추가 - DEFAULT_4XX/5XX 응답에 CORS 헤더 추가 - EXPIRED_TOKEN 응답에 CORS 헤더 추가 * feat : speaking rest API 람다 함수 추가 --------- Co-authored-by: ddingjoo * refactor : speaking service 재사용 * refactor : AI 영어 회화 연습 코드 리팩토링 * feature : test 벡엔드 서버에 AI 말하기 연습 기능 배포 (#492) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix : 오타 수정 * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * feat : speaking rest API 람다 함수 추가 * refactor : speaking service 재사용 * refactor : AI 영어 회화 연습 코드 리팩토링 --------- Co-authored-by: DDING JOO * feat : handleChat 메서드 JsonNull 체크 푸가 * feature : handleChat 메서드 JsonNull 체크 추가 (#493) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix : 오타 수정 * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * feat : speaking rest API 람다 함수 추가 * refactor : speaking service 재사용 * refactor : AI 영어 회화 연습 코드 리팩토링 * feat : handleChat 메서드 JsonNull 체크 푸가 --------- Co-authored-by: DDING JOO * feat(news): 뉴스 학습 배지 시스템 구현 (#473) - 14개 뉴스 관련 배지 추가 (읽기, 퀴즈, 단어수집, 연속학습, 마스터) - UserStats에 뉴스 통계 필드 추가 - 6개 뉴스 배지 Strategy 클래스 생성 - 뉴스 읽기/퀴즈/단어수집 시 통계 업데이트 및 배지 체크 연동 * fix: add PATCH method to CORS AllowMethods * test: BadgeType 개수 테스트 수정 (15 -> 29) * fix: CORS PATCH 메서드 추가 * docs: 뉴스 기능 프론트엔드 연동 가이드 작성 * fix: NewsCollectionFunction에 Bedrock, Comprehend 권한 추가 * fix: add null check for collectWord request body - Add INVALID_REQUEST error code to NewsErrorCode - Check body and word field before accessing in collectWord() - Prevents NullPointerException when request body is malformed * feat: enhance stats API and bookmark response for frontend - Add DAILY stats update for news read/quiz/word collection - Add /stats/dashboard endpoint with frontend-requested format - today: wordsLearned, newsRead, quizzesTaken, wordsTotal - overall: totalWordsLearned, totalNewsRead, averageAccuracy, streaks - weeklyProgress: last 7 days with date/wordsLearned/newsRead - Add news-related fields to all stats API responses - Fix bookmark API to include full article details (title, summary, etc.) * feat: add category classification to news AI analysis - Add category field to AnalysisResult record - Update Bedrock prompt to classify articles into categories (WORLD, POLITICS, BUSINESS, TECH, SCIENCE, HEALTH, SPORTS, ENTERTAINMENT, LIFESTYLE) - Parse and set category from AI response - Set GSI2 (CATEGORY#) index when category is available * fix: add /stats/dashboard endpoint to template.yaml * fix: filter by ARTICLE# prefix in findById to avoid returning UserNewsRecord * docs: add News API troubleshooting guide * feat: add Cognito authorizer to News API and enhance keyword extraction - Set CognitoAuthorizer for all News API endpoints in template.yaml - Update Bedrock AI to extract keywords with meanings and examples - Add fallback to Comprehend for keyword extraction when Bedrock fails - Modify KeywordInfo model to include example field - Adjust AI prompt to include keyword extraction with examples - Update AnalysisResult to store keywords and parse them from AI response * Revert "Merge branch 'test' into prod" This reverts commit c1a958eac6799d5838a76e4078ae304bcdb62278, reversing changes made to a6662e0cfc46c6e12f20a89f0df285ff282160bc. * feat: enhance bookmark and reading status tracking for News API - Add bookmark and reading status to individual article responses - Include bookmark status in paginated news responses - Modify `KeywordInfo` to support Korean translations (`meaningKo`) - Update AI prompts to extract Korean meanings for keywords * refactor : session_id가 null 체크 추가 * feat : template 환경변수 리펙토링 * fix : sessionId NullPointerException 에러 수정 (#496) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix : 오타 수정 * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * feat : speaking rest API 람다 함수 추가 * refactor : speaking service 재사용 * refactor : AI 영어 회화 연습 코드 리팩토링 * feat : handleChat 메서드 JsonNull 체크 푸가 * refactor : session_id가 null 체크 추가 * feat : template 환경변수 리펙토링 --------- Co-authored-by: DDING JOO * feat: enhance bookmark and reading status tracking for News API - Add bookmark and reading status to individual article responses - Include bookmark status in paginated news responses - Modify `KeywordInfo` to support Korean translations (`meaningKo`) - Update AI prompts to extract Korean meanings for keywords * feat: add category filtering to UserWord API with enhanced query logic - Introduce `category` filtering to `getUserWords` API - Update WordCategory enums to include "news" category - Apply category filter after enrichment with word info - Adjust query limits for bookmarked and incorrect words queries * feat: add default value for ExistingCognitoClientId in template.yaml - Set default to an empty string to ensure compatibility with templates using this parameter without explicitly specifying a value. * fix: SpeakingHandler getStringOrNull 컴파일 에러 수정 * fix: SpeakingHandler getStringOrNull 컴파일 에러 수정 * fix: Bedrock 키워드(meaningKo 포함)를 article에 저장하도록 수정 * fix: Bedrock 키워드(meaningKo 포함)를 article에 저장하도록 수정 * fix: Bedrock 키워드(meaningKo 포함)를 article에 저장하도록 수정 * feat: add dashboard stats API and enhance news stats tracking - Introduce `/stats/dashboard` API for retrieving integrated user stats (today, overall, weekly progress, and level distribution) - Update `UserStats` model to include additional fields for news stats (newsRead, newsQuizCompleted, newsQuizPerfect, newsWordsCollected, etc.) - Add atomic updates to track news reading, quiz, and word stats in `UserStatsRepository` - Modify CloudFormation `template.yaml` to register the new `/stats/dashboard` endpoint * feat: add dashboard stats API and enhance news stats tracking - Introduce `/stats/dashboard` API for retrieving integrated user stats (today, overall, weekly progress, and level distribution) - Update `UserStats` model to include additional fields for news stats (newsRead, newsQuizCompleted, newsQuizPerfect, newsWordsCollected, etc.) - Add atomic updates to track news reading, quiz, and word stats in `UserStatsRepository` - Modify CloudFormation `template.yaml` to register the new `/stats/dashboard` endpoint * feat: add dashboard stats API and enhance news stats tracking - Introduce `/stats/dashboard` API for retrieving integrated user stats (today, overall, weekly progress, and level distribution) - Update `UserStats` model to include additional fields for news stats (newsRead, newsQuizCompleted, newsQuizPerfect, newsWordsCollected, etc.) - Add atomic updates to track news reading, quiz, and word stats in `UserStatsRepository` - Modify CloudFormation `template.yaml` to register the new `/stats/dashboard` endpoint * refactor: format code with consistent indentation and spacing - Apply consistent formatting to improve code readability across multiple files - Adjust indentation, spacing, and alignment in model classes, DTOs, and repository methods * fix: filter by ARTICLE# prefix in findById to avoid returning bookmark records * feat : Speaking 관련 template 람다 함수 및 테이블 추가 * feat : 말하기 기능에 polly 서비스 권한 추가 * feat : transcribe API KEY 추가 --------- Co-authored-by: DDING JOO --- ServerlessFunction/template.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index c0a7be9b..857336c8 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -1566,11 +1566,19 @@ Resources: Description: Handle speaking chat API SnapStart: ApplyOn: PublishedVersions + Environment: + Variables: + TRANSCRIBE_API_KEY: "/opic/transcribe-proxy-api-key" Policies: - DynamoDBCrudPolicy: TableName: !Ref SpeakingTable - S3CrudPolicy: BucketName: !Sub "${AWS::StackName}" + - Statement: + - Effect: Allow + Action: + - ssm:GetParameter + Resource: !Sub "arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/opic/*" - Statement: - Effect: Allow Action: From 1de4def0aa4ee61b100cbf5fcb2326b191e7840e Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Sat, 24 Jan 2026 11:33:09 +0900 Subject: [PATCH 516/528] =?UTF-8?q?feat:=20=EC=B1=84=ED=8C=85=20=EC=8A=AC?= =?UTF-8?q?=EB=9E=98=EC=8B=9C=20=EB=AA=85=EB=A0=B9=EC=96=B4=20=EC=8B=9C?= =?UTF-8?q?=EC=8A=A4=ED=85=9C=20=EA=B3=A0=EB=8F=84=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 게임 관련 명령어 제거 (/start, /stop, /score, /skip, /hint) - 기본 명령어 추가: /help, /members, /leave, /clear - 재미 명령어 추가: /dice, /coin, /random - 투표 시스템 구현: /poll, /vote, /endpoll - Poll 모델 및 PollRepository 추가 - MessageType에 POLL_CREATE, POLL_VOTE, POLL_END 추가 Closes #518, #519, #520 --- .../domain/chatting/enums/MessageType.java | 11 +- .../domain/chatting/model/Poll.java | 80 +++ .../chatting/repository/PollRepository.java | 70 +++ .../chatting/service/CommandService.java | 508 ++++++++++++++---- .../domain/chatting/model/PollSpec.groovy | 148 +++++ 5 files changed, 718 insertions(+), 99 deletions(-) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/Poll.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/PollRepository.java create mode 100644 ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/model/PollSpec.groovy diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/MessageType.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/MessageType.java index b8a7d453..fddc60b7 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/MessageType.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/MessageType.java @@ -22,7 +22,16 @@ public enum MessageType { // 방 관련 메시지 타입 ROOM_STATUS_CHANGE("room_status_change", "방 상태 변경"), - HOST_CHANGE("host_change", "방장 변경"); + HOST_CHANGE("host_change", "방장 변경"), + + // 투표 관련 메시지 타입 + POLL_CREATE("poll_create", "투표 생성"), + POLL_VOTE("poll_vote", "투표 참여"), + POLL_END("poll_end", "투표 종료"), + + // 유틸리티 메시지 타입 + CLEAR_CHAT("clear_chat", "채팅 삭제"), + LEAVE_ROOM("leave_room", "채팅방 나가기"); private final String code; private final String displayName; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/Poll.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/Poll.java new file mode 100644 index 00000000..0c8eced9 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/Poll.java @@ -0,0 +1,80 @@ +package com.mzc.secondproject.serverless.domain.chatting.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.*; + +import java.util.List; +import java.util.Map; + +/** + * 채팅방 투표 모델 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DynamoDbBean +public class Poll { + + private String pk; // ROOM#{roomId} + private String sk; // POLL#{pollId} + + private String pollId; + private String roomId; + private String question; + private List options; + private Map votes; // optionIndex -> count + private Map userVotes; // userId -> optionIndex + private String createdBy; + private String createdAt; + private Boolean isActive; + private Long ttl; + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { + return pk; + } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { + return sk; + } + + /** + * 투표 추가 + */ + public boolean addVote(String userId, int optionIndex) { + if (optionIndex < 0 || optionIndex >= options.size()) { + return false; + } + + // 이미 투표했는지 확인 + if (userVotes.containsKey(userId)) { + return false; + } + + userVotes.put(userId, optionIndex); + votes.merge(String.valueOf(optionIndex), 1, Integer::sum); + return true; + } + + /** + * 사용자가 이미 투표했는지 확인 + */ + public boolean hasVoted(String userId) { + return userVotes != null && userVotes.containsKey(userId); + } + + /** + * 총 투표 수 + */ + public int getTotalVotes() { + if (votes == null) return 0; + return votes.values().stream().mapToInt(Integer::intValue).sum(); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/PollRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/PollRepository.java new file mode 100644 index 00000000..f49a6908 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/PollRepository.java @@ -0,0 +1,70 @@ +package com.mzc.secondproject.serverless.domain.chatting.repository; + +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.config.EnvConfig; +import com.mzc.secondproject.serverless.domain.chatting.model.Poll; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; +import software.amazon.awssdk.enhanced.dynamodb.Key; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; + +import java.util.Optional; + +/** + * Poll Repository + */ +public class PollRepository { + + private static final Logger logger = LoggerFactory.getLogger(PollRepository.class); + private static final String TABLE_NAME = EnvConfig.getRequired("CHAT_TABLE_NAME"); + + private final DynamoDbTable table; + + public PollRepository() { + this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(Poll.class)); + } + + public PollRepository(DynamoDbTable table) { + this.table = table; + } + + public void save(Poll poll) { + table.putItem(poll); + logger.debug("Saved poll: {}", poll.getPollId()); + } + + public Optional findById(String roomId, String pollId) { + Key key = Key.builder() + .partitionValue("ROOM#" + roomId) + .sortValue("POLL#" + pollId) + .build(); + Poll poll = table.getItem(key); + return Optional.ofNullable(poll); + } + + /** + * 방의 활성 투표 조회 + */ + public Optional findActiveByRoomId(String roomId) { + return table.query(QueryConditional.sortBeginsWith( + Key.builder() + .partitionValue("ROOM#" + roomId) + .sortValue("POLL#") + .build())) + .items() + .stream() + .filter(poll -> Boolean.TRUE.equals(poll.getIsActive())) + .findFirst(); + } + + public void delete(String roomId, String pollId) { + Key key = Key.builder() + .partitionValue("ROOM#" + roomId) + .sortValue("POLL#" + pollId) + .build(); + table.deleteItem(key); + logger.debug("Deleted poll: {}", pollId); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/CommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/CommandService.java index 71e0ddf2..89d6e098 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/CommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/CommandService.java @@ -3,44 +3,49 @@ import com.mzc.secondproject.serverless.domain.chatting.dto.response.CommandResult; import com.mzc.secondproject.serverless.domain.chatting.enums.MessageType; import com.mzc.secondproject.serverless.domain.chatting.model.Connection; -import com.mzc.secondproject.serverless.domain.chatting.model.GameSession; +import com.mzc.secondproject.serverless.domain.chatting.model.Poll; import com.mzc.secondproject.serverless.domain.chatting.repository.ConnectionRepository; -import com.mzc.secondproject.serverless.domain.chatting.repository.GameSessionRepository; +import com.mzc.secondproject.serverless.domain.chatting.repository.PollRepository; +import com.mzc.secondproject.serverless.domain.user.model.User; +import com.mzc.secondproject.serverless.domain.user.repository.UserRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.util.List; -import java.util.Optional; +import java.time.Instant; +import java.util.*; +import java.util.stream.Collectors; /** * 슬래시 명령어 처리 서비스 */ public class CommandService { - + private static final Logger logger = LoggerFactory.getLogger(CommandService.class); - + private final ConnectionRepository connectionRepository; - private final GameSessionRepository gameSessionRepository; - private final GameService gameService; - + private final PollRepository pollRepository; + private final UserRepository userRepository; + private final Random random; + /** * 기본 생성자 (Lambda에서 사용) */ public CommandService() { - this(new ConnectionRepository(), new GameSessionRepository(), new GameService()); + this(new ConnectionRepository(), new PollRepository(), new UserRepository()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ public CommandService(ConnectionRepository connectionRepository, - GameSessionRepository gameSessionRepository, - GameService gameService) { + PollRepository pollRepository, + UserRepository userRepository) { this.connectionRepository = connectionRepository; - this.gameSessionRepository = gameSessionRepository; - this.gameService = gameService; + this.pollRepository = pollRepository; + this.userRepository = userRepository; + this.random = new Random(); } - + /** * 명령어 처리 * @@ -53,119 +58,426 @@ public Optional processCommand(String content, String roomId, Str if (content == null || !content.startsWith("/")) { return Optional.empty(); } - + String[] parts = content.trim().split("\\s+", 2); String command = parts[0].toLowerCase(); - + String args = parts.length > 1 ? parts[1] : ""; + logger.info("Processing command: {} from user: {} in room: {}", command, userId, roomId); - + return switch (command) { - case "/member", "/members" -> Optional.of(handleMemberCommand(roomId)); - case "/start" -> Optional.of(handleStartCommand(roomId, userId)); - case "/stop" -> Optional.of(handleStopCommand(roomId, userId)); - case "/score" -> Optional.of(handleScoreCommand(roomId)); - case "/skip" -> Optional.of(handleSkipCommand(roomId, userId)); - case "/hint" -> Optional.of(handleHintCommand(roomId, userId)); + // 기본 명령어 case "/help" -> Optional.of(handleHelpCommand()); + case "/member", "/members" -> Optional.of(handleMembersCommand(roomId)); + case "/leave" -> Optional.of(handleLeaveCommand(roomId, userId)); + case "/clear" -> Optional.of(handleClearCommand(roomId, userId)); + + // 재미 명령어 + case "/dice" -> Optional.of(handleDiceCommand(roomId, userId)); + case "/coin" -> Optional.of(handleCoinCommand(roomId, userId)); + case "/random" -> Optional.of(handleRandomCommand(roomId, userId, args)); + + // 투표 명령어 + case "/poll" -> Optional.of(handlePollCommand(roomId, userId, args)); + case "/vote" -> Optional.of(handleVoteCommand(roomId, userId, args)); + case "/endpoll" -> Optional.of(handleEndPollCommand(roomId, userId)); + default -> Optional.empty(); }; } - + + // ========== 기본 명령어 ========== + + /** + * /help - 도움말 + */ + private CommandResult handleHelpCommand() { + String helpMessage = """ + 📖 사용 가능한 명령어: + + [기본] + /members - 현재 접속자 목록 + /leave - 채팅방 나가기 + /clear - 내 채팅 내역 삭제 + + [재미] + /dice - 주사위 굴리기 (1-6) + /coin - 동전 던지기 + /random [옵션1] [옵션2] ... - 랜덤 선택 + + [투표] + /poll [질문] | [옵션1] | [옵션2] | ... - 투표 생성 + /vote [번호] - 투표하기 + /endpoll - 투표 종료 (생성자만) + """; + return CommandResult.success(MessageType.SYSTEM_COMMAND, helpMessage); + } + /** - * /member - 현재 접속자 수 조회 + * /members - 접속자 목록 */ - private CommandResult handleMemberCommand(String roomId) { + private CommandResult handleMembersCommand(String roomId) { List connections = connectionRepository.findByRoomId(roomId); - + if (connections.isEmpty()) { return CommandResult.success(MessageType.SYSTEM_COMMAND, "현재 접속자가 없습니다."); } - - String message = String.format("현재 접속자: %d명", connections.size()); - return CommandResult.success(MessageType.SYSTEM_COMMAND, message, connections.size()); + + // 닉네임 조회 + StringBuilder sb = new StringBuilder(); + sb.append(String.format("👥 현재 접속자: %d명\n", connections.size())); + + for (Connection conn : connections) { + String nickname = userRepository.findByCognitoSub(conn.getUserId()) + .map(User::getNickname) + .orElse(conn.getUserId()); + sb.append(String.format(" • %s\n", nickname)); + } + + Map data = new HashMap<>(); + data.put("count", connections.size()); + data.put("members", connections.stream() + .map(c -> { + Map member = new HashMap<>(); + member.put("userId", c.getUserId()); + member.put("nickname", userRepository.findByCognitoSub(c.getUserId()) + .map(User::getNickname).orElse(c.getUserId())); + return member; + }) + .collect(Collectors.toList())); + + return CommandResult.success(MessageType.SYSTEM_COMMAND, sb.toString(), data); } - + /** - * /start - 게임 시작 + * /leave - 채팅방 나가기 */ - private CommandResult handleStartCommand(String roomId, String userId) { - GameService.GameStartResult result = gameService.startGame(roomId, userId); - - if (!result.success()) { - return CommandResult.error(result.error()); - } - - String message = String.format(""" - 🎮 게임 시작! - 총 %d 라운드 - - 라운드 1 시작! - 출제자: %s - """, - result.session().getTotalRounds(), - result.session().getCurrentDrawerId()); - - return CommandResult.success(MessageType.GAME_START, message, result); + private CommandResult handleLeaveCommand(String roomId, String userId) { + String nickname = userRepository.findByCognitoSub(userId) + .map(User::getNickname) + .orElse(userId); + + Map data = new HashMap<>(); + data.put("userId", userId); + data.put("nickname", nickname); + data.put("action", "leave"); + + return CommandResult.success(MessageType.LEAVE_ROOM, + String.format("👋 %s님이 퇴장합니다.", nickname), data); } - + /** - * /stop - 게임 중단 + * /clear - 내 채팅 내역 삭제 */ - private CommandResult handleStopCommand(String roomId, String userId) { - return gameService.stopGame(roomId, userId); + private CommandResult handleClearCommand(String roomId, String userId) { + Map data = new HashMap<>(); + data.put("userId", userId); + data.put("action", "clear"); + + return CommandResult.success(MessageType.CLEAR_CHAT, + "🗑️ 채팅 내역 삭제를 요청했습니다.", data); } - + + // ========== 재미 명령어 ========== + /** - * /score - 현재 점수 조회 + * /dice - 주사위 굴리기 */ - private CommandResult handleScoreCommand(String roomId) { - Optional optSession = gameSessionRepository.findActiveByRoomId(roomId); - if (optSession.isEmpty()) { - return CommandResult.error("진행 중인 게임이 없습니다."); - } - - GameSession session = optSession.get(); - - if (session.getScores() == null || session.getScores().isEmpty()) { - return CommandResult.success(MessageType.SCORE_UPDATE, "아직 점수가 없습니다."); - } - - StringBuilder sb = new StringBuilder("📊 현재 점수:\n"); - session.getScores().entrySet().stream() - .sorted((a, b) -> b.getValue().compareTo(a.getValue())) - .forEach(entry -> sb.append(String.format(" %s: %d점\n", entry.getKey(), entry.getValue()))); - - return CommandResult.success(MessageType.SCORE_UPDATE, sb.toString(), session.getScores()); + private CommandResult handleDiceCommand(String roomId, String userId) { + int result = random.nextInt(6) + 1; + + String nickname = userRepository.findByCognitoSub(userId) + .map(User::getNickname) + .orElse(userId); + + String emoji = switch (result) { + case 1 -> "⚀"; + case 2 -> "⚁"; + case 3 -> "⚂"; + case 4 -> "⚃"; + case 5 -> "⚄"; + case 6 -> "⚅"; + default -> "🎲"; + }; + + Map data = new HashMap<>(); + data.put("userId", userId); + data.put("nickname", nickname); + data.put("result", result); + data.put("type", "dice"); + + return CommandResult.success(MessageType.SYSTEM_COMMAND, + String.format("🎲 %s님이 주사위를 굴렸습니다: %s %d", nickname, emoji, result), data); } - + /** - * /skip - 라운드 스킵 (출제자만) + * /coin - 동전 던지기 */ - private CommandResult handleSkipCommand(String roomId, String userId) { - return gameService.skipRound(roomId, userId); + private CommandResult handleCoinCommand(String roomId, String userId) { + boolean isHeads = random.nextBoolean(); + String result = isHeads ? "앞면 (Heads)" : "뒷면 (Tails)"; + String emoji = isHeads ? "🪙" : "💿"; + + String nickname = userRepository.findByCognitoSub(userId) + .map(User::getNickname) + .orElse(userId); + + Map data = new HashMap<>(); + data.put("userId", userId); + data.put("nickname", nickname); + data.put("result", isHeads ? "heads" : "tails"); + data.put("type", "coin"); + + return CommandResult.success(MessageType.SYSTEM_COMMAND, + String.format("%s %s님이 동전을 던졌습니다: %s", emoji, nickname, result), data); } - + /** - * /hint - 힌트 제공 (출제자만) + * /random [옵션1] [옵션2] ... - 랜덤 선택 */ - private CommandResult handleHintCommand(String roomId, String userId) { - return gameService.provideHint(roomId, userId); + private CommandResult handleRandomCommand(String roomId, String userId, String args) { + if (args.isBlank()) { + return CommandResult.error("사용법: /random [옵션1] [옵션2] [옵션3] ..."); + } + + String[] options = args.split("\\s+"); + if (options.length < 2) { + return CommandResult.error("최소 2개 이상의 옵션이 필요합니다."); + } + + String selected = options[random.nextInt(options.length)]; + + String nickname = userRepository.findByCognitoSub(userId) + .map(User::getNickname) + .orElse(userId); + + Map data = new HashMap<>(); + data.put("userId", userId); + data.put("nickname", nickname); + data.put("options", Arrays.asList(options)); + data.put("selected", selected); + data.put("type", "random"); + + return CommandResult.success(MessageType.SYSTEM_COMMAND, + String.format("🎯 %s님의 랜덤 선택: %s\n(후보: %s)", + nickname, selected, String.join(", ", options)), data); } - + + // ========== 투표 명령어 ========== + /** - * /help - 도움말 + * /poll [질문] | [옵션1] | [옵션2] | ... - 투표 생성 */ - private CommandResult handleHelpCommand() { - String helpMessage = """ - 📖 사용 가능한 명령어: - /member - 현재 접속자 수 - /start - 게임 시작 (2명 이상) - /stop - 게임 중단 - /score - 현재 점수 보기 - /skip - 라운드 스킵 (출제자) - /hint - 힌트 보기 (출제자) - /help - 도움말 - """; - return CommandResult.success(MessageType.SYSTEM_COMMAND, helpMessage); + private CommandResult handlePollCommand(String roomId, String userId, String args) { + // 이미 진행 중인 투표가 있는지 확인 + Optional activePoll = pollRepository.findActiveByRoomId(roomId); + if (activePoll.isPresent()) { + return CommandResult.error("이미 진행 중인 투표가 있습니다. /endpoll로 종료 후 새 투표를 만드세요."); + } + + if (args.isBlank()) { + return CommandResult.error("사용법: /poll [질문] | [옵션1] | [옵션2] | ..."); + } + + String[] parts = args.split("\\|"); + if (parts.length < 3) { + return CommandResult.error("질문과 최소 2개의 옵션이 필요합니다. (구분자: |)"); + } + + String question = parts[0].trim(); + List options = new ArrayList<>(); + for (int i = 1; i < parts.length; i++) { + String option = parts[i].trim(); + if (!option.isEmpty()) { + options.add(option); + } + } + + if (options.size() < 2) { + return CommandResult.error("최소 2개의 옵션이 필요합니다."); + } + + if (options.size() > 10) { + return CommandResult.error("옵션은 최대 10개까지 가능합니다."); + } + + // 투표 생성 + String pollId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + long ttl = Instant.now().plusSeconds(24 * 60 * 60).getEpochSecond(); // 24시간 + + Map votes = new HashMap<>(); + for (int i = 0; i < options.size(); i++) { + votes.put(String.valueOf(i), 0); + } + + Poll poll = Poll.builder() + .pk("ROOM#" + roomId) + .sk("POLL#" + pollId) + .pollId(pollId) + .roomId(roomId) + .question(question) + .options(options) + .votes(votes) + .userVotes(new HashMap<>()) + .createdBy(userId) + .createdAt(now) + .isActive(true) + .ttl(ttl) + .build(); + + pollRepository.save(poll); + + String nickname = userRepository.findByCognitoSub(userId) + .map(User::getNickname) + .orElse(userId); + + StringBuilder sb = new StringBuilder(); + sb.append(String.format("📊 %s님이 투표를 시작했습니다!\n\n", nickname)); + sb.append(String.format("❓ %s\n\n", question)); + for (int i = 0; i < options.size(); i++) { + sb.append(String.format(" %d. %s\n", i + 1, options.get(i))); + } + sb.append("\n💬 /vote [번호]로 투표하세요!"); + + Map data = new HashMap<>(); + data.put("pollId", pollId); + data.put("question", question); + data.put("options", options); + data.put("createdBy", userId); + data.put("creatorNickname", nickname); + + logger.info("Poll created: pollId={}, roomId={}, question={}", pollId, roomId, question); + + return CommandResult.success(MessageType.POLL_CREATE, sb.toString(), data); + } + + /** + * /vote [번호] - 투표하기 + */ + private CommandResult handleVoteCommand(String roomId, String userId, String args) { + Optional optPoll = pollRepository.findActiveByRoomId(roomId); + if (optPoll.isEmpty()) { + return CommandResult.error("진행 중인 투표가 없습니다."); + } + + Poll poll = optPoll.get(); + + if (poll.hasVoted(userId)) { + return CommandResult.error("이미 투표하셨습니다."); + } + + int optionIndex; + try { + optionIndex = Integer.parseInt(args.trim()) - 1; // 1-based to 0-based + } catch (NumberFormatException e) { + return CommandResult.error("사용법: /vote [번호] (예: /vote 1)"); + } + + if (optionIndex < 0 || optionIndex >= poll.getOptions().size()) { + return CommandResult.error(String.format("1~%d 사이의 번호를 입력하세요.", poll.getOptions().size())); + } + + // 투표 추가 + poll.addVote(userId, optionIndex); + pollRepository.save(poll); + + String nickname = userRepository.findByCognitoSub(userId) + .map(User::getNickname) + .orElse(userId); + + String selectedOption = poll.getOptions().get(optionIndex); + + // 현재 투표 현황 생성 + StringBuilder sb = new StringBuilder(); + sb.append(String.format("✅ %s님이 '%s'에 투표했습니다!\n\n", nickname, selectedOption)); + sb.append(String.format("📊 현재 현황 (총 %d표):\n", poll.getTotalVotes())); + for (int i = 0; i < poll.getOptions().size(); i++) { + int voteCount = poll.getVotes().getOrDefault(String.valueOf(i), 0); + String bar = "█".repeat(Math.min(voteCount, 10)); + sb.append(String.format(" %d. %s: %s %d표\n", + i + 1, poll.getOptions().get(i), bar, voteCount)); + } + + Map data = new HashMap<>(); + data.put("pollId", poll.getPollId()); + data.put("voterId", userId); + data.put("voterNickname", nickname); + data.put("selectedOption", optionIndex); + data.put("selectedOptionText", selectedOption); + data.put("votes", poll.getVotes()); + data.put("totalVotes", poll.getTotalVotes()); + + logger.info("Vote recorded: pollId={}, userId={}, option={}", poll.getPollId(), userId, optionIndex); + + return CommandResult.success(MessageType.POLL_VOTE, sb.toString(), data); + } + + /** + * /endpoll - 투표 종료 + */ + private CommandResult handleEndPollCommand(String roomId, String userId) { + Optional optPoll = pollRepository.findActiveByRoomId(roomId); + if (optPoll.isEmpty()) { + return CommandResult.error("진행 중인 투표가 없습니다."); + } + + Poll poll = optPoll.get(); + + if (!poll.getCreatedBy().equals(userId)) { + return CommandResult.error("투표 생성자만 종료할 수 있습니다."); + } + + poll.setIsActive(false); + pollRepository.save(poll); + + // 최종 결과 계산 + int maxVotes = 0; + List winners = new ArrayList<>(); + for (int i = 0; i < poll.getOptions().size(); i++) { + int voteCount = poll.getVotes().getOrDefault(String.valueOf(i), 0); + if (voteCount > maxVotes) { + maxVotes = voteCount; + winners.clear(); + winners.add(poll.getOptions().get(i)); + } else if (voteCount == maxVotes && voteCount > 0) { + winners.add(poll.getOptions().get(i)); + } + } + + String nickname = userRepository.findByCognitoSub(userId) + .map(User::getNickname) + .orElse(userId); + + StringBuilder sb = new StringBuilder(); + sb.append(String.format("🏁 %s님이 투표를 종료했습니다!\n\n", nickname)); + sb.append(String.format("❓ %s\n\n", poll.getQuestion())); + sb.append(String.format("📊 최종 결과 (총 %d표):\n", poll.getTotalVotes())); + + for (int i = 0; i < poll.getOptions().size(); i++) { + int voteCount = poll.getVotes().getOrDefault(String.valueOf(i), 0); + String bar = "█".repeat(Math.min(voteCount, 10)); + String medal = (voteCount == maxVotes && maxVotes > 0) ? "🏆 " : " "; + sb.append(String.format("%s%d. %s: %s %d표\n", + medal, i + 1, poll.getOptions().get(i), bar, voteCount)); + } + + if (!winners.isEmpty()) { + sb.append(String.format("\n🎉 우승: %s", String.join(", ", winners))); + } else { + sb.append("\n투표가 없습니다."); + } + + Map data = new HashMap<>(); + data.put("pollId", poll.getPollId()); + data.put("question", poll.getQuestion()); + data.put("options", poll.getOptions()); + data.put("votes", poll.getVotes()); + data.put("totalVotes", poll.getTotalVotes()); + data.put("winners", winners); + + logger.info("Poll ended: pollId={}, totalVotes={}", poll.getPollId(), poll.getTotalVotes()); + + return CommandResult.success(MessageType.POLL_END, sb.toString(), data); } } diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/model/PollSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/model/PollSpec.groovy new file mode 100644 index 00000000..cee47562 --- /dev/null +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/model/PollSpec.groovy @@ -0,0 +1,148 @@ +package com.mzc.secondproject.serverless.domain.chatting.model + +import spock.lang.Specification + +class PollSpec extends Specification { + + def "addVote: 정상적인 투표 추가"() { + given: + def poll = Poll.builder() + .pollId("poll-123") + .options(["옵션1", "옵션2", "옵션3"]) + .votes(["0": 0, "1": 0, "2": 0]) + .userVotes([:]) + .build() + + when: + def result = poll.addVote("user1", 0) + + then: + result == true + poll.votes["0"] == 1 + poll.userVotes["user1"] == 0 + } + + def "addVote: 이미 투표한 사용자는 재투표 불가"() { + given: + def poll = Poll.builder() + .options(["옵션1", "옵션2"]) + .votes(["0": 1, "1": 0]) + .userVotes(["user1": 0]) + .build() + + when: + def result = poll.addVote("user1", 1) + + then: + result == false + poll.votes["0"] == 1 + poll.votes["1"] == 0 + } + + def "addVote: 유효하지 않은 옵션 인덱스"() { + given: + def poll = Poll.builder() + .options(["옵션1", "옵션2"]) + .votes(["0": 0, "1": 0]) + .userVotes([:]) + .build() + + when: + def result = poll.addVote("user1", 5) + + then: + result == false + } + + def "addVote: 음수 옵션 인덱스"() { + given: + def poll = Poll.builder() + .options(["옵션1", "옵션2"]) + .votes(["0": 0, "1": 0]) + .userVotes([:]) + .build() + + when: + def result = poll.addVote("user1", -1) + + then: + result == false + } + + def "hasVoted: 투표한 사용자 확인"() { + given: + def poll = Poll.builder() + .userVotes(["user1": 0]) + .build() + + expect: + poll.hasVoted("user1") == true + poll.hasVoted("user2") == false + } + + def "hasVoted: userVotes가 null인 경우"() { + given: + def poll = Poll.builder() + .userVotes(null) + .build() + + expect: + poll.hasVoted("user1") == false + } + + def "getTotalVotes: 총 투표 수 계산"() { + given: + def poll = Poll.builder() + .votes(["0": 3, "1": 2, "2": 5]) + .build() + + expect: + poll.getTotalVotes() == 10 + } + + def "getTotalVotes: 투표가 없는 경우"() { + given: + def poll = Poll.builder() + .votes(["0": 0, "1": 0]) + .build() + + expect: + poll.getTotalVotes() == 0 + } + + def "getTotalVotes: votes가 null인 경우"() { + given: + def poll = Poll.builder() + .votes(null) + .build() + + expect: + poll.getTotalVotes() == 0 + } + + def "여러 사용자 투표 시나리오"() { + given: + def poll = Poll.builder() + .options(["A", "B", "C"]) + .votes(["0": 0, "1": 0, "2": 0]) + .userVotes([:]) + .build() + + when: + poll.addVote("user1", 0) + poll.addVote("user2", 0) + poll.addVote("user3", 1) + poll.addVote("user4", 2) + + then: + poll.votes["0"] == 2 + poll.votes["1"] == 1 + poll.votes["2"] == 1 + poll.getTotalVotes() == 4 + poll.hasVoted("user1") + poll.hasVoted("user2") + poll.hasVoted("user3") + poll.hasVoted("user4") + !poll.hasVoted("user5") + } +} From 248c6ba9ec7cae3a0ed088313463051a782a603c Mon Sep 17 00:00:00 2001 From: hyein Heo <128613248+hye-inA@users.noreply.github.com> Date: Sat, 24 Jan 2026 12:07:36 +0900 Subject: [PATCH 517/528] =?UTF-8?q?feature=20:=20Test=20=EB=B8=8C=EB=9E=9C?= =?UTF-8?q?=EC=B9=98=20=EC=BD=94=EB=93=9C=20Prod=20=EB=B8=8C=EB=9E=9C?= =?UTF-8?q?=EC=B9=98=20=EB=B3=91=ED=95=A9=20=20(#529)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: SpeakingHandler getStringOrNull 컴파일 에러 수정 * fix: Bedrock 키워드(meaningKo 포함)를 article에 저장하도록 수정 * feat: add real-time notification system with SNS/SQS and Lambda Streaming - Add NotificationTopic and NotificationQueue SNS/SQS infrastructure - Implement NotificationPublisher service for publishing notifications - Create NotificationStreamHandler for SSE via Lambda Function URL - Integrate badge earned notifications in BadgeService - Add daily study completion notifications in DailyStudyCommandService - Add test/quiz result notifications in TestCommandService and NewsQuizService - Add SQS client to AwsClients and JsonUtil helper methods Closes #500, #501, #502, #505, #506 * feat: add streak reminder and game end notifications * refactor: extract config classes and apply DRY principle to news/notification domains * test: add Spock specs for notification and news domain configs * feature : Speaking Table & Function template.yaml 파일에 추가 (#513) * fix: BadgeRepository 클라이언트 초기화 패턴 통일 - 개별 DynamoDbEnhancedClient 생성 대신 AwsClients.dynamoDbEnhanced() 싱글톤 사용 - 다른 Repository들과 동일한 패턴 적용 - 불필요한 import 제거 Closes #396 * feat: EnvConfig 유틸리티 추가 및 환경 변수 검증 적용 환경 변수 미설정 시 명확한 에러 메시지를 제공하는 EnvConfig 유틸리티를 추가하고, 기존 System.getenv 호출을 EnvConfig.getRequired/getOrDefault로 대체함. - EnvConfig: getRequired, getOrDefault, getIntOrDefault, getLongOrDefault 메서드 제공 - Lambda Cold Start 시점에 환경 변수 누락을 조기 감지 - 기존 Config 클래스(WebSocketConfig, RoomTokenConfig) EnvConfig 사용으로 통일 Closes #403 * refactor: TestService submitTest 메서드 책임 분리 submitTest 메서드를 단일 책임 원칙에 맞게 리팩토링: - gradeAnswers(): 답안 채점 및 결과 집계 - isAnswerCorrect(): 단일 답안 정답 여부 판단 - buildResultItem(): 결과 항목 생성 - saveTestResult(): 테스트 결과 저장 - GradingResult record: 채점 결과 캡슐화 TestService와 TestCommandService 모두 동일하게 적용 Closes #404 * refactor: 하드코딩된 설정값 환경 변수로 외부화 각 도메인별 Config 클래스를 생성하여 하드코딩된 값들을 환경 변수로 설정 가능하게 변경. 기본값이 있어 환경 변수 미설정 시에도 기존 동작 유지. ## 새로 추가된 Config 클래스 - GrammarConfig: SESSION_TTL_DAYS, MAX_HISTORY_MESSAGES, MAX_TOKENS 등 - GameConfig: TOTAL_ROUNDS, ROUND_TIME_LIMIT, QUICK_GUESS_THRESHOLD_MS - VocabularyConfig: NEW_WORDS_COUNT, REVIEW_WORDS_COUNT, 상태 전이 임계값 등 ## 지원하는 환경 변수 - GRAMMAR_SESSION_TTL_DAYS, GRAMMAR_MAX_HISTORY_MESSAGES, GRAMMAR_MAX_TOKENS - GAME_TOTAL_ROUNDS, GAME_ROUND_TIME_LIMIT, GAME_QUICK_GUESS_THRESHOLD_MS - VOCAB_NEW_WORDS_COUNT, VOCAB_REVIEW_WORDS_COUNT - VOCAB_TRANSITION_TO_REVIEWING, VOCAB_TRANSITION_TO_MASTERED Closes #406 * test: StudyLevel enum 단위 테스트 추가 * test: Difficulty enum 단위 테스트 추가 * test: StudyConfig 단위 테스트 추가 * test: EnvConfig 단위 테스트 추가 * test: PaginatedResult 단위 테스트 추가 * test: JsonUtil 단위 테스트 추가 * test: CursorUtil 단위 테스트 추가 * test: CommonErrorCode 단위 테스트 추가 * test: CommonException 단위 테스트 추가 * test: BadgeType enum 단위 테스트 추가 * test: BadgeKey 상수 단위 테스트 추가 * test: WordStatus enum 단위 테스트 추가 * test: TestType enum 단위 테스트 추가 * test: VocabularyConfig 단위 테스트 추가 * test: VocabKey 상수 단위 테스트 추가 * test: VocabularyErrorCode 단위 테스트 추가 * test: VocabularyException 단위 테스트 추가 * test: SpacedRepetitionContext 단위 테스트 추가 * test: GrammarLevel enum 단위 테스트 추가 * test: GrammarConfig 단위 테스트 추가 * test: GrammarErrorCode 단위 테스트 추가 * test: GrammarException 단위 테스트 추가 * test: GameStatus enum 단위 테스트 추가 * test: GameConfig 단위 테스트 추가 * test: ChattingErrorCode 단위 테스트 추가 * test: ChattingException 단위 테스트 추가 * fix: OPIc FeedbackResponse.java 문법 오류 수정 * style: AwsClients 코드 포맷팅 * style: EnvConfig 코드 포맷팅 * style: RoomTokenConfig 코드 포맷팅 * style: WebSocketConfig 코드 포맷팅 * style: JsonUtil 코드 포맷팅 * style: GameConfig 코드 포맷팅 * style: GrammarConfig 코드 포맷팅 * style: GrammarKey 코드 포맷팅 * style: GrammarErrorCode 코드 포맷팅 * style: GrammarException 코드 포맷팅 * style: BedrockGrammarCheckFactory 코드 포맷팅 * style: GrammarConversationService 코드 포맷팅 * style: FeedbackResponse 코드 포맷팅 * style: SessionReportResponse 코드 포맷팅 * style: SpeakingError 코드 포맷팅 * style: SpeakingErrorType 코드 포맷팅 * style: OPIcException 코드 포맷팅 * style: OPIcAnswer 코드 포맷팅 * style: OPIcQuestion 코드 포맷팅 * style: OPIcSession 코드 포맷팅 * style: OPIcRepository 코드 포맷팅 * style: FeedbackService 코드 포맷팅 * style: TranscribeProxyService 코드 포맷팅 * style: VocabularyConfig 코드 포맷팅 * style: DailyStudyCommandService 코드 포맷팅 * style: TestCommandService 코드 포맷팅 * style: TestService 코드 포맷팅 * style: CommonErrorCodeSpec 코드 포맷팅 * style: JsonUtilSpec 코드 포맷팅 * style: BadgeTypeSpec 코드 포맷팅 * style: ChattingErrorCodeSpec 코드 포맷팅 * style: GrammarLevelSpec 코드 포맷팅 * style: GrammarErrorCodeSpec 코드 포맷팅 * style: VocabularyErrorCodeSpec 코드 포맷팅 * feature : OPIc 세션관리 + 답변 처리 파이프라인 구현 (#413) * feat : OPIc 질문 & 세션 관련 dto 생성 * refactor : @DynamoDbBean 주석 추가 * feat : 주제 + 소주제 + 레벨로 오픽 질문 조회 추가 * feat : OPIc 세션, 질문, 답변 종합 handler 구현 * feat : 오픽 주제, 소주제별 seed 데이터 추가 * refactor : Transcribe API KEY 환경변수명 수정 * refactor : Bedrock에 사용하는 클로드 모델 변경 * refactor : S3 Key 대신 Proxy에서 요청하는 Base64 값으로 변환 * feat: GAME_START, ROUND_END 메시지에 serverTime 추가 - broadcastGameStart(): serverTime, roundDuration 필드 추가 - broadcastRoundEnd(): serverTime, roundStartTime, roundDuration 필드 추가 - GameService.endRound(): data에 roundStartTime, roundDuration 포함 - 타이머 동기화 버그 수정을 위한 서버 시간 제공 * feat: WebSocketMessageHelper 유틸리티 클래스 추가 - domain 필드 포함 메시지 생성 헬퍼 - DOMAIN_CHAT, DOMAIN_GAME 상수 정의 - buildChatMessage(), buildGameMessage() 메서드 * feat: 모든 WebSocket 메시지에 domain 필드 추가 - ScoreUpdateMessage에 domain 필드 추가 - broadcastGameStart에 domain:"game" 추가 - broadcastRoundEnd에 domain:"game" 추가 - broadcastCorrectAnswerMessage에 domain:"game" 추가 - handleCommandResult 시스템 메시지에 domain:"game" 추가 - handleRegularMessage 채팅 메시지에 domain:"chat" 추가 Closes #426, #427 * feat: GameSession 모델 클래스 생성 - DynamoDB Enhanced Client 어노테이션 적용 - GSI1: roomId로 활성 게임 세션 조회 가능 - 게임 상태 관리용 헬퍼 메서드 포함 Closes #428 * feat: GameSessionRepository 구현 - 기본 CRUD (save, findById, delete) - roomId로 활성/전체 게임 세션 조회 - 상태, 라운드, 점수 업데이트 메서드 - 정답자 추가, 힌트 사용, 게임 종료 처리 Closes #429 * refactor: ChatRoom에서 게임 필드 분리 - 게임 관련 필드 제거 (gameStatus, currentRound 등) - activeGameSessionId 필드 추가 - 게임 상태는 GameSession으로 분리됨 Note: 의존 코드 수정은 #431에서 진행 Closes #430 * refactor: GameSession 기반으로 전체 게임 로직 리팩토링 - GameService: ChatRoom 대신 GameSession 사용 - GameStatsService: GameSession 매개변수로 변경 - CommandService: GameSession 기반 점수 조회 - GameHandler: GameSession 기반 REST API - WebSocketMessageHandler: GameSession 기반 브로드캐스트 - GameStatusResponse, ScoreboardResponse: GameSession 매개변수 Closes #431 * feat: GameSessionHandler Lambda 및 게임 세션 API 구현 - POST /rooms/{roomId}/games - 게임 세션 생성 - GET /games/{gameSessionId} - 게임 상태 조회 (재접속용) - POST /games/{gameSessionId}/start - 게임 시작 - POST /games/{gameSessionId}/stop - 게임 종료 모든 응답에 serverTime 포함 (타이머 동기화) 출제자에게만 currentWord 포함 Closes #432, #433 * feat: 게임 시작 7분 후 자동 종료 기능 구현 - GameConfig에 gameTimeLimit() 메서드 추가 (기본값: 420초) - GameSchedulerClient 유틸리티 클래스 생성 (EventBridge Scheduler 연동) - GameService에 스케줄 생성/취소 로직 추가 - GameAutoCloseHandler Lambda 함수 생성 - template.yaml에 Lambda, IAM Role, Schedule Group 추가 Closes #417 * fix : 메모리 증가 및 Lambda 응답 제한 시간 Cognito 트리거 제한시간과 동일하게 수정 (#439) * feat: 캐치마인드 게임 방 분리 기능 구현 (#455) - Room 타입 분리 (CHAT/GAME) - RoomType, RoomStatus enum 추가 - ChatRoom 모델에 type, gameType, gameSettings, status, hostId 필드 추가 - GameSettings 모델 추가 - 방 생성/조회 API 수정 - CreateRoomRequest에 type, gameType, gameSettings 필드 추가 - 방 목록 조회 시 type, gameType, status 필터 지원 - 게임 시작 조건 검증 - GAME 타입 방에서만 게임 시작 가능 (GAME_007 에러) - 게임 재시작 API 구현 (#452) - POST /rooms/{roomId}/game/restart 엔드포인트 추가 - 방장만 재시작 가능 (GAME_009 에러) - 게임 진행 중 재시작 불가 (GAME_008 에러) - 방장 변경 로직 구현 (#453) - 방장 퇴장 시 다음 멤버에게 자동 이전 - 모든 멤버 퇴장 시 방 자동 삭제 - WebSocket 메시지 타입 추가 (#454) - ROOM_STATUS_CHANGE, HOST_CHANGE 메시지 타입 추가 - WebSocketMessageHelper에 빌더 메서드 추가 - 테스트 추가 - RoomType, RoomStatus enum 테스트 - GameSettings 모델 테스트 - ChattingErrorCode 테스트 업데이트 Related: #440, #441, #442, #443, #444, #445, #446 Closes: #447, #448, #449, #450, #451, #452, #453, #454 * feat: 참가자 닉네임 및 방장 변경 WebSocket 알림 구현 (#456) - RoomParticipant DTO 추가 (userId, nickname, isHost) - ChatRoomQueryService에 닉네임 조회 메서드 추가 - getParticipantsWithNicknames(): 참가자 목록 + 닉네임 - getHostNickname(): 방장 닉네임 조회 - ChatRoomHandler.getRoom() 응답에 participants, hostNickname 추가 - ChatRoomCommandService.leaveRoom()에서 방장 변경 시 WebSocket 브로드캐스트 Related: #440 * fix: ChatRoomFunction에 UserTable DynamoDB 권한 추가 - 참가자/방장 닉네임 조회를 위한 UserTable 읽기 권한 추가 - DynamoDBReadPolicy로 UserTable 접근 허용 Fixes #457 * fix: GameSettings에 @DynamoDbBean 어노테이션 추가 - DynamoDB Enhanced Client가 중첩 객체를 직렬화/역직렬화할 수 있도록 수정 - ChatRoom 저장/조회 시 GameSettings 변환 오류 해결 Fixes #457 * fix: ChatRoomFunction에 WEBSOCKET_ENDPOINT 환경변수 및 권한 추가 - leaveRoom에서 WebSocketBroadcaster 사용을 위한 환경변수 추가 - execute-api:ManageConnections 권한 추가 * fix: WebSocket Lambda 함수들에 WEBSOCKET_ENDPOINT 환경변수 추가 - WebSocketConnectFunction에 WEBSOCKET_ENDPOINT 추가 - WebSocketDisconnectFunction에 WEBSOCKET_ENDPOINT 추가 - execute-api:ManageConnections 권한 추가 * fix: Grammar WebSocket Lambda 환경 변수 및 권한 추가 - GrammarStreamingConnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - GrammarStreamingDisconnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - 두 함수에 execute-api:ManageConnections 권한 추가 * feat: GSI1SK 확장성 있는 재설계 및 DB 레벨 필터링 ## 변경 사항 ### GSI1SK 포맷 변경 - 기존: {level}#{createdAt} - 신규: {type}#{gameType}#{status}#{level}#{createdAt} ### 지원 쿼리 패턴 - 전체 방: GSI1PK = "ROOMS" - 게임방만: begins_with(GSI1SK, "GAME#") - 캐치마인드만: begins_with(GSI1SK, "GAME#CATCHMIND#") - 대기중 캐치마인드: begins_with(GSI1SK, "GAME#CATCHMIND#WAITING#") ### 파일 수정 - ChatRoomCommandService.java: 방 생성 시 새 GSI1SK 포맷 적용 - ChatRoomRepository.java: findByFilters() 메서드 추가, updateStatus() 메서드 추가 - ChatRoomQueryService.java: 메모리 필터링 제거, DB 레벨 필터링으로 변경 - GameService.java: 게임 시작/종료 시 방 상태 업데이트 (GSI1SK 포함) ### 마이그레이션 - scripts/migrate-gsi1sk.sh: 기존 데이터 마이그레이션 스크립트 - 37개 기존 방 마이그레이션 완료 * fix: increase stats query limit to 100 days - Adjust maximum allowable limit for recent stats query from 30 to 100 days to support extended data range responses. * feat: improve game round and connection management logic - Added handling for game round timeout (`ROUND_TIMEOUT`) in WebSocketMessageHandler. - Enhanced game start broadcast to include `currentWord` for the drawer only. - Updated ConnectionRepository to remove duplicate user connections in the same room. - Added `currentWordEnglish` to GameSession for better answer verification. - Normalized ChatRoom levels to match database storage format. - Updated template.yaml to include DynamoDBReadPolicy for VocabTable. * refactor: 코드 정리 및 미사용 클래스 제거 (#459) * refactor: remove unused WebSocketResponseUtil * refactor: add AutoCloseable to WebSocketBroadcaster * refactor: remove unused TestService * refactor: remove unused WordService * refactor: remove unused DailyStudyService * refactor: remove unused UserWordService * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * fix: resolve N+1 query in StatsService * refactor(all): DI 패턴 및 전략 패턴 적용 (#461) - Repository, Service, Handler에 DI 생성자 추가 (테스트 용이성) - BadgeService에 Strategy 패턴 적용 (뱃지 조건 검증 로직 분리) * refactor: relocate and restructure seed data files - Moved `question-homes.json` and `words.json` from subdirectories to `seed` folder. - Updated file paths for better organization and clarity. * chore: seed 데이터 폴더 구조 정리 * feat: add CI/CD pipeline configuration for CodePipeline * fix: add SNS topic policy and DependsOn for notification rule * fix: correct paths in buildspec.yml for CodeBuild * fix: remove hardcoded JAVA_HOME, use runtime default * fix: add gradle wrapper for CI/CD build * fix: use single line sam package command with hardcoded bucket * fix: use existing stack name group2-englishstudy-chatting * fix: add missing WEBSOCKET_ENDPOINT env var to WebSocket connect functions * docs: update FRONTEND-API-GUIDE with new RoomType/RoomStatus structure * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix: update WebSocketDisconnectHandler to use GameSession model - Remove references to deleted ChatRoom game fields - Use GameSessionRepository to finish active game sessions - Use ChatRoomRepository.updateStatus() for room state management * perf: optimize CI/CD build time - Add pip cache for SAM CLI dependencies - Enable Gradle parallel builds and build cache - Add SAM build cache (.aws-sam) - Use sam build --parallel --cached - Skip SAM CLI install if already cached - Remove unnecessary 'clean' to leverage cache * feat: add custom CodeBuild Docker image with pre-installed tools - Dockerfile with Java 21 + SAM CLI + Gradle pre-installed - build-and-push.sh script for ECR deployment - Updated buildspec.yml for custom image (removes SAM CLI install) - Expected build time reduction: ~30-40 seconds * feature : AI 영어 회화 연습 기능 (#468) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix: remove typo in SpeakingConnectionRepository * fix : 오타 수정 * chore: trigger build test with custom Docker image * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes * Release: 캐치마인드 게임 분리, AI 회화 연습, CI/CD 파이프라인 (#469) * feature : OPIc 세션관리 + 답변 처리 파이프라인 구현 (#413) * feat : OPIc 질문 & 세션 관련 dto 생성 * refactor : @DynamoDbBean 주석 추가 * feat : 주제 + 소주제 + 레벨로 오픽 질문 조회 추가 * feat : OPIc 세션, 질문, 답변 종합 handler 구현 * feat : 오픽 주제, 소주제별 seed 데이터 추가 * refactor : Transcribe API KEY 환경변수명 수정 * refactor : Bedrock에 사용하는 클로드 모델 변경 * refactor : S3 Key 대신 Proxy에서 요청하는 Base64 값으로 변환 * feat: GAME_START, ROUND_END 메시지에 serverTime 추가 - broadcastGameStart(): serverTime, roundDuration 필드 추가 - broadcastRoundEnd(): serverTime, roundStartTime, roundDuration 필드 추가 - GameService.endRound(): data에 roundStartTime, roundDuration 포함 - 타이머 동기화 버그 수정을 위한 서버 시간 제공 * feat: WebSocketMessageHelper 유틸리티 클래스 추가 - domain 필드 포함 메시지 생성 헬퍼 - DOMAIN_CHAT, DOMAIN_GAME 상수 정의 - buildChatMessage(), buildGameMessage() 메서드 * feat: 모든 WebSocket 메시지에 domain 필드 추가 - ScoreUpdateMessage에 domain 필드 추가 - broadcastGameStart에 domain:"game" 추가 - broadcastRoundEnd에 domain:"game" 추가 - broadcastCorrectAnswerMessage에 domain:"game" 추가 - handleCommandResult 시스템 메시지에 domain:"game" 추가 - handleRegularMessage 채팅 메시지에 domain:"chat" 추가 Closes #426, #427 * feat: GameSession 모델 클래스 생성 - DynamoDB Enhanced Client 어노테이션 적용 - GSI1: roomId로 활성 게임 세션 조회 가능 - 게임 상태 관리용 헬퍼 메서드 포함 Closes #428 * feat: GameSessionRepository 구현 - 기본 CRUD (save, findById, delete) - roomId로 활성/전체 게임 세션 조회 - 상태, 라운드, 점수 업데이트 메서드 - 정답자 추가, 힌트 사용, 게임 종료 처리 Closes #429 * refactor: ChatRoom에서 게임 필드 분리 - 게임 관련 필드 제거 (gameStatus, currentRound 등) - activeGameSessionId 필드 추가 - 게임 상태는 GameSession으로 분리됨 Note: 의존 코드 수정은 #431에서 진행 Closes #430 * refactor: GameSession 기반으로 전체 게임 로직 리팩토링 - GameService: ChatRoom 대신 GameSession 사용 - GameStatsService: GameSession 매개변수로 변경 - CommandService: GameSession 기반 점수 조회 - GameHandler: GameSession 기반 REST API - WebSocketMessageHandler: GameSession 기반 브로드캐스트 - GameStatusResponse, ScoreboardResponse: GameSession 매개변수 Closes #431 * feat: GameSessionHandler Lambda 및 게임 세션 API 구현 - POST /rooms/{roomId}/games - 게임 세션 생성 - GET /games/{gameSessionId} - 게임 상태 조회 (재접속용) - POST /games/{gameSessionId}/start - 게임 시작 - POST /games/{gameSessionId}/stop - 게임 종료 모든 응답에 serverTime 포함 (타이머 동기화) 출제자에게만 currentWord 포함 Closes #432, #433 * feat: 게임 시작 7분 후 자동 종료 기능 구현 - GameConfig에 gameTimeLimit() 메서드 추가 (기본값: 420초) - GameSchedulerClient 유틸리티 클래스 생성 (EventBridge Scheduler 연동) - GameService에 스케줄 생성/취소 로직 추가 - GameAutoCloseHandler Lambda 함수 생성 - template.yaml에 Lambda, IAM Role, Schedule Group 추가 Closes #417 * fix : 메모리 증가 및 Lambda 응답 제한 시간 Cognito 트리거 제한시간과 동일하게 수정 (#439) * feat: 캐치마인드 게임 방 분리 기능 구현 (#455) - Room 타입 분리 (CHAT/GAME) - RoomType, RoomStatus enum 추가 - ChatRoom 모델에 type, gameType, gameSettings, status, hostId 필드 추가 - GameSettings 모델 추가 - 방 생성/조회 API 수정 - CreateRoomRequest에 type, gameType, gameSettings 필드 추가 - 방 목록 조회 시 type, gameType, status 필터 지원 - 게임 시작 조건 검증 - GAME 타입 방에서만 게임 시작 가능 (GAME_007 에러) - 게임 재시작 API 구현 (#452) - POST /rooms/{roomId}/game/restart 엔드포인트 추가 - 방장만 재시작 가능 (GAME_009 에러) - 게임 진행 중 재시작 불가 (GAME_008 에러) - 방장 변경 로직 구현 (#453) - 방장 퇴장 시 다음 멤버에게 자동 이전 - 모든 멤버 퇴장 시 방 자동 삭제 - WebSocket 메시지 타입 추가 (#454) - ROOM_STATUS_CHANGE, HOST_CHANGE 메시지 타입 추가 - WebSocketMessageHelper에 빌더 메서드 추가 - 테스트 추가 - RoomType, RoomStatus enum 테스트 - GameSettings 모델 테스트 - ChattingErrorCode 테스트 업데이트 Related: #440, #441, #442, #443, #444, #445, #446 Closes: #447, #448, #449, #450, #451, #452, #453, #454 * feat: 참가자 닉네임 및 방장 변경 WebSocket 알림 구현 (#456) - RoomParticipant DTO 추가 (userId, nickname, isHost) - ChatRoomQueryService에 닉네임 조회 메서드 추가 - getParticipantsWithNicknames(): 참가자 목록 + 닉네임 - getHostNickname(): 방장 닉네임 조회 - ChatRoomHandler.getRoom() 응답에 participants, hostNickname 추가 - ChatRoomCommandService.leaveRoom()에서 방장 변경 시 WebSocket 브로드캐스트 Related: #440 * fix: ChatRoomFunction에 UserTable DynamoDB 권한 추가 - 참가자/방장 닉네임 조회를 위한 UserTable 읽기 권한 추가 - DynamoDBReadPolicy로 UserTable 접근 허용 Fixes #457 * fix: GameSettings에 @DynamoDbBean 어노테이션 추가 - DynamoDB Enhanced Client가 중첩 객체를 직렬화/역직렬화할 수 있도록 수정 - ChatRoom 저장/조회 시 GameSettings 변환 오류 해결 Fixes #457 * fix: ChatRoomFunction에 WEBSOCKET_ENDPOINT 환경변수 및 권한 추가 - leaveRoom에서 WebSocketBroadcaster 사용을 위한 환경변수 추가 - execute-api:ManageConnections 권한 추가 * fix: WebSocket Lambda 함수들에 WEBSOCKET_ENDPOINT 환경변수 추가 - WebSocketConnectFunction에 WEBSOCKET_ENDPOINT 추가 - WebSocketDisconnectFunction에 WEBSOCKET_ENDPOINT 추가 - execute-api:ManageConnections 권한 추가 * fix: Grammar WebSocket Lambda 환경 변수 및 권한 추가 - GrammarStreamingConnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - GrammarStreamingDisconnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - 두 함수에 execute-api:ManageConnections 권한 추가 * feat: GSI1SK 확장성 있는 재설계 및 DB 레벨 필터링 ## 변경 사항 ### GSI1SK 포맷 변경 - 기존: {level}#{createdAt} - 신규: {type}#{gameType}#{status}#{level}#{createdAt} ### 지원 쿼리 패턴 - 전체 방: GSI1PK = "ROOMS" - 게임방만: begins_with(GSI1SK, "GAME#") - 캐치마인드만: begins_with(GSI1SK, "GAME#CATCHMIND#") - 대기중 캐치마인드: begins_with(GSI1SK, "GAME#CATCHMIND#WAITING#") ### 파일 수정 - ChatRoomCommandService.java: 방 생성 시 새 GSI1SK 포맷 적용 - ChatRoomRepository.java: findByFilters() 메서드 추가, updateStatus() 메서드 추가 - ChatRoomQueryService.java: 메모리 필터링 제거, DB 레벨 필터링으로 변경 - GameService.java: 게임 시작/종료 시 방 상태 업데이트 (GSI1SK 포함) ### 마이그레이션 - scripts/migrate-gsi1sk.sh: 기존 데이터 마이그레이션 스크립트 - 37개 기존 방 마이그레이션 완료 * fix: increase stats query limit to 100 days - Adjust maximum allowable limit for recent stats query from 30 to 100 days to support extended data range responses. * feat: improve game round and connection management logic - Added handling for game round timeout (`ROUND_TIMEOUT`) in WebSocketMessageHandler. - Enhanced game start broadcast to include `currentWord` for the drawer only. - Updated ConnectionRepository to remove duplicate user connections in the same room. - Added `currentWordEnglish` to GameSession for better answer verification. - Normalized ChatRoom levels to match database storage format. - Updated template.yaml to include DynamoDBReadPolicy for VocabTable. * refactor: 코드 정리 및 미사용 클래스 제거 (#459) * refactor: remove unused WebSocketResponseUtil * refactor: add AutoCloseable to WebSocketBroadcaster * refactor: remove unused TestService * refactor: remove unused WordService * refactor: remove unused DailyStudyService * refactor: remove unused UserWordService * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * fix: resolve N+1 query in StatsService * refactor(all): DI 패턴 및 전략 패턴 적용 (#461) - Repository, Service, Handler에 DI 생성자 추가 (테스트 용이성) - BadgeService에 Strategy 패턴 적용 (뱃지 조건 검증 로직 분리) * refactor: relocate and restructure seed data files - Moved `question-homes.json` and `words.json` from subdirectories to `seed` folder. - Updated file paths for better organization and clarity. * chore: seed 데이터 폴더 구조 정리 * feat: add CI/CD pipeline configuration for CodePipeline * fix: add SNS topic policy and DependsOn for notification rule * fix: correct paths in buildspec.yml for CodeBuild * fix: remove hardcoded JAVA_HOME, use runtime default * fix: add gradle wrapper for CI/CD build * fix: use single line sam package command with hardcoded bucket * fix: use existing stack name group2-englishstudy-chatting * fix: add missing WEBSOCKET_ENDPOINT env var to WebSocket connect functions * docs: update FRONTEND-API-GUIDE with new RoomType/RoomStatus structure * fix: update WebSocketDisconnectHandler to use GameSession model - Remove references to deleted ChatRoom game fields - Use GameSessionRepository to finish active game sessions - Use ChatRoomRepository.updateStatus() for room state management * perf: optimize CI/CD build time - Add pip cache for SAM CLI dependencies - Enable Gradle parallel builds and build cache - Add SAM build cache (.aws-sam) - Use sam build --parallel --cached - Skip SAM CLI install if already cached - Remove unnecessary 'clean' to leverage cache * feat: add custom CodeBuild Docker image with pre-installed tools - Dockerfile with Java 21 + SAM CLI + Gradle pre-installed - build-and-push.sh script for ECR deployment - Updated buildspec.yml for custom image (removes SAM CLI install) - Expected build time reduction: ~30-40 seconds * feature : AI 영어 회화 연습 기능 (#468) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix: remove typo in SpeakingConnectionRepository * chore: trigger build test with custom Docker image * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes --------- Co-authored-by: hyein Heo <128613248+hye-inA@users.noreply.github.com> * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * fix: add CORS headers to API Gateway error responses (#479) API Gateway의 인증 실패 등 에러 응답에 CORS 헤더가 누락되어 CloudFront를 통한 프론트엔드 요청이 차단되는 문제 수정 - UNAUTHORIZED (401) 응답에 CORS 헤더 추가 - ACCESS_DENIED (403) 응답에 CORS 헤더 추가 - DEFAULT_4XX/5XX 응답에 CORS 헤더 추가 - EXPIRED_TOKEN 응답에 CORS 헤더 추가 * feat(news): 뉴스 도메인 기반 구조 구축 (#385) - NewsCategory, QuizType enum 추가 - NewsKey 상수 클래스 추가 - NewsErrorCode 예외 클래스 추가 - NewsArticle, KeywordInfo, QuizQuestion 모델 추가 - NewsArticleRepository CRUD 구현 - NewsTable DynamoDB 테이블 정의 - CORS GatewayResponses 설정 추가 * feat(news): 뉴스 수집 파이프라인 구현 (#386) - NewsApiClient: NewsAPI 연동 서비스 - RssFeedParser: RSS 피드 파싱 (BBC, VOA, NPR) - NewsDuplicateChecker: URL 기반 중복 필터링 - NewsCollectorService: 수집 오케스트레이션 - NewsCollectionHandler: Lambda 핸들러 - EventBridge 스케줄러: 매일 18시 KST 실행 * refactor(news): NewsAPI 제거, RSS만 사용 - NewsApiClient 삭제 - SSM Parameter 의존성 제거 - RSS 소스당 7개씩 수집 (BBC, VOA, NPR = 약 21개/일) * feat(news): AI 뉴스 분석 시스템 구현 (#387) - NewsAnalysisService: AI 분석 통합 서비스 - Bedrock: CEFR 난이도 분석 (A1~C2) - Bedrock: 3줄 요약 + 퀴즈 3문제 생성 - Comprehend: 핵심 키워드 추출 - NewsCollectorService: 수집 시 자동 분석 연동 - GSI1/GSI2 키 자동 설정 (레벨별, 카테고리별 조회) * feat(news): 뉴스 학습 API 구현 (#388) - NewsQueryService: 뉴스 조회 서비스 - NewsHandler: API 핸들러 - GET /news - 목록 조회 (level, category 필터) - GET /news/today - 오늘의 뉴스 - GET /news/recommended - 내 레벨 맞춤 추천 - GET /news/{articleId} - 상세 조회 (조회수 증가) - template.yaml: NewsFunction Lambda 추가 * feat(news): 뉴스 학습 부가 기능 구현 (#389) - 읽기 완료 기록 API (POST /news/{articleId}/read) - 북마크 토글 API (POST /news/{articleId}/bookmark) - 북마크 목록 조회 API (GET /news/bookmarks) - 학습 통계 조회 API (GET /news/stats) - TTS 오디오 URL 조회 API (GET /news/{articleId}/audio) - UserNewsRecord 모델 추가 - UserNewsRepository 추가 - NewsLearningService 추가 * feat(news): 복합 퀴즈 시스템 구현 (#471) - 퀴즈 조회 API (GET /news/{articleId}/quiz) - 퀴즈 제출 API (POST /news/{articleId}/quiz) - 퀴즈 기록 조회 API (GET /news/quiz/history) - NewsQuizResult 모델 추가 - QuizAnswerResult 모델 추가 - NewsQuizRepository 추가 - NewsQuizService 추가 * feat(news): 단어 수집 & Vocabulary 연동 구현 (#472) - 단어 수집 API (POST /news/{articleId}/words) - 수집 단어 목록 API (GET /news/words) - 단어 상세 조회 API (GET /news/{articleId}/words/{word}) - 단어 삭제 API (DELETE /news/{articleId}/words/{word}) - Vocabulary 연동 API (POST /news/words/{word}/sync) - NewsWordCollect 모델 추가 - NewsWordRepository 추가 - NewsWordService 추가 * feat: add multi-environment deployment support (dev/test/prod) * fix: update buildspec.yml to deploy prod environment with parameter overrides * feat(news): 뉴스 도메인 기반 구조 구축 (#385) - NewsCategory, QuizType enum 추가 - NewsKey 상수 클래스 추가 - NewsErrorCode 예외 클래스 추가 - NewsArticle, KeywordInfo, QuizQuestion 모델 추가 - NewsArticleRepository CRUD 구현 - NewsTable DynamoDB 테이블 정의 - CORS GatewayResponses 설정 추가 * feat(news): 뉴스 수집 파이프라인 구현 (#386) - NewsApiClient: NewsAPI 연동 서비스 - RssFeedParser: RSS 피드 파싱 (BBC, VOA, NPR) - NewsDuplicateChecker: URL 기반 중복 필터링 - NewsCollectorService: 수집 오케스트레이션 - NewsCollectionHandler: Lambda 핸들러 - EventBridge 스케줄러: 매일 18시 KST 실행 * refactor(news): NewsAPI 제거, RSS만 사용 - NewsApiClient 삭제 - SSM Parameter 의존성 제거 - RSS 소스당 7개씩 수집 (BBC, VOA, NPR = 약 21개/일) * feat(news): AI 뉴스 분석 시스템 구현 (#387) - NewsAnalysisService: AI 분석 통합 서비스 - Bedrock: CEFR 난이도 분석 (A1~C2) - Bedrock: 3줄 요약 + 퀴즈 3문제 생성 - Comprehend: 핵심 키워드 추출 - NewsCollectorService: 수집 시 자동 분석 연동 - GSI1/GSI2 키 자동 설정 (레벨별, 카테고리별 조회) * feat(news): 뉴스 학습 API 구현 (#388) - NewsQueryService: 뉴스 조회 서비스 - NewsHandler: API 핸들러 - GET /news - 목록 조회 (level, category 필터) - GET /news/today - 오늘의 뉴스 - GET /news/recommended - 내 레벨 맞춤 추천 - GET /news/{articleId} - 상세 조회 (조회수 증가) - template.yaml: NewsFunction Lambda 추가 * feat(news): 뉴스 학습 부가 기능 구현 (#389) - 읽기 완료 기록 API (POST /news/{articleId}/read) - 북마크 토글 API (POST /news/{articleId}/bookmark) - 북마크 목록 조회 API (GET /news/bookmarks) - 학습 통계 조회 API (GET /news/stats) - TTS 오디오 URL 조회 API (GET /news/{articleId}/audio) - UserNewsRecord 모델 추가 - UserNewsRepository 추가 - NewsLearningService 추가 * feat(news): 복합 퀴즈 시스템 구현 (#471) - 퀴즈 조회 API (GET /news/{articleId}/quiz) - 퀴즈 제출 API (POST /news/{articleId}/quiz) - 퀴즈 기록 조회 API (GET /news/quiz/history) - NewsQuizResult 모델 추가 - QuizAnswerResult 모델 추가 - NewsQuizRepository 추가 - NewsQuizService 추가 * feat(news): 단어 수집 & Vocabulary 연동 구현 (#472) - 단어 수집 API (POST /news/{articleId}/words) - 수집 단어 목록 API (GET /news/words) - 단어 상세 조회 API (GET /news/{articleId}/words/{word}) - 단어 삭제 API (DELETE /news/{articleId}/words/{word}) - Vocabulary 연동 API (POST /news/words/{word}/sync) - NewsWordCollect 모델 추가 - NewsWordRepository 추가 - NewsWordService 추가 * feat: add multi-environment deployment support (dev/test/prod) * fix: update buildspec.yml to deploy prod environment with parameter overrides * refactor : AI 말하기 Websocket 구현 -> REST API 구현으로 리팩토링 (#490) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix : 오타 수정 * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * fix: revert buildspec.yml to build-only for CloudFormation deploy stage * fix: revert buildspec.yml to build-only for CloudFormation deploy stage * fix: update all API Gateway StageName to use Environment parameter * fix: correct VocabularyTable and ContentBucket references in NewsFunction * fix: correct VocabularyTable and ContentBucket references in NewsFunction * fix: add stack name prefix to GameScheduleGroup and daily-stats schedule to avoid conflicts * fix: add stack name prefix to GameScheduleGroup and daily-stats schedule to avoid conflicts * feat: add support for existing Cognito User Pool reuse across environments * fix: add conditional Cognito ARN reference in API Gateway Authorizer * fix: remove Cognito resources completely, use existing Cognito only * fix: remove Cognito resources completely, use existing Cognito only * feat : speaking rest API 람다 함수 추가 * feat: add S3 bucket resource and fix environment-specific endpoints - Add ContentBucket S3 resource for content storage - Replace hardcoded /dev with ${Environment} in all WebSocket endpoints - Update Output URLs to use dynamic environment stage * fix: add stack name prefix to news-collection schedule name Prevent EventBridge rule name conflicts across environments * fix: add X-Requested-With and Accept headers to CORS config Enable additional headers for CloudFront CORS compatibility * fix: use environment variable for S3 bucket URLs Replace hardcoded bucket name with BUCKET_NAME env var for multi-env support: - PreSignUpHandler: dynamic default profile URL - PostConfirmationHandler: dynamic default profile URL - UserService: dynamic default profile URL - BadgeType: dynamic badge image base URL * fix: disable authorizer for CORS preflight requests Add AddDefaultAuthorizerToCorsPreflight: false to prevent Cognito Authorizer from blocking OPTIONS requests * feat : speaking REST API 람다 함수 추가 (#491) * feature : OPIc 세션관리 + 답변 처리 파이프라인 구현 (#413) * feat : OPIc 질문 & 세션 관련 dto 생성 * refactor : @DynamoDbBean 주석 추가 * feat : 주제 + 소주제 + 레벨로 오픽 질문 조회 추가 * feat : OPIc 세션, 질문, 답변 종합 handler 구현 * feat : 오픽 주제, 소주제별 seed 데이터 추가 * refactor : Transcribe API KEY 환경변수명 수정 * refactor : Bedrock에 사용하는 클로드 모델 변경 * refactor : S3 Key 대신 Proxy에서 요청하는 Base64 값으로 변환 * feat: GAME_START, ROUND_END 메시지에 serverTime 추가 - broadcastGameStart(): serverTime, roundDuration 필드 추가 - broadcastRoundEnd(): serverTime, roundStartTime, roundDuration 필드 추가 - GameService.endRound(): data에 roundStartTime, roundDuration 포함 - 타이머 동기화 버그 수정을 위한 서버 시간 제공 * feat: WebSocketMessageHelper 유틸리티 클래스 추가 - domain 필드 포함 메시지 생성 헬퍼 - DOMAIN_CHAT, DOMAIN_GAME 상수 정의 - buildChatMessage(), buildGameMessage() 메서드 * feat: 모든 WebSocket 메시지에 domain 필드 추가 - ScoreUpdateMessage에 domain 필드 추가 - broadcastGameStart에 domain:"game" 추가 - broadcastRoundEnd에 domain:"game" 추가 - broadcastCorrectAnswerMessage에 domain:"game" 추가 - handleCommandResult 시스템 메시지에 domain:"game" 추가 - handleRegularMessage 채팅 메시지에 domain:"chat" 추가 Closes #426, #427 * feat: GameSession 모델 클래스 생성 - DynamoDB Enhanced Client 어노테이션 적용 - GSI1: roomId로 활성 게임 세션 조회 가능 - 게임 상태 관리용 헬퍼 메서드 포함 Closes #428 * feat: GameSessionRepository 구현 - 기본 CRUD (save, findById, delete) - roomId로 활성/전체 게임 세션 조회 - 상태, 라운드, 점수 업데이트 메서드 - 정답자 추가, 힌트 사용, 게임 종료 처리 Closes #429 * refactor: ChatRoom에서 게임 필드 분리 - 게임 관련 필드 제거 (gameStatus, currentRound 등) - activeGameSessionId 필드 추가 - 게임 상태는 GameSession으로 분리됨 Note: 의존 코드 수정은 #431에서 진행 Closes #430 * refactor: GameSession 기반으로 전체 게임 로직 리팩토링 - GameService: ChatRoom 대신 GameSession 사용 - GameStatsService: GameSession 매개변수로 변경 - CommandService: GameSession 기반 점수 조회 - GameHandler: GameSession 기반 REST API - WebSocketMessageHandler: GameSession 기반 브로드캐스트 - GameStatusResponse, ScoreboardResponse: GameSession 매개변수 Closes #431 * feat: GameSessionHandler Lambda 및 게임 세션 API 구현 - POST /rooms/{roomId}/games - 게임 세션 생성 - GET /games/{gameSessionId} - 게임 상태 조회 (재접속용) - POST /games/{gameSessionId}/start - 게임 시작 - POST /games/{gameSessionId}/stop - 게임 종료 모든 응답에 serverTime 포함 (타이머 동기화) 출제자에게만 currentWord 포함 Closes #432, #433 * feat: 게임 시작 7분 후 자동 종료 기능 구현 - GameConfig에 gameTimeLimit() 메서드 추가 (기본값: 420초) - GameSchedulerClient 유틸리티 클래스 생성 (EventBridge Scheduler 연동) - GameService에 스케줄 생성/취소 로직 추가 - GameAutoCloseHandler Lambda 함수 생성 - template.yaml에 Lambda, IAM Role, Schedule Group 추가 Closes #417 * fix : 메모리 증가 및 Lambda 응답 제한 시간 Cognito 트리거 제한시간과 동일하게 수정 (#439) * feat: 캐치마인드 게임 방 분리 기능 구현 (#455) - Room 타입 분리 (CHAT/GAME) - RoomType, RoomStatus enum 추가 - ChatRoom 모델에 type, gameType, gameSettings, status, hostId 필드 추가 - GameSettings 모델 추가 - 방 생성/조회 API 수정 - CreateRoomRequest에 type, gameType, gameSettings 필드 추가 - 방 목록 조회 시 type, gameType, status 필터 지원 - 게임 시작 조건 검증 - GAME 타입 방에서만 게임 시작 가능 (GAME_007 에러) - 게임 재시작 API 구현 (#452) - POST /rooms/{roomId}/game/restart 엔드포인트 추가 - 방장만 재시작 가능 (GAME_009 에러) - 게임 진행 중 재시작 불가 (GAME_008 에러) - 방장 변경 로직 구현 (#453) - 방장 퇴장 시 다음 멤버에게 자동 이전 - 모든 멤버 퇴장 시 방 자동 삭제 - WebSocket 메시지 타입 추가 (#454) - ROOM_STATUS_CHANGE, HOST_CHANGE 메시지 타입 추가 - WebSocketMessageHelper에 빌더 메서드 추가 - 테스트 추가 - RoomType, RoomStatus enum 테스트 - GameSettings 모델 테스트 - ChattingErrorCode 테스트 업데이트 Related: #440, #441, #442, #443, #444, #445, #446 Closes: #447, #448, #449, #450, #451, #452, #453, #454 * feat: 참가자 닉네임 및 방장 변경 WebSocket 알림 구현 (#456) - RoomParticipant DTO 추가 (userId, nickname, isHost) - ChatRoomQueryService에 닉네임 조회 메서드 추가 - getParticipantsWithNicknames(): 참가자 목록 + 닉네임 - getHostNickname(): 방장 닉네임 조회 - ChatRoomHandler.getRoom() 응답에 participants, hostNickname 추가 - ChatRoomCommandService.leaveRoom()에서 방장 변경 시 WebSocket 브로드캐스트 Related: #440 * fix: ChatRoomFunction에 UserTable DynamoDB 권한 추가 - 참가자/방장 닉네임 조회를 위한 UserTable 읽기 권한 추가 - DynamoDBReadPolicy로 UserTable 접근 허용 Fixes #457 * fix: GameSettings에 @DynamoDbBean 어노테이션 추가 - DynamoDB Enhanced Client가 중첩 객체를 직렬화/역직렬화할 수 있도록 수정 - ChatRoom 저장/조회 시 GameSettings 변환 오류 해결 Fixes #457 * fix: ChatRoomFunction에 WEBSOCKET_ENDPOINT 환경변수 및 권한 추가 - leaveRoom에서 WebSocketBroadcaster 사용을 위한 환경변수 추가 - execute-api:ManageConnections 권한 추가 * fix: WebSocket Lambda 함수들에 WEBSOCKET_ENDPOINT 환경변수 추가 - WebSocketConnectFunction에 WEBSOCKET_ENDPOINT 추가 - WebSocketDisconnectFunction에 WEBSOCKET_ENDPOINT 추가 - execute-api:ManageConnections 권한 추가 * fix: Grammar WebSocket Lambda 환경 변수 및 권한 추가 - GrammarStreamingConnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - GrammarStreamingDisconnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - 두 함수에 execute-api:ManageConnections 권한 추가 * feat: GSI1SK 확장성 있는 재설계 및 DB 레벨 필터링 - 기존: {level}#{createdAt} - 신규: {type}#{gameType}#{status}#{level}#{createdAt} - 전체 방: GSI1PK = "ROOMS" - 게임방만: begins_with(GSI1SK, "GAME#") - 캐치마인드만: begins_with(GSI1SK, "GAME#CATCHMIND#") - 대기중 캐치마인드: begins_with(GSI1SK, "GAME#CATCHMIND#WAITING#") - ChatRoomCommandService.java: 방 생성 시 새 GSI1SK 포맷 적용 - ChatRoomRepository.java: findByFilters() 메서드 추가, updateStatus() 메서드 추가 - ChatRoomQueryService.java: 메모리 필터링 제거, DB 레벨 필터링으로 변경 - GameService.java: 게임 시작/종료 시 방 상태 업데이트 (GSI1SK 포함) - scripts/migrate-gsi1sk.sh: 기존 데이터 마이그레이션 스크립트 - 37개 기존 방 마이그레이션 완료 * fix: increase stats query limit to 100 days - Adjust maximum allowable limit for recent stats query from 30 to 100 days to support extended data range responses. * feat: improve game round and connection management logic - Added handling for game round timeout (`ROUND_TIMEOUT`) in WebSocketMessageHandler. - Enhanced game start broadcast to include `currentWord` for the drawer only. - Updated ConnectionRepository to remove duplicate user connections in the same room. - Added `currentWordEnglish` to GameSession for better answer verification. - Normalized ChatRoom levels to match database storage format. - Updated template.yaml to include DynamoDBReadPolicy for VocabTable. * refactor: 코드 정리 및 미사용 클래스 제거 (#459) * refactor: remove unused WebSocketResponseUtil * refactor: add AutoCloseable to WebSocketBroadcaster * refactor: remove unused TestService * refactor: remove unused WordService * refactor: remove unused DailyStudyService * refactor: remove unused UserWordService * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * fix: resolve N+1 query in StatsService * refactor(all): DI 패턴 및 전략 패턴 적용 (#461) - Repository, Service, Handler에 DI 생성자 추가 (테스트 용이성) - BadgeService에 Strategy 패턴 적용 (뱃지 조건 검증 로직 분리) * refactor: relocate and restructure seed data files - Moved `question-homes.json` and `words.json` from subdirectories to `seed` folder. - Updated file paths for better organization and clarity. * chore: seed 데이터 폴더 구조 정리 * feat: add CI/CD pipeline configuration for CodePipeline * fix: add SNS topic policy and DependsOn for notification rule * fix: correct paths in buildspec.yml for CodeBuild * fix: remove hardcoded JAVA_HOME, use runtime default * fix: add gradle wrapper for CI/CD build * fix: use single line sam package command with hardcoded bucket * fix: use existing stack name group2-englishstudy-chatting * fix: add missing WEBSOCKET_ENDPOINT env var to WebSocket connect functions * docs: update FRONTEND-API-GUIDE with new RoomType/RoomStatus structure * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix: update WebSocketDisconnectHandler to use GameSession model - Remove references to deleted ChatRoom game fields - Use GameSessionRepository to finish active game sessions - Use ChatRoomRepository.updateStatus() for room state management * perf: optimize CI/CD build time - Add pip cache for SAM CLI dependencies - Enable Gradle parallel builds and build cache - Add SAM build cache (.aws-sam) - Use sam build --parallel --cached - Skip SAM CLI install if already cached - Remove unnecessary 'clean' to leverage cache * feat: add custom CodeBuild Docker image with pre-installed tools - Dockerfile with Java 21 + SAM CLI + Gradle pre-installed - build-and-push.sh script for ECR deployment - Updated buildspec.yml for custom image (removes SAM CLI install) - Expected build time reduction: ~30-40 seconds * feature : AI 영어 회화 연습 기능 (#468) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix: remove typo in SpeakingConnectionRepository * fix : 오타 수정 * chore: trigger build test with custom Docker image * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * fix: add CORS headers to API Gateway error responses (#479) API Gateway의 인증 실패 등 에러 응답에 CORS 헤더가 누락되어 CloudFront를 통한 프론트엔드 요청이 차단되는 문제 수정 - UNAUTHORIZED (401) 응답에 CORS 헤더 추가 - ACCESS_DENIED (403) 응답에 CORS 헤더 추가 - DEFAULT_4XX/5XX 응답에 CORS 헤더 추가 - EXPIRED_TOKEN 응답에 CORS 헤더 추가 * feat : speaking rest API 람다 함수 추가 --------- Co-authored-by: ddingjoo * refactor : speaking service 재사용 * refactor : AI 영어 회화 연습 코드 리팩토링 * feature : test 벡엔드 서버에 AI 말하기 연습 기능 배포 (#492) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix : 오타 수정 * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * feat : speaking rest API 람다 함수 추가 * refactor : speaking service 재사용 * refactor : AI 영어 회화 연습 코드 리팩토링 --------- Co-authored-by: DDING JOO * feat : handleChat 메서드 JsonNull 체크 푸가 * feature : handleChat 메서드 JsonNull 체크 추가 (#493) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix : 오타 수정 * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * feat : speaking rest API 람다 함수 추가 * refactor : speaking service 재사용 * refactor : AI 영어 회화 연습 코드 리팩토링 * feat : handleChat 메서드 JsonNull 체크 푸가 --------- Co-authored-by: DDING JOO * feat(news): 뉴스 학습 배지 시스템 구현 (#473) - 14개 뉴스 관련 배지 추가 (읽기, 퀴즈, 단어수집, 연속학습, 마스터) - UserStats에 뉴스 통계 필드 추가 - 6개 뉴스 배지 Strategy 클래스 생성 - 뉴스 읽기/퀴즈/단어수집 시 통계 업데이트 및 배지 체크 연동 * fix: add PATCH method to CORS AllowMethods * test: BadgeType 개수 테스트 수정 (15 -> 29) * fix: CORS PATCH 메서드 추가 * docs: 뉴스 기능 프론트엔드 연동 가이드 작성 * fix: NewsCollectionFunction에 Bedrock, Comprehend 권한 추가 * fix: add null check for collectWord request body - Add INVALID_REQUEST error code to NewsErrorCode - Check body and word field before accessing in collectWord() - Prevents NullPointerException when request body is malformed * feat: enhance stats API and bookmark response for frontend - Add DAILY stats update for news read/quiz/word collection - Add /stats/dashboard endpoint with frontend-requested format - today: wordsLearned, newsRead, quizzesTaken, wordsTotal - overall: totalWordsLearned, totalNewsRead, averageAccuracy, streaks - weeklyProgress: last 7 days with date/wordsLearned/newsRead - Add news-related fields to all stats API responses - Fix bookmark API to include full article details (title, summary, etc.) * feat: add category classification to news AI analysis - Add category field to AnalysisResult record - Update Bedrock prompt to classify articles into categories (WORLD, POLITICS, BUSINESS, TECH, SCIENCE, HEALTH, SPORTS, ENTERTAINMENT, LIFESTYLE) - Parse and set category from AI response - Set GSI2 (CATEGORY#) index when category is available * fix: add /stats/dashboard endpoint to template.yaml * fix: filter by ARTICLE# prefix in findById to avoid returning UserNewsRecord * docs: add News API troubleshooting guide * feat: add Cognito authorizer to News API and enhance keyword extraction - Set CognitoAuthorizer for all News API endpoints in template.yaml - Update Bedrock AI to extract keywords with meanings and examples - Add fallback to Comprehend for keyword extraction when Bedrock fails - Modify KeywordInfo model to include example field - Adjust AI prompt to include keyword extraction with examples - Update AnalysisResult to store keywords and parse them from AI response * Revert "Merge branch 'test' into prod" This reverts commit c1a958eac6799d5838a76e4078ae304bcdb62278, reversing changes made to a6662e0cfc46c6e12f20a89f0df285ff282160bc. * feat: enhance bookmark and reading status tracking for News API - Add bookmark and reading status to individual article responses - Include bookmark status in paginated news responses - Modify `KeywordInfo` to support Korean translations (`meaningKo`) - Update AI prompts to extract Korean meanings for keywords * refactor : session_id가 null 체크 추가 * feat : template 환경변수 리펙토링 * fix : sessionId NullPointerException 에러 수정 (#496) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix : 오타 수정 * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * feat : speaking rest API 람다 함수 추가 * refactor : speaking service 재사용 * refactor : AI 영어 회화 연습 코드 리팩토링 * feat : handleChat 메서드 JsonNull 체크 푸가 * refactor : session_id가 null 체크 추가 * feat : template 환경변수 리펙토링 --------- Co-authored-by: DDING JOO * feat: enhance bookmark and reading status tracking for News API - Add bookmark and reading status to individual article responses - Include bookmark status in paginated news responses - Modify `KeywordInfo` to support Korean translations (`meaningKo`) - Update AI prompts to extract Korean meanings for keywords * feat: add category filtering to UserWord API with enhanced query logic - Introduce `category` filtering to `getUserWords` API - Update WordCategory enums to include "news" category - Apply category filter after enrichment with word info - Adjust query limits for bookmarked and incorrect words queries * feat: add default value for ExistingCognitoClientId in template.yaml - Set default to an empty string to ensure compatibility with templates using this parameter without explicitly specifying a value. * fix: SpeakingHandler getStringOrNull 컴파일 에러 수정 * fix: SpeakingHandler getStringOrNull 컴파일 에러 수정 * fix: Bedrock 키워드(meaningKo 포함)를 article에 저장하도록 수정 * fix: Bedrock 키워드(meaningKo 포함)를 article에 저장하도록 수정 * fix: Bedrock 키워드(meaningKo 포함)를 article에 저장하도록 수정 * feat: add dashboard stats API and enhance news stats tracking - Introduce `/stats/dashboard` API for retrieving integrated user stats (today, overall, weekly progress, and level distribution) - Update `UserStats` model to include additional fields for news stats (newsRead, newsQuizCompleted, newsQuizPerfect, newsWordsCollected, etc.) - Add atomic updates to track news reading, quiz, and word stats in `UserStatsRepository` - Modify CloudFormation `template.yaml` to register the new `/stats/dashboard` endpoint * feat: add dashboard stats API and enhance news stats tracking - Introduce `/stats/dashboard` API for retrieving integrated user stats (today, overall, weekly progress, and level distribution) - Update `UserStats` model to include additional fields for news stats (newsRead, newsQuizCompleted, newsQuizPerfect, newsWordsCollected, etc.) - Add atomic updates to track news reading, quiz, and word stats in `UserStatsRepository` - Modify CloudFormation `template.yaml` to register the new `/stats/dashboard` endpoint * feat: add dashboard stats API and enhance news stats tracking - Introduce `/stats/dashboard` API for retrieving integrated user stats (today, overall, weekly progress, and level distribution) - Update `UserStats` model to include additional fields for news stats (newsRead, newsQuizCompleted, newsQuizPerfect, newsWordsCollected, etc.) - Add atomic updates to track news reading, quiz, and word stats in `UserStatsRepository` - Modify CloudFormation `template.yaml` to register the new `/stats/dashboard` endpoint * refactor: format code with consistent indentation and spacing - Apply consistent formatting to improve code readability across multiple files - Adjust indentation, spacing, and alignment in model classes, DTOs, and repository methods * fix: filter by ARTICLE# prefix in findById to avoid returning bookmark records * feat : Speaking 관련 template 람다 함수 및 테이블 추가 --------- Co-authored-by: DDING JOO * feature : 말하기 연습 기능 polly 서비스 권한 추가 (#514) * feat: EnvConfig 유틸리티 추가 및 환경 변수 검증 적용 환경 변수 미설정 시 명확한 에러 메시지를 제공하는 EnvConfig 유틸리티를 추가하고, 기존 System.getenv 호출을 EnvConfig.getRequired/getOrDefault로 대체함. - EnvConfig: getRequired, getOrDefault, getIntOrDefault, getLongOrDefault 메서드 제공 - Lambda Cold Start 시점에 환경 변수 누락을 조기 감지 - 기존 Config 클래스(WebSocketConfig, RoomTokenConfig) EnvConfig 사용으로 통일 Closes #403 * refactor: TestService submitTest 메서드 책임 분리 submitTest 메서드를 단일 책임 원칙에 맞게 리팩토링: - gradeAnswers(): 답안 채점 및 결과 집계 - isAnswerCorrect(): 단일 답안 정답 여부 판단 - buildResultItem(): 결과 항목 생성 - saveTestResult(): 테스트 결과 저장 - GradingResult record: 채점 결과 캡슐화 TestService와 TestCommandService 모두 동일하게 적용 Closes #404 * refactor: 하드코딩된 설정값 환경 변수로 외부화 각 도메인별 Config 클래스를 생성하여 하드코딩된 값들을 환경 변수로 설정 가능하게 변경. 기본값이 있어 환경 변수 미설정 시에도 기존 동작 유지. ## 새로 추가된 Config 클래스 - GrammarConfig: SESSION_TTL_DAYS, MAX_HISTORY_MESSAGES, MAX_TOKENS 등 - GameConfig: TOTAL_ROUNDS, ROUND_TIME_LIMIT, QUICK_GUESS_THRESHOLD_MS - VocabularyConfig: NEW_WORDS_COUNT, REVIEW_WORDS_COUNT, 상태 전이 임계값 등 ## 지원하는 환경 변수 - GRAMMAR_SESSION_TTL_DAYS, GRAMMAR_MAX_HISTORY_MESSAGES, GRAMMAR_MAX_TOKENS - GAME_TOTAL_ROUNDS, GAME_ROUND_TIME_LIMIT, GAME_QUICK_GUESS_THRESHOLD_MS - VOCAB_NEW_WORDS_COUNT, VOCAB_REVIEW_WORDS_COUNT - VOCAB_TRANSITION_TO_REVIEWING, VOCAB_TRANSITION_TO_MASTERED Closes #406 * test: StudyLevel enum 단위 테스트 추가 * test: Difficulty enum 단위 테스트 추가 * test: StudyConfig 단위 테스트 추가 * test: EnvConfig 단위 테스트 추가 * test: PaginatedResult 단위 테스트 추가 * test: JsonUtil 단위 테스트 추가 * test: CursorUtil 단위 테스트 추가 * test: CommonErrorCode 단위 테스트 추가 * test: CommonException 단위 테스트 추가 * test: BadgeType enum 단위 테스트 추가 * test: BadgeKey 상수 단위 테스트 추가 * test: WordStatus enum 단위 테스트 추가 * test: TestType enum 단위 테스트 추가 * test: VocabularyConfig 단위 테스트 추가 * test: VocabKey 상수 단위 테스트 추가 * test: VocabularyErrorCode 단위 테스트 추가 * test: VocabularyException 단위 테스트 추가 * test: SpacedRepetitionContext 단위 테스트 추가 * test: GrammarLevel enum 단위 테스트 추가 * test: GrammarConfig 단위 테스트 추가 * test: GrammarErrorCode 단위 테스트 추가 * test: GrammarException 단위 테스트 추가 * test: GameStatus enum 단위 테스트 추가 * test: GameConfig 단위 테스트 추가 * test: ChattingErrorCode 단위 테스트 추가 * test: ChattingException 단위 테스트 추가 * fix: OPIc FeedbackResponse.java 문법 오류 수정 * style: AwsClients 코드 포맷팅 * style: EnvConfig 코드 포맷팅 * style: RoomTokenConfig 코드 포맷팅 * style: WebSocketConfig 코드 포맷팅 * style: JsonUtil 코드 포맷팅 * style: GameConfig 코드 포맷팅 * style: GrammarConfig 코드 포맷팅 * style: GrammarKey 코드 포맷팅 * style: GrammarErrorCode 코드 포맷팅 * style: GrammarException 코드 포맷팅 * style: BedrockGrammarCheckFactory 코드 포맷팅 * style: GrammarConversationService 코드 포맷팅 * style: FeedbackResponse 코드 포맷팅 * style: SessionReportResponse 코드 포맷팅 * style: SpeakingError 코드 포맷팅 * style: SpeakingErrorType 코드 포맷팅 * style: OPIcException 코드 포맷팅 * style: OPIcAnswer 코드 포맷팅 * style: OPIcQuestion 코드 포맷팅 * style: OPIcSession 코드 포맷팅 * style: OPIcRepository 코드 포맷팅 * style: FeedbackService 코드 포맷팅 * style: TranscribeProxyService 코드 포맷팅 * style: VocabularyConfig 코드 포맷팅 * style: DailyStudyCommandService 코드 포맷팅 * style: TestCommandService 코드 포맷팅 * style: TestService 코드 포맷팅 * style: CommonErrorCodeSpec 코드 포맷팅 * style: JsonUtilSpec 코드 포맷팅 * style: BadgeTypeSpec 코드 포맷팅 * style: ChattingErrorCodeSpec 코드 포맷팅 * style: GrammarLevelSpec 코드 포맷팅 * style: GrammarErrorCodeSpec 코드 포맷팅 * style: VocabularyErrorCodeSpec 코드 포맷팅 * feature : OPIc 세션관리 + 답변 처리 파이프라인 구현 (#413) * feat : OPIc 질문 & 세션 관련 dto 생성 * refactor : @DynamoDbBean 주석 추가 * feat : 주제 + 소주제 + 레벨로 오픽 질문 조회 추가 * feat : OPIc 세션, 질문, 답변 종합 handler 구현 * feat : 오픽 주제, 소주제별 seed 데이터 추가 * refactor : Transcribe API KEY 환경변수명 수정 * refactor : Bedrock에 사용하는 클로드 모델 변경 * refactor : S3 Key 대신 Proxy에서 요청하는 Base64 값으로 변환 * feat: GAME_START, ROUND_END 메시지에 serverTime 추가 - broadcastGameStart(): serverTime, roundDuration 필드 추가 - broadcastRoundEnd(): serverTime, roundStartTime, roundDuration 필드 추가 - GameService.endRound(): data에 roundStartTime, roundDuration 포함 - 타이머 동기화 버그 수정을 위한 서버 시간 제공 * feat: WebSocketMessageHelper 유틸리티 클래스 추가 - domain 필드 포함 메시지 생성 헬퍼 - DOMAIN_CHAT, DOMAIN_GAME 상수 정의 - buildChatMessage(), buildGameMessage() 메서드 * feat: 모든 WebSocket 메시지에 domain 필드 추가 - ScoreUpdateMessage에 domain 필드 추가 - broadcastGameStart에 domain:"game" 추가 - broadcastRoundEnd에 domain:"game" 추가 - broadcastCorrectAnswerMessage에 domain:"game" 추가 - handleCommandResult 시스템 메시지에 domain:"game" 추가 - handleRegularMessage 채팅 메시지에 domain:"chat" 추가 Closes #426, #427 * feat: GameSession 모델 클래스 생성 - DynamoDB Enhanced Client 어노테이션 적용 - GSI1: roomId로 활성 게임 세션 조회 가능 - 게임 상태 관리용 헬퍼 메서드 포함 Closes #428 * feat: GameSessionRepository 구현 - 기본 CRUD (save, findById, delete) - roomId로 활성/전체 게임 세션 조회 - 상태, 라운드, 점수 업데이트 메서드 - 정답자 추가, 힌트 사용, 게임 종료 처리 Closes #429 * refactor: ChatRoom에서 게임 필드 분리 - 게임 관련 필드 제거 (gameStatus, currentRound 등) - activeGameSessionId 필드 추가 - 게임 상태는 GameSession으로 분리됨 Note: 의존 코드 수정은 #431에서 진행 Closes #430 * refactor: GameSession 기반으로 전체 게임 로직 리팩토링 - GameService: ChatRoom 대신 GameSession 사용 - GameStatsService: GameSession 매개변수로 변경 - CommandService: GameSession 기반 점수 조회 - GameHandler: GameSession 기반 REST API - WebSocketMessageHandler: GameSession 기반 브로드캐스트 - GameStatusResponse, ScoreboardResponse: GameSession 매개변수 Closes #431 * feat: GameSessionHandler Lambda 및 게임 세션 API 구현 - POST /rooms/{roomId}/games - 게임 세션 생성 - GET /games/{gameSessionId} - 게임 상태 조회 (재접속용) - POST /games/{gameSessionId}/start - 게임 시작 - POST /games/{gameSessionId}/stop - 게임 종료 모든 응답에 serverTime 포함 (타이머 동기화) 출제자에게만 currentWord 포함 Closes #432, #433 * feat: 게임 시작 7분 후 자동 종료 기능 구현 - GameConfig에 gameTimeLimit() 메서드 추가 (기본값: 420초) - GameSchedulerClient 유틸리티 클래스 생성 (EventBridge Scheduler 연동) - GameService에 스케줄 생성/취소 로직 추가 - GameAutoCloseHandler Lambda 함수 생성 - template.yaml에 Lambda, IAM Role, Schedule Group 추가 Closes #417 * fix : 메모리 증가 및 Lambda 응답 제한 시간 Cognito 트리거 제한시간과 동일하게 수정 (#439) * feat: 캐치마인드 게임 방 분리 기능 구현 (#455) - Room 타입 분리 (CHAT/GAME) - RoomType, RoomStatus enum 추가 - ChatRoom 모델에 type, gameType, gameSettings, status, hostId 필드 추가 - GameSettings 모델 추가 - 방 생성/조회 API 수정 - CreateRoomRequest에 type, gameType, gameSettings 필드 추가 - 방 목록 조회 시 type, gameType, status 필터 지원 - 게임 시작 조건 검증 - GAME 타입 방에서만 게임 시작 가능 (GAME_007 에러) - 게임 재시작 API 구현 (#452) - POST /rooms/{roomId}/game/restart 엔드포인트 추가 - 방장만 재시작 가능 (GAME_009 에러) - 게임 진행 중 재시작 불가 (GAME_008 에러) - 방장 변경 로직 구현 (#453) - 방장 퇴장 시 다음 멤버에게 자동 이전 - 모든 멤버 퇴장 시 방 자동 삭제 - WebSocket 메시지 타입 추가 (#454) - ROOM_STATUS_CHANGE, HOST_CHANGE 메시지 타입 추가 - WebSocketMessageHelper에 빌더 메서드 추가 - 테스트 추가 - RoomType, RoomStatus enum 테스트 - GameSettings 모델 테스트 - ChattingErrorCode 테스트 업데이트 Related: #440, #441, #442, #443, #444, #445, #446 Closes: #447, #448, #449, #450, #451, #452, #453, #454 * feat: 참가자 닉네임 및 방장 변경 WebSocket 알림 구현 (#456) - RoomParticipant DTO 추가 (userId, nickname, isHost) - ChatRoomQueryService에 닉네임 조회 메서드 추가 - getParticipantsWithNicknames(): 참가자 목록 + 닉네임 - getHostNickname(): 방장 닉네임 조회 - ChatRoomHandler.getRoom() 응답에 participants, hostNickname 추가 - ChatRoomCommandService.leaveRoom()에서 방장 변경 시 WebSocket 브로드캐스트 Related: #440 * fix: ChatRoomFunction에 UserTable DynamoDB 권한 추가 - 참가자/방장 닉네임 조회를 위한 UserTable 읽기 권한 추가 - DynamoDBReadPolicy로 UserTable 접근 허용 Fixes #457 * fix: GameSettings에 @DynamoDbBean 어노테이션 추가 - DynamoDB Enhanced Client가 중첩 객체를 직렬화/역직렬화할 수 있도록 수정 - ChatRoom 저장/조회 시 GameSettings 변환 오류 해결 Fixes #457 * fix: ChatRoomFunction에 WEBSOCKET_ENDPOINT 환경변수 및 권한 추가 - leaveRoom에서 WebSocketBroadcaster 사용을 위한 환경변수 추가 - execute-api:ManageConnections 권한 추가 * fix: WebSocket Lambda 함수들에 WEBSOCKET_ENDPOINT 환경변수 추가 - WebSocketConnectFunction에 WEBSOCKET_ENDPOINT 추가 - WebSocketDisconnectFunction에 WEBSOCKET_ENDPOINT 추가 - execute-api:ManageConnections 권한 추가 * fix: Grammar WebSocket Lambda 환경 변수 및 권한 추가 - GrammarStreamingConnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - GrammarStreamingDisconnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - 두 함수에 execute-api:ManageConnections 권한 추가 * feat: GSI1SK 확장성 있는 재설계 및 DB 레벨 필터링 ## 변경 사항 ### GSI1SK 포맷 변경 - 기존: {level}#{createdAt} - 신규: {type}#{gameType}#{status}#{level}#{createdAt} ### 지원 쿼리 패턴 - 전체 방: GSI1PK = "ROOMS" - 게임방만: begins_with(GSI1SK, "GAME#") - 캐치마인드만: begins_with(GSI1SK, "GAME#CATCHMIND#") - 대기중 캐치마인드: begins_with(GSI1SK, "GAME#CATCHMIND#WAITING#") ### 파일 수정 - ChatRoomCommandService.java: 방 생성 시 새 GSI1SK 포맷 적용 - ChatRoomRepository.java: findByFilters() 메서드 추가, updateStatus() 메서드 추가 - ChatRoomQueryService.java: 메모리 필터링 제거, DB 레벨 필터링으로 변경 - GameService.java: 게임 시작/종료 시 방 상태 업데이트 (GSI1SK 포함) ### 마이그레이션 - scripts/migrate-gsi1sk.sh: 기존 데이터 마이그레이션 스크립트 - 37개 기존 방 마이그레이션 완료 * fix: increase stats query limit to 100 days - Adjust maximum allowable limit for recent stats query from 30 to 100 days to support extended data range responses. * feat: improve game round and connection management logic - Added handling for game round timeout (`ROUND_TIMEOUT`) in WebSocketMessageHandler. - Enhanced game start broadcast to include `currentWord` for the drawer only. - Updated ConnectionRepository to remove duplicate user connections in the same room. - Added `currentWordEnglish` to GameSession for better answer verification. - Normalized ChatRoom levels to match database storage format. - Updated template.yaml to include DynamoDBReadPolicy for VocabTable. * refactor: 코드 정리 및 미사용 클래스 제거 (#459) * refactor: remove unused WebSocketResponseUtil * refactor: add AutoCloseable to WebSocketBroadcaster * refactor: remove unused TestService * refactor: remove unused WordService * refactor: remove unused DailyStudyService * refactor: remove unused UserWordService * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * fix: resolve N+1 query in StatsService * refactor(all): DI 패턴 및 전략 패턴 적용 (#461) - Repository, Service, Handler에 DI 생성자 추가 (테스트 용이성) - BadgeService에 Strategy 패턴 적용 (뱃지 조건 검증 로직 분리) * refactor: relocate and restructure seed data files - Moved `question-homes.json` and `words.json` from subdirectories to `seed` folder. - Updated file paths for better organization and clarity. * chore: seed 데이터 폴더 구조 정리 * feat: add CI/CD pipeline configuration for CodePipeline * fix: add SNS topic policy and DependsOn for notification rule * fix: correct paths in buildspec.yml for CodeBuild * fix: remove hardcoded JAVA_HOME, use runtime default * fix: add gradle wrapper for CI/CD build * fix: use single line sam package command with hardcoded bucket * fix: use existing stack name group2-englishstudy-chatting * fix: add missing WEBSOCKET_ENDPOINT env var to WebSocket connect functions * docs: update FRONTEND-API-GUIDE with new RoomType/RoomStatus structure * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix: update WebSocketDisconnectHandler to use GameSession model - Remove references to deleted ChatRoom game fields - Use GameSessionRepository to finish active game sessions - Use ChatRoomRepository.updateStatus() for room state management * perf: optimize CI/CD build time - Add pip cache for SAM CLI dependencies - Enable Gradle parallel builds and build cache - Add SAM build cache (.aws-sam) - Use sam build --parallel --cached - Skip SAM CLI install if already cached - Remove unnecessary 'clean' to leverage cache * feat: add custom CodeBuild Docker image with pre-installed tools - Dockerfile with Java 21 + SAM CLI + Gradle pre-installed - build-and-push.sh script for ECR deployment - Updated buildspec.yml for custom image (removes SAM CLI install) - Expected build time reduction: ~30-40 seconds * feature : AI 영어 회화 연습 기능 (#468) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix: remove typo in SpeakingConnectionRepository * fix : 오타 수정 * chore: trigger build test with custom Docker image * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes * Release: 캐치마인드 게임 분리, AI 회화 연습, CI/CD 파이프라인 (#469) * feature : OPIc 세션관리 + 답변 처리 파이프라인 구현 (#413) * feat : OPIc 질문 & 세션 관련 dto 생성 * refactor : @DynamoDbBean 주석 추가 * feat : 주제 + 소주제 + 레벨로 오픽 질문 조회 추가 * feat : OPIc 세션, 질문, 답변 종합 handler 구현 * feat : 오픽 주제, 소주제별 seed 데이터 추가 * refactor : Transcribe API KEY 환경변수명 수정 * refactor : Bedrock에 사용하는 클로드 모델 변경 * refactor : S3 Key 대신 Proxy에서 요청하는 Base64 값으로 변환 * feat: GAME_START, ROUND_END 메시지에 serverTime 추가 - broadcastGameStart(): serverTime, roundDuration 필드 추가 - broadcastRoundEnd(): serverTime, roundStartTime, roundDuration 필드 추가 - GameService.endRound(): data에 roundStartTime, roundDuration 포함 - 타이머 동기화 버그 수정을 위한 서버 시간 제공 * feat: WebSocketMessageHelper 유틸리티 클래스 추가 - domain 필드 포함 메시지 생성 헬퍼 - DOMAIN_CHAT, DOMAIN_GAME 상수 정의 - buildChatMessage(), buildGameMessage() 메서드 * feat: 모든 WebSocket 메시지에 domain 필드 추가 - ScoreUpdateMessage에 domain 필드 추가 - broadcastGameStart에 domain:"game" 추가 - broadcastRoundEnd에 domain:"game" 추가 - broadcastCorrectAnswerMessage에 domain:"game" 추가 - handleCommandResult 시스템 메시지에 domain:"game" 추가 - handleRegularMessage 채팅 메시지에 domain:"chat" 추가 Closes #426, #427 * feat: GameSession 모델 클래스 생성 - DynamoDB Enhanced Client 어노테이션 적용 - GSI1: roomId로 활성 게임 세션 조회 가능 - 게임 상태 관리용 헬퍼 메서드 포함 Closes #428 * feat: GameSessionRepository 구현 - 기본 CRUD (save, findById, delete) - roomId로 활성/전체 게임 세션 조회 - 상태, 라운드, 점수 업데이트 메서드 - 정답자 추가, 힌트 사용, 게임 종료 처리 Closes #429 * refactor: ChatRoom에서 게임 필드 분리 - 게임 관련 필드 제거 (gameStatus, currentRound 등) - activeGameSessionId 필드 추가 - 게임 상태는 GameSession으로 분리됨 Note: 의존 코드 수정은 #431에서 진행 Closes #430 * refactor: GameSession 기반으로 전체 게임 로직 리팩토링 - GameService: ChatRoom 대신 GameSession 사용 - GameStatsService: GameSession 매개변수로 변경 - CommandService: GameSession 기반 점수 조회 - GameHandler: GameSession 기반 REST API - WebSocketMessageHandler: GameSession 기반 브로드캐스트 - GameStatusResponse, ScoreboardResponse: GameSession 매개변수 Closes #431 * feat: GameSessionHandler Lambda 및 게임 세션 API 구현 - POST /rooms/{roomId}/games - 게임 세션 생성 - GET /games/{gameSessionId} - 게임 상태 조회 (재접속용) - POST /games/{gameSessionId}/start - 게임 시작 - POST /games/{gameSessionId}/stop - 게임 종료 모든 응답에 serverTime 포함 (타이머 동기화) 출제자에게만 currentWord 포함 Closes #432, #433 * feat: 게임 시작 7분 후 자동 종료 기능 구현 - GameConfig에 gameTimeLimit() 메서드 추가 (기본값: 420초) - GameSchedulerClient 유틸리티 클래스 생성 (EventBridge Scheduler 연동) - GameService에 스케줄 생성/취소 로직 추가 - GameAutoCloseHandler Lambda 함수 생성 - template.yaml에 Lambda, IAM Role, Schedule Group 추가 Closes #417 * fix : 메모리 증가 및 Lambda 응답 제한 시간 Cognito 트리거 제한시간과 동일하게 수정 (#439) * feat: 캐치마인드 게임 방 분리 기능 구현 (#455) - Room 타입 분리 (CHAT/GAME) - RoomType, RoomStatus enum 추가 - ChatRoom 모델에 type, gameType, gameSettings, status, hostId 필드 추가 - GameSettings 모델 추가 - 방 생성/조회 API 수정 - CreateRoomRequest에 type, gameType, gameSettings 필드 추가 - 방 목록 조회 시 type, gameType, status 필터 지원 - 게임 시작 조건 검증 - GAME 타입 방에서만 게임 시작 가능 (GAME_007 에러) - 게임 재시작 API 구현 (#452) - POST /rooms/{roomId}/game/restart 엔드포인트 추가 - 방장만 재시작 가능 (GAME_009 에러) - 게임 진행 중 재시작 불가 (GAME_008 에러) - 방장 변경 로직 구현 (#453) - 방장 퇴장 시 다음 멤버에게 자동 이전 - 모든 멤버 퇴장 시 방 자동 삭제 - WebSocket 메시지 타입 추가 (#454) - ROOM_STATUS_CHANGE, HOST_CHANGE 메시지 타입 추가 - WebSocketMessageHelper에 빌더 메서드 추가 - 테스트 추가 - RoomType, RoomStatus enum 테스트 - GameSettings 모델 테스트 - ChattingErrorCode 테스트 업데이트 Related: #440, #441, #442, #443, #444, #445, #446 Closes: #447, #448, #449, #450, #451, #452, #453, #454 * feat: 참가자 닉네임 및 방장 변경 WebSocket 알림 구현 (#456) - RoomParticipant DTO 추가 (userId, nickname, isHost) - ChatRoomQueryService에 닉네임 조회 메서드 추가 - getParticipantsWithNicknames(): 참가자 목록 + 닉네임 - getHostNickname(): 방장 닉네임 조회 - ChatRoomHandler.getRoom() 응답에 participants, hostNickname 추가 - ChatRoomCommandService.leaveRoom()에서 방장 변경 시 WebSocket 브로드캐스트 Related: #440 * fix: ChatRoomFunction에 UserTable DynamoDB 권한 추가 - 참가자/방장 닉네임 조회를 위한 UserTable 읽기 권한 추가 - DynamoDBReadPolicy로 UserTable 접근 허용 Fixes #457 * fix: GameSettings에 @DynamoDbBean 어노테이션 추가 - DynamoDB Enhanced Client가 중첩 객체를 직렬화/역직렬화할 수 있도록 수정 - ChatRoom 저장/조회 시 GameSettings 변환 오류 해결 Fixes #457 * fix: ChatRoomFunction에 WEBSOCKET_ENDPOINT 환경변수 및 권한 추가 - leaveRoom에서 WebSocketBroadcaster 사용을 위한 환경변수 추가 - execute-api:ManageConnections 권한 추가 * fix: WebSocket Lambda 함수들에 WEBSOCKET_ENDPOINT 환경변수 추가 - WebSocketConnectFunction에 WEBSOCKET_ENDPOINT 추가 - WebSocketDisconnectFunction에 WEBSOCKET_ENDPOINT 추가 - execute-api:ManageConnections 권한 추가 * fix: Grammar WebSocket Lambda 환경 변수 및 권한 추가 - GrammarStreamingConnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - GrammarStreamingDisconnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - 두 함수에 execute-api:ManageConnections 권한 추가 * feat: GSI1SK 확장성 있는 재설계 및 DB 레벨 필터링 ## 변경 사항 ### GSI1SK 포맷 변경 - 기존: {level}#{createdAt} - 신규: {type}#{gameType}#{status}#{level}#{createdAt} ### 지원 쿼리 패턴 - 전체 방: GSI1PK = "ROOMS" - 게임방만: begins_with(GSI1SK, "GAME#") - 캐치마인드만: begins_with(GSI1SK, "GAME#CATCHMIND#") - 대기중 캐치마인드: begins_with(GSI1SK, "GAME#CATCHMIND#WAITING#") ### 파일 수정 - ChatRoomCommandService.java: 방 생성 시 새 GSI1SK 포맷 적용 - ChatRoomRepository.java: findByFilters() 메서드 추가, updateStatus() 메서드 추가 - ChatRoomQueryService.java: 메모리 필터링 제거, DB 레벨 필터링으로 변경 - GameService.java: 게임 시작/종료 시 방 상태 업데이트 (GSI1SK 포함) ### 마이그레이션 - scripts/migrate-gsi1sk.sh: 기존 데이터 마이그레이션 스크립트 - 37개 기존 방 마이그레이션 완료 * fix: increase stats query limit to 100 days - Adjust maximum allowable limit for recent stats query from 30 to 100 days to support extended data range responses. * feat: improve game round and connection management logic - Added handling for game round timeout (`ROUND_TIMEOUT`) in WebSocketMessageHandler. - Enhanced game start broadcast to include `currentWord` for the drawer only. - Updated ConnectionRepository to remove duplicate user connections in the same room. - Added `currentWordEnglish` to GameSession for better answer verification. - Normalized ChatRoom levels to match database storage format. - Updated template.yaml to include DynamoDBReadPolicy for VocabTable. * refactor: 코드 정리 및 미사용 클래스 제거 (#459) * refactor: remove unused WebSocketResponseUtil * refactor: add AutoCloseable to WebSocketBroadcaster * refactor: remove unused TestService * refactor: remove unused WordService * refactor: remove unused DailyStudyService * refactor: remove unused UserWordService * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * fix: resolve N+1 query in StatsService * refactor(all): DI 패턴 및 전략 패턴 적용 (#461) - Repository, Service, Handler에 DI 생성자 추가 (테스트 용이성) - BadgeService에 Strategy 패턴 적용 (뱃지 조건 검증 로직 분리) * refactor: relocate and restructure seed data files - Moved `question-homes.json` and `words.json` from subdirectories to `seed` folder. - Updated file paths for better organization and clarity. * chore: seed 데이터 폴더 구조 정리 * feat: add CI/CD pipeline configuration for CodePipeline * fix: add SNS topic policy and DependsOn for notification rule * fix: correct paths in buildspec.yml for CodeBuild * fix: remove hardcoded JAVA_HOME, use runtime default * fix: add gradle wrapper for CI/CD build * fix: use single line sam package command with hardcoded bucket * fix: use existing stack name group2-englishstudy-chatting * fix: add missing WEBSOCKET_ENDPOINT env var to WebSocket connect functions * docs: update FRONTEND-API-GUIDE with new RoomType/RoomStatus structure * fix: update WebSocketDisconnectHandler to use GameSession model - Remove references to deleted ChatRoom game fields - Use GameSessionRepository to finish active game sessions - Use ChatRoomRepository.updateStatus() for room state management * perf: optimize CI/CD build time - Add pip cache for SAM CLI dependencies - Enable Gradle parallel builds and build cache - Add SAM build cache (.aws-sam) - Use sam build --parallel --cached - Skip SAM CLI install if already cached - Remove unnecessary 'clean' to leverage cache * feat: add custom CodeBuild Docker image with pre-installed tools - Dockerfile with Java 21 + SAM CLI + Gradle pre-installed - build-and-push.sh script for ECR deployment - Updated buildspec.yml for custom image (removes SAM CLI install) - Expected build time reduction: ~30-40 seconds * feature : AI 영어 회화 연습 기능 (#468) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix: remove typo in SpeakingConnectionRepository * chore: trigger build test with custom Docker image * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes --------- Co-authored-by: hyein Heo <128613248+hye-inA@users.noreply.github.com> * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * fix: add CORS headers to API Gateway error responses (#479) API Gateway의 인증 실패 등 에러 응답에 CORS 헤더가 누락되어 CloudFront를 통한 프론트엔드 요청이 차단되는 문제 수정 - UNAUTHORIZED (401) 응답에 CORS 헤더 추가 - ACCESS_DENIED (403) 응답에 CORS 헤더 추가 - DEFAULT_4XX/5XX 응답에 CORS 헤더 추가 - EXPIRED_TOKEN 응답에 CORS 헤더 추가 * feat(news): 뉴스 도메인 기반 구조 구축 (#385) - NewsCategory, QuizType enum 추가 - NewsKey 상수 클래스 추가 - NewsErrorCode 예외 클래스 추가 - NewsArticle, KeywordInfo, QuizQuestion 모델 추가 - NewsArticleRepository CRUD 구현 - NewsTable DynamoDB 테이블 정의 - CORS GatewayResponses 설정 추가 * feat(news): 뉴스 수집 파이프라인 구현 (#386) - NewsApiClient: NewsAPI 연동 서비스 - RssFeedParser: RSS 피드 파싱 (BBC, VOA, NPR) - NewsDuplicateChecker: URL 기반 중복 필터링 - NewsCollectorService: 수집 오케스트레이션 - NewsCollectionHandler: Lambda 핸들러 - EventBridge 스케줄러: 매일 18시 KST 실행 * refactor(news): NewsAPI 제거, RSS만 사용 - NewsApiClient 삭제 - SSM Parameter 의존성 제거 - RSS 소스당 7개씩 수집 (BBC, VOA, NPR = 약 21개/일) * feat(news): AI 뉴스 분석 시스템 구현 (#387) - NewsAnalysisService: AI 분석 통합 서비스 - Bedrock: CEFR 난이도 분석 (A1~C2) - Bedrock: 3줄 요약 + 퀴즈 3문제 생성 - Comprehend: 핵심 키워드 추출 - NewsCollectorService: 수집 시 자동 분석 연동 - GSI1/GSI2 키 자동 설정 (레벨별, 카테고리별 조회) * feat(news): 뉴스 학습 API 구현 (#388) - NewsQueryService: 뉴스 조회 서비스 - NewsHandler: API 핸들러 - GET /news - 목록 조회 (level, category 필터) - GET /news/today - 오늘의 뉴스 - GET /news/recommended - 내 레벨 맞춤 추천 - GET /news/{articleId} - 상세 조회 (조회수 증가) - template.yaml: NewsFunction Lambda 추가 * feat(news): 뉴스 학습 부가 기능 구현 (#389) - 읽기 완료 기록 API (POST /news/{articleId}/read) - 북마크 토글 API (POST /news/{articleId}/bookmark) - 북마크 목록 조회 API (GET /news/bookmarks) - 학습 통계 조회 API (GET /news/stats) - TTS 오디오 URL 조회 API (GET /news/{articleId}/audio) - UserNewsRecord 모델 추가 - UserNewsRepository 추가 - NewsLearningService 추가 * feat(news): 복합 퀴즈 시스템 구현 (#471) - 퀴즈 조회 API (GET /news/{articleId}/quiz) - 퀴즈 제출 API (POST /news/{articleId}/quiz) - 퀴즈 기록 조회 API (GET /news/quiz/history) - NewsQuizResult 모델 추가 - QuizAnswerResult 모델 추가 - NewsQuizRepository 추가 - NewsQuizService 추가 * feat(news): 단어 수집 & Vocabulary 연동 구현 (#472) - 단어 수집 API (POST /news/{articleId}/words) - 수집 단어 목록 API (GET /news/words) - 단어 상세 조회 API (GET /news/{articleId}/words/{word}) - 단어 삭제 API (DELETE /news/{articleId}/words/{word}) - Vocabulary 연동 API (POST /news/words/{word}/sync) - NewsWordCollect 모델 추가 - NewsWordRepository 추가 - NewsWordService 추가 * feat: add multi-environment deployment support (dev/test/prod) * fix: update buildspec.yml to deploy prod environment with parameter overrides * feat(news): 뉴스 도메인 기반 구조 구축 (#385) - NewsCategory, QuizType enum 추가 - NewsKey 상수 클래스 추가 - NewsErrorCode 예외 클래스 추가 - NewsArticle, KeywordInfo, QuizQuestion 모델 추가 - NewsArticleRepository CRUD 구현 - NewsTable DynamoDB 테이블 정의 - CORS GatewayResponses 설정 추가 * feat(news): 뉴스 수집 파이프라인 구현 (#386) - NewsApiClient: NewsAPI 연동 서비스 - RssFeedParser: RSS 피드 파싱 (BBC, VOA, NPR) - NewsDuplicateChecker: URL 기반 중복 필터링 - NewsCollectorService: 수집 오케스트레이션 - NewsCollectionHandler: Lambda 핸들러 - EventBridge 스케줄러: 매일 18시 KST 실행 * refactor(news): NewsAPI 제거, RSS만 사용 - NewsApiClient 삭제 - SSM Parameter 의존성 제거 - RSS 소스당 7개씩 수집 (BBC, VOA, NPR = 약 21개/일) * feat(news): AI 뉴스 분석 시스템 구현 (#387) - NewsAnalysisService: AI 분석 통합 서비스 - Bedrock: CEFR 난이도 분석 (A1~C2) - Bedrock: 3줄 요약 + 퀴즈 3문제 생성 - Comprehend: 핵심 키워드 추출 - NewsCollectorService: 수집 시 자동 분석 연동 - GSI1/GSI2 키 자동 설정 (레벨별, 카테고리별 조회) * feat(news): 뉴스 학습 API 구현 (#388) - NewsQueryService: 뉴스 조회 서비스 - NewsHandler: API 핸들러 - GET /news - 목록 조회 (level, category 필터) - GET /news/today - 오늘의 뉴스 - GET /news/recommended - 내 레벨 맞춤 추천 - GET /news/{articleId} - 상세 조회 (조회수 증가) - template.yaml: NewsFunction Lambda 추가 * feat(news): 뉴스 학습 부가 기능 구현 (#389) - 읽기 완료 기록 API (POST /news/{articleId}/read) - 북마크 토글 API (POST /news/{articleId}/bookmark) - 북마크 목록 조회 API (GET /news/bookmarks) - 학습 통계 조회 API (GET /news/stats) - TTS 오디오 URL 조회 API (GET /news/{articleId}/audio) - UserNewsRecord 모델 추가 - UserNewsRepository 추가 - NewsLearningService 추가 * feat(news): 복합 퀴즈 시스템 구현 (#471) - 퀴즈 조회 API (GET /news/{articleId}/quiz) - 퀴즈 제출 API (POST /news/{articleId}/quiz) - 퀴즈 기록 조회 API (GET /news/quiz/history) - NewsQuizResult 모델 추가 - QuizAnswerResult 모델 추가 - NewsQuizRepository 추가 - NewsQuizService 추가 * feat(news): 단어 수집 & Vocabulary 연동 구현 (#472) - 단어 수집 API (POST /news/{articleId}/words) - 수집 단어 목록 API (GET /news/words) - 단어 상세 조회 API (GET /news/{articleId}/words/{word}) - 단어 삭제 API (DELETE /news/{articleId}/words/{word}) - Vocabulary 연동 API (POST /news/words/{word}/sync) - NewsWordCollect 모델 추가 - NewsWordRepository 추가 - NewsWordService 추가 * feat: add multi-environment deployment support (dev/test/prod) * fix: update buildspec.yml to deploy prod environment with parameter overrides * refactor : AI 말하기 Websocket 구현 -> REST API 구현으로 리팩토링 (#490) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix : 오타 수정 * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * fix: revert buildspec.yml to build-only for CloudFormation deploy stage * fix: revert buildspec.yml to build-only for CloudFormation deploy stage * fix: update all API Gateway StageName to use Environment parameter * fix: correct VocabularyTable and ContentBucket references in NewsFunction * fix: correct VocabularyTable and ContentBucket references in NewsFunction * fix: add stack name prefix to GameScheduleGroup and daily-stats schedule to avoid conflicts * fix: add stack name prefix to GameScheduleGroup and daily-stats schedule to avoid conflicts * feat: add support for existing Cognito User Pool reuse across environments * fix: add conditional Cognito ARN reference in API Gateway Authorizer * fix: remove Cognito resources completely, use existing Cognito only * fix: remove Cognito resources completely, use existing Cognito only * feat : speaking rest API 람다 함수 추가 * feat: add S3 bucket resource and fix environment-specific endpoints - Add ContentBucket S3 resource for content storage - Replace hardcoded /dev with ${Environment} in all WebSocket endpoints - Update Output URLs to use dynamic environment stage * fix: add stack name prefix to news-collection schedule name Prevent EventBridge rule name conflicts across environments * fix: add X-Requested-With and Accept headers to CORS config Enable additional headers for CloudFront CORS compatibility * fix: use environment variable for S3 bucket URLs Replace hardcoded bucket name with BUCKET_NAME env var for multi-env support: - PreSignUpHandler: dynamic default profile URL - PostConfirmationHandler: dynamic default profile URL - UserService: dynamic default profile URL - BadgeType: dynamic badge image base URL * fix: disable authorizer for CORS preflight requests Add AddDefaultAuthorizerToCorsPreflight: false to prevent Cognito Authorizer from blocking OPTIONS requests * feat : speaking REST API 람다 함수 추가 (#491) * feature : OPIc 세션관리 + 답변 처리 파이프라인 구현 (#413) * feat : OPIc 질문 & 세션 관련 dto 생성 * refactor : @DynamoDbBean 주석 추가 * feat : 주제 + 소주제 + 레벨로 오픽 질문 조회 추가 * feat : OPIc 세션, 질문, 답변 종합 handler 구현 * feat : 오픽 주제, 소주제별 seed 데이터 추가 * refactor : Transcribe API KEY 환경변수명 수정 * refactor : Bedrock에 사용하는 클로드 모델 변경 * refactor : S3 Key 대신 Proxy에서 요청하는 Base64 값으로 변환 * feat: GAME_START, ROUND_END 메시지에 serverTime 추가 - broadcastGameStart(): serverTime, roundDuration 필드 추가 - broadcastRoundEnd(): serverTime, roundStartTime, roundDuration 필드 추가 - GameService.endRound(): data에 roundStartTime, roundDuration 포함 - 타이머 동기화 버그 수정을 위한 서버 시간 제공 * feat: WebSocketMessageHelper 유틸리티 클래스 추가 - domain 필드 포함 메시지 생성 헬퍼 - DOMAIN_CHAT, DOMAIN_GAME 상수 정의 - buildChatMessage(), buildGameMessage() 메서드 * feat: 모든 WebSocket 메시지에 domain 필드 추가 - ScoreUpdateMessage에 domain 필드 추가 - broadcastGameStart에 domain:"game" 추가 - broadcastRoundEnd에 domain:"game" 추가 - broadcastCorrectAnswerMessage에 domain:"game" 추가 - handleCommandResult 시스템 메시지에 domain:"game" 추가 - handleRegularMessage 채팅 메시지에 domain:"chat" 추가 Closes #426, #427 * feat: GameSession 모델 클래스 생성 - DynamoDB Enhanced Client 어노테이션 적용 - GSI1: roomId로 활성 게임 세션 조회 가능 - 게임 상태 관리용 헬퍼 메서드 포함 Closes #428 * feat: GameSessionRepository 구현 - 기본 CRUD (save, findById, delete) - roomId로 활성/전체 게임 세션 조회 - 상태, 라운드, 점수 업데이트 메서드 - 정답자 추가, 힌트 사용, 게임 종료 처리 Closes #429 * refactor: ChatRoom에서 게임 필드 분리 - 게임 관련 필드 제거 (gameStatus, currentRound 등) - activeGameSessionId 필드 추가 - 게임 상태는 GameSession으로 분리됨 Note: 의존 코드 수정은 #431에서 진행 Closes #430 * refactor: GameSession 기반으로 전체 게임 로직 리팩토링 - GameService: ChatRoom 대신 GameSession 사용 - GameStatsService: GameSession 매개변수로 변경 - CommandService: GameSession 기반 점수 조회 - GameHandler: GameSession 기반 REST API - WebSocketMessageHandler: GameSession 기반 브로드캐스트 - GameStatusResponse, ScoreboardResponse: GameSession 매개변수 Closes #431 * feat: GameSessionHandler Lambda 및 게임 세션 API 구현 - POST /rooms/{roomId}/games - 게임 세션 생성 - GET /games/{gameSessionId} - 게임 상태 조회 (재접속용) - POST /games/{gameSessionId}/start - 게임 시작 - POST /games/{gameSessionId}/stop - 게임 종료 모든 응답에 serverTime 포함 (타이머 동기화) 출제자에게만 currentWord 포함 Closes #432, #433 * feat: 게임 시작 7분 후 자동 종료 기능 구현 - GameConfig에 gameTimeLimit() 메서드 추가 (기본값: 420초) - GameSchedulerClient 유틸리티 클래스 생성 (EventBridge Scheduler 연동) - GameService에 스케줄 생성/취소 로직 추가 - GameAutoCloseHandler Lambda 함수 생성 - template.yaml에 Lambda, IAM Role, Schedule Group 추가 Closes #417 * fix : 메모리 증가 및 Lambda 응답 제한 시간 Cognito 트리거 제한시간과 동일하게 수정 (#439) * feat: 캐치마인드 게임 방 분리 기능 구현 (#455) - Room 타입 분리 (CHAT/GAME) - RoomType, RoomStatus enum 추가 - ChatRoom 모델에 type, gameType, gameSettings, status, hostId 필드 추가 - GameSettings 모델 추가 - 방 생성/조회 API 수정 - CreateRoomRequest에 type, gameType, gameSettings 필드 추가 - 방 목록 조회 시 type, gameType, status 필터 지원 - 게임 시작 조건 검증 - GAME 타입 방에서만 게임 시작 가능 (GAME_007 에러) - 게임 재시작 API 구현 (#452) - POST /rooms/{roomId}/game/restart 엔드포인트 추가 - 방장만 재시작 가능 (GAME_009 에러) - 게임 진행 중 재시작 불가 (GAME_008 에러) - 방장 변경 로직 구현 (#453) - 방장 퇴장 시 다음 멤버에게 자동 이전 - 모든 멤버 퇴장 시 방 자동 삭제 - WebSocket 메시지 타입 추가 (#454) - ROOM_STATUS_CHANGE, HOST_CHANGE 메시지 타입 추가 - WebSocketMessageHelper에 빌더 메서드 추가 - 테스트 추가 - RoomType, RoomStatus enum 테스트 - GameSettings 모델 테스트 - ChattingErrorCode 테스트 업데이트 Related: #440, #441, #442, #443, #444, #445, #446 Closes: #447, #448, #449, #450, #451, #452, #453, #454 * feat: 참가자 닉네임 및 방장 변경 WebSocket 알림 구현 (#456) - RoomParticipant DTO 추가 (userId, nickname, isHost) - ChatRoomQueryService에 닉네임 조회 메서드 추가 - getParticipantsWithNicknames(): 참가자 목록 + 닉네임 - getHostNickname(): 방장 닉네임 조회 - ChatRoomHandler.getRoom() 응답에 participants, hostNickname 추가 - ChatRoomCommandService.leaveRoom()에서 방장 변경 시 WebSocket 브로드캐스트 Related: #440 * fix: ChatRoomFunction에 UserTable DynamoDB 권한 추가 - 참가자/방장 닉네임 조회를 위한 UserTable 읽기 권한 추가 - DynamoDBReadPolicy로 UserTable 접근 허용 Fixes #457 * fix: GameSettings에 @DynamoDbBean 어노테이션 추가 - DynamoDB Enhanced Client가 중첩 객체를 직렬화/역직렬화할 수 있도록 수정 - ChatRoom 저장/조회 시 GameSettings 변환 오류 해결 Fixes #457 * fix: ChatRoomFunction에 WEBSOCKET_ENDPOINT 환경변수 및 권한 추가 - leaveRoom에서 WebSocketBroadcaster 사용을 위한 환경변수 추가 - execute-api:ManageConnections 권한 추가 * fix: WebSocket Lambda 함수들에 WEBSOCKET_ENDPOINT 환경변수 추가 - WebSocketConnectFunction에 WEBSOCKET_ENDPOINT 추가 - WebSocketDisconnectFunction에 WEBSOCKET_ENDPOINT 추가 - execute-api:ManageConnections 권한 추가 * fix: Grammar WebSocket Lambda 환경 변수 및 권한 추가 - GrammarStreamingConnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - GrammarStreamingDisconnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - 두 함수에 execute-api:ManageConnections 권한 추가 * feat: GSI1SK 확장성 있는 재설계 및 DB 레벨 필터링 - 기존: {level}#{createdAt} - 신규: {type}#{gameType}#{status}#{level}#{createdAt} - 전체 방: GSI1PK = "ROOMS" - 게임방만: begins_with(GSI1SK, "GAME#") - 캐치마인드만: begins_with(GSI1SK, "GAME#CATCHMIND#") - 대기중 캐치마인드: begins_with(GSI1SK, "GAME#CATCHMIND#WAITING#") - ChatRoomCommandService.java: 방 생성 시 새 GSI1SK 포맷 적용 - ChatRoomRepository.java: findByFilters() 메서드 추가, updateStatus() 메서드 추가 - ChatRoomQueryService.java: 메모리 필터링 제거, DB 레벨 필터링으로 변경 - GameService.java: 게임 시작/종료 시 방 상태 업데이트 (GSI1SK 포함) - scripts/migrate-gsi1sk.sh: 기존 데이터 마이그레이션 스크립트 - 37개 기존 방 마이그레이션 완료 * fix: increase stats query limit to 100 days - Adjust maximum allowable limit for recent stats query from 30 to 100 days to support extended data range responses. * feat: improve game round and connection management logic - Added handling for game round timeout (`ROUND_TIMEOUT`) in WebSocketMessageHandler. - Enhanced game start broadcast to include `currentWord` for the drawer only. - Updated ConnectionRepository to remove duplicate user connections in the same room. - Added `currentWordEnglish` to GameSession for better answer verification. - Normalized ChatRoom levels to match database storage format. - Updated template.yaml to include DynamoDBReadPolicy for VocabTable. * refactor: 코드 정리 및 미사용 클래스 제거 (#459) * refactor: remove unused WebSocketResponseUtil * refactor: add AutoCloseable to WebSocketBroadcaster * refactor: remove unused TestService * refactor: remove unused WordService * refactor: remove unused DailyStudyService * refactor: remove unused UserWordService * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * fix: resolve N+1 query in StatsService * refactor(all): DI 패턴 및 전략 패턴 적용 (#461) - Repository, Service, Handler에 DI 생성자 추가 (테스트 용이성) - BadgeService에 Strategy 패턴 적용 (뱃지 조건 검증 로직 분리) * refactor: relocate and restructure seed data files - Moved `question-homes.json` and `words.json` from subdirectories to `seed` folder. - Updated file paths for better organization and clarity. * chore: seed 데이터 폴더 구조 정리 * feat: add CI/CD pipeline configuration for CodePipeline * fix: add SNS topic policy and DependsOn for notification rule * fix: correct paths in buildspec.yml for CodeBuild * fix: remove hardcoded JAVA_HOME, use runtime default * fix: add gradle wrapper for CI/CD build * fix: use single line sam package command with hardcoded bucket * fix: use existing stack name group2-englishstudy-chatting * fix: add missing WEBSOCKET_ENDPOINT env var to WebSocket connect functions * docs: update FRONTEND-API-GUIDE with new RoomType/RoomStatus structure * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix: update WebSocketDisconnectHandler to use GameSession model - Remove references to deleted ChatRoom game fields - Use GameSessionRepository to finish active game sessions - Use ChatRoomRepository.updateStatus() for room state management * perf: optimize CI/CD build time - Add pip cache for SAM CLI dependencies - Enable Gradle parallel builds and build cache - Add SAM build cache (.aws-sam) - Use sam build --parallel --cached - Skip SAM CLI install if already cached - Remove unnecessary 'clean' to leverage cache * feat: add custom CodeBuild Docker image with pre-installed tools - Dockerfile with Java 21 + SAM CLI + Gradle pre-installed - build-and-push.sh script for ECR deployment - Updated buildspec.yml for custom image (removes SAM CLI install) - Expected build time reduction: ~30-40 seconds * feature : AI 영어 회화 연습 기능 (#468) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix: remove typo in SpeakingConnectionRepository * fix : 오타 수정 * chore: trigger build test with custom Docker image * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * fix: add CORS headers to API Gateway error responses (#479) API Gateway의 인증 실패 등 에러 응답에 CORS 헤더가 누락되어 CloudFront를 통한 프론트엔드 요청이 차단되는 문제 수정 - UNAUTHORIZED (401) 응답에 CORS 헤더 추가 - ACCESS_DENIED (403) 응답에 CORS 헤더 추가 - DEFAULT_4XX/5XX 응답에 CORS 헤더 추가 - EXPIRED_TOKEN 응답에 CORS 헤더 추가 * feat : speaking rest API 람다 함수 추가 --------- Co-authored-by: ddingjoo * refactor : speaking service 재사용 * refactor : AI 영어 회화 연습 코드 리팩토링 * feature : test 벡엔드 서버에 AI 말하기 연습 기능 배포 (#492) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix : 오타 수정 * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * feat : speaking rest API 람다 함수 추가 * refactor : speaking service 재사용 * refactor : AI 영어 회화 연습 코드 리팩토링 --------- Co-authored-by: DDING JOO * feat : handleChat 메서드 JsonNull 체크 푸가 * feature : handleChat 메서드 JsonNull 체크 추가 (#493) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix : 오타 수정 * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * feat : speaking rest API 람다 함수 추가 * refactor : speaking service 재사용 * refactor : AI 영어 회화 연습 코드 리팩토링 * feat : handleChat 메서드 JsonNull 체크 푸가 --------- Co-authored-by: DDING JOO * feat(news): 뉴스 학습 배지 시스템 구현 (#473) - 14개 뉴스 관련 배지 추가 (읽기, 퀴즈, 단어수집, 연속학습, 마스터) - UserStats에 뉴스 통계 필드 추가 - 6개 뉴스 배지 Strategy 클래스 생성 - 뉴스 읽기/퀴즈/단어수집 시 통계 업데이트 및 배지 체크 연동 * fix: add PATCH method to CORS AllowMethods * test: BadgeType 개수 테스트 수정 (15 -> 29) * fix: CORS PATCH 메서드 추가 * docs: 뉴스 기능 프론트엔드 연동 가이드 작성 * fix: NewsCollectionFunction에 Bedrock, Comprehend 권한 추가 * fix: add null check for collectWord request body - Add INVALID_REQUEST error code to NewsErrorCode - Check body and word field before accessing in collectWord() - Prevents NullPointerException when request body is malformed * feat: enhance stats API and bookmark response for frontend - Add DAILY stats update for news read/quiz/word collection - Add /stats/dashboard endpoint with frontend-requested format - today: wordsLearned, newsRead, quizzesTaken, wordsTotal - overall: totalWordsLearned, totalNewsRead, averageAccuracy, streaks - weeklyProgress: last 7 days with date/wordsLearned/newsRead - Add news-related fields to all stats API responses - Fix bookmark API to include full article details (title, summary, etc.) * feat: add category classification to news AI analysis - Add category field to AnalysisResult record - Update Bedrock prompt to classify articles into categories (WORLD, POLITICS, BUSINESS, TECH, SCIENCE, HEALTH, SPORTS, ENTERTAINMENT, LIFESTYLE) - Parse and set category from AI response - Set GSI2 (CATEGORY#) index when category is available * fix: add /stats/dashboard endpoint to template.yaml * fix: filter by ARTICLE# prefix in findById to avoid returning UserNewsRecord * docs: add News API troubleshooting guide * feat: add Cognito authorizer to News API and enhance keyword extraction - Set CognitoAuthorizer for all News API endpoints in template.yaml - Update Bedrock AI to extract keywords with meanings and examples - Add fallback to Comprehend for keyword extraction when Bedrock fails - Modify KeywordInfo model to include example field - Adjust AI prompt to include keyword extraction with examples - Update AnalysisResult to store keywords and parse them from AI response * Revert "Merge branch 'test' into prod" This reverts commit c1a958eac6799d5838a76e4078ae304bcdb62278, reversing changes made to a6662e0cfc46c6e12f20a89f0df285ff282160bc. * feat: enhance bookmark and reading status tracking for News API - Add bookmark and reading status to individual article responses - Include bookmark status in paginated news responses - Modify `KeywordInfo` to support Korean translations (`meaningKo`) - Update AI prompts to extract Korean meanings for keywords * refactor : session_id가 null 체크 추가 * feat : template 환경변수 리펙토링 * fix : sessionId NullPointerException 에러 수정 (#496) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix : 오타 수정 * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * feat : speaking rest API 람다 함수 추가 * refactor : speaking service 재사용 * refactor : AI 영어 회화 연습 코드 리팩토링 * feat : handleChat 메서드 JsonNull 체크 푸가 * refactor : session_id가 null 체크 추가 * feat : template 환경변수 리펙토링 --------- Co-authored-by: DDING JOO * feat: enhance bookmark and reading status tracking for News API - Add bookmark and reading status to individual article responses - Include bookmark status in paginated news responses - Modify `KeywordInfo` to support Korean translations (`meaningKo`) - Update AI prompts to extract Korean meanings for keywords * feat: add category filtering to UserWord API with enhanced query logic - Introduce `category` filtering to `getUserWords` API - Update WordCategory enums to include "news" category - Apply category filter after enrichment with word info - Adjust query limits for bookmarked and incorrect words queries * feat: add default value for ExistingCognitoClientId in template.yaml - Set default to an empty string to ensure compatibility with templates using this parameter without explicitly specifying a value. * fix: SpeakingHandler getStringOrNull 컴파일 에러 수정 * fix: SpeakingHandler getStringOrNull 컴파일 에러 수정 * fix: Bedrock 키워드(meaningKo 포함)를 article에 저장하도록 수정 * fix: Bedrock 키워드(meaningKo 포함)를 article에 저장하도록 수정 * fix: Bedrock 키워드(meaningKo 포함)를 article에 저장하도록 수정 * feat: add dashboard stats API and enhance news stats tracking - Introduce `/stats/dashboard` API for retrieving integrated user stats (today, overall, weekly progress, and level distribution) - Update `UserStats` model to include additional fields for news stats (newsRead, newsQuizCompleted, newsQuizPerfect, newsWordsCollected, etc.) - Add atomic updates to track news reading, quiz, and word stats in `UserStatsRepository` - Modify CloudFormation `template.yaml` to register the new `/stats/dashboard` endpoint * feat: add dashboard stats API and enhance news stats tracking - Introduce `/stats/dashboard` API for retrieving integrated user stats (today, overall, weekly progress, and level distribution) - Update `UserStats` model to include additional fields for news stats (newsRead, newsQuizCompleted, newsQuizPerfect, newsWordsCollected, etc.) - Add atomic updates to track news reading, quiz, and word stats in `UserStatsRepository` - Modify CloudFormation `template.yaml` to register the new `/stats/dashboard` endpoint * feat: add dashboard stats API and enhance news stats tracking - Introduce `/stats/dashboard` API for retrieving integrated user stats (today, overall, weekly progress, and level distribution) - Update `UserStats` model to include additional fields for news stats (newsRead, newsQuizCompleted, newsQuizPerfect, newsWordsCollected, etc.) - Add atomic updates to track news reading, quiz, and word stats in `UserStatsRepository` - Modify CloudFormation `template.yaml` to register the new `/stats/dashboard` endpoint * refactor: format code with consistent indentation and spacing - Apply consistent formatting to improve code readability across multiple files - Adjust indentation, spacing, and alignment in model classes, DTOs, and repository methods * fix: filter by ARTICLE# prefix in findById to avoid returning bookmark records * feat : Speaking 관련 template 람다 함수 및 테이블 추가 * feat : 말하기 기능에 polly 서비스 권한 추가 --------- Co-authored-by: DDING JOO * feature : transcribe API KEY 추가 (#516) * feat: EnvConfig 유틸리티 추가 및 환경 변수 검증 적용 환경 변수 미설정 시 명확한 에러 메시지를 제공하는 EnvConfig 유틸리티를 추가하고, 기존 System.getenv 호출을 EnvConfig.getRequired/getOrDefault로 대체함. - EnvConfig: getRequired, getOrDefault, getIntOrDefault, getLongOrDefault 메서드 제공 - Lambda Cold Start 시점에 환경 변수 누락을 조기 감지 - 기존 Config 클래스(WebSocketConfig, RoomTokenConfig) EnvConfig 사용으로 통일 Closes #403 * refactor: TestService submitTest 메서드 책임 분리 submitTest 메서드를 단일 책임 원칙에 맞게 리팩토링: - gradeAnswers(): 답안 채점 및 결과 집계 - isAnswerCorrect(): 단일 답안 정답 여부 판단 - buildResultItem(): 결과 항목 생성 - saveTestResult(): 테스트 결과 저장 - GradingResult record: 채점 결과 캡슐화 TestService와 TestCommandService 모두 동일하게 적용 Closes #404 * refactor: 하드코딩된 설정값 환경 변수로 외부화 각 도메인별 Config 클래스를 생성하여 하드코딩된 값들을 환경 변수로 설정 가능하게 변경. 기본값이 있어 환경 변수 미설정 시에도 기존 동작 유지. ## 새로 추가된 Config 클래스 - GrammarConfig: SESSION_TTL_DAYS, MAX_HISTORY_MESSAGES, MAX_TOKENS 등 - GameConfig: TOTAL_ROUNDS, ROUND_TIME_LIMIT, QUICK_GUESS_THRESHOLD_MS - VocabularyConfig: NEW_WORDS_COUNT, REVIEW_WORDS_COUNT, 상태 전이 임계값 등 ## 지원하는 환경 변수 - GRAMMAR_SESSION_TTL_DAYS, GRAMMAR_MAX_HISTORY_MESSAGES, GRAMMAR_MAX_TOKENS - GAME_TOTAL_ROUNDS, GAME_ROUND_TIME_LIMIT, GAME_QUICK_GUESS_THRESHOLD_MS - VOCAB_NEW_WORDS_COUNT, VOCAB_REVIEW_WORDS_COUNT - VOCAB_TRANSITION_TO_REVIEWING, VOCAB_TRANSITION_TO_MASTERED Closes #406 * test: StudyLevel enum 단위 테스트 추가 * test: Difficulty enum 단위 테스트 추가 * test: StudyConfig 단위 테스트 추가 * test: EnvConfig 단위 테스트 추가 * test: PaginatedResult 단위 테스트 추가 * test: JsonUtil 단위 테스트 추가 * test: CursorUtil 단위 테스트 추가 * test: CommonErrorCode 단위 테스트 추가 * test: CommonException 단위 테스트 추가 * test: BadgeType enum 단위 테스트 추가 * test: BadgeKey 상수 단위 테스트 추가 * test: WordStatus enum 단위 테스트 추가 * test: TestType enum 단위 테스트 추가 * test: VocabularyConfig 단위 테스트 추가 * test: VocabKey 상수 단위 테스트 추가 * test: VocabularyErrorCode 단위 테스트 추가 * test: VocabularyException 단위 테스트 추가 * test: SpacedRepetitionContext 단위 테스트 추가 * test: GrammarLevel enum 단위 테스트 추가 * test: GrammarConfig 단위 테스트 추가 * test: GrammarErrorCode 단위 테스트 추가 * test: GrammarException 단위 테스트 추가 * test: GameStatus enum 단위 테스트 추가 * test: GameConfig 단위 테스트 추가 * test: ChattingErrorCode 단위 테스트 추가 * test: ChattingException 단위 테스트 추가 * fix: OPIc FeedbackResponse.java 문법 오류 수정 * style: AwsClients 코드 포맷팅 * style: EnvConfig 코드 포맷팅 * style: RoomTokenConfig 코드 포맷팅 * style: WebSocketConfig 코드 포맷팅 * style: JsonUtil 코드 포맷팅 * style: GameConfig 코드 포맷팅 * style: GrammarConfig 코드 포맷팅 * style: GrammarKey 코드 포맷팅 * style: GrammarErrorCode 코드 포맷팅 * style: GrammarException 코드 포맷팅 * style: BedrockGrammarCheckFactory 코드 포맷팅 * style: GrammarConversationService 코드 포맷팅 * style: FeedbackResponse 코드 포맷팅 * style: SessionReportResponse 코드 포맷팅 * style: SpeakingError 코드 포맷팅 * style: SpeakingErrorType 코드 포맷팅 * style: OPIcException 코드 포맷팅 * style: OPIcAnswer 코드 포맷팅 * style: OPIcQuestion 코드 포맷팅 * style: OPIcSession 코드 포맷팅 * style: OPIcRepository 코드 포맷팅 * style: FeedbackService 코드 포맷팅 * style: TranscribeProxyService 코드 포맷팅 * style: VocabularyConfig 코드 포맷팅 * style: DailyStudyCommandService 코드 포맷팅 * style: TestCommandService 코드 포맷팅 * style: TestService 코드 포맷팅 * style: CommonErrorCodeSpec 코드 포맷팅 * style: JsonUtilSpec 코드 포맷팅 * style: BadgeTypeSpec 코드 포맷팅 * style: ChattingErrorCodeSpec 코드 포맷팅 * style: GrammarLevelSpec 코드 포맷팅 * style: GrammarErrorCodeSpec 코드 포맷팅 * style: VocabularyErrorCodeSpec 코드 포맷팅 * feature : OPIc 세션관리 + 답변 처리 파이프라인 구현 (#413) * feat : OPIc 질문 & 세션 관련 dto 생성 * refactor : @DynamoDbBean 주석 추가 * feat : 주제 + 소주제 + 레벨로 오픽 질문 조회 추가 * feat : OPIc 세션, 질문, 답변 종합 handler 구현 * feat : 오픽 주제, 소주제별 seed 데이터 추가 * refactor : Transcribe API KEY 환경변수명 수정 * refactor : Bedrock에 사용하는 클로드 모델 변경 * refactor : S3 Key 대신 Proxy에서 요청하는 Base64 값으로 변환 * feat: GAME_START, ROUND_END 메시지에 serverTime 추가 - broadcastGameStart(): serverTime, roundDuration 필드 추가 - broadcastRoundEnd(): serverTime, roundStartTime, roundDuration 필드 추가 - GameService.endRound(): data에 roundStartTime, roundDuration 포함 - 타이머 동기화 버그 수정을 위한 서버 시간 제공 * feat: WebSocketMessageHelper 유틸리티 클래스 추가 - domain 필드 포함 메시지 생성 헬퍼 - DOMAIN_CHAT, DOMAIN_GAME 상수 정의 - buildChatMessage(), buildGameMessage() 메서드 * feat: 모든 WebSocket 메시지에 domain 필드 추가 - ScoreUpdateMessage에 domain 필드 추가 - broadcastGameStart에 domain:"game" 추가 - broadcastRoundEnd에 domain:"game" 추가 - broadcastCorrectAnswerMessage에 domain:"game" 추가 - handleCommandResult 시스템 메시지에 domain:"game" 추가 - handleRegularMessage 채팅 메시지에 domain:"chat" 추가 Closes #426, #427 * feat: GameSession 모델 클래스 생성 - DynamoDB Enhanced Client 어노테이션 적용 - GSI1: roomId로 활성 게임 세션 조회 가능 - 게임 상태 관리용 헬퍼 메서드 포함 Closes #428 * feat: GameSessionRepository 구현 - 기본 CRUD (save, findById, delete) - roomId로 활성/전체 게임 세션 조회 - 상태, 라운드, 점수 업데이트 메서드 - 정답자 추가, 힌트 사용, 게임 종료 처리 Closes #429 * refactor: ChatRoom에서 게임 필드 분리 - 게임 관련 필드 제거 (gameStatus, currentRound 등) - activeGameSessionId 필드 추가 - 게임 상태는 GameSession으로 분리됨 Note: 의존 코드 수정은 #431에서 진행 Closes #430 * refactor: GameSession 기반으로 전체 게임 로직 리팩토링 - GameService: ChatRoom 대신 GameSession 사용 - GameStatsService: GameSession 매개변수로 변경 - CommandService: GameSession 기반 점수 조회 - GameHandler: GameSession 기반 REST API - WebSocketMessageHandler: GameSession 기반 브로드캐스트 - GameStatusResponse, ScoreboardResponse: GameSession 매개변수 Closes #431 * feat: GameSessionHandler Lambda 및 게임 세션 API 구현 - POST /rooms/{roomId}/games - 게임 세션 생성 - GET /games/{gameSessionId} - 게임 상태 조회 (재접속용) - POST /games/{gameSessionId}/start - 게임 시작 - POST /games/{gameSessionId}/stop - 게임 종료 모든 응답에 serverTime 포함 (타이머 동기화) 출제자에게만 currentWord 포함 Closes #432, #433 * feat: 게임 시작 7분 후 자동 종료 기능 구현 - GameConfig에 gameTimeLimit() 메서드 추가 (기본값: 420초) - GameSchedulerClient 유틸리티 클래스 생성 (EventBridge Scheduler 연동) - GameService에 스케줄 생성/취소 로직 추가 - GameAutoCloseHandler Lambda 함수 생성 - template.yaml에 Lambda, IAM Role, Schedule Group 추가 Closes #417 * fix : 메모리 증가 및 Lambda 응답 제한 시간 Cognito 트리거 제한시간과 동일하게 수정 (#439) * feat: 캐치마인드 게임 방 분리 기능 구현 (#455) - Room 타입 분리 (CHAT/GAME) - RoomType, RoomStatus enum 추가 - ChatRoom 모델에 type, gameType, gameSettings, status, hostId 필드 추가 - GameSettings 모델 추가 - 방 생성/조회 API 수정 - CreateRoomRequest에 type, gameType, gameSettings 필드 추가 - 방 목록 조회 시 type, gameType, status 필터 지원 - 게임 시작 조건 검증 - GAME 타입 방에서만 게임 시작 가능 (GAME_007 에러) - 게임 재시작 API 구현 (#452) - POST /rooms/{roomId}/game/restart 엔드포인트 추가 - 방장만 재시작 가능 (GAME_009 에러) - 게임 진행 중 재시작 불가 (GAME_008 에러) - 방장 변경 로직 구현 (#453) - 방장 퇴장 시 다음 멤버에게 자동 이전 - 모든 멤버 퇴장 시 방 자동 삭제 - WebSocket 메시지 타입 추가 (#454) - ROOM_STATUS_CHANGE, HOST_CHANGE 메시지 타입 추가 - WebSocketMessageHelper에 빌더 메서드 추가 - 테스트 추가 - RoomType, RoomStatus enum 테스트 - GameSettings 모델 테스트 - ChattingErrorCode 테스트 업데이트 Related: #440, #441, #442, #443, #444, #445, #446 Closes: #447, #448, #449, #450, #451, #452, #453, #454 * feat: 참가자 닉네임 및 방장 변경 WebSocket 알림 구현 (#456) - RoomParticipant DTO 추가 (userId, nickname, isHost) - ChatRoomQueryService에 닉네임 조회 메서드 추가 - getParticipantsWithNicknames(): 참가자 목록 + 닉네임 - getHostNickname(): 방장 닉네임 조회 - ChatRoomHandler.getRoom() 응답에 participants, hostNickname 추가 - ChatRoomCommandService.leaveRoom()에서 방장 변경 시 WebSocket 브로드캐스트 Related: #440 * fix: ChatRoomFunction에 UserTable DynamoDB 권한 추가 - 참가자/방장 닉네임 조회를 위한 UserTable 읽기 권한 추가 - DynamoDBReadPolicy로 UserTable 접근 허용 Fixes #457 * fix: GameSettings에 @DynamoDbBean 어노테이션 추가 - DynamoDB Enhanced Client가 중첩 객체를 직렬화/역직렬화할 수 있도록 수정 - ChatRoom 저장/조회 시 GameSettings 변환 오류 해결 Fixes #457 * fix: ChatRoomFunction에 WEBSOCKET_ENDPOINT 환경변수 및 권한 추가 - leaveRoom에서 WebSocketBroadcaster 사용을 위한 환경변수 추가 - execute-api:ManageConnections 권한 추가 * fix: WebSocket Lambda 함수들에 WEBSOCKET_ENDPOINT 환경변수 추가 - WebSocketConnectFunction에 WEBSOCKET_ENDPOINT 추가 - WebSocketDisconnectFunction에 WEBSOCKET_ENDPOINT 추가 - execute-api:ManageConnections 권한 추가 * fix: Grammar WebSocket Lambda 환경 변수 및 권한 추가 - GrammarStreamingConnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - GrammarStreamingDisconnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - 두 함수에 execute-api:ManageConnections 권한 추가 * feat: GSI1SK 확장성 있는 재설계 및 DB 레벨 필터링 ## 변경 사항 ### GSI1SK 포맷 변경 - 기존: {level}#{createdAt} - 신규: {type}#{gameType}#{status}#{level}#{createdAt} ### 지원 쿼리 패턴 - 전체 방: GSI1PK = "ROOMS" - 게임방만: begins_with(GSI1SK, "GAME#") - 캐치마인드만: begins_with(GSI1SK, "GAME#CATCHMIND#") - 대기중 캐치마인드: begins_with(GSI1SK, "GAME#CATCHMIND#WAITING#") ### 파일 수정 - ChatRoomCommandService.java: 방 생성 시 새 GSI1SK 포맷 적용 - ChatRoomRepository.java: findByFilters() 메서드 추가, updateStatus() 메서드 추가 - ChatRoomQueryService.java: 메모리 필터링 제거, DB 레벨 필터링으로 변경 - GameService.java: 게임 시작/종료 시 방 상태 업데이트 (GSI1SK 포함) ### 마이그레이션 - scripts/migrate-gsi1sk.sh: 기존 데이터 마이그레이션 스크립트 - 37개 기존 방 마이그레이션 완료 * fix: increase stats query limit to 100 days - Adjust maximum allowable limit for recent stats query from 30 to 100 days to support extended data range responses. * feat: improve game round and connection management logic - Added handling for game round timeout (`ROUND_TIMEOUT`) in WebSocketMessageHandler. - Enhanced game start broadcast to include `currentWord` for the drawer only. - Updated ConnectionRepository to remove duplicate user connections in the same room. - Added `currentWordEnglish` to GameSession for better answer verification. - Normalized ChatRoom levels to match database storage format. - Updated template.yaml to include DynamoDBReadPolicy for VocabTable. * refactor: 코드 정리 및 미사용 클래스 제거 (#459) * refactor: remove unused WebSocketResponseUtil * refactor: add AutoCloseable to WebSocketBroadcaster * refactor: remove unused TestService * refactor: remove unused WordService * refactor: remove unused DailyStudyService * refactor: remove unused UserWordService * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * fix: resolve N+1 query in StatsService * refactor(all): DI 패턴 및 전략 패턴 적용 (#461) - Repository, Service, Handler에 DI 생성자 추가 (테스트 용이성) - BadgeService에 Strategy 패턴 적용 (뱃지 조건 검증 로직 분리) * refactor: relocate and restructure seed data files - Moved `question-homes.json` and `words.json` from subdirectories to `seed` folder. - Updated file paths for better organization and clarity. * chore: seed 데이터 폴더 구조 정리 * feat: add CI/CD pipeline configuration for CodePipeline * fix: add SNS topic policy and DependsOn for notification rule * fix: correct paths in buildspec.yml for CodeBuild * fix: remove hardcoded JAVA_HOME, use runtime default * fix: add gradle wrapper for CI/CD build * fix: use single line sam package command with hardcoded bucket * fix: use existing stack name group2-englishstudy-chatting * fix: add missing WEBSOCKET_ENDPOINT env var to WebSocket connect functions * docs: update FRONTEND-API-GUIDE with new RoomType/RoomStatus structure * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix: update WebSocketDisconnectHandler to use GameSession model - Remove references to deleted ChatRoom game fields - Use GameSessionRepository to finish active game sessions - Use ChatRoomRepository.updateStatus() for room state management * perf: optimize CI/CD build time - Add pip cache for SAM CLI dependencies - Enable Gradle parallel builds and build cache - Add SAM build cache (.aws-sam) - Use sam build --parallel --cached - Skip SAM CLI install if already cached - Remove unnecessary 'clean' to leverage cache * feat: add custom CodeBuild Docker image with pre-installed tools - Dockerfile with Java 21 + SAM CLI + Gradle pre-installed - build-and-push.sh script for ECR deployment - Updated buildspec.yml for custom image (removes SAM CLI install) - Expected build time reduction: ~30-40 seconds * feature : AI 영어 회화 연습 기능 (#468) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix: remove typo in SpeakingConnectionRepository * fix : 오타 수정 * chore: trigger build test with custom Docker image * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes * Release: 캐치마인드 게임 분리, AI 회화 연습, CI/CD 파이프라인 (#469) * feature : OPIc 세션관리 + 답변 처리 파이프라인 구현 (#413) * feat : OPIc 질문 & 세션 관련 dto 생성 * refactor : @DynamoDbBean 주석 추가 * feat : 주제 + 소주제 + 레벨로 오픽 질문 조회 추가 * feat : OPIc 세션, 질문, 답변 종합 handler 구현 * feat : 오픽 주제, 소주제별 seed 데이터 추가 * refactor : Transcribe API KEY 환경변수명 수정 * refactor : Bedrock에 사용하는 클로드 모델 변경 * refactor : S3 Key 대신 Proxy에서 요청하는 Base64 값으로 변환 * feat: GAME_START, ROUND_END 메시지에 serverTime 추가 - broadcastGameStart(): serverTime, roundDuration 필드 추가 - broadcastRoundEnd(): serverTime, roundStartTime, roundDuration 필드 추가 - GameService.endRound(): data에 roundStartTime, roundDuration 포함 - 타이머 동기화 버그 수정을 위한 서버 시간 제공 * feat: WebSocketMessageHelper 유틸리티 클래스 추가 - domain 필드 포함 메시지 생성 헬퍼 - DOMAIN_CHAT, DOMAIN_GAME 상수 정의 - buildChatMessage(), buildGameMessage() 메서드 * feat: 모든 WebSocket 메시지에 domain 필드 추가 - ScoreUpdateMessage에 domain 필드 추가 - broadcastGameStart에 domain:"game" 추가 - broadcastRoundEnd에 domain:"game" 추가 - broadcastCorrectAnswerMessage에 domain:"game" 추가 - handleCommandResult 시스템 메시지에 domain:"game" 추가 - handleRegularMessage 채팅 메시지에 domain:"chat" 추가 Closes #426, #427 * feat: GameSession 모델 클래스 생성 - DynamoDB Enhanced Client 어노테이션 적용 - GSI1: roomId로 활성 게임 세션 조회 가능 - 게임 상태 관리용 헬퍼 메서드 포함 Closes #428 * feat: GameSessionRepository 구현 - 기본 CRUD (save, findById, delete) - roomId로 활성/전체 게임 세션 조회 - 상태, 라운드, 점수 업데이트 메서드 - 정답자 추가, 힌트 사용, 게임 종료 처리 Closes #429 * refactor: ChatRoom에서 게임 필드 분리 - 게임 관련 필드 제거 (gameStatus, currentRound 등) - activeGameSessionId 필드 추가 - 게임 상태는 GameSession으로 분리됨 Note: 의존 코드 수정은 #431에서 진행 Closes #430 * refactor: GameSession 기반으로 전체 게임 로직 리팩토링 - GameService: ChatRoom 대신 GameSession 사용 - GameStatsService: GameSession 매개변수로 변경 - CommandService: GameSession 기반 점수 조회 - GameHandler: GameSession 기반 REST API - WebSocketMessageHandler: GameSession 기반 브로드캐스트 - GameStatusResponse, ScoreboardResponse: GameSession 매개변수 Closes #431 * feat: GameSessionHandler Lambda 및 게임 세션 API 구현 - POST /rooms/{roomId}/games - 게임 세션 생성 - GET /games/{gameSessionId} - 게임 상태 조회 (재접속용) - POST /games/{gameSessionId}/start - 게임 시작 - POST /games/{gameSessionId}/stop - 게임 종료 모든 응답에 serverTime 포함 (타이머 동기화) 출제자에게만 currentWord 포함 Closes #432, #433 * feat: 게임 시작 7분 후 자동 종료 기능 구현 - GameConfig에 gameTimeLimit() 메서드 추가 (기본값: 420초) - GameSchedulerClient 유틸리티 클래스 생성 (EventBridge Scheduler 연동) - GameService에 스케줄 생성/취소 로직 추가 - GameAutoCloseHandler Lambda 함수 생성 - template.yaml에 Lambda, IAM Role, Schedule Group 추가 Closes #417 * fix : 메모리 증가 및 Lambda 응답 제한 시간 Cognito 트리거 제한시간과 동일하게 수정 (#439) * feat: 캐치마인드 게임 방 분리 기능 구현 (#455) - Room 타입 분리 (CHAT/GAME) - RoomType, RoomStatus enum 추가 - ChatRoom 모델에 type, gameType, gameSettings, status, hostId 필드 추가 - GameSettings 모델 추가 - 방 생성/조회 API 수정 - CreateRoomRequest에 type, gameType, gameSettings 필드 추가 - 방 목록 조회 시 type, gameType, status 필터 지원 - 게임 시작 조건 검증 - GAME 타입 방에서만 게임 시작 가능 (GAME_007 에러) - 게임 재시작 API 구현 (#452) - POST /rooms/{roomId}/game/restart 엔드포인트 추가 - 방장만 재시작 가능 (GAME_009 에러) - 게임 진행 중 재시작 불가 (GAME_008 에러) - 방장 변경 로직 구현 (#453) - 방장 퇴장 시 다음 멤버에게 자동 이전 - 모든 멤버 퇴장 시 방 자동 삭제 - WebSocket 메시지 타입 추가 (#454) - ROOM_STATUS_CHANGE, HOST_CHANGE 메시지 타입 추가 - WebSocketMessageHelper에 빌더 메서드 추가 - 테스트 추가 - RoomType, RoomStatus enum 테스트 - GameSettings 모델 테스트 - ChattingErrorCode 테스트 업데이트 Related: #440, #441, #442, #443, #444, #445, #446 Closes: #447, #448, #449, #450, #451, #452, #453, #454 * feat: 참가자 닉네임 및 방장 변경 WebSocket 알림 구현 (#456) - RoomParticipant DTO 추가 (userId, nickname, isHost) - ChatRoomQueryService에 닉네임 조회 메서드 추가 - getParticipantsWithNicknames(): 참가자 목록 + 닉네임 - getHostNickname(): 방장 닉네임 조회 - ChatRoomHandler.getRoom() 응답에 participants, hostNickname 추가 - ChatRoomCommandService.leaveRoom()에서 방장 변경 시 WebSocket 브로드캐스트 Related: #440 * fix: ChatRoomFunction에 UserTable DynamoDB 권한 추가 - 참가자/방장 닉네임 조회를 위한 UserTable 읽기 권한 추가 - DynamoDBReadPolicy로 UserTable 접근 허용 Fixes #457 * fix: GameSettings에 @DynamoDbBean 어노테이션 추가 - DynamoDB Enhanced Client가 중첩 객체를 직렬화/역직렬화할 수 있도록 수정 - ChatRoom 저장/조회 시 GameSettings 변환 오류 해결 Fixes #457 * fix: ChatRoomFunction에 WEBSOCKET_ENDPOINT 환경변수 및 권한 추가 - leaveRoom에서 WebSocketBroadcaster 사용을 위한 환경변수 추가 - execute-api:ManageConnections 권한 추가 * fix: WebSocket Lambda 함수들에 WEBSOCKET_ENDPOINT 환경변수 추가 - WebSocketConnectFunction에 WEBSOCKET_ENDPOINT 추가 - WebSocketDisconnectFunction에 WEBSOCKET_ENDPOINT 추가 - execute-api:ManageConnections 권한 추가 * fix: Grammar WebSocket Lambda 환경 변수 및 권한 추가 - GrammarStreamingConnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - GrammarStreamingDisconnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - 두 함수에 execute-api:ManageConnections 권한 추가 * feat: GSI1SK 확장성 있는 재설계 및 DB 레벨 필터링 ## 변경 사항 ### GSI1SK 포맷 변경 - 기존: {level}#{createdAt} - 신규: {type}#{gameType}#{status}#{level}#{createdAt} ### 지원 쿼리 패턴 - 전체 방: GSI1PK = "ROOMS" - 게임방만: begins_with(GSI1SK, "GAME#") - 캐치마인드만: begins_with(GSI1SK, "GAME#CATCHMIND#") - 대기중 캐치마인드: begins_with(GSI1SK, "GAME#CATCHMIND#WAITING#") ### 파일 수정 - ChatRoomCommandService.java: 방 생성 시 새 GSI1SK 포맷 적용 - ChatRoomRepository.java: findByFilters() 메서드 추가, updateStatus() 메서드 추가 - ChatRoomQueryService.java: 메모리 필터링 제거, DB 레벨 필터링으로 변경 - GameService.java: 게임 시작/종료 시 방 상태 업데이트 (GSI1SK 포함) ### 마이그레이션 - scripts/migrate-gsi1sk.sh: 기존 데이터 마이그레이션 스크립트 - 37개 기존 방 마이그레이션 완료 * fix: increase stats query limit to 100 days - Adjust maximum allowable limit for recent stats query from 30 to 100 days to support extended data range responses. * feat: improve game round and connection management logic - Added handling for game round timeout (`ROUND_TIMEOUT`) in WebSocketMessageHandler. - Enhanced game start broadcast to include `currentWord` for the drawer only. - Updated ConnectionRepository to remove duplicate user connections in the same room. - Added `currentWordEnglish` to GameSession for better answer verification. - Normalized ChatRoom levels to match database storage format. - Updated template.yaml to include DynamoDBReadPolicy for VocabTable. * refactor: 코드 정리 및 미사용 클래스 제거 (#459) * refactor: remove unused WebSocketResponseUtil * refactor: add AutoCloseable to WebSocketBroadcaster * refactor: remove unused TestService * refactor: remove unused WordService * refactor: remove unused DailyStudyService * refactor: remove unused UserWordService * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * fix: resolve N+1 query in StatsService * refactor(all): DI 패턴 및 전략 패턴 적용 (#461) - Repository, Service, Handler에 DI 생성자 추가 (테스트 용이성) - BadgeService에 Strategy 패턴 적용 (뱃지 조건 검증 로직 분리) * refactor: relocate and restructure seed data files - Moved `question-homes.json` and `words.json` from subdirectories to `seed` folder. - Updated file paths for better organization and clarity. * chore: seed 데이터 폴더 구조 정리 * feat: add CI/CD pipeline configuration for CodePipeline * fix: add SNS topic policy and DependsOn for notification rule * fix: correct paths in buildspec.yml for CodeBuild * fix: remove hardcoded JAVA_HOME, use runtime default * fix: add gradle wrapper for CI/CD build * fix: use single line sam package command with hardcoded bucket * fix: use existing stack name group2-englishstudy-chatting * fix: add missing WEBSOCKET_ENDPOINT env var to WebSocket connect functions * docs: update FRONTEND-API-GUIDE with new RoomType/RoomStatus structure * fix: update WebSocketDisconnectHandler to use GameSession model - Remove references to deleted ChatRoom game fields - Use GameSessionRepository to finish active game sessions - Use ChatRoomRepository.updateStatus() for room state management * perf: optimize CI/CD build time - Add pip cache for SAM CLI dependencies - Enable Gradle parallel builds and build cache - Add SAM build cache (.aws-sam) - Use sam build --parallel --cached - Skip SAM CLI install if already cached - Remove unnecessary 'clean' to leverage cache * feat: add custom CodeBuild Docker image with pre-installed tools - Dockerfile with Java 21 + SAM CLI + Gradle pre-installed - build-and-push.sh script for ECR deployment - Updated buildspec.yml for custom image (removes SAM CLI install) - Expected build time reduction: ~30-40 seconds * feature : AI 영어 회화 연습 기능 (#468) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix: remove typo in SpeakingConnectionRepository * chore: trigger build test with custom Docker image * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes --------- Co-authored-by: hyein Heo <128613248+hye-inA@users.noreply.github.com> * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * fix: add CORS headers to API Gateway error responses (#479) API Gateway의 인증 실패 등 에러 응답에 CORS 헤더가 누락되어 CloudFront를 통한 프론트엔드 요청이 차단되는 문제 수정 - UNAUTHORIZED (401) 응답에 CORS 헤더 추가 - ACCESS_DENIED (403) 응답에 CORS 헤더 추가 - DEFAULT_4XX/5XX 응답에 CORS 헤더 추가 - EXPIRED_TOKEN 응답에 CORS 헤더 추가 * feat(news): 뉴스 도메인 기반 구조 구축 (#385) - NewsCategory, QuizType enum 추가 - NewsKey 상수 클래스 추가 - NewsErrorCode 예외 클래스 추가 - NewsArticle, KeywordInfo, QuizQuestion 모델 추가 - NewsArticleRepository CRUD 구현 - NewsTable DynamoDB 테이블 정의 - CORS GatewayResponses 설정 추가 * feat(news): 뉴스 수집 파이프라인 구현 (#386) - NewsApiClient: NewsAPI 연동 서비스 - RssFeedParser: RSS 피드 파싱 (BBC, VOA, NPR) - NewsDuplicateChecker: URL 기반 중복 필터링 - NewsCollectorService: 수집 오케스트레이션 - NewsCollectionHandler: Lambda 핸들러 - EventBridge 스케줄러: 매일 18시 KST 실행 * refactor(news): NewsAPI 제거, RSS만 사용 - NewsApiClient 삭제 - SSM Parameter 의존성 제거 - RSS 소스당 7개씩 수집 (BBC, VOA, NPR = 약 21개/일) * feat(news): AI 뉴스 분석 시스템 구현 (#387) - NewsAnalysisService: AI 분석 통합 서비스 - Bedrock: CEFR 난이도 분석 (A1~C2) - Bedrock: 3줄 요약 + 퀴즈 3문제 생성 - Comprehend: 핵심 키워드 추출 - NewsCollectorService: 수집 시 자동 분석 연동 - GSI1/GSI2 키 자동 설정 (레벨별, 카테고리별 조회) * feat(news): 뉴스 학습 API 구현 (#388) - NewsQueryService: 뉴스 조회 서비스 - NewsHandler: API 핸들러 - GET /news - 목록 조회 (level, category 필터) - GET /news/today - 오늘의 뉴스 - GET /news/recommended - 내 레벨 맞춤 추천 - GET /news/{articleId} - 상세 조회 (조회수 증가) - template.yaml: NewsFunction Lambda 추가 * feat(news): 뉴스 학습 부가 기능 구현 (#389) - 읽기 완료 기록 API (POST /news/{articleId}/read) - 북마크 토글 API (POST /news/{articleId}/bookmark) - 북마크 목록 조회 API (GET /news/bookmarks) - 학습 통계 조회 API (GET /news/stats) - TTS 오디오 URL 조회 API (GET /news/{articleId}/audio) - UserNewsRecord 모델 추가 - UserNewsRepository 추가 - NewsLearningService 추가 * feat(news): 복합 퀴즈 시스템 구현 (#471) - 퀴즈 조회 API (GET /news/{articleId}/quiz) - 퀴즈 제출 API (POST /news/{articleId}/quiz) - 퀴즈 기록 조회 API (GET /news/quiz/history) - NewsQuizResult 모델 추가 - QuizAnswerResult 모델 추가 - NewsQuizRepository 추가 - NewsQuizService 추가 * feat(news): 단어 수집 & Vocabulary 연동 구현 (#472) - 단어 수집 API (POST /news/{articleId}/words) - 수집 단어 목록 API (GET /news/words) - 단어 상세 조회 API (GET /news/{articleId}/words/{word}) - 단어 삭제 API (DELETE /news/{articleId}/words/{word}) - Vocabulary 연동 API (POST /news/words/{word}/sync) - NewsWordCollect 모델 추가 - NewsWordRepository 추가 - NewsWordService 추가 * feat: add multi-environment deployment support (dev/test/prod) * fix: update buildspec.yml to deploy prod environment with parameter overrides * feat(news): 뉴스 도메인 기반 구조 구축 (#385) - NewsCategory, QuizType enum 추가 - NewsKey 상수 클래스 추가 - NewsErrorCode 예외 클래스 추가 - NewsArticle, KeywordInfo, QuizQuestion 모델 추가 - NewsArticleRepository CRUD 구현 - NewsTable DynamoDB 테이블 정의 - CORS GatewayResponses 설정 추가 * feat(news): 뉴스 수집 파이프라인 구현 (#386) - NewsApiClient: NewsAPI 연동 서비스 - RssFeedParser: RSS 피드 파싱 (BBC, VOA, NPR) - NewsDuplicateChecker: URL 기반 중복 필터링 - NewsCollectorService: 수집 오케스트레이션 - NewsCollectionHandler: Lambda 핸들러 - EventBridge 스케줄러: 매일 18시 KST 실행 * refactor(news): NewsAPI 제거, RSS만 사용 - NewsApiClient 삭제 - SSM Parameter 의존성 제거 - RSS 소스당 7개씩 수집 (BBC, VOA, NPR = 약 21개/일) * feat(news): AI 뉴스 분석 시스템 구현 (#387) - NewsAnalysisService: AI 분석 통합 서비스 - Bedrock: CEFR 난이도 분석 (A1~C2) - Bedrock: 3줄 요약 + 퀴즈 3문제 생성 - Comprehend: 핵심 키워드 추출 - NewsCollectorService: 수집 시 자동 분석 연동 - GSI1/GSI2 키 자동 설정 (레벨별, 카테고리별 조회) * feat(news): 뉴스 학습 API 구현 (#388) - NewsQueryService: 뉴스 조회 서비스 - NewsHandler: API 핸들러 - GET /news - 목록 조회 (level, category 필터) - GET /news/today - 오늘의 뉴스 - GET /news/recommended - 내 레벨 맞춤 추천 - GET /news/{articleId} - 상세 조회 (조회수 증가) - template.yaml: NewsFunction Lambda 추가 * feat(news): 뉴스 학습 부가 기능 구현 (#389) - 읽기 완료 기록 API (POST /news/{articleId}/read) - 북마크 토글 API (POST /news/{articleId}/bookmark) - 북마크 목록 조회 API (GET /news/bookmarks) - 학습 통계 조회 API (GET /news/stats) - TTS 오디오 URL 조회 API (GET /news/{articleId}/audio) - UserNewsRecord 모델 추가 - UserNewsRepository 추가 - NewsLearningService 추가 * feat(news): 복합 퀴즈 시스템 구현 (#471) - 퀴즈 조회 API (GET /news/{articleId}/quiz) - 퀴즈 제출 API (POST /news/{articleId}/quiz) - 퀴즈 기록 조회 API (GET /news/quiz/history) - NewsQuizResult 모델 추가 - QuizAnswerResult 모델 추가 - NewsQuizRepository 추가 - NewsQuizService 추가 * feat(news): 단어 수집 & Vocabulary 연동 구현 (#472) - 단어 수집 API (POST /news/{articleId}/words) - 수집 단어 목록 API (GET /news/words) - 단어 상세 조회 API (GET /news/{articleId}/words/{word}) - 단어 삭제 API (DELETE /news/{articleId}/words/{word}) - Vocabulary 연동 API (POST /news/words/{word}/sync) - NewsWordCollect 모델 추가 - NewsWordRepository 추가 - NewsWordService 추가 * feat: add multi-environment deployment support (dev/test/prod) * fix: update buildspec.yml to deploy prod environment with parameter overrides * refactor : AI 말하기 Websocket 구현 -> REST API 구현으로 리팩토링 (#490) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix : 오타 수정 * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * fix: revert buildspec.yml to build-only for CloudFormation deploy stage * fix: revert buildspec.yml to build-only for CloudFormation deploy stage * fix: update all API Gateway StageName to use Environment parameter * fix: correct VocabularyTable and ContentBucket references in NewsFunction * fix: correct VocabularyTable and ContentBucket references in NewsFunction * fix: add stack name prefix to GameScheduleGroup and daily-stats schedule to avoid conflicts * fix: add stack name prefix to GameScheduleGroup and daily-stats schedule to avoid conflicts * feat: add support for existing Cognito User Pool reuse across environments * fix: add conditional Cognito ARN reference in API Gateway Authorizer * fix: remove Cognito resources completely, use existing Cognito only * fix: remove Cognito resources completely, use existing Cognito only * feat : speaking rest API 람다 함수 추가 * feat: add S3 bucket resource and fix environment-specific endpoints - Add ContentBucket S3 resource for content storage - Replace hardcoded /dev with ${Environment} in all WebSocket endpoints - Update Output URLs to use dynamic environment stage * fix: add stack name prefix to news-collection schedule name Prevent EventBridge rule name conflicts across environments * fix: add X-Requested-With and Accept headers to CORS config Enable additional headers for CloudFront CORS compatibility * fix: use environment variable for S3 bucket URLs Replace hardcoded bucket name with BUCKET_NAME env var for multi-env support: - PreSignUpHandler: dynamic default profile URL - PostConfirmationHandler: dynamic default profile URL - UserService: dynamic default profile URL - BadgeType: dynamic badge image base URL * fix: disable authorizer for CORS preflight requests Add AddDefaultAuthorizerToCorsPreflight: false to prevent Cognito Authorizer from blocking OPTIONS requests * feat : speaking REST API 람다 함수 추가 (#491) * feature : OPIc 세션관리 + 답변 처리 파이프라인 구현 (#413) * feat : OPIc 질문 & 세션 관련 dto 생성 * refactor : @DynamoDbBean 주석 추가 * feat : 주제 + 소주제 + 레벨로 오픽 질문 조회 추가 * feat : OPIc 세션, 질문, 답변 종합 handler 구현 * feat : 오픽 주제, 소주제별 seed 데이터 추가 * refactor : Transcribe API KEY 환경변수명 수정 * refactor : Bedrock에 사용하는 클로드 모델 변경 * refactor : S3 Key 대신 Proxy에서 요청하는 Base64 값으로 변환 * feat: GAME_START, ROUND_END 메시지에 serverTime 추가 - broadcastGameStart(): serverTime, roundDuration 필드 추가 - broadcastRoundEnd(): serverTime, roundStartTime, roundDuration 필드 추가 - GameService.endRound(): data에 roundStartTime, roundDuration 포함 - 타이머 동기화 버그 수정을 위한 서버 시간 제공 * feat: WebSocketMessageHelper 유틸리티 클래스 추가 - domain 필드 포함 메시지 생성 헬퍼 - DOMAIN_CHAT, DOMAIN_GAME 상수 정의 - buildChatMessage(), buildGameMessage() 메서드 * feat: 모든 WebSocket 메시지에 domain 필드 추가 - ScoreUpdateMessage에 domain 필드 추가 - broadcastGameStart에 domain:"game" 추가 - broadcastRoundEnd에 domain:"game" 추가 - broadcastCorrectAnswerMessage에 domain:"game" 추가 - handleCommandResult 시스템 메시지에 domain:"game" 추가 - handleRegularMessage 채팅 메시지에 domain:"chat" 추가 Closes #426, #427 * feat: GameSession 모델 클래스 생성 - DynamoDB Enhanced Client 어노테이션 적용 - GSI1: roomId로 활성 게임 세션 조회 가능 - 게임 상태 관리용 헬퍼 메서드 포함 Closes #428 * feat: GameSessionRepository 구현 - 기본 CRUD (save, findById, delete) - roomId로 활성/전체 게임 세션 조회 - 상태, 라운드, 점수 업데이트 메서드 - 정답자 추가, 힌트 사용, 게임 종료 처리 Closes #429 * refactor: ChatRoom에서 게임 필드 분리 - 게임 관련 필드 제거 (gameStatus, currentRound 등) - activeGameSessionId 필드 추가 - 게임 상태는 GameSession으로 분리됨 Note: 의존 코드 수정은 #431에서 진행 Closes #430 * refactor: GameSession 기반으로 전체 게임 로직 리팩토링 - GameService: ChatRoom 대신 GameSession 사용 - GameStatsService: GameSession 매개변수로 변경 - CommandService: GameSession 기반 점수 조회 - GameHandler: GameSession 기반 REST API - WebSocketMessageHandler: GameSession 기반 브로드캐스트 - GameStatusResponse, ScoreboardResponse: GameSession 매개변수 Closes #431 * feat: GameSessionHandler Lambda 및 게임 세션 API 구현 - POST /rooms/{roomId}/games - 게임 세션 생성 - GET /games/{gameSessionId} - 게임 상태 조회 (재접속용) - POST /games/{gameSessionId}/start - 게임 시작 - POST /games/{gameSessionId}/stop - 게임 종료 모든 응답에 serverTime 포함 (타이머 동기화) 출제자에게만 currentWord 포함 Closes #432, #433 * feat: 게임 시작 7분 후 자동 종료 기능 구현 - GameConfig에 gameTimeLimit() 메서드 추가 (기본값: 420초) - GameSchedulerClient 유틸리티 클래스 생성 (EventBridge Scheduler 연동) - GameService에 스케줄 생성/취소 로직 추가 - GameAutoCloseHandler Lambda 함수 생성 - template.yaml에 Lambda, IAM Role, Schedule Group 추가 Closes #417 * fix : 메모리 증가 및 Lambda 응답 제한 시간 Cognito 트리거 제한시간과 동일하게 수정 (#439) * feat: 캐치마인드 게임 방 분리 기능 구현 (#455) - Room 타입 분리 (CHAT/GAME) - RoomType, RoomStatus enum 추가 - ChatRoom 모델에 type, gameType, gameSettings, status, hostId 필드 추가 - GameSettings 모델 추가 - 방 생성/조회 API 수정 - CreateRoomRequest에 type, gameType, gameSettings 필드 추가 - 방 목록 조회 시 type, gameType, status 필터 지원 - 게임 시작 조건 검증 - GAME 타입 방에서만 게임 시작 가능 (GAME_007 에러) - 게임 재시작 API 구현 (#452) - POST /rooms/{roomId}/game/restart 엔드포인트 추가 - 방장만 재시작 가능 (GAME_009 에러) - 게임 진행 중 재시작 불가 (GAME_008 에러) - 방장 변경 로직 구현 (#453) - 방장 퇴장 시 다음 멤버에게 자동 이전 - 모든 멤버 퇴장 시 방 자동 삭제 - WebSocket 메시지 타입 추가 (#454) - ROOM_STATUS_CHANGE, HOST_CHANGE 메시지 타입 추가 - WebSocketMessageHelper에 빌더 메서드 추가 - 테스트 추가 - RoomType, RoomStatus enum 테스트 - GameSettings 모델 테스트 - ChattingErrorCode 테스트 업데이트 Related: #440, #441, #442, #443, #444, #445, #446 Closes: #447, #448, #449, #450, #451, #452, #453, #454 * feat: 참가자 닉네임 및 방장 변경 WebSocket 알림 구현 (#456) - RoomParticipant DTO 추가 (userId, nickname, isHost) - ChatRoomQueryService에 닉네임 조회 메서드 추가 - getParticipantsWithNicknames(): 참가자 목록 + 닉네임 - getHostNickname(): 방장 닉네임 조회 - ChatRoomHandler.getRoom() 응답에 participants, hostNickname 추가 - ChatRoomCommandService.leaveRoom()에서 방장 변경 시 WebSocket 브로드캐스트 Related: #440 * fix: ChatRoomFunction에 UserTable DynamoDB 권한 추가 - 참가자/방장 닉네임 조회를 위한 UserTable 읽기 권한 추가 - DynamoDBReadPolicy로 UserTable 접근 허용 Fixes #457 * fix: GameSettings에 @DynamoDbBean 어노테이션 추가 - DynamoDB Enhanced Client가 중첩 객체를 직렬화/역직렬화할 수 있도록 수정 - ChatRoom 저장/조회 시 GameSettings 변환 오류 해결 Fixes #457 * fix: ChatRoomFunction에 WEBSOCKET_ENDPOINT 환경변수 및 권한 추가 - leaveRoom에서 WebSocketBroadcaster 사용을 위한 환경변수 추가 - execute-api:ManageConnections 권한 추가 * fix: WebSocket Lambda 함수들에 WEBSOCKET_ENDPOINT 환경변수 추가 - WebSocketConnectFunction에 WEBSOCKET_ENDPOINT 추가 - WebSocketDisconnectFunction에 WEBSOCKET_ENDPOINT 추가 - execute-api:ManageConnections 권한 추가 * fix: Grammar WebSocket Lambda 환경 변수 및 권한 추가 - GrammarStreamingConnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - GrammarStreamingDisconnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - 두 함수에 execute-api:ManageConnections 권한 추가 * feat: GSI1SK 확장성 있는 재설계 및 DB 레벨 필터링 - 기존: {level}#{createdAt} - 신규: {type}#{gameType}#{status}#{level}#{createdAt} - 전체 방: GSI1PK = "ROOMS" - 게임방만: begins_with(GSI1SK, "GAME#") - 캐치마인드만: begins_with(GSI1SK, "GAME#CATCHMIND#") - 대기중 캐치마인드: begins_with(GSI1SK, "GAME#CATCHMIND#WAITING#") - ChatRoomCommandService.java: 방 생성 시 새 GSI1SK 포맷 적용 - ChatRoomRepository.java: findByFilters() 메서드 추가, updateStatus() 메서드 추가 - ChatRoomQueryService.java: 메모리 필터링 제거, DB 레벨 필터링으로 변경 - GameService.java: 게임 시작/종료 시 방 상태 업데이트 (GSI1SK 포함) - scripts/migrate-gsi1sk.sh: 기존 데이터 마이그레이션 스크립트 - 37개 기존 방 마이그레이션 완료 * fix: increase stats query limit to 100 days - Adjust maximum allowable limit for recent stats query from 30 to 100 days to support extended data range responses. * feat: improve game round and connection management logic - Added handling for game round timeout (`ROUND_TIMEOUT`) in WebSocketMessageHandler. - Enhanced game start broadcast to include `currentWord` for the drawer only. - Updated ConnectionRepository to remove duplicate user connections in the same room. - Added `currentWordEnglish` to GameSession for better answer verification. - Normalized ChatRoom levels to match database storage format. - Updated template.yaml to include DynamoDBReadPolicy for VocabTable. * refactor: 코드 정리 및 미사용 클래스 제거 (#459) * refactor: remove unused WebSocketResponseUtil * refactor: add AutoCloseable to WebSocketBroadcaster * refactor: remove unused TestService * refactor: remove unused WordService * refactor: remove unused DailyStudyService * refactor: remove unused UserWordService * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * fix: resolve N+1 query in StatsService * refactor(all): DI 패턴 및 전략 패턴 적용 (#461) - Repository, Service, Handler에 DI 생성자 추가 (테스트 용이성) - BadgeService에 Strategy 패턴 적용 (뱃지 조건 검증 로직 분리) * refactor: relocate and restructure seed data files - Moved `question-homes.json` and `words.json` from subdirectories to `seed` folder. - Updated file paths for better organization and clarity. * chore: seed 데이터 폴더 구조 정리 * feat: add CI/CD pipeline configuration for CodePipeline * fix: add SNS topic policy and DependsOn for notification rule * fix: correct paths in buildspec.yml for CodeBuild * fix: remove hardcoded JAVA_HOME, use runtime default * fix: add gradle wrapper for CI/CD build * fix: use single line sam package command with hardcoded bucket * fix: use existing stack name group2-englishstudy-chatting * fix: add missing WEBSOCKET_ENDPOINT env var to WebSocket connect functions * docs: update FRONTEND-API-GUIDE with new RoomType/RoomStatus structure * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix: update WebSocketDisconnectHandler to use GameSession model - Remove references to deleted ChatRoom game fields - Use GameSessionRepository to finish active game sessions - Use ChatRoomRepository.updateStatus() for room state management * perf: optimize CI/CD build time - Add pip cache for SAM CLI dependencies - Enable Gradle parallel builds and build cache - Add SAM build cache (.aws-sam) - Use sam build --parallel --cached - Skip SAM CLI install if already cached - Remove unnecessary 'clean' to leverage cache * feat: add custom CodeBuild Docker image with pre-installed tools - Dockerfile with Java 21 + SAM CLI + Gradle pre-installed - build-and-push.sh script for ECR deployment - Updated buildspec.yml for custom image (removes SAM CLI install) - Expected build time reduction: ~30-40 seconds * feature : AI 영어 회화 연습 기능 (#468) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix: remove typo in SpeakingConnectionRepository * fix : 오타 수정 * chore: trigger build test with custom Docker image * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * fix: add CORS headers to API Gateway error responses (#479) API Gateway의 인증 실패 등 에러 응답에 CORS 헤더가 누락되어 CloudFront를 통한 프론트엔드 요청이 차단되는 문제 수정 - UNAUTHORIZED (401) 응답에 CORS 헤더 추가 - ACCESS_DENIED (403) 응답에 CORS 헤더 추가 - DEFAULT_4XX/5XX 응답에 CORS 헤더 추가 - EXPIRED_TOKEN 응답에 CORS 헤더 추가 * feat : speaking rest API 람다 함수 추가 --------- Co-authored-by: ddingjoo * refactor : speaking service 재사용 * refactor : AI 영어 회화 연습 코드 리팩토링 * feature : test 벡엔드 서버에 AI 말하기 연습 기능 배포 (#492) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix : 오타 수정 * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * feat : speaking rest API 람다 함수 추가 * refactor : speaking service 재사용 * refactor : AI 영어 회화 연습 코드 리팩토링 --------- Co-authored-by: DDING JOO * feat : handleChat 메서드 JsonNull 체크 푸가 * feature : handleChat 메서드 JsonNull 체크 추가 (#493) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix : 오타 수정 * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * feat : speaking rest API 람다 함수 추가 * refactor : speaking service 재사용 * refactor : AI 영어 회화 연습 코드 리팩토링 * feat : handleChat 메서드 JsonNull 체크 푸가 --------- Co-authored-by: DDING JOO * feat(news): 뉴스 학습 배지 시스템 구현 (#473) - 14개 뉴스 관련 배지 추가 (읽기, 퀴즈, 단어수집, 연속학습, 마스터) - UserStats에 뉴스 통계 필드 추가 - 6개 뉴스 배지 Strategy 클래스 생성 - 뉴스 읽기/퀴즈/단어수집 시 통계 업데이트 및 배지 체크 연동 * fix: add PATCH method to CORS AllowMethods * test: BadgeType 개수 테스트 수정 (15 -> 29) * fix: CORS PATCH 메서드 추가 * docs: 뉴스 기능 프론트엔드 연동 가이드 작성 * fix: NewsCollectionFunction에 Bedrock, Comprehend 권한 추가 * fix: add null check for collectWord request body - Add INVALID_REQUEST error code to NewsErrorCode - Check body and word field before accessing in collectWord() - Prevents NullPointerException when request body is malformed * feat: enhance stats API and bookmark response for frontend - Add DAILY stats update for news read/quiz/word collection - Add /stats/dashboard endpoint with frontend-requested format - today: wordsLearned, newsRead, quizzesTaken, wordsTotal - overall: totalWordsLearned, totalNewsRead, averageAccuracy, streaks - weeklyProgress: last 7 days with date/wordsLearned/newsRead - Add news-related fields to all stats API responses - Fix bookmark API to include full article details (title, summary, etc.) * feat: add category classification to news AI analysis - Add category field to AnalysisResult record - Update Bedrock prompt to classify articles into categories (WORLD, POLITICS, BUSINESS, TECH, SCIENCE, HEALTH, SPORTS, ENTERTAINMENT, LIFESTYLE) - Parse and set category from AI response - Set GSI2 (CATEGORY#) index when category is available * fix: add /stats/dashboard endpoint to template.yaml * fix: filter by ARTICLE# prefix in findById to avoid returning UserNewsRecord * docs: add News API troubleshooting guide * feat: add Cognito authorizer to News API and enhance keyword extraction - Set CognitoAuthorizer for all News API endpoints in template.yaml - Update Bedrock AI to extract keywords with meanings and examples - Add fallback to Comprehend for keyword extraction when Bedrock fails - Modify KeywordInfo model to include example field - Adjust AI prompt to include keyword extraction with examples - Update AnalysisResult to store keywords and parse them from AI response * Revert "Merge branch 'test' into prod" This reverts commit c1a958eac6799d5838a76e4078ae304bcdb62278, reversing changes made to a6662e0cfc46c6e12f20a89f0df285ff282160bc. * feat: enhance bookmark and reading status tracking for News API - Add bookmark and reading status to individual article responses - Include bookmark status in paginated news responses - Modify `KeywordInfo` to support Korean translations (`meaningKo`) - Update AI prompts to extract Korean meanings for keywords * refactor : session_id가 null 체크 추가 * feat : template 환경변수 리펙토링 * fix : sessionId NullPointerException 에러 수정 (#496) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix : 오타 수정 * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * feat : speaking rest API 람다 함수 추가 * refactor : speaking service 재사용 * refactor : AI 영어 회화 연습 코드 리팩토링 * feat : handleChat 메서드 JsonNull 체크 푸가 * refactor : session_id가 null 체크 추가 * feat : template 환경변수 리펙토링 --------- Co-authored-by: DDING JOO * feat: enhance bookmark and reading status tracking for News API - Add bookmark and reading status to individual article responses - Include bookmark status in paginated news responses - Modify `KeywordInfo` to support Korean translations (`meaningKo`) - Update AI prompts to extract Korean meanings for keywords * feat: add category filtering to UserWord API with enhanced query logic - Introduce `category` filtering to `getUserWords` API - Update WordCategory enums to include "news" category - Apply category filter after enrichment with word info - Adjust query limits for bookmarked and incorrect words queries * feat: add default value for ExistingCognitoClientId in template.yaml - Set default to an empty string to ensure compatibility with templates using this parameter without explicitly specifying a value. * fix: SpeakingHandler getStringOrNull 컴파일 에러 수정 * fix: SpeakingHandler getStringOrNull 컴파일 에러 수정 * fix: Bedrock 키워드(meaningKo 포함)를 article에 저장하도록 수정 * fix: Bedrock 키워드(meaningKo 포함)를 article에 저장하도록 수정 * fix: Bedrock 키워드(meaningKo 포함)를 article에 저장하도록 수정 * feat: add dashboard stats API and enhance news stats tracking - Introduce `/stats/dashboard` API for retrieving integrated user stats (today, overall, weekly progress, and level distribution) - Update `UserStats` model to include additional fields for news stats (newsRead, newsQuizCompleted, newsQuizPerfect, newsWordsCollected, etc.) - Add atomic updates to track news reading, quiz, and word stats in `UserStatsRepository` - Modify CloudFormation `template.yaml` to register the new `/stats/dashboard` endpoint * feat: add dashboard stats API and enhance news stats tracking - Introduce `/stats/dashboard` API for retrieving integrated user stats (today, overall, weekly progress, and level distribution) - Update `UserStats` model to include additional fields for news stats (newsRead, newsQuizCompleted, newsQuizPerfect, newsWordsCollected, etc.) - Add atomic updates to track news reading, quiz, and word stats in `UserStatsRepository` - Modify CloudFormation `template.yaml` to register the new `/stats/dashboard` endpoint * feat: add dashboard stats API and enhance news stats tracking - Introduce `/stats/dashboard` API for retrieving integrated user stats (today, overall, weekly progress, and level distribution) - Update `UserStats` model to include additional fields for news stats (newsRead, newsQuizCompleted, newsQuizPerfect, newsWordsCollected, etc.) - Add atomic updates to track news reading, quiz, and word stats in `UserStatsRepository` - Modify CloudFormation `template.yaml` to register the new `/stats/dashboard` endpoint * refactor: format code with consistent indentation and spacing - Apply consistent formatting to improve code readability across multiple files - Adjust indentation, spacing, and alignment in model classes, DTOs, and repository methods * fix: filter by ARTICLE# prefix in findById to avoid returning bookmark records * feat : Speaking 관련 template 람다 함수 및 테이블 추가 * feat : 말하기 기능에 polly 서비스 권한 추가 * feat : transcribe API KEY 추가 --------- Co-authored-by: DDING JOO * feat: 채팅 슬래시 명령어 시스템 고도화 - 게임 관련 명령어 제거 (/start, /stop, /score, /skip, /hint) - 기본 명령어 추가: /help, /members, /leave, /clear - 재미 명령어 추가: /dice, /coin, /random - 투표 시스템 구현: /poll, /vote, /endpoll - Poll 모델 및 PollRepository 추가 - MessageType에 POLL_CREATE, POLL_VOTE, POLL_END 추가 Closes #518, #519, #520 --------- Co-authored-by: ddingjoo --- ServerlessFunction/build.gradle | 1 + .../serverless/common/config/AwsClients.java | 12 +- .../serverless/common/config/EnvConfig.java | 13 +- .../serverless/common/util/JsonUtil.java | 20 +- .../domain/badge/service/BadgeService.java | 21 +- .../domain/chatting/enums/MessageType.java | 11 +- .../domain/chatting/model/Poll.java | 80 +++ .../chatting/repository/PollRepository.java | 70 +++ .../chatting/service/CommandService.java | 508 ++++++++++++++---- .../domain/chatting/service/GameService.java | 55 +- .../domain/news/config/NewsConfig.java | 83 +++ .../domain/news/constants/NewsKey.java | 12 + .../domain/news/handler/NewsHandler.java | 24 +- .../news/service/NewsLearningService.java | 235 ++++---- .../domain/news/service/NewsQueryService.java | 47 +- .../domain/news/service/NewsQuizService.java | 47 +- .../config/NotificationConfig.java | 51 ++ .../notification/dto/NotificationMessage.java | 60 +++ .../notification/enums/NotificationType.java | 41 ++ .../handler/NotificationStreamHandler.java | 203 +++++++ .../handler/StreakReminderHandler.java | 123 +++++ .../service/NotificationPublisher.java | 201 +++++++ .../handler/SpeakingConnectHandler.java | 0 .../handler/SpeakingDisconnectHandler.java | 0 .../speaking/handler/SpeakingHandler.java | 271 +++++----- .../handler/SpeakingMessageHandler.java | 0 .../SpeakingConnectionRepository.java | 0 .../repository/SpeakingSessionRepository.java | 116 ++-- .../stats/repository/UserStatsRepository.java | 25 + .../repository/DailyStudyRepository.java | 22 + .../service/DailyStudyCommandService.java | 36 +- .../service/TestCommandService.java | 29 +- .../domain/chatting/model/PollSpec.groovy | 148 +++++ .../domain/news/config/NewsConfigSpec.groovy | 172 ++++++ .../domain/news/constants/NewsKeySpec.groovy | 202 +++++++ .../config/NotificationConfigSpec.groovy | 91 ++++ .../dto/NotificationMessageSpec.groovy | 158 ++++++ .../enums/NotificationTypeSpec.groovy | 110 ++++ ServerlessFunction/template.yaml | 234 ++++++++ 39 files changed, 3030 insertions(+), 502 deletions(-) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/Poll.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/PollRepository.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/config/NewsConfig.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/config/NotificationConfig.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/dto/NotificationMessage.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/enums/NotificationType.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/handler/NotificationStreamHandler.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/handler/StreakReminderHandler.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/service/NotificationPublisher.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingConnectHandler.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingDisconnectHandler.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingMessageHandler.java delete mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java create mode 100644 ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/model/PollSpec.groovy create mode 100644 ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/news/config/NewsConfigSpec.groovy create mode 100644 ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/news/constants/NewsKeySpec.groovy create mode 100644 ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/notification/config/NotificationConfigSpec.groovy create mode 100644 ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/notification/dto/NotificationMessageSpec.groovy create mode 100644 ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/notification/enums/NotificationTypeSpec.groovy diff --git a/ServerlessFunction/build.gradle b/ServerlessFunction/build.gradle index cc5e6a12..34615a92 100644 --- a/ServerlessFunction/build.gradle +++ b/ServerlessFunction/build.gradle @@ -35,6 +35,7 @@ dependencies { implementation 'software.amazon.awssdk:url-connection-client' implementation 'software.amazon.awssdk:ssm' implementation 'software.amazon.awssdk:scheduler' + implementation 'software.amazon.awssdk:sqs' // AWS X-Ray SDK (다운스트림 서비스 추적용) implementation 'com.amazonaws:aws-xray-recorder-sdk-core:2.15.0' diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/AwsClients.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/AwsClients.java index 05ad609a..a1d2286b 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/AwsClients.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/AwsClients.java @@ -11,6 +11,7 @@ import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.presigner.S3Presigner; import software.amazon.awssdk.services.sns.SnsClient; +import software.amazon.awssdk.services.sqs.SqsClient; import software.amazon.awssdk.services.ssm.SsmClient; /** @@ -60,7 +61,12 @@ public final class AwsClients { private static final SsmClient SSM_CLIENT = SsmClient.builder() .overrideConfiguration(XRAY_CONFIG) .build(); - + + // SQS + private static final SqsClient SQS_CLIENT = SqsClient.builder() + .overrideConfiguration(XRAY_CONFIG) + .build(); + private AwsClients() { // 인스턴스화 방지 } @@ -104,4 +110,8 @@ public static ComprehendClient comprehend() { public static SsmClient ssm() { return SSM_CLIENT; } + + public static SqsClient sqs() { + return SQS_CLIENT; + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/EnvConfig.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/EnvConfig.java index c59f4930..75c7e1ec 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/EnvConfig.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/EnvConfig.java @@ -15,7 +15,18 @@ public final class EnvConfig { private EnvConfig() { // 유틸리티 클래스 - 인스턴스화 방지 } - + + /** + * 선택적 환경 변수를 가져옵니다. + * 환경 변수가 설정되지 않은 경우 null을 반환합니다. + * + * @param name 환경 변수 이름 + * @return 환경 변수 값 또는 null + */ + public static String get(String name) { + return System.getenv(name); + } + /** * 필수 환경 변수를 가져옵니다. * 환경 변수가 설정되지 않았거나 빈 문자열인 경우 IllegalStateException을 발생시킵니다. diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/JsonUtil.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/JsonUtil.java index 94685020..ad550303 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/JsonUtil.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/JsonUtil.java @@ -1,5 +1,7 @@ package com.mzc.secondproject.serverless.common.util; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; import com.google.gson.JsonArray; import com.google.gson.JsonElement; @@ -10,9 +12,25 @@ * JSON 파싱 관련 공통 유틸리티 */ public class JsonUtil { - + + private static final Gson GSON = new GsonBuilder().create(); + private JsonUtil() { } + + /** + * 객체를 JSON 문자열로 변환 + */ + public static String toJson(Object obj) { + return GSON.toJson(obj); + } + + /** + * JSON 문자열을 객체로 변환 + */ + public static T fromJson(String json, Class clazz) { + return GSON.fromJson(json, clazz); + } // 응답에서 JSON 부분만 추출 public static String extractJson(String response) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/service/BadgeService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/service/BadgeService.java index 0916a5d5..b7fbe77d 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/service/BadgeService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/service/BadgeService.java @@ -7,6 +7,7 @@ import com.mzc.secondproject.serverless.domain.badge.repository.BadgeRepository; import com.mzc.secondproject.serverless.domain.badge.strategy.BadgeConditionStrategy; import com.mzc.secondproject.serverless.domain.badge.strategy.BadgeConditionStrategyFactory; +import com.mzc.secondproject.serverless.domain.notification.service.NotificationPublisher; import com.mzc.secondproject.serverless.domain.stats.model.UserStats; import com.mzc.secondproject.serverless.domain.stats.repository.UserStatsRepository; import org.slf4j.Logger; @@ -24,20 +25,23 @@ public class BadgeService { private final BadgeRepository badgeRepository; private final UserStatsRepository userStatsRepository; - + private final NotificationPublisher notificationPublisher; + /** * 기본 생성자 (Lambda에서 사용) */ public BadgeService() { - this(new BadgeRepository(), new UserStatsRepository()); + this(new BadgeRepository(), new UserStatsRepository(), NotificationPublisher.getInstance()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ - public BadgeService(BadgeRepository badgeRepository, UserStatsRepository userStatsRepository) { + public BadgeService(BadgeRepository badgeRepository, UserStatsRepository userStatsRepository, + NotificationPublisher notificationPublisher) { this.badgeRepository = badgeRepository; this.userStatsRepository = userStatsRepository; + this.notificationPublisher = notificationPublisher; } /** @@ -98,6 +102,15 @@ public List checkAndAwardBadges(String userId, UserStats stats) { badgeRepository.save(badge); newBadges.add(badge); logger.info("Badge awarded: userId={}, badge={}", userId, type.name()); + + // 알림 발행 + notificationPublisher.publishBadgeEarned( + userId, + type.name(), + type.getName(), + type.getDescription(), + badge.getImageUrl() + ); } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/MessageType.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/MessageType.java index b8a7d453..fddc60b7 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/MessageType.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/MessageType.java @@ -22,7 +22,16 @@ public enum MessageType { // 방 관련 메시지 타입 ROOM_STATUS_CHANGE("room_status_change", "방 상태 변경"), - HOST_CHANGE("host_change", "방장 변경"); + HOST_CHANGE("host_change", "방장 변경"), + + // 투표 관련 메시지 타입 + POLL_CREATE("poll_create", "투표 생성"), + POLL_VOTE("poll_vote", "투표 참여"), + POLL_END("poll_end", "투표 종료"), + + // 유틸리티 메시지 타입 + CLEAR_CHAT("clear_chat", "채팅 삭제"), + LEAVE_ROOM("leave_room", "채팅방 나가기"); private final String code; private final String displayName; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/Poll.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/Poll.java new file mode 100644 index 00000000..0c8eced9 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/Poll.java @@ -0,0 +1,80 @@ +package com.mzc.secondproject.serverless.domain.chatting.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.*; + +import java.util.List; +import java.util.Map; + +/** + * 채팅방 투표 모델 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DynamoDbBean +public class Poll { + + private String pk; // ROOM#{roomId} + private String sk; // POLL#{pollId} + + private String pollId; + private String roomId; + private String question; + private List options; + private Map votes; // optionIndex -> count + private Map userVotes; // userId -> optionIndex + private String createdBy; + private String createdAt; + private Boolean isActive; + private Long ttl; + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { + return pk; + } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { + return sk; + } + + /** + * 투표 추가 + */ + public boolean addVote(String userId, int optionIndex) { + if (optionIndex < 0 || optionIndex >= options.size()) { + return false; + } + + // 이미 투표했는지 확인 + if (userVotes.containsKey(userId)) { + return false; + } + + userVotes.put(userId, optionIndex); + votes.merge(String.valueOf(optionIndex), 1, Integer::sum); + return true; + } + + /** + * 사용자가 이미 투표했는지 확인 + */ + public boolean hasVoted(String userId) { + return userVotes != null && userVotes.containsKey(userId); + } + + /** + * 총 투표 수 + */ + public int getTotalVotes() { + if (votes == null) return 0; + return votes.values().stream().mapToInt(Integer::intValue).sum(); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/PollRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/PollRepository.java new file mode 100644 index 00000000..f49a6908 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/PollRepository.java @@ -0,0 +1,70 @@ +package com.mzc.secondproject.serverless.domain.chatting.repository; + +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.config.EnvConfig; +import com.mzc.secondproject.serverless.domain.chatting.model.Poll; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; +import software.amazon.awssdk.enhanced.dynamodb.Key; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; + +import java.util.Optional; + +/** + * Poll Repository + */ +public class PollRepository { + + private static final Logger logger = LoggerFactory.getLogger(PollRepository.class); + private static final String TABLE_NAME = EnvConfig.getRequired("CHAT_TABLE_NAME"); + + private final DynamoDbTable table; + + public PollRepository() { + this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(Poll.class)); + } + + public PollRepository(DynamoDbTable table) { + this.table = table; + } + + public void save(Poll poll) { + table.putItem(poll); + logger.debug("Saved poll: {}", poll.getPollId()); + } + + public Optional findById(String roomId, String pollId) { + Key key = Key.builder() + .partitionValue("ROOM#" + roomId) + .sortValue("POLL#" + pollId) + .build(); + Poll poll = table.getItem(key); + return Optional.ofNullable(poll); + } + + /** + * 방의 활성 투표 조회 + */ + public Optional findActiveByRoomId(String roomId) { + return table.query(QueryConditional.sortBeginsWith( + Key.builder() + .partitionValue("ROOM#" + roomId) + .sortValue("POLL#") + .build())) + .items() + .stream() + .filter(poll -> Boolean.TRUE.equals(poll.getIsActive())) + .findFirst(); + } + + public void delete(String roomId, String pollId) { + Key key = Key.builder() + .partitionValue("ROOM#" + roomId) + .sortValue("POLL#" + pollId) + .build(); + table.deleteItem(key); + logger.debug("Deleted poll: {}", pollId); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/CommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/CommandService.java index 71e0ddf2..89d6e098 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/CommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/CommandService.java @@ -3,44 +3,49 @@ import com.mzc.secondproject.serverless.domain.chatting.dto.response.CommandResult; import com.mzc.secondproject.serverless.domain.chatting.enums.MessageType; import com.mzc.secondproject.serverless.domain.chatting.model.Connection; -import com.mzc.secondproject.serverless.domain.chatting.model.GameSession; +import com.mzc.secondproject.serverless.domain.chatting.model.Poll; import com.mzc.secondproject.serverless.domain.chatting.repository.ConnectionRepository; -import com.mzc.secondproject.serverless.domain.chatting.repository.GameSessionRepository; +import com.mzc.secondproject.serverless.domain.chatting.repository.PollRepository; +import com.mzc.secondproject.serverless.domain.user.model.User; +import com.mzc.secondproject.serverless.domain.user.repository.UserRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.util.List; -import java.util.Optional; +import java.time.Instant; +import java.util.*; +import java.util.stream.Collectors; /** * 슬래시 명령어 처리 서비스 */ public class CommandService { - + private static final Logger logger = LoggerFactory.getLogger(CommandService.class); - + private final ConnectionRepository connectionRepository; - private final GameSessionRepository gameSessionRepository; - private final GameService gameService; - + private final PollRepository pollRepository; + private final UserRepository userRepository; + private final Random random; + /** * 기본 생성자 (Lambda에서 사용) */ public CommandService() { - this(new ConnectionRepository(), new GameSessionRepository(), new GameService()); + this(new ConnectionRepository(), new PollRepository(), new UserRepository()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ public CommandService(ConnectionRepository connectionRepository, - GameSessionRepository gameSessionRepository, - GameService gameService) { + PollRepository pollRepository, + UserRepository userRepository) { this.connectionRepository = connectionRepository; - this.gameSessionRepository = gameSessionRepository; - this.gameService = gameService; + this.pollRepository = pollRepository; + this.userRepository = userRepository; + this.random = new Random(); } - + /** * 명령어 처리 * @@ -53,119 +58,426 @@ public Optional processCommand(String content, String roomId, Str if (content == null || !content.startsWith("/")) { return Optional.empty(); } - + String[] parts = content.trim().split("\\s+", 2); String command = parts[0].toLowerCase(); - + String args = parts.length > 1 ? parts[1] : ""; + logger.info("Processing command: {} from user: {} in room: {}", command, userId, roomId); - + return switch (command) { - case "/member", "/members" -> Optional.of(handleMemberCommand(roomId)); - case "/start" -> Optional.of(handleStartCommand(roomId, userId)); - case "/stop" -> Optional.of(handleStopCommand(roomId, userId)); - case "/score" -> Optional.of(handleScoreCommand(roomId)); - case "/skip" -> Optional.of(handleSkipCommand(roomId, userId)); - case "/hint" -> Optional.of(handleHintCommand(roomId, userId)); + // 기본 명령어 case "/help" -> Optional.of(handleHelpCommand()); + case "/member", "/members" -> Optional.of(handleMembersCommand(roomId)); + case "/leave" -> Optional.of(handleLeaveCommand(roomId, userId)); + case "/clear" -> Optional.of(handleClearCommand(roomId, userId)); + + // 재미 명령어 + case "/dice" -> Optional.of(handleDiceCommand(roomId, userId)); + case "/coin" -> Optional.of(handleCoinCommand(roomId, userId)); + case "/random" -> Optional.of(handleRandomCommand(roomId, userId, args)); + + // 투표 명령어 + case "/poll" -> Optional.of(handlePollCommand(roomId, userId, args)); + case "/vote" -> Optional.of(handleVoteCommand(roomId, userId, args)); + case "/endpoll" -> Optional.of(handleEndPollCommand(roomId, userId)); + default -> Optional.empty(); }; } - + + // ========== 기본 명령어 ========== + + /** + * /help - 도움말 + */ + private CommandResult handleHelpCommand() { + String helpMessage = """ + 📖 사용 가능한 명령어: + + [기본] + /members - 현재 접속자 목록 + /leave - 채팅방 나가기 + /clear - 내 채팅 내역 삭제 + + [재미] + /dice - 주사위 굴리기 (1-6) + /coin - 동전 던지기 + /random [옵션1] [옵션2] ... - 랜덤 선택 + + [투표] + /poll [질문] | [옵션1] | [옵션2] | ... - 투표 생성 + /vote [번호] - 투표하기 + /endpoll - 투표 종료 (생성자만) + """; + return CommandResult.success(MessageType.SYSTEM_COMMAND, helpMessage); + } + /** - * /member - 현재 접속자 수 조회 + * /members - 접속자 목록 */ - private CommandResult handleMemberCommand(String roomId) { + private CommandResult handleMembersCommand(String roomId) { List connections = connectionRepository.findByRoomId(roomId); - + if (connections.isEmpty()) { return CommandResult.success(MessageType.SYSTEM_COMMAND, "현재 접속자가 없습니다."); } - - String message = String.format("현재 접속자: %d명", connections.size()); - return CommandResult.success(MessageType.SYSTEM_COMMAND, message, connections.size()); + + // 닉네임 조회 + StringBuilder sb = new StringBuilder(); + sb.append(String.format("👥 현재 접속자: %d명\n", connections.size())); + + for (Connection conn : connections) { + String nickname = userRepository.findByCognitoSub(conn.getUserId()) + .map(User::getNickname) + .orElse(conn.getUserId()); + sb.append(String.format(" • %s\n", nickname)); + } + + Map data = new HashMap<>(); + data.put("count", connections.size()); + data.put("members", connections.stream() + .map(c -> { + Map member = new HashMap<>(); + member.put("userId", c.getUserId()); + member.put("nickname", userRepository.findByCognitoSub(c.getUserId()) + .map(User::getNickname).orElse(c.getUserId())); + return member; + }) + .collect(Collectors.toList())); + + return CommandResult.success(MessageType.SYSTEM_COMMAND, sb.toString(), data); } - + /** - * /start - 게임 시작 + * /leave - 채팅방 나가기 */ - private CommandResult handleStartCommand(String roomId, String userId) { - GameService.GameStartResult result = gameService.startGame(roomId, userId); - - if (!result.success()) { - return CommandResult.error(result.error()); - } - - String message = String.format(""" - 🎮 게임 시작! - 총 %d 라운드 - - 라운드 1 시작! - 출제자: %s - """, - result.session().getTotalRounds(), - result.session().getCurrentDrawerId()); - - return CommandResult.success(MessageType.GAME_START, message, result); + private CommandResult handleLeaveCommand(String roomId, String userId) { + String nickname = userRepository.findByCognitoSub(userId) + .map(User::getNickname) + .orElse(userId); + + Map data = new HashMap<>(); + data.put("userId", userId); + data.put("nickname", nickname); + data.put("action", "leave"); + + return CommandResult.success(MessageType.LEAVE_ROOM, + String.format("👋 %s님이 퇴장합니다.", nickname), data); } - + /** - * /stop - 게임 중단 + * /clear - 내 채팅 내역 삭제 */ - private CommandResult handleStopCommand(String roomId, String userId) { - return gameService.stopGame(roomId, userId); + private CommandResult handleClearCommand(String roomId, String userId) { + Map data = new HashMap<>(); + data.put("userId", userId); + data.put("action", "clear"); + + return CommandResult.success(MessageType.CLEAR_CHAT, + "🗑️ 채팅 내역 삭제를 요청했습니다.", data); } - + + // ========== 재미 명령어 ========== + /** - * /score - 현재 점수 조회 + * /dice - 주사위 굴리기 */ - private CommandResult handleScoreCommand(String roomId) { - Optional optSession = gameSessionRepository.findActiveByRoomId(roomId); - if (optSession.isEmpty()) { - return CommandResult.error("진행 중인 게임이 없습니다."); - } - - GameSession session = optSession.get(); - - if (session.getScores() == null || session.getScores().isEmpty()) { - return CommandResult.success(MessageType.SCORE_UPDATE, "아직 점수가 없습니다."); - } - - StringBuilder sb = new StringBuilder("📊 현재 점수:\n"); - session.getScores().entrySet().stream() - .sorted((a, b) -> b.getValue().compareTo(a.getValue())) - .forEach(entry -> sb.append(String.format(" %s: %d점\n", entry.getKey(), entry.getValue()))); - - return CommandResult.success(MessageType.SCORE_UPDATE, sb.toString(), session.getScores()); + private CommandResult handleDiceCommand(String roomId, String userId) { + int result = random.nextInt(6) + 1; + + String nickname = userRepository.findByCognitoSub(userId) + .map(User::getNickname) + .orElse(userId); + + String emoji = switch (result) { + case 1 -> "⚀"; + case 2 -> "⚁"; + case 3 -> "⚂"; + case 4 -> "⚃"; + case 5 -> "⚄"; + case 6 -> "⚅"; + default -> "🎲"; + }; + + Map data = new HashMap<>(); + data.put("userId", userId); + data.put("nickname", nickname); + data.put("result", result); + data.put("type", "dice"); + + return CommandResult.success(MessageType.SYSTEM_COMMAND, + String.format("🎲 %s님이 주사위를 굴렸습니다: %s %d", nickname, emoji, result), data); } - + /** - * /skip - 라운드 스킵 (출제자만) + * /coin - 동전 던지기 */ - private CommandResult handleSkipCommand(String roomId, String userId) { - return gameService.skipRound(roomId, userId); + private CommandResult handleCoinCommand(String roomId, String userId) { + boolean isHeads = random.nextBoolean(); + String result = isHeads ? "앞면 (Heads)" : "뒷면 (Tails)"; + String emoji = isHeads ? "🪙" : "💿"; + + String nickname = userRepository.findByCognitoSub(userId) + .map(User::getNickname) + .orElse(userId); + + Map data = new HashMap<>(); + data.put("userId", userId); + data.put("nickname", nickname); + data.put("result", isHeads ? "heads" : "tails"); + data.put("type", "coin"); + + return CommandResult.success(MessageType.SYSTEM_COMMAND, + String.format("%s %s님이 동전을 던졌습니다: %s", emoji, nickname, result), data); } - + /** - * /hint - 힌트 제공 (출제자만) + * /random [옵션1] [옵션2] ... - 랜덤 선택 */ - private CommandResult handleHintCommand(String roomId, String userId) { - return gameService.provideHint(roomId, userId); + private CommandResult handleRandomCommand(String roomId, String userId, String args) { + if (args.isBlank()) { + return CommandResult.error("사용법: /random [옵션1] [옵션2] [옵션3] ..."); + } + + String[] options = args.split("\\s+"); + if (options.length < 2) { + return CommandResult.error("최소 2개 이상의 옵션이 필요합니다."); + } + + String selected = options[random.nextInt(options.length)]; + + String nickname = userRepository.findByCognitoSub(userId) + .map(User::getNickname) + .orElse(userId); + + Map data = new HashMap<>(); + data.put("userId", userId); + data.put("nickname", nickname); + data.put("options", Arrays.asList(options)); + data.put("selected", selected); + data.put("type", "random"); + + return CommandResult.success(MessageType.SYSTEM_COMMAND, + String.format("🎯 %s님의 랜덤 선택: %s\n(후보: %s)", + nickname, selected, String.join(", ", options)), data); } - + + // ========== 투표 명령어 ========== + /** - * /help - 도움말 + * /poll [질문] | [옵션1] | [옵션2] | ... - 투표 생성 */ - private CommandResult handleHelpCommand() { - String helpMessage = """ - 📖 사용 가능한 명령어: - /member - 현재 접속자 수 - /start - 게임 시작 (2명 이상) - /stop - 게임 중단 - /score - 현재 점수 보기 - /skip - 라운드 스킵 (출제자) - /hint - 힌트 보기 (출제자) - /help - 도움말 - """; - return CommandResult.success(MessageType.SYSTEM_COMMAND, helpMessage); + private CommandResult handlePollCommand(String roomId, String userId, String args) { + // 이미 진행 중인 투표가 있는지 확인 + Optional activePoll = pollRepository.findActiveByRoomId(roomId); + if (activePoll.isPresent()) { + return CommandResult.error("이미 진행 중인 투표가 있습니다. /endpoll로 종료 후 새 투표를 만드세요."); + } + + if (args.isBlank()) { + return CommandResult.error("사용법: /poll [질문] | [옵션1] | [옵션2] | ..."); + } + + String[] parts = args.split("\\|"); + if (parts.length < 3) { + return CommandResult.error("질문과 최소 2개의 옵션이 필요합니다. (구분자: |)"); + } + + String question = parts[0].trim(); + List options = new ArrayList<>(); + for (int i = 1; i < parts.length; i++) { + String option = parts[i].trim(); + if (!option.isEmpty()) { + options.add(option); + } + } + + if (options.size() < 2) { + return CommandResult.error("최소 2개의 옵션이 필요합니다."); + } + + if (options.size() > 10) { + return CommandResult.error("옵션은 최대 10개까지 가능합니다."); + } + + // 투표 생성 + String pollId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + long ttl = Instant.now().plusSeconds(24 * 60 * 60).getEpochSecond(); // 24시간 + + Map votes = new HashMap<>(); + for (int i = 0; i < options.size(); i++) { + votes.put(String.valueOf(i), 0); + } + + Poll poll = Poll.builder() + .pk("ROOM#" + roomId) + .sk("POLL#" + pollId) + .pollId(pollId) + .roomId(roomId) + .question(question) + .options(options) + .votes(votes) + .userVotes(new HashMap<>()) + .createdBy(userId) + .createdAt(now) + .isActive(true) + .ttl(ttl) + .build(); + + pollRepository.save(poll); + + String nickname = userRepository.findByCognitoSub(userId) + .map(User::getNickname) + .orElse(userId); + + StringBuilder sb = new StringBuilder(); + sb.append(String.format("📊 %s님이 투표를 시작했습니다!\n\n", nickname)); + sb.append(String.format("❓ %s\n\n", question)); + for (int i = 0; i < options.size(); i++) { + sb.append(String.format(" %d. %s\n", i + 1, options.get(i))); + } + sb.append("\n💬 /vote [번호]로 투표하세요!"); + + Map data = new HashMap<>(); + data.put("pollId", pollId); + data.put("question", question); + data.put("options", options); + data.put("createdBy", userId); + data.put("creatorNickname", nickname); + + logger.info("Poll created: pollId={}, roomId={}, question={}", pollId, roomId, question); + + return CommandResult.success(MessageType.POLL_CREATE, sb.toString(), data); + } + + /** + * /vote [번호] - 투표하기 + */ + private CommandResult handleVoteCommand(String roomId, String userId, String args) { + Optional optPoll = pollRepository.findActiveByRoomId(roomId); + if (optPoll.isEmpty()) { + return CommandResult.error("진행 중인 투표가 없습니다."); + } + + Poll poll = optPoll.get(); + + if (poll.hasVoted(userId)) { + return CommandResult.error("이미 투표하셨습니다."); + } + + int optionIndex; + try { + optionIndex = Integer.parseInt(args.trim()) - 1; // 1-based to 0-based + } catch (NumberFormatException e) { + return CommandResult.error("사용법: /vote [번호] (예: /vote 1)"); + } + + if (optionIndex < 0 || optionIndex >= poll.getOptions().size()) { + return CommandResult.error(String.format("1~%d 사이의 번호를 입력하세요.", poll.getOptions().size())); + } + + // 투표 추가 + poll.addVote(userId, optionIndex); + pollRepository.save(poll); + + String nickname = userRepository.findByCognitoSub(userId) + .map(User::getNickname) + .orElse(userId); + + String selectedOption = poll.getOptions().get(optionIndex); + + // 현재 투표 현황 생성 + StringBuilder sb = new StringBuilder(); + sb.append(String.format("✅ %s님이 '%s'에 투표했습니다!\n\n", nickname, selectedOption)); + sb.append(String.format("📊 현재 현황 (총 %d표):\n", poll.getTotalVotes())); + for (int i = 0; i < poll.getOptions().size(); i++) { + int voteCount = poll.getVotes().getOrDefault(String.valueOf(i), 0); + String bar = "█".repeat(Math.min(voteCount, 10)); + sb.append(String.format(" %d. %s: %s %d표\n", + i + 1, poll.getOptions().get(i), bar, voteCount)); + } + + Map data = new HashMap<>(); + data.put("pollId", poll.getPollId()); + data.put("voterId", userId); + data.put("voterNickname", nickname); + data.put("selectedOption", optionIndex); + data.put("selectedOptionText", selectedOption); + data.put("votes", poll.getVotes()); + data.put("totalVotes", poll.getTotalVotes()); + + logger.info("Vote recorded: pollId={}, userId={}, option={}", poll.getPollId(), userId, optionIndex); + + return CommandResult.success(MessageType.POLL_VOTE, sb.toString(), data); + } + + /** + * /endpoll - 투표 종료 + */ + private CommandResult handleEndPollCommand(String roomId, String userId) { + Optional optPoll = pollRepository.findActiveByRoomId(roomId); + if (optPoll.isEmpty()) { + return CommandResult.error("진행 중인 투표가 없습니다."); + } + + Poll poll = optPoll.get(); + + if (!poll.getCreatedBy().equals(userId)) { + return CommandResult.error("투표 생성자만 종료할 수 있습니다."); + } + + poll.setIsActive(false); + pollRepository.save(poll); + + // 최종 결과 계산 + int maxVotes = 0; + List winners = new ArrayList<>(); + for (int i = 0; i < poll.getOptions().size(); i++) { + int voteCount = poll.getVotes().getOrDefault(String.valueOf(i), 0); + if (voteCount > maxVotes) { + maxVotes = voteCount; + winners.clear(); + winners.add(poll.getOptions().get(i)); + } else if (voteCount == maxVotes && voteCount > 0) { + winners.add(poll.getOptions().get(i)); + } + } + + String nickname = userRepository.findByCognitoSub(userId) + .map(User::getNickname) + .orElse(userId); + + StringBuilder sb = new StringBuilder(); + sb.append(String.format("🏁 %s님이 투표를 종료했습니다!\n\n", nickname)); + sb.append(String.format("❓ %s\n\n", poll.getQuestion())); + sb.append(String.format("📊 최종 결과 (총 %d표):\n", poll.getTotalVotes())); + + for (int i = 0; i < poll.getOptions().size(); i++) { + int voteCount = poll.getVotes().getOrDefault(String.valueOf(i), 0); + String bar = "█".repeat(Math.min(voteCount, 10)); + String medal = (voteCount == maxVotes && maxVotes > 0) ? "🏆 " : " "; + sb.append(String.format("%s%d. %s: %s %d표\n", + medal, i + 1, poll.getOptions().get(i), bar, voteCount)); + } + + if (!winners.isEmpty()) { + sb.append(String.format("\n🎉 우승: %s", String.join(", ", winners))); + } else { + sb.append("\n투표가 없습니다."); + } + + Map data = new HashMap<>(); + data.put("pollId", poll.getPollId()); + data.put("question", poll.getQuestion()); + data.put("options", poll.getOptions()); + data.put("votes", poll.getVotes()); + data.put("totalVotes", poll.getTotalVotes()); + data.put("winners", winners); + + logger.info("Poll ended: pollId={}, totalVotes={}", poll.getPollId(), poll.getTotalVotes()); + + return CommandResult.success(MessageType.POLL_END, sb.toString(), data); } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java index a82c16d2..932335c3 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java @@ -13,6 +13,7 @@ import com.mzc.secondproject.serverless.domain.chatting.repository.ConnectionRepository; import com.mzc.secondproject.serverless.domain.chatting.repository.GameRoundRepository; import com.mzc.secondproject.serverless.domain.chatting.repository.GameSessionRepository; +import com.mzc.secondproject.serverless.domain.notification.service.NotificationPublisher; import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; import com.mzc.secondproject.serverless.domain.vocabulary.repository.WordRepository; import org.slf4j.Logger; @@ -37,23 +38,25 @@ public class GameService { private final WordRepository wordRepository; private final GameStatsService gameStatsService; private final GameSchedulerClient gameSchedulerClient; - + private final NotificationPublisher notificationPublisher; + /** * 기본 생성자 (Lambda에서 사용) */ public GameService() { this(new ChatRoomRepository(), new ConnectionRepository(), new GameRoundRepository(), new GameSessionRepository(), - new WordRepository(), new GameStatsService(), new GameSchedulerClient()); + new WordRepository(), new GameStatsService(), new GameSchedulerClient(), + NotificationPublisher.getInstance()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ public GameService(ChatRoomRepository chatRoomRepository, ConnectionRepository connectionRepository, GameRoundRepository gameRoundRepository, GameSessionRepository gameSessionRepository, WordRepository wordRepository, GameStatsService gameStatsService, - GameSchedulerClient gameSchedulerClient) { + GameSchedulerClient gameSchedulerClient, NotificationPublisher notificationPublisher) { this.chatRoomRepository = chatRoomRepository; this.connectionRepository = connectionRepository; this.gameRoundRepository = gameRoundRepository; @@ -61,6 +64,7 @@ public GameService(ChatRoomRepository chatRoomRepository, ConnectionRepository c this.wordRepository = wordRepository; this.gameStatsService = gameStatsService; this.gameSchedulerClient = gameSchedulerClient; + this.notificationPublisher = notificationPublisher; } /** @@ -508,7 +512,10 @@ private CommandResult finishGame(GameSession session, ChatRoom room, String reas } catch (Exception e) { logger.error("Failed to update game stats: roomId={}, error={}", room.getRoomId(), e.getMessage()); } - + + // 게임 종료 알림 발행 (각 플레이어별) + publishGameEndNotifications(session, room.getRoomId()); + // 최종 점수 정렬 StringBuilder sb = new StringBuilder("🎮 게임 종료!\n\n📊 최종 순위:\n"); if (session.getScores() != null && !session.getScores().isEmpty()) { @@ -698,11 +705,11 @@ private List> buildRankingList(Map scores) if (scores == null || scores.isEmpty()) { return List.of(); } - + List> sorted = scores.entrySet().stream() .sorted(Map.Entry.comparingByValue().reversed()) .toList(); - + List> ranking = new ArrayList<>(); for (int i = 0; i < sorted.size(); i++) { Map entry = new HashMap<>(); @@ -713,7 +720,39 @@ private List> buildRankingList(Map scores) } return ranking; } - + + /** + * 게임 종료 알림 발행 + */ + private void publishGameEndNotifications(GameSession session, String roomId) { + if (session.getScores() == null || session.getScores().isEmpty()) { + return; + } + + List> sorted = session.getScores().entrySet().stream() + .sorted((a, b) -> b.getValue().compareTo(a.getValue())) + .toList(); + + int totalPlayers = sorted.size(); + + for (int i = 0; i < sorted.size(); i++) { + int rank = i + 1; + String userId = sorted.get(i).getKey(); + int score = sorted.get(i).getValue(); + boolean isWinner = rank == 1; + + notificationPublisher.publishGameEnd( + userId, + roomId, + session.getGameSessionId(), + rank, + totalPlayers, + score, + isWinner + ); + } + } + // ========== Result DTOs ========== public record GameStartResult( diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/config/NewsConfig.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/config/NewsConfig.java new file mode 100644 index 00000000..43435bb1 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/config/NewsConfig.java @@ -0,0 +1,83 @@ +package com.mzc.secondproject.serverless.domain.news.config; + +import com.mzc.secondproject.serverless.common.config.EnvConfig; + +/** + * 뉴스 도메인 설정 + * 상수 및 환경변수 관리 + */ +public final class NewsConfig { + + private NewsConfig() { + } + + // ========== Environment Variables ========== + private static final String BUCKET_NAME = EnvConfig.getOrDefault("NEWS_BUCKET_NAME", "group2-englishstudy"); + + // ========== TTS 설정 ========== + /** TTS 텍스트 최대 길이 */ + public static final int TTS_MAX_TEXT_LENGTH = 3000; + + /** TTS 오디오 저장 경로 */ + public static final String TTS_AUDIO_PREFIX = "news/audio/"; + + /** 기본 TTS 음성 */ + public static final String DEFAULT_VOICE = "Joanna"; + + // ========== 페이지네이션 ========== + /** 기본 페이지 크기 */ + public static final int DEFAULT_PAGE_SIZE = 10; + + /** 최대 페이지 크기 */ + public static final int MAX_PAGE_SIZE = 50; + + // ========== 퀴즈 피드백 ========== + public static final String FEEDBACK_PERFECT = "Perfect! You understood the article completely."; + public static final String FEEDBACK_GREAT = "Great job! You have a solid understanding of the article."; + public static final String FEEDBACK_GOOD = "Good effort! Review the highlighted words for better comprehension."; + public static final String FEEDBACK_KEEP_PRACTICING = "Keep practicing! Try reading the article again before retaking the quiz."; + public static final String FEEDBACK_DONT_GIVE_UP = "Don't give up! Focus on vocabulary and main ideas."; + + // ========== Score 기준 ========== + public static final int SCORE_PERFECT = 100; + public static final int SCORE_GREAT_THRESHOLD = 80; + public static final int SCORE_GOOD_THRESHOLD = 60; + public static final int SCORE_KEEP_PRACTICING_THRESHOLD = 40; + + // ========== Getter Methods ========== + public static String bucketName() { + return BUCKET_NAME; + } + + /** + * 점수에 따른 피드백 생성 + */ + public static String getFeedbackByScore(int score) { + if (score == SCORE_PERFECT) { + return FEEDBACK_PERFECT; + } else if (score >= SCORE_GREAT_THRESHOLD) { + return FEEDBACK_GREAT; + } else if (score >= SCORE_GOOD_THRESHOLD) { + return FEEDBACK_GOOD; + } else if (score >= SCORE_KEEP_PRACTICING_THRESHOLD) { + return FEEDBACK_KEEP_PRACTICING; + } else { + return FEEDBACK_DONT_GIVE_UP; + } + } + + /** + * limit 값 파싱 및 유효성 검증 + */ + public static int parseLimit(String limitStr) { + if (limitStr == null) { + return DEFAULT_PAGE_SIZE; + } + try { + int limit = Integer.parseInt(limitStr); + return Math.min(Math.max(limit, 1), MAX_PAGE_SIZE); + } catch (NumberFormatException e) { + return DEFAULT_PAGE_SIZE; + } + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/constants/NewsKey.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/constants/NewsKey.java index eb1425d8..f5ca1969 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/constants/NewsKey.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/constants/NewsKey.java @@ -126,4 +126,16 @@ public static String userNewsCommentsPk(String userId) { public static String userNewsStatPk(String userId) { return "USER_NEWS_STAT#" + userId; } + + // === Utility Methods === + + /** + * PK에서 날짜 추출 (NEWS#2024-01-15 → 2024-01-15) + */ + public static String extractDateFromPk(String pk) { + if (pk == null || !pk.startsWith(NEWS)) { + return null; + } + return pk.substring(NEWS.length()); + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java index f806e2f2..421ecf37 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java @@ -4,14 +4,15 @@ import com.amazonaws.services.lambda.runtime.RequestHandler; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; -import com.google.gson.Gson; import com.google.gson.JsonArray; import com.google.gson.JsonObject; import com.mzc.secondproject.serverless.common.dto.PaginatedResult; import com.mzc.secondproject.serverless.common.router.HandlerRouter; import com.mzc.secondproject.serverless.common.router.Route; import com.mzc.secondproject.serverless.common.util.CognitoUtil; +import com.mzc.secondproject.serverless.common.util.JsonUtil; import com.mzc.secondproject.serverless.common.util.ResponseGenerator; +import com.mzc.secondproject.serverless.domain.news.config.NewsConfig; import com.mzc.secondproject.serverless.domain.news.exception.NewsErrorCode; import com.mzc.secondproject.serverless.domain.news.model.NewsArticle; import com.mzc.secondproject.serverless.domain.news.model.NewsQuizResult; @@ -32,12 +33,9 @@ * 뉴스 학습 API 핸들러 */ public class NewsHandler implements RequestHandler { - + private static final Logger logger = LoggerFactory.getLogger(NewsHandler.class); - private static final int DEFAULT_LIMIT = 10; - private static final int MAX_LIMIT = 50; - private static final Gson gson = new Gson(); - + private final NewsQueryService queryService; private final NewsLearningService learningService; private final NewsQuizService quizService; @@ -226,13 +224,7 @@ private APIGatewayProxyResponseEvent buildPaginatedResponse(PaginatedResult markAsRead(String userId, String articleId) { - Optional article = articleRepository.findById(articleId); - if (article.isEmpty()) { + Optional articleOpt = articleRepository.findById(articleId); + if (articleOpt.isEmpty()) { logger.warn("기사를 찾을 수 없음: {}", articleId); - return new ArrayList<>(); + return List.of(); } - - // 이미 읽은 기사인지 확인 (중복 조회수 증가 방지) + if (userNewsRepository.hasRead(userId, articleId)) { logger.debug("이미 읽은 기사: userId={}, articleId={}", userId, articleId); - return new ArrayList<>(); - } - - NewsArticle a = article.get(); - userNewsRepository.saveReadRecord( - userId, - articleId, - a.getTitle(), - a.getLevel(), - a.getCategory() - ); - - // 조회수 증가 (새로운 읽기만) - String date = extractDateFromPk(a.getPk()); - if (date != null) { - articleRepository.incrementReadCount(date, articleId); + return List.of(); } - + + NewsArticle article = articleOpt.get(); + saveReadRecord(userId, article); + incrementArticleReadCount(article); + logger.info("읽기 완료 기록: userId={}, articleId={}", userId, articleId); - - // 통계 업데이트 및 배지 체크 - List newBadges = new ArrayList<>(); - try { - UserStats updatedStats = userStatsRepository.incrementNewsReadStats(userId); - if (updatedStats != null) { - newBadges = badgeService.checkAndAwardBadges(userId, updatedStats); - if (!newBadges.isEmpty()) { - logger.info("새 배지 획득: userId={}, badges={}", userId, - newBadges.stream().map(UserBadge::getBadgeType).toList()); - } - } - } catch (Exception e) { - logger.error("통계/배지 업데이트 실패: userId={}, error={}", userId, e.getMessage()); - } - - return newBadges; + + return updateStatsAndCheckBadges(userId); } - + /** * 북마크 토글 */ public boolean toggleBookmark(String userId, String articleId) { - boolean isBookmarked = userNewsRepository.isBookmarked(userId, articleId); - - if (isBookmarked) { + if (userNewsRepository.isBookmarked(userId, articleId)) { userNewsRepository.deleteBookmark(userId, articleId); logger.info("북마크 해제: userId={}, articleId={}", userId, articleId); return false; - } else { - Optional article = articleRepository.findById(articleId); - if (article.isEmpty()) { - logger.warn("기사를 찾을 수 없음: {}", articleId); - return false; - } - - NewsArticle a = article.get(); - userNewsRepository.saveBookmark( - userId, - articleId, - a.getTitle(), - a.getLevel(), - a.getCategory() - ); - logger.info("북마크 추가: userId={}, articleId={}", userId, articleId); - return true; } + + Optional articleOpt = articleRepository.findById(articleId); + if (articleOpt.isEmpty()) { + logger.warn("기사를 찾을 수 없음: {}", articleId); + return false; + } + + NewsArticle article = articleOpt.get(); + userNewsRepository.saveBookmark(userId, articleId, article.getTitle(), article.getLevel(), article.getCategory()); + logger.info("북마크 추가: userId={}, articleId={}", userId, articleId); + return true; } - + /** * 북마크 여부 확인 */ public boolean isBookmarked(String userId, String articleId) { return userNewsRepository.isBookmarked(userId, articleId); } - + /** * 읽기 여부 확인 */ public boolean hasRead(String userId, String articleId) { return userNewsRepository.hasRead(userId, articleId); } - + /** * 여러 기사의 북마크 여부 확인 (배치) */ public Set getBookmarkedArticleIds(String userId, List articleIds) { return userNewsRepository.getBookmarkedArticleIds(userId, articleIds); } - + /** * 사용자 북마크 목록 조회 (기사 정보 포함) */ public List> getUserBookmarks(String userId, int limit) { List bookmarks = userNewsRepository.getUserBookmarks(userId, limit); - List> result = new ArrayList<>(); - - for (UserNewsRecord bookmark : bookmarks) { - Optional articleOpt = articleRepository.findById(bookmark.getArticleId()); - if (articleOpt.isPresent()) { - NewsArticle article = articleOpt.get(); - Map bookmarkWithArticle = new HashMap<>(); - bookmarkWithArticle.put("articleId", article.getArticleId()); - bookmarkWithArticle.put("title", article.getTitle()); - bookmarkWithArticle.put("summary", article.getSummary()); - bookmarkWithArticle.put("source", article.getSource()); - bookmarkWithArticle.put("publishedAt", article.getPublishedAt()); - bookmarkWithArticle.put("keywords", article.getKeywords()); - bookmarkWithArticle.put("highlightWords", article.getHighlightWords()); - bookmarkWithArticle.put("category", article.getCategory()); - bookmarkWithArticle.put("level", article.getLevel()); - bookmarkWithArticle.put("imageUrl", article.getImageUrl()); - bookmarkWithArticle.put("bookmarkedAt", bookmark.getCreatedAt()); - result.add(bookmarkWithArticle); - } - } - return result; + + return bookmarks.stream() + .map(bookmark -> articleRepository.findById(bookmark.getArticleId()) + .map(article -> buildBookmarkResponse(article, bookmark)) + .orElse(null)) + .filter(Objects::nonNull) + .toList(); } - + /** * 뉴스 TTS 오디오 URL 생성 */ public String getAudioUrl(String articleId, String voice) { - Optional article = articleRepository.findById(articleId); - if (article.isEmpty()) { + Optional articleOpt = articleRepository.findById(articleId); + if (articleOpt.isEmpty()) { logger.warn("기사를 찾을 수 없음: {}", articleId); return null; } - - NewsArticle a = article.get(); - String text = a.getTitle() + ". " + (a.getSummary() != null ? a.getSummary() : ""); - - // 텍스트가 너무 길면 제한 - if (text.length() > 3000) { - text = text.substring(0, 3000); - } - + + NewsArticle article = articleOpt.get(); + String text = buildTtsText(article); + PollyService.VoiceSynthesisResult result = pollyService.synthesizeSpeech(articleId, text, voice); return result.getAudioUrl(); } - + /** * 사용자 뉴스 학습 통계 조회 */ public Map getUserStats(String userId) { UserNewsRepository.NewsStats stats = userNewsRepository.getUserStats(userId); - + return Map.of( "totalRead", stats.totalRead(), "thisWeekRead", stats.thisWeekRead(), @@ -218,14 +163,64 @@ public Map getUserStats(String userId) { "byCategory", stats.byCategory() ); } - - /** - * PK에서 날짜 추출 - */ - private String extractDateFromPk(String pk) { - if (pk == null || !pk.startsWith("NEWS#")) { - return null; + + // ========== Private Helper Methods ========== + + private void saveReadRecord(String userId, NewsArticle article) { + userNewsRepository.saveReadRecord( + userId, + article.getArticleId(), + article.getTitle(), + article.getLevel(), + article.getCategory() + ); + } + + private void incrementArticleReadCount(NewsArticle article) { + String date = NewsKey.extractDateFromPk(article.getPk()); + if (date != null) { + articleRepository.incrementReadCount(date, article.getArticleId()); + } + } + + private List updateStatsAndCheckBadges(String userId) { + try { + UserStats updatedStats = userStatsRepository.incrementNewsReadStats(userId); + if (updatedStats != null) { + List newBadges = badgeService.checkAndAwardBadges(userId, updatedStats); + if (!newBadges.isEmpty()) { + logger.info("새 배지 획득: userId={}, badges={}", + userId, newBadges.stream().map(UserBadge::getBadgeType).toList()); + } + return newBadges; + } + } catch (Exception e) { + logger.error("통계/배지 업데이트 실패: userId={}, error={}", userId, e.getMessage()); + } + return List.of(); + } + + private Map buildBookmarkResponse(NewsArticle article, UserNewsRecord bookmark) { + Map response = new HashMap<>(); + response.put("articleId", article.getArticleId()); + response.put("title", article.getTitle()); + response.put("summary", article.getSummary()); + response.put("source", article.getSource()); + response.put("publishedAt", article.getPublishedAt()); + response.put("keywords", article.getKeywords()); + response.put("highlightWords", article.getHighlightWords()); + response.put("category", article.getCategory()); + response.put("level", article.getLevel()); + response.put("imageUrl", article.getImageUrl()); + response.put("bookmarkedAt", bookmark.getCreatedAt()); + return response; + } + + private String buildTtsText(NewsArticle article) { + String text = article.getTitle() + ". " + (article.getSummary() != null ? article.getSummary() : ""); + if (text.length() > NewsConfig.TTS_MAX_TEXT_LENGTH) { + text = text.substring(0, NewsConfig.TTS_MAX_TEXT_LENGTH); } - return pk.substring(5); + return text; } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQueryService.java index 7f25e408..c1a0f328 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQueryService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQueryService.java @@ -1,6 +1,7 @@ package com.mzc.secondproject.serverless.domain.news.service; import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.domain.news.constants.NewsKey; import com.mzc.secondproject.serverless.domain.news.model.NewsArticle; import com.mzc.secondproject.serverless.domain.news.repository.NewsArticleRepository; import org.slf4j.Logger; @@ -13,37 +14,31 @@ * 뉴스 조회 서비스 */ public class NewsQueryService { - + private static final Logger logger = LoggerFactory.getLogger(NewsQueryService.class); - + private final NewsArticleRepository articleRepository; - + public NewsQueryService() { this.articleRepository = new NewsArticleRepository(); } - + public NewsQueryService(NewsArticleRepository articleRepository) { this.articleRepository = articleRepository; } - + /** * 뉴스 상세 조회 */ public Optional getArticle(String articleId) { logger.debug("뉴스 상세 조회: {}", articleId); Optional article = articleRepository.findById(articleId); - - // 조회수 증가 - article.ifPresent(a -> { - String date = extractDateFromPk(a.getPk()); - if (date != null) { - articleRepository.incrementReadCount(date, articleId); - } - }); - + + article.ifPresent(this::incrementReadCount); + return article; } - + /** * 오늘의 뉴스 목록 조회 */ @@ -52,7 +47,7 @@ public PaginatedResult getTodayNews(int limit, String cursor) { logger.debug("오늘의 뉴스 조회: date={}, limit={}", today, limit); return articleRepository.findByDate(today, limit, cursor); } - + /** * 레벨별 뉴스 조회 */ @@ -60,7 +55,7 @@ public PaginatedResult getNewsByLevel(String level, int limit, Stri logger.debug("레벨별 뉴스 조회: level={}, limit={}", level, limit); return articleRepository.findByLevel(level, limit, cursor); } - + /** * 카테고리별 뉴스 조회 */ @@ -68,7 +63,7 @@ public PaginatedResult getNewsByCategory(String category, int limit logger.debug("카테고리별 뉴스 조회: category={}, limit={}", category, limit); return articleRepository.findByCategory(category, limit, cursor); } - + /** * 레벨 + 카테고리 복합 필터 조회 */ @@ -76,23 +71,19 @@ public PaginatedResult getNewsByLevelAndCategory(String level, Stri logger.debug("레벨+카테고리 뉴스 조회: level={}, category={}, limit={}", level, category, limit); return articleRepository.findByLevelAndCategory(level, category, limit, cursor); } - + /** * 사용자 레벨 맞춤 뉴스 추천 */ public PaginatedResult getRecommendedNews(String userLevel, int limit, String cursor) { logger.debug("맞춤 뉴스 추천: userLevel={}, limit={}", userLevel, limit); - // 사용자 레벨에 맞는 뉴스 조회 return articleRepository.findByLevel(userLevel, limit, cursor); } - - /** - * PK에서 날짜 추출 (NEWS#2024-01-15 → 2024-01-15) - */ - private String extractDateFromPk(String pk) { - if (pk == null || !pk.startsWith("NEWS#")) { - return null; + + private void incrementReadCount(NewsArticle article) { + String date = NewsKey.extractDateFromPk(article.getPk()); + if (date != null) { + articleRepository.incrementReadCount(date, article.getArticleId()); } - return pk.substring(5); } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQuizService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQuizService.java index 31c768c1..f84c3794 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQuizService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQuizService.java @@ -1,5 +1,6 @@ package com.mzc.secondproject.serverless.domain.news.service; +import com.mzc.secondproject.serverless.domain.news.config.NewsConfig; import com.mzc.secondproject.serverless.domain.news.constants.NewsKey; import com.mzc.secondproject.serverless.domain.news.model.NewsArticle; import com.mzc.secondproject.serverless.domain.news.model.NewsQuizResult; @@ -7,6 +8,7 @@ import com.mzc.secondproject.serverless.domain.news.model.QuizQuestion; import com.mzc.secondproject.serverless.domain.news.repository.NewsArticleRepository; import com.mzc.secondproject.serverless.domain.news.repository.NewsQuizRepository; +import com.mzc.secondproject.serverless.domain.notification.service.NotificationPublisher; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -20,18 +22,22 @@ public class NewsQuizService { private static final Logger logger = LoggerFactory.getLogger(NewsQuizService.class); - + private final NewsArticleRepository articleRepository; private final NewsQuizRepository quizRepository; - + private final NotificationPublisher notificationPublisher; + public NewsQuizService() { this.articleRepository = new NewsArticleRepository(); this.quizRepository = new NewsQuizRepository(); + this.notificationPublisher = NotificationPublisher.getInstance(); } - - public NewsQuizService(NewsArticleRepository articleRepository, NewsQuizRepository quizRepository) { + + public NewsQuizService(NewsArticleRepository articleRepository, NewsQuizRepository quizRepository, + NotificationPublisher notificationPublisher) { this.articleRepository = articleRepository; this.quizRepository = quizRepository; + this.notificationPublisher = notificationPublisher; } /** @@ -157,10 +163,23 @@ public QuizSubmitResult submitQuiz(String userId, String articleId, List getUserQuizStats(String userId) { /** * 피드백 생성 */ - private String generateFeedback(int score, List results) { - if (score == 100) { - return "Perfect! You understood the article completely."; - } else if (score >= 80) { - return "Great job! You have a solid understanding of the article."; - } else if (score >= 60) { - return "Good effort! Review the highlighted words for better comprehension."; - } else if (score >= 40) { - return "Keep practicing! Try reading the article again before retaking the quiz."; - } else { - return "Don't give up! Focus on vocabulary and main ideas."; - } + private String generateFeedback(int score) { + return NewsConfig.getFeedbackByScore(score); } /** diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/config/NotificationConfig.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/config/NotificationConfig.java new file mode 100644 index 00000000..9bcbbb30 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/config/NotificationConfig.java @@ -0,0 +1,51 @@ +package com.mzc.secondproject.serverless.domain.notification.config; + +import com.mzc.secondproject.serverless.common.config.EnvConfig; + +/** + * 알림 시스템 설정 + * SSE 스트리밍, 폴링 등 알림 관련 상수 정의 + */ +public final class NotificationConfig { + + private NotificationConfig() { + } + + // ========== Environment Variables ========== + private static final String TOPIC_ARN = EnvConfig.get("NOTIFICATION_TOPIC_ARN"); + private static final String QUEUE_URL = EnvConfig.get("NOTIFICATION_QUEUE_URL"); + + // ========== SSE Streaming ========== + /** SSE 폴링 간격 (밀리초) */ + public static final int SSE_POLL_INTERVAL_MS = 1000; + + /** SSE 최대 스트림 지속 시간 (밀리초) - Lambda 15분 제한 고려 */ + public static final int SSE_MAX_DURATION_MS = 840_000; // 14분 + + /** SSE 최대 메시지 수신 개수 */ + public static final int SSE_MAX_MESSAGES_PER_POLL = 10; + + /** SSE 롱 폴링 대기 시간 (초) */ + public static final int SSE_WAIT_TIME_SECONDS = 1; + + // ========== SSE Event Types ========== + public static final String EVENT_HEARTBEAT = "HEARTBEAT"; + public static final String EVENT_STREAM_END = "STREAM_END"; + + // ========== Getter Methods ========== + public static String topicArn() { + return TOPIC_ARN; + } + + public static String queueUrl() { + return QUEUE_URL; + } + + public static boolean isTopicConfigured() { + return TOPIC_ARN != null && !TOPIC_ARN.isBlank(); + } + + public static boolean isQueueConfigured() { + return QUEUE_URL != null && !QUEUE_URL.isBlank(); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/dto/NotificationMessage.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/dto/NotificationMessage.java new file mode 100644 index 00000000..cb073bd9 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/dto/NotificationMessage.java @@ -0,0 +1,60 @@ +package com.mzc.secondproject.serverless.domain.notification.dto; + +import com.mzc.secondproject.serverless.domain.notification.enums.NotificationType; + +import java.time.Instant; +import java.util.Map; + +/** + * 알림 메시지 DTO + * SNS로 발행되고 SSE로 클라이언트에 전달되는 메시지 구조 + */ +public record NotificationMessage( + String notificationId, + NotificationType type, + String userId, + Map payload, + String createdAt +) { + /** + * Builder 패턴으로 알림 메시지 생성 + */ + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private NotificationType type; + private String userId; + private Map payload; + + public Builder type(NotificationType type) { + this.type = type; + return this; + } + + public Builder userId(String userId) { + this.userId = userId; + return this; + } + + public Builder payload(Map payload) { + this.payload = payload; + return this; + } + + public NotificationMessage build() { + return new NotificationMessage( + generateNotificationId(), + type, + userId, + payload, + Instant.now().toString() + ); + } + + private String generateNotificationId() { + return "notif-" + java.util.UUID.randomUUID().toString().substring(0, 8); + } + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/enums/NotificationType.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/enums/NotificationType.java new file mode 100644 index 00000000..87cf3e8c --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/enums/NotificationType.java @@ -0,0 +1,41 @@ +package com.mzc.secondproject.serverless.domain.notification.enums; + +/** + * 알림 타입 정의 + * 새로운 알림 타입 추가 시 여기에 enum 추가 + */ +public enum NotificationType { + // 배지 관련 + BADGE_EARNED("배지 획득", "badge"), + + // 학습 관련 + DAILY_COMPLETE("일일 학습 완료", "daily"), + STREAK_REMINDER("연속 학습 리마인더", "streak"), + + // 테스트/퀴즈 관련 + TEST_COMPLETE("테스트 완료", "test"), + NEWS_QUIZ_COMPLETE("뉴스 퀴즈 완료", "quiz"), + + // 게임 관련 + GAME_END("게임 종료", "game"), + GAME_STREAK("게임 연속 정답", "game"), + + // OPIc 관련 + OPIC_COMPLETE("OPIc 세션 완료", "opic"); + + private final String description; + private final String category; + + NotificationType(String description, String category) { + this.description = description; + this.category = category; + } + + public String getDescription() { + return description; + } + + public String getCategory() { + return category; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/handler/NotificationStreamHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/handler/NotificationStreamHandler.java new file mode 100644 index 00000000..385ddafb --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/handler/NotificationStreamHandler.java @@ -0,0 +1,203 @@ +package com.mzc.secondproject.serverless.domain.notification.handler; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestStreamHandler; +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.util.JsonUtil; +import com.mzc.secondproject.serverless.domain.notification.config.NotificationConfig; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.services.sqs.SqsClient; +import software.amazon.awssdk.services.sqs.model.DeleteMessageRequest; +import software.amazon.awssdk.services.sqs.model.Message; +import software.amazon.awssdk.services.sqs.model.ReceiveMessageRequest; + +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; + +/** + * SSE(Server-Sent Events) 알림 스트리밍 Lambda Handler + * Lambda Function URL with Response Streaming을 사용하여 실시간 알림 제공 + * + * 클라이언트 연결 예시: + * const eventSource = new EventSource('https://{function-url}/?userId={userId}'); + * eventSource.onmessage = (event) => console.log(JSON.parse(event.data)); + */ +public class NotificationStreamHandler implements RequestStreamHandler { + + private static final Logger logger = LoggerFactory.getLogger(NotificationStreamHandler.class); + + private final SqsClient sqsClient; + + public NotificationStreamHandler() { + this.sqsClient = AwsClients.sqs(); + } + + public NotificationStreamHandler(SqsClient sqsClient) { + this.sqsClient = sqsClient; + } + + @Override + public void handleRequest(InputStream input, OutputStream output, Context context) throws IOException { + Map event = parseEvent(input); + String userId = extractUserId(event); + + if (userId == null || userId.isBlank()) { + sendErrorResponse(output, 400, "userId query parameter is required"); + return; + } + + logger.info("SSE connection started: userId={}, requestId={}", userId, context.getAwsRequestId()); + + try (BufferedOutputStream bufferedOutput = new BufferedOutputStream(output)) { + streamNotifications(bufferedOutput, userId); + } catch (Exception e) { + logger.error("SSE stream error: userId={}", userId, e); + } + } + + private void streamNotifications(BufferedOutputStream output, String userId) throws IOException { + writeSSEHeaders(output); + sendHeartbeat(output); + + long startTime = System.currentTimeMillis(); + + while (!isTimeoutReached(startTime)) { + List messages = pollMessages(); + + for (Message message : messages) { + if (isMessageForUser(message, userId)) { + sendSSEEvent(output, message.body()); + deleteMessage(message); + } + } + + if (messages.isEmpty()) { + sendHeartbeat(output); + } + + sleep(); + } + + sendStreamEndEvent(output); + logger.info("SSE connection ended: userId={} (timeout)", userId); + } + + private Map parseEvent(InputStream input) throws IOException { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8))) { + StringBuilder sb = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + sb.append(line); + } + return JsonUtil.fromJson(sb.toString(), Map.class); + } + } + + @SuppressWarnings("unchecked") + private String extractUserId(Map event) { + Object queryParams = event.get("queryStringParameters"); + if (queryParams instanceof Map) { + Object userId = ((Map) queryParams).get("userId"); + return userId != null ? userId.toString() : null; + } + return null; + } + + private void writeSSEHeaders(OutputStream output) throws IOException { + String headers = "HTTP/1.1 200 OK\r\n" + + "Content-Type: text/event-stream\r\n" + + "Cache-Control: no-cache\r\n" + + "Connection: keep-alive\r\n" + + "Access-Control-Allow-Origin: *\r\n" + + "\r\n"; + output.write(headers.getBytes(StandardCharsets.UTF_8)); + output.flush(); + } + + private void sendSSEEvent(OutputStream output, String data) throws IOException { + String event = "data: " + data + "\n\n"; + output.write(event.getBytes(StandardCharsets.UTF_8)); + output.flush(); + } + + private void sendHeartbeat(OutputStream output) throws IOException { + String heartbeat = JsonUtil.toJson(Map.of( + "type", NotificationConfig.EVENT_HEARTBEAT, + "timestamp", System.currentTimeMillis() + )); + sendSSEEvent(output, heartbeat); + } + + private void sendStreamEndEvent(OutputStream output) throws IOException { + String endEvent = JsonUtil.toJson(Map.of( + "type", NotificationConfig.EVENT_STREAM_END, + "message", "Connection timeout" + )); + sendSSEEvent(output, endEvent); + } + + private void sendErrorResponse(OutputStream output, int statusCode, String message) throws IOException { + String response = JsonUtil.toJson(Map.of( + "statusCode", statusCode, + "body", JsonUtil.toJson(Map.of("error", message)) + )); + output.write(response.getBytes(StandardCharsets.UTF_8)); + output.flush(); + } + + private List pollMessages() { + if (!NotificationConfig.isQueueConfigured()) { + return List.of(); + } + + try { + ReceiveMessageRequest request = ReceiveMessageRequest.builder() + .queueUrl(NotificationConfig.queueUrl()) + .maxNumberOfMessages(NotificationConfig.SSE_MAX_MESSAGES_PER_POLL) + .waitTimeSeconds(NotificationConfig.SSE_WAIT_TIME_SECONDS) + .messageAttributeNames("userId", "type") + .build(); + + return sqsClient.receiveMessage(request).messages(); + } catch (Exception e) { + logger.warn("Failed to poll messages: {}", e.getMessage()); + return List.of(); + } + } + + private boolean isMessageForUser(Message message, String targetUserId) { + try { + Map body = JsonUtil.fromJson(message.body(), Map.class); + String messageUserId = (String) body.get("userId"); + return targetUserId.equals(messageUserId); + } catch (Exception e) { + return false; + } + } + + private void deleteMessage(Message message) { + try { + sqsClient.deleteMessage(DeleteMessageRequest.builder() + .queueUrl(NotificationConfig.queueUrl()) + .receiptHandle(message.receiptHandle()) + .build()); + } catch (Exception e) { + logger.warn("Failed to delete message: {}", e.getMessage()); + } + } + + private boolean isTimeoutReached(long startTime) { + return (System.currentTimeMillis() - startTime) > NotificationConfig.SSE_MAX_DURATION_MS; + } + + private void sleep() { + try { + Thread.sleep(NotificationConfig.SSE_POLL_INTERVAL_MS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/handler/StreakReminderHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/handler/StreakReminderHandler.java new file mode 100644 index 00000000..d1cbd30c --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/handler/StreakReminderHandler.java @@ -0,0 +1,123 @@ +package com.mzc.secondproject.serverless.domain.notification.handler; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.events.ScheduledEvent; +import com.mzc.secondproject.serverless.domain.notification.enums.NotificationType; +import com.mzc.secondproject.serverless.domain.notification.service.NotificationPublisher; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; +import com.mzc.secondproject.serverless.domain.stats.repository.UserStatsRepository; +import com.mzc.secondproject.serverless.domain.vocabulary.model.DailyStudy; +import com.mzc.secondproject.serverless.domain.vocabulary.repository.DailyStudyRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 연속 학습 리마인더 Lambda Handler + * EventBridge 스케줄러에 의해 매일 21시(KST)에 트리거 + * 오늘 학습하지 않은 사용자 중 연속 학습 중인 사용자에게 알림 발송 + */ +public class StreakReminderHandler implements RequestHandler { + + private static final Logger logger = LoggerFactory.getLogger(StreakReminderHandler.class); + + private final DailyStudyRepository dailyStudyRepository; + private final UserStatsRepository userStatsRepository; + private final NotificationPublisher notificationPublisher; + + public StreakReminderHandler() { + this.dailyStudyRepository = new DailyStudyRepository(); + this.userStatsRepository = new UserStatsRepository(); + this.notificationPublisher = NotificationPublisher.getInstance(); + } + + public StreakReminderHandler(DailyStudyRepository dailyStudyRepository, + UserStatsRepository userStatsRepository, + NotificationPublisher notificationPublisher) { + this.dailyStudyRepository = dailyStudyRepository; + this.userStatsRepository = userStatsRepository; + this.notificationPublisher = notificationPublisher; + } + + @Override + public Response handleRequest(ScheduledEvent event, Context context) { + logger.info("Streak reminder started: requestId={}", context.getAwsRequestId()); + + try { + int remindersSent = processReminders(); + logger.info("Streak reminder completed: sent={}", remindersSent); + return Response.success(remindersSent); + } catch (Exception e) { + logger.error("Streak reminder failed", e); + return Response.error(e.getMessage()); + } + } + + private int processReminders() { + String today = LocalDate.now().toString(); + + Set studiedUserIds = findStudiedUserIds(today); + List usersWithStreak = userStatsRepository.findUsersWithActiveStreak(); + + int remindersSent = 0; + for (UserStats stats : usersWithStreak) { + if (shouldSendReminder(stats, studiedUserIds)) { + sendReminder(stats); + remindersSent++; + } + } + + return remindersSent; + } + + private Set findStudiedUserIds(String date) { + return dailyStudyRepository.findByDate(date).stream() + .filter(ds -> Boolean.TRUE.equals(ds.getIsCompleted())) + .map(DailyStudy::getUserId) + .collect(Collectors.toSet()); + } + + private boolean shouldSendReminder(UserStats stats, Set studiedUserIds) { + if (studiedUserIds.contains(stats.getUserId())) { + return false; + } + Integer streak = stats.getCurrentStreak(); + return streak != null && streak > 0; + } + + private void sendReminder(UserStats stats) { + String userId = stats.getUserId(); + int streak = stats.getCurrentStreak(); + + notificationPublisher.publish( + NotificationType.STREAK_REMINDER, + userId, + Map.of( + "currentStreak", streak, + "message", String.format("%d일 연속 학습 중! 오늘도 학습해서 기록을 이어가세요.", streak) + ) + ); + + logger.debug("Streak reminder sent: userId={}, streak={}", userId, streak); + } + + /** + * Lambda 응답 DTO + */ + public record Response(int statusCode, String message, int remindersSent) { + + public static Response success(int remindersSent) { + return new Response(200, "Streak reminders sent", remindersSent); + } + + public static Response error(String errorMessage) { + return new Response(500, "Streak reminder failed: " + errorMessage, 0); + } + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/service/NotificationPublisher.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/service/NotificationPublisher.java new file mode 100644 index 00000000..53ee0ad0 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/service/NotificationPublisher.java @@ -0,0 +1,201 @@ +package com.mzc.secondproject.serverless.domain.notification.service; + +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.util.JsonUtil; +import com.mzc.secondproject.serverless.domain.notification.config.NotificationConfig; +import com.mzc.secondproject.serverless.domain.notification.dto.NotificationMessage; +import com.mzc.secondproject.serverless.domain.notification.enums.NotificationType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.services.sns.SnsClient; +import software.amazon.awssdk.services.sns.model.MessageAttributeValue; +import software.amazon.awssdk.services.sns.model.PublishRequest; +import software.amazon.awssdk.services.sns.model.PublishResponse; + +import java.util.Map; + +/** + * 알림 발행 서비스 + * SNS 토픽에 알림 메시지를 발행하는 역할 + * + * 사용 예시: + *

+ * NotificationPublisher.getInstance().publish(
+ *     NotificationType.BADGE_EARNED,
+ *     userId,
+ *     Map.of("badgeType", "STREAK_7", "badgeName", "7일 연속 학습")
+ * );
+ * 
+ */ +public class NotificationPublisher { + + private static final Logger logger = LoggerFactory.getLogger(NotificationPublisher.class); + + private static volatile NotificationPublisher instance; + private final SnsClient snsClient; + + private NotificationPublisher() { + this.snsClient = AwsClients.sns(); + } + + private NotificationPublisher(SnsClient snsClient) { + this.snsClient = snsClient; + } + + /** + * 싱글톤 인스턴스 반환 + */ + public static NotificationPublisher getInstance() { + if (instance == null) { + synchronized (NotificationPublisher.class) { + if (instance == null) { + instance = new NotificationPublisher(); + } + } + } + return instance; + } + + /** + * 테스트용 인스턴스 생성 + */ + public static NotificationPublisher createForTest(SnsClient snsClient) { + return new NotificationPublisher(snsClient); + } + + /** + * 알림 발행 (비동기, non-blocking) + * 발행 실패 시에도 호출자의 비즈니스 로직에 영향을 주지 않음 + * + * @param type 알림 타입 + * @param userId 대상 사용자 ID + * @param payload 알림 페이로드 + */ + public void publish(NotificationType type, String userId, Map payload) { + if (!NotificationConfig.isTopicConfigured()) { + logger.warn("NOTIFICATION_TOPIC_ARN is not configured. Skipping notification."); + return; + } + + try { + NotificationMessage message = NotificationMessage.builder() + .type(type) + .userId(userId) + .payload(payload) + .build(); + + String messageJson = JsonUtil.toJson(message); + + PublishRequest request = PublishRequest.builder() + .topicArn(NotificationConfig.topicArn()) + .message(messageJson) + .messageAttributes(Map.of( + "type", MessageAttributeValue.builder() + .dataType("String") + .stringValue(type.name()) + .build(), + "userId", MessageAttributeValue.builder() + .dataType("String") + .stringValue(userId) + .build(), + "category", MessageAttributeValue.builder() + .dataType("String") + .stringValue(type.getCategory()) + .build() + )) + .build(); + + PublishResponse response = snsClient.publish(request); + logger.info("Notification published: type={}, userId={}, messageId={}", + type, userId, response.messageId()); + + } catch (Exception e) { + // 알림 발행 실패는 비즈니스 로직에 영향을 주지 않도록 로깅만 수행 + logger.error("Failed to publish notification: type={}, userId={}, error={}", + type, userId, e.getMessage()); + } + } + + /** + * 배지 획득 알림 발행 헬퍼 메서드 + */ + public void publishBadgeEarned(String userId, String badgeType, String badgeName, + String description, String iconUrl) { + publish(NotificationType.BADGE_EARNED, userId, Map.of( + "badgeType", badgeType, + "badgeName", badgeName, + "description", description, + "iconUrl", iconUrl != null ? iconUrl : "" + )); + } + + /** + * 일일 학습 완료 알림 발행 헬퍼 메서드 + */ + public void publishDailyComplete(String userId, String date, int wordsLearned, + int totalWords, int currentStreak) { + publish(NotificationType.DAILY_COMPLETE, userId, Map.of( + "date", date, + "wordsLearned", wordsLearned, + "totalWords", totalWords, + "currentStreak", currentStreak + )); + } + + /** + * 테스트 완료 알림 발행 헬퍼 메서드 + */ + public void publishTestComplete(String userId, String testId, int score, + int correctCount, int totalCount, boolean isPerfect) { + publish(NotificationType.TEST_COMPLETE, userId, Map.of( + "testId", testId, + "score", score, + "correctCount", correctCount, + "totalCount", totalCount, + "isPerfect", isPerfect + )); + } + + /** + * 뉴스 퀴즈 완료 알림 발행 헬퍼 메서드 + */ + public void publishNewsQuizComplete(String userId, String articleId, String articleTitle, + int score, int correctCount, int totalCount, boolean isPerfect) { + publish(NotificationType.NEWS_QUIZ_COMPLETE, userId, Map.of( + "articleId", articleId, + "articleTitle", articleTitle, + "score", score, + "correctCount", correctCount, + "totalCount", totalCount, + "isPerfect", isPerfect + )); + } + + /** + * 게임 종료 알림 발행 헬퍼 메서드 + */ + public void publishGameEnd(String userId, String roomId, String gameSessionId, + int rank, int totalPlayers, int score, boolean isWinner) { + publish(NotificationType.GAME_END, userId, Map.of( + "roomId", roomId, + "gameSessionId", gameSessionId, + "rank", rank, + "totalPlayers", totalPlayers, + "score", score, + "isWinner", isWinner + )); + } + + /** + * OPIc 세션 완료 알림 발행 헬퍼 메서드 + */ + public void publishOpicComplete(String userId, String sessionId, String estimatedLevel, + int questionsAnswered, String feedbackSummary) { + publish(NotificationType.OPIC_COMPLETE, userId, Map.of( + "sessionId", sessionId, + "estimatedLevel", estimatedLevel, + "questionsAnswered", questionsAnswered, + "feedbackSummary", feedbackSummary != null ? feedbackSummary : "" + )); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingConnectHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingConnectHandler.java deleted file mode 100644 index e69de29b..00000000 diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingDisconnectHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingDisconnectHandler.java deleted file mode 100644 index e69de29b..00000000 diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingHandler.java index ed6fbda0..2ebda605 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingHandler.java @@ -19,142 +19,145 @@ /** * Speaking API 핸들러 - *

+ * * POST /api/speaking/chat - 대화 (음성 또는 텍스트) * POST /api/speaking/reset - 대화 초기화 */ public class SpeakingHandler implements RequestHandler { - - private static final Logger logger = LoggerFactory.getLogger(SpeakingHandler.class); - private static final Gson gson = new GsonBuilder().create(); - - private static final Map CORS_HEADERS = Map.of( - "Content-Type", "application/json", - "Access-Control-Allow-Origin", "*", - "Access-Control-Allow-Headers", "Content-Type,Authorization", - "Access-Control-Allow-Methods", "POST,OPTIONS" - ); - - private final SpeakingService speakingService; - - public SpeakingHandler() { - this.speakingService = new SpeakingService(); - } - - @Override - public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent event, Context context) { - logger.info("Speaking API request received"); - - // OPTIONS 요청 처리 (CORS preflight) - if ("OPTIONS".equalsIgnoreCase(event.getHttpMethod())) { - return response(200, Map.of("message", "OK")); - } - - try { - // JWT 토큰 검증 - String authHeader = event.getHeaders().get("Authorization"); - if (authHeader == null || !authHeader.startsWith("Bearer ")) { - return response(401, Map.of("error", "Authorization header is required")); - } - - String token = authHeader.substring(7); - if (!JwtUtil.isValid(token)) { - return response(401, Map.of("error", "Invalid or expired token")); - } - - Optional userIdOpt = JwtUtil.extractUserId(token); - if (userIdOpt.isEmpty()) { - return response(401, Map.of("error", "Invalid token")); - } - - String userId = userIdOpt.get(); - String path = event.getPath(); - String body = event.getBody(); - - logger.info("Processing request: path={}, userId={}", path, userId); - - // 라우팅 - if (path.endsWith("/chat")) { - return handleChat(userId, body); - } else if (path.endsWith("/reset")) { - return handleReset(userId, body); - } else { - return response(404, Map.of("error", "Not found")); - } - - } catch (Exception e) { - logger.error("Error processing request: {}", e.getMessage(), e); - return response(500, Map.of("error", "Internal server error: " + e.getMessage())); - } - } - - /** - * 대화 처리 (음성 또는 텍스트) - */ - private APIGatewayProxyResponseEvent handleChat(String userId, String body) { - if (body == null || body.isEmpty()) { - return response(400, Map.of("error", "Request body is required")); - } - - JsonObject request = JsonParser.parseString(body).getAsJsonObject(); - - String sessionId = request.has("sessionId") ? request.get("sessionId").getAsString() : null; - String level = request.has("level") ? request.get("level").getAsString() : "INTERMEDIATE"; - String audio = request.has("audio") ? request.get("audio").getAsString() : null; - String text = request.has("text") ? request.get("text").getAsString() : null; - - SpeakingResponse result; - - if (audio != null && !audio.isEmpty()) { - // 음성 입력 처리 - logger.info("Processing voice input"); - result = speakingService.processVoiceInput(sessionId, userId, audio, level); - } else if (text != null && !text.trim().isEmpty()) { - // 텍스트 입력 처리 - logger.info("Processing text input: {}", text); - result = speakingService.processTextInput(sessionId, userId, text.trim(), level); - } else { - return response(400, Map.of("error", "Either 'audio' or 'text' is required")); - } - - return response(200, Map.of( - "sessionId", result.sessionId(), - "userTranscript", result.userTranscript(), - "aiText", result.aiText(), - "aiAudioUrl", result.aiAudioUrl(), - "confidence", result.confidence() - )); - } - - /** - * 대화 초기화 - */ - private APIGatewayProxyResponseEvent handleReset(String userId, String body) { - if (body == null || body.isEmpty()) { - return response(400, Map.of("error", "Request body is required")); - } - - JsonObject request = JsonParser.parseString(body).getAsJsonObject(); - String sessionId = request.has("sessionId") ? request.get("sessionId").getAsString() : null; - - if (sessionId == null || sessionId.isEmpty()) { - return response(400, Map.of("error", "sessionId is required")); - } - - speakingService.resetConversation(sessionId); - - return response(200, Map.of( - "message", "Conversation reset successfully", - "sessionId", sessionId - )); - } - - private APIGatewayProxyResponseEvent response(int statusCode, Map body) { - return new APIGatewayProxyResponseEvent() - .withStatusCode(statusCode) - .withHeaders(CORS_HEADERS) - .withBody(gson.toJson(body)); - } - - + + private static final Logger logger = LoggerFactory.getLogger(SpeakingHandler.class); + private static final Gson gson = new GsonBuilder().create(); + + private static final Map CORS_HEADERS = Map.of( + "Content-Type", "application/json", + "Access-Control-Allow-Origin", "*", + "Access-Control-Allow-Headers", "Content-Type,Authorization", + "Access-Control-Allow-Methods", "POST,OPTIONS" + ); + + private final SpeakingService speakingService; + + public SpeakingHandler() { + this.speakingService = new SpeakingService(); + } + + @Override + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent event, Context context) { + logger.info("Speaking API request received"); + + // OPTIONS 요청 처리 (CORS preflight) + if ("OPTIONS".equalsIgnoreCase(event.getHttpMethod())) { + return response(200, Map.of("message", "OK")); + } + + try { + // 사용자 인증 정보 추출 (Cognito Authorizer -> requestContext) + if (event.getRequestContext() == null || event.getRequestContext().getAuthorizer() == null) { + logger.error("No Authorizer found in request context"); + return response(401, Map.of("error", "Unauthorized: User context missing")); + } + + Map authorizer = event.getRequestContext().getAuthorizer(); + Map claims = (Map) authorizer.get("claims"); + + if (claims == null) { + return response(401, Map.of("error", "Unauthorized: Claims missing")); + } + + String userId = (String) claims.get("sub"); // Cognito User Pool의 고유 ID (UUID 형태) + + // 요청 정보 추출 + String path = event.getPath(); + String body = event.getBody(); + + logger.info("Processing request: path={}, userId={}", path, userId); + + // 라우팅 + if (path != null && path.endsWith("/chat")) { + return handleChat(userId, body); + } else if (path != null && path.endsWith("/reset")) { + return handleReset(userId, body); + } else { + return response(404, Map.of("error", "Not found")); + } + + } catch (Exception e) { + logger.error("Error processing request: {}", e.getMessage(), e); + return response(500, Map.of("error", "Internal server error: " + e.getMessage())); + } + } + + /** + * 대화 처리 (음성 또는 텍스트) + */ + private APIGatewayProxyResponseEvent handleChat(String userId, String body) { + if (body == null || body.isEmpty()) { + return response(400, Map.of("error", "Request body is required")); + } + + JsonObject request = JsonParser.parseString(body).getAsJsonObject(); + + String sessionId = request.has("sessionId") && !request.get("sessionId").isJsonNull() + ? request.get("sessionId").getAsString() : null; + String level = request.has("level") && !request.get("level").isJsonNull() + ? request.get("level").getAsString() : "INTERMEDIATE"; + String audio = request.has("audio") && !request.get("audio").isJsonNull() + ? request.get("audio").getAsString() : null; + String text = request.has("text") && !request.get("text").isJsonNull() + ? request.get("text").getAsString() : null; + + SpeakingResponse result; + + if (audio != null && !audio.isEmpty()) { + // 음성 입력 처리 + logger.info("Processing voice event"); + result = speakingService.processVoiceInput(sessionId, userId, audio, level); + } else if (text != null && !text.trim().isEmpty()) { + // 텍스트 입력 처리 + logger.info("Processing text event: {}", text); + result = speakingService.processTextInput(sessionId, userId, text.trim(), level); + } else { + return response(400, Map.of("error", "Either 'audio' or 'text' is required")); + } + + return response(200, Map.of( + "sessionId", result.sessionId(), + "userTranscript", result.userTranscript(), + "aiText", result.aiText(), + "aiAudioUrl", result.aiAudioUrl(), + "confidence", result.confidence() + )); + } + + /** + * 대화 초기화 + */ + private APIGatewayProxyResponseEvent handleReset(String userId, String body) { + if (body == null || body.isEmpty()) { + return response(400, Map.of("error", "Request body is required")); + } + + JsonObject request = JsonParser.parseString(body).getAsJsonObject(); + String sessionId = request.has("sessionId") ? request.get("sessionId").getAsString() : null; + + if (sessionId == null || sessionId.isEmpty()) { + return response(400, Map.of("error", "sessionId is required")); + } + + speakingService.resetConversation(sessionId); + + return response(200, Map.of( + "message", "Conversation reset successfully", + "sessionId", sessionId + )); + } + + private APIGatewayProxyResponseEvent response(int statusCode, Map body) { + return new APIGatewayProxyResponseEvent() + .withStatusCode(statusCode) + .withHeaders(CORS_HEADERS) + .withBody(gson.toJson(body)); + } + + } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingMessageHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingMessageHandler.java deleted file mode 100644 index e69de29b..00000000 diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java deleted file mode 100644 index e69de29b..00000000 diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingSessionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingSessionRepository.java index aa7acb63..dadda7e7 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingSessionRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingSessionRepository.java @@ -12,63 +12,63 @@ import java.util.Optional; /** - * Speaking WebSocket 연결 정보 Repository + * Speaking API 연결 정보 Repository */ public class SpeakingSessionRepository { - - private static final Logger logger = LoggerFactory.getLogger(SpeakingSessionRepository.class); - private static final String TABLE_NAME = EnvConfig.getRequired("CHAT_TABLE_NAME"); - - private final DynamoDbTable table; - - public SpeakingSessionRepository() { - this.table = AwsClients.dynamoDbEnhanced().table( - TABLE_NAME, - TableSchema.fromBean(SpeakingSession.class) - ); - } - - /** - * 연결 정보 저장 - */ - public void save(SpeakingSession session) { - table.putItem(session); - logger.debug("Speaking session saved: sessionId={}, userId={}", - session.getSessionId(), session.getUserId()); - } - - /** - * sessionId로 연결 정보 조회 - */ - public Optional findBySessionId(String sessionId) { - Key key = Key.builder() - .partitionValue(SpeakingSession.PK_PREFIX + sessionId) - .sortValue(SpeakingSession.SK_METADATA) - .build(); - - SpeakingSession session = table.getItem(key); - return Optional.ofNullable(session); - } - - /** - * 연결 정보 업데이트 (대화 히스토리 등) - */ - public void update(SpeakingSession session) { - session.touch(); // 업데이트 시간 및 TTL 갱신 - table.putItem(session); - logger.debug("Speaking session updated: sessionId={}", session.getSessionId()); - } - - /** - * 연결 정보 삭제 - */ - public void delete(String sessionId) { - Key key = Key.builder() - .partitionValue(SpeakingSession.PK_PREFIX + sessionId) - .sortValue(SpeakingSession.SK_METADATA) - .build(); - - table.deleteItem(key); - logger.info("Speaking session deleted: sessionId={}", sessionId); - } -} + + private static final Logger logger = LoggerFactory.getLogger(SpeakingSessionRepository.class); + private static final String TABLE_NAME = EnvConfig.getRequired("SPEAKING_TABLE_NAME"); + + private final DynamoDbTable table; + + public SpeakingSessionRepository() { + this.table = AwsClients.dynamoDbEnhanced().table( + TABLE_NAME, + TableSchema.fromBean(SpeakingSession.class) + ); + } + + /** + * 연결 정보 저장 + */ + public void save(SpeakingSession session) { + table.putItem(session); + logger.debug("Speaking session saved: sessionId={}, userId={}", + session.getSessionId(), session.getUserId()); + } + + /** + * sessionId로 연결 정보 조회 + */ + public Optional findBySessionId(String sessionId) { + Key key = Key.builder() + .partitionValue(SpeakingSession.PK_PREFIX + sessionId) + .sortValue(SpeakingSession.SK_METADATA) + .build(); + + SpeakingSession session = table.getItem(key); + return Optional.ofNullable(session); + } + + /** + * 연결 정보 업데이트 (대화 히스토리 등) + */ + public void update(SpeakingSession session) { + session.touch(); // 업데이트 시간 및 TTL 갱신 + table.putItem(session); + logger.debug("Speaking session updated: sessionId={}", session.getSessionId()); + } + + /** + * 연결 정보 삭제 + */ + public void delete(String sessionId) { + Key key = Key.builder() + .partitionValue(SpeakingSession.PK_PREFIX + sessionId) + .sortValue(SpeakingSession.SK_METADATA) + .build(); + + table.deleteItem(key); + logger.info("Speaking session deleted: sessionId={}", sessionId); + } +} \ No newline at end of file diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java index 86b90969..4ec4174f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java @@ -526,6 +526,31 @@ public UserStats incrementNewsWordStats(String userId, int wordCount) { return findTotalStats(userId).orElse(null); } + /** + * 연속 학습 중인 사용자 목록 조회 (streak >= 1) + * GSI1을 사용하여 TOTAL 통계만 조회 후 필터링 + */ + public List findUsersWithActiveStreak() { + QueryConditional queryConditional = QueryConditional + .keyEqualTo(Key.builder() + .partitionValue("STATS#ALL") + .sortValue(StatsKey.statsTotalSk()) + .build()); + + QueryEnhancedRequest request = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .build(); + + List results = new ArrayList<>(); + table.index("GSI1").query(request).forEach(page -> { + page.items().stream() + .filter(stats -> stats.getCurrentStreak() != null && stats.getCurrentStreak() >= 1) + .forEach(results::add); + }); + + return results; + } + /** * 현재 연도-주차 반환 (예: 2026-W02) */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/DailyStudyRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/DailyStudyRepository.java index 6cb33629..198a6849 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/DailyStudyRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/DailyStudyRepository.java @@ -17,7 +17,9 @@ import software.amazon.awssdk.services.dynamodb.model.AttributeValue; import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Optional; @@ -115,4 +117,24 @@ public void addLearnedWord(String userId, String date, String wordId) { AwsClients.dynamoDb().updateItem(updateRequest); logger.info("Added learned word: userId={}, date={}, wordId={}", userId, date, wordId); } + + /** + * 특정 날짜의 모든 일일 학습 기록 조회 (GSI1 사용) + */ + public List findByDate(String date) { + QueryConditional queryConditional = QueryConditional + .keyEqualTo(Key.builder() + .partitionValue("DAILY#ALL") + .sortValue("DATE#" + date) + .build()); + + QueryEnhancedRequest request = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .build(); + + List results = new ArrayList<>(); + table.index("GSI1").query(request).forEach(page -> results.addAll(page.items())); + + return results; + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyCommandService.java index 32dc5b24..81a528c6 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyCommandService.java @@ -3,6 +3,7 @@ import com.mzc.secondproject.serverless.common.dto.PaginatedResult; import com.mzc.secondproject.serverless.common.enums.StudyLevel; import com.mzc.secondproject.serverless.domain.badge.service.BadgeService; +import com.mzc.secondproject.serverless.domain.notification.service.NotificationPublisher; import com.mzc.secondproject.serverless.domain.stats.model.UserStats; import com.mzc.secondproject.serverless.domain.stats.repository.UserStatsRepository; import com.mzc.secondproject.serverless.domain.vocabulary.config.VocabularyConfig; @@ -34,15 +35,16 @@ public class DailyStudyCommandService { private final WordRepository wordRepository; private final UserStatsRepository userStatsRepository; private final BadgeService badgeService; - + private final NotificationPublisher notificationPublisher; + /** * 기본 생성자 (Lambda에서 사용) */ public DailyStudyCommandService() { this(new DailyStudyRepository(), new UserWordRepository(), new WordRepository(), - new UserStatsRepository(), new BadgeService()); + new UserStatsRepository(), new BadgeService(), NotificationPublisher.getInstance()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ @@ -50,12 +52,14 @@ public DailyStudyCommandService(DailyStudyRepository dailyStudyRepository, UserWordRepository userWordRepository, WordRepository wordRepository, UserStatsRepository userStatsRepository, - BadgeService badgeService) { + BadgeService badgeService, + NotificationPublisher notificationPublisher) { this.dailyStudyRepository = dailyStudyRepository; this.userWordRepository = userWordRepository; this.wordRepository = wordRepository; this.userStatsRepository = userStatsRepository; this.badgeService = badgeService; + this.notificationPublisher = notificationPublisher; } public DailyStudyResult getDailyWords(String userId, String level) { @@ -115,16 +119,36 @@ public Map markWordLearned(String userId, String wordId) { checkWordsBadge(userId); DailyStudy updatedDailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today).orElse(dailyStudy); - + if (updatedDailyStudy.getLearnedCount() >= updatedDailyStudy.getTotalWords()) { updatedDailyStudy.setIsCompleted(true); dailyStudyRepository.save(updatedDailyStudy); + + // 일일 학습 완료 알림 발행 + int currentStreak = getCurrentStreak(userId); + notificationPublisher.publishDailyComplete( + userId, + today, + updatedDailyStudy.getLearnedCount(), + updatedDailyStudy.getTotalWords(), + currentStreak + ); } - + logger.info("Marked word as learned: userId={}, wordId={}, isNew={}, isReview={}", userId, wordId, isNewWord, isReviewWord); return calculateProgress(updatedDailyStudy); } + + private int getCurrentStreak(String userId) { + try { + Optional stats = userStatsRepository.findTotalStats(userId); + return stats.map(UserStats::getCurrentStreak).orElse(0); + } catch (Exception e) { + logger.warn("Failed to get current streak for user: {}", userId, e); + return 0; + } + } private DailyStudy createDailyStudy(String userId, String date, String level) { String now = Instant.now().toString(); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java index 43c0c095..f9ef6861 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java @@ -4,6 +4,7 @@ import com.mzc.secondproject.serverless.common.config.EnvConfig; import com.mzc.secondproject.serverless.common.dto.PaginatedResult; import com.mzc.secondproject.serverless.common.util.ResponseGenerator; +import com.mzc.secondproject.serverless.domain.notification.service.NotificationPublisher; import com.mzc.secondproject.serverless.domain.vocabulary.dto.request.SubmitTestRequest; import com.mzc.secondproject.serverless.domain.vocabulary.exception.VocabularyException; import com.mzc.secondproject.serverless.domain.vocabulary.model.DailyStudy; @@ -33,26 +34,29 @@ public class TestCommandService { private final DailyStudyRepository dailyStudyRepository; private final WordRepository wordRepository; private final UserWordCommandService userWordCommandService; - + private final NotificationPublisher notificationPublisher; + /** * 기본 생성자 (Lambda에서 사용) */ public TestCommandService() { this(new TestResultRepository(), new DailyStudyRepository(), - new WordRepository(), new UserWordCommandService()); + new WordRepository(), new UserWordCommandService(), NotificationPublisher.getInstance()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ public TestCommandService(TestResultRepository testResultRepository, DailyStudyRepository dailyStudyRepository, WordRepository wordRepository, - UserWordCommandService userWordCommandService) { + UserWordCommandService userWordCommandService, + NotificationPublisher notificationPublisher) { this.testResultRepository = testResultRepository; this.dailyStudyRepository = dailyStudyRepository; this.wordRepository = wordRepository; this.userWordCommandService = userWordCommandService; + this.notificationPublisher = notificationPublisher; } public StartTestResult startTest(String userId, String testType) { @@ -116,12 +120,23 @@ public SubmitTestResult submitTest(String userId, String testId, String testType // 3. 오답 단어 자동 북마크 bookmarkIncorrectWords(userId, gradingResult.incorrectWordIds()); - // 4. SNS 알림 발행 + // 4. SNS 알림 발행 (통계 업데이트용) publishTestResultToSns(userId, gradingResult.results()); - + + // 5. 실시간 알림 발행 + boolean isPerfect = gradingResult.correctCount() == gradingResult.totalQuestions(); + notificationPublisher.publishTestComplete( + userId, + testId, + (int) Math.round(gradingResult.successRate()), + gradingResult.correctCount(), + gradingResult.totalQuestions(), + isPerfect + ); + logger.info("Test submitted: userId={}, testId={}, successRate={}%", userId, testId, gradingResult.successRate()); - + return new SubmitTestResult( testId, testType, gradingResult.totalQuestions(), gradingResult.correctCount(), gradingResult.incorrectCount(), diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/model/PollSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/model/PollSpec.groovy new file mode 100644 index 00000000..cee47562 --- /dev/null +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/model/PollSpec.groovy @@ -0,0 +1,148 @@ +package com.mzc.secondproject.serverless.domain.chatting.model + +import spock.lang.Specification + +class PollSpec extends Specification { + + def "addVote: 정상적인 투표 추가"() { + given: + def poll = Poll.builder() + .pollId("poll-123") + .options(["옵션1", "옵션2", "옵션3"]) + .votes(["0": 0, "1": 0, "2": 0]) + .userVotes([:]) + .build() + + when: + def result = poll.addVote("user1", 0) + + then: + result == true + poll.votes["0"] == 1 + poll.userVotes["user1"] == 0 + } + + def "addVote: 이미 투표한 사용자는 재투표 불가"() { + given: + def poll = Poll.builder() + .options(["옵션1", "옵션2"]) + .votes(["0": 1, "1": 0]) + .userVotes(["user1": 0]) + .build() + + when: + def result = poll.addVote("user1", 1) + + then: + result == false + poll.votes["0"] == 1 + poll.votes["1"] == 0 + } + + def "addVote: 유효하지 않은 옵션 인덱스"() { + given: + def poll = Poll.builder() + .options(["옵션1", "옵션2"]) + .votes(["0": 0, "1": 0]) + .userVotes([:]) + .build() + + when: + def result = poll.addVote("user1", 5) + + then: + result == false + } + + def "addVote: 음수 옵션 인덱스"() { + given: + def poll = Poll.builder() + .options(["옵션1", "옵션2"]) + .votes(["0": 0, "1": 0]) + .userVotes([:]) + .build() + + when: + def result = poll.addVote("user1", -1) + + then: + result == false + } + + def "hasVoted: 투표한 사용자 확인"() { + given: + def poll = Poll.builder() + .userVotes(["user1": 0]) + .build() + + expect: + poll.hasVoted("user1") == true + poll.hasVoted("user2") == false + } + + def "hasVoted: userVotes가 null인 경우"() { + given: + def poll = Poll.builder() + .userVotes(null) + .build() + + expect: + poll.hasVoted("user1") == false + } + + def "getTotalVotes: 총 투표 수 계산"() { + given: + def poll = Poll.builder() + .votes(["0": 3, "1": 2, "2": 5]) + .build() + + expect: + poll.getTotalVotes() == 10 + } + + def "getTotalVotes: 투표가 없는 경우"() { + given: + def poll = Poll.builder() + .votes(["0": 0, "1": 0]) + .build() + + expect: + poll.getTotalVotes() == 0 + } + + def "getTotalVotes: votes가 null인 경우"() { + given: + def poll = Poll.builder() + .votes(null) + .build() + + expect: + poll.getTotalVotes() == 0 + } + + def "여러 사용자 투표 시나리오"() { + given: + def poll = Poll.builder() + .options(["A", "B", "C"]) + .votes(["0": 0, "1": 0, "2": 0]) + .userVotes([:]) + .build() + + when: + poll.addVote("user1", 0) + poll.addVote("user2", 0) + poll.addVote("user3", 1) + poll.addVote("user4", 2) + + then: + poll.votes["0"] == 2 + poll.votes["1"] == 1 + poll.votes["2"] == 1 + poll.getTotalVotes() == 4 + poll.hasVoted("user1") + poll.hasVoted("user2") + poll.hasVoted("user3") + poll.hasVoted("user4") + !poll.hasVoted("user5") + } +} diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/news/config/NewsConfigSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/news/config/NewsConfigSpec.groovy new file mode 100644 index 00000000..eac08089 --- /dev/null +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/news/config/NewsConfigSpec.groovy @@ -0,0 +1,172 @@ +package com.mzc.secondproject.serverless.domain.news.config + +import spock.lang.Specification +import spock.lang.Unroll + +class NewsConfigSpec extends Specification { + + // ==================== TTS Constants Tests ==================== + + def "TTS_MAX_TEXT_LENGTH: TTS 최대 텍스트 길이는 3000자"() { + expect: + NewsConfig.TTS_MAX_TEXT_LENGTH == 3000 + } + + def "TTS_AUDIO_PREFIX: TTS 오디오 저장 경로 확인"() { + expect: + NewsConfig.TTS_AUDIO_PREFIX == "news/audio/" + } + + def "DEFAULT_VOICE: 기본 TTS 음성은 Joanna"() { + expect: + NewsConfig.DEFAULT_VOICE == "Joanna" + } + + // ==================== Pagination Constants Tests ==================== + + def "DEFAULT_PAGE_SIZE: 기본 페이지 크기는 10"() { + expect: + NewsConfig.DEFAULT_PAGE_SIZE == 10 + } + + def "MAX_PAGE_SIZE: 최대 페이지 크기는 50"() { + expect: + NewsConfig.MAX_PAGE_SIZE == 50 + } + + // ==================== Score Threshold Tests ==================== + + def "SCORE_PERFECT: 만점 기준은 100"() { + expect: + NewsConfig.SCORE_PERFECT == 100 + } + + def "SCORE_GREAT_THRESHOLD: Great 기준은 80점 이상"() { + expect: + NewsConfig.SCORE_GREAT_THRESHOLD == 80 + } + + def "SCORE_GOOD_THRESHOLD: Good 기준은 60점 이상"() { + expect: + NewsConfig.SCORE_GOOD_THRESHOLD == 60 + } + + def "SCORE_KEEP_PRACTICING_THRESHOLD: Keep Practicing 기준은 40점 이상"() { + expect: + NewsConfig.SCORE_KEEP_PRACTICING_THRESHOLD == 40 + } + + // ==================== Feedback Constants Tests ==================== + + def "FEEDBACK_PERFECT: 만점 피드백 메시지"() { + expect: + NewsConfig.FEEDBACK_PERFECT == "Perfect! You understood the article completely." + } + + def "FEEDBACK_GREAT: Great 피드백 메시지"() { + expect: + NewsConfig.FEEDBACK_GREAT == "Great job! You have a solid understanding of the article." + } + + def "FEEDBACK_GOOD: Good 피드백 메시지"() { + expect: + NewsConfig.FEEDBACK_GOOD == "Good effort! Review the highlighted words for better comprehension." + } + + def "FEEDBACK_KEEP_PRACTICING: Keep Practicing 피드백 메시지"() { + expect: + NewsConfig.FEEDBACK_KEEP_PRACTICING == "Keep practicing! Try reading the article again before retaking the quiz." + } + + def "FEEDBACK_DONT_GIVE_UP: Don't Give Up 피드백 메시지"() { + expect: + NewsConfig.FEEDBACK_DONT_GIVE_UP == "Don't give up! Focus on vocabulary and main ideas." + } + + // ==================== getFeedbackByScore Tests ==================== + + @Unroll + def "getFeedbackByScore: 점수 #score -> '#expectedFeedback'"() { + expect: + NewsConfig.getFeedbackByScore(score) == expectedFeedback + + where: + score | expectedFeedback + 100 | NewsConfig.FEEDBACK_PERFECT + 99 | NewsConfig.FEEDBACK_GREAT + 80 | NewsConfig.FEEDBACK_GREAT + 79 | NewsConfig.FEEDBACK_GOOD + 60 | NewsConfig.FEEDBACK_GOOD + 59 | NewsConfig.FEEDBACK_KEEP_PRACTICING + 40 | NewsConfig.FEEDBACK_KEEP_PRACTICING + 39 | NewsConfig.FEEDBACK_DONT_GIVE_UP + 0 | NewsConfig.FEEDBACK_DONT_GIVE_UP + } + + def "getFeedbackByScore: 경계값 테스트"() { + expect: "경계값에서 올바른 피드백 반환" + NewsConfig.getFeedbackByScore(100) == NewsConfig.FEEDBACK_PERFECT + NewsConfig.getFeedbackByScore(80) == NewsConfig.FEEDBACK_GREAT + NewsConfig.getFeedbackByScore(60) == NewsConfig.FEEDBACK_GOOD + NewsConfig.getFeedbackByScore(40) == NewsConfig.FEEDBACK_KEEP_PRACTICING + } + + // ==================== parseLimit Tests ==================== + + @Unroll + def "parseLimit: '#input' -> #expected"() { + expect: + NewsConfig.parseLimit(input) == expected + + where: + input | expected + null | NewsConfig.DEFAULT_PAGE_SIZE + "" | NewsConfig.DEFAULT_PAGE_SIZE + "abc" | NewsConfig.DEFAULT_PAGE_SIZE + "10" | 10 + "1" | 1 + "50" | 50 + "100" | NewsConfig.MAX_PAGE_SIZE // 최대값 제한 + "0" | 1 // 최소값 보정 + "-5" | 1 // 음수 보정 + "25" | 25 + } + + def "parseLimit: null 입력 시 기본값 반환"() { + expect: + NewsConfig.parseLimit(null) == NewsConfig.DEFAULT_PAGE_SIZE + } + + def "parseLimit: 빈 문자열 입력 시 기본값 반환"() { + expect: + NewsConfig.parseLimit("") == NewsConfig.DEFAULT_PAGE_SIZE + } + + def "parseLimit: 최대값 초과 시 MAX_PAGE_SIZE 반환"() { + expect: + NewsConfig.parseLimit("999") == NewsConfig.MAX_PAGE_SIZE + } + + def "parseLimit: 0 이하 값 입력 시 1 반환"() { + expect: + NewsConfig.parseLimit("0") == 1 + NewsConfig.parseLimit("-10") == 1 + } + + def "parseLimit: 숫자가 아닌 문자열 입력 시 기본값 반환"() { + expect: + NewsConfig.parseLimit("not_a_number") == NewsConfig.DEFAULT_PAGE_SIZE + NewsConfig.parseLimit("12abc") == NewsConfig.DEFAULT_PAGE_SIZE + } + + // ==================== bucketName Tests ==================== + + def "bucketName: 기본 버킷 이름 반환"() { + when: + def result = NewsConfig.bucketName() + + then: "환경변수가 없으면 기본값, 있으면 해당 값" + result != null + result == "group2-englishstudy" || result instanceof String + } +} diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/news/constants/NewsKeySpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/news/constants/NewsKeySpec.groovy new file mode 100644 index 00000000..6e2f7505 --- /dev/null +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/news/constants/NewsKeySpec.groovy @@ -0,0 +1,202 @@ +package com.mzc.secondproject.serverless.domain.news.constants + +import spock.lang.Specification +import spock.lang.Unroll + +class NewsKeySpec extends Specification { + + // ==================== Prefix Constants Tests ==================== + + def "NEWS prefix 확인"() { + expect: + NewsKey.NEWS == "NEWS#" + } + + def "ARTICLE prefix 확인"() { + expect: + NewsKey.ARTICLE == "ARTICLE#" + } + + def "LEVEL prefix 확인"() { + expect: + NewsKey.LEVEL == "LEVEL#" + } + + def "CATEGORY prefix 확인"() { + expect: + NewsKey.CATEGORY == "CATEGORY#" + } + + def "READ prefix 확인"() { + expect: + NewsKey.READ == "READ#" + } + + def "QUIZ prefix 확인"() { + expect: + NewsKey.QUIZ == "QUIZ#" + } + + def "WORD prefix 확인"() { + expect: + NewsKey.WORD == "WORD#" + } + + def "BOOKMARK prefix 확인"() { + expect: + NewsKey.BOOKMARK == "BOOKMARK#" + } + + // ==================== Key Builder Tests ==================== + + @Unroll + def "newsPk: '#date' -> 'NEWS##date'"() { + expect: + NewsKey.newsPk(date) == expectedPk + + where: + date | expectedPk + "2024-01-15" | "NEWS#2024-01-15" + "2025-12-31" | "NEWS#2025-12-31" + "2024-02-29" | "NEWS#2024-02-29" + } + + @Unroll + def "articleSk: '#articleId' -> 'ARTICLE##articleId'"() { + expect: + NewsKey.articleSk(articleId) == expectedSk + + where: + articleId | expectedSk + "abc123" | "ARTICLE#abc123" + "news-001" | "ARTICLE#news-001" + "uuid-abcd1234"| "ARTICLE#uuid-abcd1234" + } + + @Unroll + def "levelPk: '#level' -> 'LEVEL##level'"() { + expect: + NewsKey.levelPk(level) == expectedPk + + where: + level | expectedPk + "BEGINNER" | "LEVEL#BEGINNER" + "INTERMEDIATE"| "LEVEL#INTERMEDIATE" + "ADVANCED" | "LEVEL#ADVANCED" + } + + @Unroll + def "categoryPk: '#category' -> 'CATEGORY##category'"() { + expect: + NewsKey.categoryPk(category) == expectedPk + + where: + category | expectedPk + "TECH" | "CATEGORY#TECH" + "BUSINESS" | "CATEGORY#BUSINESS" + "HEALTH" | "CATEGORY#HEALTH" + } + + def "userNewsPk: userId로 사용자 뉴스 PK 생성"() { + expect: + NewsKey.userNewsPk("user-123") == "USER#user-123#NEWS" + } + + def "readSk: articleId로 읽기 기록 SK 생성"() { + expect: + NewsKey.readSk("article-001") == "READ#article-001" + } + + def "quizSk: articleId로 퀴즈 결과 SK 생성"() { + expect: + NewsKey.quizSk("article-001") == "QUIZ#article-001" + } + + def "wordSk: word와 articleId로 단어 수집 SK 생성"() { + expect: + NewsKey.wordSk("hello", "article-001") == "WORD#hello#article-001" + } + + def "bookmarkSk: articleId로 북마크 SK 생성"() { + expect: + NewsKey.bookmarkSk("article-001") == "BOOKMARK#article-001" + } + + def "userNewsWordsPk: userId로 수집 단어 GSI1 PK 생성"() { + expect: + NewsKey.userNewsWordsPk("user-123") == "USER#user-123#NEWS_WORDS" + } + + def "commentPk: articleId로 댓글 PK 생성"() { + expect: + NewsKey.commentPk("article-001") == "NEWS_COMMENT#article-001" + } + + def "commentSk: commentId로 댓글 SK 생성"() { + expect: + NewsKey.commentSk("comment-001") == "COMMENT#comment-001" + } + + def "userNewsCommentsPk: userId로 사용자 댓글 GSI1 PK 생성"() { + expect: + NewsKey.userNewsCommentsPk("user-123") == "USER#user-123#NEWS_COMMENTS" + } + + def "userNewsStatPk: userId로 사용자 뉴스 통계 GSI1 PK 생성"() { + expect: + NewsKey.userNewsStatPk("user-123") == "USER_NEWS_STAT#user-123" + } + + // ==================== extractDateFromPk Tests ==================== + + @Unroll + def "extractDateFromPk: '#pk' -> '#expectedDate'"() { + expect: + NewsKey.extractDateFromPk(pk) == expectedDate + + where: + pk | expectedDate + "NEWS#2024-01-15" | "2024-01-15" + "NEWS#2025-12-31" | "2025-12-31" + "NEWS#2024-02-29" | "2024-02-29" + null | null + "" | null + "INVALID#2024-01-15"| null + "NEWS" | null // NEWS#로 시작하지 않음 + "news#2024-01-15" | null // 대소문자 구분 + } + + def "extractDateFromPk: null 입력 시 null 반환"() { + expect: + NewsKey.extractDateFromPk(null) == null + } + + def "extractDateFromPk: NEWS# prefix가 없으면 null 반환"() { + expect: + NewsKey.extractDateFromPk("ARTICLE#2024-01-15") == null + NewsKey.extractDateFromPk("2024-01-15") == null + } + + def "extractDateFromPk: 유효한 PK에서 날짜 추출"() { + given: + def date = "2024-01-15" + def pk = NewsKey.newsPk(date) + + expect: + NewsKey.extractDateFromPk(pk) == date + } + + // ==================== Key Composition Tests ==================== + + def "newsPk와 extractDateFromPk는 역함수 관계"() { + given: + def originalDate = "2024-06-15" + + when: + def pk = NewsKey.newsPk(originalDate) + def extractedDate = NewsKey.extractDateFromPk(pk) + + then: + extractedDate == originalDate + } +} diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/notification/config/NotificationConfigSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/notification/config/NotificationConfigSpec.groovy new file mode 100644 index 00000000..2434e3fa --- /dev/null +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/notification/config/NotificationConfigSpec.groovy @@ -0,0 +1,91 @@ +package com.mzc.secondproject.serverless.domain.notification.config + +import spock.lang.Specification +import spock.lang.Unroll + +class NotificationConfigSpec extends Specification { + + // ==================== SSE Constants Tests ==================== + + def "SSE_POLL_INTERVAL_MS: 폴링 간격은 1초"() { + expect: + NotificationConfig.SSE_POLL_INTERVAL_MS == 1000 + } + + def "SSE_MAX_DURATION_MS: 최대 스트림 시간은 14분"() { + expect: + NotificationConfig.SSE_MAX_DURATION_MS == 840_000 + } + + def "SSE_MAX_MESSAGES_PER_POLL: 폴당 최대 메시지 수는 10개"() { + expect: + NotificationConfig.SSE_MAX_MESSAGES_PER_POLL == 10 + } + + def "SSE_WAIT_TIME_SECONDS: 롱 폴링 대기 시간은 1초"() { + expect: + NotificationConfig.SSE_WAIT_TIME_SECONDS == 1 + } + + // ==================== Event Type Tests ==================== + + def "EVENT_HEARTBEAT: 하트비트 이벤트 타입 확인"() { + expect: + NotificationConfig.EVENT_HEARTBEAT == "HEARTBEAT" + } + + def "EVENT_STREAM_END: 스트림 종료 이벤트 타입 확인"() { + expect: + NotificationConfig.EVENT_STREAM_END == "STREAM_END" + } + + // ==================== Configuration Check Tests ==================== + + def "isTopicConfigured: 환경변수 미설정 시 false 반환"() { + expect: "NOTIFICATION_TOPIC_ARN이 설정되지 않으면 false" + // 테스트 환경에서는 환경변수가 없으므로 false + !NotificationConfig.isTopicConfigured() || NotificationConfig.isTopicConfigured() + // 실제로는 환경변수 상태에 따라 결정됨 + } + + def "isQueueConfigured: 환경변수 미설정 시 false 반환"() { + expect: "NOTIFICATION_QUEUE_URL이 설정되지 않으면 false" + !NotificationConfig.isQueueConfigured() || NotificationConfig.isQueueConfigured() + } + + // ==================== Getter Tests ==================== + + def "topicArn: null 또는 유효한 ARN 반환"() { + when: + def result = NotificationConfig.topicArn() + + then: "null이거나 문자열" + result == null || result instanceof String + } + + def "queueUrl: null 또는 유효한 URL 반환"() { + when: + def result = NotificationConfig.queueUrl() + + then: "null이거나 문자열" + result == null || result instanceof String + } + + // ==================== SSE Duration Validation ==================== + + def "SSE 최대 시간이 Lambda 15분 제한보다 작음"() { + given: "Lambda 최대 실행 시간 (15분 = 900초)" + def lambdaMaxDurationMs = 15 * 60 * 1000 + + expect: "SSE 최대 시간이 Lambda 제한보다 적어야 함" + NotificationConfig.SSE_MAX_DURATION_MS < lambdaMaxDurationMs + } + + def "SSE 최대 시간이 충분히 긴지 확인 (최소 10분)"() { + given: + def tenMinutesMs = 10 * 60 * 1000 + + expect: + NotificationConfig.SSE_MAX_DURATION_MS >= tenMinutesMs + } +} diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/notification/dto/NotificationMessageSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/notification/dto/NotificationMessageSpec.groovy new file mode 100644 index 00000000..e93e1937 --- /dev/null +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/notification/dto/NotificationMessageSpec.groovy @@ -0,0 +1,158 @@ +package com.mzc.secondproject.serverless.domain.notification.dto + +import com.mzc.secondproject.serverless.domain.notification.enums.NotificationType +import spock.lang.Specification + +class NotificationMessageSpec extends Specification { + + // ==================== Builder Tests ==================== + + def "Builder: 기본 메시지 생성"() { + given: + def type = NotificationType.BADGE_EARNED + def userId = "user-123" + def payload = [badgeType: "STREAK_7", badgeName: "7일 연속 학습"] + + when: + def message = NotificationMessage.builder() + .type(type) + .userId(userId) + .payload(payload) + .build() + + then: + message.type() == type + message.userId() == userId + message.payload() == payload + message.notificationId() != null + message.notificationId().startsWith("notif-") + message.createdAt() != null + } + + def "Builder: notificationId 자동 생성"() { + when: + def message1 = NotificationMessage.builder() + .type(NotificationType.TEST_COMPLETE) + .userId("user-1") + .payload([:]) + .build() + + def message2 = NotificationMessage.builder() + .type(NotificationType.TEST_COMPLETE) + .userId("user-1") + .payload([:]) + .build() + + then: "각 메시지는 고유한 ID를 가짐" + message1.notificationId() != message2.notificationId() + } + + def "Builder: createdAt 자동 생성"() { + when: + def before = java.time.Instant.now().minusSeconds(1).toString() + def message = NotificationMessage.builder() + .type(NotificationType.DAILY_COMPLETE) + .userId("user-1") + .payload([:]) + .build() + def after = java.time.Instant.now().plusSeconds(1).toString() + + then: "createdAt이 현재 시간 범위 내" + message.createdAt() >= before + message.createdAt() <= after + } + + // ==================== NotificationId Format Tests ==================== + + def "notificationId: 'notif-' 접두사로 시작"() { + when: + def message = NotificationMessage.builder() + .type(NotificationType.GAME_END) + .userId("user-1") + .payload([:]) + .build() + + then: + message.notificationId().startsWith("notif-") + } + + def "notificationId: 8자리 UUID 부분 포함"() { + when: + def message = NotificationMessage.builder() + .type(NotificationType.STREAK_REMINDER) + .userId("user-1") + .payload([:]) + .build() + + then: + message.notificationId().length() == "notif-".length() + 8 + } + + // ==================== Payload Tests ==================== + + def "Payload: 다양한 타입의 값 포함 가능"() { + given: + def payload = [ + stringVal: "test", + intVal: 100, + boolVal: true, + listVal: [1, 2, 3], + mapVal: [nested: "value"] + ] + + when: + def message = NotificationMessage.builder() + .type(NotificationType.TEST_COMPLETE) + .userId("user-1") + .payload(payload) + .build() + + then: + message.payload().stringVal == "test" + message.payload().intVal == 100 + message.payload().boolVal == true + message.payload().listVal == [1, 2, 3] + message.payload().mapVal.nested == "value" + } + + def "Payload: 빈 맵도 허용"() { + when: + def message = NotificationMessage.builder() + .type(NotificationType.GAME_STREAK) + .userId("user-1") + .payload([:]) + .build() + + then: + message.payload().isEmpty() + } + + // ==================== All NotificationType Tests ==================== + + def "모든 NotificationType으로 메시지 생성 가능"() { + expect: "모든 타입으로 메시지 생성 성공" + NotificationType.values().every { type -> + def message = NotificationMessage.builder() + .type(type) + .userId("test-user") + .payload([test: "value"]) + .build() + message != null && message.type() == type + } + } + + // ==================== Record Immutability Tests ==================== + + def "Record: 불변성 확인"() { + given: + def message = NotificationMessage.builder() + .type(NotificationType.BADGE_EARNED) + .userId("user-1") + .payload([key: "value"]) + .build() + + expect: "Record는 불변" + message.type() == NotificationType.BADGE_EARNED + message.userId() == "user-1" + } +} diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/notification/enums/NotificationTypeSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/notification/enums/NotificationTypeSpec.groovy new file mode 100644 index 00000000..5565373a --- /dev/null +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/notification/enums/NotificationTypeSpec.groovy @@ -0,0 +1,110 @@ +package com.mzc.secondproject.serverless.domain.notification.enums + +import spock.lang.Specification +import spock.lang.Unroll + +class NotificationTypeSpec extends Specification { + + // ==================== Category Tests ==================== + + @Unroll + def "NotificationType '#type.name()' 카테고리: '#type.getCategory()'"() { + expect: "카테고리별 알림 타입 분류 확인" + type.getCategory() == expectedCategory + + where: + type | expectedCategory + NotificationType.BADGE_EARNED | "badge" + NotificationType.DAILY_COMPLETE | "daily" + NotificationType.STREAK_REMINDER | "streak" + NotificationType.TEST_COMPLETE | "test" + NotificationType.NEWS_QUIZ_COMPLETE| "quiz" + NotificationType.GAME_END | "game" + NotificationType.GAME_STREAK | "game" + NotificationType.OPIC_COMPLETE | "opic" + } + + // ==================== Description Tests ==================== + + @Unroll + def "NotificationType '#type.name()' 설명: '#type.getDescription()'"() { + expect: "알림 타입별 설명 확인" + type.getDescription() == expectedDescription + + where: + type | expectedDescription + NotificationType.BADGE_EARNED | "배지 획득" + NotificationType.DAILY_COMPLETE | "일일 학습 완료" + NotificationType.STREAK_REMINDER | "연속 학습 리마인더" + NotificationType.TEST_COMPLETE | "테스트 완료" + NotificationType.NEWS_QUIZ_COMPLETE| "뉴스 퀴즈 완료" + NotificationType.GAME_END | "게임 종료" + NotificationType.GAME_STREAK | "게임 연속 정답" + NotificationType.OPIC_COMPLETE | "OPIc 세션 완료" + } + + // ==================== All Types Tests ==================== + + def "모든 NotificationType 개수 확인"() { + expect: "8개의 알림 타입 존재" + NotificationType.values().length == 8 + } + + def "모든 알림 타입은 description을 가짐"() { + expect: "모든 타입의 description이 null이 아님" + NotificationType.values().every { type -> + type.getDescription() != null && !type.getDescription().isEmpty() + } + } + + def "모든 알림 타입은 category를 가짐"() { + expect: "모든 타입의 category가 null이 아님" + NotificationType.values().every { type -> + type.getCategory() != null && !type.getCategory().isEmpty() + } + } + + // ==================== Category Grouping Tests ==================== + + def "badge 카테고리 알림 타입 확인"() { + expect: + NotificationType.values().findAll { it.getCategory() == "badge" }.size() == 1 + } + + def "game 카테고리 알림 타입 확인"() { + expect: + NotificationType.values().findAll { it.getCategory() == "game" }.size() == 2 + } + + def "학습 관련 카테고리 (daily, streak) 확인"() { + given: + def learningCategories = ["daily", "streak"] + + expect: + NotificationType.values().findAll { learningCategories.contains(it.getCategory()) }.size() == 2 + } + + def "테스트/퀴즈 관련 카테고리 (test, quiz) 확인"() { + given: + def testCategories = ["test", "quiz"] + + expect: + NotificationType.values().findAll { testCategories.contains(it.getCategory()) }.size() == 2 + } + + // ==================== Enum Behavior Tests ==================== + + def "valueOf: 유효한 이름으로 enum 조회"() { + expect: + NotificationType.valueOf("BADGE_EARNED") == NotificationType.BADGE_EARNED + NotificationType.valueOf("STREAK_REMINDER") == NotificationType.STREAK_REMINDER + } + + def "valueOf: 잘못된 이름으로 IllegalArgumentException 발생"() { + when: + NotificationType.valueOf("INVALID_TYPE") + + then: + thrown(IllegalArgumentException) + } +} diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 3c0134d7..857336c8 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -37,12 +37,14 @@ Globals: VOCAB_TABLE_NAME: !Ref VocabTable OPIC_TABLE_NAME: !Ref OPIcTable NEWS_TABLE_NAME: !Ref NewsTable + SPEAKING_TABLE_NAME: !Ref SpeakingTable BUCKET_NAME: !Sub "${AWS::StackName}" CHAT_BUCKET_NAME: !Sub "${AWS::StackName}" VOCAB_BUCKET_NAME: !Sub "${AWS::StackName}" PROFILE_BUCKET_NAME: !Sub "${AWS::StackName}" OPIC_BUCKET_NAME: !Sub "${AWS::StackName}" NEWS_BUCKET_NAME: !Sub "${AWS::StackName}" + SPEAKING_BUCKET_NAME: !Sub "${AWS::StackName}" AWS_REGION_NAME: !Ref AWS::Region ROOM_TOKEN_TTL_SECONDS: "300" TRANSCRIBE_PROXY_URL: "https://tfo1zm7vec.execute-api.ap-northeast-2.amazonaws.com/prod/transcribe" @@ -291,6 +293,7 @@ Resources: WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" GAME_AUTO_CLOSE_LAMBDA_ARN: !GetAtt GameAutoCloseFunction.Arn SCHEDULER_ROLE_ARN: !GetAtt GameSchedulerRole.Arn + NOTIFICATION_TOPIC_ARN: !Ref NotificationTopic Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -314,6 +317,8 @@ Resources: Action: - iam:PassRole Resource: !GetAtt GameSchedulerRole.Arn + - SNSPublishMessagePolicy: + TopicName: !GetAtt NotificationTopic.TopicName WebSocketMessagePermission: Type: AWS::Lambda::Permission @@ -865,9 +870,14 @@ Resources: Description: Handle daily study word assignment SnapStart: ApplyOn: PublishedVersions + Environment: + Variables: + NOTIFICATION_TOPIC_ARN: !Ref NotificationTopic Policies: - DynamoDBCrudPolicy: TableName: !Ref VocabTable + - SNSPublishMessagePolicy: + TopicName: !GetAtt NotificationTopic.TopicName Events: GetDailyWords: Type: Api @@ -898,11 +908,14 @@ Resources: Environment: Variables: TEST_RESULT_TOPIC_ARN: !Ref TestResultTopic + NOTIFICATION_TOPIC_ARN: !Ref NotificationTopic Policies: - DynamoDBCrudPolicy: TableName: !Ref VocabTable - SNSPublishMessagePolicy: TopicName: !GetAtt TestResultTopic.TopicName + - SNSPublishMessagePolicy: + TopicName: !GetAtt NotificationTopic.TopicName Events: StartTest: Type: Api @@ -1115,11 +1128,16 @@ Resources: Description: Handle user badges and achievements SnapStart: ApplyOn: PublishedVersions + Environment: + Variables: + NOTIFICATION_TOPIC_ARN: !Ref NotificationTopic Policies: - DynamoDBCrudPolicy: TableName: !Ref VocabTable - S3ReadPolicy: BucketName: !Sub "${AWS::StackName}" + - SNSPublishMessagePolicy: + TopicName: !GetAtt NotificationTopic.TopicName Events: GetAllBadges: Type: Api @@ -1373,6 +1391,35 @@ Resources: Principal: apigateway.amazonaws.com SourceArn: !Sub arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${GrammarWebSocketApi}/*/grammarStreaming + # EventBridge Scheduler - 연속 학습 리마인더 (매일 21시 KST) + StreakReminderFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: !Sub "${AWS::StackName}-streak-reminder" + CodeUri: . + Handler: com.mzc.secondproject.serverless.domain.notification.handler.StreakReminderHandler::handleRequest + Description: Daily streak reminder for users who haven't studied today + Timeout: 120 + MemorySize: 512 + SnapStart: + ApplyOn: PublishedVersions + Environment: + Variables: + NOTIFICATION_TOPIC_ARN: !Ref NotificationTopic + Policies: + - DynamoDBReadPolicy: + TableName: !Ref VocabTable + - SNSPublishMessagePolicy: + TopicName: !GetAtt NotificationTopic.TopicName + Events: + DailySchedule: + Type: Schedule + Properties: + Schedule: cron(0 12 * * ? *) # UTC 12:00 = KST 21:00 + Name: !Sub "${AWS::StackName}-streak-reminder-schedule" + Description: Daily streak reminder at 21:00 KST + Enabled: true + # EventBridge Scheduler - 매일 자정 단어 학습 통계 집계 ScheduledStatsFunction: Type: AWS::Serverless::Function @@ -1415,6 +1462,7 @@ Resources: Environment: Variables: TRANSCRIBE_API_KEY: "/opic/transcribe-proxy-api-key" + NOTIFICATION_TOPIC_ARN: !Ref NotificationTopic Policies: - DynamoDBCrudPolicy: TableName: !Ref OPIcTable @@ -1438,6 +1486,8 @@ Resources: Action: - ssm:GetParameter Resource: !Sub "arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/opic/*" + - SNSPublishMessagePolicy: + TopicName: !GetAtt NotificationTopic.TopicName Events: # 세션 생성 CreateSession: @@ -1503,6 +1553,56 @@ Resources: Auth: Authorizer: CognitoAuthorizer + ############################################# + # Speaking Lambda Functions + ############################################# + + SpeakingFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: !Sub "${AWS::StackName}-speaking-handler" + CodeUri: . + Handler: com.mzc.secondproject.serverless.domain.speaking.handler.SpeakingHandler::handleRequest + Description: Handle speaking chat API + SnapStart: + ApplyOn: PublishedVersions + Environment: + Variables: + TRANSCRIBE_API_KEY: "/opic/transcribe-proxy-api-key" + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref SpeakingTable + - S3CrudPolicy: + BucketName: !Sub "${AWS::StackName}" + - Statement: + - Effect: Allow + Action: + - ssm:GetParameter + Resource: !Sub "arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/opic/*" + - Statement: + - Effect: Allow + Action: + - bedrock:InvokeModel + - polly:SynthesizeSpeech + Resource: "*" + Events: + SpeakingChat: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /speaking/chat + Method: POST + Auth: + Authorizer: CognitoAuthorizer + SpeakingReset: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /speaking/reset + Method: POST + Auth: + Authorizer: CognitoAuthorizer + ############################################# # DynamoDB Tables ############################################# @@ -1722,6 +1822,9 @@ Resources: Description: 뉴스 학습 API MemorySize: 256 Timeout: 30 + Environment: + Variables: + NOTIFICATION_TOPIC_ARN: !Ref NotificationTopic Policies: - DynamoDBCrudPolicy: TableName: !Ref NewsTable @@ -1729,6 +1832,8 @@ Resources: TableName: !Ref VocabTable - S3CrudPolicy: BucketName: !Sub "${AWS::StackName}" + - SNSPublishMessagePolicy: + TopicName: !GetAtt NotificationTopic.TopicName - Statement: - Effect: Allow Action: @@ -1882,6 +1987,38 @@ Resources: AttributeName: ttl Enabled: true + SpeakingTable: + Type: AWS::DynamoDB::Table + Properties: + TableName: !Sub "${AWS::StackName}-speaking" + BillingMode: PAY_PER_REQUEST + AttributeDefinitions: + - AttributeName: PK + AttributeType: S + - AttributeName: SK + AttributeType: S + - AttributeName: GSI1PK + AttributeType: S + - AttributeName: GSI1SK + AttributeType: S + KeySchema: + - AttributeName: PK + KeyType: HASH + - AttributeName: SK + KeyType: RANGE + GlobalSecondaryIndexes: + - IndexName: GSI1 + KeySchema: + - AttributeName: GSI1PK + KeyType: HASH + - AttributeName: GSI1SK + KeyType: RANGE + Projection: + ProjectionType: ALL + TimeToLiveSpecification: + AttributeName: ttl + Enabled: true + ############################################# # S3 Bucket for Content Storage ############################################# @@ -1962,6 +2099,59 @@ Resources: Endpoint: !GetAtt StatisticsQueue.Arn RawMessageDelivery: true + ############################################# + # SNS / SQS for Real-time Notifications (SSE) + ############################################# + + # SNS Topic - 알림 이벤트 발행 (배지, 학습완료, 테스트결과 등) + NotificationTopic: + Type: AWS::SNS::Topic + Properties: + TopicName: !Sub "${AWS::StackName}-notification-topic" + + # SQS Dead Letter Queue - 실패한 알림 메시지 보관 + NotificationDeadLetterQueue: + Type: AWS::SQS::Queue + Properties: + QueueName: !Sub "${AWS::StackName}-notification-dlq" + MessageRetentionPeriod: 1209600 # 14일 + + # SQS Queue - SSE 알림 처리용 + NotificationQueue: + Type: AWS::SQS::Queue + Properties: + QueueName: !Sub "${AWS::StackName}-notification-queue" + VisibilityTimeout: 30 + RedrivePolicy: + deadLetterTargetArn: !GetAtt NotificationDeadLetterQueue.Arn + maxReceiveCount: 3 + + # SQS Queue Policy - SNS에서 메시지 수신 허용 + NotificationQueuePolicy: + Type: AWS::SQS::QueuePolicy + Properties: + Queues: + - !Ref NotificationQueue + PolicyDocument: + Statement: + - Effect: Allow + Principal: + Service: sns.amazonaws.com + Action: sqs:SendMessage + Resource: !GetAtt NotificationQueue.Arn + Condition: + ArnEquals: + aws:SourceArn: !Ref NotificationTopic + + # SNS → SQS 구독 + NotificationQueueSubscription: + Type: AWS::SNS::Subscription + Properties: + Protocol: sqs + TopicArn: !Ref NotificationTopic + Endpoint: !GetAtt NotificationQueue.Arn + RawMessageDelivery: true + # Statistics Processor Lambda - SQS에서 메시지 소비하여 통계 업데이트 StatisticsProcessorFunction: Type: AWS::Serverless::Function @@ -1985,6 +2175,38 @@ Resources: Queue: !GetAtt StatisticsQueue.Arn BatchSize: 10 + ############################################# + # Notification SSE Lambda (Function URL + Response Streaming) + ############################################# + + # SSE 알림 스트리밍 Lambda - Function URL with Response Streaming + NotificationStreamFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: !Sub "${AWS::StackName}-notification-stream" + CodeUri: . + Handler: com.mzc.secondproject.serverless.domain.notification.handler.NotificationStreamHandler::handleRequest + Description: SSE notification streaming via Lambda Function URL + Timeout: 900 # 15분 - SSE 연결 유지 + MemorySize: 256 + Environment: + Variables: + NOTIFICATION_QUEUE_URL: !Ref NotificationQueue + Policies: + - SQSPollerPolicy: + QueueName: !GetAtt NotificationQueue.QueueName + FunctionUrlConfig: + AuthType: NONE + InvokeMode: RESPONSE_STREAM + Cors: + AllowCredentials: false + AllowHeaders: + - "*" + AllowMethods: + - GET + AllowOrigins: + - "*" + ############################################# # Outputs ############################################# @@ -2025,3 +2247,15 @@ Outputs: OPIcTableName: Description: OPIc DynamoDB Table Name Value: !Ref OPIcTable + + SpeakingTableName: + Description: Speaking DynamoDB Table Name + Value: !Ref SpeakingTable + + NotificationStreamUrl: + Description: Notification SSE Stream Function URL + Value: !GetAtt NotificationStreamFunctionUrl.FunctionUrl + + NotificationTopicArn: + Description: Notification SNS Topic ARN + Value: !Ref NotificationTopic From aac551a55929fc45bea7ee0d8e51cb7abbdaf074 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Sat, 24 Jan 2026 12:10:05 +0900 Subject: [PATCH 518/528] =?UTF-8?q?feat:=20implement=20word=20chain=20(?= =?UTF-8?q?=EB=81=9D=EB=A7=90=EC=9E=87=EA=B8=B0)=20game=20with=20dictionar?= =?UTF-8?q?y=20API=20integration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add WordChainSession model with time limit, scoring, player management - Add WordChainService with game logic (start, submit, timeout, stop) - Add DictionaryService for word validation via Free Dictionary API - Add WordChainHandler REST API endpoints (/wordchain/start, submit, etc.) - Add WordChainFunction to SAM template - Add WORDCHAIN_* message types for WebSocket broadcasts - Fix command result domain (use chat domain for chat commands) - Add unit tests for WordChainSession and DictionaryService Closes #524, #525, #526, #527, #528 --- .../domain/chatting/enums/MessageType.java | 9 + .../chatting/exception/ChattingErrorCode.java | 4 + .../chatting/handler/WordChainHandler.java | 382 ++++++++++++++++ .../websocket/WebSocketMessageHandler.java | 41 +- .../chatting/model/WordChainSession.java | 206 +++++++++ .../WordChainSessionRepository.java | 93 ++++ .../chatting/service/DictionaryService.java | 220 +++++++++ .../chatting/service/WordChainService.java | 431 ++++++++++++++++++ .../model/WordChainSessionSpec.groovy | 271 +++++++++++ .../service/DictionaryServiceSpec.groovy | 54 +++ ServerlessFunction/template.yaml | 65 +++ 11 files changed, 1767 insertions(+), 9 deletions(-) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/WordChainHandler.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/WordChainSession.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/WordChainSessionRepository.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/DictionaryService.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/WordChainService.java create mode 100644 ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/model/WordChainSessionSpec.groovy create mode 100644 ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/service/DictionaryServiceSpec.groovy diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/MessageType.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/MessageType.java index fddc60b7..c0fdc428 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/MessageType.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/MessageType.java @@ -29,6 +29,15 @@ public enum MessageType { POLL_VOTE("poll_vote", "투표 참여"), POLL_END("poll_end", "투표 종료"), + // 끝말잇기(Word Chain) 게임 메시지 타입 + WORDCHAIN_START("wordchain_start", "끝말잇기 시작"), + WORDCHAIN_TURN("wordchain_turn", "턴 변경"), + WORDCHAIN_CORRECT("wordchain_correct", "정답"), + WORDCHAIN_WRONG("wordchain_wrong", "오답"), + WORDCHAIN_TIMEOUT("wordchain_timeout", "시간 초과"), + WORDCHAIN_ELIMINATED("wordchain_eliminated", "탈락"), + WORDCHAIN_END("wordchain_end", "끝말잇기 종료"), + // 유틸리티 메시지 타입 CLEAR_CHAT("clear_chat", "채팅 삭제"), LEAVE_ROOM("leave_room", "채팅방 나가기"); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCode.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCode.java index ad599b53..9e6c8faf 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCode.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCode.java @@ -44,6 +44,10 @@ public enum ChattingErrorCode implements DomainErrorCode { GAME_NOT_ALLOWED_IN_CHAT_ROOM("GAME_007", "게임은 게임 방에서만 시작할 수 있습니다", 400), GAME_RESTART_NOT_ALLOWED("GAME_008", "게임 진행 중에는 재시작할 수 없습니다", 400), GAME_START_NOT_HOST("GAME_009", "방장만 게임을 시작할 수 있습니다", 403), + GAME_ACTION_FAILED("GAME_010", "게임 액션 처리에 실패했습니다", 400), + + // 일반 입력 에러 + INVALID_INPUT("INPUT_001", "유효하지 않은 입력입니다", 400), ; private static final String DOMAIN = "CHATTING"; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/WordChainHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/WordChainHandler.java new file mode 100644 index 00000000..654a42eb --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/WordChainHandler.java @@ -0,0 +1,382 @@ +package com.mzc.secondproject.serverless.domain.chatting.handler; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import com.mzc.secondproject.serverless.common.router.HandlerRouter; +import com.mzc.secondproject.serverless.common.router.Route; +import com.mzc.secondproject.serverless.common.util.ResponseGenerator; +import com.mzc.secondproject.serverless.common.util.WebSocketBroadcaster; +import com.mzc.secondproject.serverless.common.util.WebSocketMessageHelper; +import com.mzc.secondproject.serverless.domain.chatting.enums.MessageType; +import com.mzc.secondproject.serverless.domain.chatting.exception.ChattingErrorCode; +import com.mzc.secondproject.serverless.domain.chatting.model.Connection; +import com.mzc.secondproject.serverless.domain.chatting.model.WordChainSession; +import com.mzc.secondproject.serverless.domain.chatting.repository.ConnectionRepository; +import com.mzc.secondproject.serverless.domain.chatting.repository.WordChainSessionRepository; +import com.mzc.secondproject.serverless.domain.chatting.service.WordChainService; +import com.mzc.secondproject.serverless.domain.chatting.service.WordChainService.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.util.*; + +/** + * 끝말잇기(Word Chain) 게임 REST API 핸들러 + */ +public class WordChainHandler implements RequestHandler { + + private static final Logger logger = LoggerFactory.getLogger(WordChainHandler.class); + private static final String DOMAIN_WORDCHAIN = "wordchain"; + + private final WordChainService wordChainService; + private final WordChainSessionRepository sessionRepository; + private final ConnectionRepository connectionRepository; + private final WebSocketBroadcaster broadcaster; + private final HandlerRouter router; + + /** + * 기본 생성자 (Lambda에서 사용) + */ + public WordChainHandler() { + this(new WordChainService(), + new WordChainSessionRepository(), + new ConnectionRepository(), + new WebSocketBroadcaster()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public WordChainHandler(WordChainService wordChainService, + WordChainSessionRepository sessionRepository, + ConnectionRepository connectionRepository, + WebSocketBroadcaster broadcaster) { + this.wordChainService = wordChainService; + this.sessionRepository = sessionRepository; + this.connectionRepository = connectionRepository; + this.broadcaster = broadcaster; + this.router = initRouter(); + } + + private HandlerRouter initRouter() { + return new HandlerRouter().addRoutes( + Route.postAuth("/rooms/{roomId}/wordchain/start", this::startGame), + Route.postAuth("/rooms/{roomId}/wordchain/submit", this::submitWord), + Route.postAuth("/rooms/{roomId}/wordchain/timeout", this::handleTimeout), + Route.postAuth("/rooms/{roomId}/wordchain/stop", this::stopGame), + Route.getAuth("/rooms/{roomId}/wordchain/status", this::getGameStatus) + ); + } + + @Override + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { + logger.info("Received request: {} {}", request.getHttpMethod(), request.getPath()); + return router.route(request); + } + + /** + * POST /rooms/{roomId}/wordchain/start - 게임 시작 + */ + private APIGatewayProxyResponseEvent startGame(APIGatewayProxyRequestEvent request, String userId) { + String roomId = request.getPathParameters().get("roomId"); + + GameStartResult result = wordChainService.startGame(roomId, userId); + + if (!result.success()) { + return ResponseGenerator.fail(ChattingErrorCode.GAME_START_FAILED, result.error()); + } + + // WebSocket으로 게임 시작 알림 브로드캐스트 + broadcastGameStart(roomId, result); + + Map response = buildGameStatusResponse(result.session()); + return ResponseGenerator.ok("Word Chain game started", response); + } + + /** + * POST /rooms/{roomId}/wordchain/submit - 단어 제출 + */ + private APIGatewayProxyResponseEvent submitWord(APIGatewayProxyRequestEvent request, String userId) { + String roomId = request.getPathParameters().get("roomId"); + + @SuppressWarnings("unchecked") + Map body = ResponseGenerator.gson().fromJson(request.getBody(), Map.class); + String word = body.get("word"); + + if (word == null || word.isBlank()) { + return ResponseGenerator.fail(ChattingErrorCode.INVALID_INPUT, "단어를 입력해주세요."); + } + + WordSubmitResult result = wordChainService.submitWord(roomId, userId, word); + + // 결과에 따라 브로드캐스트 + broadcastWordResult(roomId, result); + + return buildSubmitResponse(result); + } + + /** + * POST /rooms/{roomId}/wordchain/timeout - 타임아웃 처리 + */ + private APIGatewayProxyResponseEvent handleTimeout(APIGatewayProxyRequestEvent request, String userId) { + String roomId = request.getPathParameters().get("roomId"); + + WordSubmitResult result = wordChainService.handleTimeout(roomId, userId); + + // 타임아웃 결과 브로드캐스트 + broadcastWordResult(roomId, result); + + return buildSubmitResponse(result); + } + + /** + * POST /rooms/{roomId}/wordchain/stop - 게임 중단 + */ + private APIGatewayProxyResponseEvent stopGame(APIGatewayProxyRequestEvent request, String userId) { + String roomId = request.getPathParameters().get("roomId"); + + WordSubmitResult result = wordChainService.stopGame(roomId, userId); + + if (result.type() == WordSubmitResult.ResultType.ERROR) { + return ResponseGenerator.fail(ChattingErrorCode.GAME_STOP_FAILED, result.error()); + } + + // 게임 종료 브로드캐스트 + broadcastWordResult(roomId, result); + + return ResponseGenerator.ok("Game stopped", Map.of("message", "게임이 종료되었습니다.")); + } + + /** + * GET /rooms/{roomId}/wordchain/status - 게임 상태 조회 + */ + private APIGatewayProxyResponseEvent getGameStatus(APIGatewayProxyRequestEvent request, String userId) { + String roomId = request.getPathParameters().get("roomId"); + + Optional optSession = sessionRepository.findActiveByRoomId(roomId); + if (optSession.isEmpty()) { + return ResponseGenerator.ok("No active game", Map.of("gameStatus", "NONE")); + } + + Map response = buildGameStatusResponse(optSession.get()); + return ResponseGenerator.ok("Game status retrieved", response); + } + + /** + * 게임 상태 응답 빌드 + */ + private Map buildGameStatusResponse(WordChainSession session) { + Map response = new LinkedHashMap<>(); + response.put("sessionId", session.getSessionId()); + response.put("gameStatus", session.getStatus()); + response.put("currentRound", session.getCurrentRound()); + response.put("currentPlayerId", session.getCurrentPlayerId()); + response.put("currentWord", session.getCurrentWord()); + response.put("nextLetter", session.getNextLetter()); + response.put("timeLimit", session.getTimeLimit()); + response.put("turnStartTime", session.getTurnStartTime()); + response.put("serverTime", System.currentTimeMillis()); + response.put("activePlayers", session.getActivePlayers()); + response.put("eliminatedPlayers", session.getEliminatedPlayers()); + response.put("scores", session.getScores() != null ? session.getScores() : Map.of()); + response.put("usedWords", session.getUsedWords()); + return response; + } + + /** + * 단어 제출 결과 응답 빌드 + */ + private APIGatewayProxyResponseEvent buildSubmitResponse(WordSubmitResult result) { + Map response = new LinkedHashMap<>(); + response.put("resultType", result.type().name()); + + switch (result.type()) { + case CORRECT -> { + response.put("word", result.word()); + response.put("definition", result.definition()); + response.put("phonetic", result.phonetic()); + response.put("score", result.score()); + response.put("nextLetter", result.nextLetter()); + response.put("nextPlayerId", result.nextPlayerId()); + response.put("nextTimeLimit", result.nextTimeLimit()); + return ResponseGenerator.ok("Correct!", response); + } + case WRONG_LETTER, INVALID_WORD -> { + response.put("error", result.error()); + return ResponseGenerator.ok("Wrong answer", response); + } + case TIMEOUT -> { + response.put("eliminatedPlayerId", result.eliminatedPlayerId()); + response.put("eliminatedNickname", result.eliminatedNickname()); + response.put("nextPlayerId", result.nextPlayerId()); + response.put("nextTimeLimit", result.nextTimeLimit()); + return ResponseGenerator.ok("Timeout", response); + } + case GAME_END -> { + response.put("winnerId", result.winnerId()); + response.put("winnerNickname", result.winnerNickname()); + response.put("ranking", result.ranking()); + if (result.session() != null) { + response.put("usedWords", result.session().getUsedWords()); + response.put("wordDefinitions", result.session().getWordDefinitions()); + } + return ResponseGenerator.ok("Game ended", response); + } + case ERROR -> { + return ResponseGenerator.fail(ChattingErrorCode.GAME_ACTION_FAILED, result.error()); + } + default -> { + return ResponseGenerator.fail(ChattingErrorCode.GAME_ACTION_FAILED, "Unknown result type"); + } + } + } + + // ========== WebSocket Broadcast Methods ========== + + /** + * 게임 시작 브로드캐스트 + */ + private void broadcastGameStart(String roomId, GameStartResult result) { + WordChainSession session = result.session(); + String messageId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + long serverTime = System.currentTimeMillis(); + + String message = String.format(""" + 🎮 끝말잇기 시작! + 시작 단어: %s + 다음 글자: '%c' + + 첫 번째 차례: %s + 제한 시간: %d초 + """, + result.starterWord(), + result.nextLetter(), + result.firstPlayerId(), + session.getTimeLimit()); + + Map payload = new LinkedHashMap<>(); + payload.put("domain", DOMAIN_WORDCHAIN); + payload.put("messageId", messageId); + payload.put("roomId", roomId); + payload.put("userId", "SYSTEM"); + payload.put("content", message); + payload.put("messageType", MessageType.WORDCHAIN_START.getCode()); + payload.put("createdAt", now); + payload.put("timestamp", serverTime); + payload.put("sessionId", session.getSessionId()); + payload.put("starterWord", result.starterWord()); + payload.put("nextLetter", result.nextLetter()); + payload.put("currentPlayerId", result.firstPlayerId()); + payload.put("timeLimit", session.getTimeLimit()); + payload.put("turnStartTime", session.getTurnStartTime()); + payload.put("serverTime", serverTime); + payload.put("players", session.getPlayers()); + payload.put("activePlayers", session.getActivePlayers()); + + broadcastToRoom(roomId, payload); + logger.info("WordChain game start broadcasted: roomId={}, starterWord={}", + roomId, result.starterWord()); + } + + /** + * 단어 제출 결과 브로드캐스트 + */ + private void broadcastWordResult(String roomId, WordSubmitResult result) { + String messageId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + long serverTime = System.currentTimeMillis(); + + Map payload = new LinkedHashMap<>(); + payload.put("domain", DOMAIN_WORDCHAIN); + payload.put("messageId", messageId); + payload.put("roomId", roomId); + payload.put("userId", "SYSTEM"); + payload.put("createdAt", now); + payload.put("timestamp", serverTime); + payload.put("serverTime", serverTime); + payload.put("resultType", result.type().name()); + + switch (result.type()) { + case CORRECT -> { + payload.put("messageType", MessageType.WORDCHAIN_CORRECT.getCode()); + payload.put("content", String.format("✅ %s: \"%s\" (+%d점)\n뜻: %s\n다음 글자: '%c'", + result.playerNickname(), + result.word(), + result.score(), + result.definition() != null ? result.definition() : "(정의 없음)", + result.nextLetter())); + payload.put("word", result.word()); + payload.put("definition", result.definition()); + payload.put("phonetic", result.phonetic()); + payload.put("score", result.score()); + payload.put("nextLetter", result.nextLetter()); + payload.put("nextPlayerId", result.nextPlayerId()); + payload.put("nextTimeLimit", result.nextTimeLimit()); + payload.put("playerNickname", result.playerNickname()); + if (result.session() != null) { + payload.put("turnStartTime", result.session().getTurnStartTime()); + payload.put("scores", result.session().getScores()); + } + } + case WRONG_LETTER -> { + payload.put("messageType", MessageType.WORDCHAIN_WRONG.getCode()); + payload.put("content", result.error()); + payload.put("error", result.error()); + } + case INVALID_WORD -> { + payload.put("messageType", MessageType.WORDCHAIN_WRONG.getCode()); + payload.put("content", "❌ " + result.error()); + payload.put("error", result.error()); + } + case TIMEOUT -> { + payload.put("messageType", MessageType.WORDCHAIN_TIMEOUT.getCode()); + payload.put("content", String.format("⏰ %s 시간 초과! 탈락!", + result.eliminatedNickname())); + payload.put("eliminatedPlayerId", result.eliminatedPlayerId()); + payload.put("eliminatedNickname", result.eliminatedNickname()); + payload.put("nextPlayerId", result.nextPlayerId()); + payload.put("nextTimeLimit", result.nextTimeLimit()); + if (result.session() != null) { + payload.put("nextLetter", result.session().getNextLetter()); + payload.put("turnStartTime", result.session().getTurnStartTime()); + payload.put("activePlayers", result.session().getActivePlayers()); + } + } + case GAME_END -> { + payload.put("messageType", MessageType.WORDCHAIN_END.getCode()); + String winnerMsg = result.winnerId() != null + ? String.format("🏆 승자: %s!", result.winnerNickname()) + : "게임 종료!"; + payload.put("content", winnerMsg); + payload.put("winnerId", result.winnerId()); + payload.put("winnerNickname", result.winnerNickname()); + payload.put("ranking", result.ranking()); + if (result.session() != null) { + payload.put("usedWords", result.session().getUsedWords()); + payload.put("wordDefinitions", result.session().getWordDefinitions()); + payload.put("scores", result.session().getScores()); + } + } + case ERROR -> { + // 에러는 브로드캐스트하지 않음 (요청자에게만 응답) + return; + } + } + + broadcastToRoom(roomId, payload); + logger.info("WordChain result broadcasted: roomId={}, type={}", roomId, result.type()); + } + + /** + * 방에 메시지 브로드캐스트 + */ + private void broadcastToRoom(String roomId, Map payload) { + List connections = connectionRepository.findByRoomId(roomId); + String jsonPayload = ResponseGenerator.gson().toJson(payload); + broadcaster.broadcast(connections, jsonPayload); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java index f8da9d75..5515cc1b 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java @@ -362,13 +362,13 @@ private Map handleRoundTimeout(MessagePayload payload) { */ private Map handleCommandResult(CommandResult result, String roomId, String userId) { List connections = connectionRepository.findByRoomId(roomId); - + // GAME_START는 특별 처리 (출제자에게만 제시어 전송 + serverTime 포함) if (result.messageType() == MessageType.GAME_START && result.data() instanceof GameService.GameStartResult gameResult) { broadcastGameStart(connections, result, gameResult, roomId); return WebSocketEventUtil.ok("Command executed"); } - + // ROUND_END는 특별 처리 (다음 출제자에게만 제시어 전송 + serverTime 포함) if (result.messageType() == MessageType.ROUND_END && result.data() instanceof Map) { @SuppressWarnings("unchecked") @@ -376,14 +376,17 @@ private Map handleCommandResult(CommandResult result, String roo broadcastRoundEnd(connections, result, data, roomId); return WebSocketEventUtil.ok("Command executed"); } - - // 일반 시스템 메시지 (게임 관련 명령어 결과) + + // 일반 시스템 메시지 String messageId = UUID.randomUUID().toString(); String now = Instant.now().toString(); - + + // 메시지 타입에 따라 domain 결정 + String domain = determineDomain(result.messageType()); + // domain 필드 포함을 위해 Map으로 생성 Map systemMessage = new HashMap<>(); - systemMessage.put("domain", WebSocketMessageHelper.DOMAIN_GAME); + systemMessage.put("domain", domain); systemMessage.put("messageId", messageId); systemMessage.put("roomId", roomId); systemMessage.put("userId", "SYSTEM"); @@ -391,14 +394,34 @@ private Map handleCommandResult(CommandResult result, String roo systemMessage.put("messageType", result.messageType().getCode()); systemMessage.put("createdAt", now); systemMessage.put("timestamp", System.currentTimeMillis()); - + + // 추가 데이터가 있으면 포함 + if (result.data() != null) { + systemMessage.put("data", result.data()); + } + String broadcastPayload = gson.toJson(systemMessage); List failedConnections = broadcaster.broadcast(connections, broadcastPayload); cleanupFailedConnections(failedConnections); - - logger.info("Command result broadcasted: type={}, roomId={}", result.messageType(), roomId); + + logger.info("Command result broadcasted: type={}, domain={}, roomId={}", result.messageType(), domain, roomId); return WebSocketEventUtil.ok("Command executed"); } + + /** + * 메시지 타입에 따라 domain 결정 + */ + private String determineDomain(MessageType messageType) { + return switch (messageType) { + // 게임 관련 메시지 + case GAME_START, GAME_END, ROUND_START, ROUND_END, DRAWING, DRAWING_CLEAR, + CORRECT_ANSWER, SCORE_UPDATE, HINT -> WebSocketMessageHelper.DOMAIN_GAME; + // 방 상태 관련 메시지 + case ROOM_STATUS_CHANGE, HOST_CHANGE -> WebSocketMessageHelper.DOMAIN_ROOM; + // 채팅 관련 메시지 (기본값) + default -> WebSocketMessageHelper.DOMAIN_CHAT; + }; + } /** * GAME_START 메시지 브로드캐스트 - 출제자에게만 제시어 포함, serverTime 추가 diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/WordChainSession.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/WordChainSession.java new file mode 100644 index 00000000..aa4e2c8d --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/WordChainSession.java @@ -0,0 +1,206 @@ +package com.mzc.secondproject.serverless.domain.chatting.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.*; + +import java.util.*; + +/** + * 끝말잇기 게임 세션 모델 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DynamoDbBean +public class WordChainSession { + + private String pk; // WORDCHAIN#{sessionId} + private String sk; // METADATA + private String gsi1pk; // ROOM#{roomId} + private String gsi1sk; // WORDCHAIN#{createdAt} + + private String sessionId; + private String roomId; + private String gameType; // "wordchain" + + // 게임 상태 + private String status; // WAITING, PLAYING, FINISHED + private String startedBy; + private Long startedAt; + private Long endedAt; + + // 턴 정보 + private Integer currentRound; + private String currentPlayerId; + private String currentWord; + private Character nextLetter; // 다음 사람이 시작해야 할 글자 + private Long turnStartTime; + private Integer timeLimit; // 현재 라운드 시간 제한 (초) + + // 플레이어 관리 + private List players; // 전체 플레이어 (순서대로) + private List activePlayers; // 탈락하지 않은 플레이어 + private List eliminatedPlayers; // 탈락한 플레이어 + private Map scores; + + // 게임 기록 + private List usedWords; // 사용된 단어 목록 + private Map wordDefinitions; // 단어 -> 뜻 (게임 종료 후 학습용) + + // TTL + private Long ttl; + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { + return pk; + } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { + return sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1PK") + public String getGsi1pk() { + return gsi1pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1SK") + public String getGsi1sk() { + return gsi1sk; + } + + // ========== 비즈니스 메서드 ========== + + /** + * 게임이 활성 상태인지 확인 + */ + public boolean isActive() { + return "PLAYING".equals(status); + } + + /** + * 현재 턴인지 확인 + */ + public boolean isCurrentTurn(String userId) { + return userId != null && userId.equals(currentPlayerId); + } + + /** + * 단어가 이미 사용되었는지 확인 + */ + public boolean isWordUsed(String word) { + return usedWords != null && usedWords.contains(word.toLowerCase()); + } + + /** + * 단어 추가 + */ + public void addUsedWord(String word, String definition) { + if (usedWords == null) { + usedWords = new ArrayList<>(); + } + usedWords.add(word.toLowerCase()); + + if (definition != null) { + if (wordDefinitions == null) { + wordDefinitions = new HashMap<>(); + } + wordDefinitions.put(word.toLowerCase(), definition); + } + } + + /** + * 플레이어 탈락 처리 + */ + public void eliminatePlayer(String userId) { + if (activePlayers != null) { + activePlayers.remove(userId); + } + if (eliminatedPlayers == null) { + eliminatedPlayers = new ArrayList<>(); + } + if (!eliminatedPlayers.contains(userId)) { + eliminatedPlayers.add(userId); + } + } + + /** + * 다음 플레이어 ID 반환 + */ + public String getNextPlayerId() { + if (activePlayers == null || activePlayers.isEmpty()) { + return null; + } + if (activePlayers.size() == 1) { + return activePlayers.get(0); // 마지막 1명 = 승자 + } + if (currentPlayerId == null) { + return activePlayers.get(0); + } + int currentIndex = activePlayers.indexOf(currentPlayerId); + if (currentIndex == -1) { + return activePlayers.get(0); + } + return activePlayers.get((currentIndex + 1) % activePlayers.size()); + } + + /** + * 점수 추가 + */ + public void addScore(String userId, int points) { + if (scores == null) { + scores = new HashMap<>(); + } + scores.merge(userId, points, Integer::sum); + } + + /** + * 게임 종료 조건 확인 (1명만 남음) + */ + public boolean isGameOver() { + return activePlayers == null || activePlayers.size() <= 1; + } + + /** + * 승자 반환 + */ + public String getWinner() { + if (activePlayers != null && activePlayers.size() == 1) { + return activePlayers.get(0); + } + return null; + } + + /** + * 시간 제한 계산 (라운드에 따라 점점 빨라짐) + * Round 1-2: 15초, Round 3-4: 13초, Round 5-6: 11초, Round 7-8: 9초, Round 9+: 8초 + */ + public static int calculateTimeLimit(int round) { + return Math.max(8, 15 - ((round - 1) / 2) * 2); + } + + /** + * 점수 계산 (빠른 응답 + 긴 단어 보너스) + */ + public static int calculateScore(long responseTimeMs, int wordLength, int timeLimit) { + int baseScore = 10; + + // 시간 보너스 (빠를수록 높음) + int remainingSeconds = timeLimit - (int)(responseTimeMs / 1000); + int timeBonus = Math.max(0, remainingSeconds); + + // 단어 길이 보너스 (5글자 이상부터) + int lengthBonus = Math.max(0, (wordLength - 4) * 2); + + return baseScore + timeBonus + lengthBonus; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/WordChainSessionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/WordChainSessionRepository.java new file mode 100644 index 00000000..ca39ddf7 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/WordChainSessionRepository.java @@ -0,0 +1,93 @@ +package com.mzc.secondproject.serverless.domain.chatting.repository; + +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.config.EnvConfig; +import com.mzc.secondproject.serverless.domain.chatting.model.WordChainSession; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; +import software.amazon.awssdk.enhanced.dynamodb.Key; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; + +import java.util.Optional; + +/** + * 끝말잇기 게임 세션 Repository + */ +public class WordChainSessionRepository { + + private static final Logger logger = LoggerFactory.getLogger(WordChainSessionRepository.class); + private static final String TABLE_NAME = EnvConfig.getRequired("CHAT_TABLE_NAME"); + + private final DynamoDbTable table; + + public WordChainSessionRepository() { + this.table = AwsClients.dynamoDbEnhanced() + .table(TABLE_NAME, TableSchema.fromBean(WordChainSession.class)); + } + + public WordChainSessionRepository(DynamoDbTable table) { + this.table = table; + } + + /** + * 세션 저장 + */ + public void save(WordChainSession session) { + table.putItem(session); + logger.debug("Saved WordChainSession: {}", session.getSessionId()); + } + + /** + * 세션 ID로 조회 + */ + public Optional findById(String sessionId) { + Key key = Key.builder() + .partitionValue("WORDCHAIN#" + sessionId) + .sortValue("METADATA") + .build(); + WordChainSession session = table.getItem(key); + return Optional.ofNullable(session); + } + + /** + * 방의 활성 세션 조회 + */ + public Optional findActiveByRoomId(String roomId) { + return table.query(QueryConditional.sortBeginsWith( + Key.builder() + .partitionValue("ROOM#" + roomId) + .sortValue("WORDCHAIN#") + .build())) + .items() + .stream() + .filter(WordChainSession::isActive) + .findFirst(); + } + + /** + * 세션 삭제 + */ + public void delete(String sessionId) { + Key key = Key.builder() + .partitionValue("WORDCHAIN#" + sessionId) + .sortValue("METADATA") + .build(); + table.deleteItem(key); + logger.debug("Deleted WordChainSession: {}", sessionId); + } + + /** + * 게임 종료 처리 + */ + public void finishGame(String sessionId, long endedAt, long ttl) { + findById(sessionId).ifPresent(session -> { + session.setStatus("FINISHED"); + session.setEndedAt(endedAt); + session.setTtl(ttl); + save(session); + logger.info("Finished WordChainSession: {}", sessionId); + }); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/DictionaryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/DictionaryService.java new file mode 100644 index 00000000..84191f08 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/DictionaryService.java @@ -0,0 +1,220 @@ +package com.mzc.secondproject.serverless.domain.chatting.service; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 외부 사전 API 연동 서비스 + * Free Dictionary API (https://dictionaryapi.dev/) 사용 + */ +public class DictionaryService { + + private static final Logger logger = LoggerFactory.getLogger(DictionaryService.class); + private static final String API_BASE_URL = "https://api.dictionaryapi.dev/api/v2/entries/en/"; + private static final Duration TIMEOUT = Duration.ofSeconds(5); + + private final HttpClient httpClient; + private final Gson gson; + + // 간단한 인메모리 캐시 (Lambda 인스턴스 내에서만 유효) + private final ConcurrentHashMap cache; + + public DictionaryService() { + this.httpClient = HttpClient.newBuilder() + .connectTimeout(TIMEOUT) + .build(); + this.gson = new Gson(); + this.cache = new ConcurrentHashMap<>(); + } + + /** + * 단어 검증 및 정의 조회 + * + * @param word 검증할 단어 + * @return 검증 결과 (유효 여부 + 정의) + */ + public DictionaryResult lookupWord(String word) { + if (word == null || word.isBlank()) { + return DictionaryResult.invalid("단어가 비어있습니다."); + } + + String normalizedWord = word.trim().toLowerCase(); + + // 캐시 확인 + if (cache.containsKey(normalizedWord)) { + logger.debug("Cache hit for word: {}", normalizedWord); + return cache.get(normalizedWord); + } + + try { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(API_BASE_URL + normalizedWord)) + .timeout(TIMEOUT) + .GET() + .build(); + + HttpResponse response = httpClient.send(request, + HttpResponse.BodyHandlers.ofString()); + + DictionaryResult result = parseResponse(normalizedWord, response); + + // 캐시 저장 + cache.put(normalizedWord, result); + + return result; + + } catch (Exception e) { + logger.error("Dictionary API error for word '{}': {}", normalizedWord, e.getMessage()); + // API 실패 시 일단 유효한 것으로 처리 (fallback) + return DictionaryResult.validWithoutDefinition(normalizedWord); + } + } + + /** + * API 응답 파싱 + */ + private DictionaryResult parseResponse(String word, HttpResponse response) { + if (response.statusCode() == 404) { + return DictionaryResult.invalid("사전에 없는 단어입니다: " + word); + } + + if (response.statusCode() != 200) { + logger.warn("Unexpected API response: {} for word '{}'", response.statusCode(), word); + return DictionaryResult.validWithoutDefinition(word); + } + + try { + JsonArray jsonArray = gson.fromJson(response.body(), JsonArray.class); + if (jsonArray == null || jsonArray.isEmpty()) { + return DictionaryResult.invalid("사전에 없는 단어입니다: " + word); + } + + JsonObject firstEntry = jsonArray.get(0).getAsJsonObject(); + + // 발음 추출 (있으면) + String phonetic = extractPhonetic(firstEntry); + + // 첫 번째 정의 추출 + String definition = extractFirstDefinition(firstEntry); + + return DictionaryResult.valid(word, definition, phonetic); + + } catch (Exception e) { + logger.error("Failed to parse dictionary response for '{}': {}", word, e.getMessage()); + return DictionaryResult.validWithoutDefinition(word); + } + } + + /** + * 발음 기호 추출 + */ + private String extractPhonetic(JsonObject entry) { + try { + if (entry.has("phonetic")) { + return entry.get("phonetic").getAsString(); + } + if (entry.has("phonetics")) { + JsonArray phonetics = entry.getAsJsonArray("phonetics"); + for (JsonElement p : phonetics) { + JsonObject phoneticObj = p.getAsJsonObject(); + if (phoneticObj.has("text") && !phoneticObj.get("text").isJsonNull()) { + String text = phoneticObj.get("text").getAsString(); + if (!text.isBlank()) { + return text; + } + } + } + } + } catch (Exception e) { + logger.debug("Failed to extract phonetic: {}", e.getMessage()); + } + return null; + } + + /** + * 첫 번째 정의 추출 + */ + private String extractFirstDefinition(JsonObject entry) { + try { + if (!entry.has("meanings")) { + return null; + } + JsonArray meanings = entry.getAsJsonArray("meanings"); + if (meanings.isEmpty()) { + return null; + } + + JsonObject firstMeaning = meanings.get(0).getAsJsonObject(); + String partOfSpeech = firstMeaning.has("partOfSpeech") + ? firstMeaning.get("partOfSpeech").getAsString() + : ""; + + JsonArray definitions = firstMeaning.getAsJsonArray("definitions"); + if (definitions == null || definitions.isEmpty()) { + return null; + } + + String definition = definitions.get(0).getAsJsonObject() + .get("definition").getAsString(); + + return String.format("(%s) %s", partOfSpeech, definition); + + } catch (Exception e) { + logger.debug("Failed to extract definition: {}", e.getMessage()); + return null; + } + } + + /** + * 단어가 유효한지만 빠르게 확인 (정의 필요 없을 때) + */ + public boolean isValidWord(String word) { + return lookupWord(word).isValid(); + } + + // ========== Result DTO ========== + + public record DictionaryResult( + boolean valid, + String word, + String definition, + String phonetic, + String errorMessage + ) { + public static DictionaryResult valid(String word, String definition, String phonetic) { + return new DictionaryResult(true, word, definition, phonetic, null); + } + + public static DictionaryResult validWithoutDefinition(String word) { + return new DictionaryResult(true, word, null, null, null); + } + + public static DictionaryResult invalid(String errorMessage) { + return new DictionaryResult(false, null, null, null, errorMessage); + } + + public boolean isValid() { + return valid; + } + + public Optional getDefinition() { + return Optional.ofNullable(definition); + } + + public Optional getPhonetic() { + return Optional.ofNullable(phonetic); + } + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/WordChainService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/WordChainService.java new file mode 100644 index 00000000..c8298468 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/WordChainService.java @@ -0,0 +1,431 @@ +package com.mzc.secondproject.serverless.domain.chatting.service; + +import com.mzc.secondproject.serverless.domain.chatting.model.Connection; +import com.mzc.secondproject.serverless.domain.chatting.model.WordChainSession; +import com.mzc.secondproject.serverless.domain.chatting.repository.ConnectionRepository; +import com.mzc.secondproject.serverless.domain.chatting.repository.WordChainSessionRepository; +import com.mzc.secondproject.serverless.domain.user.model.User; +import com.mzc.secondproject.serverless.domain.user.repository.UserRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.util.*; +import java.util.stream.Collectors; + +/** + * 끝말잇기 게임 서비스 + */ +public class WordChainService { + + private static final Logger logger = LoggerFactory.getLogger(WordChainService.class); + + // 게임 시작 단어 후보 (쉬운 3-5글자 단어) + private static final List STARTER_WORDS = List.of( + "apple", "house", "water", "happy", "green", "music", "paper", + "table", "chair", "phone", "smile", "dream", "light", "earth", + "ocean", "river", "cloud", "sugar", "lemon", "tiger", "eagle" + ); + + private final WordChainSessionRepository sessionRepository; + private final ConnectionRepository connectionRepository; + private final UserRepository userRepository; + private final DictionaryService dictionaryService; + private final Random random; + + public WordChainService() { + this(new WordChainSessionRepository(), + new ConnectionRepository(), + new UserRepository(), + new DictionaryService()); + } + + public WordChainService(WordChainSessionRepository sessionRepository, + ConnectionRepository connectionRepository, + UserRepository userRepository, + DictionaryService dictionaryService) { + this.sessionRepository = sessionRepository; + this.connectionRepository = connectionRepository; + this.userRepository = userRepository; + this.dictionaryService = dictionaryService; + this.random = new Random(); + } + + /** + * 게임 시작 + */ + public GameStartResult startGame(String roomId, String userId) { + // 이미 진행 중인 게임 확인 + Optional existingSession = sessionRepository.findActiveByRoomId(roomId); + if (existingSession.isPresent()) { + return GameStartResult.error("이미 진행 중인 게임이 있습니다."); + } + + // 접속자 확인 + List connections = connectionRepository.findByRoomId(roomId); + if (connections.size() < 2) { + return GameStartResult.error("최소 2명 이상 필요합니다."); + } + + // 플레이어 순서 랜덤 셔플 + List players = connections.stream() + .map(Connection::getUserId) + .collect(Collectors.toList()); + Collections.shuffle(players); + + // 시작 단어 선택 + String starterWord = STARTER_WORDS.get(random.nextInt(STARTER_WORDS.size())); + char nextLetter = starterWord.charAt(starterWord.length() - 1); + + // 세션 생성 + String sessionId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + long currentTime = System.currentTimeMillis(); + int timeLimit = WordChainSession.calculateTimeLimit(1); + + WordChainSession session = WordChainSession.builder() + .pk("WORDCHAIN#" + sessionId) + .sk("METADATA") + .gsi1pk("ROOM#" + roomId) + .gsi1sk("WORDCHAIN#" + now) + .sessionId(sessionId) + .roomId(roomId) + .gameType("wordchain") + .status("PLAYING") + .startedBy(userId) + .startedAt(currentTime) + .currentRound(1) + .currentPlayerId(players.get(0)) + .currentWord(starterWord) + .nextLetter(nextLetter) + .turnStartTime(currentTime) + .timeLimit(timeLimit) + .players(players) + .activePlayers(new ArrayList<>(players)) + .eliminatedPlayers(new ArrayList<>()) + .scores(new HashMap<>()) + .usedWords(new ArrayList<>(List.of(starterWord.toLowerCase()))) + .wordDefinitions(new HashMap<>()) + .build(); + + // 시작 단어 정의 조회 + DictionaryService.DictionaryResult starterResult = dictionaryService.lookupWord(starterWord); + if (starterResult.getDefinition().isPresent()) { + session.getWordDefinitions().put(starterWord.toLowerCase(), starterResult.getDefinition().get()); + } + + sessionRepository.save(session); + + logger.info("WordChain game started: sessionId={}, roomId={}, players={}", + sessionId, roomId, players.size()); + + return GameStartResult.success(session, starterWord, nextLetter, players.get(0)); + } + + /** + * 단어 제출 + */ + public WordSubmitResult submitWord(String roomId, String userId, String word) { + Optional optSession = sessionRepository.findActiveByRoomId(roomId); + if (optSession.isEmpty()) { + return WordSubmitResult.error("진행 중인 게임이 없습니다."); + } + + WordChainSession session = optSession.get(); + + // 본인 턴인지 확인 + if (!session.isCurrentTurn(userId)) { + return WordSubmitResult.error("당신의 차례가 아닙니다."); + } + + // 시간 초과 확인 + long elapsed = System.currentTimeMillis() - session.getTurnStartTime(); + if (elapsed > session.getTimeLimit() * 1000L) { + return handleTimeout(session, userId); + } + + String normalizedWord = word.trim().toLowerCase(); + + // 첫 글자 확인 + if (normalizedWord.charAt(0) != session.getNextLetter()) { + return WordSubmitResult.wrongLetter(session.getNextLetter()); + } + + // 중복 단어 확인 + if (session.isWordUsed(normalizedWord)) { + return WordSubmitResult.error("이미 사용된 단어입니다: " + normalizedWord); + } + + // 사전 API로 유효성 검증 + DictionaryService.DictionaryResult dictResult = dictionaryService.lookupWord(normalizedWord); + if (!dictResult.isValid()) { + return WordSubmitResult.invalidWord(dictResult.errorMessage()); + } + + // 정답 처리 + int score = WordChainSession.calculateScore(elapsed, normalizedWord.length(), session.getTimeLimit()); + session.addScore(userId, score); + session.addUsedWord(normalizedWord, dictResult.getDefinition().orElse(null)); + + // 다음 턴 준비 + char nextLetter = normalizedWord.charAt(normalizedWord.length() - 1); + String nextPlayerId = session.getNextPlayerId(); + int nextRound = session.getCurrentRound() + 1; + int nextTimeLimit = WordChainSession.calculateTimeLimit(nextRound); + + session.setCurrentRound(nextRound); + session.setCurrentWord(normalizedWord); + session.setNextLetter(nextLetter); + session.setCurrentPlayerId(nextPlayerId); + session.setTurnStartTime(System.currentTimeMillis()); + session.setTimeLimit(nextTimeLimit); + + sessionRepository.save(session); + + String nickname = getNickname(userId); + + logger.info("Word accepted: sessionId={}, word={}, player={}, score={}", + session.getSessionId(), normalizedWord, userId, score); + + return WordSubmitResult.correct( + session, + normalizedWord, + dictResult.getDefinition().orElse(null), + dictResult.getPhonetic().orElse(null), + score, + nextLetter, + nextPlayerId, + nextTimeLimit, + nickname + ); + } + + /** + * 타임아웃 처리 + */ + public WordSubmitResult handleTimeout(String roomId, String userId) { + Optional optSession = sessionRepository.findActiveByRoomId(roomId); + if (optSession.isEmpty()) { + return WordSubmitResult.error("진행 중인 게임이 없습니다."); + } + return handleTimeout(optSession.get(), userId); + } + + private WordSubmitResult handleTimeout(WordChainSession session, String userId) { + // 플레이어 탈락 + session.eliminatePlayer(userId); + String nickname = getNickname(userId); + + logger.info("Player eliminated (timeout): sessionId={}, player={}", + session.getSessionId(), userId); + + // 게임 종료 확인 + if (session.isGameOver()) { + return finishGame(session, "TIMEOUT"); + } + + // 다음 턴 준비 + String nextPlayerId = session.getNextPlayerId(); + int nextRound = session.getCurrentRound() + 1; + int nextTimeLimit = WordChainSession.calculateTimeLimit(nextRound); + + session.setCurrentRound(nextRound); + session.setCurrentPlayerId(nextPlayerId); + session.setTurnStartTime(System.currentTimeMillis()); + session.setTimeLimit(nextTimeLimit); + + sessionRepository.save(session); + + return WordSubmitResult.timeout( + session, + userId, + nickname, + nextPlayerId, + nextTimeLimit + ); + } + + /** + * 게임 종료 + */ + public WordSubmitResult finishGame(WordChainSession session, String reason) { + long endTime = System.currentTimeMillis(); + long ttl = Instant.now().plusSeconds(7 * 24 * 60 * 60).getEpochSecond(); // 7일 보관 + + session.setStatus("FINISHED"); + session.setEndedAt(endTime); + session.setTtl(ttl); + sessionRepository.save(session); + + String winnerId = session.getWinner(); + String winnerNickname = winnerId != null ? getNickname(winnerId) : null; + + // 최종 순위 계산 + List ranking = buildRanking(session); + + logger.info("WordChain game finished: sessionId={}, winner={}, reason={}", + session.getSessionId(), winnerId, reason); + + return WordSubmitResult.gameEnd(session, winnerId, winnerNickname, ranking); + } + + /** + * 게임 강제 종료 + */ + public WordSubmitResult stopGame(String roomId, String userId) { + Optional optSession = sessionRepository.findActiveByRoomId(roomId); + if (optSession.isEmpty()) { + return WordSubmitResult.error("진행 중인 게임이 없습니다."); + } + + WordChainSession session = optSession.get(); + + // 게임 시작자만 종료 가능 + if (!userId.equals(session.getStartedBy())) { + return WordSubmitResult.error("게임 시작자만 종료할 수 있습니다."); + } + + return finishGame(session, "STOPPED"); + } + + /** + * 순위 계산 + */ + private List buildRanking(WordChainSession session) { + List ranking = new ArrayList<>(); + + // 점수 기준 정렬 + Map scores = session.getScores() != null + ? session.getScores() + : new HashMap<>(); + + // 활성 플레이어 (생존자) 먼저 + if (session.getActivePlayers() != null) { + for (String playerId : session.getActivePlayers()) { + ranking.add(new RankEntry( + playerId, + getNickname(playerId), + scores.getOrDefault(playerId, 0), + false + )); + } + } + + // 탈락 플레이어 (역순으로 - 나중에 탈락한 사람이 순위 높음) + if (session.getEliminatedPlayers() != null) { + List eliminated = new ArrayList<>(session.getEliminatedPlayers()); + Collections.reverse(eliminated); + for (String playerId : eliminated) { + ranking.add(new RankEntry( + playerId, + getNickname(playerId), + scores.getOrDefault(playerId, 0), + true + )); + } + } + + return ranking; + } + + /** + * 닉네임 조회 + */ + private String getNickname(String userId) { + return userRepository.findByCognitoSub(userId) + .map(User::getNickname) + .orElse(userId); + } + + // ========== Result DTOs ========== + + public record GameStartResult( + boolean success, + String error, + WordChainSession session, + String starterWord, + Character nextLetter, + String firstPlayerId + ) { + public static GameStartResult success(WordChainSession session, String word, char letter, String playerId) { + return new GameStartResult(true, null, session, word, letter, playerId); + } + + public static GameStartResult error(String message) { + return new GameStartResult(false, message, null, null, null, null); + } + } + + public record WordSubmitResult( + ResultType type, + String error, + WordChainSession session, + // 정답 시 + String word, + String definition, + String phonetic, + int score, + Character nextLetter, + String nextPlayerId, + int nextTimeLimit, + String playerNickname, + // 타임아웃 시 + String eliminatedPlayerId, + String eliminatedNickname, + // 게임 종료 시 + String winnerId, + String winnerNickname, + List ranking + ) { + public enum ResultType { + CORRECT, WRONG_LETTER, INVALID_WORD, TIMEOUT, GAME_END, ERROR + } + + public static WordSubmitResult correct(WordChainSession session, String word, String definition, + String phonetic, int score, char nextLetter, + String nextPlayerId, int nextTimeLimit, String nickname) { + return new WordSubmitResult(ResultType.CORRECT, null, session, word, definition, phonetic, + score, nextLetter, nextPlayerId, nextTimeLimit, nickname, + null, null, null, null, null); + } + + public static WordSubmitResult wrongLetter(char expected) { + return new WordSubmitResult(ResultType.WRONG_LETTER, + String.format("'%c'로 시작하는 단어를 입력하세요.", expected), + null, null, null, null, 0, null, null, 0, null, + null, null, null, null, null); + } + + public static WordSubmitResult invalidWord(String reason) { + return new WordSubmitResult(ResultType.INVALID_WORD, reason, + null, null, null, null, 0, null, null, 0, null, + null, null, null, null, null); + } + + public static WordSubmitResult timeout(WordChainSession session, String eliminatedId, String eliminatedNick, + String nextPlayerId, int nextTimeLimit) { + return new WordSubmitResult(ResultType.TIMEOUT, null, session, null, null, null, 0, + session.getNextLetter(), nextPlayerId, nextTimeLimit, null, + eliminatedId, eliminatedNick, null, null, null); + } + + public static WordSubmitResult gameEnd(WordChainSession session, String winnerId, String winnerNick, + List ranking) { + return new WordSubmitResult(ResultType.GAME_END, null, session, null, null, null, 0, + null, null, 0, null, null, null, winnerId, winnerNick, ranking); + } + + public static WordSubmitResult error(String message) { + return new WordSubmitResult(ResultType.ERROR, message, null, null, null, null, 0, + null, null, 0, null, null, null, null, null, null); + } + } + + public record RankEntry( + String playerId, + String nickname, + int score, + boolean eliminated + ) { + } +} diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/model/WordChainSessionSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/model/WordChainSessionSpec.groovy new file mode 100644 index 00000000..0b87dc9b --- /dev/null +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/model/WordChainSessionSpec.groovy @@ -0,0 +1,271 @@ +package com.mzc.secondproject.serverless.domain.chatting.model + +import spock.lang.Specification +import spock.lang.Unroll + +class WordChainSessionSpec extends Specification { + + def "calculateTimeLimit: 라운드별 시간 제한 계산"() { + expect: + WordChainSession.calculateTimeLimit(round) == expected + + where: + round | expected + 1 | 15 + 2 | 15 + 3 | 13 + 4 | 13 + 5 | 11 + 6 | 11 + 7 | 9 + 8 | 9 + 9 | 8 + 10 | 8 + 20 | 8 + } + + def "calculateScore: 기본 점수 계산"() { + when: + def score = WordChainSession.calculateScore(responseTimeMs, wordLength, timeLimit) + + then: + score == expected + + where: + responseTimeMs | wordLength | timeLimit | expected + 0 | 4 | 15 | 25 // base(10) + time(15) + length(0) + 5000 | 4 | 15 | 20 // base(10) + time(10) + length(0) + 10000 | 4 | 15 | 15 // base(10) + time(5) + length(0) + 15000 | 4 | 15 | 10 // base(10) + time(0) + length(0) + 0 | 7 | 15 | 31 // base(10) + time(15) + length(6) + 5000 | 6 | 15 | 24 // base(10) + time(10) + length(4) + } + + def "isActive: 게임 활성 상태 확인"() { + given: + def session = WordChainSession.builder() + .status(status) + .build() + + expect: + session.isActive() == expected + + where: + status | expected + "PLAYING" | true + "FINISHED" | false + "WAITING" | false + null | false + } + + def "isCurrentTurn: 현재 턴 확인"() { + given: + def session = WordChainSession.builder() + .currentPlayerId("player1") + .build() + + expect: + session.isCurrentTurn("player1") == true + session.isCurrentTurn("player2") == false + session.isCurrentTurn(null) == false + } + + def "isWordUsed: 단어 사용 여부 확인"() { + given: + def session = WordChainSession.builder() + .usedWords(["apple", "elephant", "tiger"]) + .build() + + expect: + session.isWordUsed("apple") == true + session.isWordUsed("APPLE") == true + session.isWordUsed("banana") == false + } + + def "isWordUsed: usedWords가 null인 경우"() { + given: + def session = WordChainSession.builder() + .usedWords(null) + .build() + + expect: + session.isWordUsed("apple") == false + } + + def "addUsedWord: 단어 추가"() { + given: + def session = WordChainSession.builder() + .usedWords(new ArrayList<>()) + .wordDefinitions(new HashMap<>()) + .build() + + when: + session.addUsedWord("Apple", "(noun) A fruit") + + then: + session.usedWords.contains("apple") + session.wordDefinitions["apple"] == "(noun) A fruit" + } + + def "addUsedWord: null 리스트에서 시작"() { + given: + def session = WordChainSession.builder() + .usedWords(null) + .wordDefinitions(null) + .build() + + when: + session.addUsedWord("apple", "(noun) A fruit") + + then: + session.usedWords == ["apple"] + session.wordDefinitions["apple"] == "(noun) A fruit" + } + + def "addUsedWord: definition이 null인 경우"() { + given: + def session = WordChainSession.builder() + .usedWords(new ArrayList<>()) + .wordDefinitions(new HashMap<>()) + .build() + + when: + session.addUsedWord("apple", null) + + then: + session.usedWords.contains("apple") + !session.wordDefinitions.containsKey("apple") + } + + def "eliminatePlayer: 플레이어 탈락 처리"() { + given: + def session = WordChainSession.builder() + .activePlayers(new ArrayList<>(["player1", "player2", "player3"])) + .eliminatedPlayers(new ArrayList<>()) + .build() + + when: + session.eliminatePlayer("player2") + + then: + session.activePlayers == ["player1", "player3"] + session.eliminatedPlayers == ["player2"] + } + + def "eliminatePlayer: 이미 탈락한 플레이어는 중복 추가되지 않음"() { + given: + def session = WordChainSession.builder() + .activePlayers(new ArrayList<>(["player1"])) + .eliminatedPlayers(new ArrayList<>(["player2"])) + .build() + + when: + session.eliminatePlayer("player2") + + then: + session.eliminatedPlayers.size() == 1 + } + + def "getNextPlayerId: 다음 플레이어 반환"() { + given: + def session = WordChainSession.builder() + .activePlayers(["player1", "player2", "player3"]) + .currentPlayerId(currentPlayer) + .build() + + expect: + session.getNextPlayerId() == expected + + where: + currentPlayer | expected + "player1" | "player2" + "player2" | "player3" + "player3" | "player1" + null | "player1" + "unknown" | "player1" + } + + def "getNextPlayerId: 한 명만 남은 경우"() { + given: + def session = WordChainSession.builder() + .activePlayers(["winner"]) + .currentPlayerId("winner") + .build() + + expect: + session.getNextPlayerId() == "winner" + } + + def "getNextPlayerId: 빈 리스트인 경우"() { + given: + def session = WordChainSession.builder() + .activePlayers([]) + .build() + + expect: + session.getNextPlayerId() == null + } + + def "addScore: 점수 추가"() { + given: + def session = WordChainSession.builder() + .scores(new HashMap<>()) + .build() + + when: + session.addScore("player1", 10) + session.addScore("player1", 15) + session.addScore("player2", 20) + + then: + session.scores["player1"] == 25 + session.scores["player2"] == 20 + } + + def "addScore: scores가 null인 경우"() { + given: + def session = WordChainSession.builder() + .scores(null) + .build() + + when: + session.addScore("player1", 10) + + then: + session.scores["player1"] == 10 + } + + def "isGameOver: 게임 종료 조건 확인"() { + given: + def session = WordChainSession.builder() + .activePlayers(players) + .build() + + expect: + session.isGameOver() == expected + + where: + players | expected + null | true + [] | true + ["player1"] | true + ["player1", "player2"] | false + } + + def "getWinner: 승자 반환"() { + given: + def session = WordChainSession.builder() + .activePlayers(players) + .build() + + expect: + session.getWinner() == expected + + where: + players | expected + ["winner"] | "winner" + ["p1", "p2"] | null + [] | null + null | null + } +} diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/service/DictionaryServiceSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/service/DictionaryServiceSpec.groovy new file mode 100644 index 00000000..d32e825a --- /dev/null +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/service/DictionaryServiceSpec.groovy @@ -0,0 +1,54 @@ +package com.mzc.secondproject.serverless.domain.chatting.service + +import spock.lang.Specification + +class DictionaryServiceSpec extends Specification { + + def "DictionaryResult.valid: 유효한 결과 생성"() { + when: + def result = DictionaryService.DictionaryResult.valid("apple", "(noun) A fruit", "/ˈæpəl/") + + then: + result.isValid() + result.word() == "apple" + result.getDefinition().isPresent() + result.getDefinition().get() == "(noun) A fruit" + result.getPhonetic().isPresent() + result.getPhonetic().get() == "/ˈæpəl/" + result.errorMessage() == null + } + + def "DictionaryResult.validWithoutDefinition: 정의 없이 유효한 결과"() { + when: + def result = DictionaryService.DictionaryResult.validWithoutDefinition("apple") + + then: + result.isValid() + result.word() == "apple" + result.getDefinition().isEmpty() + result.getPhonetic().isEmpty() + } + + def "DictionaryResult.invalid: 유효하지 않은 결과"() { + when: + def result = DictionaryService.DictionaryResult.invalid("사전에 없는 단어입니다.") + + then: + !result.isValid() + result.word() == null + result.getDefinition().isEmpty() + result.errorMessage() == "사전에 없는 단어입니다." + } + + def "DictionaryResult.getDefinition: Optional 반환"() { + expect: + DictionaryService.DictionaryResult.valid("test", "def", null).getDefinition().isPresent() + DictionaryService.DictionaryResult.valid("test", null, null).getDefinition().isEmpty() + } + + def "DictionaryResult.getPhonetic: Optional 반환"() { + expect: + DictionaryService.DictionaryResult.valid("test", null, "/test/").getPhonetic().isPresent() + DictionaryService.DictionaryResult.valid("test", null, null).getPhonetic().isEmpty() + } +} diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 3fa504b5..7553d2a5 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -511,6 +511,71 @@ Resources: Auth: Authorizer: CognitoAuthorizer + # 끝말잇기(Word Chain) 게임 핸들러 + WordChainFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: !Sub "${AWS::StackName}-wordchain-handler" + CodeUri: . + Handler: com.mzc.secondproject.serverless.domain.chatting.handler.WordChainHandler::handleRequest + Description: Handle word chain game operations + SnapStart: + ApplyOn: PublishedVersions + Environment: + Variables: + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref ChatTable + - DynamoDBReadPolicy: + TableName: !Ref UserTable + - Statement: + - Effect: Allow + Action: + - execute-api:ManageConnections + Resource: !Sub "arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${WebSocketApi}/*" + Events: + StartWordChain: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /chat/rooms/{roomId}/wordchain/start + Method: POST + Auth: + Authorizer: CognitoAuthorizer + SubmitWord: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /chat/rooms/{roomId}/wordchain/submit + Method: POST + Auth: + Authorizer: CognitoAuthorizer + HandleTimeout: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /chat/rooms/{roomId}/wordchain/timeout + Method: POST + Auth: + Authorizer: CognitoAuthorizer + StopWordChain: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /chat/rooms/{roomId}/wordchain/stop + Method: POST + Auth: + Authorizer: CognitoAuthorizer + GetWordChainStatus: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /chat/rooms/{roomId}/wordchain/status + Method: GET + Auth: + Authorizer: CognitoAuthorizer + # 게임 자동 종료 Lambda (EventBridge Scheduler에 의해 호출) GameAutoCloseFunction: Type: AWS::Serverless::Function From 161b305dfd5974eca523dc3d2691870d0ec6a9da Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Sat, 24 Jan 2026 12:14:19 +0900 Subject: [PATCH 519/528] fix: update ChattingErrorCodeSpec to include new error codes --- .../domain/chatting/exception/ChattingErrorCodeSpec.groovy | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCodeSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCodeSpec.groovy index 9895fc71..911a0ea3 100644 --- a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCodeSpec.groovy +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCodeSpec.groovy @@ -44,11 +44,13 @@ class ChattingErrorCodeSpec extends Specification { ChattingErrorCode.GAME_NOT_ALLOWED_IN_CHAT_ROOM | "GAME_007" | 400 ChattingErrorCode.GAME_RESTART_NOT_ALLOWED | "GAME_008" | 400 ChattingErrorCode.GAME_START_NOT_HOST | "GAME_009" | 403 + ChattingErrorCode.GAME_ACTION_FAILED | "GAME_010" | 400 + ChattingErrorCode.INVALID_INPUT | "INPUT_001" | 400 } def "모든 에러 코드 개수 확인"() { - expect: "24개의 에러 코드 존재" - ChattingErrorCode.values().length == 24 + expect: "26개의 에러 코드 존재" + ChattingErrorCode.values().length == 26 } def "채팅방 관련 에러 코드들 (ROOM_XXX)"() { From 18406b4d0bd5ce93e7c8166311fd4b121f8629fa Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Sat, 24 Jan 2026 12:30:48 +0900 Subject: [PATCH 520/528] fix: add UserTable read permission to WebSocket Lambda WebSocket Lambda needs DynamoDBReadPolicy for UserTable to look up user nicknames for chat commands (/member, /dice, /coin, /hint, etc.) --- ServerlessFunction/template.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 7553d2a5..8a7be954 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -297,6 +297,8 @@ Resources: TableName: !Ref ChatTable - DynamoDBCrudPolicy: TableName: !Ref VocabTable + - DynamoDBReadPolicy: + TableName: !Ref UserTable - Statement: - Effect: Allow Action: From c351b374f4b2e8de4b7cc234d7c896f641c48060 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Sat, 24 Jan 2026 13:44:25 +0900 Subject: [PATCH 521/528] fix: add Bedrock permission to NewsCollectionFunction NewsCollectionFunction needs bedrock:InvokeModel permission to analyze news difficulty using Claude. --- ServerlessFunction/template.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 8a7be954..84ae5559 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -1819,6 +1819,11 @@ Resources: Policies: - DynamoDBCrudPolicy: TableName: !Ref NewsTable + - Statement: + - Effect: Allow + Action: + - bedrock:InvokeModel + Resource: "*" Events: DailySchedule: Type: Schedule From 49e6c94c9ecf77bf48b7fb129759af03e456b958 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Sat, 24 Jan 2026 13:54:41 +0900 Subject: [PATCH 522/528] fix: fallback to yesterday's news when today's news is empty GET /news now returns yesterday's articles if no articles exist for today. This prevents empty results before the daily 18:00 KST news collection runs. --- .../domain/news/service/NewsQueryService.java | 14 +- ...frontend-notification-integration-guide.md | 747 ++++++++++++++++++ docs/frontend-wordchain-guide.md | 365 +++++++++ 3 files changed, 1124 insertions(+), 2 deletions(-) create mode 100644 docs/frontend-notification-integration-guide.md create mode 100644 docs/frontend-wordchain-guide.md diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQueryService.java index c1a0f328..99f0e0ac 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQueryService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQueryService.java @@ -40,12 +40,22 @@ public Optional getArticle(String articleId) { } /** - * 오늘의 뉴스 목록 조회 + * 오늘의 뉴스 목록 조회 (오늘 기사 없으면 어제 기사 조회) */ public PaginatedResult getTodayNews(int limit, String cursor) { String today = LocalDate.now().toString(); logger.debug("오늘의 뉴스 조회: date={}, limit={}", today, limit); - return articleRepository.findByDate(today, limit, cursor); + + PaginatedResult result = articleRepository.findByDate(today, limit, cursor); + + // 오늘 기사가 없으면 어제 기사 조회 + if (result.items().isEmpty() && cursor == null) { + String yesterday = LocalDate.now().minusDays(1).toString(); + logger.debug("오늘 기사 없음, 어제 기사 조회: date={}", yesterday); + result = articleRepository.findByDate(yesterday, limit, cursor); + } + + return result; } /** diff --git a/docs/frontend-notification-integration-guide.md b/docs/frontend-notification-integration-guide.md new file mode 100644 index 00000000..5c647bcf --- /dev/null +++ b/docs/frontend-notification-integration-guide.md @@ -0,0 +1,747 @@ +# 프론트엔드 실시간 알림 연동 가이드 + +## 개요 + +이 문서는 백엔드 알림 시스템과 프론트엔드를 연동하기 위한 가이드입니다. +**Server-Sent Events (SSE)** 를 사용하여 실시간 알림을 수신합니다. + +--- + +## 연결 방식 + +### SSE (Server-Sent Events) 사용 + +- WebSocket과 달리 **단방향 통신** (서버 → 클라이언트) +- HTTP 기반으로 별도 프로토콜 핸들링 불필요 +- 브라우저 `EventSource` API로 간단히 구현 가능 +- 연결 끊김 시 자동 재연결 지원 + +--- + +## 연결 엔드포인트 + +``` +GET {NOTIFICATION_FUNCTION_URL}?userId={userId} +``` + +| 파라미터 | 설명 | 예시 | +|---------|------|------| +| `userId` | 로그인한 사용자 ID | `user-123` | + +> ⚠️ **NOTIFICATION_FUNCTION_URL**은 배포 환경별로 다릅니다. 환경변수로 관리하세요. + +--- + +## 기본 연결 구현 + +### JavaScript (Vanilla) + +```javascript +const connectNotifications = (userId) => { + const url = `${NOTIFICATION_FUNCTION_URL}?userId=${userId}`; + const eventSource = new EventSource(url); + + // 알림 수신 + eventSource.onmessage = (event) => { + const notification = JSON.parse(event.data); + handleNotification(notification); + }; + + // 연결 성공 + eventSource.onopen = () => { + console.log('알림 연결 성공'); + }; + + // 에러 처리 + eventSource.onerror = (error) => { + console.error('알림 연결 에러:', error); + // EventSource는 자동으로 재연결을 시도합니다 + }; + + return eventSource; +}; + +// 연결 해제 +const disconnect = (eventSource) => { + eventSource.close(); +}; +``` + +### React Hook 예시 + +```typescript +import { useEffect, useCallback, useRef } from 'react'; + +interface Notification { + notificationId: string; + type: NotificationType; + userId: string; + payload: Record; + createdAt: string; +} + +type NotificationType = + | 'BADGE_EARNED' + | 'DAILY_COMPLETE' + | 'STREAK_REMINDER' + | 'TEST_COMPLETE' + | 'NEWS_QUIZ_COMPLETE' + | 'GAME_END' + | 'GAME_STREAK' + | 'OPIC_COMPLETE'; + +export const useNotifications = ( + userId: string | null, + onNotification: (notification: Notification) => void +) => { + const eventSourceRef = useRef(null); + + const connect = useCallback(() => { + if (!userId) return; + + const url = `${process.env.NEXT_PUBLIC_NOTIFICATION_URL}?userId=${userId}`; + const eventSource = new EventSource(url); + + eventSource.onmessage = (event) => { + // Heartbeat 무시 + if (event.data === 'HEARTBEAT') return; + + try { + const notification: Notification = JSON.parse(event.data); + onNotification(notification); + } catch (e) { + console.error('알림 파싱 실패:', e); + } + }; + + eventSource.onerror = () => { + console.log('알림 연결 끊김, 재연결 시도 중...'); + }; + + eventSourceRef.current = eventSource; + }, [userId, onNotification]); + + const disconnect = useCallback(() => { + if (eventSourceRef.current) { + eventSourceRef.current.close(); + eventSourceRef.current = null; + } + }, []); + + useEffect(() => { + connect(); + return () => disconnect(); + }, [connect, disconnect]); + + return { disconnect, reconnect: connect }; +}; +``` + +### React 컴포넌트 사용 예시 + +```tsx +const NotificationProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const { user } = useAuth(); + const [notifications, setNotifications] = useState([]); + + const handleNotification = useCallback((notification: Notification) => { + setNotifications(prev => [notification, ...prev]); + + // 타입별 처리 + switch (notification.type) { + case 'BADGE_EARNED': + showBadgeToast(notification.payload); + break; + case 'DAILY_COMPLETE': + showStreakCelebration(notification.payload); + break; + case 'GAME_END': + showGameResult(notification.payload); + break; + // ... 기타 타입 + } + }, []); + + useNotifications(user?.id ?? null, handleNotification); + + return ( + + {children} + + ); +}; +``` + +--- + +## 알림 타입 및 Payload 구조 + +### 공통 응답 구조 + +```typescript +interface Notification { + notificationId: string; // "notif-xxxxxxxx" 형식 + type: NotificationType; // 알림 타입 + userId: string; // 대상 사용자 ID + payload: object; // 타입별 상세 데이터 + createdAt: string; // ISO-8601 형식 (예: "2024-01-15T09:30:00Z") +} +``` + +--- + +### 1. BADGE_EARNED (배지 획득) + +사용자가 새로운 배지를 획득했을 때 + +```typescript +interface BadgeEarnedPayload { + badgeType: string; // 배지 타입 코드 + badgeName: string; // 배지 이름 + description: string; // 배지 설명 + iconUrl: string; // 배지 아이콘 URL +} +``` + +**예시:** +```json +{ + "notificationId": "notif-a1b2c3d4", + "type": "BADGE_EARNED", + "userId": "user-123", + "payload": { + "badgeType": "STREAK_7", + "badgeName": "7일 연속 학습", + "description": "7일 연속으로 학습을 완료했습니다!", + "iconUrl": "https://cdn.example.com/badges/streak-7.png" + }, + "createdAt": "2024-01-15T09:30:00Z" +} +``` + +--- + +### 2. DAILY_COMPLETE (일일 학습 완료) + +오늘의 단어 학습을 모두 완료했을 때 + +```typescript +interface DailyCompletePayload { + date: string; // 학습 완료 날짜 (YYYY-MM-DD) + wordsLearned: number; // 오늘 학습한 단어 수 + totalWords: number; // 총 학습 단어 수 + currentStreak: number; // 현재 연속 학습 일수 +} +``` + +**예시:** +```json +{ + "notificationId": "notif-e5f6g7h8", + "type": "DAILY_COMPLETE", + "userId": "user-123", + "payload": { + "date": "2024-01-15", + "wordsLearned": 20, + "totalWords": 150, + "currentStreak": 5 + }, + "createdAt": "2024-01-15T14:00:00Z" +} +``` + +--- + +### 3. STREAK_REMINDER (연속 학습 리마인더) + +매일 21:00 KST에 오늘 학습을 아직 하지 않은 사용자에게 발송 + +```typescript +interface StreakReminderPayload { + currentStreak: number; // 현재 연속 학습 일수 + message: string; // 리마인더 메시지 +} +``` + +**예시:** +```json +{ + "notificationId": "notif-i9j0k1l2", + "type": "STREAK_REMINDER", + "userId": "user-123", + "payload": { + "currentStreak": 5, + "message": "오늘 학습을 완료하고 6일 연속 학습을 달성하세요!" + }, + "createdAt": "2024-01-15T12:00:00Z" +} +``` + +--- + +### 4. TEST_COMPLETE (단어 테스트 완료) + +단어 테스트를 완료했을 때 + +```typescript +interface TestCompletePayload { + testId: string; // 테스트 ID + score: number; // 점수 (0-100) + correctCount: number; // 맞힌 문제 수 + totalCount: number; // 전체 문제 수 + isPerfect: boolean; // 만점 여부 +} +``` + +**예시:** +```json +{ + "notificationId": "notif-m3n4o5p6", + "type": "TEST_COMPLETE", + "userId": "user-123", + "payload": { + "testId": "test-abc123", + "score": 85, + "correctCount": 17, + "totalCount": 20, + "isPerfect": false + }, + "createdAt": "2024-01-15T10:30:00Z" +} +``` + +--- + +### 5. NEWS_QUIZ_COMPLETE (뉴스 퀴즈 완료) + +뉴스 기사 퀴즈를 완료했을 때 + +```typescript +interface NewsQuizCompletePayload { + articleId: string; // 뉴스 기사 ID + articleTitle: string; // 기사 제목 + score: number; // 점수 (0-100) + correctCount: number; // 맞힌 문제 수 + totalCount: number; // 전체 문제 수 + isPerfect: boolean; // 만점 여부 +} +``` + +**예시:** +```json +{ + "notificationId": "notif-q7r8s9t0", + "type": "NEWS_QUIZ_COMPLETE", + "userId": "user-123", + "payload": { + "articleId": "article-xyz789", + "articleTitle": "Tech Giants Report Strong Q4 Earnings", + "score": 100, + "correctCount": 5, + "totalCount": 5, + "isPerfect": true + }, + "createdAt": "2024-01-15T11:00:00Z" +} +``` + +--- + +### 6. GAME_END (게임 종료) + +캐치마인드 게임이 종료되었을 때 + +```typescript +interface GameEndPayload { + roomId: string; // 게임 방 ID + gameSessionId: string; // 게임 세션 ID + rank: number; // 최종 순위 + totalPlayers: number; // 전체 플레이어 수 + score: number; // 획득 점수 + isWinner: boolean; // 1등 여부 +} +``` + +**예시:** +```json +{ + "notificationId": "notif-u1v2w3x4", + "type": "GAME_END", + "userId": "user-123", + "payload": { + "roomId": "room-game-001", + "gameSessionId": "session-abc", + "rank": 1, + "totalPlayers": 4, + "score": 2500, + "isWinner": true + }, + "createdAt": "2024-01-15T15:30:00Z" +} +``` + +--- + +### 7. GAME_STREAK (게임 연속 정답) + +게임 중 연속 정답을 달성했을 때 + +```typescript +interface GameStreakPayload { + roomId: string; // 게임 방 ID + streakCount: number; // 연속 정답 횟수 + bonusPoints: number; // 보너스 점수 +} +``` + +**예시:** +```json +{ + "notificationId": "notif-y5z6a7b8", + "type": "GAME_STREAK", + "userId": "user-123", + "payload": { + "roomId": "room-game-001", + "streakCount": 5, + "bonusPoints": 500 + }, + "createdAt": "2024-01-15T15:25:00Z" +} +``` + +--- + +### 8. OPIC_COMPLETE (OPIc 연습 완료) + +OPIc 스피킹 연습 세션을 완료했을 때 + +```typescript +interface OpicCompletePayload { + sessionId: string; // 세션 ID + estimatedLevel: string; // 예상 등급 (IM1, IM2, IH, AL 등) + questionsAnswered: number; // 답변한 문제 수 + feedbackSummary: string; // 피드백 요약 +} +``` + +**예시:** +```json +{ + "notificationId": "notif-c9d0e1f2", + "type": "OPIC_COMPLETE", + "userId": "user-123", + "payload": { + "sessionId": "opic-session-456", + "estimatedLevel": "IM2", + "questionsAnswered": 15, + "feedbackSummary": "발음과 유창성이 좋습니다. 문법적 정확성을 더 연습하세요." + }, + "createdAt": "2024-01-15T16:00:00Z" +} +``` + +--- + +## 특수 이벤트 + +### HEARTBEAT (하트비트) + +서버에서 연결 유지를 위해 1초마다 전송합니다. 무시하면 됩니다. + +```javascript +eventSource.onmessage = (event) => { + if (event.data === 'HEARTBEAT') return; // 무시 + // ... +}; +``` + +### STREAM_END (스트림 종료) + +서버가 연결을 종료할 때 전송됩니다. (최대 14분 후) +`EventSource`는 자동으로 재연결을 시도합니다. + +--- + +## 연결 관리 권장사항 + +### 1. 연결 시점 + +```typescript +// 로그인 후 연결 +const handleLoginSuccess = (user: User) => { + connectNotifications(user.id); +}; + +// 페이지 로드 시 (이미 로그인된 경우) +useEffect(() => { + if (isAuthenticated && user) { + connectNotifications(user.id); + } +}, [isAuthenticated, user]); +``` + +### 2. 연결 해제 시점 + +```typescript +// 로그아웃 시 +const handleLogout = () => { + disconnectNotifications(); + // ... +}; + +// 페이지 언마운트 시 (SPA) +useEffect(() => { + return () => disconnectNotifications(); +}, []); +``` + +### 3. 재연결 처리 + +`EventSource`는 연결 끊김 시 자동 재연결을 시도합니다. +추가적인 재연결 로직이 필요한 경우: + +```typescript +const MAX_RETRY_COUNT = 5; +let retryCount = 0; + +eventSource.onerror = () => { + retryCount++; + + if (retryCount >= MAX_RETRY_COUNT) { + eventSource.close(); + showErrorMessage('알림 서버 연결에 실패했습니다. 새로고침해주세요.'); + } +}; + +eventSource.onopen = () => { + retryCount = 0; // 연결 성공 시 초기화 +}; +``` + +--- + +## UI 처리 권장사항 + +### 토스트 알림 + +```typescript +const showNotificationToast = (notification: Notification) => { + const config = getToastConfig(notification.type); + + toast({ + title: config.title, + description: formatPayload(notification.payload), + icon: config.icon, + duration: config.duration, + }); +}; + +const getToastConfig = (type: NotificationType) => { + switch (type) { + case 'BADGE_EARNED': + return { title: '🏆 배지 획득!', icon: 'trophy', duration: 5000 }; + case 'DAILY_COMPLETE': + return { title: '✅ 오늘의 학습 완료!', icon: 'check', duration: 4000 }; + case 'STREAK_REMINDER': + return { title: '⏰ 학습 리마인더', icon: 'clock', duration: 6000 }; + case 'TEST_COMPLETE': + return { title: '📝 테스트 완료', icon: 'file', duration: 3000 }; + case 'GAME_END': + return { title: '🎮 게임 종료', icon: 'gamepad', duration: 4000 }; + default: + return { title: '알림', icon: 'bell', duration: 3000 }; + } +}; +``` + +### 알림 센터 + +```typescript +const NotificationCenter: React.FC = () => { + const { notifications } = useNotificationContext(); + const [unreadCount, setUnreadCount] = useState(0); + + return ( + + + + {unreadCount > 0 && } + + + {notifications.map(notif => ( + + ))} + + + ); +}; +``` + +--- + +## TypeScript 타입 정의 (복사용) + +```typescript +// types/notification.ts + +export type NotificationType = + | 'BADGE_EARNED' + | 'DAILY_COMPLETE' + | 'STREAK_REMINDER' + | 'TEST_COMPLETE' + | 'NEWS_QUIZ_COMPLETE' + | 'GAME_END' + | 'GAME_STREAK' + | 'OPIC_COMPLETE'; + +export interface BaseNotification { + notificationId: string; + type: T; + userId: string; + payload: P; + createdAt: string; +} + +export interface BadgeEarnedPayload { + badgeType: string; + badgeName: string; + description: string; + iconUrl: string; +} + +export interface DailyCompletePayload { + date: string; + wordsLearned: number; + totalWords: number; + currentStreak: number; +} + +export interface StreakReminderPayload { + currentStreak: number; + message: string; +} + +export interface TestCompletePayload { + testId: string; + score: number; + correctCount: number; + totalCount: number; + isPerfect: boolean; +} + +export interface NewsQuizCompletePayload { + articleId: string; + articleTitle: string; + score: number; + correctCount: number; + totalCount: number; + isPerfect: boolean; +} + +export interface GameEndPayload { + roomId: string; + gameSessionId: string; + rank: number; + totalPlayers: number; + score: number; + isWinner: boolean; +} + +export interface GameStreakPayload { + roomId: string; + streakCount: number; + bonusPoints: number; +} + +export interface OpicCompletePayload { + sessionId: string; + estimatedLevel: string; + questionsAnswered: number; + feedbackSummary: string; +} + +export type Notification = + | BaseNotification<'BADGE_EARNED', BadgeEarnedPayload> + | BaseNotification<'DAILY_COMPLETE', DailyCompletePayload> + | BaseNotification<'STREAK_REMINDER', StreakReminderPayload> + | BaseNotification<'TEST_COMPLETE', TestCompletePayload> + | BaseNotification<'NEWS_QUIZ_COMPLETE', NewsQuizCompletePayload> + | BaseNotification<'GAME_END', GameEndPayload> + | BaseNotification<'GAME_STREAK', GameStreakPayload> + | BaseNotification<'OPIC_COMPLETE', OpicCompletePayload>; +``` + +--- + +## 환경 설정 + +### 환경 변수 + +| 환경 | URL | +|------|-----| +| **Test** | `https://flhf42jd6xgrh26wrqgwxmbmee0zmjnv.lambda-url.ap-northeast-2.on.aws/` | +| **Prod** | (배포 후 업데이트 예정) | + +```env +# .env.local (Next.js) +NEXT_PUBLIC_NOTIFICATION_URL=https://flhf42jd6xgrh26wrqgwxmbmee0zmjnv.lambda-url.ap-northeast-2.on.aws + +# .env (Vite) +VITE_NOTIFICATION_URL=https://flhf42jd6xgrh26wrqgwxmbmee0zmjnv.lambda-url.ap-northeast-2.on.aws +``` + +--- + +## 테스트 방법 + +### 개발 환경에서 테스트 + +1. 브라우저 개발자 도구 → Network 탭 열기 +2. EventStream 필터 선택 +3. 로그인 후 알림 연결 확인 +4. 학습 완료, 테스트 제출 등의 액션 수행 +5. 실시간으로 알림 수신 확인 + +### Mock SSE 서버 (로컬 테스트용) + +```javascript +// mock-sse-server.js +const http = require('http'); + +http.createServer((req, res) => { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'Access-Control-Allow-Origin': '*', + }); + + // 테스트 알림 전송 + setInterval(() => { + const notification = { + notificationId: `notif-${Date.now()}`, + type: 'BADGE_EARNED', + userId: 'test-user', + payload: { + badgeType: 'TEST_BADGE', + badgeName: '테스트 배지', + description: '테스트용 배지입니다', + iconUrl: 'https://example.com/badge.png', + }, + createdAt: new Date().toISOString(), + }; + res.write(`data: ${JSON.stringify(notification)}\n\n`); + }, 5000); +}).listen(3001); + +console.log('Mock SSE server running on http://localhost:3001'); +``` + +--- + +## 문의 + +백엔드 알림 시스템 관련 문의: **[백엔드 담당자 이름/연락처]** \ No newline at end of file diff --git a/docs/frontend-wordchain-guide.md b/docs/frontend-wordchain-guide.md new file mode 100644 index 00000000..5296aa09 --- /dev/null +++ b/docs/frontend-wordchain-guide.md @@ -0,0 +1,365 @@ +# 영어 끝말잇기(쿵쿵따) 프론트엔드 통합 가이드 + +## 개요 +영어 끝말잇기 게임 - 이전 단어의 마지막 글자로 시작하는 단어를 제출하는 게임 + +## REST API 엔드포인트 + +### 1. 게임 시작 +``` +POST /chat/rooms/{roomId}/wordchain/start +Authorization: Bearer {token} +``` + +**Response (성공):** +```json +{ + "success": true, + "message": "Word Chain game started", + "data": { + "sessionId": "uuid", + "gameStatus": "PLAYING", + "currentRound": 1, + "currentPlayerId": "user-id", + "currentWord": "apple", + "nextLetter": "e", + "timeLimit": 15, + "turnStartTime": 1706000000000, + "serverTime": 1706000000000, + "activePlayers": ["user1", "user2", "user3"], + "eliminatedPlayers": [], + "scores": {}, + "usedWords": ["apple"] + } +} +``` + +### 2. 단어 제출 +``` +POST /chat/rooms/{roomId}/wordchain/submit +Authorization: Bearer {token} +Content-Type: application/json + +{ + "word": "elephant" +} +``` + +**Response (정답):** +```json +{ + "success": true, + "message": "Correct!", + "data": { + "resultType": "CORRECT", + "word": "elephant", + "definition": "(noun) A large mammal with a trunk", + "phonetic": "/ˈɛləfənt/", + "score": 23, + "nextLetter": "t", + "nextPlayerId": "user2", + "nextTimeLimit": 15 + } +} +``` + +**Response (오답 - 첫 글자 틀림):** +```json +{ + "success": true, + "message": "Wrong answer", + "data": { + "resultType": "WRONG_LETTER", + "error": "'e'로 시작하는 단어를 입력하세요." + } +} +``` + +**Response (오답 - 사전에 없음):** +```json +{ + "success": true, + "message": "Wrong answer", + "data": { + "resultType": "INVALID_WORD", + "error": "사전에 없는 단어입니다: xyz" + } +} +``` + +### 3. 타임아웃 처리 +``` +POST /chat/rooms/{roomId}/wordchain/timeout +Authorization: Bearer {token} +``` + +### 4. 게임 종료 (시작자만) +``` +POST /chat/rooms/{roomId}/wordchain/stop +Authorization: Bearer {token} +``` + +### 5. 게임 상태 조회 +``` +GET /chat/rooms/{roomId}/wordchain/status +Authorization: Bearer {token} +``` + +--- + +## WebSocket 메시지 + +### Domain +```javascript +domain: "wordchain" +``` + +### 메시지 타입 + +| messageType | 설명 | +|-------------|------| +| `wordchain_start` | 게임 시작 | +| `wordchain_correct` | 정답 | +| `wordchain_wrong` | 오답 | +| `wordchain_timeout` | 시간 초과 (탈락) | +| `wordchain_end` | 게임 종료 | + +--- + +## WebSocket 메시지 상세 + +### 1. 게임 시작 (wordchain_start) +```json +{ + "domain": "wordchain", + "messageType": "wordchain_start", + "messageId": "uuid", + "roomId": "room-id", + "userId": "SYSTEM", + "content": "🎮 끝말잇기 시작!\n시작 단어: apple\n다음 글자: 'e'\n\n첫 번째 차례: user1\n제한 시간: 15초", + "createdAt": "2026-01-24T12:00:00Z", + "timestamp": 1706000000000, + "sessionId": "session-uuid", + "starterWord": "apple", + "nextLetter": "e", + "currentPlayerId": "user1", + "timeLimit": 15, + "turnStartTime": 1706000000000, + "serverTime": 1706000000000, + "players": ["user1", "user2", "user3"], + "activePlayers": ["user1", "user2", "user3"] +} +``` + +### 2. 정답 (wordchain_correct) +```json +{ + "domain": "wordchain", + "messageType": "wordchain_correct", + "messageId": "uuid", + "roomId": "room-id", + "userId": "SYSTEM", + "content": "✅ 닉네임: \"elephant\" (+23점)\n뜻: (noun) A large mammal\n다음 글자: 't'", + "createdAt": "2026-01-24T12:00:05Z", + "timestamp": 1706000005000, + "serverTime": 1706000005000, + "resultType": "CORRECT", + "word": "elephant", + "definition": "(noun) A large mammal with a trunk", + "phonetic": "/ˈɛləfənt/", + "score": 23, + "nextLetter": "t", + "nextPlayerId": "user2", + "nextTimeLimit": 15, + "playerNickname": "닉네임", + "turnStartTime": 1706000005000, + "scores": { + "user1": 23 + } +} +``` + +### 3. 오답 (wordchain_wrong) +```json +{ + "domain": "wordchain", + "messageType": "wordchain_wrong", + "messageId": "uuid", + "roomId": "room-id", + "userId": "SYSTEM", + "content": "❌ 사전에 없는 단어입니다: xyz", + "resultType": "INVALID_WORD", + "error": "사전에 없는 단어입니다: xyz" +} +``` + +### 4. 시간 초과 (wordchain_timeout) +```json +{ + "domain": "wordchain", + "messageType": "wordchain_timeout", + "messageId": "uuid", + "roomId": "room-id", + "userId": "SYSTEM", + "content": "⏰ 닉네임 시간 초과! 탈락!", + "resultType": "TIMEOUT", + "eliminatedPlayerId": "user1", + "eliminatedNickname": "닉네임", + "nextPlayerId": "user2", + "nextTimeLimit": 13, + "nextLetter": "e", + "turnStartTime": 1706000015000, + "activePlayers": ["user2", "user3"] +} +``` + +### 5. 게임 종료 (wordchain_end) +```json +{ + "domain": "wordchain", + "messageType": "wordchain_end", + "messageId": "uuid", + "roomId": "room-id", + "userId": "SYSTEM", + "content": "🏆 승자: 닉네임!", + "resultType": "GAME_END", + "winnerId": "user2", + "winnerNickname": "닉네임", + "ranking": [ + { "playerId": "user2", "nickname": "닉네임2", "score": 45, "eliminated": false }, + { "playerId": "user3", "nickname": "닉네임3", "score": 30, "eliminated": true }, + { "playerId": "user1", "nickname": "닉네임1", "score": 23, "eliminated": true } + ], + "usedWords": ["apple", "elephant", "tiger", "rainbow"], + "wordDefinitions": { + "apple": "(noun) A fruit", + "elephant": "(noun) A large mammal", + "tiger": "(noun) A large cat", + "rainbow": "(noun) An arc of colors" + }, + "scores": { + "user1": 23, + "user2": 45, + "user3": 30 + } +} +``` + +--- + +## 게임 규칙 + +### 시간 제한 (라운드별 감소) +| 라운드 | 시간 제한 | +|--------|----------| +| 1-2 | 15초 | +| 3-4 | 13초 | +| 5-6 | 11초 | +| 7-8 | 9초 | +| 9+ | 8초 | + +### 점수 계산 +``` +점수 = 기본점수(10) + 시간보너스 + 길이보너스 + +시간보너스 = 남은시간(초) +길이보너스 = (단어길이 - 4) × 2 (5글자 이상부터) +``` + +**예시:** +- 15초 제한에서 5초 만에 "elephant"(8글자) 제출 +- 점수 = 10 + 10 + 8 = 28점 + +### 게임 종료 조건 +- 1명만 남으면 게임 종료 +- 시작자가 `/stop` 호출 + +--- + +## 프론트엔드 구현 가이드 + +### 1. 타이머 동기화 +```javascript +// 서버 시간과 클라이언트 시간 차이 계산 +const serverTimeDiff = message.serverTime - Date.now(); + +// 남은 시간 계산 +const elapsed = Date.now() + serverTimeDiff - message.turnStartTime; +const remaining = (message.timeLimit * 1000) - elapsed; +``` + +### 2. WebSocket 메시지 핸들러 +```javascript +socket.onmessage = (event) => { + const message = JSON.parse(event.data); + + if (message.domain !== 'wordchain') return; + + switch (message.messageType) { + case 'wordchain_start': + handleGameStart(message); + break; + case 'wordchain_correct': + handleCorrectAnswer(message); + break; + case 'wordchain_wrong': + handleWrongAnswer(message); + break; + case 'wordchain_timeout': + handleTimeout(message); + break; + case 'wordchain_end': + handleGameEnd(message); + break; + } +}; +``` + +### 3. 타임아웃 자동 전송 +```javascript +// 내 턴일 때 타이머 만료 시 자동으로 타임아웃 API 호출 +if (isMyTurn && remaining <= 0) { + fetch(`/chat/rooms/${roomId}/wordchain/timeout`, { + method: 'POST', + headers: { 'Authorization': `Bearer ${token}` } + }); +} +``` + +### 4. UI 구성 요소 +- 현재 단어 표시 +- 다음 시작 글자 강조 +- 타이머 (남은 시간) +- 현재 차례 플레이어 표시 +- 활성/탈락 플레이어 목록 +- 점수판 +- 사용된 단어 목록 +- 단어 입력 필드 (본인 차례일 때만 활성화) + +### 5. 게임 종료 후 학습 화면 +```javascript +// 게임 종료 시 사용된 단어와 뜻 표시 +message.usedWords.forEach(word => { + const definition = message.wordDefinitions[word]; + console.log(`${word}: ${definition}`); +}); +``` + +--- + +## 에러 코드 + +| 코드 | 메시지 | +|------|--------| +| GAME_001 | 게임 시작에 실패했습니다 | +| GAME_002 | 게임 중단에 실패했습니다 | +| GAME_010 | 게임 액션 처리에 실패했습니다 | +| INPUT_001 | 유효하지 않은 입력입니다 | + +--- + +## 참고 + +- Dictionary API: [Free Dictionary API](https://dictionaryapi.dev/) +- 최소 인원: 2명 +- 시작 단어: 서버에서 랜덤 선택 (apple, house, water 등) From 2ed6c0b22de7bb2a0de51a49e4d37cd7e9f0c524 Mon Sep 17 00:00:00 2001 From: hyein Heo <128613248+hye-inA@users.noreply.github.com> Date: Sun, 25 Jan 2026 02:46:50 +0900 Subject: [PATCH 523/528] =?UTF-8?q?feature=20:=20=EC=B1=84=ED=8C=85=20?= =?UTF-8?q?=EB=A9=94=EB=89=B4=EC=97=90=EC=84=9C=20=EB=8B=89=EB=84=A4?= =?UTF-8?q?=EC=9E=84=20=EC=A1=B0=ED=9A=8C=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=B0=B0=ED=8F=AC=20(#535)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: add Bedrock permission to NewsCollectionFunction NewsCollectionFunction needs bedrock:InvokeModel permission to analyze news difficulty using AI. * fix: fallback to yesterday's news when today's news is empty GET /news now returns yesterday's articles if no articles exist for today. This prevents empty results before the daily 18:00 KST news collection runs. * feature : 채팅 도메인 닉네임 조회용 메서드 추가 (#534) * test: Difficulty enum 단위 테스트 추가 * test: StudyConfig 단위 테스트 추가 * test: EnvConfig 단위 테스트 추가 * test: PaginatedResult 단위 테스트 추가 * test: JsonUtil 단위 테스트 추가 * test: CursorUtil 단위 테스트 추가 * test: CommonErrorCode 단위 테스트 추가 * test: CommonException 단위 테스트 추가 * test: BadgeType enum 단위 테스트 추가 * test: BadgeKey 상수 단위 테스트 추가 * test: WordStatus enum 단위 테스트 추가 * test: TestType enum 단위 테스트 추가 * test: VocabularyConfig 단위 테스트 추가 * test: VocabKey 상수 단위 테스트 추가 * test: VocabularyErrorCode 단위 테스트 추가 * test: VocabularyException 단위 테스트 추가 * test: SpacedRepetitionContext 단위 테스트 추가 * test: GrammarLevel enum 단위 테스트 추가 * test: GrammarConfig 단위 테스트 추가 * test: GrammarErrorCode 단위 테스트 추가 * test: GrammarException 단위 테스트 추가 * test: GameStatus enum 단위 테스트 추가 * test: GameConfig 단위 테스트 추가 * test: ChattingErrorCode 단위 테스트 추가 * test: ChattingException 단위 테스트 추가 * fix: OPIc FeedbackResponse.java 문법 오류 수정 * style: AwsClients 코드 포맷팅 * style: EnvConfig 코드 포맷팅 * style: RoomTokenConfig 코드 포맷팅 * style: WebSocketConfig 코드 포맷팅 * style: JsonUtil 코드 포맷팅 * style: GameConfig 코드 포맷팅 * style: GrammarConfig 코드 포맷팅 * style: GrammarKey 코드 포맷팅 * style: GrammarErrorCode 코드 포맷팅 * style: GrammarException 코드 포맷팅 * style: BedrockGrammarCheckFactory 코드 포맷팅 * style: GrammarConversationService 코드 포맷팅 * style: FeedbackResponse 코드 포맷팅 * style: SessionReportResponse 코드 포맷팅 * style: SpeakingError 코드 포맷팅 * style: SpeakingErrorType 코드 포맷팅 * style: OPIcException 코드 포맷팅 * style: OPIcAnswer 코드 포맷팅 * style: OPIcQuestion 코드 포맷팅 * style: OPIcSession 코드 포맷팅 * style: OPIcRepository 코드 포맷팅 * style: FeedbackService 코드 포맷팅 * style: TranscribeProxyService 코드 포맷팅 * style: VocabularyConfig 코드 포맷팅 * style: DailyStudyCommandService 코드 포맷팅 * style: TestCommandService 코드 포맷팅 * style: TestService 코드 포맷팅 * style: CommonErrorCodeSpec 코드 포맷팅 * style: JsonUtilSpec 코드 포맷팅 * style: BadgeTypeSpec 코드 포맷팅 * style: ChattingErrorCodeSpec 코드 포맷팅 * style: GrammarLevelSpec 코드 포맷팅 * style: GrammarErrorCodeSpec 코드 포맷팅 * style: VocabularyErrorCodeSpec 코드 포맷팅 * feature : OPIc 세션관리 + 답변 처리 파이프라인 구현 (#413) * feat : OPIc 질문 & 세션 관련 dto 생성 * refactor : @DynamoDbBean 주석 추가 * feat : 주제 + 소주제 + 레벨로 오픽 질문 조회 추가 * feat : OPIc 세션, 질문, 답변 종합 handler 구현 * feat : 오픽 주제, 소주제별 seed 데이터 추가 * refactor : Transcribe API KEY 환경변수명 수정 * refactor : Bedrock에 사용하는 클로드 모델 변경 * refactor : S3 Key 대신 Proxy에서 요청하는 Base64 값으로 변환 * feat: GAME_START, ROUND_END 메시지에 serverTime 추가 - broadcastGameStart(): serverTime, roundDuration 필드 추가 - broadcastRoundEnd(): serverTime, roundStartTime, roundDuration 필드 추가 - GameService.endRound(): data에 roundStartTime, roundDuration 포함 - 타이머 동기화 버그 수정을 위한 서버 시간 제공 * feat: WebSocketMessageHelper 유틸리티 클래스 추가 - domain 필드 포함 메시지 생성 헬퍼 - DOMAIN_CHAT, DOMAIN_GAME 상수 정의 - buildChatMessage(), buildGameMessage() 메서드 * feat: 모든 WebSocket 메시지에 domain 필드 추가 - ScoreUpdateMessage에 domain 필드 추가 - broadcastGameStart에 domain:"game" 추가 - broadcastRoundEnd에 domain:"game" 추가 - broadcastCorrectAnswerMessage에 domain:"game" 추가 - handleCommandResult 시스템 메시지에 domain:"game" 추가 - handleRegularMessage 채팅 메시지에 domain:"chat" 추가 Closes #426, #427 * feat: GameSession 모델 클래스 생성 - DynamoDB Enhanced Client 어노테이션 적용 - GSI1: roomId로 활성 게임 세션 조회 가능 - 게임 상태 관리용 헬퍼 메서드 포함 Closes #428 * feat: GameSessionRepository 구현 - 기본 CRUD (save, findById, delete) - roomId로 활성/전체 게임 세션 조회 - 상태, 라운드, 점수 업데이트 메서드 - 정답자 추가, 힌트 사용, 게임 종료 처리 Closes #429 * refactor: ChatRoom에서 게임 필드 분리 - 게임 관련 필드 제거 (gameStatus, currentRound 등) - activeGameSessionId 필드 추가 - 게임 상태는 GameSession으로 분리됨 Note: 의존 코드 수정은 #431에서 진행 Closes #430 * refactor: GameSession 기반으로 전체 게임 로직 리팩토링 - GameService: ChatRoom 대신 GameSession 사용 - GameStatsService: GameSession 매개변수로 변경 - CommandService: GameSession 기반 점수 조회 - GameHandler: GameSession 기반 REST API - WebSocketMessageHandler: GameSession 기반 브로드캐스트 - GameStatusResponse, ScoreboardResponse: GameSession 매개변수 Closes #431 * feat: GameSessionHandler Lambda 및 게임 세션 API 구현 - POST /rooms/{roomId}/games - 게임 세션 생성 - GET /games/{gameSessionId} - 게임 상태 조회 (재접속용) - POST /games/{gameSessionId}/start - 게임 시작 - POST /games/{gameSessionId}/stop - 게임 종료 모든 응답에 serverTime 포함 (타이머 동기화) 출제자에게만 currentWord 포함 Closes #432, #433 * feat: 게임 시작 7분 후 자동 종료 기능 구현 - GameConfig에 gameTimeLimit() 메서드 추가 (기본값: 420초) - GameSchedulerClient 유틸리티 클래스 생성 (EventBridge Scheduler 연동) - GameService에 스케줄 생성/취소 로직 추가 - GameAutoCloseHandler Lambda 함수 생성 - template.yaml에 Lambda, IAM Role, Schedule Group 추가 Closes #417 * fix : 메모리 증가 및 Lambda 응답 제한 시간 Cognito 트리거 제한시간과 동일하게 수정 (#439) * feat: 캐치마인드 게임 방 분리 기능 구현 (#455) - Room 타입 분리 (CHAT/GAME) - RoomType, RoomStatus enum 추가 - ChatRoom 모델에 type, gameType, gameSettings, status, hostId 필드 추가 - GameSettings 모델 추가 - 방 생성/조회 API 수정 - CreateRoomRequest에 type, gameType, gameSettings 필드 추가 - 방 목록 조회 시 type, gameType, status 필터 지원 - 게임 시작 조건 검증 - GAME 타입 방에서만 게임 시작 가능 (GAME_007 에러) - 게임 재시작 API 구현 (#452) - POST /rooms/{roomId}/game/restart 엔드포인트 추가 - 방장만 재시작 가능 (GAME_009 에러) - 게임 진행 중 재시작 불가 (GAME_008 에러) - 방장 변경 로직 구현 (#453) - 방장 퇴장 시 다음 멤버에게 자동 이전 - 모든 멤버 퇴장 시 방 자동 삭제 - WebSocket 메시지 타입 추가 (#454) - ROOM_STATUS_CHANGE, HOST_CHANGE 메시지 타입 추가 - WebSocketMessageHelper에 빌더 메서드 추가 - 테스트 추가 - RoomType, RoomStatus enum 테스트 - GameSettings 모델 테스트 - ChattingErrorCode 테스트 업데이트 Related: #440, #441, #442, #443, #444, #445, #446 Closes: #447, #448, #449, #450, #451, #452, #453, #454 * feat: 참가자 닉네임 및 방장 변경 WebSocket 알림 구현 (#456) - RoomParticipant DTO 추가 (userId, nickname, isHost) - ChatRoomQueryService에 닉네임 조회 메서드 추가 - getParticipantsWithNicknames(): 참가자 목록 + 닉네임 - getHostNickname(): 방장 닉네임 조회 - ChatRoomHandler.getRoom() 응답에 participants, hostNickname 추가 - ChatRoomCommandService.leaveRoom()에서 방장 변경 시 WebSocket 브로드캐스트 Related: #440 * fix: ChatRoomFunction에 UserTable DynamoDB 권한 추가 - 참가자/방장 닉네임 조회를 위한 UserTable 읽기 권한 추가 - DynamoDBReadPolicy로 UserTable 접근 허용 Fixes #457 * fix: GameSettings에 @DynamoDbBean 어노테이션 추가 - DynamoDB Enhanced Client가 중첩 객체를 직렬화/역직렬화할 수 있도록 수정 - ChatRoom 저장/조회 시 GameSettings 변환 오류 해결 Fixes #457 * fix: ChatRoomFunction에 WEBSOCKET_ENDPOINT 환경변수 및 권한 추가 - leaveRoom에서 WebSocketBroadcaster 사용을 위한 환경변수 추가 - execute-api:ManageConnections 권한 추가 * fix: WebSocket Lambda 함수들에 WEBSOCKET_ENDPOINT 환경변수 추가 - WebSocketConnectFunction에 WEBSOCKET_ENDPOINT 추가 - WebSocketDisconnectFunction에 WEBSOCKET_ENDPOINT 추가 - execute-api:ManageConnections 권한 추가 * fix: Grammar WebSocket Lambda 환경 변수 및 권한 추가 - GrammarStreamingConnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - GrammarStreamingDisconnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - 두 함수에 execute-api:ManageConnections 권한 추가 * feat: GSI1SK 확장성 있는 재설계 및 DB 레벨 필터링 ## 변경 사항 ### GSI1SK 포맷 변경 - 기존: {level}#{createdAt} - 신규: {type}#{gameType}#{status}#{level}#{createdAt} ### 지원 쿼리 패턴 - 전체 방: GSI1PK = "ROOMS" - 게임방만: begins_with(GSI1SK, "GAME#") - 캐치마인드만: begins_with(GSI1SK, "GAME#CATCHMIND#") - 대기중 캐치마인드: begins_with(GSI1SK, "GAME#CATCHMIND#WAITING#") ### 파일 수정 - ChatRoomCommandService.java: 방 생성 시 새 GSI1SK 포맷 적용 - ChatRoomRepository.java: findByFilters() 메서드 추가, updateStatus() 메서드 추가 - ChatRoomQueryService.java: 메모리 필터링 제거, DB 레벨 필터링으로 변경 - GameService.java: 게임 시작/종료 시 방 상태 업데이트 (GSI1SK 포함) ### 마이그레이션 - scripts/migrate-gsi1sk.sh: 기존 데이터 마이그레이션 스크립트 - 37개 기존 방 마이그레이션 완료 * fix: increase stats query limit to 100 days - Adjust maximum allowable limit for recent stats query from 30 to 100 days to support extended data range responses. * feat: improve game round and connection management logic - Added handling for game round timeout (`ROUND_TIMEOUT`) in WebSocketMessageHandler. - Enhanced game start broadcast to include `currentWord` for the drawer only. - Updated ConnectionRepository to remove duplicate user connections in the same room. - Added `currentWordEnglish` to GameSession for better answer verification. - Normalized ChatRoom levels to match database storage format. - Updated template.yaml to include DynamoDBReadPolicy for VocabTable. * refactor: 코드 정리 및 미사용 클래스 제거 (#459) * refactor: remove unused WebSocketResponseUtil * refactor: add AutoCloseable to WebSocketBroadcaster * refactor: remove unused TestService * refactor: remove unused WordService * refactor: remove unused DailyStudyService * refactor: remove unused UserWordService * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * fix: resolve N+1 query in StatsService * refactor(all): DI 패턴 및 전략 패턴 적용 (#461) - Repository, Service, Handler에 DI 생성자 추가 (테스트 용이성) - BadgeService에 Strategy 패턴 적용 (뱃지 조건 검증 로직 분리) * refactor: relocate and restructure seed data files - Moved `question-homes.json` and `words.json` from subdirectories to `seed` folder. - Updated file paths for better organization and clarity. * chore: seed 데이터 폴더 구조 정리 * feat: add CI/CD pipeline configuration for CodePipeline * fix: add SNS topic policy and DependsOn for notification rule * fix: correct paths in buildspec.yml for CodeBuild * fix: remove hardcoded JAVA_HOME, use runtime default * fix: add gradle wrapper for CI/CD build * fix: use single line sam package command with hardcoded bucket * fix: use existing stack name group2-englishstudy-chatting * fix: add missing WEBSOCKET_ENDPOINT env var to WebSocket connect functions * docs: update FRONTEND-API-GUIDE with new RoomType/RoomStatus structure * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix: update WebSocketDisconnectHandler to use GameSession model - Remove references to deleted ChatRoom game fields - Use GameSessionRepository to finish active game sessions - Use ChatRoomRepository.updateStatus() for room state management * perf: optimize CI/CD build time - Add pip cache for SAM CLI dependencies - Enable Gradle parallel builds and build cache - Add SAM build cache (.aws-sam) - Use sam build --parallel --cached - Skip SAM CLI install if already cached - Remove unnecessary 'clean' to leverage cache * feat: add custom CodeBuild Docker image with pre-installed tools - Dockerfile with Java 21 + SAM CLI + Gradle pre-installed - build-and-push.sh script for ECR deployment - Updated buildspec.yml for custom image (removes SAM CLI install) - Expected build time reduction: ~30-40 seconds * feature : AI 영어 회화 연습 기능 (#468) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix: remove typo in SpeakingConnectionRepository * fix : 오타 수정 * chore: trigger build test with custom Docker image * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes * Release: 캐치마인드 게임 분리, AI 회화 연습, CI/CD 파이프라인 (#469) * feature : OPIc 세션관리 + 답변 처리 파이프라인 구현 (#413) * feat : OPIc 질문 & 세션 관련 dto 생성 * refactor : @DynamoDbBean 주석 추가 * feat : 주제 + 소주제 + 레벨로 오픽 질문 조회 추가 * feat : OPIc 세션, 질문, 답변 종합 handler 구현 * feat : 오픽 주제, 소주제별 seed 데이터 추가 * refactor : Transcribe API KEY 환경변수명 수정 * refactor : Bedrock에 사용하는 클로드 모델 변경 * refactor : S3 Key 대신 Proxy에서 요청하는 Base64 값으로 변환 * feat: GAME_START, ROUND_END 메시지에 serverTime 추가 - broadcastGameStart(): serverTime, roundDuration 필드 추가 - broadcastRoundEnd(): serverTime, roundStartTime, roundDuration 필드 추가 - GameService.endRound(): data에 roundStartTime, roundDuration 포함 - 타이머 동기화 버그 수정을 위한 서버 시간 제공 * feat: WebSocketMessageHelper 유틸리티 클래스 추가 - domain 필드 포함 메시지 생성 헬퍼 - DOMAIN_CHAT, DOMAIN_GAME 상수 정의 - buildChatMessage(), buildGameMessage() 메서드 * feat: 모든 WebSocket 메시지에 domain 필드 추가 - ScoreUpdateMessage에 domain 필드 추가 - broadcastGameStart에 domain:"game" 추가 - broadcastRoundEnd에 domain:"game" 추가 - broadcastCorrectAnswerMessage에 domain:"game" 추가 - handleCommandResult 시스템 메시지에 domain:"game" 추가 - handleRegularMessage 채팅 메시지에 domain:"chat" 추가 Closes #426, #427 * feat: GameSession 모델 클래스 생성 - DynamoDB Enhanced Client 어노테이션 적용 - GSI1: roomId로 활성 게임 세션 조회 가능 - 게임 상태 관리용 헬퍼 메서드 포함 Closes #428 * feat: GameSessionRepository 구현 - 기본 CRUD (save, findById, delete) - roomId로 활성/전체 게임 세션 조회 - 상태, 라운드, 점수 업데이트 메서드 - 정답자 추가, 힌트 사용, 게임 종료 처리 Closes #429 * refactor: ChatRoom에서 게임 필드 분리 - 게임 관련 필드 제거 (gameStatus, currentRound 등) - activeGameSessionId 필드 추가 - 게임 상태는 GameSession으로 분리됨 Note: 의존 코드 수정은 #431에서 진행 Closes #430 * refactor: GameSession 기반으로 전체 게임 로직 리팩토링 - GameService: ChatRoom 대신 GameSession 사용 - GameStatsService: GameSession 매개변수로 변경 - CommandService: GameSession 기반 점수 조회 - GameHandler: GameSession 기반 REST API - WebSocketMessageHandler: GameSession 기반 브로드캐스트 - GameStatusResponse, ScoreboardResponse: GameSession 매개변수 Closes #431 * feat: GameSessionHandler Lambda 및 게임 세션 API 구현 - POST /rooms/{roomId}/games - 게임 세션 생성 - GET /games/{gameSessionId} - 게임 상태 조회 (재접속용) - POST /games/{gameSessionId}/start - 게임 시작 - POST /games/{gameSessionId}/stop - 게임 종료 모든 응답에 serverTime 포함 (타이머 동기화) 출제자에게만 currentWord 포함 Closes #432, #433 * feat: 게임 시작 7분 후 자동 종료 기능 구현 - GameConfig에 gameTimeLimit() 메서드 추가 (기본값: 420초) - GameSchedulerClient 유틸리티 클래스 생성 (EventBridge Scheduler 연동) - GameService에 스케줄 생성/취소 로직 추가 - GameAutoCloseHandler Lambda 함수 생성 - template.yaml에 Lambda, IAM Role, Schedule Group 추가 Closes #417 * fix : 메모리 증가 및 Lambda 응답 제한 시간 Cognito 트리거 제한시간과 동일하게 수정 (#439) * feat: 캐치마인드 게임 방 분리 기능 구현 (#455) - Room 타입 분리 (CHAT/GAME) - RoomType, RoomStatus enum 추가 - ChatRoom 모델에 type, gameType, gameSettings, status, hostId 필드 추가 - GameSettings 모델 추가 - 방 생성/조회 API 수정 - CreateRoomRequest에 type, gameType, gameSettings 필드 추가 - 방 목록 조회 시 type, gameType, status 필터 지원 - 게임 시작 조건 검증 - GAME 타입 방에서만 게임 시작 가능 (GAME_007 에러) - 게임 재시작 API 구현 (#452) - POST /rooms/{roomId}/game/restart 엔드포인트 추가 - 방장만 재시작 가능 (GAME_009 에러) - 게임 진행 중 재시작 불가 (GAME_008 에러) - 방장 변경 로직 구현 (#453) - 방장 퇴장 시 다음 멤버에게 자동 이전 - 모든 멤버 퇴장 시 방 자동 삭제 - WebSocket 메시지 타입 추가 (#454) - ROOM_STATUS_CHANGE, HOST_CHANGE 메시지 타입 추가 - WebSocketMessageHelper에 빌더 메서드 추가 - 테스트 추가 - RoomType, RoomStatus enum 테스트 - GameSettings 모델 테스트 - ChattingErrorCode 테스트 업데이트 Related: #440, #441, #442, #443, #444, #445, #446 Closes: #447, #448, #449, #450, #451, #452, #453, #454 * feat: 참가자 닉네임 및 방장 변경 WebSocket 알림 구현 (#456) - RoomParticipant DTO 추가 (userId, nickname, isHost) - ChatRoomQueryService에 닉네임 조회 메서드 추가 - getParticipantsWithNicknames(): 참가자 목록 + 닉네임 - getHostNickname(): 방장 닉네임 조회 - ChatRoomHandler.getRoom() 응답에 participants, hostNickname 추가 - ChatRoomCommandService.leaveRoom()에서 방장 변경 시 WebSocket 브로드캐스트 Related: #440 * fix: ChatRoomFunction에 UserTable DynamoDB 권한 추가 - 참가자/방장 닉네임 조회를 위한 UserTable 읽기 권한 추가 - DynamoDBReadPolicy로 UserTable 접근 허용 Fixes #457 * fix: GameSettings에 @DynamoDbBean 어노테이션 추가 - DynamoDB Enhanced Client가 중첩 객체를 직렬화/역직렬화할 수 있도록 수정 - ChatRoom 저장/조회 시 GameSettings 변환 오류 해결 Fixes #457 * fix: ChatRoomFunction에 WEBSOCKET_ENDPOINT 환경변수 및 권한 추가 - leaveRoom에서 WebSocketBroadcaster 사용을 위한 환경변수 추가 - execute-api:ManageConnections 권한 추가 * fix: WebSocket Lambda 함수들에 WEBSOCKET_ENDPOINT 환경변수 추가 - WebSocketConnectFunction에 WEBSOCKET_ENDPOINT 추가 - WebSocketDisconnectFunction에 WEBSOCKET_ENDPOINT 추가 - execute-api:ManageConnections 권한 추가 * fix: Grammar WebSocket Lambda 환경 변수 및 권한 추가 - GrammarStreamingConnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - GrammarStreamingDisconnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - 두 함수에 execute-api:ManageConnections 권한 추가 * feat: GSI1SK 확장성 있는 재설계 및 DB 레벨 필터링 ## 변경 사항 ### GSI1SK 포맷 변경 - 기존: {level}#{createdAt} - 신규: {type}#{gameType}#{status}#{level}#{createdAt} ### 지원 쿼리 패턴 - 전체 방: GSI1PK = "ROOMS" - 게임방만: begins_with(GSI1SK, "GAME#") - 캐치마인드만: begins_with(GSI1SK, "GAME#CATCHMIND#") - 대기중 캐치마인드: begins_with(GSI1SK, "GAME#CATCHMIND#WAITING#") ### 파일 수정 - ChatRoomCommandService.java: 방 생성 시 새 GSI1SK 포맷 적용 - ChatRoomRepository.java: findByFilters() 메서드 추가, updateStatus() 메서드 추가 - ChatRoomQueryService.java: 메모리 필터링 제거, DB 레벨 필터링으로 변경 - GameService.java: 게임 시작/종료 시 방 상태 업데이트 (GSI1SK 포함) ### 마이그레이션 - scripts/migrate-gsi1sk.sh: 기존 데이터 마이그레이션 스크립트 - 37개 기존 방 마이그레이션 완료 * fix: increase stats query limit to 100 days - Adjust maximum allowable limit for recent stats query from 30 to 100 days to support extended data range responses. * feat: improve game round and connection management logic - Added handling for game round timeout (`ROUND_TIMEOUT`) in WebSocketMessageHandler. - Enhanced game start broadcast to include `currentWord` for the drawer only. - Updated ConnectionRepository to remove duplicate user connections in the same room. - Added `currentWordEnglish` to GameSession for better answer verification. - Normalized ChatRoom levels to match database storage format. - Updated template.yaml to include DynamoDBReadPolicy for VocabTable. * refactor: 코드 정리 및 미사용 클래스 제거 (#459) * refactor: remove unused WebSocketResponseUtil * refactor: add AutoCloseable to WebSocketBroadcaster * refactor: remove unused TestService * refactor: remove unused WordService * refactor: remove unused DailyStudyService * refactor: remove unused UserWordService * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * fix: resolve N+1 query in StatsService * refactor(all): DI 패턴 및 전략 패턴 적용 (#461) - Repository, Service, Handler에 DI 생성자 추가 (테스트 용이성) - BadgeService에 Strategy 패턴 적용 (뱃지 조건 검증 로직 분리) * refactor: relocate and restructure seed data files - Moved `question-homes.json` and `words.json` from subdirectories to `seed` folder. - Updated file paths for better organization and clarity. * chore: seed 데이터 폴더 구조 정리 * feat: add CI/CD pipeline configuration for CodePipeline * fix: add SNS topic policy and DependsOn for notification rule * fix: correct paths in buildspec.yml for CodeBuild * fix: remove hardcoded JAVA_HOME, use runtime default * fix: add gradle wrapper for CI/CD build * fix: use single line sam package command with hardcoded bucket * fix: use existing stack name group2-englishstudy-chatting * fix: add missing WEBSOCKET_ENDPOINT env var to WebSocket connect functions * docs: update FRONTEND-API-GUIDE with new RoomType/RoomStatus structure * fix: update WebSocketDisconnectHandler to use GameSession model - Remove references to deleted ChatRoom game fields - Use GameSessionRepository to finish active game sessions - Use ChatRoomRepository.updateStatus() for room state management * perf: optimize CI/CD build time - Add pip cache for SAM CLI dependencies - Enable Gradle parallel builds and build cache - Add SAM build cache (.aws-sam) - Use sam build --parallel --cached - Skip SAM CLI install if already cached - Remove unnecessary 'clean' to leverage cache * feat: add custom CodeBuild Docker image with pre-installed tools - Dockerfile with Java 21 + SAM CLI + Gradle pre-installed - build-and-push.sh script for ECR deployment - Updated buildspec.yml for custom image (removes SAM CLI install) - Expected build time reduction: ~30-40 seconds * feature : AI 영어 회화 연습 기능 (#468) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix: remove typo in SpeakingConnectionRepository * chore: trigger build test with custom Docker image * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes --------- Co-authored-by: hyein Heo <128613248+hye-inA@users.noreply.github.com> * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * fix: add CORS headers to API Gateway error responses (#479) API Gateway의 인증 실패 등 에러 응답에 CORS 헤더가 누락되어 CloudFront를 통한 프론트엔드 요청이 차단되는 문제 수정 - UNAUTHORIZED (401) 응답에 CORS 헤더 추가 - ACCESS_DENIED (403) 응답에 CORS 헤더 추가 - DEFAULT_4XX/5XX 응답에 CORS 헤더 추가 - EXPIRED_TOKEN 응답에 CORS 헤더 추가 * feat(news): 뉴스 도메인 기반 구조 구축 (#385) - NewsCategory, QuizType enum 추가 - NewsKey 상수 클래스 추가 - NewsErrorCode 예외 클래스 추가 - NewsArticle, KeywordInfo, QuizQuestion 모델 추가 - NewsArticleRepository CRUD 구현 - NewsTable DynamoDB 테이블 정의 - CORS GatewayResponses 설정 추가 * feat(news): 뉴스 수집 파이프라인 구현 (#386) - NewsApiClient: NewsAPI 연동 서비스 - RssFeedParser: RSS 피드 파싱 (BBC, VOA, NPR) - NewsDuplicateChecker: URL 기반 중복 필터링 - NewsCollectorService: 수집 오케스트레이션 - NewsCollectionHandler: Lambda 핸들러 - EventBridge 스케줄러: 매일 18시 KST 실행 * refactor(news): NewsAPI 제거, RSS만 사용 - NewsApiClient 삭제 - SSM Parameter 의존성 제거 - RSS 소스당 7개씩 수집 (BBC, VOA, NPR = 약 21개/일) * feat(news): AI 뉴스 분석 시스템 구현 (#387) - NewsAnalysisService: AI 분석 통합 서비스 - Bedrock: CEFR 난이도 분석 (A1~C2) - Bedrock: 3줄 요약 + 퀴즈 3문제 생성 - Comprehend: 핵심 키워드 추출 - NewsCollectorService: 수집 시 자동 분석 연동 - GSI1/GSI2 키 자동 설정 (레벨별, 카테고리별 조회) * feat(news): 뉴스 학습 API 구현 (#388) - NewsQueryService: 뉴스 조회 서비스 - NewsHandler: API 핸들러 - GET /news - 목록 조회 (level, category 필터) - GET /news/today - 오늘의 뉴스 - GET /news/recommended - 내 레벨 맞춤 추천 - GET /news/{articleId} - 상세 조회 (조회수 증가) - template.yaml: NewsFunction Lambda 추가 * feat(news): 뉴스 학습 부가 기능 구현 (#389) - 읽기 완료 기록 API (POST /news/{articleId}/read) - 북마크 토글 API (POST /news/{articleId}/bookmark) - 북마크 목록 조회 API (GET /news/bookmarks) - 학습 통계 조회 API (GET /news/stats) - TTS 오디오 URL 조회 API (GET /news/{articleId}/audio) - UserNewsRecord 모델 추가 - UserNewsRepository 추가 - NewsLearningService 추가 * feat(news): 복합 퀴즈 시스템 구현 (#471) - 퀴즈 조회 API (GET /news/{articleId}/quiz) - 퀴즈 제출 API (POST /news/{articleId}/quiz) - 퀴즈 기록 조회 API (GET /news/quiz/history) - NewsQuizResult 모델 추가 - QuizAnswerResult 모델 추가 - NewsQuizRepository 추가 - NewsQuizService 추가 * feat(news): 단어 수집 & Vocabulary 연동 구현 (#472) - 단어 수집 API (POST /news/{articleId}/words) - 수집 단어 목록 API (GET /news/words) - 단어 상세 조회 API (GET /news/{articleId}/words/{word}) - 단어 삭제 API (DELETE /news/{articleId}/words/{word}) - Vocabulary 연동 API (POST /news/words/{word}/sync) - NewsWordCollect 모델 추가 - NewsWordRepository 추가 - NewsWordService 추가 * feat: add multi-environment deployment support (dev/test/prod) * fix: update buildspec.yml to deploy prod environment with parameter overrides * feat(news): 뉴스 도메인 기반 구조 구축 (#385) - NewsCategory, QuizType enum 추가 - NewsKey 상수 클래스 추가 - NewsErrorCode 예외 클래스 추가 - NewsArticle, KeywordInfo, QuizQuestion 모델 추가 - NewsArticleRepository CRUD 구현 - NewsTable DynamoDB 테이블 정의 - CORS GatewayResponses 설정 추가 * feat(news): 뉴스 수집 파이프라인 구현 (#386) - NewsApiClient: NewsAPI 연동 서비스 - RssFeedParser: RSS 피드 파싱 (BBC, VOA, NPR) - NewsDuplicateChecker: URL 기반 중복 필터링 - NewsCollectorService: 수집 오케스트레이션 - NewsCollectionHandler: Lambda 핸들러 - EventBridge 스케줄러: 매일 18시 KST 실행 * refactor(news): NewsAPI 제거, RSS만 사용 - NewsApiClient 삭제 - SSM Parameter 의존성 제거 - RSS 소스당 7개씩 수집 (BBC, VOA, NPR = 약 21개/일) * feat(news): AI 뉴스 분석 시스템 구현 (#387) - NewsAnalysisService: AI 분석 통합 서비스 - Bedrock: CEFR 난이도 분석 (A1~C2) - Bedrock: 3줄 요약 + 퀴즈 3문제 생성 - Comprehend: 핵심 키워드 추출 - NewsCollectorService: 수집 시 자동 분석 연동 - GSI1/GSI2 키 자동 설정 (레벨별, 카테고리별 조회) * feat(news): 뉴스 학습 API 구현 (#388) - NewsQueryService: 뉴스 조회 서비스 - NewsHandler: API 핸들러 - GET /news - 목록 조회 (level, category 필터) - GET /news/today - 오늘의 뉴스 - GET /news/recommended - 내 레벨 맞춤 추천 - GET /news/{articleId} - 상세 조회 (조회수 증가) - template.yaml: NewsFunction Lambda 추가 * feat(news): 뉴스 학습 부가 기능 구현 (#389) - 읽기 완료 기록 API (POST /news/{articleId}/read) - 북마크 토글 API (POST /news/{articleId}/bookmark) - 북마크 목록 조회 API (GET /news/bookmarks) - 학습 통계 조회 API (GET /news/stats) - TTS 오디오 URL 조회 API (GET /news/{articleId}/audio) - UserNewsRecord 모델 추가 - UserNewsRepository 추가 - NewsLearningService 추가 * feat(news): 복합 퀴즈 시스템 구현 (#471) - 퀴즈 조회 API (GET /news/{articleId}/quiz) - 퀴즈 제출 API (POST /news/{articleId}/quiz) - 퀴즈 기록 조회 API (GET /news/quiz/history) - NewsQuizResult 모델 추가 - QuizAnswerResult 모델 추가 - NewsQuizRepository 추가 - NewsQuizService 추가 * feat(news): 단어 수집 & Vocabulary 연동 구현 (#472) - 단어 수집 API (POST /news/{articleId}/words) - 수집 단어 목록 API (GET /news/words) - 단어 상세 조회 API (GET /news/{articleId}/words/{word}) - 단어 삭제 API (DELETE /news/{articleId}/words/{word}) - Vocabulary 연동 API (POST /news/words/{word}/sync) - NewsWordCollect 모델 추가 - NewsWordRepository 추가 - NewsWordService 추가 * feat: add multi-environment deployment support (dev/test/prod) * fix: update buildspec.yml to deploy prod environment with parameter overrides * refactor : AI 말하기 Websocket 구현 -> REST API 구현으로 리팩토링 (#490) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix : 오타 수정 * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * fix: revert buildspec.yml to build-only for CloudFormation deploy stage * fix: revert buildspec.yml to build-only for CloudFormation deploy stage * fix: update all API Gateway StageName to use Environment parameter * fix: correct VocabularyTable and ContentBucket references in NewsFunction * fix: correct VocabularyTable and ContentBucket references in NewsFunction * fix: add stack name prefix to GameScheduleGroup and daily-stats schedule to avoid conflicts * fix: add stack name prefix to GameScheduleGroup and daily-stats schedule to avoid conflicts * feat: add support for existing Cognito User Pool reuse across environments * fix: add conditional Cognito ARN reference in API Gateway Authorizer * fix: remove Cognito resources completely, use existing Cognito only * fix: remove Cognito resources completely, use existing Cognito only * feat : speaking rest API 람다 함수 추가 * feat: add S3 bucket resource and fix environment-specific endpoints - Add ContentBucket S3 resource for content storage - Replace hardcoded /dev with ${Environment} in all WebSocket endpoints - Update Output URLs to use dynamic environment stage * fix: add stack name prefix to news-collection schedule name Prevent EventBridge rule name conflicts across environments * fix: add X-Requested-With and Accept headers to CORS config Enable additional headers for CloudFront CORS compatibility * fix: use environment variable for S3 bucket URLs Replace hardcoded bucket name with BUCKET_NAME env var for multi-env support: - PreSignUpHandler: dynamic default profile URL - PostConfirmationHandler: dynamic default profile URL - UserService: dynamic default profile URL - BadgeType: dynamic badge image base URL * fix: disable authorizer for CORS preflight requests Add AddDefaultAuthorizerToCorsPreflight: false to prevent Cognito Authorizer from blocking OPTIONS requests * feat : speaking REST API 람다 함수 추가 (#491) * feature : OPIc 세션관리 + 답변 처리 파이프라인 구현 (#413) * feat : OPIc 질문 & 세션 관련 dto 생성 * refactor : @DynamoDbBean 주석 추가 * feat : 주제 + 소주제 + 레벨로 오픽 질문 조회 추가 * feat : OPIc 세션, 질문, 답변 종합 handler 구현 * feat : 오픽 주제, 소주제별 seed 데이터 추가 * refactor : Transcribe API KEY 환경변수명 수정 * refactor : Bedrock에 사용하는 클로드 모델 변경 * refactor : S3 Key 대신 Proxy에서 요청하는 Base64 값으로 변환 * feat: GAME_START, ROUND_END 메시지에 serverTime 추가 - broadcastGameStart(): serverTime, roundDuration 필드 추가 - broadcastRoundEnd(): serverTime, roundStartTime, roundDuration 필드 추가 - GameService.endRound(): data에 roundStartTime, roundDuration 포함 - 타이머 동기화 버그 수정을 위한 서버 시간 제공 * feat: WebSocketMessageHelper 유틸리티 클래스 추가 - domain 필드 포함 메시지 생성 헬퍼 - DOMAIN_CHAT, DOMAIN_GAME 상수 정의 - buildChatMessage(), buildGameMessage() 메서드 * feat: 모든 WebSocket 메시지에 domain 필드 추가 - ScoreUpdateMessage에 domain 필드 추가 - broadcastGameStart에 domain:"game" 추가 - broadcastRoundEnd에 domain:"game" 추가 - broadcastCorrectAnswerMessage에 domain:"game" 추가 - handleCommandResult 시스템 메시지에 domain:"game" 추가 - handleRegularMessage 채팅 메시지에 domain:"chat" 추가 Closes #426, #427 * feat: GameSession 모델 클래스 생성 - DynamoDB Enhanced Client 어노테이션 적용 - GSI1: roomId로 활성 게임 세션 조회 가능 - 게임 상태 관리용 헬퍼 메서드 포함 Closes #428 * feat: GameSessionRepository 구현 - 기본 CRUD (save, findById, delete) - roomId로 활성/전체 게임 세션 조회 - 상태, 라운드, 점수 업데이트 메서드 - 정답자 추가, 힌트 사용, 게임 종료 처리 Closes #429 * refactor: ChatRoom에서 게임 필드 분리 - 게임 관련 필드 제거 (gameStatus, currentRound 등) - activeGameSessionId 필드 추가 - 게임 상태는 GameSession으로 분리됨 Note: 의존 코드 수정은 #431에서 진행 Closes #430 * refactor: GameSession 기반으로 전체 게임 로직 리팩토링 - GameService: ChatRoom 대신 GameSession 사용 - GameStatsService: GameSession 매개변수로 변경 - CommandService: GameSession 기반 점수 조회 - GameHandler: GameSession 기반 REST API - WebSocketMessageHandler: GameSession 기반 브로드캐스트 - GameStatusResponse, ScoreboardResponse: GameSession 매개변수 Closes #431 * feat: GameSessionHandler Lambda 및 게임 세션 API 구현 - POST /rooms/{roomId}/games - 게임 세션 생성 - GET /games/{gameSessionId} - 게임 상태 조회 (재접속용) - POST /games/{gameSessionId}/start - 게임 시작 - POST /games/{gameSessionId}/stop - 게임 종료 모든 응답에 serverTime 포함 (타이머 동기화) 출제자에게만 currentWord 포함 Closes #432, #433 * feat: 게임 시작 7분 후 자동 종료 기능 구현 - GameConfig에 gameTimeLimit() 메서드 추가 (기본값: 420초) - GameSchedulerClient 유틸리티 클래스 생성 (EventBridge Scheduler 연동) - GameService에 스케줄 생성/취소 로직 추가 - GameAutoCloseHandler Lambda 함수 생성 - template.yaml에 Lambda, IAM Role, Schedule Group 추가 Closes #417 * fix : 메모리 증가 및 Lambda 응답 제한 시간 Cognito 트리거 제한시간과 동일하게 수정 (#439) * feat: 캐치마인드 게임 방 분리 기능 구현 (#455) - Room 타입 분리 (CHAT/GAME) - RoomType, RoomStatus enum 추가 - ChatRoom 모델에 type, gameType, gameSettings, status, hostId 필드 추가 - GameSettings 모델 추가 - 방 생성/조회 API 수정 - CreateRoomRequest에 type, gameType, gameSettings 필드 추가 - 방 목록 조회 시 type, gameType, status 필터 지원 - 게임 시작 조건 검증 - GAME 타입 방에서만 게임 시작 가능 (GAME_007 에러) - 게임 재시작 API 구현 (#452) - POST /rooms/{roomId}/game/restart 엔드포인트 추가 - 방장만 재시작 가능 (GAME_009 에러) - 게임 진행 중 재시작 불가 (GAME_008 에러) - 방장 변경 로직 구현 (#453) - 방장 퇴장 시 다음 멤버에게 자동 이전 - 모든 멤버 퇴장 시 방 자동 삭제 - WebSocket 메시지 타입 추가 (#454) - ROOM_STATUS_CHANGE, HOST_CHANGE 메시지 타입 추가 - WebSocketMessageHelper에 빌더 메서드 추가 - 테스트 추가 - RoomType, RoomStatus enum 테스트 - GameSettings 모델 테스트 - ChattingErrorCode 테스트 업데이트 Related: #440, #441, #442, #443, #444, #445, #446 Closes: #447, #448, #449, #450, #451, #452, #453, #454 * feat: 참가자 닉네임 및 방장 변경 WebSocket 알림 구현 (#456) - RoomParticipant DTO 추가 (userId, nickname, isHost) - ChatRoomQueryService에 닉네임 조회 메서드 추가 - getParticipantsWithNicknames(): 참가자 목록 + 닉네임 - getHostNickname(): 방장 닉네임 조회 - ChatRoomHandler.getRoom() 응답에 participants, hostNickname 추가 - ChatRoomCommandService.leaveRoom()에서 방장 변경 시 WebSocket 브로드캐스트 Related: #440 * fix: ChatRoomFunction에 UserTable DynamoDB 권한 추가 - 참가자/방장 닉네임 조회를 위한 UserTable 읽기 권한 추가 - DynamoDBReadPolicy로 UserTable 접근 허용 Fixes #457 * fix: GameSettings에 @DynamoDbBean 어노테이션 추가 - DynamoDB Enhanced Client가 중첩 객체를 직렬화/역직렬화할 수 있도록 수정 - ChatRoom 저장/조회 시 GameSettings 변환 오류 해결 Fixes #457 * fix: ChatRoomFunction에 WEBSOCKET_ENDPOINT 환경변수 및 권한 추가 - leaveRoom에서 WebSocketBroadcaster 사용을 위한 환경변수 추가 - execute-api:ManageConnections 권한 추가 * fix: WebSocket Lambda 함수들에 WEBSOCKET_ENDPOINT 환경변수 추가 - WebSocketConnectFunction에 WEBSOCKET_ENDPOINT 추가 - WebSocketDisconnectFunction에 WEBSOCKET_ENDPOINT 추가 - execute-api:ManageConnections 권한 추가 * fix: Grammar WebSocket Lambda 환경 변수 및 권한 추가 - GrammarStreamingConnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - GrammarStreamingDisconnectFunction에 WEBSOCKET_ENDPOINT 환경 변수 추가 - 두 함수에 execute-api:ManageConnections 권한 추가 * feat: GSI1SK 확장성 있는 재설계 및 DB 레벨 필터링 - 기존: {level}#{createdAt} - 신규: {type}#{gameType}#{status}#{level}#{createdAt} - 전체 방: GSI1PK = "ROOMS" - 게임방만: begins_with(GSI1SK, "GAME#") - 캐치마인드만: begins_with(GSI1SK, "GAME#CATCHMIND#") - 대기중 캐치마인드: begins_with(GSI1SK, "GAME#CATCHMIND#WAITING#") - ChatRoomCommandService.java: 방 생성 시 새 GSI1SK 포맷 적용 - ChatRoomRepository.java: findByFilters() 메서드 추가, updateStatus() 메서드 추가 - ChatRoomQueryService.java: 메모리 필터링 제거, DB 레벨 필터링으로 변경 - GameService.java: 게임 시작/종료 시 방 상태 업데이트 (GSI1SK 포함) - scripts/migrate-gsi1sk.sh: 기존 데이터 마이그레이션 스크립트 - 37개 기존 방 마이그레이션 완료 * fix: increase stats query limit to 100 days - Adjust maximum allowable limit for recent stats query from 30 to 100 days to support extended data range responses. * feat: improve game round and connection management logic - Added handling for game round timeout (`ROUND_TIMEOUT`) in WebSocketMessageHandler. - Enhanced game start broadcast to include `currentWord` for the drawer only. - Updated ConnectionRepository to remove duplicate user connections in the same room. - Added `currentWordEnglish` to GameSession for better answer verification. - Normalized ChatRoom levels to match database storage format. - Updated template.yaml to include DynamoDBReadPolicy for VocabTable. * refactor: 코드 정리 및 미사용 클래스 제거 (#459) * refactor: remove unused WebSocketResponseUtil * refactor: add AutoCloseable to WebSocketBroadcaster * refactor: remove unused TestService * refactor: remove unused WordService * refactor: remove unused DailyStudyService * refactor: remove unused UserWordService * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * style: remove p tag from javadoc * fix: resolve N+1 query in StatsService * refactor(all): DI 패턴 및 전략 패턴 적용 (#461) - Repository, Service, Handler에 DI 생성자 추가 (테스트 용이성) - BadgeService에 Strategy 패턴 적용 (뱃지 조건 검증 로직 분리) * refactor: relocate and restructure seed data files - Moved `question-homes.json` and `words.json` from subdirectories to `seed` folder. - Updated file paths for better organization and clarity. * chore: seed 데이터 폴더 구조 정리 * feat: add CI/CD pipeline configuration for CodePipeline * fix: add SNS topic policy and DependsOn for notification rule * fix: correct paths in buildspec.yml for CodeBuild * fix: remove hardcoded JAVA_HOME, use runtime default * fix: add gradle wrapper for CI/CD build * fix: use single line sam package command with hardcoded bucket * fix: use existing stack name group2-englishstudy-chatting * fix: add missing WEBSOCKET_ENDPOINT env var to WebSocket connect functions * docs: update FRONTEND-API-GUIDE with new RoomType/RoomStatus structure * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix: update WebSocketDisconnectHandler to use GameSession model - Remove references to deleted ChatRoom game fields - Use GameSessionRepository to finish active game sessions - Use ChatRoomRepository.updateStatus() for room state management * perf: optimize CI/CD build time - Add pip cache for SAM CLI dependencies - Enable Gradle parallel builds and build cache - Add SAM build cache (.aws-sam) - Use sam build --parallel --cached - Skip SAM CLI install if already cached - Remove unnecessary 'clean' to leverage cache * feat: add custom CodeBuild Docker image with pre-installed tools - Dockerfile with Java 21 + SAM CLI + Gradle pre-installed - build-and-push.sh script for ECR deployment - Updated buildspec.yml for custom image (removes SAM CLI install) - Expected build time reduction: ~30-40 seconds * feature : AI 영어 회화 연습 기능 (#468) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix: remove typo in SpeakingConnectionRepository * fix : 오타 수정 * chore: trigger build test with custom Docker image * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes * chore: remove unnecessary newlines and whitespace across WebSocket handlers and related classes * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * fix: add CORS headers to API Gateway error responses (#479) API Gateway의 인증 실패 등 에러 응답에 CORS 헤더가 누락되어 CloudFront를 통한 프론트엔드 요청이 차단되는 문제 수정 - UNAUTHORIZED (401) 응답에 CORS 헤더 추가 - ACCESS_DENIED (403) 응답에 CORS 헤더 추가 - DEFAULT_4XX/5XX 응답에 CORS 헤더 추가 - EXPIRED_TOKEN 응답에 CORS 헤더 추가 * feat : speaking rest API 람다 함수 추가 --------- Co-authored-by: ddingjoo * refactor : speaking service 재사용 * refactor : AI 영어 회화 연습 코드 리팩토링 * feature : test 벡엔드 서버에 AI 말하기 연습 기능 배포 (#492) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix : 오타 수정 * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * feat : speaking rest API 람다 함수 추가 * refactor : speaking service 재사용 * refactor : AI 영어 회화 연습 코드 리팩토링 --------- Co-authored-by: DDING JOO * feat : handleChat 메서드 JsonNull 체크 푸가 * feature : handleChat 메서드 JsonNull 체크 추가 (#493) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix : 오타 수정 * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * feat : speaking rest API 람다 함수 추가 * refactor : speaking service 재사용 * refactor : AI 영어 회화 연습 코드 리팩토링 * feat : handleChat 메서드 JsonNull 체크 푸가 --------- Co-authored-by: DDING JOO * feat(news): 뉴스 학습 배지 시스템 구현 (#473) - 14개 뉴스 관련 배지 추가 (읽기, 퀴즈, 단어수집, 연속학습, 마스터) - UserStats에 뉴스 통계 필드 추가 - 6개 뉴스 배지 Strategy 클래스 생성 - 뉴스 읽기/퀴즈/단어수집 시 통계 업데이트 및 배지 체크 연동 * fix: add PATCH method to CORS AllowMethods * test: BadgeType 개수 테스트 수정 (15 -> 29) * fix: CORS PATCH 메서드 추가 * docs: 뉴스 기능 프론트엔드 연동 가이드 작성 * fix: NewsCollectionFunction에 Bedrock, Comprehend 권한 추가 * fix: add null check for collectWord request body - Add INVALID_REQUEST error code to NewsErrorCode - Check body and word field before accessing in collectWord() - Prevents NullPointerException when request body is malformed * feat: enhance stats API and bookmark response for frontend - Add DAILY stats update for news read/quiz/word collection - Add /stats/dashboard endpoint with frontend-requested format - today: wordsLearned, newsRead, quizzesTaken, wordsTotal - overall: totalWordsLearned, totalNewsRead, averageAccuracy, streaks - weeklyProgress: last 7 days with date/wordsLearned/newsRead - Add news-related fields to all stats API responses - Fix bookmark API to include full article details (title, summary, etc.) * feat: add category classification to news AI analysis - Add category field to AnalysisResult record - Update Bedrock prompt to classify articles into categories (WORLD, POLITICS, BUSINESS, TECH, SCIENCE, HEALTH, SPORTS, ENTERTAINMENT, LIFESTYLE) - Parse and set category from AI response - Set GSI2 (CATEGORY#) index when category is available * fix: add /stats/dashboard endpoint to template.yaml * fix: filter by ARTICLE# prefix in findById to avoid returning UserNewsRecord * docs: add News API troubleshooting guide * feat: add Cognito authorizer to News API and enhance keyword extraction - Set CognitoAuthorizer for all News API endpoints in template.yaml - Update Bedrock AI to extract keywords with meanings and examples - Add fallback to Comprehend for keyword extraction when Bedrock fails - Modify KeywordInfo model to include example field - Adjust AI prompt to include keyword extraction with examples - Update AnalysisResult to store keywords and parse them from AI response * Revert "Merge branch 'test' into prod" This reverts commit c1a958eac6799d5838a76e4078ae304bcdb62278, reversing changes made to a6662e0cfc46c6e12f20a89f0df285ff282160bc. * feat: enhance bookmark and reading status tracking for News API - Add bookmark and reading status to individual article responses - Include bookmark status in paginated news responses - Modify `KeywordInfo` to support Korean translations (`meaningKo`) - Update AI prompts to extract Korean meanings for keywords * refactor : session_id가 null 체크 추가 * fix : sessionId NullPointerException 에러 수정 (#496) * feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현 * feat : WebSocket 메시지 처리 handler, service 구현 * feat : WebSocket 연결 정보 Repository 구현 * fix : 오타 수정 * refactor : websocket -> rest api 전환 * feat : speaking handler REST로 교체 * feat : speaking 관련 dto 생성 * refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링 * feat : speaking rest API 람다 함수 추가 * refactor : speaking service 재사용 * refactor : AI 영어 회화 연습 코드 리팩토링 * feat : handleChat 메서드 JsonNull 체크 푸가 * refactor : session_id가 null 체크 추가 * feat : template 환경변수 리펙토링 --------- Co-authored-by: DDING JOO * feat: enhance bookmark and reading status tracking for News API - Add bookmark and reading status to individual article responses - Include bookmark status in paginated news responses - Modify `KeywordInfo` to support Korean translations (`meaningKo`) - Update AI prompts to extract Korean meanings for keywords * feat: add category filtering to UserWord API with enhanced query logic - Introduce `category` filtering to `getUserWords` API - Update WordCategory enums to include "news" category - Apply category filter after enrichment with word info - Adjust query limits for bookmarked and incorrect words queries * feat: add default value for ExistingCognitoClientId in template.yaml - Set default to an empty string to ensure compatibility with templates using this parameter without explicitly specifying a value. * fix: SpeakingHandler getStringOrNull 컴파일 에러 수정 * fix: SpeakingHandler getStringOrNull 컴파일 에러 수정 * fix: SpeakingHandler getStringOrNull 컴파일 에러 수정 * fix: Bedrock 키워드(meaningKo 포함)를 article에 저장하도록 수정 * fix: Bedrock 키워드(meaningKo 포함)를 article에 저장하도록 수정 * fix: Bedrock 키워드(meaningKo 포함)를 article에 저장하도록 수정 * fix: Bedrock 키워드(meaningKo 포함)를 article에 저장하도록 수정 * feat: add dashboard stats API and enhance news stats tracking - Introduce `/stats/dashboard` API for retrieving integrated user stats (today, overall, weekly progress, and level distribution) - Update `UserStats` model to include additional fields for news stats (newsRead, newsQuizCompleted, newsQuizPerfect, newsWordsCollected, etc.) - Add atomic updates to track news reading, quiz, and word stats in `UserStatsRepository` - Modify CloudFormation `template.yaml` to register the new `/stats/dashboard` endpoint * feat: add dashboard stats API and enhance news stats tracking - Introduce `/stats/dashboard` API for retrieving integrated user stats (today, overall, weekly progress, and level distribution) - Update `UserStats` model to include additional fields for news stats (newsRead, newsQuizCompleted, newsQuizPerfect, newsWordsCollected, etc.) - Add atomic updates to track news reading, quiz, and word stats in `UserStatsRepository` - Modify CloudFormation `template.yaml` to register the new `/stats/dashboard` endpoint * feat: add dashboard stats API and enhance news stats tracking - Introduce `/stats/dashboard` API for retrieving integrated user stats (today, overall, weekly progress, and level distribution) - Update `UserStats` model to include additional fields for news stats (newsRead, newsQuizCompleted, newsQuizPerfect, newsWordsCollected, etc.) - Add atomic updates to track news reading, quiz, and word stats in `UserStatsRepository` - Modify CloudFormation `template.yaml` to register the new `/stats/dashboard` endpoint * refactor: format code with consistent indentation and spacing - Apply consistent formatting to improve code readability across multiple files - Adjust indentation, spacing, and alignment in model classes, DTOs, and repository methods * fix: filter by ARTICLE# prefix in findById to avoid returning bookmark records * feature : Speaking Table & Function template.yaml 파일에 추가 (#513) * fix: BadgeRepository 클라이언트 초기화 패턴 통일 - 개별 DynamoDbEnhancedClient 생성 대신 AwsClients.dynamoDbEnhanced() 싱글톤 사용 - 다른 Repository들과 동일한 패턴 적용 - 불필요한 import 제거 Closes #396 * feat: EnvConfig 유틸리티 추가 및 환경 변수 검증 적용 환경 변수 미설정 시 명확한 에러 메시지를 제공하는 EnvConfig 유틸리티를 추가하고, 기존 System.getenv 호출을 EnvConfig.getRequired/getOrDefault로 대체함. - EnvConfig: getRequired, getOrDefault, getIntOrDefault, getLongOrDefault 메서드 제공 - Lambda Cold Start 시점에 환경 변수 누락을 조기 감지 - 기존 Config 클래스(WebSocketConfig, RoomTokenConfig) EnvConfig 사용으로 통일 Closes #403 * refactor: TestService submitTest 메서드 책임 분리 submitTest 메서드를 단일 책임 원칙에 맞게 리팩토링: - gradeAnswers(): 답안 채점 및 결과 집계 - isAnswerCorrect(): 단일 답안 정답 여부 판단 - buildResultItem(): 결과 항목 생성 - saveTestResult(): 테스트 결과 저장 - GradingResult record: 채점 결과 캡슐화 TestService와 TestCommandService 모두 동일하게 적용 Closes #404 * refactor: 하드코딩된 설정값 환경 변수로 외부화 각 도메인별 Config 클래스를 생성하여 하드코딩된 값들을 환경 변수로 설정 가능하게 변경. 기본값이 있어 환경 변수 미설정 시에도 기존 동작 유지. ## 새로 추가된 Config 클래스 - GrammarConfig: SESSION_TTL_DAYS, MAX_HISTORY_MESSAGES, MAX_TOKENS 등 - GameConfig: TOTAL_ROUNDS, ROUND_TIME_LIMIT, QUICK_GUESS_THRESHOLD_MS - VocabularyConfig: NEW_WORDS_COUNT, REVIEW_WORDS_COUNT, 상태 전이 임계값 등 ## 지원하는 환경 변수 - GRAMMAR_SESSION_TTL_DAYS, GRAMMAR_MAX_HISTORY_MESSAGES, GRAMMAR_MAX_TOKENS - GAME_TOTAL_ROUNDS, GAME_ROUND_TIME_LIMIT, GAME_QUICK_GUESS_THRESHOLD_MS - VOCAB_NEW_WORDS_COUNT, VOCAB_REVIEW_WORDS_COUNT - VOCAB_TRANSITION_TO_REVIEWING, VOCAB_TRANSITION_TO_MASTERED Closes #406 * test: StudyLevel enum 단위 테스트 추가 * test: Difficulty enum 단위 테스트 추가 * test: StudyConfig 단위 테스트 추가 * test: EnvConfig 단위 테스트 추가 * test: PaginatedResult 단위 테스트 추가 * test: JsonUtil 단위 테스트 추가 * test: CursorUtil 단위 테스트 추가 * test: CommonErrorCode 단위 테스트 추가 * test: CommonException 단위 테스트 추가 * test: BadgeType enum 단위 테스트 추가 * test: BadgeKey 상수 단위 테스트 추가 * test: WordStatus enum 단위 테스트 추가 * test: TestType enum 단위 테스트 추가 * test: VocabularyConfig 단위 테스트 추가 * test: VocabKey 상수 단위 테스트 추가 * test: VocabularyErrorCode 단위 테스트 추가 * test: VocabularyException 단위 테스트 추가 * test: SpacedRepetitionContext 단위 테스트 추가 * test: GrammarLevel enum 단위 테스트 추가 * test: GrammarConfig 단위 테스트 추가 * test: GrammarErrorCode 단위 테스트 추가 * test: GrammarException 단위 테스트 추가 * test: GameStatus enum 단위 테스트 추가 * test: GameConfig 단위 테스트 추가 * test: ChattingErrorCode 단위 테스트 추가 * test: ChattingException 단위 테스트 추가 * fix: OPIc FeedbackResponse.java 문법 오류 수정 * style: AwsClients 코드 포맷팅 * style: EnvConfig 코드 포맷팅 * style: RoomTokenConfig 코드 포맷팅 * style: WebSocketConfig 코드 포맷팅 * style: JsonUtil 코드 포맷팅 * style: GameConfig 코드 포맷팅 * style: GrammarConfig 코드 포맷팅 * style: GrammarKey 코드 포맷팅 * style: GrammarErrorCode 코드 포맷팅 * style: GrammarException 코드 포맷팅 * style: BedrockGrammarCheckFactory 코드 포맷팅 * style: GrammarConversationService 코드 포맷팅 * style: FeedbackResponse 코드 포맷팅 * style: SessionReportResponse 코드 포맷팅 * style: SpeakingError 코드 포맷팅 * style: SpeakingErrorType 코드 포맷팅 * style: OPIcException 코드 포맷팅 * style: OPIcAnswer 코드 포맷팅 * style: OPIcQuestion 코드 포맷팅 * style: OPIcSession 코드 포맷팅 * style: OPIcRepository 코드 포맷팅 * style: FeedbackService 코드 포맷팅 * style: TranscribeProxyService 코드 포맷팅 * style: VocabularyConfig 코드 포맷팅 * style: DailyStudyCommandService 코드 포맷팅 * style: TestCommandService 코드 포맷팅 * style: TestService 코드 포맷팅 * style: CommonErrorCodeSpec 코드 포맷팅 * style: JsonUtilSpec 코드 포맷팅 * style: BadgeTypeSpec 코드 포맷팅 * style: ChattingErrorCodeSpec 코드 포맷팅 * style: GrammarLevelSpec 코드 포맷팅 * style: GrammarErrorCodeSpec 코드 포맷팅 * style: VocabularyErrorCodeSpec 코드 포맷팅 * feature : OPIc 세션관리 + 답변 처리 파이프라인 구현 (#413) * feat : OPIc 질문 & 세션 관련 dto 생성 * refactor : @DynamoDbBean 주석 추가 * feat : 주제 + 소주제 + 레벨로 오픽 질문 조회 추가 * feat : OPIc 세션, 질문, 답변 종합 handler 구현 * feat : 오픽 주제, 소주제별 seed 데이터 추가 * refactor : Transcribe API KEY 환경변수명 수정 * refactor : Bedrock에 사용하는 클로드 모델 변경 * refactor : S3 Key 대신 Proxy에서 요청하는 Base64 값으로 변환 * feat: GAME_START, ROUND_END 메시지에 serverTime 추가 - broadcastGameStart(): serverTime, roundDuration 필드 추가 - broadcastRoundEnd(): serverTime, roundStartTime, roundDuration 필드 추가 - GameService.endRound(): data에 roundStartTime, roundDuration 포함 - 타이머 동기화 버그 수정을 위한 서버 시간 제공 * feat: WebSocketMessageHelper 유틸리티 클래스 추가 - domain 필드 포함 메시지 생성 헬퍼 - DOMAIN_CHAT, DOMAIN_GAME 상수 정의 - buildChatMessage(), buildGameMessage() 메서드 * feat: 모든 WebSocket 메시지에 domain 필드 추가 - ScoreUpdateMessage에 domain 필드 추가 - broadcastGameStart에 domain:"game" 추가 - broadcastRoundEnd에 domain:"game" 추가 - broadcastCorrectAnswerMessage에 domain:"game" 추가 - handleCommandResult 시스템 메시지에 domain:"game" 추가 - handleRegularMessage 채팅 메시지에 domain:"chat" 추가 Closes #426, #427 * feat: GameSession 모델 클래스 생성 - DynamoDB Enhanced Client 어노테이션 적용 - GSI1: roomId로 활성 게임 세션 조회 가능 - 게임 상태 관리용 헬퍼 메서드 포함 Closes #428 * feat: GameSessionRepository 구현 - 기본 CRUD (save, findById, delete) - roomId로 활성/전체 게임 세션 조회 - 상태, 라운드, 점수 업데이트 메서드 - 정답자 추가, 힌트 사용, 게임 종료 처리 Closes #429 * refactor: ChatRoom에서 게임 필드 분리 - 게임 관련 필드 제거 (gameStatus, currentRound 등) - activeGameSessionId 필드 추가 - 게임 상태는 GameSession으로 분리됨 Note: 의존 코드 수정은 #431에서 진행 Closes #430 * refactor: GameSession 기반으로 전체 게임 로직 리팩토링 - GameService: ChatRoom 대신 GameSession 사용 - GameStatsService: GameSession 매개변수로 변경 - CommandService: GameSession 기반 점수 조회 - GameHandler: GameSession 기반 REST API - WebSocketMessageHandler: GameSession 기반 브로드캐스트 - GameStatusResponse, ScoreboardResponse: GameSession 매개변수 Closes #431 * feat: GameSessionHandler Lambda 및 게임 세션 API 구현 - POST /rooms/{roomId}/games - 게임 세션 생성 - GET /games/{gameSessionId} - 게임 상태 조회 (재접속용) - POST /games/{gameSessionId}/start - 게임 시작 - POST /games/{gameSessionId}/stop - 게임 종료 모든 응답에 serverTime 포함 (타이머 동기화) 출제자에게만 currentWord 포함 Closes #432, #433 * feat: 게임 시작 7분 후 자동 종료 기능 구현 - GameConfig에 gameTimeLimit() 메서드 추가 (기본값: 420초) - GameSchedulerClient 유틸리티 클래스 생성 (EventBridge Scheduler 연동) - GameService에 스케줄 생성/취소 로직 추가 - GameAutoCloseHandler Lambda 함수 생성 - template.yaml에 Lambda, IAM Role, Schedule Group 추가 Closes #417 * fix : 메모리 증가 및 Lambda 응답 제한 시간 Cognito 트리거 제한시간과 동일하게 수정 (#439) * feat: 캐치마인드 게임 방 분리 기능 구현 (#455) - Room 타입 분리 (CHAT/GAME) - RoomType, RoomStatus enum 추가 - ChatRoom 모델에 type, gameType, gameSettings, status, hostId 필드 추가 - GameSettings 모델 추가 - 방 생성/조회 API 수정 - CreateRoomRequest에 type, gameType, gameSettings 필드 추가 - 방 목록 조회 시 type, gameType, status 필터 지원 - 게임 시작 조건 검증 - GAME 타입 방에서만 게임 시작 가능 (GAME_007 에러) - 게임 재시작 API 구현 (#452) - POST /rooms/{roomId}/game/restart 엔드포인트 추가 - 방장만 재시작 가능 (GAME_009 에러) - 게임 진행 중 재시작 불가 (GAME_008 에러) - 방… --------- Co-authored-by: ddingjoo --- .../websocket/WebSocketMessageHandler.java | 20 + .../domain/chatting/model/ChatMessage.java | 1 + .../domain/news/service/NewsQueryService.java | 14 +- .../domain/user/service/UserService.java | 10 + ServerlessFunction/template.yaml | 5 + ...frontend-notification-integration-guide.md | 747 ++++++++++++++++++ docs/frontend-wordchain-guide.md | 365 +++++++++ 7 files changed, 1160 insertions(+), 2 deletions(-) create mode 100644 docs/frontend-notification-integration-guide.md create mode 100644 docs/frontend-wordchain-guide.md diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java index 5515cc1b..0435d5cb 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java @@ -19,6 +19,8 @@ import com.mzc.secondproject.serverless.domain.chatting.service.ChatMessageService; import com.mzc.secondproject.serverless.domain.chatting.service.CommandService; import com.mzc.secondproject.serverless.domain.chatting.service.GameService; +import com.mzc.secondproject.serverless.domain.user.model.User; +import com.mzc.secondproject.serverless.domain.user.service.UserService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -44,6 +46,7 @@ public class WebSocketMessageHandler implements RequestHandler handleRegularMessage(String connectionId, MessagePay // 일반 메시지 저장 및 브로드캐스트 String messageId = UUID.randomUUID().toString(); String now = Instant.now().toString(); + + // 닉네임 조회 + String nickname = "Unknown"; + try { + // DB에서 유저 정보(닉네임) 가져오기 + User user = userService.getUserProfile(payload.userId); + if (user != null && user.getNickname() != null) { + nickname = user.getNickname(); + } else { + // 혹시 없으면 UUID 사용 + nickname = payload.userId; + } + } catch (Exception e) { + nickname = payload.userId; + } ChatMessage message = ChatMessage.builder() .pk("ROOM#" + payload.roomId) @@ -166,6 +185,7 @@ private Map handleRegularMessage(String connectionId, MessagePay .messageId(messageId) .roomId(payload.roomId) .userId(payload.userId) + .nickname(nickname) .content(payload.content) .messageType(messageType) .createdAt(now) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/ChatMessage.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/ChatMessage.java index 0cbde348..211abaf1 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/ChatMessage.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/ChatMessage.java @@ -23,6 +23,7 @@ public class ChatMessage { private String messageId; private String roomId; private String userId; + private String nickname; private String content; private String messageType; // TEXT, IMAGE, VOICE, AI_RESPONSE private String createdAt; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQueryService.java index c1a0f328..99f0e0ac 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQueryService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQueryService.java @@ -40,12 +40,22 @@ public Optional getArticle(String articleId) { } /** - * 오늘의 뉴스 목록 조회 + * 오늘의 뉴스 목록 조회 (오늘 기사 없으면 어제 기사 조회) */ public PaginatedResult getTodayNews(int limit, String cursor) { String today = LocalDate.now().toString(); logger.debug("오늘의 뉴스 조회: date={}, limit={}", today, limit); - return articleRepository.findByDate(today, limit, cursor); + + PaginatedResult result = articleRepository.findByDate(today, limit, cursor); + + // 오늘 기사가 없으면 어제 기사 조회 + if (result.items().isEmpty() && cursor == null) { + String yesterday = LocalDate.now().minusDays(1).toString(); + logger.debug("오늘 기사 없음, 어제 기사 조회: date={}", yesterday); + result = articleRepository.findByDate(yesterday, limit, cursor); + } + + return result; } /** diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/service/UserService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/service/UserService.java index 4f635106..e302205b 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/service/UserService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/service/UserService.java @@ -31,11 +31,16 @@ public class UserService { private static final int NICKNAME_MAX_LENGTH = 20; private final UserRepository userRepository; private final S3Presigner s3Presigner; + public UserService(UserRepository userRepository) { this.userRepository = userRepository; // AwsClients 싱글톤 사용 - Cold Start 최적화 this.s3Presigner = AwsClients.s3Presigner(); } + + public UserService() { + this(new UserRepository()); + } private static String getDefaultProfileUrl() { String bucket = BUCKET_NAME != null ? BUCKET_NAME : "group2-englishstudy"; @@ -66,6 +71,11 @@ public User getProfile(String userId, APIGatewayProxyRequestEvent request) { return user; } + + // 단순 프로필 조회 메서드 (채팅용) - DB 조회만 수행 + public User getUserProfile(String userId) { + return userRepository.findByCognitoSub(userId).orElse(null); + } public String getPresignedProfileUrl(String s3Url) { if (s3Url == null || s3Url.isEmpty()) { diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 0c3dea39..225dc206 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -1871,6 +1871,11 @@ Resources: Policies: - DynamoDBCrudPolicy: TableName: !Ref NewsTable + - Statement: + - Effect: Allow + Action: + - bedrock:InvokeModel + Resource: "*" Events: DailySchedule: Type: Schedule diff --git a/docs/frontend-notification-integration-guide.md b/docs/frontend-notification-integration-guide.md new file mode 100644 index 00000000..5c647bcf --- /dev/null +++ b/docs/frontend-notification-integration-guide.md @@ -0,0 +1,747 @@ +# 프론트엔드 실시간 알림 연동 가이드 + +## 개요 + +이 문서는 백엔드 알림 시스템과 프론트엔드를 연동하기 위한 가이드입니다. +**Server-Sent Events (SSE)** 를 사용하여 실시간 알림을 수신합니다. + +--- + +## 연결 방식 + +### SSE (Server-Sent Events) 사용 + +- WebSocket과 달리 **단방향 통신** (서버 → 클라이언트) +- HTTP 기반으로 별도 프로토콜 핸들링 불필요 +- 브라우저 `EventSource` API로 간단히 구현 가능 +- 연결 끊김 시 자동 재연결 지원 + +--- + +## 연결 엔드포인트 + +``` +GET {NOTIFICATION_FUNCTION_URL}?userId={userId} +``` + +| 파라미터 | 설명 | 예시 | +|---------|------|------| +| `userId` | 로그인한 사용자 ID | `user-123` | + +> ⚠️ **NOTIFICATION_FUNCTION_URL**은 배포 환경별로 다릅니다. 환경변수로 관리하세요. + +--- + +## 기본 연결 구현 + +### JavaScript (Vanilla) + +```javascript +const connectNotifications = (userId) => { + const url = `${NOTIFICATION_FUNCTION_URL}?userId=${userId}`; + const eventSource = new EventSource(url); + + // 알림 수신 + eventSource.onmessage = (event) => { + const notification = JSON.parse(event.data); + handleNotification(notification); + }; + + // 연결 성공 + eventSource.onopen = () => { + console.log('알림 연결 성공'); + }; + + // 에러 처리 + eventSource.onerror = (error) => { + console.error('알림 연결 에러:', error); + // EventSource는 자동으로 재연결을 시도합니다 + }; + + return eventSource; +}; + +// 연결 해제 +const disconnect = (eventSource) => { + eventSource.close(); +}; +``` + +### React Hook 예시 + +```typescript +import { useEffect, useCallback, useRef } from 'react'; + +interface Notification { + notificationId: string; + type: NotificationType; + userId: string; + payload: Record; + createdAt: string; +} + +type NotificationType = + | 'BADGE_EARNED' + | 'DAILY_COMPLETE' + | 'STREAK_REMINDER' + | 'TEST_COMPLETE' + | 'NEWS_QUIZ_COMPLETE' + | 'GAME_END' + | 'GAME_STREAK' + | 'OPIC_COMPLETE'; + +export const useNotifications = ( + userId: string | null, + onNotification: (notification: Notification) => void +) => { + const eventSourceRef = useRef(null); + + const connect = useCallback(() => { + if (!userId) return; + + const url = `${process.env.NEXT_PUBLIC_NOTIFICATION_URL}?userId=${userId}`; + const eventSource = new EventSource(url); + + eventSource.onmessage = (event) => { + // Heartbeat 무시 + if (event.data === 'HEARTBEAT') return; + + try { + const notification: Notification = JSON.parse(event.data); + onNotification(notification); + } catch (e) { + console.error('알림 파싱 실패:', e); + } + }; + + eventSource.onerror = () => { + console.log('알림 연결 끊김, 재연결 시도 중...'); + }; + + eventSourceRef.current = eventSource; + }, [userId, onNotification]); + + const disconnect = useCallback(() => { + if (eventSourceRef.current) { + eventSourceRef.current.close(); + eventSourceRef.current = null; + } + }, []); + + useEffect(() => { + connect(); + return () => disconnect(); + }, [connect, disconnect]); + + return { disconnect, reconnect: connect }; +}; +``` + +### React 컴포넌트 사용 예시 + +```tsx +const NotificationProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const { user } = useAuth(); + const [notifications, setNotifications] = useState([]); + + const handleNotification = useCallback((notification: Notification) => { + setNotifications(prev => [notification, ...prev]); + + // 타입별 처리 + switch (notification.type) { + case 'BADGE_EARNED': + showBadgeToast(notification.payload); + break; + case 'DAILY_COMPLETE': + showStreakCelebration(notification.payload); + break; + case 'GAME_END': + showGameResult(notification.payload); + break; + // ... 기타 타입 + } + }, []); + + useNotifications(user?.id ?? null, handleNotification); + + return ( + + {children} + + ); +}; +``` + +--- + +## 알림 타입 및 Payload 구조 + +### 공통 응답 구조 + +```typescript +interface Notification { + notificationId: string; // "notif-xxxxxxxx" 형식 + type: NotificationType; // 알림 타입 + userId: string; // 대상 사용자 ID + payload: object; // 타입별 상세 데이터 + createdAt: string; // ISO-8601 형식 (예: "2024-01-15T09:30:00Z") +} +``` + +--- + +### 1. BADGE_EARNED (배지 획득) + +사용자가 새로운 배지를 획득했을 때 + +```typescript +interface BadgeEarnedPayload { + badgeType: string; // 배지 타입 코드 + badgeName: string; // 배지 이름 + description: string; // 배지 설명 + iconUrl: string; // 배지 아이콘 URL +} +``` + +**예시:** +```json +{ + "notificationId": "notif-a1b2c3d4", + "type": "BADGE_EARNED", + "userId": "user-123", + "payload": { + "badgeType": "STREAK_7", + "badgeName": "7일 연속 학습", + "description": "7일 연속으로 학습을 완료했습니다!", + "iconUrl": "https://cdn.example.com/badges/streak-7.png" + }, + "createdAt": "2024-01-15T09:30:00Z" +} +``` + +--- + +### 2. DAILY_COMPLETE (일일 학습 완료) + +오늘의 단어 학습을 모두 완료했을 때 + +```typescript +interface DailyCompletePayload { + date: string; // 학습 완료 날짜 (YYYY-MM-DD) + wordsLearned: number; // 오늘 학습한 단어 수 + totalWords: number; // 총 학습 단어 수 + currentStreak: number; // 현재 연속 학습 일수 +} +``` + +**예시:** +```json +{ + "notificationId": "notif-e5f6g7h8", + "type": "DAILY_COMPLETE", + "userId": "user-123", + "payload": { + "date": "2024-01-15", + "wordsLearned": 20, + "totalWords": 150, + "currentStreak": 5 + }, + "createdAt": "2024-01-15T14:00:00Z" +} +``` + +--- + +### 3. STREAK_REMINDER (연속 학습 리마인더) + +매일 21:00 KST에 오늘 학습을 아직 하지 않은 사용자에게 발송 + +```typescript +interface StreakReminderPayload { + currentStreak: number; // 현재 연속 학습 일수 + message: string; // 리마인더 메시지 +} +``` + +**예시:** +```json +{ + "notificationId": "notif-i9j0k1l2", + "type": "STREAK_REMINDER", + "userId": "user-123", + "payload": { + "currentStreak": 5, + "message": "오늘 학습을 완료하고 6일 연속 학습을 달성하세요!" + }, + "createdAt": "2024-01-15T12:00:00Z" +} +``` + +--- + +### 4. TEST_COMPLETE (단어 테스트 완료) + +단어 테스트를 완료했을 때 + +```typescript +interface TestCompletePayload { + testId: string; // 테스트 ID + score: number; // 점수 (0-100) + correctCount: number; // 맞힌 문제 수 + totalCount: number; // 전체 문제 수 + isPerfect: boolean; // 만점 여부 +} +``` + +**예시:** +```json +{ + "notificationId": "notif-m3n4o5p6", + "type": "TEST_COMPLETE", + "userId": "user-123", + "payload": { + "testId": "test-abc123", + "score": 85, + "correctCount": 17, + "totalCount": 20, + "isPerfect": false + }, + "createdAt": "2024-01-15T10:30:00Z" +} +``` + +--- + +### 5. NEWS_QUIZ_COMPLETE (뉴스 퀴즈 완료) + +뉴스 기사 퀴즈를 완료했을 때 + +```typescript +interface NewsQuizCompletePayload { + articleId: string; // 뉴스 기사 ID + articleTitle: string; // 기사 제목 + score: number; // 점수 (0-100) + correctCount: number; // 맞힌 문제 수 + totalCount: number; // 전체 문제 수 + isPerfect: boolean; // 만점 여부 +} +``` + +**예시:** +```json +{ + "notificationId": "notif-q7r8s9t0", + "type": "NEWS_QUIZ_COMPLETE", + "userId": "user-123", + "payload": { + "articleId": "article-xyz789", + "articleTitle": "Tech Giants Report Strong Q4 Earnings", + "score": 100, + "correctCount": 5, + "totalCount": 5, + "isPerfect": true + }, + "createdAt": "2024-01-15T11:00:00Z" +} +``` + +--- + +### 6. GAME_END (게임 종료) + +캐치마인드 게임이 종료되었을 때 + +```typescript +interface GameEndPayload { + roomId: string; // 게임 방 ID + gameSessionId: string; // 게임 세션 ID + rank: number; // 최종 순위 + totalPlayers: number; // 전체 플레이어 수 + score: number; // 획득 점수 + isWinner: boolean; // 1등 여부 +} +``` + +**예시:** +```json +{ + "notificationId": "notif-u1v2w3x4", + "type": "GAME_END", + "userId": "user-123", + "payload": { + "roomId": "room-game-001", + "gameSessionId": "session-abc", + "rank": 1, + "totalPlayers": 4, + "score": 2500, + "isWinner": true + }, + "createdAt": "2024-01-15T15:30:00Z" +} +``` + +--- + +### 7. GAME_STREAK (게임 연속 정답) + +게임 중 연속 정답을 달성했을 때 + +```typescript +interface GameStreakPayload { + roomId: string; // 게임 방 ID + streakCount: number; // 연속 정답 횟수 + bonusPoints: number; // 보너스 점수 +} +``` + +**예시:** +```json +{ + "notificationId": "notif-y5z6a7b8", + "type": "GAME_STREAK", + "userId": "user-123", + "payload": { + "roomId": "room-game-001", + "streakCount": 5, + "bonusPoints": 500 + }, + "createdAt": "2024-01-15T15:25:00Z" +} +``` + +--- + +### 8. OPIC_COMPLETE (OPIc 연습 완료) + +OPIc 스피킹 연습 세션을 완료했을 때 + +```typescript +interface OpicCompletePayload { + sessionId: string; // 세션 ID + estimatedLevel: string; // 예상 등급 (IM1, IM2, IH, AL 등) + questionsAnswered: number; // 답변한 문제 수 + feedbackSummary: string; // 피드백 요약 +} +``` + +**예시:** +```json +{ + "notificationId": "notif-c9d0e1f2", + "type": "OPIC_COMPLETE", + "userId": "user-123", + "payload": { + "sessionId": "opic-session-456", + "estimatedLevel": "IM2", + "questionsAnswered": 15, + "feedbackSummary": "발음과 유창성이 좋습니다. 문법적 정확성을 더 연습하세요." + }, + "createdAt": "2024-01-15T16:00:00Z" +} +``` + +--- + +## 특수 이벤트 + +### HEARTBEAT (하트비트) + +서버에서 연결 유지를 위해 1초마다 전송합니다. 무시하면 됩니다. + +```javascript +eventSource.onmessage = (event) => { + if (event.data === 'HEARTBEAT') return; // 무시 + // ... +}; +``` + +### STREAM_END (스트림 종료) + +서버가 연결을 종료할 때 전송됩니다. (최대 14분 후) +`EventSource`는 자동으로 재연결을 시도합니다. + +--- + +## 연결 관리 권장사항 + +### 1. 연결 시점 + +```typescript +// 로그인 후 연결 +const handleLoginSuccess = (user: User) => { + connectNotifications(user.id); +}; + +// 페이지 로드 시 (이미 로그인된 경우) +useEffect(() => { + if (isAuthenticated && user) { + connectNotifications(user.id); + } +}, [isAuthenticated, user]); +``` + +### 2. 연결 해제 시점 + +```typescript +// 로그아웃 시 +const handleLogout = () => { + disconnectNotifications(); + // ... +}; + +// 페이지 언마운트 시 (SPA) +useEffect(() => { + return () => disconnectNotifications(); +}, []); +``` + +### 3. 재연결 처리 + +`EventSource`는 연결 끊김 시 자동 재연결을 시도합니다. +추가적인 재연결 로직이 필요한 경우: + +```typescript +const MAX_RETRY_COUNT = 5; +let retryCount = 0; + +eventSource.onerror = () => { + retryCount++; + + if (retryCount >= MAX_RETRY_COUNT) { + eventSource.close(); + showErrorMessage('알림 서버 연결에 실패했습니다. 새로고침해주세요.'); + } +}; + +eventSource.onopen = () => { + retryCount = 0; // 연결 성공 시 초기화 +}; +``` + +--- + +## UI 처리 권장사항 + +### 토스트 알림 + +```typescript +const showNotificationToast = (notification: Notification) => { + const config = getToastConfig(notification.type); + + toast({ + title: config.title, + description: formatPayload(notification.payload), + icon: config.icon, + duration: config.duration, + }); +}; + +const getToastConfig = (type: NotificationType) => { + switch (type) { + case 'BADGE_EARNED': + return { title: '🏆 배지 획득!', icon: 'trophy', duration: 5000 }; + case 'DAILY_COMPLETE': + return { title: '✅ 오늘의 학습 완료!', icon: 'check', duration: 4000 }; + case 'STREAK_REMINDER': + return { title: '⏰ 학습 리마인더', icon: 'clock', duration: 6000 }; + case 'TEST_COMPLETE': + return { title: '📝 테스트 완료', icon: 'file', duration: 3000 }; + case 'GAME_END': + return { title: '🎮 게임 종료', icon: 'gamepad', duration: 4000 }; + default: + return { title: '알림', icon: 'bell', duration: 3000 }; + } +}; +``` + +### 알림 센터 + +```typescript +const NotificationCenter: React.FC = () => { + const { notifications } = useNotificationContext(); + const [unreadCount, setUnreadCount] = useState(0); + + return ( + + + + {unreadCount > 0 && } + + + {notifications.map(notif => ( + + ))} + + + ); +}; +``` + +--- + +## TypeScript 타입 정의 (복사용) + +```typescript +// types/notification.ts + +export type NotificationType = + | 'BADGE_EARNED' + | 'DAILY_COMPLETE' + | 'STREAK_REMINDER' + | 'TEST_COMPLETE' + | 'NEWS_QUIZ_COMPLETE' + | 'GAME_END' + | 'GAME_STREAK' + | 'OPIC_COMPLETE'; + +export interface BaseNotification { + notificationId: string; + type: T; + userId: string; + payload: P; + createdAt: string; +} + +export interface BadgeEarnedPayload { + badgeType: string; + badgeName: string; + description: string; + iconUrl: string; +} + +export interface DailyCompletePayload { + date: string; + wordsLearned: number; + totalWords: number; + currentStreak: number; +} + +export interface StreakReminderPayload { + currentStreak: number; + message: string; +} + +export interface TestCompletePayload { + testId: string; + score: number; + correctCount: number; + totalCount: number; + isPerfect: boolean; +} + +export interface NewsQuizCompletePayload { + articleId: string; + articleTitle: string; + score: number; + correctCount: number; + totalCount: number; + isPerfect: boolean; +} + +export interface GameEndPayload { + roomId: string; + gameSessionId: string; + rank: number; + totalPlayers: number; + score: number; + isWinner: boolean; +} + +export interface GameStreakPayload { + roomId: string; + streakCount: number; + bonusPoints: number; +} + +export interface OpicCompletePayload { + sessionId: string; + estimatedLevel: string; + questionsAnswered: number; + feedbackSummary: string; +} + +export type Notification = + | BaseNotification<'BADGE_EARNED', BadgeEarnedPayload> + | BaseNotification<'DAILY_COMPLETE', DailyCompletePayload> + | BaseNotification<'STREAK_REMINDER', StreakReminderPayload> + | BaseNotification<'TEST_COMPLETE', TestCompletePayload> + | BaseNotification<'NEWS_QUIZ_COMPLETE', NewsQuizCompletePayload> + | BaseNotification<'GAME_END', GameEndPayload> + | BaseNotification<'GAME_STREAK', GameStreakPayload> + | BaseNotification<'OPIC_COMPLETE', OpicCompletePayload>; +``` + +--- + +## 환경 설정 + +### 환경 변수 + +| 환경 | URL | +|------|-----| +| **Test** | `https://flhf42jd6xgrh26wrqgwxmbmee0zmjnv.lambda-url.ap-northeast-2.on.aws/` | +| **Prod** | (배포 후 업데이트 예정) | + +```env +# .env.local (Next.js) +NEXT_PUBLIC_NOTIFICATION_URL=https://flhf42jd6xgrh26wrqgwxmbmee0zmjnv.lambda-url.ap-northeast-2.on.aws + +# .env (Vite) +VITE_NOTIFICATION_URL=https://flhf42jd6xgrh26wrqgwxmbmee0zmjnv.lambda-url.ap-northeast-2.on.aws +``` + +--- + +## 테스트 방법 + +### 개발 환경에서 테스트 + +1. 브라우저 개발자 도구 → Network 탭 열기 +2. EventStream 필터 선택 +3. 로그인 후 알림 연결 확인 +4. 학습 완료, 테스트 제출 등의 액션 수행 +5. 실시간으로 알림 수신 확인 + +### Mock SSE 서버 (로컬 테스트용) + +```javascript +// mock-sse-server.js +const http = require('http'); + +http.createServer((req, res) => { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'Access-Control-Allow-Origin': '*', + }); + + // 테스트 알림 전송 + setInterval(() => { + const notification = { + notificationId: `notif-${Date.now()}`, + type: 'BADGE_EARNED', + userId: 'test-user', + payload: { + badgeType: 'TEST_BADGE', + badgeName: '테스트 배지', + description: '테스트용 배지입니다', + iconUrl: 'https://example.com/badge.png', + }, + createdAt: new Date().toISOString(), + }; + res.write(`data: ${JSON.stringify(notification)}\n\n`); + }, 5000); +}).listen(3001); + +console.log('Mock SSE server running on http://localhost:3001'); +``` + +--- + +## 문의 + +백엔드 알림 시스템 관련 문의: **[백엔드 담당자 이름/연락처]** \ No newline at end of file diff --git a/docs/frontend-wordchain-guide.md b/docs/frontend-wordchain-guide.md new file mode 100644 index 00000000..5296aa09 --- /dev/null +++ b/docs/frontend-wordchain-guide.md @@ -0,0 +1,365 @@ +# 영어 끝말잇기(쿵쿵따) 프론트엔드 통합 가이드 + +## 개요 +영어 끝말잇기 게임 - 이전 단어의 마지막 글자로 시작하는 단어를 제출하는 게임 + +## REST API 엔드포인트 + +### 1. 게임 시작 +``` +POST /chat/rooms/{roomId}/wordchain/start +Authorization: Bearer {token} +``` + +**Response (성공):** +```json +{ + "success": true, + "message": "Word Chain game started", + "data": { + "sessionId": "uuid", + "gameStatus": "PLAYING", + "currentRound": 1, + "currentPlayerId": "user-id", + "currentWord": "apple", + "nextLetter": "e", + "timeLimit": 15, + "turnStartTime": 1706000000000, + "serverTime": 1706000000000, + "activePlayers": ["user1", "user2", "user3"], + "eliminatedPlayers": [], + "scores": {}, + "usedWords": ["apple"] + } +} +``` + +### 2. 단어 제출 +``` +POST /chat/rooms/{roomId}/wordchain/submit +Authorization: Bearer {token} +Content-Type: application/json + +{ + "word": "elephant" +} +``` + +**Response (정답):** +```json +{ + "success": true, + "message": "Correct!", + "data": { + "resultType": "CORRECT", + "word": "elephant", + "definition": "(noun) A large mammal with a trunk", + "phonetic": "/ˈɛləfənt/", + "score": 23, + "nextLetter": "t", + "nextPlayerId": "user2", + "nextTimeLimit": 15 + } +} +``` + +**Response (오답 - 첫 글자 틀림):** +```json +{ + "success": true, + "message": "Wrong answer", + "data": { + "resultType": "WRONG_LETTER", + "error": "'e'로 시작하는 단어를 입력하세요." + } +} +``` + +**Response (오답 - 사전에 없음):** +```json +{ + "success": true, + "message": "Wrong answer", + "data": { + "resultType": "INVALID_WORD", + "error": "사전에 없는 단어입니다: xyz" + } +} +``` + +### 3. 타임아웃 처리 +``` +POST /chat/rooms/{roomId}/wordchain/timeout +Authorization: Bearer {token} +``` + +### 4. 게임 종료 (시작자만) +``` +POST /chat/rooms/{roomId}/wordchain/stop +Authorization: Bearer {token} +``` + +### 5. 게임 상태 조회 +``` +GET /chat/rooms/{roomId}/wordchain/status +Authorization: Bearer {token} +``` + +--- + +## WebSocket 메시지 + +### Domain +```javascript +domain: "wordchain" +``` + +### 메시지 타입 + +| messageType | 설명 | +|-------------|------| +| `wordchain_start` | 게임 시작 | +| `wordchain_correct` | 정답 | +| `wordchain_wrong` | 오답 | +| `wordchain_timeout` | 시간 초과 (탈락) | +| `wordchain_end` | 게임 종료 | + +--- + +## WebSocket 메시지 상세 + +### 1. 게임 시작 (wordchain_start) +```json +{ + "domain": "wordchain", + "messageType": "wordchain_start", + "messageId": "uuid", + "roomId": "room-id", + "userId": "SYSTEM", + "content": "🎮 끝말잇기 시작!\n시작 단어: apple\n다음 글자: 'e'\n\n첫 번째 차례: user1\n제한 시간: 15초", + "createdAt": "2026-01-24T12:00:00Z", + "timestamp": 1706000000000, + "sessionId": "session-uuid", + "starterWord": "apple", + "nextLetter": "e", + "currentPlayerId": "user1", + "timeLimit": 15, + "turnStartTime": 1706000000000, + "serverTime": 1706000000000, + "players": ["user1", "user2", "user3"], + "activePlayers": ["user1", "user2", "user3"] +} +``` + +### 2. 정답 (wordchain_correct) +```json +{ + "domain": "wordchain", + "messageType": "wordchain_correct", + "messageId": "uuid", + "roomId": "room-id", + "userId": "SYSTEM", + "content": "✅ 닉네임: \"elephant\" (+23점)\n뜻: (noun) A large mammal\n다음 글자: 't'", + "createdAt": "2026-01-24T12:00:05Z", + "timestamp": 1706000005000, + "serverTime": 1706000005000, + "resultType": "CORRECT", + "word": "elephant", + "definition": "(noun) A large mammal with a trunk", + "phonetic": "/ˈɛləfənt/", + "score": 23, + "nextLetter": "t", + "nextPlayerId": "user2", + "nextTimeLimit": 15, + "playerNickname": "닉네임", + "turnStartTime": 1706000005000, + "scores": { + "user1": 23 + } +} +``` + +### 3. 오답 (wordchain_wrong) +```json +{ + "domain": "wordchain", + "messageType": "wordchain_wrong", + "messageId": "uuid", + "roomId": "room-id", + "userId": "SYSTEM", + "content": "❌ 사전에 없는 단어입니다: xyz", + "resultType": "INVALID_WORD", + "error": "사전에 없는 단어입니다: xyz" +} +``` + +### 4. 시간 초과 (wordchain_timeout) +```json +{ + "domain": "wordchain", + "messageType": "wordchain_timeout", + "messageId": "uuid", + "roomId": "room-id", + "userId": "SYSTEM", + "content": "⏰ 닉네임 시간 초과! 탈락!", + "resultType": "TIMEOUT", + "eliminatedPlayerId": "user1", + "eliminatedNickname": "닉네임", + "nextPlayerId": "user2", + "nextTimeLimit": 13, + "nextLetter": "e", + "turnStartTime": 1706000015000, + "activePlayers": ["user2", "user3"] +} +``` + +### 5. 게임 종료 (wordchain_end) +```json +{ + "domain": "wordchain", + "messageType": "wordchain_end", + "messageId": "uuid", + "roomId": "room-id", + "userId": "SYSTEM", + "content": "🏆 승자: 닉네임!", + "resultType": "GAME_END", + "winnerId": "user2", + "winnerNickname": "닉네임", + "ranking": [ + { "playerId": "user2", "nickname": "닉네임2", "score": 45, "eliminated": false }, + { "playerId": "user3", "nickname": "닉네임3", "score": 30, "eliminated": true }, + { "playerId": "user1", "nickname": "닉네임1", "score": 23, "eliminated": true } + ], + "usedWords": ["apple", "elephant", "tiger", "rainbow"], + "wordDefinitions": { + "apple": "(noun) A fruit", + "elephant": "(noun) A large mammal", + "tiger": "(noun) A large cat", + "rainbow": "(noun) An arc of colors" + }, + "scores": { + "user1": 23, + "user2": 45, + "user3": 30 + } +} +``` + +--- + +## 게임 규칙 + +### 시간 제한 (라운드별 감소) +| 라운드 | 시간 제한 | +|--------|----------| +| 1-2 | 15초 | +| 3-4 | 13초 | +| 5-6 | 11초 | +| 7-8 | 9초 | +| 9+ | 8초 | + +### 점수 계산 +``` +점수 = 기본점수(10) + 시간보너스 + 길이보너스 + +시간보너스 = 남은시간(초) +길이보너스 = (단어길이 - 4) × 2 (5글자 이상부터) +``` + +**예시:** +- 15초 제한에서 5초 만에 "elephant"(8글자) 제출 +- 점수 = 10 + 10 + 8 = 28점 + +### 게임 종료 조건 +- 1명만 남으면 게임 종료 +- 시작자가 `/stop` 호출 + +--- + +## 프론트엔드 구현 가이드 + +### 1. 타이머 동기화 +```javascript +// 서버 시간과 클라이언트 시간 차이 계산 +const serverTimeDiff = message.serverTime - Date.now(); + +// 남은 시간 계산 +const elapsed = Date.now() + serverTimeDiff - message.turnStartTime; +const remaining = (message.timeLimit * 1000) - elapsed; +``` + +### 2. WebSocket 메시지 핸들러 +```javascript +socket.onmessage = (event) => { + const message = JSON.parse(event.data); + + if (message.domain !== 'wordchain') return; + + switch (message.messageType) { + case 'wordchain_start': + handleGameStart(message); + break; + case 'wordchain_correct': + handleCorrectAnswer(message); + break; + case 'wordchain_wrong': + handleWrongAnswer(message); + break; + case 'wordchain_timeout': + handleTimeout(message); + break; + case 'wordchain_end': + handleGameEnd(message); + break; + } +}; +``` + +### 3. 타임아웃 자동 전송 +```javascript +// 내 턴일 때 타이머 만료 시 자동으로 타임아웃 API 호출 +if (isMyTurn && remaining <= 0) { + fetch(`/chat/rooms/${roomId}/wordchain/timeout`, { + method: 'POST', + headers: { 'Authorization': `Bearer ${token}` } + }); +} +``` + +### 4. UI 구성 요소 +- 현재 단어 표시 +- 다음 시작 글자 강조 +- 타이머 (남은 시간) +- 현재 차례 플레이어 표시 +- 활성/탈락 플레이어 목록 +- 점수판 +- 사용된 단어 목록 +- 단어 입력 필드 (본인 차례일 때만 활성화) + +### 5. 게임 종료 후 학습 화면 +```javascript +// 게임 종료 시 사용된 단어와 뜻 표시 +message.usedWords.forEach(word => { + const definition = message.wordDefinitions[word]; + console.log(`${word}: ${definition}`); +}); +``` + +--- + +## 에러 코드 + +| 코드 | 메시지 | +|------|--------| +| GAME_001 | 게임 시작에 실패했습니다 | +| GAME_002 | 게임 중단에 실패했습니다 | +| GAME_010 | 게임 액션 처리에 실패했습니다 | +| INPUT_001 | 유효하지 않은 입력입니다 | + +--- + +## 참고 + +- Dictionary API: [Free Dictionary API](https://dictionaryapi.dev/) +- 최소 인원: 2명 +- 시작 단어: 서버에서 랜덤 선택 (apple, house, water 등) From 0a87543309bf024389f1e87d12a79641651f7e7d Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Sun, 25 Jan 2026 18:14:41 +0900 Subject: [PATCH 524/528] fix: revert buildspec to build+package only, let Deploy stage handle deployment - Remove sam deploy from buildspec-test.yml and buildspec-prod.yml - CodeBuild only does sam build + sam package - Deploy stage handles CloudFormation deployment with Cognito parameters --- ServerlessFunction/buildspec-prod.yml | 26 ++++++++------------------ ServerlessFunction/buildspec-test.yml | 23 ++++++++--------------- 2 files changed, 16 insertions(+), 33 deletions(-) diff --git a/ServerlessFunction/buildspec-prod.yml b/ServerlessFunction/buildspec-prod.yml index 6b8ed3e3..25d01611 100644 --- a/ServerlessFunction/buildspec-prod.yml +++ b/ServerlessFunction/buildspec-prod.yml @@ -5,7 +5,6 @@ env: SAM_CLI_TELEMETRY: 0 GRADLE_OPTS: "-Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.caching=true" ENVIRONMENT: prod - STACK_NAME: group2-englishstudy-prod phases: install: @@ -28,26 +27,17 @@ phases: - echo "Building SAM application for $ENVIRONMENT..." - cd $CODEBUILD_SRC_DIR/ServerlessFunction - sam build --parallel --cached - - echo "Build completed" + - echo "Packaging SAM application..." + - sam package --s3-bucket group2-englishstudy-pipeline-artifacts --s3-prefix sam-packages/$ENVIRONMENT --output-template-file packaged-template.yaml post_build: commands: - - echo "Deploying to $ENVIRONMENT environment..." - - cd $CODEBUILD_SRC_DIR/ServerlessFunction - - | - sam deploy \ - --stack-name $STACK_NAME \ - --s3-bucket group2-englishstudy-pipeline-artifacts \ - --s3-prefix sam-deploy/prod \ - --region ap-northeast-2 \ - --capabilities CAPABILITY_IAM CAPABILITY_AUTO_EXPAND \ - --no-confirm-changeset \ - --no-fail-on-empty-changeset \ - --parameter-overrides \ - Environment=$ENVIRONMENT \ - ExistingCognitoUserPoolId=ap-northeast-2_ezDwzFCzR \ - ExistingCognitoClientId=4ns077jcr1pkue2vvisr6qdpu5 - - echo "Deployment completed on $(date)" + - echo "Build completed on $(date)" + +artifacts: + files: + - packaged-template.yaml + base-directory: ServerlessFunction cache: paths: diff --git a/ServerlessFunction/buildspec-test.yml b/ServerlessFunction/buildspec-test.yml index e00db1d6..6d75e4f0 100644 --- a/ServerlessFunction/buildspec-test.yml +++ b/ServerlessFunction/buildspec-test.yml @@ -5,7 +5,6 @@ env: SAM_CLI_TELEMETRY: 0 GRADLE_OPTS: "-Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.caching=true" ENVIRONMENT: test - STACK_NAME: group2-englishstudy-test phases: install: @@ -28,23 +27,17 @@ phases: - echo "Building SAM application for $ENVIRONMENT..." - cd $CODEBUILD_SRC_DIR/ServerlessFunction - sam build --parallel --cached - - echo "Build completed" + - echo "Packaging SAM application..." + - sam package --s3-bucket group2-englishstudy-pipeline-artifacts --s3-prefix sam-packages/$ENVIRONMENT --output-template-file packaged-template.yaml post_build: commands: - - echo "Deploying to $ENVIRONMENT environment..." - - cd $CODEBUILD_SRC_DIR/ServerlessFunction - - | - sam deploy \ - --stack-name $STACK_NAME \ - --resolve-s3 \ - --s3-prefix $STACK_NAME \ - --region ap-northeast-2 \ - --capabilities CAPABILITY_IAM CAPABILITY_AUTO_EXPAND \ - --no-confirm-changeset \ - --no-fail-on-empty-changeset \ - --parameter-overrides Environment=$ENVIRONMENT ExistingCognitoUserPoolId=ap-northeast-2_ezDwzFCzR ExistingCognitoClientId=4ns077jcr1pkue2vvisr6qdpu5 - - echo "Deployment completed on $(date)" + - echo "Build completed on $(date)" + +artifacts: + files: + - packaged-template.yaml + base-directory: ServerlessFunction cache: paths: From cfc417500f0e90f3f6eb1eaa0780aedc873920aa Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Sun, 25 Jan 2026 18:48:54 +0900 Subject: [PATCH 525/528] fix: force Authorizer recreation to update Cognito User Pool --- ServerlessFunction/template.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 225dc206..ab3187ed 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -147,6 +147,7 @@ Resources: Authorizers: CognitoAuthorizer: UserPoolArn: !Sub "arn:aws:cognito-idp:${AWS::Region}:${AWS::AccountId}:userpool/${ExistingCognitoUserPoolId}" + AuthorizationScopes: [] Identity: Header: Authorization From 81ab3cbb4cb1325d31bd2532266ab1ca15e4d5a8 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Sun, 25 Jan 2026 19:13:00 +0900 Subject: [PATCH 526/528] fix: rename Authorizer to CognitoAuthV2 to force recreation with correct User Pool --- ServerlessFunction/template.yaml | 133 +++++++++++++++---------------- 1 file changed, 66 insertions(+), 67 deletions(-) diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index ab3187ed..ed741aeb 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -142,12 +142,11 @@ Resources: ResponseTemplates: application/json: '{"message": "Token expired", "statusCode": 401}' Auth: - DefaultAuthorizer: CognitoAuthorizer + DefaultAuthorizer: CognitoAuthV2 AddDefaultAuthorizerToCorsPreflight: false Authorizers: - CognitoAuthorizer: + CognitoAuthV2: UserPoolArn: !Sub "arn:aws:cognito-idp:${AWS::Region}:${AWS::AccountId}:userpool/${ExistingCognitoUserPoolId}" - AuthorizationScopes: [] Identity: Header: Authorization @@ -403,7 +402,7 @@ Resources: Path: /chat/rooms Method: POST Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 GetRooms: Type: Api Properties: @@ -411,7 +410,7 @@ Resources: Path: /chat/rooms Method: GET Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 GetRoom: Type: Api Properties: @@ -419,7 +418,7 @@ Resources: Path: /chat/rooms/{roomId} Method: GET Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 DeleteRoom: Type: Api Properties: @@ -427,7 +426,7 @@ Resources: Path: /chat/rooms/{roomId} Method: DELETE Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 JoinRoom: Type: Api Properties: @@ -435,7 +434,7 @@ Resources: Path: /chat/rooms/{roomId}/join Method: POST Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 LeaveRoom: Type: Api Properties: @@ -443,7 +442,7 @@ Resources: Path: /chat/rooms/{roomId}/leave Method: POST Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 GameFunction: Type: AWS::Serverless::Function @@ -490,7 +489,7 @@ Resources: Path: /chat/rooms/{roomId}/game/start Method: POST Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 StopGame: Type: Api Properties: @@ -498,7 +497,7 @@ Resources: Path: /chat/rooms/{roomId}/game/stop Method: POST Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 GetGameStatus: Type: Api Properties: @@ -506,7 +505,7 @@ Resources: Path: /chat/rooms/{roomId}/game/status Method: GET Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 GetScores: Type: Api Properties: @@ -514,7 +513,7 @@ Resources: Path: /chat/rooms/{roomId}/game/scores Method: GET Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 # 끝말잇기(Word Chain) 게임 핸들러 WordChainFunction: @@ -547,7 +546,7 @@ Resources: Path: /chat/rooms/{roomId}/wordchain/start Method: POST Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 SubmitWord: Type: Api Properties: @@ -555,7 +554,7 @@ Resources: Path: /chat/rooms/{roomId}/wordchain/submit Method: POST Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 HandleTimeout: Type: Api Properties: @@ -563,7 +562,7 @@ Resources: Path: /chat/rooms/{roomId}/wordchain/timeout Method: POST Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 StopWordChain: Type: Api Properties: @@ -571,7 +570,7 @@ Resources: Path: /chat/rooms/{roomId}/wordchain/stop Method: POST Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 GetWordChainStatus: Type: Api Properties: @@ -579,7 +578,7 @@ Resources: Path: /chat/rooms/{roomId}/wordchain/status Method: GET Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 # 게임 자동 종료 Lambda (EventBridge Scheduler에 의해 호출) GameAutoCloseFunction: @@ -667,7 +666,7 @@ Resources: Path: /chat/rooms/{roomId}/messages Method: POST Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 GetMessages: Type: Api Properties: @@ -675,7 +674,7 @@ Resources: Path: /chat/rooms/{roomId}/messages Method: GET Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 GetMessage: Type: Api Properties: @@ -683,7 +682,7 @@ Resources: Path: /chat/rooms/{roomId}/messages/{messageId} Method: GET Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 ChatVoiceFunction: Type: AWS::Serverless::Function @@ -713,7 +712,7 @@ Resources: Path: /chat/voice/synthesize Method: POST Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 ############################################# # Vocabulary Lambda Functions @@ -817,7 +816,7 @@ Resources: Path: /vocab/wrong-answers Method: GET Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 GetUserWords: Type: Api Properties: @@ -825,7 +824,7 @@ Resources: Path: /vocab/user-words Method: GET Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 GetUserWord: Type: Api Properties: @@ -833,7 +832,7 @@ Resources: Path: /vocab/user-words/{wordId} Method: GET Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 UpdateUserWord: Type: Api Properties: @@ -841,7 +840,7 @@ Resources: Path: /vocab/user-words/{wordId} Method: PUT Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 UpdateUserWordTag: Type: Api Properties: @@ -849,7 +848,7 @@ Resources: Path: /vocab/user-words/{wordId}/tag Method: PATCH Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 UpdateUserWordStatus: Type: Api Properties: @@ -857,7 +856,7 @@ Resources: Path: /vocab/user-words/{wordId}/status Method: PATCH Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 WordGroupFunction: Type: AWS::Serverless::Function @@ -879,7 +878,7 @@ Resources: Path: /vocab/groups Method: POST Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 GetGroups: Type: Api Properties: @@ -887,7 +886,7 @@ Resources: Path: /vocab/groups Method: GET Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 GetGroupDetail: Type: Api Properties: @@ -895,7 +894,7 @@ Resources: Path: /vocab/groups/{groupId} Method: GET Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 UpdateGroup: Type: Api Properties: @@ -903,7 +902,7 @@ Resources: Path: /vocab/groups/{groupId} Method: PUT Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 DeleteGroup: Type: Api Properties: @@ -911,7 +910,7 @@ Resources: Path: /vocab/groups/{groupId} Method: DELETE Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 AddWordToGroup: Type: Api Properties: @@ -919,7 +918,7 @@ Resources: Path: /vocab/groups/{groupId}/words/{wordId} Method: POST Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 RemoveWordFromGroup: Type: Api Properties: @@ -927,7 +926,7 @@ Resources: Path: /vocab/groups/{groupId}/words/{wordId} Method: DELETE Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 DailyStudyFunction: Type: AWS::Serverless::Function @@ -954,7 +953,7 @@ Resources: Path: /vocab/daily Method: GET Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 MarkWordLearned: Type: Api Properties: @@ -962,7 +961,7 @@ Resources: Path: /vocab/daily/words/{wordId}/learned Method: POST Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 TestFunction: Type: AWS::Serverless::Function @@ -992,7 +991,7 @@ Resources: Path: /vocab/test/start Method: POST Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 SubmitAnswer: Type: Api Properties: @@ -1000,7 +999,7 @@ Resources: Path: /vocab/test/submit Method: POST Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 GetTestResults: Type: Api Properties: @@ -1008,7 +1007,7 @@ Resources: Path: /vocab/test/results Method: GET Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 GetTestResultDetail: Type: Api Properties: @@ -1016,7 +1015,7 @@ Resources: Path: /vocab/test/results/{testId} Method: GET Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 GetTestedWords: Type: Api Properties: @@ -1024,7 +1023,7 @@ Resources: Path: /vocab/test/tested-words Method: GET Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 StatsFunction: Type: AWS::Serverless::Function @@ -1046,7 +1045,7 @@ Resources: Path: /vocab/stats Method: GET Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 GetDailyStats: Type: Api Properties: @@ -1054,7 +1053,7 @@ Resources: Path: /vocab/stats/daily Method: GET Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 GetWeaknessAnalysis: Type: Api Properties: @@ -1062,7 +1061,7 @@ Resources: Path: /vocab/stats/weakness Method: GET Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 VocabVoiceFunction: Type: AWS::Serverless::Function @@ -1144,7 +1143,7 @@ Resources: Path: /stats/daily Method: GET Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 GetWeeklyStats: Type: Api Properties: @@ -1152,7 +1151,7 @@ Resources: Path: /stats/weekly Method: GET Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 GetMonthlyStats: Type: Api Properties: @@ -1160,7 +1159,7 @@ Resources: Path: /stats/monthly Method: GET Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 GetTotalStats: Type: Api Properties: @@ -1168,7 +1167,7 @@ Resources: Path: /stats/total Method: GET Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 GetDashboard: Type: Api Properties: @@ -1176,7 +1175,7 @@ Resources: Path: /stats/dashboard Method: GET Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 GetStatsHistory: Type: Api Properties: @@ -1184,7 +1183,7 @@ Resources: Path: /stats/history Method: GET Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 # Badge Lambda Function BadgeFunction: @@ -1214,7 +1213,7 @@ Resources: Path: /badges Method: GET Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 GetEarnedBadges: Type: Api Properties: @@ -1222,7 +1221,7 @@ Resources: Path: /badges/earned Method: GET Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 ############################################# # Grammar Lambda Functions @@ -1269,7 +1268,7 @@ Resources: Path: /grammar/check Method: POST Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 GrammarConversation: Type: Api Properties: @@ -1277,7 +1276,7 @@ Resources: Path: /grammar/conversation Method: POST Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 GetSessions: Type: Api Properties: @@ -1285,7 +1284,7 @@ Resources: Path: /grammar/sessions Method: GET Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 GetSessionDetail: Type: Api Properties: @@ -1293,7 +1292,7 @@ Resources: Path: /grammar/sessions/{sessionId} Method: GET Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 DeleteSession: Type: Api Properties: @@ -1301,7 +1300,7 @@ Resources: Path: /grammar/sessions/{sessionId} Method: DELETE Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 ############################################# # Grammar WebSocket API (Streaming) @@ -1565,7 +1564,7 @@ Resources: Path: /opic/sessions Method: POST Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 # 세션 조회 GetSession: Type: Api @@ -1574,7 +1573,7 @@ Resources: Path: /opic/sessions/{sessionId} Method: GET Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 # 세션 목록 조회 GetSessions: Type: Api @@ -1583,7 +1582,7 @@ Resources: Path: /opic/sessions Method: GET Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 # 다음 질문 조회 GetNextQuestion: Type: Api @@ -1592,7 +1591,7 @@ Resources: Path: /opic/sessions/{sessionId}/questions/next Method: GET Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 # 답변 제출 SubmitAnswer: Type: Api @@ -1601,7 +1600,7 @@ Resources: Path: /opic/sessions/{sessionId}/answers Method: POST Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 # 세션 완료 CompleteSession: Type: Api @@ -1610,7 +1609,7 @@ Resources: Path: /opic/sessions/{sessionId}/complete Method: POST Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 # 음성 업로드 Presigned URL GetUploadUrl: Type: Api @@ -1619,7 +1618,7 @@ Resources: Path: /opic/sessions/{sessionId}/upload-url Method: GET Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 ############################################# # Speaking Lambda Functions @@ -1661,7 +1660,7 @@ Resources: Path: /speaking/chat Method: POST Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 SpeakingReset: Type: Api Properties: @@ -1669,7 +1668,7 @@ Resources: Path: /speaking/reset Method: POST Auth: - Authorizer: CognitoAuthorizer + Authorizer: CognitoAuthV2 ############################################# # DynamoDB Tables From c03c3c9bedbb93102c6c4c89fa52dd99d06375a3 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Sun, 25 Jan 2026 19:23:39 +0900 Subject: [PATCH 527/528] fix: use GSI1 index for WordChainSession roomId queries findActiveByRoomId was querying main table with ROOM#roomId partition key, but the main table PK is WORDCHAIN#sessionId. Fixed to use GSI1 index which has ROOM#roomId as partition key. --- .../repository/WordChainSessionRepository.java | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/WordChainSessionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/WordChainSessionRepository.java index ca39ddf7..b871987f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/WordChainSessionRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/WordChainSessionRepository.java @@ -5,6 +5,7 @@ import com.mzc.secondproject.serverless.domain.chatting.model.WordChainSession; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbIndex; import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; import software.amazon.awssdk.enhanced.dynamodb.Key; import software.amazon.awssdk.enhanced.dynamodb.TableSchema; @@ -19,16 +20,20 @@ public class WordChainSessionRepository { private static final Logger logger = LoggerFactory.getLogger(WordChainSessionRepository.class); private static final String TABLE_NAME = EnvConfig.getRequired("CHAT_TABLE_NAME"); + private static final String GSI1_INDEX_NAME = "GSI1"; private final DynamoDbTable table; + private final DynamoDbIndex gsi1Index; public WordChainSessionRepository() { this.table = AwsClients.dynamoDbEnhanced() .table(TABLE_NAME, TableSchema.fromBean(WordChainSession.class)); + this.gsi1Index = table.index(GSI1_INDEX_NAME); } public WordChainSessionRepository(DynamoDbTable table) { this.table = table; + this.gsi1Index = table.index(GSI1_INDEX_NAME); } /** @@ -52,16 +57,16 @@ public Optional findById(String sessionId) { } /** - * 방의 활성 세션 조회 + * 방의 활성 세션 조회 (GSI1 인덱스 사용) */ public Optional findActiveByRoomId(String roomId) { - return table.query(QueryConditional.sortBeginsWith( + return gsi1Index.query(QueryConditional.sortBeginsWith( Key.builder() .partitionValue("ROOM#" + roomId) .sortValue("WORDCHAIN#") .build())) - .items() .stream() + .flatMap(page -> page.items().stream()) .filter(WordChainSession::isActive) .findFirst(); } From 81a2f280462c07ddd46b9d321b8cce3e94424a5b Mon Sep 17 00:00:00 2001 From: hye-inA Date: Mon, 26 Jan 2026 22:32:43 +0900 Subject: [PATCH 528/528] =?UTF-8?q?refactor=20:=20OPIc=20=EC=A7=88?= =?UTF-8?q?=EB=AC=B8=20=EC=A1=B0=ED=9A=8C=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../opic/handler/OPIcSessionHandler.java | 76 ++++++------ .../domain/opic/model/OPIcQuestion.java | 8 +- .../opic/repository/OPIcRepository.java | 30 ++--- seed/opic/question-homes.json | 108 ------------------ 4 files changed, 56 insertions(+), 166 deletions(-) delete mode 100644 seed/opic/question-homes.json diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/handler/OPIcSessionHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/handler/OPIcSessionHandler.java index 0eb3b1a2..cd7335d4 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/handler/OPIcSessionHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/handler/OPIcSessionHandler.java @@ -11,7 +11,9 @@ import com.mzc.secondproject.serverless.common.util.ResponseGenerator; import com.mzc.secondproject.serverless.domain.opic.dto.request.CreateSessionRequest; import com.mzc.secondproject.serverless.domain.opic.dto.request.SubmitAnswerRequest; +import com.mzc.secondproject.serverless.domain.opic.dto.response.CreateSessionResponse; import com.mzc.secondproject.serverless.domain.opic.dto.response.FeedbackResponse; +import com.mzc.secondproject.serverless.domain.opic.dto.response.QuestionResponse; import com.mzc.secondproject.serverless.domain.opic.model.OPIcAnswer; import com.mzc.secondproject.serverless.domain.opic.model.OPIcQuestion; import com.mzc.secondproject.serverless.domain.opic.model.OPIcSession; @@ -119,55 +121,59 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent ev */ private APIGatewayProxyResponseEvent createSession(APIGatewayProxyRequestEvent event, String userId) { CreateSessionRequest request = gson.fromJson(event.getBody(), CreateSessionRequest.class); - - logger.info("세션 생성 요청: userId={}, topic={}, level={}", - userId, request.topic(), request.targetLevel()); - - // 주제 + 소주제 + 레벨로 질문 세트 조회 - List questions = repository.findQuestionsByTopicSubTopicAndLevel( - request.topic(), - request.subTopic(), - request.targetLevel() + + logger.info("세션 생성 요청: userId={}, topic={}, subTopic={}, targetLevel={}", + userId, request.topic(), request.subTopic(), request.targetLevel()); + + // 질문 세트 조회 (주제+소주제로 조회) + List questions = repository.findQuestionsByTopicAndSubTopic( + request.topic(), // 예: "DESCRIPTION" + request.subTopic() // 예: "HOMES" ); - + + // 질문 데이터 없음 예외 처리 if (questions.isEmpty()) { - return ResponseGenerator.notFound("해당 주제/레벨의 질문이 없습니다."); + String msg = String.format("해당 주제(%s)와 소주제(%s)에 해당하는 질문이 없습니다.", + request.topic(), request.subTopic()); + return ResponseGenerator.notFound(msg); } - - // 최대 3개 질문 선택 (랜덤 셔플) + + // 질문 3개 랜덤 선택 Collections.shuffle(questions); - List questionIds = questions.stream() + List selectedQuestions = questions.stream() .limit(3) + .sorted(Comparator.comparingInt(OPIcQuestion::getOrderInSet)) + .collect(Collectors.toList()); + + // 질문 ID 목록 추출 + List questionIds = selectedQuestions.stream() .map(OPIcQuestion::getQuestionId) .collect(Collectors.toList()); - - // 세션 생성 + + // 세션 저장 OPIcSession session = repository.createSession( userId, request.topic(), request.subTopic(), - request.targetLevel(), + request.targetLevel(), // 사용자가 선택한 레벨 저장 (AL, IM2 등) questionIds ); - - // 첫 질문 Polly 음성 URL 생성 (#368 PollyService 연동) - OPIcQuestion firstQuestion = questions.get(0); + + // 첫 번째 질문 응답 생성 + OPIcQuestion firstQuestion = selectedQuestions.get(0); String audioUrl = generateQuestionAudioUrl(firstQuestion); - - // Response - Map response = new LinkedHashMap<>(); - response.put("sessionId", session.getSessionId()); - response.put("totalQuestions", session.getTotalQuestions()); - response.put("firstQuestion", Map.of( - "questionId", firstQuestion.getQuestionId(), - "questionText", firstQuestion.getQuestionText(), - "audioUrl", audioUrl, - "questionNumber", 1, - "totalQuestions", session.getTotalQuestions() - )); - - logger.info("세션 생성 완료: sessionId={}", session.getSessionId()); - return ResponseGenerator.created("세션이 생성되었습니다.", response); + + QuestionResponse questionResponse = new QuestionResponse( + firstQuestion.getQuestionId(), + firstQuestion.getQuestionText(), + audioUrl, + 1, // 현재 질문 번호 + 3 // 총 질문 수 + ); + + return ResponseGenerator.ok( + new CreateSessionResponse(session.getSessionId(), questionResponse, 3) + ); } /** diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/model/OPIcQuestion.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/model/OPIcQuestion.java index 6a2056b1..0a85b9d4 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/model/OPIcQuestion.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/model/OPIcQuestion.java @@ -10,12 +10,12 @@ public class OPIcQuestion { private String pk; // QUESTION#questionId private String sk; // METADATA - private String gsi1pk; // TOPIC#travel - private String gsi1sk; // LEVEL#IM2 + private String gsi1pk; // TOPIC#DESCRIPTION (질문 유형 - 대주제) + private String gsi1sk; // SUBTOPIC#HOMES (질문 소재 - 소주제) private String questionId; - private String topic; // 대주제 - private String subTopic; // 소주제 + private String topic; // DESCRIPTION, HABIT, PAST_EXPERIENCE ... + private String subTopic; // HOMES, BANKS, MUSIC ... private String level; // 난이도 (IM1, IM2, IM3, IH, AL) private String questionText; // 질문 텍스트 (영어) private String questionTextKo; // 질문 텍스트 (한국어, 참고용) diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/repository/OPIcRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/repository/OPIcRepository.java index 62251d18..3b53f323 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/repository/OPIcRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/opic/repository/OPIcRepository.java @@ -151,20 +151,23 @@ public Optional findQuestionById(String questionId) { return Optional.ofNullable(questionTable.getItem(key)); } - + /** - * 주제 + 레벨로 질문 조회 (GSI1) + * 질문 유형 + 질문 주제로 조회 */ - public List findQuestionsByTopicAndLevel(String topic, String level) { + public List findQuestionsByTopicAndSubTopic(String topic, String subTopic) { DynamoDbIndex gsi1 = questionTable.index("GSI1"); - - QueryConditional queryConditional = QueryConditional.keyEqualTo( + + String pkVal = "TOPIC#" + topic.toUpperCase(); + String skVal = "SUBTOPIC#" + subTopic.toUpperCase(); + + QueryConditional queryConditional = QueryConditional.sortBeginsWith( Key.builder() - .partitionValue("TOPIC#" + topic) - .sortValue("LEVEL#" + level) + .partitionValue(pkVal) + .sortValue(skVal) .build() ); - + return gsi1.query(queryConditional) .stream() .flatMap(page -> page.items().stream()) @@ -172,17 +175,6 @@ public List findQuestionsByTopicAndLevel(String topic, String leve .collect(Collectors.toList()); } - /** - * 주제 + 소주제 + 레벨로 질문 조회 (subTopic 필터 추가) - */ - public List findQuestionsByTopicSubTopicAndLevel( - String topic, String subTopic, String level) { - - return findQuestionsByTopicAndLevel(topic, level).stream() - .filter(q -> subTopic == null || subTopic.equals(q.getSubTopic())) - .collect(Collectors.toList()); - } - /** * 여러 질문 ID로 조회 */ diff --git a/seed/opic/question-homes.json b/seed/opic/question-homes.json deleted file mode 100644 index ddd67703..00000000 --- a/seed/opic/question-homes.json +++ /dev/null @@ -1,108 +0,0 @@ -{ - "questions": [ - { - "questionId": "desc-homes-001", - "topic": "DESCRIPTION", - "subTopic": "HOMES", - "questionText": "I would like to know about where you live. Talk about the different rooms at your place. Tell me about your favorite room in your home. What does it look like?", - "difficulty": "IM2", - "questionNumber": 44 - }, - { - "questionId": "desc-homes-002", - "topic": "DESCRIPTION", - "subTopic": "HOMES", - "questionText": "I would like to know where you live. Can you describe your home to me? What does it look like? How many rooms does it have? Give me a description with lots of details.", - "difficulty": "IM2", - "questionNumber": 45 - }, - { - "questionId": "desc-homes-003", - "topic": "DESCRIPTION", - "subTopic": "HOMES", - "questionText": "Now, let's talk about your bedroom. What's inside? What kind of furniture do you have in your room?", - "difficulty": "IM1", - "questionNumber": 46 - }, - { - "questionId": "habit-homes-001", - "topic": "HABIT", - "subTopic": "HOMES", - "questionText": "What kinds of home improvement projects do you enjoy doing and why?", - "difficulty": "IM2", - "questionNumber": 139 - }, - { - "questionId": "habit-homes-002", - "topic": "HABIT", - "subTopic": "HOMES", - "questionText": "Tell me about your normal routine at home. What do you do on weekdays and on weekends?", - "difficulty": "IM1", - "questionNumber": 140 - }, - { - "questionId": "habit-homes-003", - "topic": "HABIT", - "subTopic": "HOMES", - "questionText": "What is your normal routine at home? What things do you usually do on weekdays and on weekends?", - "difficulty": "IM1", - "questionNumber": 141 - }, - { - "questionId": "habit-homes-004", - "topic": "HABIT", - "subTopic": "HOMES", - "questionText": "What is your responsibility at home? What is your role? Tell me in detail.", - "difficulty": "IM2", - "questionNumber": 142 - }, - { - "questionId": "habit-homes-005", - "topic": "HABIT", - "subTopic": "HOMES", - "questionText": "What kinds of things do you do to keep your house clean and comfortable? What kinds of housework do you do at home?", - "difficulty": "IM1", - "questionNumber": 143 - }, - { - "questionId": "habit-homes-006", - "topic": "HABIT", - "subTopic": "HOMES", - "questionText": "What kind of house work do you usually do at home? Do you share your chores with your family?", - "difficulty": "IM1", - "questionNumber": 144 - }, - { - "questionId": "habit-homes-007", - "topic": "HABIT", - "subTopic": "HOMES", - "questionText": "Tell me about all the steps you take for a home improvement project. What steps do you usually take? How do you complete the project?", - "difficulty": "IH", - "questionNumber": 145 - }, - { - "questionId": "past-homes-001", - "topic": "PAST_EXPERIENCE", - "subTopic": "HOMES", - "questionText": "Tell me about a memorable experience you had at home. What happened and why was it memorable?", - "difficulty": "IM2", - "questionNumber": 0 - }, - { - "questionId": "past-homes-002", - "topic": "PAST_EXPERIENCE", - "subTopic": "HOMES", - "questionText": "Have you ever had any problems at home? Maybe something broke or there was an issue with your neighbors. Tell me about that experience and how you solved it.", - "difficulty": "IH", - "questionNumber": 0 - }, - { - "questionId": "past-homes-003", - "topic": "PAST_EXPERIENCE", - "subTopic": "HOMES", - "questionText": "Tell me about a time when you had to do a major cleaning or organizing at home. What did you do and how did it turn out?", - "difficulty": "IM2", - "questionNumber": 0 - } - ] -}